diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index ec58249fcd9e2..7ebad8e808308 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -676,6 +676,7 @@ async function generateRuntimePrefetchResult( prerenderResumeDataCache, renderResumeDataCache, rootParams, + requestStore.headers, requestStore.cookies, requestStore.draftMode ) @@ -686,6 +687,7 @@ async function generateRuntimePrefetchResult( prerenderResumeDataCache, renderResumeDataCache, rootParams, + requestStore.headers, requestStore.cookies, requestStore.draftMode, onError @@ -707,6 +709,7 @@ async function prospectiveRuntimeServerPrerender( prerenderResumeDataCache: PrerenderResumeDataCache | null, renderResumeDataCache: RenderResumeDataCache | null, rootParams: Params, + headers: PrerenderStoreModernRuntime['headers'], cookies: PrerenderStoreModernRuntime['cookies'], draftMode: PrerenderStoreModernRuntime['draftMode'] ) { @@ -757,6 +760,7 @@ async function prospectiveRuntimeServerPrerender( // We only need task sequencing in the final prerender. runtimeStagePromise: null, // These are not present in regular prerenders, but allowed in a runtime prerender. + headers, cookies, draftMode, } @@ -842,6 +846,7 @@ async function finalRuntimeServerPrerender( prerenderResumeDataCache: PrerenderResumeDataCache | null, renderResumeDataCache: RenderResumeDataCache | null, rootParams: Params, + headers: PrerenderStoreModernRuntime['headers'], cookies: PrerenderStoreModernRuntime['cookies'], draftMode: PrerenderStoreModernRuntime['draftMode'], onError: (err: unknown) => string | undefined @@ -892,6 +897,7 @@ async function finalRuntimeServerPrerender( // Used to separate the "Static" stage from the "Runtime" stage. runtimeStagePromise, // These are not present in regular prerenders, but allowed in a runtime prerender. + headers, cookies, draftMode, } diff --git a/packages/next/src/server/app-render/work-unit-async-storage.external.ts b/packages/next/src/server/app-render/work-unit-async-storage.external.ts index a963d0d8255fc..50870fa103c64 100644 --- a/packages/next/src/server/app-render/work-unit-async-storage.external.ts +++ b/packages/next/src/server/app-render/work-unit-async-storage.external.ts @@ -120,6 +120,7 @@ export interface PrerenderStoreModernRuntime */ readonly runtimeStagePromise: Promise | null + readonly headers: RequestStore['headers'] readonly cookies: RequestStore['cookies'] readonly draftMode: RequestStore['draftMode'] } @@ -294,10 +295,7 @@ export interface PrivateUseCacheStore extends CommonUseCacheStore { */ readonly runtimeStagePromise: Promise | null - /** - * As opposed to the public cache store, the private cache store is allowed to - * access the request cookies. - */ + readonly headers: ReadonlyHeaders readonly cookies: ReadonlyRequestCookies /** diff --git a/packages/next/src/server/request/cookies.ts b/packages/next/src/server/request/cookies.ts index 191b7735fe4a6..269484074262d 100644 --- a/packages/next/src/server/request/cookies.ts +++ b/packages/next/src/server/request/cookies.ts @@ -126,10 +126,11 @@ export function cookies(): Promise { makeUntrackedCookies(workUnitStore.cookies) ) case 'private-cache': + // Private caches are delayed until the runtime stage in use-cache-wrapper, + // so we don't need an additional delay here. if (process.env.__NEXT_CACHE_COMPONENTS) { return makeUntrackedCookies(workUnitStore.cookies) } - return makeUntrackedExoticCookies(workUnitStore.cookies) case 'request': trackDynamicDataInDynamicRender(workUnitStore) diff --git a/packages/next/src/server/request/headers.ts b/packages/next/src/server/request/headers.ts index bb5867073209e..aad290f479068 100644 --- a/packages/next/src/server/request/headers.ts +++ b/packages/next/src/server/request/headers.ts @@ -12,6 +12,7 @@ import { type PrerenderStoreModern, } from '../app-render/work-unit-async-storage.external' import { + delayUntilRuntimeStage, postponeWithTracking, throwToInterruptStaticGeneration, trackDynamicDataInDynamicRender, @@ -92,20 +93,13 @@ export function headers(): Promise { workStore.invalidDynamicUsageError ??= error throw error } - case 'private-cache': { - const error = new Error( - `Route ${workStore.route} used "headers" inside "use cache: private". Accessing "headers" inside a private cache scope is not supported. If you need this data inside a cached function use "headers" outside of the cached function and pass the required dynamic data in as an argument. See more info here: https://nextjs.org/docs/messages/next-request-in-use-cache` - ) - Error.captureStackTrace(error, headers) - workStore.invalidDynamicUsageError ??= error - throw error - } case 'unstable-cache': throw new Error( `Route ${workStore.route} used "headers" inside a function cached with "unstable_cache(...)". Accessing Dynamic data sources inside a cache scope is not supported. If you need this data inside a cached function use "headers" outside of the cached function and pass the required dynamic data in as an argument. See more info here: https://nextjs.org/docs/app/api-reference/functions/unstable_cache` ) case 'prerender': case 'prerender-client': + case 'private-cache': case 'prerender-runtime': case 'prerender-ppr': case 'prerender-legacy': @@ -125,7 +119,6 @@ export function headers(): Promise { if (workUnitStore) { switch (workUnitStore.type) { case 'prerender': - case 'prerender-runtime': return makeHangingHeaders(workStore, workUnitStore) case 'prerender-client': const exportName = '`headers`' @@ -152,6 +145,18 @@ export function headers(): Promise { workStore, workUnitStore ) + case 'prerender-runtime': + return delayUntilRuntimeStage( + workUnitStore, + makeUntrackedHeaders(workUnitStore.headers) + ) + case 'private-cache': + // Private caches are delayed until the runtime stage in use-cache-wrapper, + // so we don't need an additional delay here. + if (process.env.__NEXT_CACHE_COMPONENTS) { + return makeUntrackedHeaders(workUnitStore.headers) + } + return makeUntrackedExoticHeaders(workUnitStore.headers) case 'request': trackDynamicDataInDynamicRender(workUnitStore) diff --git a/packages/next/src/server/use-cache/use-cache-wrapper.ts b/packages/next/src/server/use-cache/use-cache-wrapper.ts index b911020caa21c..85a505d0e50e6 100644 --- a/packages/next/src/server/use-cache/use-cache-wrapper.ts +++ b/packages/next/src/server/use-cache/use-cache-wrapper.ts @@ -211,6 +211,7 @@ function createUseCacheStore( outerWorkUnitStore ), rootParams: outerWorkUnitStore.rootParams, + headers: outerWorkUnitStore.headers, cookies: outerWorkUnitStore.cookies, } } else { diff --git a/test/e2e/app-dir/cache-components-errors/cache-components-errors.test.ts b/test/e2e/app-dir/cache-components-errors/cache-components-errors.test.ts index bcf2709469e9d..4875e6c6616c1 100644 --- a/test/e2e/app-dir/cache-components-errors/cache-components-errors.test.ts +++ b/test/e2e/app-dir/cache-components-errors/cache-components-errors.test.ts @@ -3406,63 +3406,6 @@ describe('Cache Components Errors', () => { }) } }) - - describe('with `headers()`', () => { - if (isNextDev) { - it('should show a redbox error', async () => { - const browser = await next.browser('/use-cache-private-headers') - - if (isTurbopack) { - await expect(browser).toDisplayRedbox(` - { - "description": "Route /use-cache-private-headers used "headers" inside "use cache: private". Accessing "headers" inside a private cache scope is not supported. If you need this data inside a cached function use "headers" outside of the cached function and pass the required dynamic data in as an argument. See more info here: https://nextjs.org/docs/messages/next-request-in-use-cache", - "environmentLabel": null, - "label": "Runtime Error", - "source": "app/use-cache-private-headers/page.tsx (25:18) @ Private - > 25 | await headers() - | ^", - "stack": [ - "Private app/use-cache-private-headers/page.tsx (25:18)", - ], - } - `) - } else { - await expect(browser).toDisplayRedbox(` - { - "description": "Route /use-cache-private-headers used "headers" inside "use cache: private". Accessing "headers" inside a private cache scope is not supported. If you need this data inside a cached function use "headers" outside of the cached function and pass the required dynamic data in as an argument. See more info here: https://nextjs.org/docs/messages/next-request-in-use-cache", - "environmentLabel": null, - "label": "Runtime Error", - "source": "app/use-cache-private-headers/page.tsx (25:18) @ Private - > 25 | await headers() - | ^", - "stack": [ - "Private app/use-cache-private-headers/page.tsx (25:18)", - ], - } - `) - } - }) - } else { - // TODO: With prefetch sentinels this should yield a build error. - it('should not fail the build and show no runtime error (caught in userland)', async () => { - await prerender('/use-cache-private-headers') - await next.start({ skipBuild: true }) - cliOutputLength = next.cliOutput.length - - const browser = await next.browser('/use-cache-private-headers', { - pushErrorAsConsoleLog: true, - }) - - expect(await browser.elementById('private').text()).toBe('Private') - - expect(await browser.log()).not.toContainEqual( - expect.objectContaining({ source: 'error' }) - ) - - expect(next.cliOutput.slice(cliOutputLength)).not.toInclude('Error') - }) - } - }) }) describe('Sync IO - Current Time - Date()', () => { diff --git a/test/e2e/app-dir/cache-components-errors/fixtures/default/app/use-cache-private-headers/layout.tsx b/test/e2e/app-dir/cache-components-errors/fixtures/default/app/use-cache-private-headers/layout.tsx deleted file mode 100644 index 745e32b8a8d23..0000000000000 --- a/test/e2e/app-dir/cache-components-errors/fixtures/default/app/use-cache-private-headers/layout.tsx +++ /dev/null @@ -1,9 +0,0 @@ -export default function Root({ children }: { children: React.ReactNode }) { - return ( - - -
{children}
- - - ) -} diff --git a/test/e2e/app-dir/cache-components-errors/fixtures/default/app/use-cache-private-headers/page.tsx b/test/e2e/app-dir/cache-components-errors/fixtures/default/app/use-cache-private-headers/page.tsx deleted file mode 100644 index ea3db2a9b0f2b..0000000000000 --- a/test/e2e/app-dir/cache-components-errors/fixtures/default/app/use-cache-private-headers/page.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { headers } from 'next/headers' -import { Suspense } from 'react' - -export default function Page() { - return ( - <> -

- This page uses `headers()` inside `'use cache: private'`, which triggers - an error at runtime. -

- Loading...

}> - -
- - ) -} - -async function Private() { - 'use cache: private' - - // Reading headers in a cache context is not allowed. We're try/catching here - // to ensure that, in dev mode, this error is shown even when it's caught in - // userland. - try { - await headers() - } catch {} - - return

Private

-} diff --git a/test/e2e/app-dir/segment-cache/prefetch-runtime/app/(default)/errors/page.tsx b/test/e2e/app-dir/segment-cache/prefetch-runtime/app/(default)/errors/page.tsx index 54b66be9a1a57..e28378a63b623 100644 --- a/test/e2e/app-dir/segment-cache/prefetch-runtime/app/(default)/errors/page.tsx +++ b/test/e2e/app-dir/segment-cache/prefetch-runtime/app/(default)/errors/page.tsx @@ -23,6 +23,12 @@ export default async function Page() { prefetch={true} /> +
  • + +
  • + +

    + This page performs sync IO after a headers() call, so we should only see + the error in a runtime prefetch +

    + Loading 1...}> + + + + ) +} + +async function RuntimePrefetchable() { + await headers() + return
    Timestamp: {Date.now()}
    +} diff --git a/test/e2e/app-dir/segment-cache/prefetch-runtime/app/(default)/in-page/headers/page.tsx b/test/e2e/app-dir/segment-cache/prefetch-runtime/app/(default)/in-page/headers/page.tsx new file mode 100644 index 0000000000000..22b40e7af4bfc --- /dev/null +++ b/test/e2e/app-dir/segment-cache/prefetch-runtime/app/(default)/in-page/headers/page.tsx @@ -0,0 +1,45 @@ +import { headers } from 'next/headers' +import { Suspense } from 'react' +import { cachedDelay, DebugRenderKind, uncachedIO } from '../../../shared' +import { connection } from 'next/server' + +export const unstable_prefetch = 'unstable_runtime' + +export default async function Page() { + return ( +
    + +

    + This page uses headers and some uncached IO, so parts of it should be + runtime-prefetchable. +

    + Loading 1...}> + + +
    + ) +} + +async function RuntimePrefetchable() { + const headersStore = await headers() + const headerValue = headersStore.get('host') === null ? 'missing' : 'present' + await cachedDelay([__filename, headerValue]) + return ( +
    +
    {`Header: ${headerValue}`}
    + Loading 2...
    }> + + + + ) +} + +async function Dynamic() { + await uncachedIO() + await connection() + return ( +
    +
    Dynamic content
    +
    + ) +} diff --git a/test/e2e/app-dir/segment-cache/prefetch-runtime/app/(default)/in-private-cache/headers/page.tsx b/test/e2e/app-dir/segment-cache/prefetch-runtime/app/(default)/in-private-cache/headers/page.tsx new file mode 100644 index 0000000000000..947bfe37f57e7 --- /dev/null +++ b/test/e2e/app-dir/segment-cache/prefetch-runtime/app/(default)/in-private-cache/headers/page.tsx @@ -0,0 +1,51 @@ +import { headers } from 'next/headers' +import { Suspense } from 'react' +import { cachedDelay, DebugRenderKind, uncachedIO } from '../../../shared' +import { connection } from 'next/server' + +export const unstable_prefetch = 'unstable_runtime' + +export default async function Page() { + return ( +
    + +

    + This page uses headers (from inside a private cache) and some uncached + IO, so parts of it should be runtime-prefetchable. +

    + Loading 1...}> + + +
    + ) +} + +async function RuntimePrefetchable() { + const headerValue = await privateCache() + return ( +
    +
    {`Header: ${headerValue}`}
    + Loading 2...
    }> + + + + ) +} + +async function privateCache() { + 'use cache: private' + const headersStore = await headers() + const headerValue = headersStore.get('host') === null ? 'missing' : 'present' + await cachedDelay([__filename, headerValue]) + return headerValue +} + +async function Dynamic() { + await uncachedIO() + await connection() + return ( +
    +
    Dynamic content
    +
    + ) +} diff --git a/test/e2e/app-dir/segment-cache/prefetch-runtime/app/(default)/page.tsx b/test/e2e/app-dir/segment-cache/prefetch-runtime/app/(default)/page.tsx index 42e0687fddcd5..c8042f4d3c088 100644 --- a/test/e2e/app-dir/segment-cache/prefetch-runtime/app/(default)/page.tsx +++ b/test/e2e/app-dir/segment-cache/prefetch-runtime/app/(default)/page.tsx @@ -16,6 +16,15 @@ export default async function Page() {
  • +
  • + headers + dynamic content +
      +
    • + +
    • +
    +
  • +
  • search params + dynamic content
      @@ -60,6 +69,14 @@ export default async function Page() {
  • +
  • + headers in private cache + dynamic content +
      +
    • + +
    • +
    +
  • dynamic params in private cache + dynamic content
      @@ -112,6 +129,14 @@ export default async function Page() {
  • +
  • + headers() promise passed to public cache + dynamic content +
      +
    • + +
    • +
    +
  • dynamic params promise passed to public cache + dynamic content
      diff --git a/test/e2e/app-dir/segment-cache/prefetch-runtime/app/(default)/passed-to-public-cache/headers/page.tsx b/test/e2e/app-dir/segment-cache/prefetch-runtime/app/(default)/passed-to-public-cache/headers/page.tsx new file mode 100644 index 0000000000000..d278f9f78f338 --- /dev/null +++ b/test/e2e/app-dir/segment-cache/prefetch-runtime/app/(default)/passed-to-public-cache/headers/page.tsx @@ -0,0 +1,57 @@ +import { headers } from 'next/headers' +import { Suspense } from 'react' +import { cachedDelay, DebugRenderKind } from '../../../shared' +import { connection } from 'next/server' + +export const unstable_prefetch = 'unstable_runtime' + +export default async function Page() { + return ( +
      + +

      + This page passes headers to a public cache, and uses some uncached IO, + so parts of it should be prefetchable with a runtime prefetch. +

      + Loading 1...}> + + +
      + ) +} + +async function RuntimePrefetchable() { + await headers() // Guard from being statically prerendered, which would make the cache hang + + // We've already awaited headers, but we still want to make sure + // that the cache doesn't consider them a hanging promise + const headerValue = await publicCache( + headers().then((headersStore) => + headersStore.get('host') === null ? 'missing' : 'present' + ) + ) + return ( +
      +
      {`Header: ${headerValue}`}
      + Loading 2...
      }> + + + + ) +} + +async function publicCache(headerPromise: Promise) { + 'use cache' + const headerValue = await headerPromise + await cachedDelay([__filename, headerValue]) + return headerValue +} + +async function Dynamic() { + await connection() + return ( +
      +
      Dynamic content
      +
      + ) +} diff --git a/test/e2e/app-dir/segment-cache/prefetch-runtime/prefetch-runtime.test.ts b/test/e2e/app-dir/segment-cache/prefetch-runtime/prefetch-runtime.test.ts index 2444ad1b1752a..873a8457daf96 100644 --- a/test/e2e/app-dir/segment-cache/prefetch-runtime/prefetch-runtime.test.ts +++ b/test/e2e/app-dir/segment-cache/prefetch-runtime/prefetch-runtime.test.ts @@ -376,6 +376,59 @@ describe('runtime prefetching', () => { ) }) + it('includes headers, but not dynamic content', 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 a runtime prefetch for one value of the search param + await act(async () => { + const linkToggle = await browser.elementByCss( + `input[data-link-accordion="/${prefix}/headers"]` + ) + await linkToggle.click() + }, [ + // Should allow reading headers + { + includes: 'Header: present', + }, + // Should not prefetch the dynamic content + { + includes: 'Dynamic content', + block: 'reject', + }, + ]) + + // Navigate to the page + await act(async () => { + await act( + async () => { + await browser.elementByCss(`a[href="/${prefix}/headers"]`).click() + }, + { + // Temporarily block the navigation request. + // The runtime-prefetched parts of the tree should be visible before it finishes. + includes: 'Dynamic content', + block: true, + } + ) + expect(await browser.elementById('header-value').text()).toEqual( + 'Header: present' + ) + }) + // After navigating, we should see both the parts that we prefetched and dynamic content. + expect(await browser.elementById('header-value').text()).toEqual( + 'Header: present' + ) + expect(await browser.elementById('dynamic-content').text()).toEqual( + 'Dynamic content' + ) + }) + it('includes cookies, but not dynamic content', async () => { let page: Playwright.Page const browser = await next.browser('/', { @@ -983,6 +1036,10 @@ describe('runtime prefetching', () => { description: 'when sync IO is used after awaiting cookies()', path: '/errors/sync-io-after-runtime-api/cookies', }, + { + description: 'when sync IO is used after awaiting headers()', + path: '/errors/sync-io-after-runtime-api/headers', + }, // TODO(dynamic-ppr): // A tree prefetch for "/dynamic-params/123" currently causes it to be prerendered on demand, // meaning that we end up statically prerendering it with all the params included.