Files
4286e5ff35 Ensure unique resolved headers() value between render passes (#96085)
A HTTP request can render the same React tree in multiple passes with
different semantics for `connection()`: the prospective and final
prerenders of a runtime prefetch, or a navigation's dynamic render and
the runtime prerender that is spawned from it to refresh the client's
prefetch cache. All of these passes previously resolved `headers()` to
the single sealed headers object created lazily by the request store, so
userland caches keyed on the identity of `await headers()` (any
per-request memoization that treats the headers object as "the request")
leaked promises across passes.

With this change, every render pass resolves `headers()` to a distinct
object over the same underlying data, making "the headers object
identifies the request within one render pass" a real contract.


---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-29 18:16:34 -04:00

61 lines
2.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('module-level caches keyed on the headers object', () => {
const { next, isNextDev } = nextTestSetup({
files: __dirname,
})
if (isNextDev) {
// Runtime prefetching only happens in production builds.
it('is skipped in dev', () => {})
return
}
it('renders dynamic content on navigation even when the spawned runtime prerender populated the cache first', async () => {
const cliOutputStart = next.cliOutput.length
let page: Playwright.Page
const browser = await next.browser('/', {
beforePageLoad(p: Playwright.Page) {
page = p
},
})
const act = createRouterAct(page)
// Reveal the link, triggering the runtime prefetch.
await act(
async () => {
const linkToggle = await browser.elementByCss(
'input[data-link-accordion="/dynamic"]'
)
await linkToggle.click()
},
{ includes: 'Header:' }
)
// Navigate. The navigation request also spawns a runtime prerender to
// refresh the client's prefetch cache, which reaches the module-level
// cache before the stage-gated dynamic render of the navigation does.
// Because each render pass resolves `headers()` to a distinct object, the
// hanging connection() promise it memoizes is keyed to the prerender pass
// only: the navigation's dynamic render misses the cache, creates its own
// promise, and connection() resolves, so the dynamic content renders.
await browser.elementByCss('a[href="/dynamic"]').click()
await retry(async () => {
expect(await browser.elementById('dynamic-content').text()).toBe(
'Dynamic content: request data'
)
})
expect(await browser.hasElementByCssSelector('#dynamic-error')).toBe(false)
// The rejection of the prerender pass's hanging promise stays within the
// pass that created it, so nothing is reported to onRequestError either.
const cliOutput = next.cliOutput.slice(cliOutputStart)
expect(cliOutput).not.toContain('[instrumentation] onRequestError:')
})
})