From 4f693363363e06cb6f07de001beeb93687ec658e Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Mon, 16 Mar 2026 15:44:53 +0100 Subject: [PATCH 1/2] Support accessing root params in `generateStaticParams` (#91189) A new `GenerateStaticParamsStore` work unit store type is now provided during `generateStaticParams` execution. This enables root param getters (`import { lang } from 'next/root-params'`) to be called inside `generateStaticParams`, allowing shared helpers that internally access root params via the special import to be used in both Server Components and `generateStaticParams` without manually threading params. Each `generateStaticParams` call now runs within a `workUnitAsyncStorage.run()` context carrying a `GenerateStaticParamsStore` with the correct `rootParams` (extracted from `parentParams` using the already-available `rootParamKeys`). The store extends `CommonWorkUnitStore`, providing `phase` and `implicitTags` which are not strictly necessary but convenient to keep call sites simple. Request-time APIs (`headers()`, `cookies()`, `connection()`, `draftMode()`) now throw specific errors when called inside `generateStaticParams` instead of the previous generic "called outside a request scope" message. Framework-internal functions like `createSearchParamsFromClient` and `createParamsFromClient` throw `InvariantError` since they should never be reached in this context. This change also unblocks a follow-up PR that removes `| undefined` from `PublicCacheContext.outerWorkUnitStore` in the use cache wrapper, since `"use cache"` is already supported inside `generateStaticParams` today but previously ran without a `WorkUnitStore`. With this store in place, requiring a `WorkUnitStore` in `"use cache"` won't break that existing usage. --- errors/next-dynamic-api-wrong-context.mdx | 6 + packages/next/errors.json | 17 +- .../next/src/build/static-paths/app.test.ts | 221 +++++++++++++++--- packages/next/src/build/static-paths/app.ts | 69 +++++- .../client/components/navigation-untracked.ts | 1 + .../next/src/server/app-render/app-render.tsx | 1 + .../app-render/create-component-tree.tsx | 2 + .../server/app-render/dynamic-rendering.ts | 13 ++ .../next/src/server/app-render/encryption.ts | 1 + .../instant-validation/boundary-impl.tsx | 1 + .../instant-samples-client.ts | 3 + .../instant-validation/instant-samples.ts | 1 + .../server/app-render/use-flight-response.tsx | 1 + .../next/src/server/app-render/vary-params.ts | 3 + .../work-unit-async-storage.external.ts | 19 +- packages/next/src/server/lib/patch-fetch.ts | 9 + .../console-dim.external.tsx | 1 + .../node-environment-extensions/io-utils.tsx | 1 + .../unhandled-rejection.external.tsx | 1 + .../next/src/server/request/connection.ts | 4 + packages/next/src/server/request/cookies.ts | 4 + .../next/src/server/request/draft-mode.ts | 8 + packages/next/src/server/request/headers.ts | 4 + packages/next/src/server/request/params.ts | 16 ++ packages/next/src/server/request/pathname.ts | 5 +- .../next/src/server/request/root-params.ts | 3 +- .../next/src/server/request/search-params.ts | 12 + .../server/route-modules/app-route/module.ts | 2 + .../next/src/server/use-cache/cache-life.ts | 1 + .../next/src/server/use-cache/cache-tag.ts | 1 + .../src/server/use-cache/use-cache-wrapper.ts | 15 ++ .../server/web/spec-extension/revalidate.ts | 4 + .../web/spec-extension/unstable-cache.ts | 2 + .../web/spec-extension/unstable-no-store.ts | 1 + .../app/[lang]/[locale]/other/[slug]/page.tsx | 5 + .../generate-static-params.test.ts | 13 ++ .../generate-static-params-errors.test.ts | 14 +- 37 files changed, 439 insertions(+), 46 deletions(-) diff --git a/errors/next-dynamic-api-wrong-context.mdx b/errors/next-dynamic-api-wrong-context.mdx index 30fcd86fb67862..f945594cf8c274 100644 --- a/errors/next-dynamic-api-wrong-context.mdx +++ b/errors/next-dynamic-api-wrong-context.mdx @@ -34,6 +34,12 @@ export async function GET() { } ``` +## `generateStaticParams` + +Request-time APIs like `headers()`, `cookies()`, `connection()`, and `draftMode()` are not available inside `generateStaticParams` because it runs at build time to determine which pages to statically generate. There is no HTTP request in this context. + +Note: Root param getters (`import { lang } from 'next/root-params'`) _are_ supported inside `generateStaticParams` for nested segments, as the parent params are known at that point. + ## Useful Links - [`headers()` function](/docs/app/api-reference/functions/headers) diff --git a/packages/next/errors.json b/packages/next/errors.json index cfd6423250de28..c13b1ab49276ef 100644 --- a/packages/next/errors.json +++ b/packages/next/errors.json @@ -1117,5 +1117,20 @@ "1116": "Route \"%s\" accessed header \"%s\" which is not defined in the \\`samples\\` of \\`unstable_instant\\`. Add it to the sample's \\`headers\\` array, or \\`[\"%s\", null]\\` if it should be absent.", "1117": "Instant validation boundaries should never appear in browser bundles.", "1118": "An error occurred while attempting to validate instant UI. This error may be preventing the validation from completing.", - "1119": "Expected edge function entrypoint to emit a JavaScript file" + "1119": "Expected edge function entrypoint to emit a JavaScript file", + "1120": "createServerParamsForServerSegment should not be called inside generateStaticParams.", + "1121": "Route %s used \\`%s\\` inside \\`generateStaticParams\\`. This is not supported because \\`generateStaticParams\\` runs at build time without an HTTP request. Read more: https://nextjs.org/docs/messages/next-dynamic-api-wrong-context", + "1122": "createParamsFromClient should not be called inside generateStaticParams.", + "1123": "Route %s used \\`cookies()\\` inside \\`generateStaticParams\\`. This is not supported because \\`generateStaticParams\\` runs at build time without an HTTP request. Read more: https://nextjs.org/docs/messages/next-dynamic-api-wrong-context", + "1124": "createPrerenderSearchParamsForClientPage should not be called inside generateStaticParams.", + "1125": "Route %s used \\`connection()\\` inside \\`generateStaticParams\\`. This is not supported because \\`generateStaticParams\\` runs at build time without an HTTP request. Read more: https://nextjs.org/docs/messages/next-dynamic-api-wrong-context", + "1126": "createPrerenderParamsForClientSegment should not be called inside generateStaticParams.", + "1127": "Route %s used \"%s\" inside \\`generateStaticParams\\` which is unsupported. To ensure revalidation is performed consistently it must always happen outside of renders and cached functions. See more info here: https://nextjs.org/docs/app/building-your-application/rendering/static-and-dynamic#dynamic-rendering", + "1128": "createServerSearchParamsForServerPage should not be called inside generateStaticParams.", + "1129": "createServerPathnameForMetadata should not be called inside generateStaticParams.", + "1130": "\\`%s\\` was called in \\`generateStaticParams\\`. Next.js should be preventing %s from being included in server component files statically, but did not in this case.", + "1131": "createServerParamsForRoute should not be called inside generateStaticParams.", + "1132": "Route %s used \\`draftMode()\\` inside \\`generateStaticParams\\`. This is not supported because \\`generateStaticParams\\` runs at build time without an HTTP request. Read more: https://nextjs.org/docs/messages/next-dynamic-api-wrong-context", + "1133": "createSearchParamsFromClient should not be called inside generateStaticParams.", + "1134": "Route %s used \\`headers()\\` inside \\`generateStaticParams\\`. This is not supported because \\`generateStaticParams\\` runs at build time without an HTTP request. Read more: https://nextjs.org/docs/messages/next-dynamic-api-wrong-context" } diff --git a/packages/next/src/build/static-paths/app.test.ts b/packages/next/src/build/static-paths/app.test.ts index d34074d105c773..d17406d101a7b0 100644 --- a/packages/next/src/build/static-paths/app.test.ts +++ b/packages/next/src/build/static-paths/app.test.ts @@ -9,7 +9,9 @@ import { } from './app' import type { PrerenderedRoute } from './types' import type { WorkStore } from '../../server/app-render/work-async-storage.external' +import type { WorkUnitAsyncStorage } from '../../server/app-render/work-unit-async-storage.external' import type { AppSegment } from '../segment-config/app/app-segments' +import { AsyncLocalStorage } from 'async_hooks' function pathnameSegments( ...segments: Array @@ -978,8 +980,11 @@ type TestAppSegment = Pick // Mock WorkStore for testing const createMockWorkStore = (fetchCache?: WorkStore['fetchCache']) => ({ fetchCache, + page: '/test-page', }) +const mockWorkUnitAsyncStorage = new AsyncLocalStorage() as WorkUnitAsyncStorage + // Helper to create mock segments const createMockSegment = ( generateStaticParams?: (options: { params?: Params }) => Promise, @@ -993,7 +998,13 @@ describe('generateRouteStaticParams', () => { describe('Basic functionality', () => { it('should return empty array for empty segments', async () => { const store = createMockWorkStore() - const result = await generateRouteStaticParams([], store, false) + const result = await generateRouteStaticParams( + [], + store, + mockWorkUnitAsyncStorage, + false, + [] + ) expect(result).toEqual([]) }) @@ -1003,7 +1014,13 @@ describe('generateRouteStaticParams', () => { createMockSegment(), ] const store = createMockWorkStore() - const result = await generateRouteStaticParams(segments, store, false) + const result = await generateRouteStaticParams( + segments, + store, + mockWorkUnitAsyncStorage, + false, + [] + ) expect(result).toEqual([]) }) @@ -1012,7 +1029,13 @@ describe('generateRouteStaticParams', () => { createMockSegment(async () => [{ id: '1' }, { id: '2' }]), ] const store = createMockWorkStore() - const result = await generateRouteStaticParams(segments, store, false) + const result = await generateRouteStaticParams( + segments, + store, + mockWorkUnitAsyncStorage, + false, + [] + ) expect(result).toEqual([{ id: '1' }, { id: '2' }]) }) @@ -1028,7 +1051,13 @@ describe('generateRouteStaticParams', () => { ]), ] const store = createMockWorkStore() - const result = await generateRouteStaticParams(segments, store, false) + const result = await generateRouteStaticParams( + segments, + store, + mockWorkUnitAsyncStorage, + false, + [] + ) expect(result).toEqual([ { category: 'tech', slug: 'tech-post-1' }, { category: 'tech', slug: 'tech-post-2' }, @@ -1047,7 +1076,13 @@ describe('generateRouteStaticParams', () => { ]), ] const store = createMockWorkStore() - const result = await generateRouteStaticParams(segments, store, false) + const result = await generateRouteStaticParams( + segments, + store, + mockWorkUnitAsyncStorage, + false, + [] + ) expect(result).toEqual([ { lang: 'en', category: 'en-tech' }, { lang: 'fr', category: 'fr-tech' }, @@ -1063,7 +1098,13 @@ describe('generateRouteStaticParams', () => { ]), ] const store = createMockWorkStore() - const result = await generateRouteStaticParams(segments, store, false) + const result = await generateRouteStaticParams( + segments, + store, + mockWorkUnitAsyncStorage, + false, + [] + ) expect(result).toEqual([{ lang: 'en', slug: 'en-slug' }]) }) }) @@ -1072,7 +1113,13 @@ describe('generateRouteStaticParams', () => { it('should handle empty generateStaticParams results', async () => { const segments: TestAppSegment[] = [createMockSegment(async () => [])] const store = createMockWorkStore() - const result = await generateRouteStaticParams(segments, store, false) + const result = await generateRouteStaticParams( + segments, + store, + mockWorkUnitAsyncStorage, + false, + [] + ) expect(result).toEqual([]) }) @@ -1082,7 +1129,13 @@ describe('generateRouteStaticParams', () => { createMockSegment(async () => []), // Empty result ] const store = createMockWorkStore() - const result = await generateRouteStaticParams(segments, store, false) + const result = await generateRouteStaticParams( + segments, + store, + mockWorkUnitAsyncStorage, + false, + [] + ) expect(result).toEqual([{ lang: 'en' }]) }) @@ -1094,7 +1147,13 @@ describe('generateRouteStaticParams', () => { ]), ] const store = createMockWorkStore() - const result = await generateRouteStaticParams(segments, store, false) + const result = await generateRouteStaticParams( + segments, + store, + mockWorkUnitAsyncStorage, + false, + [] + ) expect(result).toEqual([ { lang: 'en', category: 'en-tech' }, { category: 'default-tech' }, @@ -1110,7 +1169,13 @@ describe('generateRouteStaticParams', () => { }), ] const store = createMockWorkStore() - await generateRouteStaticParams(segments, store, false) + await generateRouteStaticParams( + segments, + store, + mockWorkUnitAsyncStorage, + false, + [] + ) expect(store.fetchCache).toBe('force-cache') }) @@ -1119,7 +1184,13 @@ describe('generateRouteStaticParams', () => { createMockSegment(async () => [{ id: '1' }]), ] const store = createMockWorkStore('force-cache') - await generateRouteStaticParams(segments, store, false) + await generateRouteStaticParams( + segments, + store, + mockWorkUnitAsyncStorage, + false, + [] + ) expect(store.fetchCache).toBe('force-cache') }) @@ -1133,7 +1204,13 @@ describe('generateRouteStaticParams', () => { }), ] const store = createMockWorkStore() - await generateRouteStaticParams(segments, store, false) + await generateRouteStaticParams( + segments, + store, + mockWorkUnitAsyncStorage, + false, + [] + ) // Should have the last fetchCache value expect(store.fetchCache).toBe('default-cache') }) @@ -1148,7 +1225,13 @@ describe('generateRouteStaticParams', () => { ]), ] const store = createMockWorkStore() - const result = await generateRouteStaticParams(segments, store, false) + const result = await generateRouteStaticParams( + segments, + store, + mockWorkUnitAsyncStorage, + false, + [] + ) expect(result).toEqual([{ slug: ['a', 'b'] }, { slug: ['c', 'd', 'e'] }]) }) @@ -1160,7 +1243,13 @@ describe('generateRouteStaticParams', () => { ]), ] const store = createMockWorkStore() - const result = await generateRouteStaticParams(segments, store, false) + const result = await generateRouteStaticParams( + segments, + store, + mockWorkUnitAsyncStorage, + false, + [] + ) expect(result).toEqual([{ lang: 'en', slug: ['en', 'post'] }]) }) }) @@ -1174,7 +1263,13 @@ describe('generateRouteStaticParams', () => { createMockSegment(async ({ params }) => [{ d: `${params?.c}-4` }]), ] const store = createMockWorkStore() - const result = await generateRouteStaticParams(segments, store, false) + const result = await generateRouteStaticParams( + segments, + store, + mockWorkUnitAsyncStorage, + false, + [] + ) expect(result).toEqual([{ a: '1', b: '1-2', c: '1-2-3', d: '1-2-3-4' }]) }) @@ -1185,7 +1280,13 @@ describe('generateRouteStaticParams', () => { createMockSegment(async () => [{ z: 'i' }, { z: 'ii' }]), ] const store = createMockWorkStore() - const result = await generateRouteStaticParams(segments, store, false) + const result = await generateRouteStaticParams( + segments, + store, + mockWorkUnitAsyncStorage, + false, + [] + ) expect(result).toEqual([ { x: '1', y: 'a', z: 'i' }, { x: '1', y: 'a', z: 'ii' }, @@ -1208,7 +1309,13 @@ describe('generateRouteStaticParams', () => { ] const store = createMockWorkStore() await expect( - generateRouteStaticParams(segments, store, false) + generateRouteStaticParams( + segments, + store, + mockWorkUnitAsyncStorage, + false, + [] + ) ).rejects.toThrow('Test error') }) @@ -1220,7 +1327,13 @@ describe('generateRouteStaticParams', () => { ] const store = createMockWorkStore() await expect( - generateRouteStaticParams(segments, store, false) + generateRouteStaticParams( + segments, + store, + mockWorkUnitAsyncStorage, + false, + [] + ) ).rejects.toThrow('Async error') }) @@ -1236,7 +1349,13 @@ describe('generateRouteStaticParams', () => { ] const store = createMockWorkStore() await expect( - generateRouteStaticParams(segments, store, false) + generateRouteStaticParams( + segments, + store, + mockWorkUnitAsyncStorage, + false, + [] + ) ).rejects.toThrow('Tech not allowed') }) @@ -1247,7 +1366,13 @@ describe('generateRouteStaticParams', () => { ] const store = createMockWorkStore() await expect( - generateRouteStaticParams(segments, store, true) + generateRouteStaticParams( + segments, + store, + mockWorkUnitAsyncStorage, + true, + [] + ) ).rejects.toThrow( 'When using Cache Components, all `generateStaticParams` functions must return at least one result' ) @@ -1259,7 +1384,13 @@ describe('generateRouteStaticParams', () => { ] const store = createMockWorkStore() await expect( - generateRouteStaticParams(segments, store, true) + generateRouteStaticParams( + segments, + store, + mockWorkUnitAsyncStorage, + true, + [] + ) ).rejects.toThrow( 'When using Cache Components, all `generateStaticParams` functions must return at least one result' ) @@ -1271,7 +1402,13 @@ describe('generateRouteStaticParams', () => { createMockSegment(async () => []), // Empty result ] const store = createMockWorkStore() - const result = await generateRouteStaticParams(segments, store, false) + const result = await generateRouteStaticParams( + segments, + store, + mockWorkUnitAsyncStorage, + false, + [] + ) expect(result).toEqual([{ lang: 'en' }]) }) @@ -1280,7 +1417,13 @@ describe('generateRouteStaticParams', () => { createMockSegment(async () => []), // Empty result at root level ] const store = createMockWorkStore() - const result = await generateRouteStaticParams(segments, store, false) + const result = await generateRouteStaticParams( + segments, + store, + mockWorkUnitAsyncStorage, + false, + [] + ) expect(result).toEqual([]) }) }) @@ -1303,7 +1446,13 @@ describe('generateRouteStaticParams', () => { ]), ] const store = createMockWorkStore() - const result = await generateRouteStaticParams(segments, store, false) + const result = await generateRouteStaticParams( + segments, + store, + mockWorkUnitAsyncStorage, + false, + [] + ) expect(result).toHaveLength(12) // 3 langs × 2 categories × 2 slugs expect(result).toContainEqual({ lang: 'en', @@ -1335,7 +1484,13 @@ describe('generateRouteStaticParams', () => { ]), ] const store = createMockWorkStore() - const result = await generateRouteStaticParams(segments, store, false) + const result = await generateRouteStaticParams( + segments, + store, + mockWorkUnitAsyncStorage, + false, + [] + ) expect(result).toEqual([ { category: 'electronics', @@ -1370,7 +1525,13 @@ describe('generateRouteStaticParams', () => { ]), ] const store = createMockWorkStore() - const result = await generateRouteStaticParams(segments, store, false) + const result = await generateRouteStaticParams( + segments, + store, + mockWorkUnitAsyncStorage, + false, + [] + ) expect(result).toHaveLength(8) // 2 years × 2 months × 2 slug variations expect(result).toContainEqual({ year: '2023', @@ -1390,7 +1551,13 @@ describe('generateRouteStaticParams', () => { ) } const store = createMockWorkStore() - const result = await generateRouteStaticParams(segments, store, false) + const result = await generateRouteStaticParams( + segments, + store, + mockWorkUnitAsyncStorage, + false, + [] + ) expect(result).toHaveLength(1) expect(Object.keys(result[0])).toHaveLength(5000) }) diff --git a/packages/next/src/build/static-paths/app.ts b/packages/next/src/build/static-paths/app.ts index 2ea273d3c3fdf7..1c8139e1fc30b4 100644 --- a/packages/next/src/build/static-paths/app.ts +++ b/packages/next/src/build/static-paths/app.ts @@ -28,6 +28,12 @@ import { throwEmptyGenerateStaticParamsError } from '../../shared/lib/errors/emp import type { AppRouteModule } from '../../server/route-modules/app-route/module.compiled' import type { NormalizedAppRoute } from '../../shared/lib/router/routes/app' import { interceptionPrefixFromParamType } from '../../shared/lib/router/utils/interception-prefix-from-param-type' +import type { + GenerateStaticParamsStore, + WorkUnitAsyncStorage, +} from '../../server/app-render/work-unit-async-storage.external' +import type { ImplicitTags } from '../../server/lib/implicit-tags' +import { getImplicitTags } from '../../server/lib/implicit-tags' /** * Filters out duplicate parameters from a list of parameters. @@ -594,6 +600,36 @@ export function assignStaticShellMetadata( } } +/** + * Calls a single generateStaticParams function within a WorkUnitStore context, + * making root param getters available during static param generation. + */ +async function callGenerateStaticParams( + generateStaticParams: NonNullable, + workUnitAsyncStorage: WorkUnitAsyncStorage, + parentParams: Params, + rootParamKeys: readonly string[], + implicitTags: ImplicitTags +): Promise { + const rootParams: Params = {} + for (const key of rootParamKeys) { + if (key in parentParams) { + rootParams[key] = parentParams[key] + } + } + + const workUnitStore: GenerateStaticParamsStore = { + type: 'generate-static-params', + phase: 'render', + implicitTags, + rootParams, + } + + return workUnitAsyncStorage.run(workUnitStore, generateStaticParams, { + params: parentParams, + }) +} + /** * Processes app directory segments to build route parameters from generateStaticParams functions. * This function walks through the segments array and calls generateStaticParams for each segment that has it, @@ -602,18 +638,25 @@ export function assignStaticShellMetadata( * * @param segments - Array of app directory segments to process * @param store - Work store for tracking fetch cache configuration + * @param workUnitAsyncStorage - AsyncLocalStorage for work unit stores + * @param isRoutePPREnabled - Whether PPR is enabled for this route + * @param rootParamKeys - The keys identifying which params are root params * @returns Promise that resolves to an array of all parameter combinations */ export async function generateRouteStaticParams( segments: ReadonlyArray< Readonly> >, - store: Pick, - isRoutePPREnabled: boolean + store: Pick, + workUnitAsyncStorage: WorkUnitAsyncStorage, + isRoutePPREnabled: boolean, + rootParamKeys: readonly string[] ): Promise { // Early return if no segments to process if (segments.length === 0) return [] + const implicitTags = await getImplicitTags(store.page, store.page, null) + // Use iterative processing with a work queue to avoid recursion overhead interface WorkItem { segmentIndex: number @@ -651,9 +694,13 @@ export async function generateRouteStaticParams( if (params.length > 0) { // Process each parent parameter combination for (const parentParams of params) { - const result = await current.generateStaticParams({ - params: parentParams, - }) + const result = await callGenerateStaticParams( + current.generateStaticParams, + workUnitAsyncStorage, + parentParams, + rootParamKeys, + implicitTags + ) if (result.length > 0) { // Merge parent params with each result item @@ -669,7 +716,13 @@ export async function generateRouteStaticParams( } } else { // No parent params, call generateStaticParams with empty object - const result = await current.generateStaticParams({ params: {} }) + const result = await callGenerateStaticParams( + current.generateStaticParams, + workUnitAsyncStorage, + {}, + rootParamKeys, + implicitTags + ) if (result.length === 0 && isRoutePPREnabled) { throwEmptyGenerateStaticParamsError() } @@ -826,7 +879,9 @@ export async function buildAppStaticPaths({ generateRouteStaticParams, segments, store, - isRoutePPREnabled + ComponentMod.workUnitAsyncStorage, + isRoutePPREnabled, + rootParamKeys ) const generatedParamNames = new Set() for (const params of routeParams) { diff --git a/packages/next/src/client/components/navigation-untracked.ts b/packages/next/src/client/components/navigation-untracked.ts index 2db06d8887a196..4b96075501f03d 100644 --- a/packages/next/src/client/components/navigation-untracked.ts +++ b/packages/next/src/client/components/navigation-untracked.ts @@ -30,6 +30,7 @@ function hasFallbackRouteParams(): boolean { case 'cache': case 'private-cache': case 'unstable-cache': + case 'generate-static-params': break default: workUnitStore satisfies never diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index 5da06a40b45d86..361c85e54fdb41 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -2118,6 +2118,7 @@ async function renderToHTMLOrFlightImpl( case 'prerender-legacy': case 'request': case 'unstable-cache': + case 'generate-static-params': return false default: workUnitStore satisfies never diff --git a/packages/next/src/server/app-render/create-component-tree.tsx b/packages/next/src/server/app-render/create-component-tree.tsx index 35d937a0d1dc50..14649442055216 100644 --- a/packages/next/src/server/app-render/create-component-tree.tsx +++ b/packages/next/src/server/app-render/create-component-tree.tsx @@ -335,6 +335,7 @@ async function createComponentTreeInternal( case 'prerender-client': case 'validation-client': case 'unstable-cache': + case 'generate-static-params': break default: workUnitStore satisfies never @@ -1290,6 +1291,7 @@ function createSeedData( case 'cache': case 'private-cache': case 'unstable-cache': + case 'generate-static-params': break default: workUnitStore satisfies never diff --git a/packages/next/src/server/app-render/dynamic-rendering.ts b/packages/next/src/server/app-render/dynamic-rendering.ts index 13e30162d6a4c4..7593a43c96e82a 100644 --- a/packages/next/src/server/app-render/dynamic-rendering.ts +++ b/packages/next/src/server/app-render/dynamic-rendering.ts @@ -147,6 +147,7 @@ export function markCurrentScopeAsDynamic( case 'prerender-legacy': case 'prerender-ppr': case 'request': + case 'generate-static-params': break default: workUnitStore satisfies never @@ -189,6 +190,8 @@ export function markCurrentScopeAsDynamic( workUnitStore.usedDynamic = true } break + case 'generate-static-params': + break default: workUnitStore satisfies never } @@ -244,6 +247,7 @@ export function trackDynamicDataInDynamicRender(workUnitStore: WorkUnitStore) { case 'prerender-ppr': case 'prerender-client': case 'validation-client': + case 'generate-static-params': break case 'request': if (process.env.NODE_ENV !== 'production') { @@ -564,6 +568,7 @@ export function createHangingInputAbortSignal( case 'cache': case 'private-cache': case 'unstable-cache': + case 'generate-static-params': return undefined default: workUnitStore satisfies never @@ -633,6 +638,10 @@ export function useDynamicRouteParams(expression: string) { throw new InvariantError( `\`${expression}\` was called inside a cache scope. Next.js should be preventing ${expression} from being included in server components statically, but did not in this case.` ) + case 'generate-static-params': + throw new InvariantError( + `\`${expression}\` was called in \`generateStaticParams\`. Next.js should be preventing ${expression} from being included in server component files statically, but did not in this case.` + ) case 'prerender-legacy': case 'request': case 'unstable-cache': @@ -689,6 +698,10 @@ export function useDynamicSearchParams(expression: string) { throw new InvariantError( `\`${expression}\` was called inside a cache scope. Next.js should be preventing ${expression} from being included in server components statically, but did not in this case.` ) + case 'generate-static-params': + throw new InvariantError( + `\`${expression}\` was called in \`generateStaticParams\`. Next.js should be preventing ${expression} from being included in server component files statically, but did not in this case.` + ) case 'request': return default: diff --git a/packages/next/src/server/app-render/encryption.ts b/packages/next/src/server/app-render/encryption.ts index 073ad57107c03e..61e9a5f70557e6 100644 --- a/packages/next/src/server/app-render/encryption.ts +++ b/packages/next/src/server/app-render/encryption.ts @@ -305,6 +305,7 @@ export async function decryptActionBoundArgs( case 'cache': case 'private-cache': case 'unstable-cache': + case 'generate-static-params': case undefined: return controller.close() default: diff --git a/packages/next/src/server/app-render/instant-validation/boundary-impl.tsx b/packages/next/src/server/app-render/instant-validation/boundary-impl.tsx index e6498ff37f84c0..8902dc578eb914 100644 --- a/packages/next/src/server/app-render/instant-validation/boundary-impl.tsx +++ b/packages/next/src/server/app-render/instant-validation/boundary-impl.tsx @@ -31,6 +31,7 @@ function getValidationBoundaryTracking(): ValidationBoundaryTracking | null { case 'cache': case 'private-cache': case 'unstable-cache': + case 'generate-static-params': break default: store satisfies never diff --git a/packages/next/src/server/app-render/instant-validation/instant-samples-client.ts b/packages/next/src/server/app-render/instant-validation/instant-samples-client.ts index 306176e190d7b8..b73bb9bd1391a2 100644 --- a/packages/next/src/server/app-render/instant-validation/instant-samples-client.ts +++ b/packages/next/src/server/app-render/instant-validation/instant-samples-client.ts @@ -38,6 +38,7 @@ export function instrumentParamsForClientValidation( case 'request': case 'private-cache': case 'unstable-cache': + case 'generate-static-params': break default: workUnitStore satisfies never @@ -77,6 +78,7 @@ export function expectCompleteParamsInClientValidation( case 'request': case 'private-cache': case 'unstable-cache': + case 'generate-static-params': break default: workUnitStore satisfies never @@ -113,6 +115,7 @@ export function instrumentSearchParamsForClientValidation( case 'request': case 'private-cache': case 'unstable-cache': + case 'generate-static-params': break default: workUnitStore satisfies never diff --git a/packages/next/src/server/app-render/instant-validation/instant-samples.ts b/packages/next/src/server/app-render/instant-validation/instant-samples.ts index f1182f3d2ffa48..23f71d68871e3a 100644 --- a/packages/next/src/server/app-render/instant-validation/instant-samples.ts +++ b/packages/next/src/server/app-render/instant-validation/instant-samples.ts @@ -46,6 +46,7 @@ function getExpectedSampleTracking(): InstantValidationSampleTracking { case 'prerender-client': case 'prerender': case 'prerender-runtime': + case 'generate-static-params': break default: workUnitStore satisfies never diff --git a/packages/next/src/server/app-render/use-flight-response.tsx b/packages/next/src/server/app-render/use-flight-response.tsx index 85aaac5057abda..5f148e1a0a43ab 100644 --- a/packages/next/src/server/app-render/use-flight-response.tsx +++ b/packages/next/src/server/app-render/use-flight-response.tsx @@ -130,6 +130,7 @@ export function getFlightStream( case 'cache': case 'private-cache': case 'unstable-cache': + case 'generate-static-params': break default: workUnitStore satisfies never diff --git a/packages/next/src/server/app-render/vary-params.ts b/packages/next/src/server/app-render/vary-params.ts index bea4bd0fb72c0b..d48b8298a0473b 100644 --- a/packages/next/src/server/app-render/vary-params.ts +++ b/packages/next/src/server/app-render/vary-params.ts @@ -134,6 +134,7 @@ export function createVaryParamsAccumulator(): VaryParamsAccumulator | null { case 'prerender-client': case 'validation-client': case 'unstable-cache': + case 'generate-static-params': break default: workUnitStore satisfies never @@ -162,6 +163,7 @@ export function getMetadataVaryParamsAccumulator(): VaryParamsAccumulator | null case 'prerender-client': case 'validation-client': case 'unstable-cache': + case 'generate-static-params': return null default: workUnitStore satisfies never @@ -209,6 +211,7 @@ export function getRootParamsVaryParamsAccumulator(): VaryParamsAccumulator | nu case 'prerender-client': case 'validation-client': case 'unstable-cache': + case 'generate-static-params': return null default: workUnitStore satisfies never 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 c8eb4643027d74..c54df1922cebe8 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 @@ -378,7 +378,16 @@ export interface UnstableCacheStore extends CommonCacheStore { */ export type CacheStore = UseCacheStore | UnstableCacheStore -export type WorkUnitStore = RequestStore | CacheStore | PrerenderStore +export interface GenerateStaticParamsStore extends CommonWorkUnitStore { + readonly type: 'generate-static-params' + readonly rootParams: Params +} + +export type WorkUnitStore = + | RequestStore + | CacheStore + | PrerenderStore + | GenerateStaticParamsStore export type WorkUnitAsyncStorage = AsyncLocalStorage @@ -418,6 +427,7 @@ export function getPrerenderResumeDataCache( case 'cache': case 'private-cache': case 'unstable-cache': + case 'generate-static-params': return null default: return workUnitStore satisfies never @@ -447,6 +457,7 @@ export function getRenderResumeDataCache( case 'private-cache': case 'unstable-cache': case 'prerender-legacy': + case 'generate-static-params': return null default: return workUnitStore satisfies never @@ -470,6 +481,7 @@ export function getHmrRefreshHash( case 'prerender-ppr': case 'prerender-legacy': case 'unstable-cache': + case 'generate-static-params': break default: workUnitStore satisfies never @@ -493,6 +505,7 @@ export function isHmrRefresh(workUnitStore: WorkUnitStore): boolean { case 'prerender-ppr': case 'prerender-legacy': case 'unstable-cache': + case 'generate-static-params': break default: workUnitStore satisfies never @@ -518,6 +531,7 @@ export function getServerComponentsHmrCache( case 'prerender-ppr': case 'prerender-legacy': case 'unstable-cache': + case 'generate-static-params': break default: workUnitStore satisfies never @@ -547,6 +561,7 @@ export function getDraftModeProviderForCacheScope( case 'validation-client': case 'prerender-ppr': case 'prerender-legacy': + case 'generate-static-params': break default: workUnitStore satisfies never @@ -571,6 +586,7 @@ export function getStagedRenderingController( case 'cache': case 'private-cache': case 'unstable-cache': + case 'generate-static-params': return null default: return workUnitStore satisfies never @@ -598,6 +614,7 @@ export function getCacheSignal( case 'cache': case 'private-cache': case 'unstable-cache': + case 'generate-static-params': return null default: return workUnitStore satisfies never diff --git a/packages/next/src/server/lib/patch-fetch.ts b/packages/next/src/server/lib/patch-fetch.ts index 48d4759325d631..39ed601e153a45 100644 --- a/packages/next/src/server/lib/patch-fetch.ts +++ b/packages/next/src/server/lib/patch-fetch.ts @@ -373,6 +373,7 @@ export function createPatchedFetcher( break case 'request': case 'unstable-cache': + case 'generate-static-params': break default: workUnitStore satisfies never @@ -412,6 +413,7 @@ export function createPatchedFetcher( case 'request': case 'cache': case 'private-cache': + case 'generate-static-params': break default: workUnitStore satisfies never @@ -583,6 +585,7 @@ export function createPatchedFetcher( case 'cache': case 'private-cache': case 'unstable-cache': + case 'generate-static-params': break default: workUnitStore satisfies never @@ -715,6 +718,7 @@ export function createPatchedFetcher( case 'cache': case 'private-cache': case 'unstable-cache': + case 'generate-static-params': break default: workUnitStore satisfies never @@ -759,6 +763,7 @@ export function createPatchedFetcher( case 'prerender-ppr': case 'prerender-legacy': case 'unstable-cache': + case 'generate-static-params': break default: workUnitStore satisfies never @@ -913,6 +918,7 @@ export function createPatchedFetcher( case 'cache': case 'private-cache': case 'unstable-cache': + case 'generate-static-params': case undefined: return createCachedDynamicResponse( workStore, @@ -996,6 +1002,7 @@ export function createPatchedFetcher( case 'cache': case 'private-cache': case 'unstable-cache': + case 'generate-static-params': break default: workUnitStore satisfies never @@ -1125,6 +1132,7 @@ export function createPatchedFetcher( case 'cache': case 'private-cache': case 'unstable-cache': + case 'generate-static-params': break default: workUnitStore satisfies never @@ -1172,6 +1180,7 @@ export function createPatchedFetcher( case 'unstable-cache': case 'prerender-legacy': case 'prerender-ppr': + case 'generate-static-params': break default: workUnitStore satisfies never diff --git a/packages/next/src/server/node-environment-extensions/console-dim.external.tsx b/packages/next/src/server/node-environment-extensions/console-dim.external.tsx index c4c80f0e629d7c..b4ff95eba6fd35 100644 --- a/packages/next/src/server/node-environment-extensions/console-dim.external.tsx +++ b/packages/next/src/server/node-environment-extensions/console-dim.external.tsx @@ -262,6 +262,7 @@ function patchConsoleMethod(methodName: InterceptableConsoleMethod): void { case 'unstable-cache': case 'private-cache': case 'request': + case 'generate-static-params': case undefined: if (consoleStore?.dim === true) { return applyWithDimming.call( diff --git a/packages/next/src/server/node-environment-extensions/io-utils.tsx b/packages/next/src/server/node-environment-extensions/io-utils.tsx index c28dcd409d330f..943eea79c0a38e 100644 --- a/packages/next/src/server/node-environment-extensions/io-utils.tsx +++ b/packages/next/src/server/node-environment-extensions/io-utils.tsx @@ -161,6 +161,7 @@ export function io(expression: string, type: ApiType) { case 'cache': case 'private-cache': case 'unstable-cache': + case 'generate-static-params': break default: workUnitStore satisfies never diff --git a/packages/next/src/server/node-environment-extensions/unhandled-rejection.external.tsx b/packages/next/src/server/node-environment-extensions/unhandled-rejection.external.tsx index a722eeb27dc6bb..9f79f6dd70ea67 100644 --- a/packages/next/src/server/node-environment-extensions/unhandled-rejection.external.tsx +++ b/packages/next/src/server/node-environment-extensions/unhandled-rejection.external.tsx @@ -620,6 +620,7 @@ function filteringUnhandledRejectionHandler( case 'cache': case 'private-cache': case 'unstable-cache': + case 'generate-static-params': break default: workUnitStore satisfies never diff --git a/packages/next/src/server/request/connection.ts b/packages/next/src/server/request/connection.ts index 29a911ba5602e4..a0fa67467b865c 100644 --- a/packages/next/src/server/request/connection.ts +++ b/packages/next/src/server/request/connection.ts @@ -75,6 +75,10 @@ export function connection(): Promise { throw new Error( `Route ${workStore.route} used \`connection()\` inside a function cached with \`unstable_cache()\`. The \`connection()\` function is used to indicate the subsequent code must only run when there is an actual Request, but caches must be able to be produced before a Request so this function is not allowed in this scope. See more info here: https://nextjs.org/docs/app/api-reference/functions/unstable_cache` ) + case 'generate-static-params': + throw new Error( + `Route ${workStore.route} used \`connection()\` inside \`generateStaticParams\`. This is not supported because \`generateStaticParams\` runs at build time without an HTTP request. Read more: https://nextjs.org/docs/messages/next-dynamic-api-wrong-context` + ) case 'prerender': case 'prerender-client': case 'prerender-runtime': diff --git a/packages/next/src/server/request/cookies.ts b/packages/next/src/server/request/cookies.ts index b8ff1b6f2ab990..7c39a64abea7ce 100644 --- a/packages/next/src/server/request/cookies.ts +++ b/packages/next/src/server/request/cookies.ts @@ -74,6 +74,10 @@ export function cookies(): Promise { throw new Error( `Route ${workStore.route} used \`cookies()\` 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 \`cookies()\` 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 'generate-static-params': + throw new Error( + `Route ${workStore.route} used \`cookies()\` inside \`generateStaticParams\`. This is not supported because \`generateStaticParams\` runs at build time without an HTTP request. Read more: https://nextjs.org/docs/messages/next-dynamic-api-wrong-context` + ) case 'prerender': return makeHangingCookies(workStore, workUnitStore) case 'prerender-client': diff --git a/packages/next/src/server/request/draft-mode.ts b/packages/next/src/server/request/draft-mode.ts index b2f604515dc7e2..faad97917fb451 100644 --- a/packages/next/src/server/request/draft-mode.ts +++ b/packages/next/src/server/request/draft-mode.ts @@ -70,6 +70,10 @@ export function draftMode(): Promise { `${exportName} must not be used within a Client Component. Next.js should be preventing ${exportName} from being included in Client Components statically, but did not in this case.` ) } + case 'generate-static-params': + throw new Error( + `Route ${workStore.route} used \`${callingExpression}()\` inside \`generateStaticParams\`. This is not supported because \`generateStaticParams\` runs at build time without an HTTP request. Read more: https://nextjs.org/docs/messages/next-dynamic-api-wrong-context` + ) default: return workUnitStore satisfies never @@ -247,6 +251,10 @@ function trackDynamicDraftMode(expression: string, constructorOpt: Function) { case 'request': trackDynamicDataInDynamicRender(workUnitStore) break + case 'generate-static-params': + throw new Error( + `Route ${workStore.route} used \`${expression}\` inside \`generateStaticParams\`. This is not supported because \`generateStaticParams\` runs at build time without an HTTP request. Read more: https://nextjs.org/docs/messages/next-dynamic-api-wrong-context` + ) default: workUnitStore satisfies never } diff --git a/packages/next/src/server/request/headers.ts b/packages/next/src/server/request/headers.ts index ed01d19cddc6b3..06c3e447b38cf8 100644 --- a/packages/next/src/server/request/headers.ts +++ b/packages/next/src/server/request/headers.ts @@ -75,6 +75,10 @@ export function headers(): Promise { 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 'generate-static-params': + throw new Error( + `Route ${workStore.route} used \`headers()\` inside \`generateStaticParams\`. This is not supported because \`generateStaticParams\` runs at build time without an HTTP request. Read more: https://nextjs.org/docs/messages/next-dynamic-api-wrong-context` + ) case 'prerender': case 'prerender-client': case 'validation-client': diff --git a/packages/next/src/server/request/params.ts b/packages/next/src/server/request/params.ts index 0e9f063df2eb36..4edd09ac842b95 100644 --- a/packages/next/src/server/request/params.ts +++ b/packages/next/src/server/request/params.ts @@ -83,6 +83,10 @@ export function createParamsFromClient( throw new InvariantError( 'createParamsFromClient should not be called in a runtime prerender.' ) + case 'generate-static-params': + throw new InvariantError( + 'createParamsFromClient should not be called inside generateStaticParams.' + ) case 'request': if (process.env.NODE_ENV === 'development') { // Semantically we only need the dev tracking when running in `next dev` @@ -163,6 +167,10 @@ export function createServerParamsForRoute( throw new InvariantError( 'createServerParamsForRoute should not be called in cache contexts.' ) + case 'generate-static-params': + throw new InvariantError( + 'createServerParamsForRoute should not be called inside generateStaticParams.' + ) case 'prerender-runtime': { // Route params are not runtime prefetchable const isRuntimePrefetchable = false @@ -233,6 +241,10 @@ export function createServerParamsForServerSegment( throw new InvariantError( 'createServerParamsForServerSegment should not be called in cache contexts.' ) + case 'generate-static-params': + throw new InvariantError( + 'createServerParamsForServerSegment should not be called inside generateStaticParams.' + ) case 'prerender-runtime': return createRuntimePrerenderParams( underlyingParams, @@ -327,6 +339,10 @@ export function createPrerenderParamsForClientSegment( throw new InvariantError( 'createPrerenderParamsForClientSegment should not be called in cache contexts.' ) + case 'generate-static-params': + throw new InvariantError( + 'createPrerenderParamsForClientSegment should not be called inside generateStaticParams.' + ) case 'prerender-ppr': case 'prerender-legacy': case 'prerender-runtime': diff --git a/packages/next/src/server/request/pathname.ts b/packages/next/src/server/request/pathname.ts index ea54f7f0a4ad0d..deb454ca18f128 100644 --- a/packages/next/src/server/request/pathname.ts +++ b/packages/next/src/server/request/pathname.ts @@ -51,7 +51,10 @@ export function createServerPathnameForMetadata( throw new InvariantError( 'createServerPathnameForMetadata should not be called in cache contexts.' ) - + case 'generate-static-params': + throw new InvariantError( + 'createServerPathnameForMetadata should not be called inside generateStaticParams.' + ) case 'prerender-runtime': return delayUntilRuntimeStage( workUnitStore, diff --git a/packages/next/src/server/request/root-params.ts b/packages/next/src/server/request/root-params.ts index df16146d789d2f..bc3f08074edf28 100644 --- a/packages/next/src/server/request/root-params.ts +++ b/packages/next/src/server/request/root-params.ts @@ -101,7 +101,8 @@ export function getRootParam(paramName: string): Promise { break } case 'private-cache': - case 'prerender-runtime': { + case 'prerender-runtime': + case 'generate-static-params': { break } default: { diff --git a/packages/next/src/server/request/search-params.ts b/packages/next/src/server/request/search-params.ts index efb51d4fd6321f..4b585592529731 100644 --- a/packages/next/src/server/request/search-params.ts +++ b/packages/next/src/server/request/search-params.ts @@ -77,6 +77,10 @@ export function createSearchParamsFromClient( throw new InvariantError( 'createSearchParamsFromClient should not be called in cache contexts.' ) + case 'generate-static-params': + throw new InvariantError( + 'createSearchParamsFromClient should not be called inside generateStaticParams.' + ) case 'request': // Client searchParams are not runtime prefetchable const isRuntimePrefetchable = false @@ -133,6 +137,10 @@ export function createServerSearchParamsForServerPage( throw new InvariantError( 'createServerSearchParamsForServerPage should not be called in cache contexts.' ) + case 'generate-static-params': + throw new InvariantError( + 'createServerSearchParamsForServerPage should not be called inside generateStaticParams.' + ) case 'prerender-runtime': return createRuntimePrerenderSearchParams( underlyingSearchParams, @@ -191,6 +199,10 @@ export function createPrerenderSearchParamsForClientPage(): Promise( case 'prerender-client': case 'validation-client': case 'request': + case 'generate-static-params': break default: workUnitStore satisfies never @@ -417,6 +418,7 @@ function getFetchUrlPrefix( case 'cache': case 'private-cache': case 'unstable-cache': + case 'generate-static-params': return workStore.route default: return workUnitStore satisfies never diff --git a/packages/next/src/server/web/spec-extension/unstable-no-store.ts b/packages/next/src/server/web/spec-extension/unstable-no-store.ts index 545b77507c9925..cf84b31f6731ad 100644 --- a/packages/next/src/server/web/spec-extension/unstable-no-store.ts +++ b/packages/next/src/server/web/spec-extension/unstable-no-store.ts @@ -44,6 +44,7 @@ export function unstable_noStore() { case 'cache': case 'private-cache': case 'unstable-cache': + case 'generate-static-params': break default: workUnitStore satisfies never diff --git a/test/e2e/app-dir/app-root-params-getters/fixtures/generate-static-params/app/[lang]/[locale]/other/[slug]/page.tsx b/test/e2e/app-dir/app-root-params-getters/fixtures/generate-static-params/app/[lang]/[locale]/other/[slug]/page.tsx index 51a350cbc510e1..3e83390645b984 100644 --- a/test/e2e/app-dir/app-root-params-getters/fixtures/generate-static-params/app/[lang]/[locale]/other/[slug]/page.tsx +++ b/test/e2e/app-dir/app-root-params-getters/fixtures/generate-static-params/app/[lang]/[locale]/other/[slug]/page.tsx @@ -1,6 +1,11 @@ import { lang, locale } from 'next/root-params' import { Suspense } from 'react' +export async function generateStaticParams() { + const l = await lang() + return [{ slug: `${l}-post` }] +} + export default async function Page({ params }) { return (
diff --git a/test/e2e/app-dir/app-root-params-getters/generate-static-params.test.ts b/test/e2e/app-dir/app-root-params-getters/generate-static-params.test.ts index 3d3b6168f991b5..38e4ee09fed754 100644 --- a/test/e2e/app-dir/app-root-params-getters/generate-static-params.test.ts +++ b/test/e2e/app-dir/app-root-params-getters/generate-static-params.test.ts @@ -36,4 +36,17 @@ describe('app-root-param-getters - generateStaticParams', () => { const $ = await next.render$(`/${params.lang}/${params.locale}`) expect($('p').text()).toBe(`hello world ${JSON.stringify(params)}`) }) + + it('should allow reading root params inside generateStaticParams', async () => { + // The [slug] segment's generateStaticParams uses `lang()` to produce + // slugs like "en-post". If root params are available during + // generateStaticParams, this page should be statically prerenderable. + const response = await next.fetch('/en/us/other/en-post') + expect(response.status).toBe(200) + const $ = cheerio.load(await response.text()) + expect($('#root-params').text()).toBe( + JSON.stringify({ lang: 'en', locale: 'us' }) + ) + expect($('#dynamic-params').text()).toBe('en-post') + }) }) diff --git a/test/production/app-dir/generate-static-params-errors/generate-static-params-errors.test.ts b/test/production/app-dir/generate-static-params-errors/generate-static-params-errors.test.ts index a9994c7df46ec5..a2a9dc2489a183 100644 --- a/test/production/app-dir/generate-static-params-errors/generate-static-params-errors.test.ts +++ b/test/production/app-dir/generate-static-params-errors/generate-static-params-errors.test.ts @@ -22,35 +22,33 @@ describe('generate-static-params-errors', () => { it('should error when cookies() is called inside generateStaticParams', async () => { await buildRoute('app/[lang]/cookies/[slug]/page.tsx') expect(getCliOutput()).toContain( - 'Error: `cookies` was called outside a request scope. Read more: https://nextjs.org/docs/messages/next-dynamic-api-wrong-context' + 'Error: Route /[lang]/cookies/[slug] used `cookies()` inside `generateStaticParams`. This is not supported because `generateStaticParams` runs at build time without an HTTP request. Read more: https://nextjs.org/docs/messages/next-dynamic-api-wrong-context' ) }) it('should error when headers() is called inside generateStaticParams', async () => { await buildRoute('app/[lang]/headers/[slug]/page.tsx') expect(getCliOutput()).toContain( - 'Error: `headers` was called outside a request scope. Read more: https://nextjs.org/docs/messages/next-dynamic-api-wrong-context' + 'Error: Route /[lang]/headers/[slug] used `headers()` inside `generateStaticParams`. This is not supported because `generateStaticParams` runs at build time without an HTTP request. Read more: https://nextjs.org/docs/messages/next-dynamic-api-wrong-context' ) }) it('should error when connection() is called inside generateStaticParams', async () => { await buildRoute('app/[lang]/connection/[slug]/page.tsx') expect(getCliOutput()).toContain( - 'Error: `connection` was called outside a request scope. Read more: https://nextjs.org/docs/messages/next-dynamic-api-wrong-context' + 'Error: Route /[lang]/connection/[slug] used `connection()` inside `generateStaticParams`. This is not supported because `generateStaticParams` runs at build time without an HTTP request. Read more: https://nextjs.org/docs/messages/next-dynamic-api-wrong-context' ) }) it('should error when draftMode() is called inside generateStaticParams', async () => { await buildRoute('app/[lang]/draft-mode/[slug]/page.tsx') expect(getCliOutput()).toContain( - 'Error: `draftMode` was called outside a request scope. Read more: https://nextjs.org/docs/messages/next-dynamic-api-wrong-context' + 'Error: Route /[lang]/draft-mode/[slug] used `draftMode()` inside `generateStaticParams`. This is not supported because `generateStaticParams` runs at build time without an HTTP request. Read more: https://nextjs.org/docs/messages/next-dynamic-api-wrong-context' ) }) - it('should error when root params are accessed inside generateStaticParams', async () => { + it('should allow root params access inside generateStaticParams', async () => { await buildRoute('app/[lang]/root-params/[slug]/page.tsx') - expect(getCliOutput()).toContain( - "Error: Route /[lang]/root-params/[slug] used `import('next/root-params').lang()` outside of a Server Component. This is not allowed." - ) + expect(getCliOutput()).not.toContain('Error') }) }) From 9bec0cce145116cb75788b1e4c1db5e8e8435ec0 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Mon, 16 Mar 2026 16:40:16 +0100 Subject: [PATCH 2/2] Avoid `undefined` outer work unit store in `"use cache"` (#91190) The `outerWorkUnitStore` in the public cache context could previously be `undefined`, which was needed when `"use cache"` was called without a `WorkUnitStore` (e.g. inside `generateStaticParams`) and during background revalidation of stale cache entries (SWR). With the previous commit providing a `GenerateStaticParamsStore` for `generateStaticParams`, the only remaining source of `undefined` was the SWR background revalidation path, which intentionally discarded the store to prevent cache life and tags from propagating back to the outer scope. A `skipPropagation` flag was added to `CacheContext` to decouple the propagation concern from the store reference. The SWR background regen now passes `skipPropagation: true` while keeping the outer store intact for reads (e.g. `implicitTags`). Previously, nested `"use cache"` calls during SWR regen would pass empty `softTags` to `cacheHandler.get()` because the store was `undefined`. Now they correctly receive the page's implicit tags. With `| undefined` removed from `PublicCacheContext.outerWorkUnitStore`, all unnecessary existence checks (`?.`, `if (x)`, `case undefined:`, ternaries) were cleaned up throughout `use-cache-wrapper.ts`, and the `shouldForceRevalidate` and `shouldDiscardCacheEntry` signatures were tightened from `WorkUnitStore | undefined` to `WorkUnitStore`. Note that module-scope `"use cache"` usage (calling a cached function at the top level of a module) was already prevented before this change by the `WorkStore` check, which throws if no `WorkStore` is present. The new `WorkUnitStore` check is an additional guard that comes after it. This also unblocks a future change to support root params inside `"use cache"` with cache keying. Since the outer work unit store is now always defined, `rootParams` can be threaded from the outer store into the cache store. The only exception will be `UnstableCacheStore`, which doesn't carry `rootParams` and can't include them in its cache key. A new `use-cache-swr` test suite was added with a persistent cache handler that does not drop entries at revalidate time (unlike the default in-memory handler), which enables testing the server-side SWR code path that was previously uncovered. > [!TIP] > Best reviewed with hidden whitespace changes. --- packages/next/errors.json | 3 +- .../src/server/use-cache/use-cache-wrapper.ts | 293 +++++++++--------- test/e2e/app-dir/use-cache-swr/app/layout.tsx | 12 + test/e2e/app-dir/use-cache-swr/app/page.tsx | 31 ++ test/e2e/app-dir/use-cache-swr/handler.js | 81 +++++ test/e2e/app-dir/use-cache-swr/next.config.js | 11 + .../use-cache-swr/use-cache-swr.test.ts | 81 +++++ 7 files changed, 361 insertions(+), 151 deletions(-) create mode 100644 test/e2e/app-dir/use-cache-swr/app/layout.tsx create mode 100644 test/e2e/app-dir/use-cache-swr/app/page.tsx create mode 100644 test/e2e/app-dir/use-cache-swr/handler.js create mode 100644 test/e2e/app-dir/use-cache-swr/next.config.js create mode 100644 test/e2e/app-dir/use-cache-swr/use-cache-swr.test.ts diff --git a/packages/next/errors.json b/packages/next/errors.json index c13b1ab49276ef..0d5837e008f04b 100644 --- a/packages/next/errors.json +++ b/packages/next/errors.json @@ -1132,5 +1132,6 @@ "1131": "createServerParamsForRoute should not be called inside generateStaticParams.", "1132": "Route %s used \\`draftMode()\\` inside \\`generateStaticParams\\`. This is not supported because \\`generateStaticParams\\` runs at build time without an HTTP request. Read more: https://nextjs.org/docs/messages/next-dynamic-api-wrong-context", "1133": "createSearchParamsFromClient should not be called inside generateStaticParams.", - "1134": "Route %s used \\`headers()\\` inside \\`generateStaticParams\\`. This is not supported because \\`generateStaticParams\\` runs at build time without an HTTP request. Read more: https://nextjs.org/docs/messages/next-dynamic-api-wrong-context" + "1134": "Route %s used \\`headers()\\` inside \\`generateStaticParams\\`. This is not supported because \\`generateStaticParams\\` runs at build time without an HTTP request. Read more: https://nextjs.org/docs/messages/next-dynamic-api-wrong-context", + "1135": "\"use cache\" cannot be used outside of App Router. Expected a 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 affed6994c4a63..3515a0c036b8b6 100644 --- a/packages/next/src/server/use-cache/use-cache-wrapper.ts +++ b/packages/next/src/server/use-cache/use-cache-wrapper.ts @@ -79,14 +79,17 @@ interface PrivateCacheContext { | RequestStore | PrivateUseCacheStore | PrerenderStoreModernRuntime + readonly skipPropagation: boolean } interface PublicCacheContext { readonly kind: 'public' // TODO: We should probably forbid nesting "use cache" inside unstable_cache. - readonly outerWorkUnitStore: - | Exclude - | undefined + readonly outerWorkUnitStore: Exclude< + WorkUnitStore, + PrerenderStoreModernClient | ValidationStoreClient + > + readonly skipPropagation: boolean } type CacheContext = PrivateCacheContext | PublicCacheContext @@ -234,29 +237,27 @@ function createUseCacheStore( let useCacheOrRequestStore: RequestStore | UseCacheStore | undefined const outerWorkUnitStore = cacheContext.outerWorkUnitStore - if (outerWorkUnitStore) { - switch (outerWorkUnitStore?.type) { - case 'cache': - case 'private-cache': - case 'request': - useCacheOrRequestStore = outerWorkUnitStore - break - case 'prerender-runtime': - case 'prerender': - case 'prerender-ppr': - case 'prerender-legacy': - case 'unstable-cache': - case 'generate-static-params': - break - default: - outerWorkUnitStore satisfies never - } + switch (outerWorkUnitStore.type) { + case 'cache': + case 'private-cache': + case 'request': + useCacheOrRequestStore = outerWorkUnitStore + break + case 'prerender-runtime': + case 'prerender': + case 'prerender-ppr': + case 'prerender-legacy': + case 'unstable-cache': + case 'generate-static-params': + break + default: + outerWorkUnitStore satisfies never } return { type: 'cache', phase: 'render', - implicitTags: outerWorkUnitStore?.implicitTags, + implicitTags: outerWorkUnitStore.implicitTags, revalidate: defaultCacheLife.revalidate, expire: defaultCacheLife.expire, stale: defaultCacheLife.stale, @@ -264,15 +265,15 @@ function createUseCacheStore( explicitExpire: undefined, explicitStale: undefined, tags: null, - hmrRefreshHash: - outerWorkUnitStore && getHmrRefreshHash(outerWorkUnitStore), + hmrRefreshHash: getHmrRefreshHash(outerWorkUnitStore), isHmrRefresh: useCacheOrRequestStore?.isHmrRefresh ?? false, serverComponentsHmrCache: useCacheOrRequestStore?.serverComponentsHmrCache, forceRevalidate: shouldForceRevalidate(workStore, outerWorkUnitStore), - draftMode: - outerWorkUnitStore && - getDraftModeProviderForCacheScope(workStore, outerWorkUnitStore), + draftMode: getDraftModeProviderForCacheScope( + workStore, + outerWorkUnitStore + ), } } } @@ -387,7 +388,7 @@ function propagateCacheLifeAndTags( cacheContext.outerWorkUnitStore satisfies never } } else { - switch (cacheContext.outerWorkUnitStore?.type) { + switch (cacheContext.outerWorkUnitStore.type) { case 'cache': case 'private-cache': case 'prerender': @@ -407,7 +408,6 @@ function propagateCacheLifeAndTags( break case 'unstable-cache': case 'generate-static-params': - case undefined: break default: cacheContext.outerWorkUnitStore satisfies never @@ -509,7 +509,7 @@ async function collectResult( tags: collectedTags === null ? [] : collectedTags, } - if (cacheContext.outerWorkUnitStore) { + if (!cacheContext.skipPropagation) { const outerWorkUnitStore = cacheContext.outerWorkUnitStore // Propagate cache life & tags to the outer context if appropriate. @@ -603,38 +603,35 @@ async function generateCacheEntryImpl( yield entry } - if (outerWorkUnitStore) { - switch (outerWorkUnitStore.type) { - case 'prerender-runtime': - case 'prerender': - // The encoded arguments might contain hanging promises. In - // this case we don't want to reject with "Error: Connection - // closed.", so we intentionally keep the iterable alive. - // This is similar to the halting trick that we do while - // rendering. - await new Promise((resolve) => { - if (outerWorkUnitStore.renderSignal.aborted) { - resolve() - } else { - outerWorkUnitStore.renderSignal.addEventListener( - 'abort', - () => resolve(), - { once: true } - ) - } - }) - break - case 'prerender-ppr': - case 'prerender-legacy': - case 'request': - case 'cache': - case 'private-cache': - case 'unstable-cache': - case 'generate-static-params': - break - default: - outerWorkUnitStore satisfies never - } + switch (outerWorkUnitStore.type) { + case 'prerender-runtime': + case 'prerender': + // The encoded arguments might contain hanging promises. In + // this case we don't want to reject with "Error: Connection + // closed.", so we intentionally keep the iterable alive. This + // is similar to the halting trick that we do while rendering. + await new Promise((resolve) => { + if (outerWorkUnitStore.renderSignal.aborted) { + resolve() + } else { + outerWorkUnitStore.renderSignal.addEventListener( + 'abort', + () => resolve(), + { once: true } + ) + } + }) + break + case 'prerender-ppr': + case 'prerender-legacy': + case 'request': + case 'cache': + case 'private-cache': + case 'unstable-cache': + case 'generate-static-params': + break + default: + outerWorkUnitStore satisfies never } }, }, @@ -677,7 +674,7 @@ async function generateCacheEntryImpl( let stream: ReadableStream - switch (outerWorkUnitStore?.type) { + switch (outerWorkUnitStore.type) { case 'prerender-runtime': case 'prerender': const timeoutAbortController = new AbortController() @@ -772,7 +769,6 @@ async function generateCacheEntryImpl( case 'private-cache': case 'unstable-cache': case 'generate-static-params': - case undefined: stream = renderToReadableStream( resultPromise, clientReferenceManifest.clientModules, @@ -951,6 +947,12 @@ export async function cache( } const workUnitStore = workUnitAsyncStorage.getStore() + if (workUnitStore === undefined) { + throw new InvariantError( + '"use cache" cannot be used outside of App Router. Expected a WorkUnitStore.' + ) + } + const name = originalFn.name let fn = originalFn let cacheContext: CacheContext @@ -958,7 +960,7 @@ export async function cache( if (isPrivate) { const expression = '"use cache: private"' - switch (workUnitStore?.type) { + switch (workUnitStore.type) { // "use cache: private" is dynamic in prerendering contexts. case 'prerender': return makeHangingPromise( @@ -1007,10 +1009,10 @@ export async function cache( cacheContext = { kind: 'private', outerWorkUnitStore: workUnitStore, + skipPropagation: false, } break case 'generate-static-params': - case undefined: throw wrapAsInvalidDynamicUsageError( new Error( // TODO: Add a link to an error documentation page when we have one. @@ -1025,7 +1027,7 @@ export async function cache( throw new InvariantError(`Unexpected work unit store.`) } } else { - switch (workUnitStore?.type) { + switch (workUnitStore.type) { case 'prerender-client': case 'validation-client': const expression = '"use cache"' @@ -1043,10 +1045,10 @@ export async function cache( // unstable_cache. (fallthrough) case 'unstable-cache': case 'generate-static-params': - case undefined: cacheContext = { kind: 'public', outerWorkUnitStore: workUnitStore, + skipPropagation: false, } break default: @@ -1072,11 +1074,9 @@ export async function cache( // components have been edited. This is a very coarse approach. But it's // also only a temporary solution until Action IDs are unique per // implementation. Remove this once Action IDs hash the implementation. - const hmrRefreshHash = workUnitStore && getHmrRefreshHash(workUnitStore) + const hmrRefreshHash = getHmrRefreshHash(workUnitStore) - const hangingInputAbortSignal = workUnitStore - ? createHangingInputAbortSignal(workUnitStore) - : undefined + const hangingInputAbortSignal = createHangingInputAbortSignal(workUnitStore) if (cacheContext.kind === 'private') { const { outerWorkUnitStore } = cacheContext @@ -1248,7 +1248,7 @@ export async function cache( let encodedCacheKeyParts: FormData | string - switch (workUnitStore?.type) { + switch (workUnitStore.type) { case 'prerender-runtime': // We're currently only using `dynamicAccessAsyncStorage` for params, // which are always available in a runtime prerender, so they will never hang, @@ -1312,15 +1312,13 @@ export async function cache( let stream: undefined | ReadableStream = undefined // Get an immutable and mutable versions of the resume data cache. - const prerenderResumeDataCache = workUnitStore - ? getPrerenderResumeDataCache(workUnitStore) - : null - const renderResumeDataCache = workUnitStore - ? getRenderResumeDataCache(workUnitStore) - : null + const prerenderResumeDataCache = getPrerenderResumeDataCache(workUnitStore) + const renderResumeDataCache = getRenderResumeDataCache(workUnitStore) + + const implicitTags = workUnitStore.implicitTags?.tags ?? [] if (renderResumeDataCache) { - const cacheSignal = workUnitStore ? getCacheSignal(workUnitStore) : null + const cacheSignal = getCacheSignal(workUnitStore) if (cacheSignal) { cacheSignal.beginRead() @@ -1333,7 +1331,6 @@ export async function cache( // When a server action calls updateTag(), the re-render should see fresh data // instead of stale RDC data. if (existingResult !== undefined) { - const implicitTags = workUnitStore?.implicitTags?.tags ?? [] if ( existingResult.entry.tags.some((tag) => isRecentlyRevalidatedTag(tag, workStore) @@ -1348,7 +1345,7 @@ export async function cache( } } - if (workUnitStore !== undefined && existingResult !== undefined) { + if (existingResult !== undefined) { if ( existingResult.entry.revalidate === 0 || existingResult.entry.expire < DYNAMIC_EXPIRE @@ -1545,50 +1542,47 @@ export async function cache( cacheSignal.endRead() } - if (workUnitStore) { - switch (workUnitStore.type) { - case 'prerender': - // If `allowEmptyStaticShell` is true, and thus a prefilled - // resume data cache was provided, then a cache miss means that - // params were part of the cache key. In this case, we can make - // this cache function a dynamic hole in the shell (or produce - // an empty shell if there's no parent suspense boundary). - // Currently, this also includes layouts and pages that don't - // read params, which will be improved when we implement - // NAR-136. Otherwise, we assume that if params are passed - // explicitly into a "use cache" function, that the params are - // also accessed. This allows us to abort early, and treat the - // function as dynamic, instead of waiting for the timeout to be - // reached. Compared to the instrumentation-based params bailout - // we do here, this also covers the case where params are - // transformed with an async function, before being passed into - // the "use cache" function, which escapes the instrumentation. - if (workUnitStore.allowEmptyStaticShell) { - return makeHangingPromise( - workUnitStore.renderSignal, - workStore.route, - 'dynamic "use cache"' - ) - } - break - case 'prerender-runtime': - case 'prerender-ppr': - case 'prerender-legacy': - case 'request': - case 'cache': - case 'private-cache': - case 'unstable-cache': - case 'generate-static-params': - break - default: - workUnitStore satisfies never - } + switch (workUnitStore.type) { + case 'prerender': + // If `allowEmptyStaticShell` is true, and thus a prefilled resume + // data cache was provided, then a cache miss means that params were + // part of the cache key. In this case, we can make this cache + // function a dynamic hole in the shell (or produce an empty shell if + // there's no parent suspense boundary). Currently, this also includes + // layouts and pages that don't read params, which will be improved + // when we implement NAR-136. Otherwise, we assume that if params are + // passed explicitly into a "use cache" function, that the params are + // also accessed. This allows us to abort early, and treat the + // function as dynamic, instead of waiting for the timeout to be + // reached. Compared to the instrumentation-based params bailout we do + // here, this also covers the case where params are transformed with + // an async function, before being passed into the "use cache" + // function, which escapes the instrumentation. + if (workUnitStore.allowEmptyStaticShell) { + return makeHangingPromise( + workUnitStore.renderSignal, + workStore.route, + 'dynamic "use cache"' + ) + } + break + case 'prerender-runtime': + case 'prerender-ppr': + case 'prerender-legacy': + case 'request': + case 'cache': + case 'private-cache': + case 'unstable-cache': + case 'generate-static-params': + break + default: + workUnitStore satisfies never } } } if (stream === undefined) { - const cacheSignal = workUnitStore ? getCacheSignal(workUnitStore) : null + const cacheSignal = getCacheSignal(workUnitStore) if (cacheSignal) { // Either the cache handler or the generation can be using I/O at this point. // We need to track when they start and when they complete. @@ -1605,17 +1599,13 @@ export async function cache( // We ignore existing cache entries when force revalidating. if (cacheHandler && !shouldForceRevalidate(workStore, workUnitStore)) { - entry = await cacheHandler.get( - serializedCacheKey, - workUnitStore?.implicitTags?.tags ?? [] - ) + entry = await cacheHandler.get(serializedCacheKey, implicitTags) } if (entry) { - const implicitTags = workUnitStore?.implicitTags?.tags ?? [] let implicitTagsExpiration = 0 - if (workUnitStore?.implicitTags) { + if (workUnitStore.implicitTags) { const lazyExpiration = workUnitStore.implicitTags.expirationsByCacheKind.get(kind) @@ -1651,7 +1641,6 @@ export async function cache( const currentTime = performance.timeOrigin + performance.now() if ( - workUnitStore !== undefined && entry !== undefined && (entry.revalidate === 0 || entry.expire < DYNAMIC_EXPIRE) ) { @@ -1839,8 +1828,14 @@ export async function cache( // revalidated entry. const result = await generateCacheEntry( workStore, - // This is not running within the context of this unit. - { kind: cacheContext.kind, outerWorkUnitStore: undefined }, + // The background revalidation preserves the outer store for reading + // (e.g. implicitTags) but skips propagation of cache life and tags + // back to the outer scope. + { + kind: cacheContext.kind, + outerWorkUnitStore: cacheContext.outerWorkUnitStore, + skipPropagation: true, + }, clientReferenceManifest, encodedCacheKeyParts, fn, @@ -1941,13 +1936,13 @@ function isLayoutSegmentFunction( function shouldForceRevalidate( workStore: WorkStore, - workUnitStore: WorkUnitStore | undefined + workUnitStore: WorkUnitStore ): boolean { if (workStore.isOnDemandRevalidate || workStore.isDraftMode) { return true } - if (process.env.__NEXT_DEV_SERVER && workUnitStore) { + if (process.env.__NEXT_DEV_SERVER) { switch (workUnitStore.type) { case 'request': return workUnitStore.headers.get('cache-control') === 'no-cache' @@ -1974,7 +1969,7 @@ function shouldForceRevalidate( function shouldDiscardCacheEntry( entry: CacheEntry, workStore: WorkStore, - workUnitStore: WorkUnitStore | undefined, + workUnitStore: WorkUnitStore, implicitTags: string[], implicitTagsExpiration: number ): boolean { @@ -1996,24 +1991,22 @@ function shouldDiscardCacheEntry( // the affected cache entries, and we don't want to discard those again during // the prerender validation. During build-time prerendering, there will never // be any pending revalidated tags. - if (workUnitStore) { - switch (workUnitStore.type) { - case 'prerender': - return false - case 'prerender-runtime': - case 'prerender-client': - case 'validation-client': - case 'prerender-ppr': - case 'prerender-legacy': - case 'request': - case 'cache': - case 'private-cache': - case 'unstable-cache': - case 'generate-static-params': - break - default: - workUnitStore satisfies never - } + switch (workUnitStore.type) { + case 'prerender': + return false + case 'prerender-runtime': + case 'prerender-client': + case 'validation-client': + case 'prerender-ppr': + case 'prerender-legacy': + case 'request': + case 'cache': + case 'private-cache': + case 'unstable-cache': + case 'generate-static-params': + break + default: + workUnitStore satisfies never } // If the cache entry contains revalidated tags that the cache handler might diff --git a/test/e2e/app-dir/use-cache-swr/app/layout.tsx b/test/e2e/app-dir/use-cache-swr/app/layout.tsx new file mode 100644 index 00000000000000..e4adb4782953e2 --- /dev/null +++ b/test/e2e/app-dir/use-cache-swr/app/layout.tsx @@ -0,0 +1,12 @@ +import { Suspense } from 'react' + +export default function RootLayout({ children }) { + return ( + + + + {children} + + + ) +} diff --git a/test/e2e/app-dir/use-cache-swr/app/page.tsx b/test/e2e/app-dir/use-cache-swr/app/page.tsx new file mode 100644 index 00000000000000..71dc7c65515fbd --- /dev/null +++ b/test/e2e/app-dir/use-cache-swr/app/page.tsx @@ -0,0 +1,31 @@ +import { cacheLife } from 'next/cache' +import { connection } from 'next/server' + +async function getInnerData(id: string) { + 'use cache' + + return new Date().toISOString() +} + +async function getOuterData(id: string) { + 'use cache' + + cacheLife({ revalidate: 5 }) + + const innerData = await getInnerData('inner') + + return { outer: new Date().toISOString(), inner: innerData } +} + +export default async function Page() { + await connection() + + const data = await getOuterData('outer') + + return ( +
+

{data.outer}

+

{data.inner}

+
+ ) +} diff --git a/test/e2e/app-dir/use-cache-swr/handler.js b/test/e2e/app-dir/use-cache-swr/handler.js new file mode 100644 index 00000000000000..9b54b4e6856a74 --- /dev/null +++ b/test/e2e/app-dir/use-cache-swr/handler.js @@ -0,0 +1,81 @@ +// @ts-check + +/** + * A persistent cache handler that does NOT drop entries at revalidate time. + * This allows the framework's SWR code path to trigger, unlike the default + * in-memory handler which expires entries at revalidate time. + */ + +/** @type {Map} */ +const store = new Map() + +/** @type {Map>} */ +const pendingSets = new Map() + +/** + * @type {import('next/dist/server/lib/cache-handlers/types').CacheHandler} + */ +const cacheHandler = { + async get(cacheKey, softTags) { + const pendingPromise = pendingSets.get(cacheKey) + if (pendingPromise) { + await pendingPromise + } + + const entry = store.get(cacheKey) + if (!entry) { + console.log('PersistentCacheHandler::get', cacheKey, softTags, '-> miss') + return undefined + } + + const [returnStream, savedStream] = entry.value.tee() + entry.value = savedStream + + console.log( + 'PersistentCacheHandler::get', + cacheKey, + softTags, + '-> hit, revalidate:', + entry.revalidate + ) + + return { ...entry, value: returnStream } + }, + + async set(cacheKey, pendingEntry) { + /** @type {() => void} */ + let resolvePending = () => {} + const pendingPromise = new Promise((resolve) => { + resolvePending = /** @type {() => void} */ (resolve) + }) + pendingSets.set(cacheKey, pendingPromise) + + try { + const entry = await pendingEntry + const [value, clonedValue] = entry.value.tee() + entry.value = value + + // Consume the cloned stream to ensure the entry is fully resolved. + const reader = clonedValue.getReader() + while (!(await reader.read()).done) {} + + store.set(cacheKey, entry) + console.log('PersistentCacheHandler::set', cacheKey) + } catch (err) { + console.log('PersistentCacheHandler::set', cacheKey, 'failed', err) + } finally { + resolvePending() + pendingSets.delete(cacheKey) + } + }, + + async refreshTags() {}, + + async getExpiration(_tags) { + return Infinity + }, + + async updateTags(_tags) {}, +} + +module.exports = cacheHandler diff --git a/test/e2e/app-dir/use-cache-swr/next.config.js b/test/e2e/app-dir/use-cache-swr/next.config.js new file mode 100644 index 00000000000000..e4334c1a7dc7c5 --- /dev/null +++ b/test/e2e/app-dir/use-cache-swr/next.config.js @@ -0,0 +1,11 @@ +/** + * @type {import('next').NextConfig} + */ +const nextConfig = { + cacheComponents: true, + cacheHandlers: { + default: require.resolve('./handler.js'), + }, +} + +module.exports = nextConfig diff --git a/test/e2e/app-dir/use-cache-swr/use-cache-swr.test.ts b/test/e2e/app-dir/use-cache-swr/use-cache-swr.test.ts new file mode 100644 index 00000000000000..13842c25fb0639 --- /dev/null +++ b/test/e2e/app-dir/use-cache-swr/use-cache-swr.test.ts @@ -0,0 +1,81 @@ +import { nextTestSetup } from 'e2e-utils' +import { retry } from 'next-test-utils' + +describe('use-cache-swr', () => { + const { next, skipped } = nextTestSetup({ + files: __dirname, + skipDeployment: true, + }) + + if (skipped) return + + let outputIndex: number + + beforeEach(() => { + outputIndex = next.cliOutput.length + }) + + it('should serve stale data and then pre-warmed data on subsequent request', async () => { + const browser = await next.browser('/') + const initialOuter = await browser.elementById('outer-data').text() + expect(initialOuter).toBeDateString() + + // Wait for the outer cache to go stale (revalidate: 5). + await new Promise((resolve) => setTimeout(resolve, 6000)) + + // This request should trigger SWR: the handler returns the stale entry, + // the framework serves it to the client, and kicks off a background regen. + await browser.refresh() + const afterStale = await browser.elementById('outer-data').text() + + // The stale data should be the same as the initial data. + expect(afterStale).toBe(initialOuter) + + // Wait for the background regen to complete by polling for its set log. + await retry(() => { + const regenOutput = next.cliOutput.slice(outputIndex) + expect(regenOutput).toMatch(/PersistentCacheHandler::set.*"outer"/) + }) + + // Reset output index to capture only the next request's handler logs. + outputIndex = next.cliOutput.length + + // Refresh again. The pre-warmed entry from the SWR regen should be served. + await browser.refresh() + const afterRegen = await browser.elementById('outer-data').text() + + // The data should now be fresh (different from the stale data). + expect(afterRegen).not.toBe(initialOuter) + + // Verify this was served from the pre-warmed cache (get hit, no set). + const cliOutput = next.cliOutput.slice(outputIndex) + expect(cliOutput).toMatch(/PersistentCacheHandler::get.*"outer".*-> hit/) + expect(cliOutput).not.toMatch(/PersistentCacheHandler::set.*"outer"/) + }) + + it('should pass implicit tags to cache handler get() for nested caches during SWR', async () => { + const browser = await next.browser('/') + await browser.elementById('outer-data').text() + + // Wait for the outer cache to go stale (revalidate: 5). + await new Promise((resolve) => setTimeout(resolve, 6000)) + + // Reset output index to capture only the SWR-related logs. + outputIndex = next.cliOutput.length + + // This triggers SWR: stale outer is served, background regen starts. + // During regen, the outer fn re-executes and calls the inner "use cache". + // The inner cache's get() should receive the page's implicit tags. + await browser.refresh() + + // Wait for the background regen to complete. + await new Promise((resolve) => setTimeout(resolve, 1000)) + + const cliOutput = next.cliOutput.slice(outputIndex) + + // The inner cache's get() during SWR regen should include the page's + // implicit tags (softTags), not an empty array. We identify the inner + // cache by its "inner" sentinel argument in the key. + expect(cliOutput).toMatch(/PersistentCacheHandler::get.*"inner".*_N_T_\//) + }) +})