mirror of
https://github.com/hanzoai/next.js.git
synced 2026-09-24 17:00:51 +00:00
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]>
61 lines
2.2 KiB
TypeScript
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:')
|
|
})
|
|
})
|