Files
next.js/test/e2e/app-dir/partial-prefetching-config/partial-prefetching-config.test.ts
Hendrik LiebauandGitHub 000e088059 Re-fetch dynamic content on navigation with partialPrefetching enabled (#94655)
With `partialPrefetching` enabled, navigating back to a page that was
first loaded as a full HTML document served stale dynamic content. Its
`connection()`-gated output kept showing the value from the original
document load instead of being re-run per request, and the `<Link
prefetch={true}>` navigation issued no server request at all. A page
first reached through a prefetch behaved correctly, so the problem only
surfaced after an initial HTML load.

The cause was a missing prefetch hint on the route tree that the client
caches from that initial HTML render. The app-page build template
assembles the route module's `renderOpts` directly from `nextConfig`,
and that field list carried `cacheComponents` and the `experimental`
block but left out `partialPrefetching`. So
`renderOpts.partialPrefetching` was `undefined` for every runtime render
even with the flag on; only the build-time prerender and per-segment
prefetch artifacts, produced through the export worker, had the correct
value. As a result the route tree computed at runtime and inlined into
the initial HTML lacked the `SubtreeHasPartialPrefetching` hint and
picked up `SubtreeHasEagerPrefetch` instead. When that tree was later
reused for a `prefetch={true}` navigation, the client never downgraded
the full prefetch to a partial one, so it promoted the fully resolved
hydration entry out of the bfcache into a non-partial segment and served
it stale.

The fix passes `nextConfig.partialPrefetching` through the template's
`renderOpts` (kept raw so `'unstable_eager'` survives), so runtime
renders emit the same hint as the build artifacts and the existing
client-side downgrade handles the rest. A regression test navigates back
to a page after its initial HTML load and asserts, via the router `act`
helper, that the navigation re-fetches the dynamic content instead of
serving it stale.
2026-06-11 16:31:19 +02:00

88 lines
3.2 KiB
TypeScript

import { nextTestSetup } from 'e2e-utils'
import { retry } from 'next-test-utils'
import type * as Playwright from 'playwright'
import { createRouterAct } from 'router-act'
describe('partial prefetching config', () => {
const { next, isNextDev } = nextTestSetup({
files: __dirname,
})
if (isNextDev) {
it('is skipped', () => {})
return
}
it('does not prefetch dynamic data, even when <Link prefetch={true}>', async () => {
let page: Playwright.Page
const browser = await next.browser('/', {
beforePageLoad(p: Playwright.Page) {
page = p
},
})
const act = createRouterAct(page)
// Reveal the link to trigger its prefetch. The Link has prefetch={true},
// and the target page has no per-segment config, so the behavior is
// determined by the global Next.js config. Because Partial Prefetching is
// enabled, the prefetch should include static content, but NOT
// dynamic data.
await act(async () => {
const linkToggle = await browser.elementByCss(
'input[data-link-accordion="/target-page"]'
)
await linkToggle.click()
}, [
{ includes: 'Static content' },
{ includes: 'Dynamic content', block: 'reject' },
])
})
it('re-fetches dynamic content on navigation after an initial HTML load', async () => {
let page: Playwright.Page
// Start directly at /target-page (a full HTML load, not a client-side
// navigation). The connection()-gated dynamic content renders once during
// this load; the resolved value is what gets seeded into the client cache.
const browser = await next.browser('/target-page', {
beforePageLoad(p: Playwright.Page) {
page = p
},
})
const act = createRouterAct(page)
// Wait for the dynamic content to stream in from the initial HTML load.
await retry(async () => {
expect(await browser.elementById('dynamic-content').text()).toContain(
'Dynamic content'
)
})
const firstValue = await browser.elementById('dynamic-content').text()
// Navigate to home via the always-present (prefetch=false) layout link.
await browser.elementByCss('a[href="/"]').click()
await retry(async () => {
expect(await browser.elementByCss('h1').text()).toBe('Home')
})
// Reveal the link (prefetch={true}) and navigate back to /target-page.
// With Partial Prefetching the prefetch must not include dynamic data, so
// the navigation has to issue a request that re-renders the
// connection()-gated content rather than serving it stale from the bfcache.
await act(
async () => {
await browser
.elementByCss('input[data-link-accordion="/target-page"]')
.click()
await browser.elementByCss('a[href="/target-page"]').click()
},
{ includes: 'Dynamic content' }
)
const secondValue = await browser.elementById('dynamic-content').text()
// connection() means "always run per request", so the value must change.
// If it's unchanged, the stale dynamic data from the initial HTML load was
// incorrectly served from the bfcache via the Full prefetch's upgrade.
expect(secondValue).not.toBe(firstValue)
})
})