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

67 lines
2.3 KiB
TypeScript

import { Suspense } from 'react'
import { headers } from 'next/headers'
import { connection } from 'next/server'
export const instant = {
unstable_samples: [{ headers: [['host', 'test-host']] }],
}
export const prefetch = 'partial'
export default function Page() {
return (
<main>
<p id="intro">This page gates its dynamic content on connection().</p>
<Suspense fallback={<div style={{ color: 'grey' }}>Loading 1...</div>}>
<RuntimePrefetchable />
</Suspense>
</main>
)
}
async function RuntimePrefetchable() {
const headersStore = await headers()
const headerValue = headersStore.get('host') === null ? 'missing' : 'present'
return (
<div>
<div id="header-value">{`Header: ${headerValue}`}</div>
<Suspense fallback={<div style={{ color: 'grey' }}>Loading 2...</div>}>
<Dynamic />
</Suspense>
</div>
)
}
// A module-level cache keyed on the identity of the headers object (like
// `dedupe()` from the Flags SDK, or any per-request memoization that treats
// the headers object as "the request"), gating its data on `connection()` so
// it only produces data during actual navigations, never during (runtime)
// prefetches.
//
// A request can be rendered by 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. In prerenders the
// connection() promise hangs and is rejected when the pass is aborted; during
// navigations it resolves. Each render pass resolves `await headers()` to a
// distinct object, which scopes identity-keyed memoization like this cache to
// a single pass: a promise created under one pass's semantics is never
// consumed by another pass.
const requestDataCache = new WeakMap<object, Promise<string>>()
async function getRequestData(): Promise<string> {
const headersStore = await headers()
let dataPromise = requestDataCache.get(headersStore)
if (dataPromise === undefined) {
dataPromise = (async () => {
await connection()
return 'request data'
})()
requestDataCache.set(headersStore, dataPromise)
}
return dataPromise
}
async function Dynamic() {
const data = await getRequestData()
return <div id="dynamic-content">Dynamic content: {data}</div>
}