From 9b2e71db6f3118d04580e34b175e5ba816b3e831 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Thu, 17 Apr 2025 15:33:57 +0200 Subject: [PATCH 01/57] Make `"use cache"` function that accesses cookies dynamic --- .../work-unit-async-storage.external.ts | 45 ++- .../src/server/dynamic-rendering-utils.ts | 21 +- packages/next/src/server/request/cookies.ts | 356 +++++++++++------- .../next/src/server/request/draft-mode.ts | 1 + packages/next/src/server/request/headers.ts | 191 +++------- .../next/src/server/request/root-params.ts | 1 + .../next/src/server/request/search-params.ts | 131 ++++--- .../src/server/use-cache/use-cache-wrapper.ts | 151 +++++--- .../fixtures/dynamic-io/app/layout.tsx | 21 ++ .../fixtures/dynamic-io/app/page.tsx | 11 + .../fixtures/dynamic-io/next.config.js | 11 + .../use-cache-cookies.test.ts | 48 +++ .../app-dir/use-cache/app/draft-mode/page.tsx | 18 +- .../e2e/app-dir/use-cache/app/errors/page.tsx | 18 +- test/e2e/app-dir/use-cache/app/errors/util.ts | 10 +- test/e2e/app-dir/use-cache/use-cache.test.ts | 18 +- test/lib/browsers/playwright.ts | 14 +- test/lib/next-webdriver.ts | 13 +- 18 files changed, 625 insertions(+), 454 deletions(-) create mode 100644 test/e2e/app-dir/use-cache-cookies/fixtures/dynamic-io/app/layout.tsx create mode 100644 test/e2e/app-dir/use-cache-cookies/fixtures/dynamic-io/app/page.tsx create mode 100644 test/e2e/app-dir/use-cache-cookies/fixtures/dynamic-io/next.config.js create mode 100644 test/e2e/app-dir/use-cache-cookies/use-cache-cookies.test.ts 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 db8fd037a5c5..9d533e2ded61 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 @@ -163,10 +163,18 @@ export interface CommonCacheStore * from which implicit tags could be inherited. */ readonly implicitTags: ImplicitTags | undefined + /** + * Draft mode is only available if the outer work unit store is a request + * store and draft mode is enabled. + */ + readonly draftMode: DraftModeProvider | undefined } -export interface UseCacheStore extends CommonCacheStore { - type: 'cache' +export interface CommonUseCacheStore extends CommonCacheStore { + /** + * The cache kind, e.g. `"remote"` for `"use cache: remote"`. + */ + readonly kind: string // Collected revalidate times and tags for this cache entry during the cache render. revalidate: number // implicit revalidate time from inner caches / fetches expire: number // server expiration time @@ -179,23 +187,34 @@ export interface UseCacheStore extends CommonCacheStore { readonly isHmrRefresh: boolean readonly serverComponentsHmrCache: ServerComponentsHmrCache | undefined readonly forceRevalidate: boolean - // Draft mode is only available if the outer work unit store is a request - // store and draft mode is enabled. - readonly draftMode: DraftModeProvider | undefined } +export interface UseCacheStore extends CommonUseCacheStore { + readonly type: 'cache' + readonly cookies: (() => Promise) | undefined +} + +// export interface UseCacheWithCookiesStore extends CommonUseCacheStore { +// readonly type: 'cache-with-cookies' +// } + export interface UnstableCacheStore extends CommonCacheStore { - type: 'unstable-cache' - // Draft mode is only available if the outer work unit store is a request - // store and draft mode is enabled. - readonly draftMode: DraftModeProvider | undefined + readonly type: 'unstable-cache' } /** - * The Cache store is for tracking information inside a "use cache" or unstable_cache context. - * Inside this context we should never expose any request or page specific information. + * The Cache store is for tracking information inside a "use cache" or + * unstable_cache context. A cache store shadows an outer request store (if + * present) as a work unit, so that we never accidentally expose any request or + * page specific information to cache functions, unless it's explicitly desired. + * For those exceptions, the data is copied over from the request store to the + * cache store, instead of generally making the request store available to cache + * functions. */ -export type CacheStore = UseCacheStore | UnstableCacheStore +export type CacheStore = + | UseCacheStore + // | UseCacheWithCookiesStore + | UnstableCacheStore export type WorkUnitStore = RequestStore | CacheStore | PrerenderStore @@ -225,6 +244,7 @@ export function getExpectedRequestStore( ) case 'cache': + // case 'cache-with-cookies': throw new Error( `\`${callingExpression}\` cannot be called inside "use cache". Call it outside and pass an argument instead. Read more: https://nextjs.org/docs/messages/next-request-in-use-cache` ) @@ -265,6 +285,7 @@ export function getRenderResumeDataCache( if ( workUnitStore.type !== 'prerender-legacy' && workUnitStore.type !== 'cache' && + // workUnitStore.type !== 'cache-with-cookies' && workUnitStore.type !== 'unstable-cache' ) { if (workUnitStore.type === 'request') { diff --git a/packages/next/src/server/dynamic-rendering-utils.ts b/packages/next/src/server/dynamic-rendering-utils.ts index 5f76fa1a51df..e21820c5c946 100644 --- a/packages/next/src/server/dynamic-rendering-utils.ts +++ b/packages/next/src/server/dynamic-rendering-utils.ts @@ -1,3 +1,5 @@ +import { ReflectAdapter } from './web/spec-extension/adapters/reflect' + export function isHangingPromiseRejectionError( err: unknown ): err is HangingPromiseRejectionError { @@ -32,7 +34,8 @@ const abortListenersBySignal = new WeakMap() */ export function makeHangingPromise( signal: AbortSignal, - expression: string + expression: string, + handler?: ProxyHandler> ): Promise { if (signal.aborted) { return Promise.reject(new HangingPromiseRejectionError(expression)) @@ -63,7 +66,21 @@ export function makeHangingPromise( // we attach a noop catch handler here to suppress this warning. If you actually await somewhere or construct // your own promise out of it you'll need to ensure you handle the error when it rejects. hangingPromise.catch(ignoreReject) - return hangingPromise + + return new Proxy(hangingPromise, { + ...handler, + get: function get(target, prop, receiver) { + if (prop === 'then' || prop === 'status') { + // TODO: Maybe annotate dynamic access here. + } + + if (handler?.get) { + return handler.get(target, prop, receiver) + } + + return ReflectAdapter.get(target, prop, receiver) + }, + }) } } diff --git a/packages/next/src/server/request/cookies.ts b/packages/next/src/server/request/cookies.ts index 524703b9f4d3..17ec192d5469 100644 --- a/packages/next/src/server/request/cookies.ts +++ b/packages/next/src/server/request/cookies.ts @@ -5,10 +5,14 @@ import { RequestCookiesAdapter, } from '../web/spec-extension/adapters/request-cookies' import { RequestCookies } from '../web/spec-extension/cookies' -import { workAsyncStorage } from '../app-render/work-async-storage.external' +import { + workAsyncStorage, + type WorkStore, +} from '../app-render/work-async-storage.external' import { workUnitAsyncStorage, type PrerenderStoreModern, + type WorkUnitStore, } from '../app-render/work-unit-async-storage.external' import { postponeWithTracking, @@ -23,6 +27,7 @@ import { makeHangingPromise } from '../dynamic-rendering-utils' import { createDedupedByCallsiteServerErrorLoggerDev } from '../create-deduped-by-callsite-server-error-logger' import { scheduleImmediate } from '../../lib/scheduler' import { isRequestAPICallableInsideAfter } from './utils' +import { ReflectAdapter } from '../web/spec-extension/adapters/reflect' /** * In this version of Next.js `cookies()` returns a Promise however you can still reference the properties of the underlying cookies object @@ -72,9 +77,16 @@ export function cookies(): Promise { } if (workUnitStore) { + // if (workUnitStore.type === 'cache-with-cookies') { + // return workUnitStore.cookies + // } else if (workUnitStore.type === 'cache') { if (workUnitStore.type === 'cache') { + if (workUnitStore.cookies) { + return workUnitStore.cookies() + } + throw new Error( - `Route ${workStore.route} used "cookies" inside "use 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/messages/next-request-in-use-cache` + `Route ${workStore.route} used "cookies" inside "use cache: ${workUnitStore.kind}". Accessing Dynamic data sources inside this kind of 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, or use the default "use cache" which does allow accessing cookies. See more info here: https://nextjs.org/docs/messages/next-request-in-use-cache` ) } else if (workUnitStore.type === 'unstable-cache') { throw new Error( @@ -157,6 +169,11 @@ const CachedCookies = new WeakMap< Promise >() +const CachedCookiesForUseCache = new WeakMap< + CacheLifetime, + Promise +>() + function makeDynamicallyTrackedExoticCookies( route: string, prerenderStore: PrerenderStoreModern @@ -166,154 +183,143 @@ function makeDynamicallyTrackedExoticCookies( return cachedPromise } + const abortAndThrow = (property: string) => { + const expression = `\`cookies()${property}\`` + const error = createCookiesAccessError(route, expression) + abortAndThrowOnSynchronousRequestDataAccess( + route, + expression, + error, + prerenderStore + ) + } + const promise = makeHangingPromise( prerenderStore.renderSignal, - '`cookies()`' + '`cookies()`', + { + get: function get(target, prop, receiver) { + switch (prop) { + case Symbol.iterator: + return function () { + abortAndThrow('[Symbol.iterator]') + } + case 'size': + abortAndThrow('.size') + break + case 'get': + case 'getAll': + case 'has': + return function () { + abortAndThrow( + arguments.length === 0 + ? `.${prop}()` + : `.${prop}(${describeNameArg(arguments[0])})` + ) + } + case 'set': + case 'delete': + return function () { + abortAndThrow( + arguments.length === 0 + ? `.${prop}()` + : arguments[0] + ? `.${prop}(${describeNameArg(arguments[0])}, ...)` + : `.${prop}(...)` + ) + } + case 'clear': + case 'toString': + return function () { + abortAndThrow(`.${prop}()`) + } + default: + return ReflectAdapter.get(target, prop, receiver) + } + }, + } ) CachedCookies.set(prerenderStore, promise) - Object.defineProperties(promise, { - [Symbol.iterator]: { - value: function () { - const expression = '`cookies()[Symbol.iterator]()`' - const error = createCookiesAccessError(route, expression) - abortAndThrowOnSynchronousRequestDataAccess( - route, - expression, - error, - prerenderStore - ) - }, - }, - size: { - get() { - const expression = '`cookies().size`' - const error = createCookiesAccessError(route, expression) - abortAndThrowOnSynchronousRequestDataAccess( - route, - expression, - error, - prerenderStore - ) - }, - }, - get: { - value: function get() { - let expression: string - if (arguments.length === 0) { - expression = '`cookies().get()`' - } else { - expression = `\`cookies().get(${describeNameArg(arguments[0])})\`` - } - const error = createCookiesAccessError(route, expression) - abortAndThrowOnSynchronousRequestDataAccess( - route, - expression, - error, - prerenderStore - ) - }, - }, - getAll: { - value: function getAll() { - let expression: string - if (arguments.length === 0) { - expression = '`cookies().getAll()`' - } else { - expression = `\`cookies().getAll(${describeNameArg(arguments[0])})\`` - } - const error = createCookiesAccessError(route, expression) - abortAndThrowOnSynchronousRequestDataAccess( - route, - expression, - error, - prerenderStore - ) - }, - }, - has: { - value: function has() { - let expression: string - if (arguments.length === 0) { - expression = '`cookies().has()`' - } else { - expression = `\`cookies().has(${describeNameArg(arguments[0])})\`` - } - const error = createCookiesAccessError(route, expression) - abortAndThrowOnSynchronousRequestDataAccess( - route, - expression, - error, - prerenderStore - ) - }, - }, - set: { - value: function set() { - let expression: string - if (arguments.length === 0) { - expression = '`cookies().set()`' - } else { - const arg = arguments[0] - if (arg) { - expression = `\`cookies().set(${describeNameArg(arg)}, ...)\`` - } else { - expression = '`cookies().set(...)`' - } + return promise +} + +function makeDynamicallyTrackedExoticCookiesForUseCache( + route: string, + prerenderStore: PrerenderStoreModern, + abortController: AbortController +): Promise { + const cachedPromise = CachedCookiesForUseCache.get(prerenderStore) + if (cachedPromise) { + return cachedPromise + } + + const abortAndThrow = (property: string) => { + const expression = `\`cookies()${property}\`` + const error = createCookiesAccessError(route, expression) + abortAndThrowOnSynchronousRequestDataAccess( + route, + expression, + error, + prerenderStore + ) + } + + const promise = makeHangingPromise( + prerenderStore.renderSignal, + '`cookies()`', + { + get: function get(target, prop, receiver) { + if (prop === 'then' || prop === 'status') { + abortController.abort( + new Error( + `Accessed \`cookies()\` in \`"use cache"\` during prerendering.` + ) + ) } - const error = createCookiesAccessError(route, expression) - abortAndThrowOnSynchronousRequestDataAccess( - route, - expression, - error, - prerenderStore - ) - }, - }, - delete: { - value: function () { - let expression: string - if (arguments.length === 0) { - expression = '`cookies().delete()`' - } else if (arguments.length === 1) { - expression = `\`cookies().delete(${describeNameArg(arguments[0])})\`` - } else { - expression = `\`cookies().delete(${describeNameArg(arguments[0])}, ...)\`` + + switch (prop) { + case Symbol.iterator: + return function () { + abortAndThrow('[Symbol.iterator]') + } + case 'size': + abortAndThrow('.size') + break + case 'get': + case 'getAll': + case 'has': + return function () { + abortAndThrow( + arguments.length === 0 + ? `.${prop}()` + : `.${prop}(${describeNameArg(arguments[0])})` + ) + } + case 'set': + case 'delete': + return function () { + abortAndThrow( + arguments.length === 0 + ? `.${prop}()` + : arguments[0] + ? `.${prop}(${describeNameArg(arguments[0])}, ...)` + : `.${prop}(...)` + ) + } + case 'clear': + case 'toString': + return function () { + abortAndThrow(`.${prop}()`) + } + default: + return ReflectAdapter.get(target, prop, receiver) } - const error = createCookiesAccessError(route, expression) - abortAndThrowOnSynchronousRequestDataAccess( - route, - expression, - error, - prerenderStore - ) }, - }, - clear: { - value: function clear() { - const expression = '`cookies().clear()`' - const error = createCookiesAccessError(route, expression) - abortAndThrowOnSynchronousRequestDataAccess( - route, - expression, - error, - prerenderStore - ) - }, - }, - toString: { - value: function toString() { - const expression = '`cookies().toString()`' - const error = createCookiesAccessError(route, expression) - abortAndThrowOnSynchronousRequestDataAccess( - route, - expression, - error, - prerenderStore - ) - }, - }, - } satisfies CookieExtensions) + } + ) + + CachedCookiesForUseCache.set(prerenderStore, promise) return promise } @@ -532,6 +538,72 @@ function makeUntrackedExoticCookiesWithDevWarnings( return promise } +export type UseCacheRenderContext = + | { + readonly type: 'prerender' + readonly workUnitStore: PrerenderStoreModern + readonly dynamicAccessAbortController: AbortController + } + | { + readonly type: 'other' + readonly workUnitStore: + | Exclude + | undefined + } + +export function createCookiesForUseCacheStore( + workStore: WorkStore, + renderContext: UseCacheRenderContext +): (() => Promise) | undefined { + if (renderContext.type === 'prerender') { + const { workUnitStore, dynamicAccessAbortController } = renderContext + + return () => + makeDynamicallyTrackedExoticCookiesForUseCache( + workStore.route, + workUnitStore, + dynamicAccessAbortController + ) + } + + if (!renderContext.workUnitStore) { + return undefined + } + + const { workUnitStore } = renderContext + + switch (workUnitStore.type) { + case 'prerender-ppr': + return () => + postponeWithTracking( + workStore.route, + 'cookies', + workUnitStore.dynamicTracking + ) + case 'prerender-legacy': + return () => + throwToInterruptStaticGeneration('cookies', workStore, workUnitStore) + // case 'cache-with-cookies': + // return workUnitStore.cookies + case 'cache': + return workUnitStore.cookies + case 'request': + if ( + process.env.NODE_ENV === 'development' && + !workStore.isPrefetchRequest + ) { + return () => + makeUntrackedExoticCookiesWithDevWarnings( + workUnitStore.cookies, + workStore.route + ) + } + return () => makeUntrackedExoticCookies(workUnitStore.cookies) + default: + return undefined + } +} + function describeNameArg(arg: unknown) { return typeof arg === 'object' && arg !== null && diff --git a/packages/next/src/server/request/draft-mode.ts b/packages/next/src/server/request/draft-mode.ts index 52e98fb59d39..22b7d34484ef 100644 --- a/packages/next/src/server/request/draft-mode.ts +++ b/packages/next/src/server/request/draft-mode.ts @@ -58,6 +58,7 @@ export function draftMode(): Promise { workStore ) + // case 'cache-with-cookies': case 'cache': case 'unstable-cache': // Inside of `"use cache"` or `unstable_cache`, draft mode is available if diff --git a/packages/next/src/server/request/headers.ts b/packages/next/src/server/request/headers.ts index 63dc0cee0e30..b05558551235 100644 --- a/packages/next/src/server/request/headers.ts +++ b/packages/next/src/server/request/headers.ts @@ -20,6 +20,7 @@ import { makeHangingPromise } from '../dynamic-rendering-utils' import { createDedupedByCallsiteServerErrorLoggerDev } from '../create-deduped-by-callsite-server-error-logger' import { scheduleImmediate } from '../../lib/scheduler' import { isRequestAPICallableInsideAfter } from './utils' +import { ReflectAdapter } from '../web/spec-extension/adapters/reflect' /** * In this version of Next.js `headers()` returns a Promise however you can still reference the properties of the underlying Headers instance @@ -147,147 +148,65 @@ function makeDynamicallyTrackedExoticHeaders( return cachedHeaders } + const abortAndThrow = (property: string) => { + const expression = `\`headers()${property}\`` + const error = createHeadersAccessError(route, expression) + abortAndThrowOnSynchronousRequestDataAccess( + route, + expression, + error, + prerenderStore + ) + } + const promise = makeHangingPromise( prerenderStore.renderSignal, - '`headers()`' + '`headers()`', + { + get: function get(target, prop, receiver) { + switch (prop) { + case Symbol.iterator: + return function () { + abortAndThrow('[Symbol.iterator]') + } + case 'delete': + case 'get': + case 'has': + return function () { + abortAndThrow( + arguments.length === 0 + ? `.${prop}()` + : `.${prop}(${describeNameArg(arguments[0])})` + ) + } + case 'append': + case 'set': + return function () { + abortAndThrow( + arguments[0] + ? `.${prop}(${describeNameArg(arguments[0])}, ...)` + : `.${prop}(...)` + ) + } + case 'forEach': + return function () { + abortAndThrow(`.${prop}(...)`) + } + case 'entries': + case 'getSetCookie': + case 'keys': + case 'values': + return function () { + abortAndThrow(`.${prop}()`) + } + default: + return ReflectAdapter.get(target, prop, receiver) + } + }, + } ) CachedHeaders.set(prerenderStore, promise) - Object.defineProperties(promise, { - append: { - value: function append() { - const expression = `\`headers().append(${describeNameArg(arguments[0])}, ...)\`` - const error = createHeadersAccessError(route, expression) - abortAndThrowOnSynchronousRequestDataAccess( - route, - expression, - error, - prerenderStore - ) - }, - }, - delete: { - value: function _delete() { - const expression = `\`headers().delete(${describeNameArg(arguments[0])})\`` - const error = createHeadersAccessError(route, expression) - abortAndThrowOnSynchronousRequestDataAccess( - route, - expression, - error, - prerenderStore - ) - }, - }, - get: { - value: function get() { - const expression = `\`headers().get(${describeNameArg(arguments[0])})\`` - const error = createHeadersAccessError(route, expression) - abortAndThrowOnSynchronousRequestDataAccess( - route, - expression, - error, - prerenderStore - ) - }, - }, - has: { - value: function has() { - const expression = `\`headers().has(${describeNameArg(arguments[0])})\`` - const error = createHeadersAccessError(route, expression) - abortAndThrowOnSynchronousRequestDataAccess( - route, - expression, - error, - prerenderStore - ) - }, - }, - set: { - value: function set() { - const expression = `\`headers().set(${describeNameArg(arguments[0])}, ...)\`` - const error = createHeadersAccessError(route, expression) - abortAndThrowOnSynchronousRequestDataAccess( - route, - expression, - error, - prerenderStore - ) - }, - }, - getSetCookie: { - value: function getSetCookie() { - const expression = '`headers().getSetCookie()`' - const error = createHeadersAccessError(route, expression) - abortAndThrowOnSynchronousRequestDataAccess( - route, - expression, - error, - prerenderStore - ) - }, - }, - forEach: { - value: function forEach() { - const expression = '`headers().forEach(...)`' - const error = createHeadersAccessError(route, expression) - abortAndThrowOnSynchronousRequestDataAccess( - route, - expression, - error, - prerenderStore - ) - }, - }, - keys: { - value: function keys() { - const expression = '`headers().keys()`' - const error = createHeadersAccessError(route, expression) - abortAndThrowOnSynchronousRequestDataAccess( - route, - expression, - error, - prerenderStore - ) - }, - }, - values: { - value: function values() { - const expression = '`headers().values()`' - const error = createHeadersAccessError(route, expression) - abortAndThrowOnSynchronousRequestDataAccess( - route, - expression, - error, - prerenderStore - ) - }, - }, - entries: { - value: function entries() { - const expression = '`headers().entries()`' - const error = createHeadersAccessError(route, expression) - abortAndThrowOnSynchronousRequestDataAccess( - route, - expression, - error, - prerenderStore - ) - }, - }, - [Symbol.iterator]: { - value: function () { - const expression = '`headers()[Symbol.iterator]()`' - const error = createHeadersAccessError(route, expression) - abortAndThrowOnSynchronousRequestDataAccess( - route, - expression, - error, - prerenderStore - ) - }, - }, - } satisfies HeadersExtensions) - return promise } diff --git a/packages/next/src/server/request/root-params.ts b/packages/next/src/server/request/root-params.ts index 3b6643159b67..ee8b32145c9d 100644 --- a/packages/next/src/server/request/root-params.ts +++ b/packages/next/src/server/request/root-params.ts @@ -39,6 +39,7 @@ export async function unstable_rootParams(): Promise { } switch (workUnitStore.type) { + // case 'cache-with-cookies': case 'unstable-cache': case 'cache': { throw new Error( diff --git a/packages/next/src/server/request/search-params.ts b/packages/next/src/server/request/search-params.ts index 70329c3e4665..f09fa750b923 100644 --- a/packages/next/src/server/request/search-params.ts +++ b/packages/next/src/server/request/search-params.ts @@ -186,60 +186,71 @@ function makeAbortingExoticSearchParams( const promise = makeHangingPromise( prerenderStore.renderSignal, - '`searchParams`' - ) - - const proxiedPromise = new Proxy(promise, { - get(target, prop, receiver) { - if (Object.hasOwn(promise, prop)) { - // The promise has this property directly. we must return it. - // We know it isn't a dynamic access because it can only be something - // that was previously written to the promise and thus not an underlying searchParam value - return ReflectAdapter.get(target, prop, receiver) - } - - switch (prop) { - case 'then': { - const expression = - '`await searchParams`, `searchParams.then`, or similar' - annotateDynamicAccess(expression, prerenderStore) - return ReflectAdapter.get(target, prop, receiver) - } - case 'status': { - const expression = - '`use(searchParams)`, `searchParams.status`, or similar' - annotateDynamicAccess(expression, prerenderStore) + '`searchParams`', + { + get(target, prop, receiver) { + if (Object.hasOwn(promise, prop)) { + // The promise has this property directly. we must return it. + // We know it isn't a dynamic access because it can only be something + // that was previously written to the promise and thus not an underlying searchParam value return ReflectAdapter.get(target, prop, receiver) } - default: { - if (typeof prop === 'string' && !wellKnownProperties.has(prop)) { - const expression = describeStringPropertyAccess( - 'searchParams', - prop - ) - const error = createSearchAccessError(route, expression) - abortAndThrowOnSynchronousRequestDataAccess( - route, - expression, - error, - prerenderStore - ) + switch (prop) { + case 'then': { + const expression = + '`await searchParams`, `searchParams.then`, or similar' + annotateDynamicAccess(expression, prerenderStore) + return ReflectAdapter.get(target, prop, receiver) + } + case 'status': { + const expression = + '`use(searchParams)`, `searchParams.status`, or similar' + annotateDynamicAccess(expression, prerenderStore) + return ReflectAdapter.get(target, prop, receiver) + } + + default: { + if (typeof prop === 'string' && !wellKnownProperties.has(prop)) { + const expression = describeStringPropertyAccess( + 'searchParams', + prop + ) + const error = createSearchAccessError(route, expression) + abortAndThrowOnSynchronousRequestDataAccess( + route, + expression, + error, + prerenderStore + ) + } + return ReflectAdapter.get(target, prop, receiver) } - return ReflectAdapter.get(target, prop, receiver) } - } - }, - has(target, prop) { - // We don't expect key checking to be used except for testing the existence of - // searchParams so we make all has tests trigger dynamic. this means that `promise.then` - // can resolve to the then function on the Promise prototype but 'then' in promise will assume - // you are testing whether the searchParams has a 'then' property. - if (typeof prop === 'string') { - const expression = describeHasCheckingStringProperty( - 'searchParams', - prop - ) + }, + has(target, prop) { + // We don't expect key checking to be used except for testing the existence of + // searchParams so we make all has tests trigger dynamic. this means that `promise.then` + // can resolve to the then function on the Promise prototype but 'then' in promise will assume + // you are testing whether the searchParams has a 'then' property. + if (typeof prop === 'string') { + const expression = describeHasCheckingStringProperty( + 'searchParams', + prop + ) + const error = createSearchAccessError(route, expression) + abortAndThrowOnSynchronousRequestDataAccess( + route, + expression, + error, + prerenderStore + ) + } + return ReflectAdapter.has(target, prop) + }, + ownKeys() { + const expression = + '`{...searchParams}`, `Object.keys(searchParams)`, or similar' const error = createSearchAccessError(route, expression) abortAndThrowOnSynchronousRequestDataAccess( route, @@ -247,24 +258,12 @@ function makeAbortingExoticSearchParams( error, prerenderStore ) - } - return ReflectAdapter.has(target, prop) - }, - ownKeys() { - const expression = - '`{...searchParams}`, `Object.keys(searchParams)`, or similar' - const error = createSearchAccessError(route, expression) - abortAndThrowOnSynchronousRequestDataAccess( - route, - expression, - error, - prerenderStore - ) - }, - }) + }, + } + ) - CachedSearchParams.set(prerenderStore, proxiedPromise) - return proxiedPromise + CachedSearchParams.set(prerenderStore, promise) + return promise } function makeErroringExoticSearchParams( 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 ad2fde25292f..0495b97b2b15 100644 --- a/packages/next/src/server/use-cache/use-cache-wrapper.ts +++ b/packages/next/src/server/use-cache/use-cache-wrapper.ts @@ -17,6 +17,7 @@ import { unstable_prerender as prerender } from 'react-server-dom-webpack/static import type { WorkStore } from '../app-render/work-async-storage.external' import { workAsyncStorage } from '../app-render/work-async-storage.external' import type { + CommonUseCacheStore, UseCacheStore, WorkUnitStore, } from '../app-render/work-unit-async-storage.external' @@ -52,6 +53,10 @@ import { import type { Params } from '../request/params' import React from 'react' import { createLazyResult, isResolvedLazyResult } from '../lib/lazy-result' +import { + createCookiesForUseCacheStore, + type UseCacheRenderContext, +} from '../request/cookies' type CacheKeyParts = | [buildId: string, id: string, args: unknown[]] @@ -71,12 +76,13 @@ const debug = process.env.NEXT_PRIVATE_DEBUG_CACHE function generateCacheEntry( workStore: WorkStore, - outerWorkUnitStore: WorkUnitStore | undefined, + renderContext: UseCacheRenderContext, + kind: string, clientReferenceManifest: DeepReadonly, encodedArguments: FormData | string, fn: (...args: unknown[]) => Promise, timeoutError: UseCacheTimeoutError -): Promise<[ReadableStream, Promise]> { +) { // We need to run this inside a clean AsyncLocalStorage snapshot so that the cache // generation cannot read anything from the context we're currently executing which // might include request specific things like cookies() inside a React.cache(). @@ -85,7 +91,8 @@ function generateCacheEntry( return workStore.runInCleanSnapshot( generateCacheEntryWithRestoredWorkStore, workStore, - outerWorkUnitStore, + renderContext, + kind, clientReferenceManifest, encodedArguments, fn, @@ -95,7 +102,8 @@ function generateCacheEntry( function generateCacheEntryWithRestoredWorkStore( workStore: WorkStore, - outerWorkUnitStore: WorkUnitStore | undefined, + renderContext: UseCacheRenderContext, + kind: string, clientReferenceManifest: DeepReadonly, encodedArguments: FormData | string, fn: (...args: unknown[]) => Promise, @@ -112,7 +120,8 @@ function generateCacheEntryWithRestoredWorkStore( workStore, generateCacheEntryWithCacheContext, workStore, - outerWorkUnitStore, + renderContext, + kind, clientReferenceManifest, encodedArguments, fn, @@ -122,7 +131,8 @@ function generateCacheEntryWithRestoredWorkStore( function generateCacheEntryWithCacheContext( workStore: WorkStore, - outerWorkUnitStore: WorkUnitStore | undefined, + renderContext: UseCacheRenderContext, + kind: string, clientReferenceManifest: DeepReadonly, encodedArguments: FormData | string, fn: (...args: unknown[]) => Promise, @@ -145,8 +155,11 @@ function generateCacheEntryWithCacheContext( ) } + const { workUnitStore: outerWorkUnitStore } = renderContext + const useCacheOrRequestStore = outerWorkUnitStore?.type === 'request' || + // outerWorkUnitStore?.type === 'cache-with-cookies' || outerWorkUnitStore?.type === 'cache' ? outerWorkUnitStore : undefined @@ -155,6 +168,7 @@ function generateCacheEntryWithCacheContext( const cacheStore: UseCacheStore = { type: 'cache', phase: 'render', + kind, implicitTags: outerWorkUnitStore?.implicitTags, revalidate: defaultCacheLife.revalidate, expire: defaultCacheLife.expire, @@ -171,13 +185,19 @@ function generateCacheEntryWithCacheContext( draftMode: outerWorkUnitStore && getDraftModeProviderForCacheScope(workStore, outerWorkUnitStore), + cookies: createCookiesForUseCacheStore(workStore, renderContext), } + // const cacheStore: UseCacheStore | UseCacheWithCookiesStore = cookies + // ? { ...commonCacheStore, type: 'cache-with-cookies', cookies } + // : { ...commonCacheStore, type: 'cache' } + return workUnitAsyncStorage.run( cacheStore, generateCacheEntryImpl, workStore, - outerWorkUnitStore, + renderContext, + kind, cacheStore, clientReferenceManifest, encodedArguments, @@ -222,7 +242,7 @@ async function collectResult( savedStream: ReadableStream, workStore: WorkStore, outerWorkUnitStore: WorkUnitStore | undefined, - innerCacheStore: UseCacheStore, + innerCacheStore: CommonUseCacheStore, startTime: number, errors: Array // This is a live array that gets pushed into. ): Promise { @@ -309,13 +329,14 @@ async function collectResult( async function generateCacheEntryImpl( workStore: WorkStore, - outerWorkUnitStore: WorkUnitStore | undefined, + renderContext: UseCacheRenderContext, + _kind: string, innerCacheStore: UseCacheStore, clientReferenceManifest: DeepReadonly, encodedArguments: FormData | string, fn: (...args: unknown[]) => Promise, timeoutError: UseCacheTimeoutError -): Promise<[ReadableStream, Promise]> { +): Promise<[ReadableStream, Promise] | [null, Promise]> { const temporaryReferences = createServerTemporaryReferenceSet() const [, , args] = @@ -336,16 +357,19 @@ async function generateCacheEntryImpl( // 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. - if (outerWorkUnitStore?.type === 'prerender') { + if (renderContext.type === 'prerender') { + const abortSignal = AbortSignal.any([ + renderContext.dynamicAccessAbortController.signal, + renderContext.workUnitStore.renderSignal, + ]) + await new Promise((resolve) => { - if (outerWorkUnitStore.renderSignal.aborted) { + if (abortSignal.aborted) { resolve() } else { - outerWorkUnitStore.renderSignal.addEventListener( - 'abort', - () => resolve(), - { once: true } - ) + abortSignal.addEventListener('abort', () => resolve(), { + once: true, + }) } }) } @@ -389,7 +413,9 @@ async function generateCacheEntryImpl( let stream: ReadableStream - if (outerWorkUnitStore?.type === 'prerender') { + if (renderContext.type === 'prerender') { + const { dynamicAccessAbortController, workUnitStore } = renderContext + const { signal: dynamicAccessAbortSignal } = dynamicAccessAbortController const timeoutAbortController = new AbortController() // If we're prerendering, we give you 50 seconds to fill a cache entry. @@ -400,10 +426,9 @@ async function generateCacheEntryImpl( timeoutAbortController.abort(timeoutError) }, 50000) - const { renderSignal } = outerWorkUnitStore - const abortSignal = AbortSignal.any([ - renderSignal, + workUnitStore.renderSignal, + dynamicAccessAbortSignal, timeoutAbortController.signal, ]) @@ -435,6 +460,18 @@ async function generateCacheEntryImpl( } else { stream = prelude } + + if (dynamicAccessAbortSignal.aborted) { + workUnitStore.cacheSignal?.endRead() + + const hangingPromise = makeHangingPromise( + workUnitStore.renderSignal, + dynamicAccessAbortSignal.reason.message + ) + + // TODO: A tuple is not the best way to return this. + return [null, hangingPromise] + } } else { stream = renderToReadableStream( resultPromise, @@ -452,7 +489,7 @@ async function generateCacheEntryImpl( const promiseOfCacheEntry = collectResult( savedStream, workStore, - outerWorkUnitStore, + renderContext.workUnitStore, innerCacheStore, startTime, errors @@ -544,6 +581,20 @@ function createTrackedReadableStream( }) } +function createRenderContext( + workUnitStore: WorkUnitStore | undefined +): UseCacheRenderContext { + if (!workUnitStore || workUnitStore.type !== 'prerender') { + return { type: 'other', workUnitStore } + } + + return { + type: 'prerender', + workUnitStore, + dynamicAccessAbortController: new AbortController(), + } +} + export function cache( kind: string, id: string, @@ -829,15 +880,24 @@ export function cache( } } + const renderContext = createRenderContext(workUnitStore) + const [newStream, pendingCacheEntry] = await generateCacheEntry( workStore, - workUnitStore, + renderContext, + kind, clientReferenceManifest, encodedCacheKeyParts, fn, timeoutError ) + if (newStream === null) { + // TODO: This is actually a hanging promise. Make this clearer by + // not returning a tuple from generateCacheEntry. + return pendingCacheEntry + } + // When draft mode is enabled, we must not save the cache entry. if (!workStore.isDraftMode) { let savedCacheEntry @@ -896,36 +956,43 @@ export function cache( // then we should warm up the cache with a fresh revalidated entry. const [ignoredStream, pendingCacheEntry] = await generateCacheEntry( workStore, - undefined, // This is not running within the context of this unit. + { + type: 'other', + // This is not running within the context of this unit. + workUnitStore: undefined, + }, + kind, clientReferenceManifest, encodedCacheKeyParts, fn, timeoutError ) - let savedCacheEntry: Promise - if (prerenderResumeDataCache) { - const split = clonePendingCacheEntry(pendingCacheEntry) - savedCacheEntry = getNthCacheEntry(split, 0) - prerenderResumeDataCache.cache.set( + if (ignoredStream !== null) { + let savedCacheEntry: Promise + if (prerenderResumeDataCache) { + const split = clonePendingCacheEntry(pendingCacheEntry) + savedCacheEntry = getNthCacheEntry(split, 0) + prerenderResumeDataCache.cache.set( + serializedCacheKey, + getNthCacheEntry(split, 1) + ) + } else { + savedCacheEntry = pendingCacheEntry + } + + const promise = cacheHandler.set( serializedCacheKey, - getNthCacheEntry(split, 1) + savedCacheEntry ) - } else { - savedCacheEntry = pendingCacheEntry - } - const promise = cacheHandler.set( - serializedCacheKey, - savedCacheEntry - ) + if (!workStore.pendingRevalidateWrites) { + workStore.pendingRevalidateWrites = [] + } + workStore.pendingRevalidateWrites.push(promise) - if (!workStore.pendingRevalidateWrites) { - workStore.pendingRevalidateWrites = [] + await ignoredStream.cancel() } - workStore.pendingRevalidateWrites.push(promise) - - await ignoredStream.cancel() } } } diff --git a/test/e2e/app-dir/use-cache-cookies/fixtures/dynamic-io/app/layout.tsx b/test/e2e/app-dir/use-cache-cookies/fixtures/dynamic-io/app/layout.tsx new file mode 100644 index 000000000000..40875e8987bf --- /dev/null +++ b/test/e2e/app-dir/use-cache-cookies/fixtures/dynamic-io/app/layout.tsx @@ -0,0 +1,21 @@ +import { Suspense } from 'react' +import { ReactNode } from 'react' + +async function getCachedDate() { + 'use cache' + + return new Date().toISOString() +} + +export default async function Root({ children }: { children: ReactNode }) { + return ( + + +

{await getCachedDate()}

+ Loading...

}> + {children} +
+ + + ) +} diff --git a/test/e2e/app-dir/use-cache-cookies/fixtures/dynamic-io/app/page.tsx b/test/e2e/app-dir/use-cache-cookies/fixtures/dynamic-io/app/page.tsx new file mode 100644 index 000000000000..cffb22410199 --- /dev/null +++ b/test/e2e/app-dir/use-cache-cookies/fixtures/dynamic-io/app/page.tsx @@ -0,0 +1,11 @@ +import { cookies } from 'next/headers' +import { setTimeout } from 'timers/promises' + +export default async function Page() { + 'use cache' + + await setTimeout(1000) + const loggedIn = (await cookies()).get('isLoggedIn') + + return

{loggedIn ? 'logged in' : 'logged out'}

+} diff --git a/test/e2e/app-dir/use-cache-cookies/fixtures/dynamic-io/next.config.js b/test/e2e/app-dir/use-cache-cookies/fixtures/dynamic-io/next.config.js new file mode 100644 index 000000000000..97dedb2358d9 --- /dev/null +++ b/test/e2e/app-dir/use-cache-cookies/fixtures/dynamic-io/next.config.js @@ -0,0 +1,11 @@ +/** + * @type {import('next').NextConfig} + */ +const nextConfig = { + experimental: { + dynamicIO: true, + ppr: process.env.__NEXT_EXPERIMENTAL_PPR === 'true', + }, +} + +module.exports = nextConfig diff --git a/test/e2e/app-dir/use-cache-cookies/use-cache-cookies.test.ts b/test/e2e/app-dir/use-cache-cookies/use-cache-cookies.test.ts new file mode 100644 index 000000000000..32994221c8db --- /dev/null +++ b/test/e2e/app-dir/use-cache-cookies/use-cache-cookies.test.ts @@ -0,0 +1,48 @@ +import { nextTestSetup } from 'e2e-utils' +import path from 'path' +import expect from 'expect' + +describe('use-cache-cookies', () => { + describe('with dynamicIO', () => { + const { next, skipped } = nextTestSetup({ + files: path.join(__dirname, 'fixtures', 'dynamic-io'), + skipDeployment: true, // TODO: Activate later. + }) + + if (skipped) { + return + } + + it('should make a cached function that accesses cookies dynamic', async () => { + const browser = await next.browser('/', { + waitUntil: 'commit', + waitHydration: false, + }) + + // The loading boundary is expected to be shown while the page is loading, + // and the dynamic cache function is evaluated. + expect(await browser.elementsByCss('#loading')).toHaveLength(1) + + const cachedInLayout = await browser + .elementById('cached-in-layout') + .text() + + expect(await browser.elementById('login-status').text()).toBe( + 'logged out' + ) + + await browser.refresh({ waitUntil: 'commit' }) + + // The login status is now cached, so no loading boundary is expected. + expect(await browser.elementsByCss('#loading')).toHaveLength(0) + + expect(await browser.elementById('cached-in-layout').text()).toBe( + cachedInLayout + ) + + expect(await browser.elementById('login-status').text()).toBe( + 'logged out' + ) + }) + }) +}) diff --git a/test/e2e/app-dir/use-cache/app/draft-mode/page.tsx b/test/e2e/app-dir/use-cache/app/draft-mode/page.tsx index e3367334ec80..74b2e2f1cde7 100644 --- a/test/e2e/app-dir/use-cache/app/draft-mode/page.tsx +++ b/test/e2e/app-dir/use-cache/app/draft-mode/page.tsx @@ -1,6 +1,6 @@ 'use cache' -import { cookies, draftMode } from 'next/headers' +import { draftMode, headers } from 'next/headers' import { Button } from './button' async function getCachedValue( @@ -34,15 +34,15 @@ export default async function Page() { const { isEnabled } = await draftMode() - // Accessing request-scoped data in "use cache" should not be allowed, even if - // draft mode is enabled. We expect the access to throw. - let isAccessingRequestScopedDataAllowedInUseCache = isEnabled + // Accessing headers in "use cache" should not be allowed, even if draft mode + // is enabled. We expect the access to throw. + let isAccessingHeadersAllowedInUseCache = isEnabled - if (isAccessingRequestScopedDataAllowedInUseCache) { + if (isAccessingHeadersAllowedInUseCache) { try { - await cookies() + await headers() } catch { - isAccessingRequestScopedDataAllowedInUseCache = false + isAccessingHeadersAllowedInUseCache = false } } @@ -71,8 +71,8 @@ export default async function Page() { >

{cachedValue}

{await cachedClosure()}

-

- {isAccessingRequestScopedDataAllowedInUseCache.toString()} +

+ {isAccessingHeadersAllowedInUseCache.toString()}

{passthroughFn()}

diff --git a/test/e2e/app-dir/use-cache/app/errors/page.tsx b/test/e2e/app-dir/use-cache/app/errors/page.tsx index 42278f43b4b1..e43d1fe29af4 100644 --- a/test/e2e/app-dir/use-cache/app/errors/page.tsx +++ b/test/e2e/app-dir/use-cache/app/errors/page.tsx @@ -2,36 +2,20 @@ import ErrorBoundary from './error-boundary' import { cookies } from 'next/headers' -import { currentUser, currentReferer, isEditing } from './util' - -async function Cookie() { - 'use cache' - return
User: {currentUser()}
-} +import { currentReferer } from './util' async function Header() { 'use cache' return
Referer: {currentReferer()}
} -async function DraftMode() { - 'use cache' - return
Editing: {isEditing()}
-} - export default async function Page() { await cookies() return (
- - -
- - -
) } diff --git a/test/e2e/app-dir/use-cache/app/errors/util.ts b/test/e2e/app-dir/use-cache/app/errors/util.ts index 42257e2e1c1f..2b60430baca8 100644 --- a/test/e2e/app-dir/use-cache/app/errors/util.ts +++ b/test/e2e/app-dir/use-cache/app/errors/util.ts @@ -1,13 +1,5 @@ -import { cookies, headers, draftMode } from 'next/headers' - -export async function currentUser() { - return (await cookies()).get('user')?.value -} +import { headers } from 'next/headers' export async function currentReferer() { return (await headers()).get('referer') } - -export async function isEditing() { - return String((await draftMode()).isEnabled) -} diff --git a/test/e2e/app-dir/use-cache/use-cache.test.ts b/test/e2e/app-dir/use-cache/use-cache.test.ts index 4261e5f2eead..345809cf6eab 100644 --- a/test/e2e/app-dir/use-cache/use-cache.test.ts +++ b/test/e2e/app-dir/use-cache/use-cache.test.ts @@ -127,15 +127,10 @@ describe('use-cache', () => { ) }) - it('should error when cookies/headers/draftMode is used inside "use cache"', async () => { + it('should error when headers is used inside "use cache"', async () => { const browser = await next.browser('/errors') await retry(async () => { - expect(await browser.elementById('cookies').text()).toContain( - isNextDev - ? 'Route /errors used "cookies" inside "use cache".' - : GENERIC_RSC_ERROR - ) expect(await browser.elementById('headers').text()).toContain( isNextDev ? 'Route /errors used "headers" inside "use cache".' @@ -143,15 +138,8 @@ describe('use-cache', () => { ) }) - expect(await browser.elementById('draft-mode').text()).toContain( - 'Editing: false' - ) - // CLI assertions are skipped in deploy mode because `next.cliOutput` will only contain build-time logs. if (!isNextDeploy) { - expect(next.cliOutput).toContain( - 'Route /errors used "cookies" inside "use cache". ' - ) expect(next.cliOutput).toContain( 'Route /errors used "headers" inside "use cache". ' ) @@ -832,10 +820,10 @@ describe('use-cache', () => { newClosureValue ) - // Accessing request-scoped data should still not be allowed. + // Accessing headers should still not be allowed. expect( await browser - .elementById('is-accessing-request-scoped-data-allowed-in-use-cache') + .elementById('is-accessing-headers-allowed-in-use-cache') .text() ).toBe('false') diff --git a/test/lib/browsers/playwright.ts b/test/lib/browsers/playwright.ts index 7f4b1ec7fc31..b5b1147b342d 100644 --- a/test/lib/browsers/playwright.ts +++ b/test/lib/browsers/playwright.ts @@ -229,6 +229,10 @@ export class Playwright { cpuThrottleRate?: number pushErrorAsConsoleLog?: boolean beforePageLoad?: (page: Page) => void + /** + * @default "load" + */ + waitUntil?: 'domcontentloaded' | 'load' | 'networkidle' | 'commit' } ) { await this.close() @@ -312,7 +316,7 @@ export class Playwright { opts?.beforePageLoad?.(page) - await page.goto(url, { waitUntil: 'load' }) + await page.goto(url, { waitUntil: opts?.waitUntil ?? 'load' }) } back(options?: Parameters[0]) { @@ -327,10 +331,14 @@ export class Playwright { await page.goForward(options) }) } - refresh() { + refresh({ + waitUntil, + }: { + waitUntil?: 'domcontentloaded' | 'load' | 'networkidle' | 'commit' + } = {}) { // do not preserve the previous chained value, it's likely to be invalid after a reload. return this.startChain(async () => { - await page.reload() + await page.reload({ waitUntil }) }) } setDimensions({ width, height }: { height: number; width: number }) { diff --git a/test/lib/next-webdriver.ts b/test/lib/next-webdriver.ts index 38883ef8bd34..064d3a8720f9 100644 --- a/test/lib/next-webdriver.ts +++ b/test/lib/next-webdriver.ts @@ -45,6 +45,12 @@ if (typeof afterAll === 'function') { } export interface WebdriverOptions { + /** + * When to consider the initial page load succeeded. + * + * @default "load" + */ + waitUntil?: 'domcontentloaded' | 'load' | 'networkidle' | 'commit' /** * whether to wait for React hydration to finish */ @@ -103,6 +109,7 @@ export default async function webdriver( } options = Object.assign(defaultOptions, options) const { + waitUntil, waitHydration, retryWaitHydration, disableCache, @@ -145,8 +152,12 @@ export default async function webdriver( cpuThrottleRate, beforePageLoad, pushErrorAsConsoleLog, + waitUntil, }) - console.log(`\n> Loaded browser with ${fullUrl}\n`) + + console.log( + `\n> Loaded browser with ${fullUrl} (waitUntil: ${JSON.stringify(waitUntil)})\n` + ) browserTeardown.push(browser.close.bind(browser)) From 64b17633560a92453a1d2b40e10478d9649a8055 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Sun, 20 Apr 2025 16:20:25 +0200 Subject: [PATCH 02/57] Unflake test --- .../fixtures/dynamic-io/app/page.tsx | 2 +- .../use-cache-cookies/use-cache-cookies.test.ts | 13 ++++++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/test/e2e/app-dir/use-cache-cookies/fixtures/dynamic-io/app/page.tsx b/test/e2e/app-dir/use-cache-cookies/fixtures/dynamic-io/app/page.tsx index cffb22410199..45c70b1aa5b0 100644 --- a/test/e2e/app-dir/use-cache-cookies/fixtures/dynamic-io/app/page.tsx +++ b/test/e2e/app-dir/use-cache-cookies/fixtures/dynamic-io/app/page.tsx @@ -4,7 +4,7 @@ import { setTimeout } from 'timers/promises' export default async function Page() { 'use cache' - await setTimeout(1000) + await setTimeout(2000) const loggedIn = (await cookies()).get('isLoggedIn') return

{loggedIn ? 'logged in' : 'logged out'}

diff --git a/test/e2e/app-dir/use-cache-cookies/use-cache-cookies.test.ts b/test/e2e/app-dir/use-cache-cookies/use-cache-cookies.test.ts index 32994221c8db..276c9f40a840 100644 --- a/test/e2e/app-dir/use-cache-cookies/use-cache-cookies.test.ts +++ b/test/e2e/app-dir/use-cache-cookies/use-cache-cookies.test.ts @@ -20,8 +20,13 @@ describe('use-cache-cookies', () => { }) // The loading boundary is expected to be shown while the page is loading, - // and the dynamic cache function is evaluated. - expect(await browser.elementsByCss('#loading')).toHaveLength(1) + // and the dynamic cache function is evaluated. We're using `browser.eval` + // here to assert on the document as early as possible. The other + // Playwright APIs (incl. `page.$`) add a delay that sometimes leads to + // missing the ephemeral loading element. + expect( + await browser.eval('document.getElementById("loading")') + ).toBeDefined() const cachedInLayout = await browser .elementById('cached-in-layout') @@ -34,7 +39,9 @@ describe('use-cache-cookies', () => { await browser.refresh({ waitUntil: 'commit' }) // The login status is now cached, so no loading boundary is expected. - expect(await browser.elementsByCss('#loading')).toHaveLength(0) + expect( + await browser.eval('document.getElementById("loading")') + ).toBeNull() expect(await browser.elementById('cached-in-layout').text()).toBe( cachedInLayout From 476cf3d680f7356100e148f22bb300e8a10f8299 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Mon, 21 Apr 2025 11:40:24 +0200 Subject: [PATCH 03/57] Return a discriminated object instead of a tuple --- .../src/server/use-cache/use-cache-wrapper.ts | 57 ++++++++++++------- 1 file changed, 37 insertions(+), 20 deletions(-) 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 0495b97b2b15..acf9030cd1e1 100644 --- a/packages/next/src/server/use-cache/use-cache-wrapper.ts +++ b/packages/next/src/server/use-cache/use-cache-wrapper.ts @@ -327,6 +327,17 @@ async function collectResult( return entry } +type GenerateCacheEntryResult = + | { + type: 'cached' + stream: ReadableStream + pendingCacheEntry: Promise + } + | { + type: 'prerender-dynamic' + hangingPromise: Promise + } + async function generateCacheEntryImpl( workStore: WorkStore, renderContext: UseCacheRenderContext, @@ -336,7 +347,7 @@ async function generateCacheEntryImpl( encodedArguments: FormData | string, fn: (...args: unknown[]) => Promise, timeoutError: UseCacheTimeoutError -): Promise<[ReadableStream, Promise] | [null, Promise]> { +): Promise { const temporaryReferences = createServerTemporaryReferenceSet() const [, , args] = @@ -469,8 +480,7 @@ async function generateCacheEntryImpl( dynamicAccessAbortSignal.reason.message ) - // TODO: A tuple is not the best way to return this. - return [null, hangingPromise] + return { type: 'prerender-dynamic', hangingPromise } } } else { stream = renderToReadableStream( @@ -486,7 +496,7 @@ async function generateCacheEntryImpl( const [returnStream, savedStream] = stream.tee() - const promiseOfCacheEntry = collectResult( + const pendingCacheEntry = collectResult( savedStream, workStore, renderContext.workUnitStore, @@ -495,10 +505,14 @@ async function generateCacheEntryImpl( errors ) - // Return the stream as we're creating it. This means that if it ends up - // erroring we cannot return a stale-while-error version but it allows - // streaming back the result earlier. - return [returnStream, promiseOfCacheEntry] + return { + type: 'cached', + // Return the stream as we're creating it. This means that if it ends up + // erroring we cannot return a stale-if-error version but it allows + // streaming back the result earlier. + stream: returnStream, + pendingCacheEntry, + } } function cloneCacheEntry(entry: CacheEntry): [CacheEntry, CacheEntry] { @@ -882,7 +896,7 @@ export function cache( const renderContext = createRenderContext(workUnitStore) - const [newStream, pendingCacheEntry] = await generateCacheEntry( + const result = await generateCacheEntry( workStore, renderContext, kind, @@ -892,12 +906,12 @@ export function cache( timeoutError ) - if (newStream === null) { - // TODO: This is actually a hanging promise. Make this clearer by - // not returning a tuple from generateCacheEntry. - return pendingCacheEntry + if (result.type === 'prerender-dynamic') { + return result.hangingPromise } + const { stream: newStream, pendingCacheEntry } = result + // When draft mode is enabled, we must not save the cache entry. if (!workStore.isDraftMode) { let savedCacheEntry @@ -952,13 +966,16 @@ export function cache( } if (currentTime > entry.timestamp + entry.revalidate * 1000) { - // If this is stale, and we're not in a prerender (i.e. this is dynamic render), - // then we should warm up the cache with a fresh revalidated entry. - const [ignoredStream, pendingCacheEntry] = await generateCacheEntry( + // If this is stale, and we're not in a prerender (i.e. this is + // dynamic render), then we should warm up the cache with a fresh + // revalidated entry. + const result = await generateCacheEntry( workStore, { type: 'other', // This is not running within the context of this unit. + // TODO: We may need to pass in a work unit store that + // includes the cookies though. workUnitStore: undefined, }, kind, @@ -968,8 +985,10 @@ export function cache( timeoutError ) - if (ignoredStream !== null) { + if (result.type === 'cached') { + const { stream: ignoredStream, pendingCacheEntry } = result let savedCacheEntry: Promise + if (prerenderResumeDataCache) { const split = clonePendingCacheEntry(pendingCacheEntry) savedCacheEntry = getNthCacheEntry(split, 0) @@ -986,9 +1005,7 @@ export function cache( savedCacheEntry ) - if (!workStore.pendingRevalidateWrites) { - workStore.pendingRevalidateWrites = [] - } + workStore.pendingRevalidateWrites ??= [] workStore.pendingRevalidateWrites.push(promise) await ignoredStream.cancel() From ceb798b7c7ba329ee5b6140816090fab0d38518e Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Mon, 21 Apr 2025 18:11:24 +0200 Subject: [PATCH 04/57] Include accessed cookies in cache key --- .../next/src/server/app-render/app-render.tsx | 14 +- .../work-unit-async-storage.external.ts | 25 +- .../src/server/async-storage/request-store.ts | 26 +- .../src/server/lib/cache-handlers/default.ts | 22 +- .../src/server/lib/cache-handlers/types.ts | 10 + packages/next/src/server/request/cookies.ts | 233 +++++++++++------- .../server/resume-data-cache/cache-store.ts | 1 + .../resume-data-cache/resume-data-cache.ts | 2 + packages/next/src/server/use-cache/types.ts | 17 ++ .../src/server/use-cache/use-cache-wrapper.ts | 46 +++- .../use-cache-cookies.test.ts | 15 ++ 11 files changed, 301 insertions(+), 110 deletions(-) create mode 100644 packages/next/src/server/use-cache/types.ts diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index dd4803930bdb..cf05ffabd1ef 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -56,7 +56,10 @@ import { NEXT_HMR_REFRESH_HASH_COOKIE, } from '../../client/components/app-router-headers' import { createMetadataContext } from '../../lib/metadata/metadata-context' -import { createRequestStoreForRender } from '../async-storage/request-store' +import { + createRequestCookies, + createRequestStoreForRender, +} from '../async-storage/request-store' import { createWorkStore } from '../async-storage/work-store' import { getAccessFallbackErrorTypeByStatus, @@ -711,6 +714,7 @@ async function warmupDevRender( tags: [], prerenderResumeDataCache, hmrRefreshHash: req.cookies[NEXT_HMR_REFRESH_HASH_COOKIE], + cookies: createRequestCookies(req), } const rscPayload = await workUnitAsyncStorage.run( @@ -2256,6 +2260,7 @@ async function spawnDynamicValidationInDev( tags: [], prerenderResumeDataCache, hmrRefreshHash, + cookies: undefined, } const initialClientController = new AbortController() @@ -2274,6 +2279,7 @@ async function spawnDynamicValidationInDev( tags: [], prerenderResumeDataCache, hmrRefreshHash, + cookies: undefined, } // We're not going to use the result of this render because the only time it could be used @@ -2420,6 +2426,7 @@ async function spawnDynamicValidationInDev( tags: [], prerenderResumeDataCache, hmrRefreshHash, + cookies: undefined, } const finalClientController = new AbortController() @@ -2442,6 +2449,7 @@ async function spawnDynamicValidationInDev( tags: [], prerenderResumeDataCache, hmrRefreshHash, + cookies: undefined, } const finalServerPayload = await workUnitAsyncStorage.run( @@ -2777,6 +2785,7 @@ async function prerenderToStream( tags: [...implicitTags.tags], prerenderResumeDataCache, hmrRefreshHash: undefined, + cookies: undefined, }) // We're not going to use the result of this render because the only time it could be used @@ -2874,6 +2883,7 @@ async function prerenderToStream( tags: [...implicitTags.tags], prerenderResumeDataCache, hmrRefreshHash: undefined, + cookies: undefined, } const prerender = require('react-dom/static.edge') @@ -2959,6 +2969,7 @@ async function prerenderToStream( tags: [...implicitTags.tags], prerenderResumeDataCache, hmrRefreshHash: undefined, + cookies: undefined, }) const finalAttemptRSCPayload = await workUnitAsyncStorage.run( @@ -3029,6 +3040,7 @@ async function prerenderToStream( tags: [...implicitTags.tags], prerenderResumeDataCache, hmrRefreshHash: undefined, + cookies: undefined, } let clientIsDynamic = false 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 9d533e2ded61..2254cadafaa9 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 @@ -123,6 +123,13 @@ export interface PrerenderStoreModern extends CommonWorkUnitStore { * subsequent dynamic render. */ readonly hmrRefreshHash: string | undefined + + /** + * The request cookies must only be provided during the dev warmup render to + * ensure that cache keys for cache functions that access cookies include the + * accessed cookies. + */ + readonly cookies: ReadonlyRequestCookies | undefined } export interface PrerenderStorePPR extends CommonWorkUnitStore { @@ -189,9 +196,25 @@ export interface CommonUseCacheStore extends CommonCacheStore { readonly forceRevalidate: boolean } +export interface UseCachePrerenderCookiesStore { + readonly type: 'prerender' + readonly getUserspaceCookies: () => Promise +} + +export interface UseCacheRequestCookiesStore { + readonly type: 'request' + readonly underlyingCookies: ReadonlyRequestCookies + readonly getUserspaceCookies: () => Promise + accessedCookieNames: Set | 'all' +} + +export type UseCacheCookiesStore = + | UseCachePrerenderCookiesStore + | UseCacheRequestCookiesStore + export interface UseCacheStore extends CommonUseCacheStore { readonly type: 'cache' - readonly cookies: (() => Promise) | undefined + readonly cookiesStore: UseCacheCookiesStore | undefined } // export interface UseCacheWithCookiesStore extends CommonUseCacheStore { diff --git a/packages/next/src/server/async-storage/request-store.ts b/packages/next/src/server/async-storage/request-store.ts index 89558398a700..2433a7e9fde7 100644 --- a/packages/next/src/server/async-storage/request-store.ts +++ b/packages/next/src/server/async-storage/request-store.ts @@ -202,17 +202,7 @@ function createRequestStoreImpl( }, get cookies() { if (!cache.cookies) { - // if middleware is setting cookie(s), then include those in - // the initial cached cookies so they can be read in render - const requestCookies = new RequestCookies( - HeadersAdapter.from(req.headers) - ) - - mergeMiddlewareCookies(req, requestCookies) - - // Seal the cookies object that'll freeze out any methods that could - // mutate the underlying data. - cache.cookies = RequestCookiesAdapter.seal(requestCookies) + cache.cookies = createRequestCookies(req) } return cache.cookies @@ -268,3 +258,17 @@ export function synchronizeMutableCookies(store: RequestStore) { responseCookiesToRequestCookies(store.mutableCookies) ) } + +export function createRequestCookies( + req: RequestContext['req'] +): ReadonlyRequestCookies { + const requestCookies = new RequestCookies(HeadersAdapter.from(req.headers)) + + // If middleware is setting cookies, then include those so that they can be + // read during render. + mergeMiddlewareCookies(req, requestCookies) + + // Seal the cookies object to freeze out any methods that could mutate the + // underlying data. + return RequestCookiesAdapter.seal(requestCookies) +} diff --git a/packages/next/src/server/lib/cache-handlers/default.ts b/packages/next/src/server/lib/cache-handlers/default.ts index 5e6044207071..01a2dbd781c0 100644 --- a/packages/next/src/server/lib/cache-handlers/default.ts +++ b/packages/next/src/server/lib/cache-handlers/default.ts @@ -39,7 +39,12 @@ const memoryCache = new LRUCache( 50 * 1024 * 1024, (entry) => entry.size ) -const pendingSets = new Map>() + +/** + * Maps an original cache key to the final cache key after a set operation has + * resolved. + */ +const pendingSets = new Map>() const debug = process.env.NEXT_PRIVATE_DEBUG_CACHE ? console.debug.bind(console, 'DefaultCacheHandler:') @@ -51,7 +56,9 @@ const DefaultCacheHandler: CacheHandlerV2 = { if (pendingPromise) { debug?.('get', cacheKey, 'pending') - await pendingPromise + const finalCacheKey = await pendingPromise + debug?.('get', cacheKey, 'resolved to', finalCacheKey) + cacheKey = finalCacheKey } const privateEntry = memoryCache.get(cacheKey) @@ -98,13 +105,14 @@ const DefaultCacheHandler: CacheHandlerV2 = { async set(cacheKey, pendingEntry) { debug?.('set', cacheKey, 'start') - let resolvePending: () => void = () => {} - const pendingPromise = new Promise((resolve) => { + let resolvePending: (finalCacheKey: string) => void = () => {} + const pendingPromise = new Promise((resolve) => { resolvePending = resolve }) pendingSets.set(cacheKey, pendingPromise) const entry = await pendingEntry + const finalCacheKey = entry.key let size = 0 @@ -117,19 +125,19 @@ const DefaultCacheHandler: CacheHandlerV2 = { size += Buffer.from(chunk.value).byteLength } - memoryCache.set(cacheKey, { + memoryCache.set(finalCacheKey, { entry, isErrored: false, errorRetryCount: 0, size, }) - debug?.('set', cacheKey, 'done') + debug?.('set', finalCacheKey, 'done') } catch (err) { // TODO: store partial buffer with error after we retry 3 times debug?.('set', cacheKey, 'failed', err) } finally { - resolvePending() + resolvePending(finalCacheKey) pendingSets.delete(cacheKey) } }, diff --git a/packages/next/src/server/lib/cache-handlers/types.ts b/packages/next/src/server/lib/cache-handlers/types.ts index 8cbb91257224..e39708531c08 100644 --- a/packages/next/src/server/lib/cache-handlers/types.ts +++ b/packages/next/src/server/lib/cache-handlers/types.ts @@ -4,6 +4,13 @@ export type Timestamp = number export interface CacheEntry { + /** + * The final cache key. This may differ from the cache key that was determined + * before the cache entry's value stream was started. During the cache entry + * generation we might encounter that cookies were read, which then need to be + * included in the final cache key. + */ + key: string /** * The ReadableStream can error and only have partial data so any cache * handlers need to handle this case and decide to keep the partial cache @@ -75,6 +82,9 @@ export interface CacheHandler { receiveExpiredTags(...tags: string[]): Promise } +// TODO: Maybe a cache handler should specify whether it is able to handle +// reading cookies, and thus switching up cache keys on the fly, during cache +// entry generation? export interface CacheHandlerV2 { /** * Retrieve a cache entry for the given cache key, if available. Will return diff --git a/packages/next/src/server/request/cookies.ts b/packages/next/src/server/request/cookies.ts index 17ec192d5469..66975aa22dae 100644 --- a/packages/next/src/server/request/cookies.ts +++ b/packages/next/src/server/request/cookies.ts @@ -12,7 +12,7 @@ import { import { workUnitAsyncStorage, type PrerenderStoreModern, - type WorkUnitStore, + type UseCacheCookiesStore, } from '../app-render/work-unit-async-storage.external' import { postponeWithTracking, @@ -28,6 +28,8 @@ import { createDedupedByCallsiteServerErrorLoggerDev } from '../create-deduped-b import { scheduleImmediate } from '../../lib/scheduler' import { isRequestAPICallableInsideAfter } from './utils' import { ReflectAdapter } from '../web/spec-extension/adapters/reflect' +import type { RequestCookie } from '@edge-runtime/cookies' +import type { UseCacheRenderContext } from '../use-cache/types' /** * In this version of Next.js `cookies()` returns a Promise however you can still reference the properties of the underlying cookies object @@ -81,8 +83,8 @@ export function cookies(): Promise { // return workUnitStore.cookies // } else if (workUnitStore.type === 'cache') { if (workUnitStore.type === 'cache') { - if (workUnitStore.cookies) { - return workUnitStore.cookies() + if (workUnitStore.cookiesStore) { + return workUnitStore.cookiesStore.getUserspaceCookies() } throw new Error( @@ -324,6 +326,57 @@ function makeDynamicallyTrackedExoticCookiesForUseCache( return promise } +function createProxiedCookies( + underlyingCookies: ReadonlyRequestCookies +): ReadonlyRequestCookies { + return new Proxy(underlyingCookies, { + get(target, prop, receiver) { + const workUnitStore = workUnitAsyncStorage.getStore() + + if ( + workUnitStore?.type === 'cache' && + workUnitStore.cookiesStore?.type === 'request' + ) { + const { cookiesStore } = workUnitStore + + // eslint-disable-next-line default-case + switch (prop) { + case 'get': + case 'getAll': + return function get( + ...args: [name: string] | [RequestCookie] + ): RequestCookie | undefined { + if (cookiesStore.accessedCookieNames !== 'all') { + for (const arg of args) { + const cookieName = typeof arg === 'string' ? arg : arg.name + cookiesStore.accessedCookieNames.add(cookieName) + } + } + + return target.get.apply(target, args) + } + case 'has': + return function get(name: string): RequestCookie | undefined { + if (cookiesStore.accessedCookieNames !== 'all') { + cookiesStore.accessedCookieNames.add(name) + } + + return target.get.call(target, name) + } + case 'toString': + return function get(name: string): RequestCookie | undefined { + cookiesStore.accessedCookieNames = 'all' + + return target.get.call(target, name) + } + } + } + + return ReflectAdapter.get(target, prop, receiver) + }, + }) +} + function makeUntrackedExoticCookies( underlyingCookies: ReadonlyRequestCookies ): Promise { @@ -332,55 +385,56 @@ function makeUntrackedExoticCookies( return cachedCookies } - const promise = Promise.resolve(underlyingCookies) - CachedCookies.set(underlyingCookies, promise) + const proxiedCookies = createProxiedCookies(underlyingCookies) + const promise = Promise.resolve(proxiedCookies) + CachedCookies.set(proxiedCookies, promise) Object.defineProperties(promise, { [Symbol.iterator]: { - value: underlyingCookies[Symbol.iterator] - ? underlyingCookies[Symbol.iterator].bind(underlyingCookies) + value: proxiedCookies[Symbol.iterator] + ? proxiedCookies[Symbol.iterator].bind(proxiedCookies) : // TODO this is a polyfill for when the underlying type is ResponseCookies // We should remove this and unify our cookies types. We could just let this continue to throw lazily // but that's already a hard thing to debug so we may as well implement it consistently. The biggest problem with // implementing this in this way is the underlying cookie type is a ResponseCookie and not a RequestCookie and so it // has extra properties not available on RequestCookie instances. - polyfilledResponseCookiesIterator.bind(underlyingCookies), + polyfilledResponseCookiesIterator.bind(proxiedCookies), }, size: { get(): number { - return underlyingCookies.size + return proxiedCookies.size }, }, get: { - value: underlyingCookies.get.bind(underlyingCookies), + value: proxiedCookies.get.bind(proxiedCookies), }, getAll: { - value: underlyingCookies.getAll.bind(underlyingCookies), + value: proxiedCookies.getAll.bind(proxiedCookies), }, has: { - value: underlyingCookies.has.bind(underlyingCookies), + value: proxiedCookies.has.bind(proxiedCookies), }, set: { - value: underlyingCookies.set.bind(underlyingCookies), + value: proxiedCookies.set.bind(proxiedCookies), }, delete: { - value: underlyingCookies.delete.bind(underlyingCookies), + value: proxiedCookies.delete.bind(proxiedCookies), }, clear: { value: // @ts-expect-error clear is defined in RequestCookies implementation but not in the type - typeof underlyingCookies.clear === 'function' + typeof proxiedCookies.clear === 'function' ? // @ts-expect-error clear is defined in RequestCookies implementation but not in the type - underlyingCookies.clear.bind(underlyingCookies) + proxiedCookies.clear.bind(proxiedCookies) : // TODO this is a polyfill for when the underlying type is ResponseCookies // We should remove this and unify our cookies types. We could just let this continue to throw lazily // but that's already a hard thing to debug so we may as well implement it consistently. The biggest problem with // implementing this in this way is the underlying cookie type is a ResponseCookie and not a RequestCookie and so it // has extra properties not available on RequestCookie instances. - polyfilledResponseCookiesClear.bind(underlyingCookies, promise), + polyfilledResponseCookiesClear.bind(proxiedCookies, promise), }, toString: { - value: underlyingCookies.toString.bind(underlyingCookies), + value: proxiedCookies.toString.bind(proxiedCookies), }, } satisfies CookieExtensions) @@ -396,19 +450,20 @@ function makeUntrackedExoticCookiesWithDevWarnings( return cachedCookies } + const proxiedCookies = createProxiedCookies(underlyingCookies) const promise = new Promise((resolve) => - scheduleImmediate(() => resolve(underlyingCookies)) + scheduleImmediate(() => resolve(proxiedCookies)) ) - CachedCookies.set(underlyingCookies, promise) + CachedCookies.set(proxiedCookies, promise) Object.defineProperties(promise, { [Symbol.iterator]: { value: function () { const expression = '`...cookies()` or similar iteration' syncIODev(route, expression) - return underlyingCookies[Symbol.iterator] - ? underlyingCookies[Symbol.iterator].apply( - underlyingCookies, + return proxiedCookies[Symbol.iterator] + ? proxiedCookies[Symbol.iterator].apply( + proxiedCookies, arguments as any ) : // TODO this is a polyfill for when the underlying type is ResponseCookies @@ -416,7 +471,7 @@ function makeUntrackedExoticCookiesWithDevWarnings( // but that's already a hard thing to debug so we may as well implement it consistently. The biggest problem with // implementing this in this way is the underlying cookie type is a ResponseCookie and not a RequestCookie and so it // has extra properties not available on RequestCookie instances. - polyfilledResponseCookiesIterator.call(underlyingCookies) + polyfilledResponseCookiesIterator.call(proxiedCookies) }, writable: false, }, @@ -424,7 +479,7 @@ function makeUntrackedExoticCookiesWithDevWarnings( get(): number { const expression = '`cookies().size`' syncIODev(route, expression) - return underlyingCookies.size + return proxiedCookies.size }, }, get: { @@ -436,7 +491,7 @@ function makeUntrackedExoticCookiesWithDevWarnings( expression = `\`cookies().get(${describeNameArg(arguments[0])})\`` } syncIODev(route, expression) - return underlyingCookies.get.apply(underlyingCookies, arguments as any) + return proxiedCookies.get.apply(proxiedCookies, arguments as any) }, writable: false, }, @@ -449,10 +504,7 @@ function makeUntrackedExoticCookiesWithDevWarnings( expression = `\`cookies().getAll(${describeNameArg(arguments[0])})\`` } syncIODev(route, expression) - return underlyingCookies.getAll.apply( - underlyingCookies, - arguments as any - ) + return proxiedCookies.getAll.apply(proxiedCookies, arguments as any) }, writable: false, }, @@ -465,7 +517,7 @@ function makeUntrackedExoticCookiesWithDevWarnings( expression = `\`cookies().has(${describeNameArg(arguments[0])})\`` } syncIODev(route, expression) - return underlyingCookies.has.apply(underlyingCookies, arguments as any) + return proxiedCookies.has.apply(proxiedCookies, arguments as any) }, writable: false, }, @@ -483,7 +535,7 @@ function makeUntrackedExoticCookiesWithDevWarnings( } } syncIODev(route, expression) - return underlyingCookies.set.apply(underlyingCookies, arguments as any) + return proxiedCookies.set.apply(proxiedCookies, arguments as any) }, writable: false, }, @@ -498,10 +550,7 @@ function makeUntrackedExoticCookiesWithDevWarnings( expression = `\`cookies().delete(${describeNameArg(arguments[0])}, ...)\`` } syncIODev(route, expression) - return underlyingCookies.delete.apply( - underlyingCookies, - arguments as any - ) + return proxiedCookies.delete.apply(proxiedCookies, arguments as any) }, writable: false, }, @@ -510,15 +559,15 @@ function makeUntrackedExoticCookiesWithDevWarnings( const expression = '`cookies().clear()`' syncIODev(route, expression) // @ts-ignore clear is defined in RequestCookies implementation but not in the type - return typeof underlyingCookies.clear === 'function' + return typeof proxiedCookies.clear === 'function' ? // @ts-ignore clear is defined in RequestCookies implementation but not in the type - underlyingCookies.clear.apply(underlyingCookies, arguments) + proxiedCookies.clear.apply(proxiedCookies, arguments) : // TODO this is a polyfill for when the underlying type is ResponseCookies // We should remove this and unify our cookies types. We could just let this continue to throw lazily // but that's already a hard thing to debug so we may as well implement it consistently. The biggest problem with // implementing this in this way is the underlying cookie type is a ResponseCookie and not a RequestCookie and so it // has extra properties not available on RequestCookie instances. - polyfilledResponseCookiesClear.call(underlyingCookies, promise) + polyfilledResponseCookiesClear.call(proxiedCookies, promise) }, writable: false, }, @@ -526,10 +575,7 @@ function makeUntrackedExoticCookiesWithDevWarnings( value: function toString() { const expression = '`cookies().toString()` or implicit casting' syncIODev(route, expression) - return underlyingCookies.toString.apply( - underlyingCookies, - arguments as any - ) + return proxiedCookies.toString.apply(proxiedCookies, arguments as any) }, writable: false, }, @@ -538,32 +584,37 @@ function makeUntrackedExoticCookiesWithDevWarnings( return promise } -export type UseCacheRenderContext = - | { - readonly type: 'prerender' - readonly workUnitStore: PrerenderStoreModern - readonly dynamicAccessAbortController: AbortController - } - | { - readonly type: 'other' - readonly workUnitStore: - | Exclude - | undefined - } - -export function createCookiesForUseCacheStore( +export function createUseCacheCookiesStore( workStore: WorkStore, renderContext: UseCacheRenderContext -): (() => Promise) | undefined { +): UseCacheCookiesStore | undefined { if (renderContext.type === 'prerender') { const { workUnitStore, dynamicAccessAbortController } = renderContext - return () => - makeDynamicallyTrackedExoticCookiesForUseCache( - workStore.route, - workUnitStore, - dynamicAccessAbortController - ) + // If there are cookies defined on a prerender store, this means we're doing + // a dev warmup render, which is more akin to a dynamic request than a + // prerender request, despite using a prerender work unit store. + if (workUnitStore.cookies) { + const userspaceCookies = makeUntrackedExoticCookies(workUnitStore.cookies) + + return { + type: 'request', + underlyingCookies: workUnitStore.cookies, + getUserspaceCookies: () => userspaceCookies, + accessedCookieNames: new Set(), + } + } + + const userspaceCookies = makeDynamicallyTrackedExoticCookiesForUseCache( + workStore.route, + workUnitStore, + dynamicAccessAbortController + ) + + return { + type: 'prerender', + getUserspaceCookies: () => userspaceCookies, + } } if (!renderContext.workUnitStore) { @@ -574,31 +625,47 @@ export function createCookiesForUseCacheStore( switch (workUnitStore.type) { case 'prerender-ppr': - return () => - postponeWithTracking( - workStore.route, - 'cookies', - workUnitStore.dynamicTracking - ) + return { + type: 'prerender', + getUserspaceCookies: () => + postponeWithTracking( + workStore.route, + 'cookies', + workUnitStore.dynamicTracking + ), + } case 'prerender-legacy': - return () => - throwToInterruptStaticGeneration('cookies', workStore, workUnitStore) + return { + type: 'prerender', + getUserspaceCookies: () => + throwToInterruptStaticGeneration('cookies', workStore, workUnitStore), + } // case 'cache-with-cookies': // return workUnitStore.cookies case 'cache': - return workUnitStore.cookies + return !workUnitStore.cookiesStore || + workUnitStore.cookiesStore.type === 'prerender' + ? workUnitStore.cookiesStore + : { + ...workUnitStore.cookiesStore, + // Each cache scope starts with a new set of accessed cookie names. + accessedCookieNames: new Set(), + } case 'request': - if ( - process.env.NODE_ENV === 'development' && - !workStore.isPrefetchRequest - ) { - return () => - makeUntrackedExoticCookiesWithDevWarnings( - workUnitStore.cookies, - workStore.route - ) + const userspaceCookies = + process.env.NODE_ENV === 'development' && !workStore.isPrefetchRequest + ? makeUntrackedExoticCookiesWithDevWarnings( + workUnitStore.cookies, + workStore.route + ) + : makeUntrackedExoticCookies(workUnitStore.cookies) + + return { + type: 'request', + underlyingCookies: workUnitStore.cookies, + getUserspaceCookies: () => userspaceCookies, + accessedCookieNames: new Set(), } - return () => makeUntrackedExoticCookies(workUnitStore.cookies) default: return undefined } diff --git a/packages/next/src/server/resume-data-cache/cache-store.ts b/packages/next/src/server/resume-data-cache/cache-store.ts index 00f5ea240513..541d4c9b70d9 100644 --- a/packages/next/src/server/resume-data-cache/cache-store.ts +++ b/packages/next/src/server/resume-data-cache/cache-store.ts @@ -64,6 +64,7 @@ export function parseUseCacheCacheStore( store.set( key, Promise.resolve({ + key, // Create a ReadableStream from the Uint8Array value: new ReadableStream({ start(controller) { diff --git a/packages/next/src/server/resume-data-cache/resume-data-cache.ts b/packages/next/src/server/resume-data-cache/resume-data-cache.ts index 0ec2dc89b270..44fcf18bcf93 100644 --- a/packages/next/src/server/resume-data-cache/resume-data-cache.ts +++ b/packages/next/src/server/resume-data-cache/resume-data-cache.ts @@ -51,6 +51,8 @@ export interface PrerenderResumeDataCache { * Supports both 'get' and 'set' operations to build the cache during * pre-rendering. */ + // TODO: For the dev warmup render, this needs to handle cache key changes + // during set operations, same as the default cache handler. readonly cache: UseCacheCacheStore /** diff --git a/packages/next/src/server/use-cache/types.ts b/packages/next/src/server/use-cache/types.ts new file mode 100644 index 000000000000..500fd717a35d --- /dev/null +++ b/packages/next/src/server/use-cache/types.ts @@ -0,0 +1,17 @@ +import type { + PrerenderStoreModern, + WorkUnitStore, +} from '../app-render/work-unit-async-storage.external' + +export type UseCacheRenderContext = + | { + readonly type: 'prerender' + readonly workUnitStore: PrerenderStoreModern + readonly dynamicAccessAbortController: AbortController + } + | { + readonly type: 'other' + readonly workUnitStore: + | Exclude + | undefined + } 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 acf9030cd1e1..35b0c72a334a 100644 --- a/packages/next/src/server/use-cache/use-cache-wrapper.ts +++ b/packages/next/src/server/use-cache/use-cache-wrapper.ts @@ -17,7 +17,7 @@ import { unstable_prerender as prerender } from 'react-server-dom-webpack/static import type { WorkStore } from '../app-render/work-async-storage.external' import { workAsyncStorage } from '../app-render/work-async-storage.external' import type { - CommonUseCacheStore, + UseCacheRequestCookiesStore, UseCacheStore, WorkUnitStore, } from '../app-render/work-unit-async-storage.external' @@ -53,10 +53,8 @@ import { import type { Params } from '../request/params' import React from 'react' import { createLazyResult, isResolvedLazyResult } from '../lib/lazy-result' -import { - createCookiesForUseCacheStore, - type UseCacheRenderContext, -} from '../request/cookies' +import type { UseCacheRenderContext } from './types' +import { createUseCacheCookiesStore } from '../request/cookies' type CacheKeyParts = | [buildId: string, id: string, args: unknown[]] @@ -81,6 +79,7 @@ function generateCacheEntry( clientReferenceManifest: DeepReadonly, encodedArguments: FormData | string, fn: (...args: unknown[]) => Promise, + cacheKey: string, timeoutError: UseCacheTimeoutError ) { // We need to run this inside a clean AsyncLocalStorage snapshot so that the cache @@ -96,6 +95,7 @@ function generateCacheEntry( clientReferenceManifest, encodedArguments, fn, + cacheKey, timeoutError ) } @@ -107,6 +107,7 @@ function generateCacheEntryWithRestoredWorkStore( clientReferenceManifest: DeepReadonly, encodedArguments: FormData | string, fn: (...args: unknown[]) => Promise, + cacheKey: string, timeoutError: UseCacheTimeoutError ) { // Since we cleared the AsyncLocalStorage we need to restore the workStore. @@ -125,6 +126,7 @@ function generateCacheEntryWithRestoredWorkStore( clientReferenceManifest, encodedArguments, fn, + cacheKey, timeoutError ) } @@ -136,6 +138,7 @@ function generateCacheEntryWithCacheContext( clientReferenceManifest: DeepReadonly, encodedArguments: FormData | string, fn: (...args: unknown[]) => Promise, + cacheKey: string, timeoutError: UseCacheTimeoutError ) { if (!workStore.cacheLifeProfiles) { @@ -185,7 +188,7 @@ function generateCacheEntryWithCacheContext( draftMode: outerWorkUnitStore && getDraftModeProviderForCacheScope(workStore, outerWorkUnitStore), - cookies: createCookiesForUseCacheStore(workStore, renderContext), + cookiesStore: createUseCacheCookiesStore(workStore, renderContext), } // const cacheStore: UseCacheStore | UseCacheWithCookiesStore = cookies @@ -202,6 +205,7 @@ function generateCacheEntryWithCacheContext( clientReferenceManifest, encodedArguments, fn, + cacheKey, timeoutError ) } @@ -238,11 +242,29 @@ function propagateCacheLifeAndTags( } } +// TODO: We should hash the cookies, or better yet hash the full cache key. +function serializeCookies(cookiesStore: UseCacheRequestCookiesStore): string { + const { accessedCookieNames, underlyingCookies } = cookiesStore + + if (accessedCookieNames === 'all') { + return underlyingCookies.toString() + } + + return [...accessedCookieNames] + .sort() + .map( + (name) => + `${encodeURIComponent(name)}=${encodeURIComponent(underlyingCookies.get(name)?.value ?? '')}` + ) + .join('; ') +} + async function collectResult( + cacheKey: string, savedStream: ReadableStream, workStore: WorkStore, outerWorkUnitStore: WorkUnitStore | undefined, - innerCacheStore: CommonUseCacheStore, + innerCacheStore: UseCacheStore, startTime: number, errors: Array // This is a live array that gets pushed into. ): Promise { @@ -303,7 +325,12 @@ async function collectResult( ? innerCacheStore.explicitStale : innerCacheStore.stale + if (innerCacheStore.cookiesStore?.type === 'request') { + cacheKey += serializeCookies(innerCacheStore.cookiesStore) + } + const entry: CacheEntry = { + key: cacheKey, value: bufferStream, timestamp: startTime, revalidate: collectedRevalidate, @@ -346,6 +373,7 @@ async function generateCacheEntryImpl( clientReferenceManifest: DeepReadonly, encodedArguments: FormData | string, fn: (...args: unknown[]) => Promise, + cacheKey: string, timeoutError: UseCacheTimeoutError ): Promise { const temporaryReferences = createServerTemporaryReferenceSet() @@ -497,6 +525,7 @@ async function generateCacheEntryImpl( const [returnStream, savedStream] = stream.tee() const pendingCacheEntry = collectResult( + cacheKey, savedStream, workStore, renderContext.workUnitStore, @@ -519,6 +548,7 @@ function cloneCacheEntry(entry: CacheEntry): [CacheEntry, CacheEntry] { const [streamA, streamB] = entry.value.tee() entry.value = streamA const clonedEntry: CacheEntry = { + key: entry.key, value: streamB, timestamp: entry.timestamp, revalidate: entry.revalidate, @@ -903,6 +933,7 @@ export function cache( clientReferenceManifest, encodedCacheKeyParts, fn, + serializedCacheKey, timeoutError ) @@ -982,6 +1013,7 @@ export function cache( clientReferenceManifest, encodedCacheKeyParts, fn, + serializedCacheKey, timeoutError ) diff --git a/test/e2e/app-dir/use-cache-cookies/use-cache-cookies.test.ts b/test/e2e/app-dir/use-cache-cookies/use-cache-cookies.test.ts index 276c9f40a840..cc598ae04222 100644 --- a/test/e2e/app-dir/use-cache-cookies/use-cache-cookies.test.ts +++ b/test/e2e/app-dir/use-cache-cookies/use-cache-cookies.test.ts @@ -51,5 +51,20 @@ describe('use-cache-cookies', () => { 'logged out' ) }) + + // TODO: Assert that data is actually cached for both variants, logged-in + // and logged-out. + it('should include used cookies in the cache key', async () => { + const browser = await next.browser('/') + + expect(await browser.elementById('login-status').text()).toBe( + 'logged out' + ) + + await browser.addCookie({ name: 'isLoggedIn', value: 'true' }) + await browser.refresh() + + expect(await browser.elementById('login-status').text()).toBe('logged in') + }) }) }) From 47776a8bf723ff17891495dd71b408f316d2e1b3 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Mon, 21 Apr 2025 18:27:04 +0200 Subject: [PATCH 05/57] Add undefined cookies to route prerender stores --- packages/next/src/server/route-modules/app-route/module.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/next/src/server/route-modules/app-route/module.ts b/packages/next/src/server/route-modules/app-route/module.ts index d2ba2b88ce8a..92f848f81160 100644 --- a/packages/next/src/server/route-modules/app-route/module.ts +++ b/packages/next/src/server/route-modules/app-route/module.ts @@ -396,6 +396,7 @@ export class AppRouteRouteModule extends RouteModule< tags: [...implicitTags.tags], prerenderResumeDataCache: null, hmrRefreshHash: undefined, + cookies: undefined, }) let prospectiveResult @@ -484,6 +485,7 @@ export class AppRouteRouteModule extends RouteModule< tags: [...implicitTags.tags], prerenderResumeDataCache: null, hmrRefreshHash: undefined, + cookies: undefined, }) let responseHandled = false From 4b290df7c840c166793066de2968030ddfa813b6 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Mon, 21 Apr 2025 19:46:54 +0200 Subject: [PATCH 06/57] Fix compiler errors related to RDC in test files --- packages/next/src/server/app-render/postponed-state.test.ts | 1 + .../next/src/server/resume-data-cache/resume-data-cache.test.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/next/src/server/app-render/postponed-state.test.ts b/packages/next/src/server/app-render/postponed-state.test.ts index f94fc5563a87..a2923c8c08d3 100644 --- a/packages/next/src/server/app-render/postponed-state.test.ts +++ b/packages/next/src/server/app-render/postponed-state.test.ts @@ -19,6 +19,7 @@ describe('getDynamicHTMLPostponedState', () => { prerenderResumeDataCache.cache.set( '1', Promise.resolve({ + key: '1', value: streamFromString('hello'), tags: [], stale: 0, diff --git a/packages/next/src/server/resume-data-cache/resume-data-cache.test.ts b/packages/next/src/server/resume-data-cache/resume-data-cache.test.ts index a06111c165d6..2c68b371222f 100644 --- a/packages/next/src/server/resume-data-cache/resume-data-cache.test.ts +++ b/packages/next/src/server/resume-data-cache/resume-data-cache.test.ts @@ -11,6 +11,7 @@ function createCacheWithSingleEntry() { cache.cache.set( 'success', Promise.resolve({ + key: 'success', value: streamFromString('value'), tags: [], stale: 0, From 2d155cc180e715a64faaaa89417fad9fd57a85f0 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Tue, 22 Apr 2025 17:11:57 +0200 Subject: [PATCH 07/57] Handle key changes in RDC --- .../server/app-render/postponed-state.test.ts | 9 +- .../server/resume-data-cache/cache-store.ts | 122 ++++++++++++------ .../resume-data-cache.test.ts | 8 +- .../resume-data-cache/resume-data-cache.ts | 10 +- 4 files changed, 98 insertions(+), 51 deletions(-) diff --git a/packages/next/src/server/app-render/postponed-state.test.ts b/packages/next/src/server/app-render/postponed-state.test.ts index a2923c8c08d3..fffaf3e4d5a8 100644 --- a/packages/next/src/server/app-render/postponed-state.test.ts +++ b/packages/next/src/server/app-render/postponed-state.test.ts @@ -45,8 +45,13 @@ describe('getDynamicHTMLPostponedState', () => { }, }, "renderResumeDataCache": { - "cache": Map { - "1" => Promise {}, + "cache": UseCacheCacheStore { + "cacheEntries": Map { + "1" => Promise {}, + }, + "pendingSets": Set { + Promise {}, + }, }, "decryptedBoundArgs": Map {}, "encryptedBoundArgs": Map {}, diff --git a/packages/next/src/server/resume-data-cache/cache-store.ts b/packages/next/src/server/resume-data-cache/cache-store.ts index 541d4c9b70d9..cffd77d33f7b 100644 --- a/packages/next/src/server/resume-data-cache/cache-store.ts +++ b/packages/next/src/server/resume-data-cache/cache-store.ts @@ -33,6 +33,7 @@ export type DecryptedBoundArgsCacheStore = CacheStore * Serialized format for "use cache" entries */ export interface UseCacheCacheStoreSerialized { + key: string value: string tags: string[] stale: number @@ -45,7 +46,46 @@ export interface UseCacheCacheStoreSerialized { * A cache store specifically for "use cache" values that stores promises of * cache entries. */ -export type UseCacheCacheStore = CacheStore> +export class UseCacheCacheStore { + private cacheEntries: Map> = new Map() + private pendingSets: Set> = new Set() + + set(key: string, pendingEntry: Promise): this { + this.cacheEntries.set(key, pendingEntry) + + const pendingPromise = pendingEntry + .then((entry) => { + // When the cache entry is resolved with a different key, we update the + // cache entries map accordingly. + if (entry.key !== key) { + this.cacheEntries.set(entry.key, Promise.resolve(entry)) + this.cacheEntries.delete(key) + } + }) + .catch(() => {}) + .finally(() => { + this.pendingSets.delete(pendingPromise) + }) + + this.pendingSets.add(pendingPromise) + + return this + } + + get(key: string): Promise | undefined { + return this.cacheEntries.get(key) + } + + async entries(): Promise]>> { + await Promise.all([...this.pendingSets]) + + return this.cacheEntries.entries() + } + + get size(): number { + return this.cacheEntries.size + } +} /** * Parses serialized cache entries into a UseCacheCacheStore @@ -53,14 +93,19 @@ export type UseCacheCacheStore = CacheStore> * @returns A new UseCacheCacheStore containing the parsed entries */ export function parseUseCacheCacheStore( - entries: Iterable<[string, UseCacheCacheStoreSerialized]> + values: Iterable ): UseCacheCacheStore { - const store = new Map>() + const store = new UseCacheCacheStore() - for (const [ + for (const { key, - { value, tags, stale, timestamp, expire, revalidate }, - ] of entries) { + value, + tags, + stale, + timestamp, + expire, + revalidate, + } of values) { store.set( key, Promise.resolve({ @@ -93,42 +138,41 @@ export function parseUseCacheCacheStore( * @returns A promise that resolves to an array of key-value pairs with serialized values */ export async function serializeUseCacheCacheStore( - entries: IterableIterator<[string, Promise]> + entries: Promise]>> ): Promise> { return Promise.all( - Array.from(entries).map(([key, value]) => { - return value - .then(async (entry) => { - const [left, right] = entry.value.tee() - entry.value = right - - let binaryString: string = '' - - // We want to encode the value as a string, but we aren't sure if the - // value is a a stream of UTF-8 bytes or not, so let's just encode it - // as a string using base64. - for await (const chunk of left) { - binaryString += arrayBufferToString(chunk) - } - - return [ + Array.from(await entries).map(async ([key, value]) => { + try { + const entry = await value + const [left, right] = entry.value.tee() + entry.value = right + + let binaryString: string = '' + + // We want to encode the value as a string, but we aren't sure if the + // value is a a stream of UTF-8 bytes or not, so let's just encode it + // as a string using base64. + for await (const chunk of left) { + binaryString += arrayBufferToString(chunk) + } + return [ + key, + { key, - { - // Encode the value as a base64 string. - value: btoa(binaryString), - tags: entry.tags, - stale: entry.stale, - timestamp: entry.timestamp, - expire: entry.expire, - revalidate: entry.revalidate, - }, - ] satisfies [string, UseCacheCacheStoreSerialized] - }) - .catch(() => { - // Any failed cache writes should be ignored as to not discard the - // entire cache. - return null - }) + // Encode the value as a base64 string. + value: btoa(binaryString), + tags: entry.tags, + stale: entry.stale, + timestamp: entry.timestamp, + expire: entry.expire, + revalidate: entry.revalidate, + }, + ] satisfies [string, UseCacheCacheStoreSerialized] + } catch { + // Any failed cache writes should be ignored as to not discard the + // entire cache. + return null + } }) ) } diff --git a/packages/next/src/server/resume-data-cache/resume-data-cache.test.ts b/packages/next/src/server/resume-data-cache/resume-data-cache.test.ts index 2c68b371222f..6ac7541796ea 100644 --- a/packages/next/src/server/resume-data-cache/resume-data-cache.test.ts +++ b/packages/next/src/server/resume-data-cache/resume-data-cache.test.ts @@ -9,9 +9,9 @@ import { inflateSync } from 'node:zlib' function createCacheWithSingleEntry() { const cache = createPrerenderResumeDataCache() cache.cache.set( - 'success', + 'original-key', Promise.resolve({ - key: 'success', + key: 'final-key', value: streamFromString('value'), tags: [], stale: 0, @@ -49,7 +49,7 @@ describe('stringifyResumeDataCache', () => { ).toString('utf-8') expect(decompressed).toMatchInlineSnapshot( - `"{"store":{"fetch":{},"cache":{"success":{"value":"dmFsdWU=","tags":[],"stale":0,"timestamp":0,"expire":0,"revalidate":0}},"encryptedBoundArgs":{}}}"` + `"{"store":{"fetch":{},"cache":{"final-key":{"key":"final-key","value":"dmFsdWU=","tags":[],"stale":0,"timestamp":0,"expire":0,"revalidate":0}},"encryptedBoundArgs":{}}}"` ) }) @@ -67,7 +67,7 @@ describe('stringifyResumeDataCache', () => { // We expect that the cache will still contain the successful entry but the // failed entry will be ignored and omitted from the output. expect(decompressed).toMatchInlineSnapshot( - `"{"store":{"fetch":{},"cache":{"success":{"value":"dmFsdWU=","tags":[],"stale":0,"timestamp":0,"expire":0,"revalidate":0}},"encryptedBoundArgs":{}}}"` + `"{"store":{"fetch":{},"cache":{"final-key":{"key":"final-key","value":"dmFsdWU=","tags":[],"stale":0,"timestamp":0,"expire":0,"revalidate":0}},"encryptedBoundArgs":{}}}"` ) }) }) diff --git a/packages/next/src/server/resume-data-cache/resume-data-cache.ts b/packages/next/src/server/resume-data-cache/resume-data-cache.ts index 44fcf18bcf93..64a22236b447 100644 --- a/packages/next/src/server/resume-data-cache/resume-data-cache.ts +++ b/packages/next/src/server/resume-data-cache/resume-data-cache.ts @@ -1,10 +1,10 @@ import { InvariantError } from '../../shared/lib/invariant-error' import { - type UseCacheCacheStore, type FetchCacheStore, type EncryptedBoundArgsCacheStore, serializeUseCacheCacheStore, parseUseCacheCacheStore, + UseCacheCacheStore, type DecryptedBoundArgsCacheStore, type UseCacheCacheStoreSerialized, } from './cache-store' @@ -51,8 +51,6 @@ export interface PrerenderResumeDataCache { * Supports both 'get' and 'set' operations to build the cache during * pre-rendering. */ - // TODO: For the dev warmup render, this needs to handle cache key changes - // during set operations, same as the default cache handler. readonly cache: UseCacheCacheStore /** @@ -147,7 +145,7 @@ export async function stringifyResumeDataCache( */ export function createPrerenderResumeDataCache(): PrerenderResumeDataCache { return { - cache: new Map(), + cache: new UseCacheCacheStore(), fetch: new Map(), encryptedBoundArgs: new Map(), decryptedBoundArgs: new Map(), @@ -185,7 +183,7 @@ export function createRenderResumeDataCache( if (prerenderResumeDataCacheOrPersistedCache === 'null') { return { - cache: new Map(), + cache: new UseCacheCacheStore(), fetch: new Map(), encryptedBoundArgs: new Map(), decryptedBoundArgs: new Map(), @@ -204,7 +202,7 @@ export function createRenderResumeDataCache( ) return { - cache: parseUseCacheCacheStore(Object.entries(json.store.cache)), + cache: parseUseCacheCacheStore(Object.values(json.store.cache)), fetch: new Map(Object.entries(json.store.fetch)), encryptedBoundArgs: new Map( Object.entries(json.store.encryptedBoundArgs) From 049f29b498f012bd514e89d38e5e957ca97a4203 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Wed, 23 Apr 2025 18:21:28 +0200 Subject: [PATCH 08/57] Include accessed cookies in cache key --- .../work-unit-async-storage.external.ts | 18 +++ .../src/server/use-cache/use-cache-wrapper.ts | 109 ++++++++++++------ .../fixtures/dynamic-io/app/layout.tsx | 2 +- .../fixtures/dynamic-io/app/page.tsx | 7 +- .../use-cache-cookies.test.ts | 36 +++++- 5 files changed, 130 insertions(+), 42 deletions(-) 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 2254cadafaa9..16a9c47509ac 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 @@ -358,3 +358,21 @@ export function getDraftModeProviderForCacheScope( return undefined } + +export function getCookies( + workUnitStore: WorkUnitStore +): ReadonlyRequestCookies | undefined { + if (workUnitStore.type === 'request' || workUnitStore.type === 'prerender') { + return workUnitStore.cookies + } + + if (workUnitStore.type === 'cache') { + const { cookiesStore } = workUnitStore + + if (cookiesStore?.type === 'request') { + return cookiesStore.underlyingCookies + } + } + + return undefined +} 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 35b0c72a334a..7c001695c2fd 100644 --- a/packages/next/src/server/use-cache/use-cache-wrapper.ts +++ b/packages/next/src/server/use-cache/use-cache-wrapper.ts @@ -17,7 +17,6 @@ import { unstable_prerender as prerender } from 'react-server-dom-webpack/static import type { WorkStore } from '../app-render/work-async-storage.external' import { workAsyncStorage } from '../app-render/work-async-storage.external' import type { - UseCacheRequestCookiesStore, UseCacheStore, WorkUnitStore, } from '../app-render/work-unit-async-storage.external' @@ -27,6 +26,7 @@ import { getPrerenderResumeDataCache, workUnitAsyncStorage, getDraftModeProviderForCacheScope, + getCookies, } from '../app-render/work-unit-async-storage.external' import { makeHangingPromise } from '../dynamic-rendering-utils' @@ -55,6 +55,7 @@ import React from 'react' import { createLazyResult, isResolvedLazyResult } from '../lib/lazy-result' import type { UseCacheRenderContext } from './types' import { createUseCacheCookiesStore } from '../request/cookies' +import type { ReadonlyRequestCookies } from '../web/spec-extension/adapters/request-cookies' type CacheKeyParts = | [buildId: string, id: string, args: unknown[]] @@ -72,6 +73,8 @@ const debug = process.env.NEXT_PRIVATE_DEBUG_CACHE ? console.debug.bind(console, 'use-cache:') : undefined +const accessedCookieNamesByCacheKey = new Map | 'all'>() + function generateCacheEntry( workStore: WorkStore, renderContext: UseCacheRenderContext, @@ -79,7 +82,7 @@ function generateCacheEntry( clientReferenceManifest: DeepReadonly, encodedArguments: FormData | string, fn: (...args: unknown[]) => Promise, - cacheKey: string, + cacheKeyWithoutCookies: string, timeoutError: UseCacheTimeoutError ) { // We need to run this inside a clean AsyncLocalStorage snapshot so that the cache @@ -95,7 +98,7 @@ function generateCacheEntry( clientReferenceManifest, encodedArguments, fn, - cacheKey, + cacheKeyWithoutCookies, timeoutError ) } @@ -107,7 +110,7 @@ function generateCacheEntryWithRestoredWorkStore( clientReferenceManifest: DeepReadonly, encodedArguments: FormData | string, fn: (...args: unknown[]) => Promise, - cacheKey: string, + cacheKeyWithoutCookies: string, timeoutError: UseCacheTimeoutError ) { // Since we cleared the AsyncLocalStorage we need to restore the workStore. @@ -126,7 +129,7 @@ function generateCacheEntryWithRestoredWorkStore( clientReferenceManifest, encodedArguments, fn, - cacheKey, + cacheKeyWithoutCookies, timeoutError ) } @@ -138,7 +141,7 @@ function generateCacheEntryWithCacheContext( clientReferenceManifest: DeepReadonly, encodedArguments: FormData | string, fn: (...args: unknown[]) => Promise, - cacheKey: string, + cacheKeyWithoutCookies: string, timeoutError: UseCacheTimeoutError ) { if (!workStore.cacheLifeProfiles) { @@ -205,7 +208,7 @@ function generateCacheEntryWithCacheContext( clientReferenceManifest, encodedArguments, fn, - cacheKey, + cacheKeyWithoutCookies, timeoutError ) } @@ -243,24 +246,25 @@ function propagateCacheLifeAndTags( } // TODO: We should hash the cookies, or better yet hash the full cache key. -function serializeCookies(cookiesStore: UseCacheRequestCookiesStore): string { - const { accessedCookieNames, underlyingCookies } = cookiesStore - +function serializeCookies( + accessedCookieNames: Set | 'all', + cookies: ReadonlyRequestCookies +): string { if (accessedCookieNames === 'all') { - return underlyingCookies.toString() + return cookies.toString() } return [...accessedCookieNames] .sort() - .map( - (name) => - `${encodeURIComponent(name)}=${encodeURIComponent(underlyingCookies.get(name)?.value ?? '')}` - ) + .map((name) => { + const value = cookies.get(name)?.value ?? '' + return `${encodeURIComponent(name)}=${encodeURIComponent(value)}` + }) .join('; ') } async function collectResult( - cacheKey: string, + cacheKeyWithoutCookies: string, savedStream: ReadableStream, workStore: WorkStore, outerWorkUnitStore: WorkUnitStore | undefined, @@ -325,8 +329,32 @@ async function collectResult( ? innerCacheStore.explicitStale : innerCacheStore.stale + let cacheKey = cacheKeyWithoutCookies + if (innerCacheStore.cookiesStore?.type === 'request') { - cacheKey += serializeCookies(innerCacheStore.cookiesStore) + const { accessedCookieNames, underlyingCookies } = + innerCacheStore.cookiesStore + + let allAccessedCookieNames = accessedCookieNamesByCacheKey.get( + cacheKeyWithoutCookies + ) + + // We only add newly accessed cookies, and never remove any previously + // accessed ones. + if (allAccessedCookieNames === undefined) { + allAccessedCookieNames = accessedCookieNames + } else if (allAccessedCookieNames !== 'all') { + for (const name of accessedCookieNames) { + allAccessedCookieNames.add(name) + } + } + + accessedCookieNamesByCacheKey.set( + cacheKeyWithoutCookies, + allAccessedCookieNames + ) + + cacheKey += serializeCookies(allAccessedCookieNames, underlyingCookies) } const entry: CacheEntry = { @@ -373,7 +401,7 @@ async function generateCacheEntryImpl( clientReferenceManifest: DeepReadonly, encodedArguments: FormData | string, fn: (...args: unknown[]) => Promise, - cacheKey: string, + cacheKeyWithoutCookies: string, timeoutError: UseCacheTimeoutError ): Promise { const temporaryReferences = createServerTemporaryReferenceSet() @@ -525,7 +553,7 @@ async function generateCacheEntryImpl( const [returnStream, savedStream] = stream.tee() const pendingCacheEntry = collectResult( - cacheKey, + cacheKeyWithoutCookies, savedStream, workStore, renderContext.workUnitStore, @@ -754,13 +782,24 @@ export function cache( { temporaryReferences, signal: hangingInputAbortSignal } ) - const serializedCacheKey = + const cacheKeyWithoutCookies = typeof encodedCacheKeyParts === 'string' ? // Fast path for the simple case for simple inputs. We let the CacheHandler // Convert it to an ArrayBuffer if it wants to. encodedCacheKeyParts : await encodeFormData(encodedCacheKeyParts) + let cacheKey = cacheKeyWithoutCookies + const cookies = workUnitStore && getCookies(workUnitStore) + + if (cookies) { + const accessedCookieNames = accessedCookieNamesByCacheKey.get(cacheKey) + + if (accessedCookieNames) { + cacheKey += serializeCookies(accessedCookieNames, cookies) + } + } + let stream: undefined | ReadableStream = undefined // Get an immutable and mutable versions of the resume data cache. @@ -780,7 +819,7 @@ export function cache( if (cacheSignal) { cacheSignal.beginRead() } - const cachedEntry = renderResumeDataCache.cache.get(serializedCacheKey) + const cachedEntry = renderResumeDataCache.cache.get(cacheKey) if (cachedEntry !== undefined) { const existingEntry = await cachedEntry propagateCacheLifeAndTags(workUnitStore, existingEntry) @@ -840,7 +879,7 @@ export function cache( let entry = shouldForceRevalidate(workStore, workUnitStore) ? undefined : await cacheHandler.get( - serializedCacheKey, + cacheKey, workUnitStore?.implicitTags?.tags ?? [] ) @@ -869,7 +908,7 @@ export function cache( implicitTagsExpiration ) ) { - debug?.('discarding stale entry', serializedCacheKey) + debug?.('discarding stale entry', cacheKey) entry = undefined } } @@ -913,14 +952,14 @@ export function cache( if (entry) { if (currentTime > entry.timestamp + entry.expire * 1000) { - debug?.('entry is expired', serializedCacheKey) + debug?.('entry is expired', cacheKey) } if ( workStore.isStaticGeneration && currentTime > entry.timestamp + entry.revalidate * 1000 ) { - debug?.('static generation, entry is stale', serializedCacheKey) + debug?.('static generation, entry is stale', cacheKey) } } @@ -933,7 +972,7 @@ export function cache( clientReferenceManifest, encodedCacheKeyParts, fn, - serializedCacheKey, + cacheKeyWithoutCookies, timeoutError ) @@ -952,17 +991,14 @@ export function cache( const split = clonePendingCacheEntry(pendingCacheEntry) savedCacheEntry = getNthCacheEntry(split, 0) prerenderResumeDataCache.cache.set( - serializedCacheKey, + cacheKey, getNthCacheEntry(split, 1) ) } else { savedCacheEntry = pendingCacheEntry } - const promise = cacheHandler.set( - serializedCacheKey, - savedCacheEntry - ) + const promise = cacheHandler.set(cacheKey, savedCacheEntry) workStore.pendingRevalidateWrites ??= [] workStore.pendingRevalidateWrites.push(promise) @@ -986,7 +1022,7 @@ export function cache( } prerenderResumeDataCache.cache.set( - serializedCacheKey, + cacheKey, Promise.resolve(entryRight) ) } else { @@ -1013,7 +1049,7 @@ export function cache( clientReferenceManifest, encodedCacheKeyParts, fn, - serializedCacheKey, + cacheKeyWithoutCookies, timeoutError ) @@ -1025,17 +1061,14 @@ export function cache( const split = clonePendingCacheEntry(pendingCacheEntry) savedCacheEntry = getNthCacheEntry(split, 0) prerenderResumeDataCache.cache.set( - serializedCacheKey, + cacheKey, getNthCacheEntry(split, 1) ) } else { savedCacheEntry = pendingCacheEntry } - const promise = cacheHandler.set( - serializedCacheKey, - savedCacheEntry - ) + const promise = cacheHandler.set(cacheKey, savedCacheEntry) workStore.pendingRevalidateWrites ??= [] workStore.pendingRevalidateWrites.push(promise) diff --git a/test/e2e/app-dir/use-cache-cookies/fixtures/dynamic-io/app/layout.tsx b/test/e2e/app-dir/use-cache-cookies/fixtures/dynamic-io/app/layout.tsx index 40875e8987bf..c0bd6b770893 100644 --- a/test/e2e/app-dir/use-cache-cookies/fixtures/dynamic-io/app/layout.tsx +++ b/test/e2e/app-dir/use-cache-cookies/fixtures/dynamic-io/app/layout.tsx @@ -11,7 +11,7 @@ export default async function Root({ children }: { children: ReactNode }) { return ( -

{await getCachedDate()}

+

Layout: {await getCachedDate()}

Loading...

}> {children}
diff --git a/test/e2e/app-dir/use-cache-cookies/fixtures/dynamic-io/app/page.tsx b/test/e2e/app-dir/use-cache-cookies/fixtures/dynamic-io/app/page.tsx index 45c70b1aa5b0..1842f9bd01c9 100644 --- a/test/e2e/app-dir/use-cache-cookies/fixtures/dynamic-io/app/page.tsx +++ b/test/e2e/app-dir/use-cache-cookies/fixtures/dynamic-io/app/page.tsx @@ -7,5 +7,10 @@ export default async function Page() { await setTimeout(2000) const loggedIn = (await cookies()).get('isLoggedIn') - return

{loggedIn ? 'logged in' : 'logged out'}

+ return ( + <> +

Page: {new Date().toISOString()}

+

{loggedIn ? 'logged in' : 'logged out'}

+ + ) } diff --git a/test/e2e/app-dir/use-cache-cookies/use-cache-cookies.test.ts b/test/e2e/app-dir/use-cache-cookies/use-cache-cookies.test.ts index cc598ae04222..26e086b3c5bc 100644 --- a/test/e2e/app-dir/use-cache-cookies/use-cache-cookies.test.ts +++ b/test/e2e/app-dir/use-cache-cookies/use-cache-cookies.test.ts @@ -52,8 +52,6 @@ describe('use-cache-cookies', () => { ) }) - // TODO: Assert that data is actually cached for both variants, logged-in - // and logged-out. it('should include used cookies in the cache key', async () => { const browser = await next.browser('/') @@ -61,10 +59,44 @@ describe('use-cache-cookies', () => { 'logged out' ) + const cachedInPageLoggedOut = await browser + .elementById('cached-in-page') + .text() + + await browser.refresh() + + expect(await browser.elementById('cached-in-page').text()).toBe( + cachedInPageLoggedOut + ) + await browser.addCookie({ name: 'isLoggedIn', value: 'true' }) await browser.refresh() expect(await browser.elementById('login-status').text()).toBe('logged in') + + const cachedInPageLoggedIn = await browser + .elementById('cached-in-page') + .text() + + expect(cachedInPageLoggedIn).not.toBe(cachedInPageLoggedOut) + + await browser.refresh() + + expect(await browser.elementById('cached-in-page').text()).toBe( + cachedInPageLoggedIn + ) + }) + + it('should ignore changes in unused cookies', async () => { + const browser = await next.browser('/') + const cachedInPage = await browser.elementById('cached-in-page').text() + + await browser.addCookie({ name: 'other', value: 'foo' }) + await browser.refresh() + + expect(await browser.elementById('cached-in-page').text()).toBe( + cachedInPage + ) }) }) }) From fbd133fa369b6a3471024d88fa47fbb6d8dc8517 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Thu, 24 Apr 2025 12:52:23 +0200 Subject: [PATCH 09/57] Add `keys` method to `UseCacheCacheStore` --- packages/next/src/server/resume-data-cache/cache-store.ts | 6 ++++++ test/e2e/app-dir/use-cache/use-cache.test.ts | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/next/src/server/resume-data-cache/cache-store.ts b/packages/next/src/server/resume-data-cache/cache-store.ts index cffd77d33f7b..58f029989401 100644 --- a/packages/next/src/server/resume-data-cache/cache-store.ts +++ b/packages/next/src/server/resume-data-cache/cache-store.ts @@ -76,6 +76,12 @@ export class UseCacheCacheStore { return this.cacheEntries.get(key) } + async keys(): Promise> { + await Promise.all([...this.pendingSets]) + + return this.cacheEntries.keys() + } + async entries(): Promise]>> { await Promise.all([...this.pendingSets]) diff --git a/test/e2e/app-dir/use-cache/use-cache.test.ts b/test/e2e/app-dir/use-cache/use-cache.test.ts index 345809cf6eab..609fc48c9960 100644 --- a/test/e2e/app-dir/use-cache/use-cache.test.ts +++ b/test/e2e/app-dir/use-cache/use-cache.test.ts @@ -924,7 +924,7 @@ describe('use-cache', () => { JSON.parse(await next.readFile('.next/server/app/rdc.meta')).postponed ) - const cacheKeys = Array.from(resumeDataCache.cache.keys()) + const cacheKeys = Array.from(await resumeDataCache.cache.keys()) // There should be no cache entry for the "middle" cache function, because // it's only used inside another cache scope ("outer"). Whereas "inner" is From 65fa2d74b8b618f41851490325360cc3c0a34988 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Thu, 24 Apr 2025 17:00:13 +0200 Subject: [PATCH 10/57] Properly handle asynchronicity in `UseCacheCacheStore` --- .../server/app-render/postponed-state.test.ts | 34 ++---- .../server/resume-data-cache/cache-store.ts | 114 ++++++++++-------- .../resume-data-cache.test.ts | 41 +++++-- .../resume-data-cache/resume-data-cache.ts | 5 +- .../src/server/use-cache/use-cache-wrapper.ts | 48 +++++--- 5 files changed, 139 insertions(+), 103 deletions(-) diff --git a/packages/next/src/server/app-render/postponed-state.test.ts b/packages/next/src/server/app-render/postponed-state.test.ts index fffaf3e4d5a8..048b08e7bcb2 100644 --- a/packages/next/src/server/app-render/postponed-state.test.ts +++ b/packages/next/src/server/app-render/postponed-state.test.ts @@ -16,10 +16,10 @@ describe('getDynamicHTMLPostponedState', () => { const fallbackRouteParams = new Map([['slug', key]]) const prerenderResumeDataCache = createPrerenderResumeDataCache() - prerenderResumeDataCache.cache.set( - '1', + await prerenderResumeDataCache.cache.set( + 'original-key', Promise.resolve({ - key: '1', + key: 'final-key', value: streamFromString('hello'), tags: [], stale: 0, @@ -36,32 +36,20 @@ describe('getDynamicHTMLPostponedState', () => { ) const parsed = parsePostponedState(state, { slug: '123' }) - expect(parsed).toMatchInlineSnapshot(` + if (parsed.type !== DynamicState.HTML) { + throw new Error('Expected HTML state') + } + + expect(parsed.data).toMatchInlineSnapshot(` { - "data": { + "123": "123", + "nested": { "123": "123", - "nested": { - "123": "123", - }, - }, - "renderResumeDataCache": { - "cache": UseCacheCacheStore { - "cacheEntries": Map { - "1" => Promise {}, - }, - "pendingSets": Set { - Promise {}, - }, - }, - "decryptedBoundArgs": Map {}, - "encryptedBoundArgs": Map {}, - "fetch": Map {}, }, - "type": 2, } `) - const value = await parsed.renderResumeDataCache.cache.get('1') + const value = await parsed.renderResumeDataCache.cache.get('final-key') expect(value).toBeDefined() diff --git a/packages/next/src/server/resume-data-cache/cache-store.ts b/packages/next/src/server/resume-data-cache/cache-store.ts index 58f029989401..913e3f59ea1f 100644 --- a/packages/next/src/server/resume-data-cache/cache-store.ts +++ b/packages/next/src/server/resume-data-cache/cache-store.ts @@ -47,48 +47,68 @@ export interface UseCacheCacheStoreSerialized { * cache entries. */ export class UseCacheCacheStore { - private cacheEntries: Map> = new Map() - private pendingSets: Set> = new Set() - - set(key: string, pendingEntry: Promise): this { - this.cacheEntries.set(key, pendingEntry) - - const pendingPromise = pendingEntry - .then((entry) => { - // When the cache entry is resolved with a different key, we update the - // cache entries map accordingly. - if (entry.key !== key) { - this.cacheEntries.set(entry.key, Promise.resolve(entry)) - this.cacheEntries.delete(key) - } - }) - .catch(() => {}) - .finally(() => { - this.pendingSets.delete(pendingPromise) + private cacheEntries: Map = new Map() + private pendingSets: Map> = new Map() + + async set( + cacheKey: string, + pendingEntry: Promise + ): Promise { + let resolvePendingPromise: (cacheKey: string) => void + + this.pendingSets.set( + cacheKey, + new Promise((resolve) => { + resolvePendingPromise = resolve }) + ) - this.pendingSets.add(pendingPromise) + try { + const entry = await pendingEntry + this.setSync(entry) + resolvePendingPromise!(entry.key) + } catch { + resolvePendingPromise!(cacheKey) + } finally { + this.pendingSets.delete(cacheKey) + } + } - return this + setSync(entry: CacheEntry): void { + this.cacheEntries.set(entry.key, entry) } - get(key: string): Promise | undefined { - return this.cacheEntries.get(key) + async get(cacheKey: string): Promise { + const pendingPromise = this.pendingSets.get(cacheKey) + + if (pendingPromise) { + cacheKey = await pendingPromise + } + + return this.cacheEntries.get(cacheKey) } async keys(): Promise> { - await Promise.all([...this.pendingSets]) + if (this.pendingSets.size > 0) { + await Promise.all([...this.pendingSets.values()]) + } return this.cacheEntries.keys() } - async entries(): Promise]>> { - await Promise.all([...this.pendingSets]) + async entries(): Promise> { + if (this.pendingSets.size > 0) { + await Promise.all([...this.pendingSets.values()]) + } return this.cacheEntries.entries() } - get size(): number { + async getSize(): Promise { + if (this.pendingSets.size > 0) { + await Promise.all([...this.pendingSets.values()]) + } + return this.cacheEntries.size } } @@ -112,27 +132,24 @@ export function parseUseCacheCacheStore( expire, revalidate, } of values) { - store.set( + store.setSync({ key, - Promise.resolve({ - key, - // Create a ReadableStream from the Uint8Array - value: new ReadableStream({ - start(controller) { - // Enqueue the Uint8Array to the stream - controller.enqueue(stringToUint8Array(atob(value))) - - // Close the stream - controller.close() - }, - }), - tags, - stale, - timestamp, - expire, - revalidate, - }) - ) + // Create a ReadableStream from the Uint8Array + value: new ReadableStream({ + start(controller) { + // Enqueue the Uint8Array to the stream + controller.enqueue(stringToUint8Array(atob(value))) + + // Close the stream + controller.close() + }, + }), + tags, + stale, + timestamp, + expire, + revalidate, + }) } return store @@ -144,12 +161,11 @@ export function parseUseCacheCacheStore( * @returns A promise that resolves to an array of key-value pairs with serialized values */ export async function serializeUseCacheCacheStore( - entries: Promise]>> + entries: Promise> ): Promise> { return Promise.all( - Array.from(await entries).map(async ([key, value]) => { + Array.from(await entries).map(async ([key, entry]) => { try { - const entry = await value const [left, right] = entry.value.tee() entry.value = right diff --git a/packages/next/src/server/resume-data-cache/resume-data-cache.test.ts b/packages/next/src/server/resume-data-cache/resume-data-cache.test.ts index 6ac7541796ea..f33e6aa385df 100644 --- a/packages/next/src/server/resume-data-cache/resume-data-cache.test.ts +++ b/packages/next/src/server/resume-data-cache/resume-data-cache.test.ts @@ -6,9 +6,9 @@ import { createPrerenderResumeDataCache } from './resume-data-cache' import { streamFromString } from '../stream-utils/node-web-streams-helper' import { inflateSync } from 'node:zlib' -function createCacheWithSingleEntry() { +async function createCacheWithSingleEntry() { const cache = createPrerenderResumeDataCache() - cache.cache.set( + await cache.cache.set( 'original-key', Promise.resolve({ key: 'final-key', @@ -24,9 +24,30 @@ function createCacheWithSingleEntry() { return cache } -function createCacheWithSingleEntryThatFails() { - const cache = createCacheWithSingleEntry() - cache.cache.set('fail', Promise.reject(new Error('Failed to serialize'))) +async function createCacheWithEntriesThatFail() { + const cache = await createCacheWithSingleEntry() + + await cache.cache.set( + 'fail-promise', + Promise.reject(new Error('Failed to serialize')) + ) + + await cache.cache.set( + 'fail-stream', + Promise.resolve({ + key: 'fail-stream', + value: new ReadableStream({ + start(controller) { + controller.error(new Error('Failed to serialize')) + }, + }), + tags: [], + stale: 0, + timestamp: 0, + expire: 0, + revalidate: 0, + }) + ) return cache } @@ -38,7 +59,7 @@ describe('stringifyResumeDataCache', () => { }) it('serializes a cache with a single entry', async () => { - const cache = createCacheWithSingleEntry() + const cache = await createCacheWithSingleEntry() const compressed = await stringifyResumeDataCache(cache) // We have to decompress the output because the compressed string is not @@ -53,8 +74,8 @@ describe('stringifyResumeDataCache', () => { ) }) - it('serializes a cache with a single entry that fails', async () => { - const cache = createCacheWithSingleEntryThatFails() + it('serializes a cache with entries that fail', async () => { + const cache = await createCacheWithEntriesThatFail() const compressed = await stringifyResumeDataCache(cache) // We have to decompress the output because the compressed string is not @@ -80,12 +101,12 @@ describe('parseResumeDataCache', () => { }) it('parses a cache with a single entry', async () => { - const cache = createCacheWithSingleEntry() + const cache = await createCacheWithSingleEntry() const serialized = await stringifyResumeDataCache(cache) const parsed = createRenderResumeDataCache(serialized) - expect(parsed.cache.size).toBe(1) + expect(await parsed.cache.getSize()).toBe(1) expect(parsed.fetch.size).toBe(0) }) }) diff --git a/packages/next/src/server/resume-data-cache/resume-data-cache.ts b/packages/next/src/server/resume-data-cache/resume-data-cache.ts index 64a22236b447..05461023438f 100644 --- a/packages/next/src/server/resume-data-cache/resume-data-cache.ts +++ b/packages/next/src/server/resume-data-cache/resume-data-cache.ts @@ -107,7 +107,10 @@ export async function stringifyResumeDataCache( '`stringifyResumeDataCache` should not be called in edge runtime.' ) } else { - if (resumeDataCache.fetch.size === 0 && resumeDataCache.cache.size === 0) { + if ( + resumeDataCache.fetch.size === 0 && + (await resumeDataCache.cache.getSize()) === 0 + ) { return 'null' } 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 7c001695c2fd..cbc76074b66f 100644 --- a/packages/next/src/server/use-cache/use-cache-wrapper.ts +++ b/packages/next/src/server/use-cache/use-cache-wrapper.ts @@ -819,9 +819,10 @@ export function cache( if (cacheSignal) { cacheSignal.beginRead() } - const cachedEntry = renderResumeDataCache.cache.get(cacheKey) - if (cachedEntry !== undefined) { - const existingEntry = await cachedEntry + + const existingEntry = await renderResumeDataCache.cache.get(cacheKey) + + if (existingEntry !== undefined) { propagateCacheLifeAndTags(workUnitStore, existingEntry) if ( workUnitStore !== undefined && @@ -913,6 +914,11 @@ export function cache( } } + const addPendingCacheSet = (promise: Promise) => { + workStore.pendingRevalidateWrites ??= [] + workStore.pendingRevalidateWrites.push(promise) + } + const currentTime = performance.timeOrigin + performance.now() if ( workUnitStore !== undefined && @@ -990,18 +996,18 @@ export function cache( // Create a clone that goes into the cache scope memory cache. const split = clonePendingCacheEntry(pendingCacheEntry) savedCacheEntry = getNthCacheEntry(split, 0) - prerenderResumeDataCache.cache.set( - cacheKey, - getNthCacheEntry(split, 1) + + addPendingCacheSet( + prerenderResumeDataCache.cache.set( + cacheKey, + getNthCacheEntry(split, 1) + ) ) } else { savedCacheEntry = pendingCacheEntry } - const promise = cacheHandler.set(cacheKey, savedCacheEntry) - - workStore.pendingRevalidateWrites ??= [] - workStore.pendingRevalidateWrites.push(promise) + addPendingCacheSet(cacheHandler.set(cacheKey, savedCacheEntry)) } stream = newStream @@ -1021,9 +1027,11 @@ export function cache( stream = entryLeft.value } - prerenderResumeDataCache.cache.set( - cacheKey, - Promise.resolve(entryRight) + addPendingCacheSet( + prerenderResumeDataCache.cache.set( + cacheKey, + Promise.resolve(entryRight) + ) ) } else { // If we're not regenerating we need to signal that we've finished @@ -1060,18 +1068,18 @@ export function cache( if (prerenderResumeDataCache) { const split = clonePendingCacheEntry(pendingCacheEntry) savedCacheEntry = getNthCacheEntry(split, 0) - prerenderResumeDataCache.cache.set( - cacheKey, - getNthCacheEntry(split, 1) + + addPendingCacheSet( + prerenderResumeDataCache.cache.set( + cacheKey, + getNthCacheEntry(split, 1) + ) ) } else { savedCacheEntry = pendingCacheEntry } - const promise = cacheHandler.set(cacheKey, savedCacheEntry) - - workStore.pendingRevalidateWrites ??= [] - workStore.pendingRevalidateWrites.push(promise) + addPendingCacheSet(cacheHandler.set(cacheKey, savedCacheEntry)) await ignoredStream.cancel() } From b89a30864daba502682c74ecb7cd6ffea2a7d2da Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Thu, 24 Apr 2025 17:15:02 +0200 Subject: [PATCH 11/57] Disable mangling in test to debug failures --- test/e2e/app-dir/use-cache-cookies/use-cache-cookies.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/e2e/app-dir/use-cache-cookies/use-cache-cookies.test.ts b/test/e2e/app-dir/use-cache-cookies/use-cache-cookies.test.ts index 26e086b3c5bc..0677ab2128fb 100644 --- a/test/e2e/app-dir/use-cache-cookies/use-cache-cookies.test.ts +++ b/test/e2e/app-dir/use-cache-cookies/use-cache-cookies.test.ts @@ -7,6 +7,7 @@ describe('use-cache-cookies', () => { const { next, skipped } = nextTestSetup({ files: path.join(__dirname, 'fixtures', 'dynamic-io'), skipDeployment: true, // TODO: Activate later. + buildOptions: ['--no-mangling'], }) if (skipped) { From c4baec15c7cf56971d0a03a696b615cdf2ba81f0 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Fri, 25 Apr 2025 14:06:14 +0200 Subject: [PATCH 12/57] Add `renderContext` to `UseCacheStore` Kinda like a poor-man's version of #78159. :( --- .../work-unit-async-storage.external.ts | 28 +-- packages/next/src/server/request/cookies.ts | 217 +++--------------- .../src/server/use-cache/render-context.ts | 130 +++++++++++ packages/next/src/server/use-cache/types.ts | 17 -- .../src/server/use-cache/use-cache-errors.ts | 6 +- .../src/server/use-cache/use-cache-wrapper.ts | 163 +++++++------ .../fixtures/dynamic-io/app/page.tsx | 2 + .../use-cache-cookies.test.ts | 3 + 8 files changed, 257 insertions(+), 309 deletions(-) create mode 100644 packages/next/src/server/use-cache/render-context.ts delete mode 100644 packages/next/src/server/use-cache/types.ts 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 16a9c47509ac..4cb591bd3aa6 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 @@ -17,6 +17,7 @@ import type { Params } from '../request/params' import type { ImplicitTags } from '../lib/implicit-tags' import type { WorkStore } from './work-async-storage.external' import { NEXT_HMR_REFRESH_HASH_COOKIE } from '../../client/components/app-router-headers' +import type { UseCacheRenderContext } from '../use-cache/render-context' export type WorkUnitPhase = 'action' | 'render' | 'after' @@ -196,25 +197,12 @@ export interface CommonUseCacheStore extends CommonCacheStore { readonly forceRevalidate: boolean } -export interface UseCachePrerenderCookiesStore { - readonly type: 'prerender' - readonly getUserspaceCookies: () => Promise -} - -export interface UseCacheRequestCookiesStore { - readonly type: 'request' - readonly underlyingCookies: ReadonlyRequestCookies - readonly getUserspaceCookies: () => Promise - accessedCookieNames: Set | 'all' -} - -export type UseCacheCookiesStore = - | UseCachePrerenderCookiesStore - | UseCacheRequestCookiesStore - export interface UseCacheStore extends CommonUseCacheStore { readonly type: 'cache' - readonly cookiesStore: UseCacheCookiesStore | undefined + // The render context can only be undefined here because we currently don't + // forbid nesting `"use cache"` in `unstable_cache`, which we probably should. + // This breaks the render context chain. + readonly renderContext: UseCacheRenderContext | undefined } // export interface UseCacheWithCookiesStore extends CommonUseCacheStore { @@ -367,10 +355,10 @@ export function getCookies( } if (workUnitStore.type === 'cache') { - const { cookiesStore } = workUnitStore + const { renderContext } = workUnitStore - if (cookiesStore?.type === 'request') { - return cookiesStore.underlyingCookies + if (renderContext?.type === 'request') { + return renderContext.underlyingCookies } } diff --git a/packages/next/src/server/request/cookies.ts b/packages/next/src/server/request/cookies.ts index 66975aa22dae..c5386b0f2f31 100644 --- a/packages/next/src/server/request/cookies.ts +++ b/packages/next/src/server/request/cookies.ts @@ -5,14 +5,10 @@ import { RequestCookiesAdapter, } from '../web/spec-extension/adapters/request-cookies' import { RequestCookies } from '../web/spec-extension/cookies' -import { - workAsyncStorage, - type WorkStore, -} from '../app-render/work-async-storage.external' +import { workAsyncStorage } from '../app-render/work-async-storage.external' import { workUnitAsyncStorage, type PrerenderStoreModern, - type UseCacheCookiesStore, } from '../app-render/work-unit-async-storage.external' import { postponeWithTracking, @@ -29,7 +25,6 @@ import { scheduleImmediate } from '../../lib/scheduler' import { isRequestAPICallableInsideAfter } from './utils' import { ReflectAdapter } from '../web/spec-extension/adapters/reflect' import type { RequestCookie } from '@edge-runtime/cookies' -import type { UseCacheRenderContext } from '../use-cache/types' /** * In this version of Next.js `cookies()` returns a Promise however you can still reference the properties of the underlying cookies object @@ -83,8 +78,8 @@ export function cookies(): Promise { // return workUnitStore.cookies // } else if (workUnitStore.type === 'cache') { if (workUnitStore.type === 'cache') { - if (workUnitStore.cookiesStore) { - return workUnitStore.cookiesStore.getUserspaceCookies() + if (workUnitStore.renderContext) { + return workUnitStore.renderContext.getUserspaceCookies() } throw new Error( @@ -171,12 +166,7 @@ const CachedCookies = new WeakMap< Promise >() -const CachedCookiesForUseCache = new WeakMap< - CacheLifetime, - Promise ->() - -function makeDynamicallyTrackedExoticCookies( +export function makeDynamicallyTrackedExoticCookies( route: string, prerenderStore: PrerenderStoreModern ): Promise { @@ -235,93 +225,29 @@ function makeDynamicallyTrackedExoticCookies( return function () { abortAndThrow(`.${prop}()`) } - default: - return ReflectAdapter.get(target, prop, receiver) - } - }, - } - ) - CachedCookies.set(prerenderStore, promise) - - return promise -} - -function makeDynamicallyTrackedExoticCookiesForUseCache( - route: string, - prerenderStore: PrerenderStoreModern, - abortController: AbortController -): Promise { - const cachedPromise = CachedCookiesForUseCache.get(prerenderStore) - if (cachedPromise) { - return cachedPromise - } - - const abortAndThrow = (property: string) => { - const expression = `\`cookies()${property}\`` - const error = createCookiesAccessError(route, expression) - abortAndThrowOnSynchronousRequestDataAccess( - route, - expression, - error, - prerenderStore - ) - } - - const promise = makeHangingPromise( - prerenderStore.renderSignal, - '`cookies()`', - { - get: function get(target, prop, receiver) { - if (prop === 'then' || prop === 'status') { - abortController.abort( - new Error( - `Accessed \`cookies()\` in \`"use cache"\` during prerendering.` - ) - ) - } - - switch (prop) { - case Symbol.iterator: - return function () { - abortAndThrow('[Symbol.iterator]') - } - case 'size': - abortAndThrow('.size') - break - case 'get': - case 'getAll': - case 'has': - return function () { - abortAndThrow( - arguments.length === 0 - ? `.${prop}()` - : `.${prop}(${describeNameArg(arguments[0])})` - ) - } - case 'set': - case 'delete': - return function () { - abortAndThrow( - arguments.length === 0 - ? `.${prop}()` - : arguments[0] - ? `.${prop}(${describeNameArg(arguments[0])}, ...)` - : `.${prop}(...)` + case 'then': + case 'status': + console.log(new Date().toISOString(), 'awaited cookies, aborting') + const workUnitStore = workUnitAsyncStorage.getStore() + + if ( + workUnitStore?.type === 'cache' && + workUnitStore.renderContext?.type === 'prerender' + ) { + workUnitStore.renderContext.dynamicAccessAbortController.abort( + new Error( + `Accessed \`cookies()\` in \`"use cache"\` during prerendering.` + ) ) } - case 'clear': - case 'toString': - return function () { - abortAndThrow(`.${prop}()`) - } + // intentionally fallthrough default: return ReflectAdapter.get(target, prop, receiver) } }, } ) - - CachedCookiesForUseCache.set(prerenderStore, promise) + CachedCookies.set(prerenderStore, promise) return promise } @@ -335,9 +261,9 @@ function createProxiedCookies( if ( workUnitStore?.type === 'cache' && - workUnitStore.cookiesStore?.type === 'request' + workUnitStore.renderContext?.type === 'request' ) { - const { cookiesStore } = workUnitStore + const { renderContext } = workUnitStore // eslint-disable-next-line default-case switch (prop) { @@ -346,10 +272,10 @@ function createProxiedCookies( return function get( ...args: [name: string] | [RequestCookie] ): RequestCookie | undefined { - if (cookiesStore.accessedCookieNames !== 'all') { + if (renderContext.accessedCookieNames !== 'all') { for (const arg of args) { const cookieName = typeof arg === 'string' ? arg : arg.name - cookiesStore.accessedCookieNames.add(cookieName) + renderContext.accessedCookieNames.add(cookieName) } } @@ -357,15 +283,15 @@ function createProxiedCookies( } case 'has': return function get(name: string): RequestCookie | undefined { - if (cookiesStore.accessedCookieNames !== 'all') { - cookiesStore.accessedCookieNames.add(name) + if (renderContext.accessedCookieNames !== 'all') { + renderContext.accessedCookieNames.add(name) } return target.get.call(target, name) } case 'toString': return function get(name: string): RequestCookie | undefined { - cookiesStore.accessedCookieNames = 'all' + renderContext.accessedCookieNames = 'all' return target.get.call(target, name) } @@ -377,7 +303,7 @@ function createProxiedCookies( }) } -function makeUntrackedExoticCookies( +export function makeUntrackedExoticCookies( underlyingCookies: ReadonlyRequestCookies ): Promise { const cachedCookies = CachedCookies.get(underlyingCookies) @@ -441,7 +367,7 @@ function makeUntrackedExoticCookies( return promise } -function makeUntrackedExoticCookiesWithDevWarnings( +export function makeUntrackedExoticCookiesWithDevWarnings( underlyingCookies: ReadonlyRequestCookies, route?: string ): Promise { @@ -584,93 +510,6 @@ function makeUntrackedExoticCookiesWithDevWarnings( return promise } -export function createUseCacheCookiesStore( - workStore: WorkStore, - renderContext: UseCacheRenderContext -): UseCacheCookiesStore | undefined { - if (renderContext.type === 'prerender') { - const { workUnitStore, dynamicAccessAbortController } = renderContext - - // If there are cookies defined on a prerender store, this means we're doing - // a dev warmup render, which is more akin to a dynamic request than a - // prerender request, despite using a prerender work unit store. - if (workUnitStore.cookies) { - const userspaceCookies = makeUntrackedExoticCookies(workUnitStore.cookies) - - return { - type: 'request', - underlyingCookies: workUnitStore.cookies, - getUserspaceCookies: () => userspaceCookies, - accessedCookieNames: new Set(), - } - } - - const userspaceCookies = makeDynamicallyTrackedExoticCookiesForUseCache( - workStore.route, - workUnitStore, - dynamicAccessAbortController - ) - - return { - type: 'prerender', - getUserspaceCookies: () => userspaceCookies, - } - } - - if (!renderContext.workUnitStore) { - return undefined - } - - const { workUnitStore } = renderContext - - switch (workUnitStore.type) { - case 'prerender-ppr': - return { - type: 'prerender', - getUserspaceCookies: () => - postponeWithTracking( - workStore.route, - 'cookies', - workUnitStore.dynamicTracking - ), - } - case 'prerender-legacy': - return { - type: 'prerender', - getUserspaceCookies: () => - throwToInterruptStaticGeneration('cookies', workStore, workUnitStore), - } - // case 'cache-with-cookies': - // return workUnitStore.cookies - case 'cache': - return !workUnitStore.cookiesStore || - workUnitStore.cookiesStore.type === 'prerender' - ? workUnitStore.cookiesStore - : { - ...workUnitStore.cookiesStore, - // Each cache scope starts with a new set of accessed cookie names. - accessedCookieNames: new Set(), - } - case 'request': - const userspaceCookies = - process.env.NODE_ENV === 'development' && !workStore.isPrefetchRequest - ? makeUntrackedExoticCookiesWithDevWarnings( - workUnitStore.cookies, - workStore.route - ) - : makeUntrackedExoticCookies(workUnitStore.cookies) - - return { - type: 'request', - underlyingCookies: workUnitStore.cookies, - getUserspaceCookies: () => userspaceCookies, - accessedCookieNames: new Set(), - } - default: - return undefined - } -} - function describeNameArg(arg: unknown) { return typeof arg === 'object' && arg !== null && diff --git a/packages/next/src/server/use-cache/render-context.ts b/packages/next/src/server/use-cache/render-context.ts new file mode 100644 index 000000000000..533e60b18258 --- /dev/null +++ b/packages/next/src/server/use-cache/render-context.ts @@ -0,0 +1,130 @@ +import { + postponeWithTracking, + throwToInterruptStaticGeneration, +} from '../app-render/dynamic-rendering' +import type { WorkStore } from '../app-render/work-async-storage.external' +import type { WorkUnitStore } from '../app-render/work-unit-async-storage.external' +import { + makeUntrackedExoticCookies, + makeDynamicallyTrackedExoticCookies, + makeUntrackedExoticCookiesWithDevWarnings, +} from '../request/cookies' +import type { ReadonlyRequestCookies } from '../web/spec-extension/adapters/request-cookies' + +export interface UseCacheRenderContextPrerender { + readonly type: 'prerender' + readonly getUserspaceCookies: () => Promise + readonly renderSignal: AbortSignal + readonly dynamicAccessAbortController: AbortController +} + +export interface UseCacheRenderContextPrerenderPPR { + readonly type: 'prerender-ppr' + readonly getUserspaceCookies: () => Promise +} + +export interface UseCacheRenderContextPrerenderLegacy { + readonly type: 'prerender-legacy' + readonly getUserspaceCookies: () => Promise +} + +export interface UseCacheRenderContextRequest { + readonly type: 'request' + readonly underlyingCookies: ReadonlyRequestCookies + readonly getUserspaceCookies: () => Promise + accessedCookieNames: Set | 'all' +} + +export type UseCacheRenderContext = + | UseCacheRenderContextPrerender + | UseCacheRenderContextPrerenderPPR + | UseCacheRenderContextPrerenderLegacy + | UseCacheRenderContextRequest + +export function createUseCacheRenderContext( + workStore: WorkStore, + workUnitStore: WorkUnitStore +): UseCacheRenderContext | undefined { + if (workUnitStore.type === 'prerender') { + // If there are cookies defined on a prerender store, this means we're doing + // a dev warmup render, which is more akin to a dynamic request than a + // prerender request, despite using a prerender work unit store. + if (workUnitStore.cookies) { + const userspaceCookies = makeUntrackedExoticCookies(workUnitStore.cookies) + + return { + type: 'request', + underlyingCookies: workUnitStore.cookies, + getUserspaceCookies: () => userspaceCookies, + accessedCookieNames: new Set(), + } + } + + const userspaceCookies = makeDynamicallyTrackedExoticCookies( + workStore.route, + workUnitStore + ) + + return { + type: 'prerender', + getUserspaceCookies: () => userspaceCookies, + renderSignal: workUnitStore.renderSignal, + dynamicAccessAbortController: new AbortController(), + } + } + + switch (workUnitStore.type) { + case 'prerender-ppr': + return { + type: 'prerender-ppr', + getUserspaceCookies: () => + postponeWithTracking( + workStore.route, + 'cookies', + workUnitStore.dynamicTracking + ), + } + case 'prerender-legacy': + return { + type: 'prerender-legacy', + getUserspaceCookies: () => + throwToInterruptStaticGeneration('cookies', workStore, workUnitStore), + } + // case 'cache-with-cookies': + // return workUnitStore.cookies + case 'cache': + switch (workUnitStore.renderContext?.type) { + case 'request': + return { + ...workUnitStore.renderContext, + // Each cache scope starts with a new set of accessed cookie names. + accessedCookieNames: new Set(), + } + case 'prerender': + return { + ...workUnitStore.renderContext, + // Each cache scope gets its own abort controller. + dynamicAccessAbortController: new AbortController(), + } + default: + return workUnitStore.renderContext + } + case 'request': + const userspaceCookies = + process.env.NODE_ENV === 'development' && !workStore.isPrefetchRequest + ? makeUntrackedExoticCookiesWithDevWarnings( + workUnitStore.cookies, + workStore.route + ) + : makeUntrackedExoticCookies(workUnitStore.cookies) + + return { + type: 'request', + underlyingCookies: workUnitStore.cookies, + getUserspaceCookies: () => userspaceCookies, + accessedCookieNames: new Set(), + } + default: + return undefined + } +} diff --git a/packages/next/src/server/use-cache/types.ts b/packages/next/src/server/use-cache/types.ts deleted file mode 100644 index 500fd717a35d..000000000000 --- a/packages/next/src/server/use-cache/types.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { - PrerenderStoreModern, - WorkUnitStore, -} from '../app-render/work-unit-async-storage.external' - -export type UseCacheRenderContext = - | { - readonly type: 'prerender' - readonly workUnitStore: PrerenderStoreModern - readonly dynamicAccessAbortController: AbortController - } - | { - readonly type: 'other' - readonly workUnitStore: - | Exclude - | undefined - } diff --git a/packages/next/src/server/use-cache/use-cache-errors.ts b/packages/next/src/server/use-cache/use-cache-errors.ts index b7c1e907d8c3..832a5d49a60b 100644 --- a/packages/next/src/server/use-cache/use-cache-errors.ts +++ b/packages/next/src/server/use-cache/use-cache-errors.ts @@ -1,11 +1,15 @@ +import { workAsyncStorage } from '../app-render/work-async-storage.external' + const USE_CACHE_TIMEOUT_ERROR_CODE = 'USE_CACHE_TIMEOUT' export class UseCacheTimeoutError extends Error { digest: typeof USE_CACHE_TIMEOUT_ERROR_CODE = USE_CACHE_TIMEOUT_ERROR_CODE constructor() { + const page = workAsyncStorage.getStore()?.page super( - 'Filling a cache during prerender timed out, likely because request-specific arguments such as params, searchParams, cookies() or dynamic data were used inside "use cache".' + page + + ' Filling a cache during prerender timed out, likely because request-specific arguments such as params, searchParams, cookies() or dynamic data were used inside "use cache".' ) } } 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 cbc76074b66f..8bfc3b4df405 100644 --- a/packages/next/src/server/use-cache/use-cache-wrapper.ts +++ b/packages/next/src/server/use-cache/use-cache-wrapper.ts @@ -53,9 +53,8 @@ import { import type { Params } from '../request/params' import React from 'react' import { createLazyResult, isResolvedLazyResult } from '../lib/lazy-result' -import type { UseCacheRenderContext } from './types' -import { createUseCacheCookiesStore } from '../request/cookies' import type { ReadonlyRequestCookies } from '../web/spec-extension/adapters/request-cookies' +import { createUseCacheRenderContext } from './render-context' type CacheKeyParts = | [buildId: string, id: string, args: unknown[]] @@ -77,7 +76,7 @@ const accessedCookieNamesByCacheKey = new Map | 'all'>() function generateCacheEntry( workStore: WorkStore, - renderContext: UseCacheRenderContext, + workUnitStore: WorkUnitStore | undefined, kind: string, clientReferenceManifest: DeepReadonly, encodedArguments: FormData | string, @@ -93,7 +92,7 @@ function generateCacheEntry( return workStore.runInCleanSnapshot( generateCacheEntryWithRestoredWorkStore, workStore, - renderContext, + workUnitStore, kind, clientReferenceManifest, encodedArguments, @@ -105,7 +104,7 @@ function generateCacheEntry( function generateCacheEntryWithRestoredWorkStore( workStore: WorkStore, - renderContext: UseCacheRenderContext, + workUnitStore: WorkUnitStore | undefined, kind: string, clientReferenceManifest: DeepReadonly, encodedArguments: FormData | string, @@ -124,7 +123,7 @@ function generateCacheEntryWithRestoredWorkStore( workStore, generateCacheEntryWithCacheContext, workStore, - renderContext, + workUnitStore, kind, clientReferenceManifest, encodedArguments, @@ -136,7 +135,7 @@ function generateCacheEntryWithRestoredWorkStore( function generateCacheEntryWithCacheContext( workStore: WorkStore, - renderContext: UseCacheRenderContext, + workUnitStore: WorkUnitStore | undefined, kind: string, clientReferenceManifest: DeepReadonly, encodedArguments: FormData | string, @@ -161,13 +160,11 @@ function generateCacheEntryWithCacheContext( ) } - const { workUnitStore: outerWorkUnitStore } = renderContext - const useCacheOrRequestStore = - outerWorkUnitStore?.type === 'request' || - // outerWorkUnitStore?.type === 'cache-with-cookies' || - outerWorkUnitStore?.type === 'cache' - ? outerWorkUnitStore + workUnitStore?.type === 'request' || + // workUnitStore?.type === 'cache-with-cookies' || + workUnitStore?.type === 'cache' + ? workUnitStore : undefined // Initialize the Store for this Cache entry. @@ -175,7 +172,7 @@ function generateCacheEntryWithCacheContext( type: 'cache', phase: 'render', kind, - implicitTags: outerWorkUnitStore?.implicitTags, + implicitTags: workUnitStore?.implicitTags, revalidate: defaultCacheLife.revalidate, expire: defaultCacheLife.expire, stale: defaultCacheLife.stale, @@ -184,14 +181,15 @@ function generateCacheEntryWithCacheContext( explicitStale: undefined, tags: null, hmrRefreshHash: - outerWorkUnitStore && getHmrRefreshHash(workStore, outerWorkUnitStore), + workUnitStore && getHmrRefreshHash(workStore, workUnitStore), isHmrRefresh: useCacheOrRequestStore?.isHmrRefresh ?? false, serverComponentsHmrCache: useCacheOrRequestStore?.serverComponentsHmrCache, - forceRevalidate: shouldForceRevalidate(workStore, outerWorkUnitStore), + forceRevalidate: shouldForceRevalidate(workStore, workUnitStore), draftMode: - outerWorkUnitStore && - getDraftModeProviderForCacheScope(workStore, outerWorkUnitStore), - cookiesStore: createUseCacheCookiesStore(workStore, renderContext), + workUnitStore && + getDraftModeProviderForCacheScope(workStore, workUnitStore), + renderContext: + workUnitStore && createUseCacheRenderContext(workStore, workUnitStore), } // const cacheStore: UseCacheStore | UseCacheWithCookiesStore = cookies @@ -202,7 +200,7 @@ function generateCacheEntryWithCacheContext( cacheStore, generateCacheEntryImpl, workStore, - renderContext, + workUnitStore, kind, cacheStore, clientReferenceManifest, @@ -312,28 +310,30 @@ async function collectResult( }, }) - const collectedTags = innerCacheStore.tags + const { + revalidate, + expire, + stale, + explicitRevalidate, + explicitExpire, + explicitStale, + renderContext, + tags, + } = innerCacheStore + + const collectedTags = tags // If cacheLife() was used to set an explicit revalidate time we use that. // Otherwise, we use the lowest of all inner fetch()/unstable_cache() or nested "use cache". // If they're lower than our default. const collectedRevalidate = - innerCacheStore.explicitRevalidate !== undefined - ? innerCacheStore.explicitRevalidate - : innerCacheStore.revalidate - const collectedExpire = - innerCacheStore.explicitExpire !== undefined - ? innerCacheStore.explicitExpire - : innerCacheStore.expire - const collectedStale = - innerCacheStore.explicitStale !== undefined - ? innerCacheStore.explicitStale - : innerCacheStore.stale + explicitRevalidate !== undefined ? explicitRevalidate : revalidate + const collectedExpire = explicitExpire !== undefined ? explicitExpire : expire + const collectedStale = explicitStale !== undefined ? explicitStale : stale let cacheKey = cacheKeyWithoutCookies - if (innerCacheStore.cookiesStore?.type === 'request') { - const { accessedCookieNames, underlyingCookies } = - innerCacheStore.cookiesStore + if (renderContext?.type === 'request') { + const { accessedCookieNames, underlyingCookies } = renderContext let allAccessedCookieNames = accessedCookieNamesByCacheKey.get( cacheKeyWithoutCookies @@ -395,7 +395,7 @@ type GenerateCacheEntryResult = async function generateCacheEntryImpl( workStore: WorkStore, - renderContext: UseCacheRenderContext, + workUnitStore: WorkUnitStore | undefined, _kind: string, innerCacheStore: UseCacheStore, clientReferenceManifest: DeepReadonly, @@ -424,19 +424,16 @@ async function generateCacheEntryImpl( // 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. - if (renderContext.type === 'prerender') { - const abortSignal = AbortSignal.any([ - renderContext.dynamicAccessAbortController.signal, - renderContext.workUnitStore.renderSignal, - ]) - + if (workUnitStore?.type === 'prerender') { await new Promise((resolve) => { - if (abortSignal.aborted) { + if (workUnitStore.renderSignal.aborted) { resolve() } else { - abortSignal.addEventListener('abort', () => resolve(), { - once: true, - }) + workUnitStore.renderSignal.addEventListener( + 'abort', + () => resolve(), + { once: true } + ) } }) } @@ -479,11 +476,16 @@ async function generateCacheEntryImpl( } let stream: ReadableStream + const { renderContext } = innerCacheStore + + if (renderContext?.type === 'prerender') { + const { signal: dynamicAccessAbortSignal } = + renderContext.dynamicAccessAbortController - if (renderContext.type === 'prerender') { - const { dynamicAccessAbortController, workUnitStore } = renderContext - const { signal: dynamicAccessAbortSignal } = dynamicAccessAbortController const timeoutAbortController = new AbortController() + const isProspectiveRender = + workUnitStore?.type === 'prerender' && workUnitStore.cacheSignal !== null + const { page } = workStore // If we're prerendering, we give you 50 seconds to fill a cache entry. // Otherwise we assume you stalled on hanging input and de-opt. This needs @@ -493,11 +495,12 @@ async function generateCacheEntryImpl( timeoutAbortController.abort(timeoutError) }, 50000) - const abortSignal = AbortSignal.any([ - workUnitStore.renderSignal, - dynamicAccessAbortSignal, - timeoutAbortController.signal, - ]) + const abortSignal = dynamicAccessAbortSignal + ? AbortSignal.any([ + dynamicAccessAbortSignal, + timeoutAbortController.signal, + ]) + : timeoutAbortController.signal const { prelude } = await prerender( resultPromise, @@ -516,6 +519,12 @@ async function generateCacheEntryImpl( } ) + console.log(new Date().toISOString(), 'got prelude', { + cacheKeyWithoutCookies, + isProspectiveRender, + page, + }) + clearTimeout(timer) if (timeoutAbortController.signal.aborted) { @@ -528,11 +537,20 @@ async function generateCacheEntryImpl( stream = prelude } - if (dynamicAccessAbortSignal.aborted) { - workUnitStore.cacheSignal?.endRead() + console.log(new Date().toISOString(), { + dynamicAccessAbortSignal: dynamicAccessAbortSignal?.aborted, + cacheKeyWithoutCookies, + isProspectiveRender, + page, + }) + + if (renderContext.dynamicAccessAbortController.signal?.aborted) { + if (workUnitStore?.type === 'prerender') { + workUnitStore.cacheSignal?.endRead() + } const hangingPromise = makeHangingPromise( - workUnitStore.renderSignal, + renderContext.renderSignal, dynamicAccessAbortSignal.reason.message ) @@ -556,7 +574,7 @@ async function generateCacheEntryImpl( cacheKeyWithoutCookies, savedStream, workStore, - renderContext.workUnitStore, + workUnitStore, innerCacheStore, startTime, errors @@ -653,20 +671,6 @@ function createTrackedReadableStream( }) } -function createRenderContext( - workUnitStore: WorkUnitStore | undefined -): UseCacheRenderContext { - if (!workUnitStore || workUnitStore.type !== 'prerender') { - return { type: 'other', workUnitStore } - } - - return { - type: 'prerender', - workUnitStore, - dynamicAccessAbortController: new AbortController(), - } -} - export function cache( kind: string, id: string, @@ -969,11 +973,9 @@ export function cache( } } - const renderContext = createRenderContext(workUnitStore) - const result = await generateCacheEntry( workStore, - renderContext, + workUnitStore, kind, clientReferenceManifest, encodedCacheKeyParts, @@ -1046,13 +1048,10 @@ export function cache( // revalidated entry. const result = await generateCacheEntry( workStore, - { - type: 'other', - // This is not running within the context of this unit. - // TODO: We may need to pass in a work unit store that - // includes the cookies though. - workUnitStore: undefined, - }, + // This is not running within the context of this unit. + // TODO: We may need to pass in a work unit store that includes + // the cookies though. + undefined, kind, clientReferenceManifest, encodedCacheKeyParts, diff --git a/test/e2e/app-dir/use-cache-cookies/fixtures/dynamic-io/app/page.tsx b/test/e2e/app-dir/use-cache-cookies/fixtures/dynamic-io/app/page.tsx index 1842f9bd01c9..b631df3d705a 100644 --- a/test/e2e/app-dir/use-cache-cookies/fixtures/dynamic-io/app/page.tsx +++ b/test/e2e/app-dir/use-cache-cookies/fixtures/dynamic-io/app/page.tsx @@ -4,7 +4,9 @@ import { setTimeout } from 'timers/promises' export default async function Page() { 'use cache' + console.log(new Date().toISOString(), 'render Page') await setTimeout(2000) + console.log(new Date().toISOString(), 'awaited timeout in Page') const loggedIn = (await cookies()).get('isLoggedIn') return ( diff --git a/test/e2e/app-dir/use-cache-cookies/use-cache-cookies.test.ts b/test/e2e/app-dir/use-cache-cookies/use-cache-cookies.test.ts index 0677ab2128fb..1f6213bf3b6b 100644 --- a/test/e2e/app-dir/use-cache-cookies/use-cache-cookies.test.ts +++ b/test/e2e/app-dir/use-cache-cookies/use-cache-cookies.test.ts @@ -8,6 +8,9 @@ describe('use-cache-cookies', () => { files: path.join(__dirname, 'fixtures', 'dynamic-io'), skipDeployment: true, // TODO: Activate later. buildOptions: ['--no-mangling'], + env: { + NEXT_PRIVATE_DEBUG_CACHE: '1', + }, }) if (skipped) { From deb49f45780be2700c329ddfc9dd742a05fc6ac9 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Fri, 25 Apr 2025 14:19:17 +0200 Subject: [PATCH 13/57] Cleanup debug logs --- .../src/server/use-cache/use-cache-errors.ts | 6 +----- .../src/server/use-cache/use-cache-wrapper.ts | 16 ---------------- 2 files changed, 1 insertion(+), 21 deletions(-) diff --git a/packages/next/src/server/use-cache/use-cache-errors.ts b/packages/next/src/server/use-cache/use-cache-errors.ts index 832a5d49a60b..b7c1e907d8c3 100644 --- a/packages/next/src/server/use-cache/use-cache-errors.ts +++ b/packages/next/src/server/use-cache/use-cache-errors.ts @@ -1,15 +1,11 @@ -import { workAsyncStorage } from '../app-render/work-async-storage.external' - const USE_CACHE_TIMEOUT_ERROR_CODE = 'USE_CACHE_TIMEOUT' export class UseCacheTimeoutError extends Error { digest: typeof USE_CACHE_TIMEOUT_ERROR_CODE = USE_CACHE_TIMEOUT_ERROR_CODE constructor() { - const page = workAsyncStorage.getStore()?.page super( - page + - ' Filling a cache during prerender timed out, likely because request-specific arguments such as params, searchParams, cookies() or dynamic data were used inside "use cache".' + 'Filling a cache during prerender timed out, likely because request-specific arguments such as params, searchParams, cookies() or dynamic data were used inside "use cache".' ) } } 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 8bfc3b4df405..1f82e29a1167 100644 --- a/packages/next/src/server/use-cache/use-cache-wrapper.ts +++ b/packages/next/src/server/use-cache/use-cache-wrapper.ts @@ -483,9 +483,6 @@ async function generateCacheEntryImpl( renderContext.dynamicAccessAbortController const timeoutAbortController = new AbortController() - const isProspectiveRender = - workUnitStore?.type === 'prerender' && workUnitStore.cacheSignal !== null - const { page } = workStore // If we're prerendering, we give you 50 seconds to fill a cache entry. // Otherwise we assume you stalled on hanging input and de-opt. This needs @@ -519,12 +516,6 @@ async function generateCacheEntryImpl( } ) - console.log(new Date().toISOString(), 'got prelude', { - cacheKeyWithoutCookies, - isProspectiveRender, - page, - }) - clearTimeout(timer) if (timeoutAbortController.signal.aborted) { @@ -537,13 +528,6 @@ async function generateCacheEntryImpl( stream = prelude } - console.log(new Date().toISOString(), { - dynamicAccessAbortSignal: dynamicAccessAbortSignal?.aborted, - cacheKeyWithoutCookies, - isProspectiveRender, - page, - }) - if (renderContext.dynamicAccessAbortController.signal?.aborted) { if (workUnitStore?.type === 'prerender') { workUnitStore.cacheSignal?.endRead() From ed11343e4f85f66dbe2f2811320f32b82c0cfaf9 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Fri, 25 Apr 2025 14:34:49 +0200 Subject: [PATCH 14/57] Remove rogue `console.log` statement --- packages/next/src/server/request/cookies.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/next/src/server/request/cookies.ts b/packages/next/src/server/request/cookies.ts index c5386b0f2f31..cf9804b2f39d 100644 --- a/packages/next/src/server/request/cookies.ts +++ b/packages/next/src/server/request/cookies.ts @@ -227,7 +227,6 @@ export function makeDynamicallyTrackedExoticCookies( } case 'then': case 'status': - console.log(new Date().toISOString(), 'awaited cookies, aborting') const workUnitStore = workUnitAsyncStorage.getStore() if ( From cb04c109dcaa053e456ae808f24f68e1030de1e3 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Fri, 25 Apr 2025 21:17:47 +0200 Subject: [PATCH 15/57] Remove debug logs --- .../app-dir/use-cache-cookies/fixtures/dynamic-io/app/page.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/test/e2e/app-dir/use-cache-cookies/fixtures/dynamic-io/app/page.tsx b/test/e2e/app-dir/use-cache-cookies/fixtures/dynamic-io/app/page.tsx index b631df3d705a..1842f9bd01c9 100644 --- a/test/e2e/app-dir/use-cache-cookies/fixtures/dynamic-io/app/page.tsx +++ b/test/e2e/app-dir/use-cache-cookies/fixtures/dynamic-io/app/page.tsx @@ -4,9 +4,7 @@ import { setTimeout } from 'timers/promises' export default async function Page() { 'use cache' - console.log(new Date().toISOString(), 'render Page') await setTimeout(2000) - console.log(new Date().toISOString(), 'awaited timeout in Page') const loggedIn = (await cookies()).get('isLoggedIn') return ( From a6928ba4daea0d7e64d36aacbd1d6e985b6a81bb Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Tue, 6 May 2025 10:57:31 +0200 Subject: [PATCH 16/57] Rename `doNotThrowOnEmptyStaticShell` to `allowEmptyStaticShell` --- packages/next/src/build/index.ts | 3 +-- packages/next/src/export/worker.ts | 8 ++++---- packages/next/src/server/app-render/app-render.tsx | 2 +- packages/next/src/server/app-render/types.ts | 9 +++++---- packages/next/src/server/config-schema.ts | 2 +- packages/next/src/server/config-shared.ts | 10 ++++++---- 6 files changed, 18 insertions(+), 16 deletions(-) diff --git a/packages/next/src/build/index.ts b/packages/next/src/build/index.ts index 8e73e892b2e4..f352c723fd8d 100644 --- a/packages/next/src/build/index.ts +++ b/packages/next/src/build/index.ts @@ -2683,8 +2683,7 @@ export default async function build( _isDynamicError: isDynamicError, _isAppDir: true, _isRoutePPREnabled: isRoutePPREnabled, - _doNotThrowOnEmptyStaticShell: - !route.throwOnEmptyStaticShell, + _allowEmptyStaticShell: !route.throwOnEmptyStaticShell, } }) }) diff --git a/packages/next/src/export/worker.ts b/packages/next/src/export/worker.ts index 2aa6a4e48459..ee1acdee932f 100644 --- a/packages/next/src/export/worker.ts +++ b/packages/next/src/export/worker.ts @@ -104,9 +104,9 @@ async function exportPageImpl( // the renderOpts. _isRoutePPREnabled: isRoutePPREnabled, - // Configure the rendering of the page not to throw if an empty static shell - // is generated while rendering using PPR. - _doNotThrowOnEmptyStaticShell: doNotThrowOnEmptyStaticShell = false, + // Configure the rendering of the page to allow that an empty static shell + // is generated while rendering using PPR and Dynamic IO. + _allowEmptyStaticShell: allowEmptyStaticShell = false, // Pull the original query out. query: originalQuery = {}, @@ -266,7 +266,7 @@ async function exportPageImpl( // If it's static, then it won't affect anything. // If it's dynamic, then it can be handled when request hits the route. serveStreamingMetadata: true, - doNotThrowOnEmptyStaticShell, + allowEmptyStaticShell, experimental: { ...input.renderOpts.experimental, isRoutePPREnabled, diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index cf05ffabd1ef..6b988c28d9ea 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -3107,7 +3107,7 @@ async function prerenderToStream( // If we've disabled throwing on empty static shell, then we don't need to // track any dynamic access that occurs above the suspense boundary because // we'll do so in the route shell. - if (!ctx.renderOpts.doNotThrowOnEmptyStaticShell) { + if (!ctx.renderOpts.allowEmptyStaticShell) { throwIfDisallowedDynamic( workStore, preludeIsEmpty, diff --git a/packages/next/src/server/app-render/types.ts b/packages/next/src/server/app-render/types.ts index f997afe93ba1..d9684dddfc74 100644 --- a/packages/next/src/server/app-render/types.ts +++ b/packages/next/src/server/app-render/types.ts @@ -269,11 +269,12 @@ export interface RenderOptsPartial { isStaticGeneration?: boolean /** - * When true, the page will be rendered using the static rendering to detect - * any dynamic API's that would have stopped the page from being fully - * statically generated. + * When true, the page is prerendered as a fallback shell, while allowing any + * dynamic accesses to result in an empty shell. This is the case when there + * are also routes prerendered with a more complete set of params. + * Prerendering those routes would catch any invalid dynamic accesses. */ - doNotThrowOnEmptyStaticShell?: boolean + allowEmptyStaticShell?: boolean /** * next config experimental.devtoolSegmentExplorer diff --git a/packages/next/src/server/config-schema.ts b/packages/next/src/server/config-schema.ts index 9bb2cb10b52b..e0800f749b85 100644 --- a/packages/next/src/server/config-schema.ts +++ b/packages/next/src/server/config-schema.ts @@ -41,7 +41,7 @@ const zExportMap: zod.ZodType = z.record( _isAppDir: z.boolean().optional(), _isDynamicError: z.boolean().optional(), _isRoutePPREnabled: z.boolean().optional(), - _doNotThrowOnEmptyStaticShell: z.boolean().optional(), + _allowEmptyStaticShell: z.boolean().optional(), }) ) diff --git a/packages/next/src/server/config-shared.ts b/packages/next/src/server/config-shared.ts index a0e61bb6eda1..529ea5fce298 100644 --- a/packages/next/src/server/config-shared.ts +++ b/packages/next/src/server/config-shared.ts @@ -750,13 +750,15 @@ export type ExportPathMap = { _isRoutePPREnabled?: boolean /** - * When true, it indicates that the diagnostic render for this page is - * disabled. This is only used when the app has `experimental.ppr` and - * `experimental.dynamicIO` enabled. + * When true, the page is prerendered as a fallback shell, while allowing + * any dynamic accesses to result in an empty shell. This is the case when + * the app has `experimental.ppr` and `experimental.dynamicIO` enabled, and + * there are also routes prerendered with a more complete set of params. + * Prerendering those routes would catch any invalid dynamic accesses. * * @internal */ - _doNotThrowOnEmptyStaticShell?: boolean + _allowEmptyStaticShell?: boolean } } From 602b7c842e2f6ea979a0581b848094212715c809 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Tue, 6 May 2025 18:13:07 +0200 Subject: [PATCH 17/57] Add test cases --- .../app/with-cached-io/last-modified.jsx | 15 +++ .../app/with-cached-io/sentinel.ts | 7 + .../with-cached-io/with-suspense/layout.jsx | 17 +++ .../params-in-page/[slug]/page.jsx | 11 ++ .../params-not-in-page/[slug]/page.jsx | 9 ++ .../without-suspense/layout.jsx | 16 +++ .../params-in-page/[slug]/page.jsx | 11 ++ .../params-not-in-page/[slug]/page.jsx | 9 ++ .../app/{ => without-io}/[slug]/page.jsx | 0 .../app/{ => without-io}/layout.jsx | 0 .../empty-fallback-shells.test.ts | 120 ++++++++++++++++-- .../empty-fallback-shells/next.config.js | 1 + 12 files changed, 205 insertions(+), 11 deletions(-) create mode 100644 test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/last-modified.jsx create mode 100644 test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/sentinel.ts create mode 100644 test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/with-suspense/layout.jsx create mode 100644 test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/with-suspense/params-in-page/[slug]/page.jsx create mode 100644 test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/with-suspense/params-not-in-page/[slug]/page.jsx create mode 100644 test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/without-suspense/layout.jsx create mode 100644 test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/without-suspense/params-in-page/[slug]/page.jsx create mode 100644 test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/without-suspense/params-not-in-page/[slug]/page.jsx rename test/e2e/app-dir/empty-fallback-shells/app/{ => without-io}/[slug]/page.jsx (100%) rename test/e2e/app-dir/empty-fallback-shells/app/{ => without-io}/layout.jsx (100%) diff --git a/test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/last-modified.jsx b/test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/last-modified.jsx new file mode 100644 index 000000000000..35df71aa8770 --- /dev/null +++ b/test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/last-modified.jsx @@ -0,0 +1,15 @@ +export async function LastModified({ params }) { + const { slug } = await params + + return ( +

+ Page /{slug} last modified: {new Date().toISOString()} +

+ ) +} + +export async function CachedLastModified({ params }) { + 'use cache' + + return +} diff --git a/test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/sentinel.ts b/test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/sentinel.ts new file mode 100644 index 000000000000..4571ba8f47bb --- /dev/null +++ b/test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/sentinel.ts @@ -0,0 +1,7 @@ +const { PHASE_PRODUCTION_BUILD } = require('next/constants') + +export function getSentinelValue() { + return process.env.NEXT_PHASE === PHASE_PRODUCTION_BUILD + ? 'buildtime' + : 'runtime' +} diff --git a/test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/with-suspense/layout.jsx b/test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/with-suspense/layout.jsx new file mode 100644 index 000000000000..7e85641b121d --- /dev/null +++ b/test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/with-suspense/layout.jsx @@ -0,0 +1,17 @@ +'use cache' + +import { Suspense } from 'react' +import { getSentinelValue } from '../sentinel' + +export default async function Layout({ children }) { + return ( + + +
+ Layout: {new Date().toISOString()} +
+ Loading...

}>{children}
+ + + ) +} diff --git a/test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/with-suspense/params-in-page/[slug]/page.jsx b/test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/with-suspense/params-in-page/[slug]/page.jsx new file mode 100644 index 000000000000..d4d9730aceee --- /dev/null +++ b/test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/with-suspense/params-in-page/[slug]/page.jsx @@ -0,0 +1,11 @@ +'use cache' + +import { LastModified } from '../../../last-modified' + +export default async function Page({ params }) { + return +} + +export async function generateStaticParams() { + return [{ slug: 'foo' }] +} diff --git a/test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/with-suspense/params-not-in-page/[slug]/page.jsx b/test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/with-suspense/params-not-in-page/[slug]/page.jsx new file mode 100644 index 000000000000..8b9bedf3e6c7 --- /dev/null +++ b/test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/with-suspense/params-not-in-page/[slug]/page.jsx @@ -0,0 +1,9 @@ +import { CachedLastModified } from '../../../last-modified' + +export default async function Page({ params }) { + return +} + +export async function generateStaticParams() { + return [{ slug: 'foo' }] +} diff --git a/test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/without-suspense/layout.jsx b/test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/without-suspense/layout.jsx new file mode 100644 index 000000000000..a9acdbda7e2b --- /dev/null +++ b/test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/without-suspense/layout.jsx @@ -0,0 +1,16 @@ +'use cache' + +import { getSentinelValue } from '../sentinel' + +export default async function Layout({ children }) { + return ( + + +
+ Layout: {new Date().toISOString()} +
+ {children} + + + ) +} diff --git a/test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/without-suspense/params-in-page/[slug]/page.jsx b/test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/without-suspense/params-in-page/[slug]/page.jsx new file mode 100644 index 000000000000..d4d9730aceee --- /dev/null +++ b/test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/without-suspense/params-in-page/[slug]/page.jsx @@ -0,0 +1,11 @@ +'use cache' + +import { LastModified } from '../../../last-modified' + +export default async function Page({ params }) { + return +} + +export async function generateStaticParams() { + return [{ slug: 'foo' }] +} diff --git a/test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/without-suspense/params-not-in-page/[slug]/page.jsx b/test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/without-suspense/params-not-in-page/[slug]/page.jsx new file mode 100644 index 000000000000..8b9bedf3e6c7 --- /dev/null +++ b/test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/without-suspense/params-not-in-page/[slug]/page.jsx @@ -0,0 +1,9 @@ +import { CachedLastModified } from '../../../last-modified' + +export default async function Page({ params }) { + return +} + +export async function generateStaticParams() { + return [{ slug: 'foo' }] +} diff --git a/test/e2e/app-dir/empty-fallback-shells/app/[slug]/page.jsx b/test/e2e/app-dir/empty-fallback-shells/app/without-io/[slug]/page.jsx similarity index 100% rename from test/e2e/app-dir/empty-fallback-shells/app/[slug]/page.jsx rename to test/e2e/app-dir/empty-fallback-shells/app/without-io/[slug]/page.jsx diff --git a/test/e2e/app-dir/empty-fallback-shells/app/layout.jsx b/test/e2e/app-dir/empty-fallback-shells/app/without-io/layout.jsx similarity index 100% rename from test/e2e/app-dir/empty-fallback-shells/app/layout.jsx rename to test/e2e/app-dir/empty-fallback-shells/app/without-io/layout.jsx diff --git a/test/e2e/app-dir/empty-fallback-shells/empty-fallback-shells.test.ts b/test/e2e/app-dir/empty-fallback-shells/empty-fallback-shells.test.ts index 04089778ea26..ef4e6b9ed98b 100644 --- a/test/e2e/app-dir/empty-fallback-shells/empty-fallback-shells.test.ts +++ b/test/e2e/app-dir/empty-fallback-shells/empty-fallback-shells.test.ts @@ -1,21 +1,119 @@ import { nextTestSetup } from 'e2e-utils' describe('empty-fallback-shells', () => { - const { next, isNextDeploy } = nextTestSetup({ + const { next, isNextDeploy, isNextStart } = nextTestSetup({ files: __dirname, }) - it('should start and not postpone the response', async () => { - const res = await next.fetch('/world') - const html = await res.text() - expect(html).toContain('hello-world') + describe('without IO', () => { + it('should start and not postpone the response', async () => { + const res = await next.fetch('/without-io/world') + const html = await res.text() + expect(html).toContain('hello-world') - if (isNextDeploy) { - expect(res.headers.get('x-matched-path')).toBe('/[slug]') - } + if (isNextDeploy) { + expect(res.headers.get('x-matched-path')).toBe('/without-io/[slug]') + } - // If we didn't use the fallback shell, then we didn't postpone the response - // and therefore shouldn't have sent the postponed header. - expect(res.headers.get('x-nextjs-postponed')).not.toBe('1') + // If we didn't use the fallback shell, then we didn't postpone the + // response and therefore shouldn't have sent the postponed header. + expect(res.headers.get('x-nextjs-postponed')).not.toBe('1') + }) + }) + + describe('with cached IO', () => { + describe('and the page wrapped in Suspense', () => { + describe('and the params accessed in the cached page', () => { + it('resumes a postponed fallback shell', async () => { + const res = await next.fetch( + '/with-cached-io/with-suspense/params-in-page/bar' + ) + + const html = await res.text() + expect(html).toContain('page-bar') + + if (isNextStart) { + expect(html).toContain('layout-buildtime') + } else { + expect(html).toContain('layout-runtime') + } + + if (isNextDeploy) { + expect(res.headers.get('x-matched-path')).toBe( + '/with-cached-io/with-suspense/params-in-page/[slug]' + ) + } else if (isNextStart) { + expect(res.headers.get('x-nextjs-postponed')).toBe('1') + } + }) + }) + + describe('and the params accessed in cached non-page function', () => { + it('resumes a postponed fallback shell', async () => { + const res = await next.fetch( + '/with-cached-io/with-suspense/params-not-in-page/bar' + ) + + const html = await res.text() + expect(html).toContain('page-bar') + + if (isNextStart) { + expect(html).toContain('layout-buildtime') + } else { + expect(html).toContain('layout-runtime') + } + + if (isNextDeploy) { + expect(res.headers.get('x-matched-path')).toBe( + '/with-cached-io/with-suspense/params-not-in-page/[slug]' + ) + } else if (isNextStart) { + expect(res.headers.get('x-nextjs-postponed')).toBe('1') + } + }) + }) + }) + + describe('and the page not wrapped in Suspense', () => { + describe('and the params accessed in the cached page', () => { + it('does not resume a postponed fallback shell', async () => { + const res = await next.fetch( + '/with-cached-io/without-suspense/params-in-page/bar' + ) + + const html = await res.text() + expect(html).toContain('page-bar') + expect(html).toContain('layout-runtime') + + if (isNextDeploy) { + expect(res.headers.get('x-matched-path')).toBe( + '/with-cached-io/without-suspense/params-in-page/[slug]' + ) + } else { + expect(res.headers.get('x-nextjs-postponed')).not.toBe('1') + } + }) + }) + + describe('and the params accessed in cached non-page function', () => { + it('resumes a postponed fallback shell', async () => { + const res = await next.fetch( + '/with-cached-io/without-suspense/params-not-in-page/bar' + ) + + const html = await res.text() + expect(html).toContain('page-bar') + expect(html).toContain('layout-runtime') + + if (isNextDeploy) { + expect(res.headers.get('x-matched-path')).toBe( + '/with-cached-io/without-suspense/params-not-in-page/[slug]' + ) + } else { + expect(res.headers.get('x-nextjs-postponed')).not.toBe('1') + } + }) + }) + }) }) }) diff --git a/test/e2e/app-dir/empty-fallback-shells/next.config.js b/test/e2e/app-dir/empty-fallback-shells/next.config.js index ac4afcf43219..3dac20d4703c 100644 --- a/test/e2e/app-dir/empty-fallback-shells/next.config.js +++ b/test/e2e/app-dir/empty-fallback-shells/next.config.js @@ -4,6 +4,7 @@ const nextConfig = { experimental: { dynamicIO: true, + prerenderEarlyExit: false, }, } From 16bcdde32195b570551177d4e38901e56b109f5f Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Tue, 6 May 2025 18:17:44 +0200 Subject: [PATCH 18/57] Set `allowEmptyStaticShell` during revalidation --- packages/next/src/build/index.ts | 11 +++++++++++ packages/next/src/server/base-server.ts | 22 +++++++++++++++++----- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/packages/next/src/build/index.ts b/packages/next/src/build/index.ts index f352c723fd8d..d118c70c4fbc 100644 --- a/packages/next/src/build/index.ts +++ b/packages/next/src/build/index.ts @@ -310,6 +310,15 @@ export interface DynamicPrerenderManifestRoute { */ fallbackSourceRoute: string | undefined + /** + * When true, the page was prerendered as a fallback shell, while allowing any + * dynamic accesses to result in an empty shell. This is the case when there + * are also routes prerendered with a more complete set of params. + * Prerendering those routes would catch any invalid dynamic accesses. This + * flag needs to be considered during revalidation. + */ + allowEmptyStaticShell: boolean + prefetchDataRoute: string | null | undefined prefetchDataRouteRegex: string | null | undefined routeRegex: string @@ -3147,6 +3156,7 @@ export default async function build( }).re.source ), allowHeader: ALLOWED_HEADERS, + allowEmptyStaticShell: !route.throwOnEmptyStaticShell, } } } @@ -3623,6 +3633,7 @@ export default async function build( prefetchDataRoute: undefined, prefetchDataRouteRegex: undefined, allowHeader: ALLOWED_HEADERS, + allowEmptyStaticShell: false, } }) diff --git a/packages/next/src/server/base-server.ts b/packages/next/src/server/base-server.ts index d9cf2abc4cf6..8df058dfd4d0 100644 --- a/packages/next/src/server/base-server.ts +++ b/packages/next/src/server/base-server.ts @@ -2531,17 +2531,27 @@ export default abstract class Server< // When html bots request PPR page, perform the full dynamic rendering. const shouldWaitOnAllReady = isHtmlBot && isRoutePPREnabled + // This is a revalidation request if the request is for a static page, and + // it is not being resumed from a postponed render, and it is not a + // dynamic RSC request. + const isRevalidate = isSSG && !postponed && !isDynamicRSCRequest + + // When we're revalidating a fallback shell, we need to set + // `allowEmptyStaticShell` to the same value that was used when the shell + // was prerendered at build time. + const allowEmptyStaticShell = Boolean( + isRevalidate && + fallbackRouteParams && + prerenderManifest.dynamicRoutes[pathname]?.allowEmptyStaticShell + ) + const renderOpts: LoadedRenderOpts = { ...components, ...opts, ...(isAppPath ? { incrementalCache, - // This is a revalidation request if the request is for a static - // page and it is not being resumed from a postponed render and - // it is not a dynamic RSC request then it is a revalidation - // request. - isRevalidate: isSSG && !postponed && !isDynamicRSCRequest, + isRevalidate, serverActions: this.nextConfig.experimental.serverActions, } : {}), @@ -2578,6 +2588,7 @@ export default abstract class Server< onAfterTaskError: undefined, // only available in dev setIsrStatus: (this as any).setIsrStatus, + allowEmptyStaticShell, } if (isDebugStaticShell || isDebugDynamicAccesses) { @@ -3539,6 +3550,7 @@ export default abstract class Server< res.statusCode = cachedData.status } + console.log('POSTPONED', didPostpone) // Mark that the request did postpone. if (didPostpone) { res.setHeader(NEXT_DID_POSTPONE_HEADER, '1') From 4b21ef899f492332c94dfaedc3f12e873d8de39d Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Tue, 6 May 2025 18:38:14 +0200 Subject: [PATCH 19/57] Add `allowEmptyStaticShell` to prerender store --- .../next/src/server/app-render/app-render.tsx | 299 ++++++++++-------- .../work-unit-async-storage.external.ts | 8 + .../server/route-modules/app-route/module.ts | 2 + 3 files changed, 184 insertions(+), 125 deletions(-) diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index 6b988c28d9ea..be693f7e3b43 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -668,7 +668,13 @@ async function warmupDevRender( workStore, } = ctx - if (!renderOpts.dev) { + const { + allowEmptyStaticShell = false, + dev, + onInstrumentationRequestError, + } = renderOpts + + if (!dev) { throw new InvariantError( 'generateDynamicFlightRenderResult should never be called in `next start` mode.' ) @@ -680,7 +686,7 @@ async function warmupDevRender( ) function onFlightDataRenderError(err: DigestedError) { - return renderOpts.onInstrumentationRequestError?.( + return onInstrumentationRequestError?.( err, req, createErrorContext(ctx, 'react-server-components-payload') @@ -708,6 +714,7 @@ async function warmupDevRender( controller: prerenderController, cacheSignal, dynamicTracking: null, + allowEmptyStaticShell, revalidate: INFINITE_CACHE, expire: INFINITE_CACHE, stale: INFINITE_CACHE, @@ -1026,6 +1033,16 @@ async function getErrorRSCPayload( } satisfies InitialRSCPayload } +function assertClientReferenceManifest( + clientReferenceManifest: RenderOpts['clientReferenceManifest'] +): asserts clientReferenceManifest is NonNullable< + RenderOpts['clientReferenceManifest'] +> { + if (!clientReferenceManifest) { + throw new InvariantError('Expected clientReferenceManifest to be defined.') + } +} + // This component must run in an SSR context. It will render the RSC root component function App({ reactServerStream, @@ -1187,6 +1204,7 @@ async function renderToHTMLOrFlightImpl( const requestTimestamp = Date.now() const { + clientReferenceManifest, serverActionsManifest, ComponentMod, nextFontManifest, @@ -1278,8 +1296,7 @@ async function renderToHTMLOrFlightImpl( const appUsingSizeAdjustment = !!nextFontManifest?.appUsingSizeAdjust - // TODO: fix this typescript - const clientReferenceManifest = renderOpts.clientReferenceManifest! + assertClientReferenceManifest(clientReferenceManifest) const serverModuleMap = createServerModuleMap({ serverActionsManifest }) @@ -1733,69 +1750,86 @@ async function renderToStream( formState: any, postponedState: PostponedState | null ): Promise> { - const renderOpts = ctx.renderOpts - const ComponentMod = renderOpts.ComponentMod - // TODO: fix this typescript - const clientReferenceManifest = renderOpts.clientReferenceManifest! + const { assetPrefix, nonce, pagePath, renderOpts } = ctx + + const { + basePath, + botType, + buildManifest, + clientReferenceManifest, + ComponentMod, + crossOrigin, + dev = false, + experimental, + nextExport = false, + onInstrumentationRequestError, + page, + reactMaxHeadersLength, + shouldWaitOnAllReady, + subresourceIntegrityManifest, + supportsDynamicResponse, + } = renderOpts + + assertClientReferenceManifest(clientReferenceManifest) const { ServerInsertedHTMLProvider, renderServerInsertedHTML } = createServerInsertedHTML() const { ServerInsertedMetadataProvider, getServerInsertedMetadata } = - createServerInsertedMetadata(ctx.nonce) + createServerInsertedMetadata(nonce) const tracingMetadata = getTracedMetadata( getTracer().getTracePropagationData(), - renderOpts.experimental.clientTraceMetadata + experimental.clientTraceMetadata ) const polyfills: JSX.IntrinsicElements['script'][] = - renderOpts.buildManifest.polyfillFiles + buildManifest.polyfillFiles .filter( (polyfill) => polyfill.endsWith('.js') && !polyfill.endsWith('.module.js') ) .map((polyfill) => ({ - src: `${ctx.assetPrefix}/_next/${polyfill}${getAssetQueryString( + src: `${assetPrefix}/_next/${polyfill}${getAssetQueryString( ctx, false )}`, - integrity: renderOpts.subresourceIntegrityManifest?.[polyfill], - crossOrigin: renderOpts.crossOrigin, + integrity: subresourceIntegrityManifest?.[polyfill], + crossOrigin, noModule: true, - nonce: ctx.nonce, + nonce, })) const [preinitScripts, bootstrapScript] = getRequiredScripts( - renderOpts.buildManifest, + buildManifest, // Why is assetPrefix optional on renderOpts? // @TODO make it default empty string on renderOpts and get rid of it from ctx - ctx.assetPrefix, - renderOpts.crossOrigin, - renderOpts.subresourceIntegrityManifest, + assetPrefix, + crossOrigin, + subresourceIntegrityManifest, getAssetQueryString(ctx, true), - ctx.nonce, - renderOpts.page + nonce, + page ) const reactServerErrorsByDigest: Map = new Map() const silenceLogger = false function onHTMLRenderRSCError(err: DigestedError) { - return renderOpts.onInstrumentationRequestError?.( + return onInstrumentationRequestError?.( err, req, createErrorContext(ctx, 'react-server-components') ) } const serverComponentsErrorHandler = createHTMLReactServerErrorHandler( - !!renderOpts.dev, - !!renderOpts.nextExport, + dev, + nextExport, reactServerErrorsByDigest, silenceLogger, onHTMLRenderRSCError ) function onHTMLRenderSSRError(err: DigestedError) { - return renderOpts.onInstrumentationRequestError?.( + return onInstrumentationRequestError?.( err, req, createErrorContext(ctx, 'server-rendering') @@ -1804,8 +1838,8 @@ async function renderToStream( const allCapturedErrors: Array = [] const htmlRendererErrorHandler = createHTMLErrorHandler( - !!renderOpts.dev, - !!renderOpts.nextExport, + dev, + nextExport, reactServerErrorsByDigest, allCapturedErrors, silenceLogger, @@ -1820,13 +1854,13 @@ async function renderToStream( try { if ( // We only want this behavior when running `next dev` - renderOpts.dev && + dev && // We only want this behavior when we have React's dev builds available process.env.NODE_ENV === 'development' && // Edge routes never prerender so we don't have a Prerender environment for anything in edge runtime process.env.NEXT_RUNTIME !== 'edge' && // We only have a Prerender environment for projects opted into dynamicIO - renderOpts.experimental.dynamicIO + experimental.dynamicIO ) { // This is a dynamic render. We don't do dynamic tracking because we're not prerendering const RSCPayload: InitialRSCPayload & { @@ -1915,7 +1949,7 @@ async function renderToStream( // in the static prelude. const inlinedReactServerDataStream = createInlinedDataReadableStream( reactServerResult.tee(), - ctx.nonce, + nonce, formState ) @@ -1939,27 +1973,24 @@ async function renderToStream( clientReferenceManifest={clientReferenceManifest} ServerInsertedHTMLProvider={ServerInsertedHTMLProvider} ServerInsertedMetadataProvider={ServerInsertedMetadataProvider} - nonce={ctx.nonce} - gracefullyDegrade={!!ctx.renderOpts.botType} + nonce={nonce} + gracefullyDegrade={!!botType} />, postponed, - { - onError: htmlRendererErrorHandler, - nonce: ctx.nonce, - } + { onError: htmlRendererErrorHandler, nonce } ) const getServerInsertedHTML = makeGetServerInsertedHTML({ polyfills, renderServerInsertedHTML, serverCapturedErrors: allCapturedErrors, - basePath: renderOpts.basePath, + basePath, tracingMetadata: tracingMetadata, }) return await continueDynamicHTMLResume(htmlStream, { inlinedDataStream: createInlinedDataReadableStream( reactServerResult.consume(), - ctx.nonce, + nonce, formState ), getServerInsertedHTML, @@ -1981,18 +2012,18 @@ async function renderToStream( clientReferenceManifest={clientReferenceManifest} ServerInsertedHTMLProvider={ServerInsertedHTMLProvider} ServerInsertedMetadataProvider={ServerInsertedMetadataProvider} - gracefullyDegrade={!!ctx.renderOpts.botType} - nonce={ctx.nonce} + gracefullyDegrade={!!botType} + nonce={nonce} />, { onError: htmlRendererErrorHandler, - nonce: ctx.nonce, + nonce, onHeaders: (headers: Headers) => { headers.forEach((value, key) => { appendHeader(key, value) }) }, - maxHeadersLength: renderOpts.reactMaxHeadersLength, + maxHeadersLength: reactMaxHeadersLength, bootstrapScripts: [bootstrapScript], formState, } @@ -2002,7 +2033,7 @@ async function renderToStream( polyfills, renderServerInsertedHTML, serverCapturedErrors: allCapturedErrors, - basePath: renderOpts.basePath, + basePath, tracingMetadata: tracingMetadata, }) /** @@ -2023,20 +2054,18 @@ async function renderToStream( * coalescing, and ISR continue working as intended. */ const generateStaticHTML = - renderOpts.supportsDynamicResponse !== true || - !!renderOpts.shouldWaitOnAllReady + supportsDynamicResponse !== true || !!shouldWaitOnAllReady - const validateRootLayout = renderOpts.dev return await continueFizzStream(htmlStream, { inlinedDataStream: createInlinedDataReadableStream( reactServerResult.consume(), - ctx.nonce, + nonce, formState ), isStaticGeneration: generateStaticHTML, getServerInsertedHTML, getServerInsertedMetadata, - validateRootLayout, + validateRootLayout: dev, }) } catch (err) { if ( @@ -2059,7 +2088,7 @@ async function renderToStream( if (shouldBailoutToCSR) { const stack = getStackWithoutErrorMessage(err) error( - `${err.reason} should be wrapped in a suspense boundary at page "${ctx.pagePath}". Read more: https://nextjs.org/docs/messages/missing-suspense-with-csr-bailout\n${stack}` + `${err.reason} should be wrapped in a suspense boundary at page "${pagePath}". Read more: https://nextjs.org/docs/messages/missing-suspense-with-csr-bailout\n${stack}` ) throw err @@ -2074,10 +2103,7 @@ async function renderToStream( errorType = 'redirect' res.statusCode = getRedirectStatusCodeFromError(err) - const redirectUrl = addPathPrefix( - getURLFromRedirectError(err), - renderOpts.basePath - ) + const redirectUrl = addPathPrefix(getURLFromRedirectError(err), basePath) // If there were mutable cookies set, we need to set them on the // response. @@ -2092,12 +2118,12 @@ async function renderToStream( } const [errorPreinitScripts, errorBootstrapScript] = getRequiredScripts( - renderOpts.buildManifest, - ctx.assetPrefix, - renderOpts.crossOrigin, - renderOpts.subresourceIntegrityManifest, + buildManifest, + assetPrefix, + crossOrigin, + subresourceIntegrityManifest, getAssetQueryString(ctx, false), - ctx.nonce, + nonce, '/_not-found/page' ) @@ -2139,12 +2165,12 @@ async function renderToStream( ServerInsertedHTMLProvider={ServerInsertedHTMLProvider} preinitScripts={errorPreinitScripts} clientReferenceManifest={clientReferenceManifest} - gracefullyDegrade={!!ctx.renderOpts.botType} - nonce={ctx.nonce} + gracefullyDegrade={!!botType} + nonce={nonce} /> ), streamOptions: { - nonce: ctx.nonce, + nonce, // Include hydration scripts in the HTML bootstrapScripts: [errorBootstrapScript], formState, @@ -2169,16 +2195,14 @@ async function renderToStream( * coalescing, and ISR continue working as intended. */ const generateStaticHTML = - renderOpts.supportsDynamicResponse !== true || - !!renderOpts.shouldWaitOnAllReady - const validateRootLayout = renderOpts.dev + supportsDynamicResponse !== true || !!shouldWaitOnAllReady return await continueFizzStream(fizzStream, { inlinedDataStream: createInlinedDataReadableStream( // This is intentionally using the readable datastream from the // main render rather than the flight data from the error page // render reactServerResult.consume(), - ctx.nonce, + nonce, formState ), isStaticGeneration: generateStaticHTML, @@ -2186,11 +2210,11 @@ async function renderToStream( polyfills, renderServerInsertedHTML, serverCapturedErrors: [], - basePath: renderOpts.basePath, + basePath, tracingMetadata: tracingMetadata, }), getServerInsertedMetadata, - validateRootLayout, + validateRootLayout: dev, }) } catch (finalErr: any) { if ( @@ -2222,10 +2246,18 @@ async function spawnDynamicValidationInDev( clientReferenceManifest: NonNullable, requestStore: RequestStore ): Promise { - const { componentMod: ComponentMod, implicitTags, workStore } = ctx + const { + componentMod: ComponentMod, + getDynamicParamFromSegment, + implicitTags, + nonce, + renderOpts: { allowEmptyStaticShell = false, botType }, + workStore, + } = ctx + const rootParams = getRootParams( ComponentMod.tree, - ctx.getDynamicParamFromSegment + getDynamicParamFromSegment ) const hmrRefreshHash = requestStore.cookies.get( @@ -2254,6 +2286,7 @@ async function spawnDynamicValidationInDev( controller: initialServerPrerenderController, cacheSignal, dynamicTracking: null, + allowEmptyStaticShell, revalidate: INFINITE_CACHE, expire: INFINITE_CACHE, stale: INFINITE_CACHE, @@ -2273,6 +2306,7 @@ async function spawnDynamicValidationInDev( controller: initialClientController, cacheSignal, dynamicTracking: null, + allowEmptyStaticShell, revalidate: INFINITE_CACHE, expire: INFINITE_CACHE, stale: INFINITE_CACHE, @@ -2340,7 +2374,6 @@ async function spawnDynamicValidationInDev( } } - const nonce = '1' const { ServerInsertedHTMLProvider } = createServerInsertedHTML() const { ServerInsertedMetadataProvider } = createServerInsertedMetadata(nonce) @@ -2356,7 +2389,7 @@ async function spawnDynamicValidationInDev( clientReferenceManifest={clientReferenceManifest} ServerInsertedHTMLProvider={ServerInsertedHTMLProvider} ServerInsertedMetadataProvider={ServerInsertedMetadataProvider} - gracefullyDegrade={!!ctx.renderOpts.botType} + gracefullyDegrade={!!botType} nonce={nonce} />, { @@ -2420,6 +2453,7 @@ async function spawnDynamicValidationInDev( // During the final prerender we don't need to track cache access so we omit the signal cacheSignal: null, dynamicTracking: serverDynamicTracking, + allowEmptyStaticShell, revalidate: INFINITE_CACHE, expire: INFINITE_CACHE, stale: INFINITE_CACHE, @@ -2443,6 +2477,7 @@ async function spawnDynamicValidationInDev( // During the final prerender we don't need to track cache access so we omit the signal cacheSignal: null, dynamicTracking: clientDynamicTracking, + allowEmptyStaticShell, revalidate: INFINITE_CACHE, expire: INFINITE_CACHE, stale: INFINITE_CACHE, @@ -2504,8 +2539,8 @@ async function spawnDynamicValidationInDev( clientReferenceManifest={clientReferenceManifest} ServerInsertedHTMLProvider={ServerInsertedHTMLProvider} ServerInsertedMetadataProvider={ServerInsertedMetadataProvider} - gracefullyDegrade={!!ctx.renderOpts.botType} - nonce={ctx.nonce} + gracefullyDegrade={!!botType} + nonce={nonce} />, { signal: finalClientController.signal, @@ -2621,10 +2656,27 @@ async function prerenderToStream( workStore, } = ctx + const { + allowEmptyStaticShell = false, + basePath, + botType, + buildManifest, + clientReferenceManifest, + ComponentMod, + crossOrigin, + dev = false, + experimental, + isDebugDynamicAccesses, + nextExport = false, + onInstrumentationRequestError, + page, + reactMaxHeadersLength, + subresourceIntegrityManifest, + } = renderOpts + + assertClientReferenceManifest(clientReferenceManifest) + const rootParams = getRootParams(tree, getDynamicParamFromSegment) - const ComponentMod = renderOpts.ComponentMod - // TODO: fix this typescript - const clientReferenceManifest = renderOpts.clientReferenceManifest! const fallbackRouteParams = workStore.fallbackRouteParams const { ServerInsertedHTMLProvider, renderServerInsertedHTML } = @@ -2634,11 +2686,11 @@ async function prerenderToStream( const tracingMetadata = getTracedMetadata( getTracer().getTracePropagationData(), - renderOpts.experimental.clientTraceMetadata + experimental.clientTraceMetadata ) const polyfills: JSX.IntrinsicElements['script'][] = - renderOpts.buildManifest.polyfillFiles + buildManifest.polyfillFiles .filter( (polyfill) => polyfill.endsWith('.js') && !polyfill.endsWith('.module.js') @@ -2648,44 +2700,44 @@ async function prerenderToStream( ctx, false )}`, - integrity: renderOpts.subresourceIntegrityManifest?.[polyfill], - crossOrigin: renderOpts.crossOrigin, + integrity: subresourceIntegrityManifest?.[polyfill], + crossOrigin, noModule: true, - nonce: nonce, + nonce, })) const [preinitScripts, bootstrapScript] = getRequiredScripts( - renderOpts.buildManifest, + buildManifest, // Why is assetPrefix optional on renderOpts? // @TODO make it default empty string on renderOpts and get rid of it from ctx assetPrefix, - renderOpts.crossOrigin, - renderOpts.subresourceIntegrityManifest, + crossOrigin, + subresourceIntegrityManifest, getAssetQueryString(ctx, true), nonce, - renderOpts.page + page ) const reactServerErrorsByDigest: Map = new Map() // We don't report errors during prerendering through our instrumentation hooks - const silenceLogger = !!renderOpts.experimental.isRoutePPREnabled + const silenceLogger = !!experimental.isRoutePPREnabled function onHTMLRenderRSCError(err: DigestedError) { - return renderOpts.onInstrumentationRequestError?.( + return onInstrumentationRequestError?.( err, req, createErrorContext(ctx, 'react-server-components') ) } const serverComponentsErrorHandler = createHTMLReactServerErrorHandler( - !!renderOpts.dev, - !!renderOpts.nextExport, + dev, + nextExport, reactServerErrorsByDigest, silenceLogger, onHTMLRenderRSCError ) function onHTMLRenderSSRError(err: DigestedError) { - return renderOpts.onInstrumentationRequestError?.( + return onInstrumentationRequestError?.( err, req, createErrorContext(ctx, 'server-rendering') @@ -2693,8 +2745,8 @@ async function prerenderToStream( } const allCapturedErrors: Array = [] const htmlRendererErrorHandler = createHTMLErrorHandler( - !!renderOpts.dev, - !!renderOpts.nextExport, + dev, + nextExport, reactServerErrorsByDigest, allCapturedErrors, silenceLogger, @@ -2727,14 +2779,14 @@ async function prerenderToStream( const selectStaleTime = (stale: number) => stale === INFINITE_CACHE && - typeof renderOpts.experimental.staleTimes?.static === 'number' - ? renderOpts.experimental.staleTimes.static + typeof experimental.staleTimes?.static === 'number' + ? experimental.staleTimes.static : stale let prerenderStore: PrerenderStore | null = null try { - if (renderOpts.experimental.dynamicIO) { + if (experimental.dynamicIO) { /** * dynamicIO with PPR * @@ -2779,6 +2831,7 @@ async function prerenderToStream( controller: initialServerPrerenderController, cacheSignal, dynamicTracking: null, + allowEmptyStaticShell, revalidate: INFINITE_CACHE, expire: INFINITE_CACHE, stale: INFINITE_CACHE, @@ -2877,6 +2930,7 @@ async function prerenderToStream( controller: initialClientController, cacheSignal, dynamicTracking: null, + allowEmptyStaticShell, revalidate: INFINITE_CACHE, expire: INFINITE_CACHE, stale: INFINITE_CACHE, @@ -2897,7 +2951,7 @@ async function prerenderToStream( clientReferenceManifest={clientReferenceManifest} ServerInsertedHTMLProvider={ServerInsertedHTMLProvider} ServerInsertedMetadataProvider={ServerInsertedMetadataProvider} - gracefullyDegrade={!!ctx.renderOpts.botType} + gracefullyDegrade={!!botType} nonce={nonce} />, { @@ -2950,7 +3004,7 @@ async function prerenderToStream( let serverIsDynamic = false const finalServerController = new AbortController() const serverDynamicTracking = createDynamicTrackingState( - renderOpts.isDebugDynamicAccesses + isDebugDynamicAccesses ) const finalRenderPrerenderStore: PrerenderStore = (prerenderStore = { @@ -2963,6 +3017,7 @@ async function prerenderToStream( // During the final prerender we don't need to track cache access so we omit the signal cacheSignal: null, dynamicTracking: serverDynamicTracking, + allowEmptyStaticShell, revalidate: INFINITE_CACHE, expire: INFINITE_CACHE, stale: INFINITE_CACHE, @@ -3021,7 +3076,7 @@ async function prerenderToStream( )) const clientDynamicTracking = createDynamicTrackingState( - renderOpts.isDebugDynamicAccesses + isDebugDynamicAccesses ) const finalClientController = new AbortController() const finalClientPrerenderStore: PrerenderStore = { @@ -3034,6 +3089,7 @@ async function prerenderToStream( // For HTML Generation we don't need to track cache reads (RSC only) cacheSignal: null, dynamicTracking: clientDynamicTracking, + allowEmptyStaticShell, revalidate: INFINITE_CACHE, expire: INFINITE_CACHE, stale: INFINITE_CACHE, @@ -3060,7 +3116,7 @@ async function prerenderToStream( clientReferenceManifest={clientReferenceManifest} ServerInsertedHTMLProvider={ServerInsertedHTMLProvider} ServerInsertedMetadataProvider={ServerInsertedMetadataProvider} - gracefullyDegrade={!!ctx.renderOpts.botType} + gracefullyDegrade={!!botType} nonce={nonce} />, { @@ -3092,7 +3148,7 @@ async function prerenderToStream( appendHeader(key, value) }) }, - maxHeadersLength: renderOpts.reactMaxHeadersLength, + maxHeadersLength: reactMaxHeadersLength, bootstrapScripts: [bootstrapScript], } ), @@ -3107,7 +3163,7 @@ async function prerenderToStream( // If we've disabled throwing on empty static shell, then we don't need to // track any dynamic access that occurs above the suspense boundary because // we'll do so in the route shell. - if (!ctx.renderOpts.allowEmptyStaticShell) { + if (!allowEmptyStaticShell) { throwIfDisallowedDynamic( workStore, preludeIsEmpty, @@ -3121,7 +3177,7 @@ async function prerenderToStream( polyfills, renderServerInsertedHTML, serverCapturedErrors: allCapturedErrors, - basePath: renderOpts.basePath, + basePath, tracingMetadata: tracingMetadata, }) @@ -3193,7 +3249,7 @@ async function prerenderToStream( clientReferenceManifest={clientReferenceManifest} ServerInsertedHTMLProvider={ServerInsertedHTMLProvider} ServerInsertedMetadataProvider={ServerInsertedMetadataProvider} - gracefullyDegrade={!!ctx.renderOpts.botType} + gracefullyDegrade={!!botType} nonce={nonce} />, JSON.parse(JSON.stringify(postponed)), @@ -3231,11 +3287,9 @@ async function prerenderToStream( collectedTags: finalRenderPrerenderStore.tags, } } - } else if (renderOpts.experimental.isRoutePPREnabled) { + } else if (experimental.isRoutePPREnabled) { // We're statically generating with PPR and need to do dynamic tracking - let dynamicTracking = createDynamicTrackingState( - renderOpts.isDebugDynamicAccesses - ) + let dynamicTracking = createDynamicTrackingState(isDebugDynamicAccesses) const prerenderResumeDataCache = createPrerenderResumeDataCache() const reactServerPrerenderStore: PrerenderStore = (prerenderStore = { @@ -3294,7 +3348,7 @@ async function prerenderToStream( clientReferenceManifest={clientReferenceManifest} ServerInsertedHTMLProvider={ServerInsertedHTMLProvider} ServerInsertedMetadataProvider={ServerInsertedMetadataProvider} - gracefullyDegrade={!!ctx.renderOpts.botType} + gracefullyDegrade={!!botType} nonce={nonce} />, { @@ -3304,7 +3358,7 @@ async function prerenderToStream( appendHeader(key, value) }) }, - maxHeadersLength: renderOpts.reactMaxHeadersLength, + maxHeadersLength: reactMaxHeadersLength, bootstrapScripts: [bootstrapScript], } ) @@ -3312,7 +3366,7 @@ async function prerenderToStream( polyfills, renderServerInsertedHTML, serverCapturedErrors: allCapturedErrors, - basePath: renderOpts.basePath, + basePath, tracingMetadata: tracingMetadata, }) @@ -3427,7 +3481,7 @@ async function prerenderToStream( clientReferenceManifest={clientReferenceManifest} ServerInsertedHTMLProvider={ServerInsertedHTMLProvider} ServerInsertedMetadataProvider={ServerInsertedMetadataProvider} - gracefullyDegrade={!!ctx.renderOpts.botType} + gracefullyDegrade={!!botType} nonce={nonce} />, JSON.parse(JSON.stringify(postponed)), @@ -3507,7 +3561,7 @@ async function prerenderToStream( clientReferenceManifest={clientReferenceManifest} ServerInsertedHTMLProvider={ServerInsertedHTMLProvider} ServerInsertedMetadataProvider={ServerInsertedMetadataProvider} - gracefullyDegrade={!!ctx.renderOpts.botType} + gracefullyDegrade={!!botType} nonce={nonce} />, { @@ -3533,7 +3587,7 @@ async function prerenderToStream( polyfills, renderServerInsertedHTML, serverCapturedErrors: allCapturedErrors, - basePath: renderOpts.basePath, + basePath, tracingMetadata: tracingMetadata, }) return { @@ -3604,10 +3658,7 @@ async function prerenderToStream( errorType = 'redirect' res.statusCode = getRedirectStatusCodeFromError(err) - const redirectUrl = addPathPrefix( - getURLFromRedirectError(err), - renderOpts.basePath - ) + const redirectUrl = addPathPrefix(getURLFromRedirectError(err), basePath) setHeader('location', redirectUrl) } else if (!shouldBailoutToCSR) { @@ -3615,10 +3666,10 @@ async function prerenderToStream( } const [errorPreinitScripts, errorBootstrapScript] = getRequiredScripts( - renderOpts.buildManifest, + buildManifest, assetPrefix, - renderOpts.crossOrigin, - renderOpts.subresourceIntegrityManifest, + crossOrigin, + subresourceIntegrityManifest, getAssetQueryString(ctx, false), nonce, '/_not-found/page' @@ -3672,7 +3723,7 @@ async function prerenderToStream( ServerInsertedHTMLProvider={ServerInsertedHTMLProvider} preinitScripts={errorPreinitScripts} clientReferenceManifest={clientReferenceManifest} - gracefullyDegrade={!!ctx.renderOpts.botType} + gracefullyDegrade={!!botType} nonce={nonce} /> ), @@ -3698,8 +3749,6 @@ async function prerenderToStream( ) } - const validateRootLayout = renderOpts.dev - // This is intentionally using the readable datastream from the main // render rather than the flight data from the error page render const flightStream = @@ -3723,11 +3772,11 @@ async function prerenderToStream( polyfills, renderServerInsertedHTML, serverCapturedErrors: [], - basePath: renderOpts.basePath, + basePath, tracingMetadata: tracingMetadata, }), getServerInsertedMetadata, - validateRootLayout, + validateRootLayout: dev, }), dynamicAccess: null, collectedRevalidate: 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 4cb591bd3aa6..0815ae5c9e4b 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 @@ -107,6 +107,14 @@ export interface PrerenderStoreModern extends CommonWorkUnitStore { readonly rootParams: Params + /** + * When true, the page is prerendered as a fallback shell, while allowing any + * dynamic accesses to result in an empty shell. This is the case when there + * are also routes prerendered with a more complete set of params. + * Prerendering those routes would catch any invalid dynamic accesses. + */ + readonly allowEmptyStaticShell: boolean + // Collected revalidate times and tags for this document during the prerender. revalidate: number // in seconds. 0 means dynamic. INFINITE_CACHE and higher means never revalidate. expire: number // server expiration time diff --git a/packages/next/src/server/route-modules/app-route/module.ts b/packages/next/src/server/route-modules/app-route/module.ts index 92f848f81160..ac6ab840e7c6 100644 --- a/packages/next/src/server/route-modules/app-route/module.ts +++ b/packages/next/src/server/route-modules/app-route/module.ts @@ -390,6 +390,7 @@ export class AppRouteRouteModule extends RouteModule< // During prospective render we don't use a controller // because we need to let all caches fill. dynamicTracking, + allowEmptyStaticShell: false, revalidate: defaultRevalidate, expire: INFINITE_CACHE, stale: INFINITE_CACHE, @@ -479,6 +480,7 @@ export class AppRouteRouteModule extends RouteModule< controller: finalController, cacheSignal: null, dynamicTracking, + allowEmptyStaticShell: false, revalidate: defaultRevalidate, expire: INFINITE_CACHE, stale: INFINITE_CACHE, From a4adff83294562cfa8e5dbe35978ba88877bc2e0 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Tue, 6 May 2025 18:39:46 +0200 Subject: [PATCH 20/57] Add `allowEmptyStaticShell` to `UseCacheRenderContextPrerender` --- packages/next/src/server/use-cache/render-context.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/next/src/server/use-cache/render-context.ts b/packages/next/src/server/use-cache/render-context.ts index 533e60b18258..2ea5872044dc 100644 --- a/packages/next/src/server/use-cache/render-context.ts +++ b/packages/next/src/server/use-cache/render-context.ts @@ -16,6 +16,7 @@ export interface UseCacheRenderContextPrerender { readonly getUserspaceCookies: () => Promise readonly renderSignal: AbortSignal readonly dynamicAccessAbortController: AbortController + readonly allowEmptyStaticShell: boolean } export interface UseCacheRenderContextPrerenderPPR { @@ -70,6 +71,7 @@ export function createUseCacheRenderContext( getUserspaceCookies: () => userspaceCookies, renderSignal: workUnitStore.renderSignal, dynamicAccessAbortController: new AbortController(), + allowEmptyStaticShell: workUnitStore.allowEmptyStaticShell, } } From a359660b0dc062f0b4dbead79cfa891b9bda20ad Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Tue, 6 May 2025 18:55:34 +0200 Subject: [PATCH 21/57] Allow cache timeout due to awaiting fallback params --- .../app-render/create-component-tree.tsx | 27 ++++---- packages/next/src/server/request/params.ts | 25 ++++++- .../src/server/use-cache/use-cache-wrapper.ts | 68 ++++++++++++------- 3 files changed, 79 insertions(+), 41 deletions(-) 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 9c2f4b7a72ac..af8e0d8ce1f9 100644 --- a/packages/next/src/server/app-render/create-component-tree.tsx +++ b/packages/next/src/server/app-render/create-component-tree.tsx @@ -677,16 +677,21 @@ async function createComponentTreeInternal({ workStore ) - // TODO(useCache): Should we use this trick also if dynamicIO is enabled, - // instead of relying on the searchParams being a hanging promise? - if (!experimental.dynamicIO && isUseCacheFunction(PageComponent)) { + // If we are passing searchParams to a server component Page we need to + // track their usage in case the current render mode tracks dynamic API + // usage. + let searchParams = createServerSearchParamsForServerPage(query, workStore) + + if (isUseCacheFunction(PageComponent)) { const UseCachePageComponent: React.ComponentType = PageComponent - // The "use cache" wrapper takes care of converting this into an - // erroring search params promise when passing it to the original - // function. - const searchParams = Promise.resolve({}) + if (!experimental.dynamicIO) { + // The "use cache" wrapper takes care of converting this into an + // erroring search params promise when passing it to the original + // function. + searchParams = Promise.resolve({}) + } pageElement = ( ) } else { - // If we are passing searchParams to a server component Page we need to - // track their usage in case the current render mode tracks dynamic API - // usage. - const searchParams = createServerSearchParamsForServerPage( - query, - workStore - ) - pageElement = ( ) diff --git a/packages/next/src/server/request/params.ts b/packages/next/src/server/request/params.ts index f0ee12bd034a..04685f9303cc 100644 --- a/packages/next/src/server/request/params.ts +++ b/packages/next/src/server/request/params.ts @@ -210,7 +210,30 @@ function makeAbortingExoticParams( const promise = makeHangingPromise( prerenderStore.renderSignal, - '`params`' + '`params`', + { + get: function get(target, prop, receiver) { + switch (prop) { + case 'then': + case 'status': + const workUnitStore = workUnitAsyncStorage.getStore() + + if ( + workUnitStore?.type === 'cache' && + workUnitStore.renderContext?.type === 'prerender' + ) { + workUnitStore.renderContext.dynamicAccessAbortController.abort( + new Error( + `Accessed fallback \`params\` in \`"use cache"\` during prerendering.` + ) + ) + } + // intentionally fallthrough + default: + return ReflectAdapter.get(target, prop, receiver) + } + }, + } ) CachedParams.set(underlyingParams, promise) 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 1f82e29a1167..37714a48e1cc 100644 --- a/packages/next/src/server/use-cache/use-cache-wrapper.ts +++ b/packages/next/src/server/use-cache/use-cache-wrapper.ts @@ -495,6 +495,7 @@ async function generateCacheEntryImpl( const abortSignal = dynamicAccessAbortSignal ? AbortSignal.any([ dynamicAccessAbortSignal, + renderContext.renderSignal, timeoutAbortController.signal, ]) : timeoutAbortController.signal @@ -518,28 +519,33 @@ async function generateCacheEntryImpl( clearTimeout(timer) - if (timeoutAbortController.signal.aborted) { - stream = new ReadableStream({ - start(controller) { - controller.error(timeoutError) - }, - }) - } else { - stream = prelude - } - - if (renderContext.dynamicAccessAbortController.signal?.aborted) { + // When the prerender is aborted for any reason (including the timeout), and + // we're prerendering a static shell that is allowed to be empty, we return + // a hanging promise. This essentially makes the "use cache" function + // dynamic in the context of the fallback shell. When there's no suspense + // boundary above, the shell will be empty, which is allowed. + if (abortSignal.aborted && renderContext.allowEmptyStaticShell) { if (workUnitStore?.type === 'prerender') { workUnitStore.cacheSignal?.endRead() } const hangingPromise = makeHangingPromise( renderContext.renderSignal, - dynamicAccessAbortSignal.reason.message + abortSignal.reason ) return { type: 'prerender-dynamic', hangingPromise } } + + if (timeoutAbortController.signal.aborted) { + stream = new ReadableStream({ + start(controller) { + controller.error(timeoutError) + }, + }) + } else { + stream = prelude + } } else { stream = renderToReadableStream( resultPromise, @@ -707,28 +713,40 @@ export function cache( ? createHangingInputAbortSignal(workUnitStore) : undefined - // When dynamicIO is not enabled, we can not encode searchParams as - // hanging promises. To still avoid unused search params from making a - // page dynamic, we overwrite them here with a promise that resolves to an - // empty object, while also overwriting the to-be-invoked function for - // generating a cache entry with a function that creates an erroring - // searchParams prop before invoking the original function. This ensures - // that used searchParams inside of cached functions would still yield an - // error. - if (!workStore.dynamicIOEnabled && isPageComponent(args)) { + if (isPageComponent(args)) { + // For page components, the cache function is overwritten, which allows + // us to apply special handling for params and searchParams (see below). + const [{ params, searchParams }] = args // Overwrite the props to omit $$isPageComponent. args = [{ params, searchParams }] fn = { [name]: async ({ - params: serializedParams, + params: _serializedParams, + searchParams: serializedSearchParams, }: Omit) => originalFn.apply(null, [ { - params: serializedParams, - searchParams: - makeErroringExoticSearchParamsForUseCache(workStore), + // We're using the original params prop, and not the serialized + // one. While it's not generally true for "use cache" args, in + // the case of `params` the serialized and original object are + // essentially equivalent, so this is safe to do (including + // fallback params that are hanging promises). It allows us to + // avoid waiting for the timeout, when prerendering a fallback + // shell of a cached page that awaits params. + params, + searchParams: workStore.dynamicIOEnabled + ? serializedSearchParams + : // When dynamicIO is not enabled, we can not encode + // searchParams as a hanging promise. To still avoid unused + // search params from making a page dynamic, we define them + // in `createComponentTree` as a promise that resolves to an + // empty object. And here, we're creating an erroring + // searchParams prop, when invoking the original function. + // This ensures that used searchParams inside of cached + // functions would still yield an error. + makeErroringExoticSearchParamsForUseCache(workStore), }, ]), }[name] as (...args: unknown[]) => Promise From a18d6e4fe7c681a739f8667342bffea40c866907 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Tue, 6 May 2025 21:03:45 +0200 Subject: [PATCH 22/57] Revert cookies-related changes --- .../work-unit-async-storage.external.ts | 49 +-- .../src/server/lib/cache-handlers/default.ts | 22 +- .../src/server/lib/cache-handlers/types.ts | 10 - packages/next/src/server/request/cookies.ts | 351 ++++++++++-------- .../next/src/server/request/draft-mode.ts | 1 - .../next/src/server/request/root-params.ts | 1 - .../server/resume-data-cache/cache-store.ts | 183 +++------ .../resume-data-cache/resume-data-cache.ts | 13 +- .../src/server/use-cache/render-context.ts | 106 +----- .../src/server/use-cache/use-cache-wrapper.ts | 216 ++++------- 10 files changed, 343 insertions(+), 609 deletions(-) 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 0815ae5c9e4b..baa3ecbaf4de 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 @@ -132,13 +132,6 @@ export interface PrerenderStoreModern extends CommonWorkUnitStore { * subsequent dynamic render. */ readonly hmrRefreshHash: string | undefined - - /** - * The request cookies must only be provided during the dev warmup render to - * ensure that cache keys for cache functions that access cookies include the - * accessed cookies. - */ - readonly cookies: ReadonlyRequestCookies | undefined } export interface PrerenderStorePPR extends CommonWorkUnitStore { @@ -186,11 +179,8 @@ export interface CommonCacheStore readonly draftMode: DraftModeProvider | undefined } -export interface CommonUseCacheStore extends CommonCacheStore { - /** - * The cache kind, e.g. `"remote"` for `"use cache: remote"`. - */ - readonly kind: string +export interface UseCacheStore extends CommonCacheStore { + type: 'cache' // Collected revalidate times and tags for this cache entry during the cache render. revalidate: number // implicit revalidate time from inner caches / fetches expire: number // server expiration time @@ -203,22 +193,14 @@ export interface CommonUseCacheStore extends CommonCacheStore { readonly isHmrRefresh: boolean readonly serverComponentsHmrCache: ServerComponentsHmrCache | undefined readonly forceRevalidate: boolean -} - -export interface UseCacheStore extends CommonUseCacheStore { - readonly type: 'cache' // The render context can only be undefined here because we currently don't // forbid nesting `"use cache"` in `unstable_cache`, which we probably should. // This breaks the render context chain. readonly renderContext: UseCacheRenderContext | undefined } -// export interface UseCacheWithCookiesStore extends CommonUseCacheStore { -// readonly type: 'cache-with-cookies' -// } - export interface UnstableCacheStore extends CommonCacheStore { - readonly type: 'unstable-cache' + type: 'unstable-cache' } /** @@ -230,10 +212,7 @@ export interface UnstableCacheStore extends CommonCacheStore { * cache store, instead of generally making the request store available to cache * functions. */ -export type CacheStore = - | UseCacheStore - // | UseCacheWithCookiesStore - | UnstableCacheStore +export type CacheStore = UseCacheStore | UnstableCacheStore export type WorkUnitStore = RequestStore | CacheStore | PrerenderStore @@ -263,7 +242,6 @@ export function getExpectedRequestStore( ) case 'cache': - // case 'cache-with-cookies': throw new Error( `\`${callingExpression}\` cannot be called inside "use cache". Call it outside and pass an argument instead. Read more: https://nextjs.org/docs/messages/next-request-in-use-cache` ) @@ -304,7 +282,6 @@ export function getRenderResumeDataCache( if ( workUnitStore.type !== 'prerender-legacy' && workUnitStore.type !== 'cache' && - // workUnitStore.type !== 'cache-with-cookies' && workUnitStore.type !== 'unstable-cache' ) { if (workUnitStore.type === 'request') { @@ -354,21 +331,3 @@ export function getDraftModeProviderForCacheScope( return undefined } - -export function getCookies( - workUnitStore: WorkUnitStore -): ReadonlyRequestCookies | undefined { - if (workUnitStore.type === 'request' || workUnitStore.type === 'prerender') { - return workUnitStore.cookies - } - - if (workUnitStore.type === 'cache') { - const { renderContext } = workUnitStore - - if (renderContext?.type === 'request') { - return renderContext.underlyingCookies - } - } - - return undefined -} diff --git a/packages/next/src/server/lib/cache-handlers/default.ts b/packages/next/src/server/lib/cache-handlers/default.ts index 01a2dbd781c0..5e6044207071 100644 --- a/packages/next/src/server/lib/cache-handlers/default.ts +++ b/packages/next/src/server/lib/cache-handlers/default.ts @@ -39,12 +39,7 @@ const memoryCache = new LRUCache( 50 * 1024 * 1024, (entry) => entry.size ) - -/** - * Maps an original cache key to the final cache key after a set operation has - * resolved. - */ -const pendingSets = new Map>() +const pendingSets = new Map>() const debug = process.env.NEXT_PRIVATE_DEBUG_CACHE ? console.debug.bind(console, 'DefaultCacheHandler:') @@ -56,9 +51,7 @@ const DefaultCacheHandler: CacheHandlerV2 = { if (pendingPromise) { debug?.('get', cacheKey, 'pending') - const finalCacheKey = await pendingPromise - debug?.('get', cacheKey, 'resolved to', finalCacheKey) - cacheKey = finalCacheKey + await pendingPromise } const privateEntry = memoryCache.get(cacheKey) @@ -105,14 +98,13 @@ const DefaultCacheHandler: CacheHandlerV2 = { async set(cacheKey, pendingEntry) { debug?.('set', cacheKey, 'start') - let resolvePending: (finalCacheKey: string) => void = () => {} - const pendingPromise = new Promise((resolve) => { + let resolvePending: () => void = () => {} + const pendingPromise = new Promise((resolve) => { resolvePending = resolve }) pendingSets.set(cacheKey, pendingPromise) const entry = await pendingEntry - const finalCacheKey = entry.key let size = 0 @@ -125,19 +117,19 @@ const DefaultCacheHandler: CacheHandlerV2 = { size += Buffer.from(chunk.value).byteLength } - memoryCache.set(finalCacheKey, { + memoryCache.set(cacheKey, { entry, isErrored: false, errorRetryCount: 0, size, }) - debug?.('set', finalCacheKey, 'done') + debug?.('set', cacheKey, 'done') } catch (err) { // TODO: store partial buffer with error after we retry 3 times debug?.('set', cacheKey, 'failed', err) } finally { - resolvePending(finalCacheKey) + resolvePending() pendingSets.delete(cacheKey) } }, diff --git a/packages/next/src/server/lib/cache-handlers/types.ts b/packages/next/src/server/lib/cache-handlers/types.ts index e39708531c08..8cbb91257224 100644 --- a/packages/next/src/server/lib/cache-handlers/types.ts +++ b/packages/next/src/server/lib/cache-handlers/types.ts @@ -4,13 +4,6 @@ export type Timestamp = number export interface CacheEntry { - /** - * The final cache key. This may differ from the cache key that was determined - * before the cache entry's value stream was started. During the cache entry - * generation we might encounter that cookies were read, which then need to be - * included in the final cache key. - */ - key: string /** * The ReadableStream can error and only have partial data so any cache * handlers need to handle this case and decide to keep the partial cache @@ -82,9 +75,6 @@ export interface CacheHandler { receiveExpiredTags(...tags: string[]): Promise } -// TODO: Maybe a cache handler should specify whether it is able to handle -// reading cookies, and thus switching up cache keys on the fly, during cache -// entry generation? export interface CacheHandlerV2 { /** * Retrieve a cache entry for the given cache key, if available. Will return diff --git a/packages/next/src/server/request/cookies.ts b/packages/next/src/server/request/cookies.ts index cf9804b2f39d..524703b9f4d3 100644 --- a/packages/next/src/server/request/cookies.ts +++ b/packages/next/src/server/request/cookies.ts @@ -23,8 +23,6 @@ import { makeHangingPromise } from '../dynamic-rendering-utils' import { createDedupedByCallsiteServerErrorLoggerDev } from '../create-deduped-by-callsite-server-error-logger' import { scheduleImmediate } from '../../lib/scheduler' import { isRequestAPICallableInsideAfter } from './utils' -import { ReflectAdapter } from '../web/spec-extension/adapters/reflect' -import type { RequestCookie } from '@edge-runtime/cookies' /** * In this version of Next.js `cookies()` returns a Promise however you can still reference the properties of the underlying cookies object @@ -74,16 +72,9 @@ export function cookies(): Promise { } if (workUnitStore) { - // if (workUnitStore.type === 'cache-with-cookies') { - // return workUnitStore.cookies - // } else if (workUnitStore.type === 'cache') { if (workUnitStore.type === 'cache') { - if (workUnitStore.renderContext) { - return workUnitStore.renderContext.getUserspaceCookies() - } - throw new Error( - `Route ${workStore.route} used "cookies" inside "use cache: ${workUnitStore.kind}". Accessing Dynamic data sources inside this kind of 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, or use the default "use cache" which does allow accessing cookies. See more info here: https://nextjs.org/docs/messages/next-request-in-use-cache` + `Route ${workStore.route} used "cookies" inside "use 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/messages/next-request-in-use-cache` ) } else if (workUnitStore.type === 'unstable-cache') { throw new Error( @@ -166,7 +157,7 @@ const CachedCookies = new WeakMap< Promise >() -export function makeDynamicallyTrackedExoticCookies( +function makeDynamicallyTrackedExoticCookies( route: string, prerenderStore: PrerenderStoreModern ): Promise { @@ -175,134 +166,159 @@ export function makeDynamicallyTrackedExoticCookies( return cachedPromise } - const abortAndThrow = (property: string) => { - const expression = `\`cookies()${property}\`` - const error = createCookiesAccessError(route, expression) - abortAndThrowOnSynchronousRequestDataAccess( - route, - expression, - error, - prerenderStore - ) - } - const promise = makeHangingPromise( prerenderStore.renderSignal, - '`cookies()`', - { - get: function get(target, prop, receiver) { - switch (prop) { - case Symbol.iterator: - return function () { - abortAndThrow('[Symbol.iterator]') - } - case 'size': - abortAndThrow('.size') - break - case 'get': - case 'getAll': - case 'has': - return function () { - abortAndThrow( - arguments.length === 0 - ? `.${prop}()` - : `.${prop}(${describeNameArg(arguments[0])})` - ) - } - case 'set': - case 'delete': - return function () { - abortAndThrow( - arguments.length === 0 - ? `.${prop}()` - : arguments[0] - ? `.${prop}(${describeNameArg(arguments[0])}, ...)` - : `.${prop}(...)` - ) - } - case 'clear': - case 'toString': - return function () { - abortAndThrow(`.${prop}()`) - } - case 'then': - case 'status': - const workUnitStore = workUnitAsyncStorage.getStore() - - if ( - workUnitStore?.type === 'cache' && - workUnitStore.renderContext?.type === 'prerender' - ) { - workUnitStore.renderContext.dynamicAccessAbortController.abort( - new Error( - `Accessed \`cookies()\` in \`"use cache"\` during prerendering.` - ) - ) - } - // intentionally fallthrough - default: - return ReflectAdapter.get(target, prop, receiver) - } - }, - } + '`cookies()`' ) CachedCookies.set(prerenderStore, promise) - return promise -} - -function createProxiedCookies( - underlyingCookies: ReadonlyRequestCookies -): ReadonlyRequestCookies { - return new Proxy(underlyingCookies, { - get(target, prop, receiver) { - const workUnitStore = workUnitAsyncStorage.getStore() - - if ( - workUnitStore?.type === 'cache' && - workUnitStore.renderContext?.type === 'request' - ) { - const { renderContext } = workUnitStore - - // eslint-disable-next-line default-case - switch (prop) { - case 'get': - case 'getAll': - return function get( - ...args: [name: string] | [RequestCookie] - ): RequestCookie | undefined { - if (renderContext.accessedCookieNames !== 'all') { - for (const arg of args) { - const cookieName = typeof arg === 'string' ? arg : arg.name - renderContext.accessedCookieNames.add(cookieName) - } - } - - return target.get.apply(target, args) - } - case 'has': - return function get(name: string): RequestCookie | undefined { - if (renderContext.accessedCookieNames !== 'all') { - renderContext.accessedCookieNames.add(name) - } - - return target.get.call(target, name) - } - case 'toString': - return function get(name: string): RequestCookie | undefined { - renderContext.accessedCookieNames = 'all' - - return target.get.call(target, name) - } + Object.defineProperties(promise, { + [Symbol.iterator]: { + value: function () { + const expression = '`cookies()[Symbol.iterator]()`' + const error = createCookiesAccessError(route, expression) + abortAndThrowOnSynchronousRequestDataAccess( + route, + expression, + error, + prerenderStore + ) + }, + }, + size: { + get() { + const expression = '`cookies().size`' + const error = createCookiesAccessError(route, expression) + abortAndThrowOnSynchronousRequestDataAccess( + route, + expression, + error, + prerenderStore + ) + }, + }, + get: { + value: function get() { + let expression: string + if (arguments.length === 0) { + expression = '`cookies().get()`' + } else { + expression = `\`cookies().get(${describeNameArg(arguments[0])})\`` } - } - - return ReflectAdapter.get(target, prop, receiver) + const error = createCookiesAccessError(route, expression) + abortAndThrowOnSynchronousRequestDataAccess( + route, + expression, + error, + prerenderStore + ) + }, }, - }) + getAll: { + value: function getAll() { + let expression: string + if (arguments.length === 0) { + expression = '`cookies().getAll()`' + } else { + expression = `\`cookies().getAll(${describeNameArg(arguments[0])})\`` + } + const error = createCookiesAccessError(route, expression) + abortAndThrowOnSynchronousRequestDataAccess( + route, + expression, + error, + prerenderStore + ) + }, + }, + has: { + value: function has() { + let expression: string + if (arguments.length === 0) { + expression = '`cookies().has()`' + } else { + expression = `\`cookies().has(${describeNameArg(arguments[0])})\`` + } + const error = createCookiesAccessError(route, expression) + abortAndThrowOnSynchronousRequestDataAccess( + route, + expression, + error, + prerenderStore + ) + }, + }, + set: { + value: function set() { + let expression: string + if (arguments.length === 0) { + expression = '`cookies().set()`' + } else { + const arg = arguments[0] + if (arg) { + expression = `\`cookies().set(${describeNameArg(arg)}, ...)\`` + } else { + expression = '`cookies().set(...)`' + } + } + const error = createCookiesAccessError(route, expression) + abortAndThrowOnSynchronousRequestDataAccess( + route, + expression, + error, + prerenderStore + ) + }, + }, + delete: { + value: function () { + let expression: string + if (arguments.length === 0) { + expression = '`cookies().delete()`' + } else if (arguments.length === 1) { + expression = `\`cookies().delete(${describeNameArg(arguments[0])})\`` + } else { + expression = `\`cookies().delete(${describeNameArg(arguments[0])}, ...)\`` + } + const error = createCookiesAccessError(route, expression) + abortAndThrowOnSynchronousRequestDataAccess( + route, + expression, + error, + prerenderStore + ) + }, + }, + clear: { + value: function clear() { + const expression = '`cookies().clear()`' + const error = createCookiesAccessError(route, expression) + abortAndThrowOnSynchronousRequestDataAccess( + route, + expression, + error, + prerenderStore + ) + }, + }, + toString: { + value: function toString() { + const expression = '`cookies().toString()`' + const error = createCookiesAccessError(route, expression) + abortAndThrowOnSynchronousRequestDataAccess( + route, + expression, + error, + prerenderStore + ) + }, + }, + } satisfies CookieExtensions) + + return promise } -export function makeUntrackedExoticCookies( +function makeUntrackedExoticCookies( underlyingCookies: ReadonlyRequestCookies ): Promise { const cachedCookies = CachedCookies.get(underlyingCookies) @@ -310,63 +326,62 @@ export function makeUntrackedExoticCookies( return cachedCookies } - const proxiedCookies = createProxiedCookies(underlyingCookies) - const promise = Promise.resolve(proxiedCookies) - CachedCookies.set(proxiedCookies, promise) + const promise = Promise.resolve(underlyingCookies) + CachedCookies.set(underlyingCookies, promise) Object.defineProperties(promise, { [Symbol.iterator]: { - value: proxiedCookies[Symbol.iterator] - ? proxiedCookies[Symbol.iterator].bind(proxiedCookies) + value: underlyingCookies[Symbol.iterator] + ? underlyingCookies[Symbol.iterator].bind(underlyingCookies) : // TODO this is a polyfill for when the underlying type is ResponseCookies // We should remove this and unify our cookies types. We could just let this continue to throw lazily // but that's already a hard thing to debug so we may as well implement it consistently. The biggest problem with // implementing this in this way is the underlying cookie type is a ResponseCookie and not a RequestCookie and so it // has extra properties not available on RequestCookie instances. - polyfilledResponseCookiesIterator.bind(proxiedCookies), + polyfilledResponseCookiesIterator.bind(underlyingCookies), }, size: { get(): number { - return proxiedCookies.size + return underlyingCookies.size }, }, get: { - value: proxiedCookies.get.bind(proxiedCookies), + value: underlyingCookies.get.bind(underlyingCookies), }, getAll: { - value: proxiedCookies.getAll.bind(proxiedCookies), + value: underlyingCookies.getAll.bind(underlyingCookies), }, has: { - value: proxiedCookies.has.bind(proxiedCookies), + value: underlyingCookies.has.bind(underlyingCookies), }, set: { - value: proxiedCookies.set.bind(proxiedCookies), + value: underlyingCookies.set.bind(underlyingCookies), }, delete: { - value: proxiedCookies.delete.bind(proxiedCookies), + value: underlyingCookies.delete.bind(underlyingCookies), }, clear: { value: // @ts-expect-error clear is defined in RequestCookies implementation but not in the type - typeof proxiedCookies.clear === 'function' + typeof underlyingCookies.clear === 'function' ? // @ts-expect-error clear is defined in RequestCookies implementation but not in the type - proxiedCookies.clear.bind(proxiedCookies) + underlyingCookies.clear.bind(underlyingCookies) : // TODO this is a polyfill for when the underlying type is ResponseCookies // We should remove this and unify our cookies types. We could just let this continue to throw lazily // but that's already a hard thing to debug so we may as well implement it consistently. The biggest problem with // implementing this in this way is the underlying cookie type is a ResponseCookie and not a RequestCookie and so it // has extra properties not available on RequestCookie instances. - polyfilledResponseCookiesClear.bind(proxiedCookies, promise), + polyfilledResponseCookiesClear.bind(underlyingCookies, promise), }, toString: { - value: proxiedCookies.toString.bind(proxiedCookies), + value: underlyingCookies.toString.bind(underlyingCookies), }, } satisfies CookieExtensions) return promise } -export function makeUntrackedExoticCookiesWithDevWarnings( +function makeUntrackedExoticCookiesWithDevWarnings( underlyingCookies: ReadonlyRequestCookies, route?: string ): Promise { @@ -375,20 +390,19 @@ export function makeUntrackedExoticCookiesWithDevWarnings( return cachedCookies } - const proxiedCookies = createProxiedCookies(underlyingCookies) const promise = new Promise((resolve) => - scheduleImmediate(() => resolve(proxiedCookies)) + scheduleImmediate(() => resolve(underlyingCookies)) ) - CachedCookies.set(proxiedCookies, promise) + CachedCookies.set(underlyingCookies, promise) Object.defineProperties(promise, { [Symbol.iterator]: { value: function () { const expression = '`...cookies()` or similar iteration' syncIODev(route, expression) - return proxiedCookies[Symbol.iterator] - ? proxiedCookies[Symbol.iterator].apply( - proxiedCookies, + return underlyingCookies[Symbol.iterator] + ? underlyingCookies[Symbol.iterator].apply( + underlyingCookies, arguments as any ) : // TODO this is a polyfill for when the underlying type is ResponseCookies @@ -396,7 +410,7 @@ export function makeUntrackedExoticCookiesWithDevWarnings( // but that's already a hard thing to debug so we may as well implement it consistently. The biggest problem with // implementing this in this way is the underlying cookie type is a ResponseCookie and not a RequestCookie and so it // has extra properties not available on RequestCookie instances. - polyfilledResponseCookiesIterator.call(proxiedCookies) + polyfilledResponseCookiesIterator.call(underlyingCookies) }, writable: false, }, @@ -404,7 +418,7 @@ export function makeUntrackedExoticCookiesWithDevWarnings( get(): number { const expression = '`cookies().size`' syncIODev(route, expression) - return proxiedCookies.size + return underlyingCookies.size }, }, get: { @@ -416,7 +430,7 @@ export function makeUntrackedExoticCookiesWithDevWarnings( expression = `\`cookies().get(${describeNameArg(arguments[0])})\`` } syncIODev(route, expression) - return proxiedCookies.get.apply(proxiedCookies, arguments as any) + return underlyingCookies.get.apply(underlyingCookies, arguments as any) }, writable: false, }, @@ -429,7 +443,10 @@ export function makeUntrackedExoticCookiesWithDevWarnings( expression = `\`cookies().getAll(${describeNameArg(arguments[0])})\`` } syncIODev(route, expression) - return proxiedCookies.getAll.apply(proxiedCookies, arguments as any) + return underlyingCookies.getAll.apply( + underlyingCookies, + arguments as any + ) }, writable: false, }, @@ -442,7 +459,7 @@ export function makeUntrackedExoticCookiesWithDevWarnings( expression = `\`cookies().has(${describeNameArg(arguments[0])})\`` } syncIODev(route, expression) - return proxiedCookies.has.apply(proxiedCookies, arguments as any) + return underlyingCookies.has.apply(underlyingCookies, arguments as any) }, writable: false, }, @@ -460,7 +477,7 @@ export function makeUntrackedExoticCookiesWithDevWarnings( } } syncIODev(route, expression) - return proxiedCookies.set.apply(proxiedCookies, arguments as any) + return underlyingCookies.set.apply(underlyingCookies, arguments as any) }, writable: false, }, @@ -475,7 +492,10 @@ export function makeUntrackedExoticCookiesWithDevWarnings( expression = `\`cookies().delete(${describeNameArg(arguments[0])}, ...)\`` } syncIODev(route, expression) - return proxiedCookies.delete.apply(proxiedCookies, arguments as any) + return underlyingCookies.delete.apply( + underlyingCookies, + arguments as any + ) }, writable: false, }, @@ -484,15 +504,15 @@ export function makeUntrackedExoticCookiesWithDevWarnings( const expression = '`cookies().clear()`' syncIODev(route, expression) // @ts-ignore clear is defined in RequestCookies implementation but not in the type - return typeof proxiedCookies.clear === 'function' + return typeof underlyingCookies.clear === 'function' ? // @ts-ignore clear is defined in RequestCookies implementation but not in the type - proxiedCookies.clear.apply(proxiedCookies, arguments) + underlyingCookies.clear.apply(underlyingCookies, arguments) : // TODO this is a polyfill for when the underlying type is ResponseCookies // We should remove this and unify our cookies types. We could just let this continue to throw lazily // but that's already a hard thing to debug so we may as well implement it consistently. The biggest problem with // implementing this in this way is the underlying cookie type is a ResponseCookie and not a RequestCookie and so it // has extra properties not available on RequestCookie instances. - polyfilledResponseCookiesClear.call(proxiedCookies, promise) + polyfilledResponseCookiesClear.call(underlyingCookies, promise) }, writable: false, }, @@ -500,7 +520,10 @@ export function makeUntrackedExoticCookiesWithDevWarnings( value: function toString() { const expression = '`cookies().toString()` or implicit casting' syncIODev(route, expression) - return proxiedCookies.toString.apply(proxiedCookies, arguments as any) + return underlyingCookies.toString.apply( + underlyingCookies, + arguments as any + ) }, writable: false, }, diff --git a/packages/next/src/server/request/draft-mode.ts b/packages/next/src/server/request/draft-mode.ts index 22b7d34484ef..52e98fb59d39 100644 --- a/packages/next/src/server/request/draft-mode.ts +++ b/packages/next/src/server/request/draft-mode.ts @@ -58,7 +58,6 @@ export function draftMode(): Promise { workStore ) - // case 'cache-with-cookies': case 'cache': case 'unstable-cache': // Inside of `"use cache"` or `unstable_cache`, draft mode is available if diff --git a/packages/next/src/server/request/root-params.ts b/packages/next/src/server/request/root-params.ts index ee8b32145c9d..3b6643159b67 100644 --- a/packages/next/src/server/request/root-params.ts +++ b/packages/next/src/server/request/root-params.ts @@ -39,7 +39,6 @@ export async function unstable_rootParams(): Promise { } switch (workUnitStore.type) { - // case 'cache-with-cookies': case 'unstable-cache': case 'cache': { throw new Error( diff --git a/packages/next/src/server/resume-data-cache/cache-store.ts b/packages/next/src/server/resume-data-cache/cache-store.ts index 913e3f59ea1f..00f5ea240513 100644 --- a/packages/next/src/server/resume-data-cache/cache-store.ts +++ b/packages/next/src/server/resume-data-cache/cache-store.ts @@ -33,7 +33,6 @@ export type DecryptedBoundArgsCacheStore = CacheStore * Serialized format for "use cache" entries */ export interface UseCacheCacheStoreSerialized { - key: string value: string tags: string[] stale: number @@ -46,72 +45,7 @@ export interface UseCacheCacheStoreSerialized { * A cache store specifically for "use cache" values that stores promises of * cache entries. */ -export class UseCacheCacheStore { - private cacheEntries: Map = new Map() - private pendingSets: Map> = new Map() - - async set( - cacheKey: string, - pendingEntry: Promise - ): Promise { - let resolvePendingPromise: (cacheKey: string) => void - - this.pendingSets.set( - cacheKey, - new Promise((resolve) => { - resolvePendingPromise = resolve - }) - ) - - try { - const entry = await pendingEntry - this.setSync(entry) - resolvePendingPromise!(entry.key) - } catch { - resolvePendingPromise!(cacheKey) - } finally { - this.pendingSets.delete(cacheKey) - } - } - - setSync(entry: CacheEntry): void { - this.cacheEntries.set(entry.key, entry) - } - - async get(cacheKey: string): Promise { - const pendingPromise = this.pendingSets.get(cacheKey) - - if (pendingPromise) { - cacheKey = await pendingPromise - } - - return this.cacheEntries.get(cacheKey) - } - - async keys(): Promise> { - if (this.pendingSets.size > 0) { - await Promise.all([...this.pendingSets.values()]) - } - - return this.cacheEntries.keys() - } - - async entries(): Promise> { - if (this.pendingSets.size > 0) { - await Promise.all([...this.pendingSets.values()]) - } - - return this.cacheEntries.entries() - } - - async getSize(): Promise { - if (this.pendingSets.size > 0) { - await Promise.all([...this.pendingSets.values()]) - } - - return this.cacheEntries.size - } -} +export type UseCacheCacheStore = CacheStore> /** * Parses serialized cache entries into a UseCacheCacheStore @@ -119,37 +53,34 @@ export class UseCacheCacheStore { * @returns A new UseCacheCacheStore containing the parsed entries */ export function parseUseCacheCacheStore( - values: Iterable + entries: Iterable<[string, UseCacheCacheStoreSerialized]> ): UseCacheCacheStore { - const store = new UseCacheCacheStore() + const store = new Map>() - for (const { + for (const [ key, - value, - tags, - stale, - timestamp, - expire, - revalidate, - } of values) { - store.setSync({ + { value, tags, stale, timestamp, expire, revalidate }, + ] of entries) { + store.set( key, - // Create a ReadableStream from the Uint8Array - value: new ReadableStream({ - start(controller) { - // Enqueue the Uint8Array to the stream - controller.enqueue(stringToUint8Array(atob(value))) - - // Close the stream - controller.close() - }, - }), - tags, - stale, - timestamp, - expire, - revalidate, - }) + Promise.resolve({ + // Create a ReadableStream from the Uint8Array + value: new ReadableStream({ + start(controller) { + // Enqueue the Uint8Array to the stream + controller.enqueue(stringToUint8Array(atob(value))) + + // Close the stream + controller.close() + }, + }), + tags, + stale, + timestamp, + expire, + revalidate, + }) + ) } return store @@ -161,40 +92,42 @@ export function parseUseCacheCacheStore( * @returns A promise that resolves to an array of key-value pairs with serialized values */ export async function serializeUseCacheCacheStore( - entries: Promise> + entries: IterableIterator<[string, Promise]> ): Promise> { return Promise.all( - Array.from(await entries).map(async ([key, entry]) => { - try { - const [left, right] = entry.value.tee() - entry.value = right - - let binaryString: string = '' - - // We want to encode the value as a string, but we aren't sure if the - // value is a a stream of UTF-8 bytes or not, so let's just encode it - // as a string using base64. - for await (const chunk of left) { - binaryString += arrayBufferToString(chunk) - } - return [ - key, - { + Array.from(entries).map(([key, value]) => { + return value + .then(async (entry) => { + const [left, right] = entry.value.tee() + entry.value = right + + let binaryString: string = '' + + // We want to encode the value as a string, but we aren't sure if the + // value is a a stream of UTF-8 bytes or not, so let's just encode it + // as a string using base64. + for await (const chunk of left) { + binaryString += arrayBufferToString(chunk) + } + + return [ key, - // Encode the value as a base64 string. - value: btoa(binaryString), - tags: entry.tags, - stale: entry.stale, - timestamp: entry.timestamp, - expire: entry.expire, - revalidate: entry.revalidate, - }, - ] satisfies [string, UseCacheCacheStoreSerialized] - } catch { - // Any failed cache writes should be ignored as to not discard the - // entire cache. - return null - } + { + // Encode the value as a base64 string. + value: btoa(binaryString), + tags: entry.tags, + stale: entry.stale, + timestamp: entry.timestamp, + expire: entry.expire, + revalidate: entry.revalidate, + }, + ] satisfies [string, UseCacheCacheStoreSerialized] + }) + .catch(() => { + // Any failed cache writes should be ignored as to not discard the + // entire cache. + return null + }) }) ) } diff --git a/packages/next/src/server/resume-data-cache/resume-data-cache.ts b/packages/next/src/server/resume-data-cache/resume-data-cache.ts index 05461023438f..0ec2dc89b270 100644 --- a/packages/next/src/server/resume-data-cache/resume-data-cache.ts +++ b/packages/next/src/server/resume-data-cache/resume-data-cache.ts @@ -1,10 +1,10 @@ import { InvariantError } from '../../shared/lib/invariant-error' import { + type UseCacheCacheStore, type FetchCacheStore, type EncryptedBoundArgsCacheStore, serializeUseCacheCacheStore, parseUseCacheCacheStore, - UseCacheCacheStore, type DecryptedBoundArgsCacheStore, type UseCacheCacheStoreSerialized, } from './cache-store' @@ -107,10 +107,7 @@ export async function stringifyResumeDataCache( '`stringifyResumeDataCache` should not be called in edge runtime.' ) } else { - if ( - resumeDataCache.fetch.size === 0 && - (await resumeDataCache.cache.getSize()) === 0 - ) { + if (resumeDataCache.fetch.size === 0 && resumeDataCache.cache.size === 0) { return 'null' } @@ -148,7 +145,7 @@ export async function stringifyResumeDataCache( */ export function createPrerenderResumeDataCache(): PrerenderResumeDataCache { return { - cache: new UseCacheCacheStore(), + cache: new Map(), fetch: new Map(), encryptedBoundArgs: new Map(), decryptedBoundArgs: new Map(), @@ -186,7 +183,7 @@ export function createRenderResumeDataCache( if (prerenderResumeDataCacheOrPersistedCache === 'null') { return { - cache: new UseCacheCacheStore(), + cache: new Map(), fetch: new Map(), encryptedBoundArgs: new Map(), decryptedBoundArgs: new Map(), @@ -205,7 +202,7 @@ export function createRenderResumeDataCache( ) return { - cache: parseUseCacheCacheStore(Object.values(json.store.cache)), + cache: parseUseCacheCacheStore(Object.entries(json.store.cache)), fetch: new Map(Object.entries(json.store.fetch)), encryptedBoundArgs: new Map( Object.entries(json.store.encryptedBoundArgs) diff --git a/packages/next/src/server/use-cache/render-context.ts b/packages/next/src/server/use-cache/render-context.ts index 2ea5872044dc..03572e4ba939 100644 --- a/packages/next/src/server/use-cache/render-context.ts +++ b/packages/next/src/server/use-cache/render-context.ts @@ -1,19 +1,7 @@ -import { - postponeWithTracking, - throwToInterruptStaticGeneration, -} from '../app-render/dynamic-rendering' -import type { WorkStore } from '../app-render/work-async-storage.external' import type { WorkUnitStore } from '../app-render/work-unit-async-storage.external' -import { - makeUntrackedExoticCookies, - makeDynamicallyTrackedExoticCookies, - makeUntrackedExoticCookiesWithDevWarnings, -} from '../request/cookies' -import type { ReadonlyRequestCookies } from '../web/spec-extension/adapters/request-cookies' export interface UseCacheRenderContextPrerender { readonly type: 'prerender' - readonly getUserspaceCookies: () => Promise readonly renderSignal: AbortSignal readonly dynamicAccessAbortController: AbortController readonly allowEmptyStaticShell: boolean @@ -21,19 +9,14 @@ export interface UseCacheRenderContextPrerender { export interface UseCacheRenderContextPrerenderPPR { readonly type: 'prerender-ppr' - readonly getUserspaceCookies: () => Promise } export interface UseCacheRenderContextPrerenderLegacy { readonly type: 'prerender-legacy' - readonly getUserspaceCookies: () => Promise } export interface UseCacheRenderContextRequest { readonly type: 'request' - readonly underlyingCookies: ReadonlyRequestCookies - readonly getUserspaceCookies: () => Promise - accessedCookieNames: Set | 'all' } export type UseCacheRenderContext = @@ -43,89 +26,30 @@ export type UseCacheRenderContext = | UseCacheRenderContextRequest export function createUseCacheRenderContext( - workStore: WorkStore, workUnitStore: WorkUnitStore ): UseCacheRenderContext | undefined { - if (workUnitStore.type === 'prerender') { - // If there are cookies defined on a prerender store, this means we're doing - // a dev warmup render, which is more akin to a dynamic request than a - // prerender request, despite using a prerender work unit store. - if (workUnitStore.cookies) { - const userspaceCookies = makeUntrackedExoticCookies(workUnitStore.cookies) - - return { - type: 'request', - underlyingCookies: workUnitStore.cookies, - getUserspaceCookies: () => userspaceCookies, - accessedCookieNames: new Set(), - } - } - - const userspaceCookies = makeDynamicallyTrackedExoticCookies( - workStore.route, - workUnitStore - ) - - return { - type: 'prerender', - getUserspaceCookies: () => userspaceCookies, - renderSignal: workUnitStore.renderSignal, - dynamicAccessAbortController: new AbortController(), - allowEmptyStaticShell: workUnitStore.allowEmptyStaticShell, - } - } - switch (workUnitStore.type) { - case 'prerender-ppr': + case 'prerender': return { - type: 'prerender-ppr', - getUserspaceCookies: () => - postponeWithTracking( - workStore.route, - 'cookies', - workUnitStore.dynamicTracking - ), + type: 'prerender', + renderSignal: workUnitStore.renderSignal, + dynamicAccessAbortController: new AbortController(), + allowEmptyStaticShell: workUnitStore.allowEmptyStaticShell, } + case 'prerender-ppr': case 'prerender-legacy': - return { - type: 'prerender-legacy', - getUserspaceCookies: () => - throwToInterruptStaticGeneration('cookies', workStore, workUnitStore), - } - // case 'cache-with-cookies': - // return workUnitStore.cookies + case 'request': + return { type: workUnitStore.type } case 'cache': - switch (workUnitStore.renderContext?.type) { - case 'request': - return { - ...workUnitStore.renderContext, - // Each cache scope starts with a new set of accessed cookie names. - accessedCookieNames: new Set(), - } - case 'prerender': - return { - ...workUnitStore.renderContext, - // Each cache scope gets its own abort controller. - dynamicAccessAbortController: new AbortController(), - } - default: - return workUnitStore.renderContext + if (workUnitStore.renderContext?.type === 'prerender') { + return { + ...workUnitStore.renderContext, + // Each cache scope gets its own abort controller. + dynamicAccessAbortController: new AbortController(), + } } - case 'request': - const userspaceCookies = - process.env.NODE_ENV === 'development' && !workStore.isPrefetchRequest - ? makeUntrackedExoticCookiesWithDevWarnings( - workUnitStore.cookies, - workStore.route - ) - : makeUntrackedExoticCookies(workUnitStore.cookies) - return { - type: 'request', - underlyingCookies: workUnitStore.cookies, - getUserspaceCookies: () => userspaceCookies, - accessedCookieNames: new Set(), - } + return workUnitStore.renderContext default: return undefined } 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 37714a48e1cc..9ca5ec4245f0 100644 --- a/packages/next/src/server/use-cache/use-cache-wrapper.ts +++ b/packages/next/src/server/use-cache/use-cache-wrapper.ts @@ -26,7 +26,6 @@ import { getPrerenderResumeDataCache, workUnitAsyncStorage, getDraftModeProviderForCacheScope, - getCookies, } from '../app-render/work-unit-async-storage.external' import { makeHangingPromise } from '../dynamic-rendering-utils' @@ -53,7 +52,6 @@ import { import type { Params } from '../request/params' import React from 'react' import { createLazyResult, isResolvedLazyResult } from '../lib/lazy-result' -import type { ReadonlyRequestCookies } from '../web/spec-extension/adapters/request-cookies' import { createUseCacheRenderContext } from './render-context' type CacheKeyParts = @@ -72,16 +70,13 @@ const debug = process.env.NEXT_PRIVATE_DEBUG_CACHE ? console.debug.bind(console, 'use-cache:') : undefined -const accessedCookieNamesByCacheKey = new Map | 'all'>() - function generateCacheEntry( workStore: WorkStore, - workUnitStore: WorkUnitStore | undefined, + outerWorkUnitStore: WorkUnitStore | undefined, kind: string, clientReferenceManifest: DeepReadonly, encodedArguments: FormData | string, fn: (...args: unknown[]) => Promise, - cacheKeyWithoutCookies: string, timeoutError: UseCacheTimeoutError ) { // We need to run this inside a clean AsyncLocalStorage snapshot so that the cache @@ -92,24 +87,22 @@ function generateCacheEntry( return workStore.runInCleanSnapshot( generateCacheEntryWithRestoredWorkStore, workStore, - workUnitStore, + outerWorkUnitStore, kind, clientReferenceManifest, encodedArguments, fn, - cacheKeyWithoutCookies, timeoutError ) } function generateCacheEntryWithRestoredWorkStore( workStore: WorkStore, - workUnitStore: WorkUnitStore | undefined, + outerWorkUnitStore: WorkUnitStore | undefined, kind: string, clientReferenceManifest: DeepReadonly, encodedArguments: FormData | string, fn: (...args: unknown[]) => Promise, - cacheKeyWithoutCookies: string, timeoutError: UseCacheTimeoutError ) { // Since we cleared the AsyncLocalStorage we need to restore the workStore. @@ -123,24 +116,22 @@ function generateCacheEntryWithRestoredWorkStore( workStore, generateCacheEntryWithCacheContext, workStore, - workUnitStore, + outerWorkUnitStore, kind, clientReferenceManifest, encodedArguments, fn, - cacheKeyWithoutCookies, timeoutError ) } function generateCacheEntryWithCacheContext( workStore: WorkStore, - workUnitStore: WorkUnitStore | undefined, + outerWorkUnitStore: WorkUnitStore | undefined, kind: string, clientReferenceManifest: DeepReadonly, encodedArguments: FormData | string, fn: (...args: unknown[]) => Promise, - cacheKeyWithoutCookies: string, timeoutError: UseCacheTimeoutError ) { if (!workStore.cacheLifeProfiles) { @@ -161,18 +152,16 @@ function generateCacheEntryWithCacheContext( } const useCacheOrRequestStore = - workUnitStore?.type === 'request' || - // workUnitStore?.type === 'cache-with-cookies' || - workUnitStore?.type === 'cache' - ? workUnitStore + outerWorkUnitStore?.type === 'request' || + outerWorkUnitStore?.type === 'cache' + ? outerWorkUnitStore : undefined // Initialize the Store for this Cache entry. const cacheStore: UseCacheStore = { type: 'cache', phase: 'render', - kind, - implicitTags: workUnitStore?.implicitTags, + implicitTags: outerWorkUnitStore?.implicitTags, revalidate: defaultCacheLife.revalidate, expire: defaultCacheLife.expire, stale: defaultCacheLife.stale, @@ -181,32 +170,27 @@ function generateCacheEntryWithCacheContext( explicitStale: undefined, tags: null, hmrRefreshHash: - workUnitStore && getHmrRefreshHash(workStore, workUnitStore), + outerWorkUnitStore && getHmrRefreshHash(workStore, outerWorkUnitStore), isHmrRefresh: useCacheOrRequestStore?.isHmrRefresh ?? false, serverComponentsHmrCache: useCacheOrRequestStore?.serverComponentsHmrCache, - forceRevalidate: shouldForceRevalidate(workStore, workUnitStore), + forceRevalidate: shouldForceRevalidate(workStore, outerWorkUnitStore), draftMode: - workUnitStore && - getDraftModeProviderForCacheScope(workStore, workUnitStore), + outerWorkUnitStore && + getDraftModeProviderForCacheScope(workStore, outerWorkUnitStore), renderContext: - workUnitStore && createUseCacheRenderContext(workStore, workUnitStore), + outerWorkUnitStore && createUseCacheRenderContext(outerWorkUnitStore), } - // const cacheStore: UseCacheStore | UseCacheWithCookiesStore = cookies - // ? { ...commonCacheStore, type: 'cache-with-cookies', cookies } - // : { ...commonCacheStore, type: 'cache' } - return workUnitAsyncStorage.run( cacheStore, generateCacheEntryImpl, workStore, - workUnitStore, + outerWorkUnitStore, kind, cacheStore, clientReferenceManifest, encodedArguments, fn, - cacheKeyWithoutCookies, timeoutError ) } @@ -243,26 +227,7 @@ function propagateCacheLifeAndTags( } } -// TODO: We should hash the cookies, or better yet hash the full cache key. -function serializeCookies( - accessedCookieNames: Set | 'all', - cookies: ReadonlyRequestCookies -): string { - if (accessedCookieNames === 'all') { - return cookies.toString() - } - - return [...accessedCookieNames] - .sort() - .map((name) => { - const value = cookies.get(name)?.value ?? '' - return `${encodeURIComponent(name)}=${encodeURIComponent(value)}` - }) - .join('; ') -} - async function collectResult( - cacheKeyWithoutCookies: string, savedStream: ReadableStream, workStore: WorkStore, outerWorkUnitStore: WorkUnitStore | undefined, @@ -310,55 +275,24 @@ async function collectResult( }, }) - const { - revalidate, - expire, - stale, - explicitRevalidate, - explicitExpire, - explicitStale, - renderContext, - tags, - } = innerCacheStore - - const collectedTags = tags + const collectedTags = innerCacheStore.tags // If cacheLife() was used to set an explicit revalidate time we use that. // Otherwise, we use the lowest of all inner fetch()/unstable_cache() or nested "use cache". // If they're lower than our default. const collectedRevalidate = - explicitRevalidate !== undefined ? explicitRevalidate : revalidate - const collectedExpire = explicitExpire !== undefined ? explicitExpire : expire - const collectedStale = explicitStale !== undefined ? explicitStale : stale - - let cacheKey = cacheKeyWithoutCookies - - if (renderContext?.type === 'request') { - const { accessedCookieNames, underlyingCookies } = renderContext - - let allAccessedCookieNames = accessedCookieNamesByCacheKey.get( - cacheKeyWithoutCookies - ) - - // We only add newly accessed cookies, and never remove any previously - // accessed ones. - if (allAccessedCookieNames === undefined) { - allAccessedCookieNames = accessedCookieNames - } else if (allAccessedCookieNames !== 'all') { - for (const name of accessedCookieNames) { - allAccessedCookieNames.add(name) - } - } - - accessedCookieNamesByCacheKey.set( - cacheKeyWithoutCookies, - allAccessedCookieNames - ) - - cacheKey += serializeCookies(allAccessedCookieNames, underlyingCookies) - } + innerCacheStore.explicitRevalidate !== undefined + ? innerCacheStore.explicitRevalidate + : innerCacheStore.revalidate + const collectedExpire = + innerCacheStore.explicitExpire !== undefined + ? innerCacheStore.explicitExpire + : innerCacheStore.expire + const collectedStale = + innerCacheStore.explicitStale !== undefined + ? innerCacheStore.explicitStale + : innerCacheStore.stale const entry: CacheEntry = { - key: cacheKey, value: bufferStream, timestamp: startTime, revalidate: collectedRevalidate, @@ -395,13 +329,12 @@ type GenerateCacheEntryResult = async function generateCacheEntryImpl( workStore: WorkStore, - workUnitStore: WorkUnitStore | undefined, + outerWorkUnitStore: WorkUnitStore | undefined, _kind: string, innerCacheStore: UseCacheStore, clientReferenceManifest: DeepReadonly, encodedArguments: FormData | string, fn: (...args: unknown[]) => Promise, - cacheKeyWithoutCookies: string, timeoutError: UseCacheTimeoutError ): Promise { const temporaryReferences = createServerTemporaryReferenceSet() @@ -424,12 +357,12 @@ async function generateCacheEntryImpl( // 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. - if (workUnitStore?.type === 'prerender') { + if (outerWorkUnitStore?.type === 'prerender') { await new Promise((resolve) => { - if (workUnitStore.renderSignal.aborted) { + if (outerWorkUnitStore.renderSignal.aborted) { resolve() } else { - workUnitStore.renderSignal.addEventListener( + outerWorkUnitStore.renderSignal.addEventListener( 'abort', () => resolve(), { once: true } @@ -525,8 +458,8 @@ async function generateCacheEntryImpl( // dynamic in the context of the fallback shell. When there's no suspense // boundary above, the shell will be empty, which is allowed. if (abortSignal.aborted && renderContext.allowEmptyStaticShell) { - if (workUnitStore?.type === 'prerender') { - workUnitStore.cacheSignal?.endRead() + if (outerWorkUnitStore?.type === 'prerender') { + outerWorkUnitStore.cacheSignal?.endRead() } const hangingPromise = makeHangingPromise( @@ -561,10 +494,9 @@ async function generateCacheEntryImpl( const [returnStream, savedStream] = stream.tee() const pendingCacheEntry = collectResult( - cacheKeyWithoutCookies, savedStream, workStore, - workUnitStore, + outerWorkUnitStore, innerCacheStore, startTime, errors @@ -584,7 +516,6 @@ function cloneCacheEntry(entry: CacheEntry): [CacheEntry, CacheEntry] { const [streamA, streamB] = entry.value.tee() entry.value = streamA const clonedEntry: CacheEntry = { - key: entry.key, value: streamB, timestamp: entry.timestamp, revalidate: entry.revalidate, @@ -788,24 +719,13 @@ export function cache( { temporaryReferences, signal: hangingInputAbortSignal } ) - const cacheKeyWithoutCookies = + const serializedCacheKey = typeof encodedCacheKeyParts === 'string' ? // Fast path for the simple case for simple inputs. We let the CacheHandler // Convert it to an ArrayBuffer if it wants to. encodedCacheKeyParts : await encodeFormData(encodedCacheKeyParts) - let cacheKey = cacheKeyWithoutCookies - const cookies = workUnitStore && getCookies(workUnitStore) - - if (cookies) { - const accessedCookieNames = accessedCookieNamesByCacheKey.get(cacheKey) - - if (accessedCookieNames) { - cacheKey += serializeCookies(accessedCookieNames, cookies) - } - } - let stream: undefined | ReadableStream = undefined // Get an immutable and mutable versions of the resume data cache. @@ -825,10 +745,9 @@ export function cache( if (cacheSignal) { cacheSignal.beginRead() } - - const existingEntry = await renderResumeDataCache.cache.get(cacheKey) - - if (existingEntry !== undefined) { + const cachedEntry = renderResumeDataCache.cache.get(serializedCacheKey) + if (cachedEntry !== undefined) { + const existingEntry = await cachedEntry propagateCacheLifeAndTags(workUnitStore, existingEntry) if ( workUnitStore !== undefined && @@ -886,7 +805,7 @@ export function cache( let entry = shouldForceRevalidate(workStore, workUnitStore) ? undefined : await cacheHandler.get( - cacheKey, + serializedCacheKey, workUnitStore?.implicitTags?.tags ?? [] ) @@ -915,16 +834,11 @@ export function cache( implicitTagsExpiration ) ) { - debug?.('discarding stale entry', cacheKey) + debug?.('discarding stale entry', serializedCacheKey) entry = undefined } } - const addPendingCacheSet = (promise: Promise) => { - workStore.pendingRevalidateWrites ??= [] - workStore.pendingRevalidateWrites.push(promise) - } - const currentTime = performance.timeOrigin + performance.now() if ( workUnitStore !== undefined && @@ -964,14 +878,14 @@ export function cache( if (entry) { if (currentTime > entry.timestamp + entry.expire * 1000) { - debug?.('entry is expired', cacheKey) + debug?.('entry is expired', serializedCacheKey) } if ( workStore.isStaticGeneration && currentTime > entry.timestamp + entry.revalidate * 1000 ) { - debug?.('static generation, entry is stale', cacheKey) + debug?.('static generation, entry is stale', serializedCacheKey) } } @@ -982,7 +896,6 @@ export function cache( clientReferenceManifest, encodedCacheKeyParts, fn, - cacheKeyWithoutCookies, timeoutError ) @@ -1000,18 +913,21 @@ export function cache( // Create a clone that goes into the cache scope memory cache. const split = clonePendingCacheEntry(pendingCacheEntry) savedCacheEntry = getNthCacheEntry(split, 0) - - addPendingCacheSet( - prerenderResumeDataCache.cache.set( - cacheKey, - getNthCacheEntry(split, 1) - ) + prerenderResumeDataCache.cache.set( + serializedCacheKey, + getNthCacheEntry(split, 1) ) } else { savedCacheEntry = pendingCacheEntry } - addPendingCacheSet(cacheHandler.set(cacheKey, savedCacheEntry)) + const promise = cacheHandler.set( + serializedCacheKey, + savedCacheEntry + ) + + workStore.pendingRevalidateWrites ??= [] + workStore.pendingRevalidateWrites.push(promise) } stream = newStream @@ -1031,11 +947,9 @@ export function cache( stream = entryLeft.value } - addPendingCacheSet( - prerenderResumeDataCache.cache.set( - cacheKey, - Promise.resolve(entryRight) - ) + prerenderResumeDataCache.cache.set( + serializedCacheKey, + Promise.resolve(entryRight) ) } else { // If we're not regenerating we need to signal that we've finished @@ -1058,7 +972,6 @@ export function cache( clientReferenceManifest, encodedCacheKeyParts, fn, - cacheKeyWithoutCookies, timeoutError ) @@ -1069,18 +982,23 @@ export function cache( if (prerenderResumeDataCache) { const split = clonePendingCacheEntry(pendingCacheEntry) savedCacheEntry = getNthCacheEntry(split, 0) - - addPendingCacheSet( - prerenderResumeDataCache.cache.set( - cacheKey, - getNthCacheEntry(split, 1) - ) + prerenderResumeDataCache.cache.set( + serializedCacheKey, + getNthCacheEntry(split, 1) ) } else { savedCacheEntry = pendingCacheEntry } - addPendingCacheSet(cacheHandler.set(cacheKey, savedCacheEntry)) + const promise = cacheHandler.set( + serializedCacheKey, + savedCacheEntry + ) + + if (!workStore.pendingRevalidateWrites) { + workStore.pendingRevalidateWrites = [] + } + workStore.pendingRevalidateWrites.push(promise) await ignoredStream.cancel() } From ce30f8d4a25e1ae98f531394ffe9230b7530c560 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Tue, 6 May 2025 21:03:56 +0200 Subject: [PATCH 23/57] Delete debug log --- packages/next/src/server/base-server.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/next/src/server/base-server.ts b/packages/next/src/server/base-server.ts index 8df058dfd4d0..a9872be8cda7 100644 --- a/packages/next/src/server/base-server.ts +++ b/packages/next/src/server/base-server.ts @@ -3550,7 +3550,6 @@ export default abstract class Server< res.statusCode = cachedData.status } - console.log('POSTPONED', didPostpone) // Mark that the request did postpone. if (didPostpone) { res.setHeader(NEXT_DID_POSTPONE_HEADER, '1') From bf9aa93906b0b93b366202f43ba0346c43b79ef0 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Tue, 6 May 2025 21:05:16 +0200 Subject: [PATCH 24/57] Revert changes in RDC tests --- .../server/app-render/postponed-state.test.ts | 28 ++++++----- .../resume-data-cache.test.ts | 48 +++++-------------- 2 files changed, 30 insertions(+), 46 deletions(-) diff --git a/packages/next/src/server/app-render/postponed-state.test.ts b/packages/next/src/server/app-render/postponed-state.test.ts index 048b08e7bcb2..f94fc5563a87 100644 --- a/packages/next/src/server/app-render/postponed-state.test.ts +++ b/packages/next/src/server/app-render/postponed-state.test.ts @@ -16,10 +16,9 @@ describe('getDynamicHTMLPostponedState', () => { const fallbackRouteParams = new Map([['slug', key]]) const prerenderResumeDataCache = createPrerenderResumeDataCache() - await prerenderResumeDataCache.cache.set( - 'original-key', + prerenderResumeDataCache.cache.set( + '1', Promise.resolve({ - key: 'final-key', value: streamFromString('hello'), tags: [], stale: 0, @@ -36,20 +35,27 @@ describe('getDynamicHTMLPostponedState', () => { ) const parsed = parsePostponedState(state, { slug: '123' }) - if (parsed.type !== DynamicState.HTML) { - throw new Error('Expected HTML state') - } - - expect(parsed.data).toMatchInlineSnapshot(` + expect(parsed).toMatchInlineSnapshot(` { - "123": "123", - "nested": { + "data": { "123": "123", + "nested": { + "123": "123", + }, + }, + "renderResumeDataCache": { + "cache": Map { + "1" => Promise {}, + }, + "decryptedBoundArgs": Map {}, + "encryptedBoundArgs": Map {}, + "fetch": Map {}, }, + "type": 2, } `) - const value = await parsed.renderResumeDataCache.cache.get('final-key') + const value = await parsed.renderResumeDataCache.cache.get('1') expect(value).toBeDefined() diff --git a/packages/next/src/server/resume-data-cache/resume-data-cache.test.ts b/packages/next/src/server/resume-data-cache/resume-data-cache.test.ts index f33e6aa385df..a06111c165d6 100644 --- a/packages/next/src/server/resume-data-cache/resume-data-cache.test.ts +++ b/packages/next/src/server/resume-data-cache/resume-data-cache.test.ts @@ -6,12 +6,11 @@ import { createPrerenderResumeDataCache } from './resume-data-cache' import { streamFromString } from '../stream-utils/node-web-streams-helper' import { inflateSync } from 'node:zlib' -async function createCacheWithSingleEntry() { +function createCacheWithSingleEntry() { const cache = createPrerenderResumeDataCache() - await cache.cache.set( - 'original-key', + cache.cache.set( + 'success', Promise.resolve({ - key: 'final-key', value: streamFromString('value'), tags: [], stale: 0, @@ -24,30 +23,9 @@ async function createCacheWithSingleEntry() { return cache } -async function createCacheWithEntriesThatFail() { - const cache = await createCacheWithSingleEntry() - - await cache.cache.set( - 'fail-promise', - Promise.reject(new Error('Failed to serialize')) - ) - - await cache.cache.set( - 'fail-stream', - Promise.resolve({ - key: 'fail-stream', - value: new ReadableStream({ - start(controller) { - controller.error(new Error('Failed to serialize')) - }, - }), - tags: [], - stale: 0, - timestamp: 0, - expire: 0, - revalidate: 0, - }) - ) +function createCacheWithSingleEntryThatFails() { + const cache = createCacheWithSingleEntry() + cache.cache.set('fail', Promise.reject(new Error('Failed to serialize'))) return cache } @@ -59,7 +37,7 @@ describe('stringifyResumeDataCache', () => { }) it('serializes a cache with a single entry', async () => { - const cache = await createCacheWithSingleEntry() + const cache = createCacheWithSingleEntry() const compressed = await stringifyResumeDataCache(cache) // We have to decompress the output because the compressed string is not @@ -70,12 +48,12 @@ describe('stringifyResumeDataCache', () => { ).toString('utf-8') expect(decompressed).toMatchInlineSnapshot( - `"{"store":{"fetch":{},"cache":{"final-key":{"key":"final-key","value":"dmFsdWU=","tags":[],"stale":0,"timestamp":0,"expire":0,"revalidate":0}},"encryptedBoundArgs":{}}}"` + `"{"store":{"fetch":{},"cache":{"success":{"value":"dmFsdWU=","tags":[],"stale":0,"timestamp":0,"expire":0,"revalidate":0}},"encryptedBoundArgs":{}}}"` ) }) - it('serializes a cache with entries that fail', async () => { - const cache = await createCacheWithEntriesThatFail() + it('serializes a cache with a single entry that fails', async () => { + const cache = createCacheWithSingleEntryThatFails() const compressed = await stringifyResumeDataCache(cache) // We have to decompress the output because the compressed string is not @@ -88,7 +66,7 @@ describe('stringifyResumeDataCache', () => { // We expect that the cache will still contain the successful entry but the // failed entry will be ignored and omitted from the output. expect(decompressed).toMatchInlineSnapshot( - `"{"store":{"fetch":{},"cache":{"final-key":{"key":"final-key","value":"dmFsdWU=","tags":[],"stale":0,"timestamp":0,"expire":0,"revalidate":0}},"encryptedBoundArgs":{}}}"` + `"{"store":{"fetch":{},"cache":{"success":{"value":"dmFsdWU=","tags":[],"stale":0,"timestamp":0,"expire":0,"revalidate":0}},"encryptedBoundArgs":{}}}"` ) }) }) @@ -101,12 +79,12 @@ describe('parseResumeDataCache', () => { }) it('parses a cache with a single entry', async () => { - const cache = await createCacheWithSingleEntry() + const cache = createCacheWithSingleEntry() const serialized = await stringifyResumeDataCache(cache) const parsed = createRenderResumeDataCache(serialized) - expect(await parsed.cache.getSize()).toBe(1) + expect(parsed.cache.size).toBe(1) expect(parsed.fetch.size).toBe(0) }) }) From 27165a83739459b36929b50f27a581eea27ce862 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Tue, 6 May 2025 21:13:43 +0200 Subject: [PATCH 25/57] Revert changes in `use-cache` test suite --- test/e2e/app-dir/use-cache/use-cache.test.ts | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/test/e2e/app-dir/use-cache/use-cache.test.ts b/test/e2e/app-dir/use-cache/use-cache.test.ts index 609fc48c9960..4261e5f2eead 100644 --- a/test/e2e/app-dir/use-cache/use-cache.test.ts +++ b/test/e2e/app-dir/use-cache/use-cache.test.ts @@ -127,10 +127,15 @@ describe('use-cache', () => { ) }) - it('should error when headers is used inside "use cache"', async () => { + it('should error when cookies/headers/draftMode is used inside "use cache"', async () => { const browser = await next.browser('/errors') await retry(async () => { + expect(await browser.elementById('cookies').text()).toContain( + isNextDev + ? 'Route /errors used "cookies" inside "use cache".' + : GENERIC_RSC_ERROR + ) expect(await browser.elementById('headers').text()).toContain( isNextDev ? 'Route /errors used "headers" inside "use cache".' @@ -138,8 +143,15 @@ describe('use-cache', () => { ) }) + expect(await browser.elementById('draft-mode').text()).toContain( + 'Editing: false' + ) + // CLI assertions are skipped in deploy mode because `next.cliOutput` will only contain build-time logs. if (!isNextDeploy) { + expect(next.cliOutput).toContain( + 'Route /errors used "cookies" inside "use cache". ' + ) expect(next.cliOutput).toContain( 'Route /errors used "headers" inside "use cache". ' ) @@ -820,10 +832,10 @@ describe('use-cache', () => { newClosureValue ) - // Accessing headers should still not be allowed. + // Accessing request-scoped data should still not be allowed. expect( await browser - .elementById('is-accessing-headers-allowed-in-use-cache') + .elementById('is-accessing-request-scoped-data-allowed-in-use-cache') .text() ).toBe('false') @@ -924,7 +936,7 @@ describe('use-cache', () => { JSON.parse(await next.readFile('.next/server/app/rdc.meta')).postponed ) - const cacheKeys = Array.from(await resumeDataCache.cache.keys()) + const cacheKeys = Array.from(resumeDataCache.cache.keys()) // There should be no cache entry for the "middle" cache function, because // it's only used inside another cache scope ("outer"). Whereas "inner" is From 4014dd09c769c0256182d92aa1ae74d5f97d5369 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Tue, 6 May 2025 21:14:20 +0200 Subject: [PATCH 26/57] Delete `use-cache-cookies` test suite --- .../fixtures/dynamic-io/app/layout.tsx | 21 ---- .../fixtures/dynamic-io/app/page.tsx | 16 --- .../fixtures/dynamic-io/next.config.js | 11 -- .../use-cache-cookies.test.ts | 106 ------------------ 4 files changed, 154 deletions(-) delete mode 100644 test/e2e/app-dir/use-cache-cookies/fixtures/dynamic-io/app/layout.tsx delete mode 100644 test/e2e/app-dir/use-cache-cookies/fixtures/dynamic-io/app/page.tsx delete mode 100644 test/e2e/app-dir/use-cache-cookies/fixtures/dynamic-io/next.config.js delete mode 100644 test/e2e/app-dir/use-cache-cookies/use-cache-cookies.test.ts diff --git a/test/e2e/app-dir/use-cache-cookies/fixtures/dynamic-io/app/layout.tsx b/test/e2e/app-dir/use-cache-cookies/fixtures/dynamic-io/app/layout.tsx deleted file mode 100644 index c0bd6b770893..000000000000 --- a/test/e2e/app-dir/use-cache-cookies/fixtures/dynamic-io/app/layout.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { Suspense } from 'react' -import { ReactNode } from 'react' - -async function getCachedDate() { - 'use cache' - - return new Date().toISOString() -} - -export default async function Root({ children }: { children: ReactNode }) { - return ( - - -

Layout: {await getCachedDate()}

- Loading...

}> - {children} -
- - - ) -} diff --git a/test/e2e/app-dir/use-cache-cookies/fixtures/dynamic-io/app/page.tsx b/test/e2e/app-dir/use-cache-cookies/fixtures/dynamic-io/app/page.tsx deleted file mode 100644 index 1842f9bd01c9..000000000000 --- a/test/e2e/app-dir/use-cache-cookies/fixtures/dynamic-io/app/page.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { cookies } from 'next/headers' -import { setTimeout } from 'timers/promises' - -export default async function Page() { - 'use cache' - - await setTimeout(2000) - const loggedIn = (await cookies()).get('isLoggedIn') - - return ( - <> -

Page: {new Date().toISOString()}

-

{loggedIn ? 'logged in' : 'logged out'}

- - ) -} diff --git a/test/e2e/app-dir/use-cache-cookies/fixtures/dynamic-io/next.config.js b/test/e2e/app-dir/use-cache-cookies/fixtures/dynamic-io/next.config.js deleted file mode 100644 index 97dedb2358d9..000000000000 --- a/test/e2e/app-dir/use-cache-cookies/fixtures/dynamic-io/next.config.js +++ /dev/null @@ -1,11 +0,0 @@ -/** - * @type {import('next').NextConfig} - */ -const nextConfig = { - experimental: { - dynamicIO: true, - ppr: process.env.__NEXT_EXPERIMENTAL_PPR === 'true', - }, -} - -module.exports = nextConfig diff --git a/test/e2e/app-dir/use-cache-cookies/use-cache-cookies.test.ts b/test/e2e/app-dir/use-cache-cookies/use-cache-cookies.test.ts deleted file mode 100644 index 1f6213bf3b6b..000000000000 --- a/test/e2e/app-dir/use-cache-cookies/use-cache-cookies.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { nextTestSetup } from 'e2e-utils' -import path from 'path' -import expect from 'expect' - -describe('use-cache-cookies', () => { - describe('with dynamicIO', () => { - const { next, skipped } = nextTestSetup({ - files: path.join(__dirname, 'fixtures', 'dynamic-io'), - skipDeployment: true, // TODO: Activate later. - buildOptions: ['--no-mangling'], - env: { - NEXT_PRIVATE_DEBUG_CACHE: '1', - }, - }) - - if (skipped) { - return - } - - it('should make a cached function that accesses cookies dynamic', async () => { - const browser = await next.browser('/', { - waitUntil: 'commit', - waitHydration: false, - }) - - // The loading boundary is expected to be shown while the page is loading, - // and the dynamic cache function is evaluated. We're using `browser.eval` - // here to assert on the document as early as possible. The other - // Playwright APIs (incl. `page.$`) add a delay that sometimes leads to - // missing the ephemeral loading element. - expect( - await browser.eval('document.getElementById("loading")') - ).toBeDefined() - - const cachedInLayout = await browser - .elementById('cached-in-layout') - .text() - - expect(await browser.elementById('login-status').text()).toBe( - 'logged out' - ) - - await browser.refresh({ waitUntil: 'commit' }) - - // The login status is now cached, so no loading boundary is expected. - expect( - await browser.eval('document.getElementById("loading")') - ).toBeNull() - - expect(await browser.elementById('cached-in-layout').text()).toBe( - cachedInLayout - ) - - expect(await browser.elementById('login-status').text()).toBe( - 'logged out' - ) - }) - - it('should include used cookies in the cache key', async () => { - const browser = await next.browser('/') - - expect(await browser.elementById('login-status').text()).toBe( - 'logged out' - ) - - const cachedInPageLoggedOut = await browser - .elementById('cached-in-page') - .text() - - await browser.refresh() - - expect(await browser.elementById('cached-in-page').text()).toBe( - cachedInPageLoggedOut - ) - - await browser.addCookie({ name: 'isLoggedIn', value: 'true' }) - await browser.refresh() - - expect(await browser.elementById('login-status').text()).toBe('logged in') - - const cachedInPageLoggedIn = await browser - .elementById('cached-in-page') - .text() - - expect(cachedInPageLoggedIn).not.toBe(cachedInPageLoggedOut) - - await browser.refresh() - - expect(await browser.elementById('cached-in-page').text()).toBe( - cachedInPageLoggedIn - ) - }) - - it('should ignore changes in unused cookies', async () => { - const browser = await next.browser('/') - const cachedInPage = await browser.elementById('cached-in-page').text() - - await browser.addCookie({ name: 'other', value: 'foo' }) - await browser.refresh() - - expect(await browser.elementById('cached-in-page').text()).toBe( - cachedInPage - ) - }) - }) -}) From a41be3731d82e6a1931916eb96d5f761155545df Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Tue, 6 May 2025 21:19:05 +0200 Subject: [PATCH 27/57] Remove `cookies` reference in route module --- packages/next/src/server/route-modules/app-route/module.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/next/src/server/route-modules/app-route/module.ts b/packages/next/src/server/route-modules/app-route/module.ts index ac6ab840e7c6..f662e28510c9 100644 --- a/packages/next/src/server/route-modules/app-route/module.ts +++ b/packages/next/src/server/route-modules/app-route/module.ts @@ -397,7 +397,6 @@ export class AppRouteRouteModule extends RouteModule< tags: [...implicitTags.tags], prerenderResumeDataCache: null, hmrRefreshHash: undefined, - cookies: undefined, }) let prospectiveResult @@ -487,7 +486,6 @@ export class AppRouteRouteModule extends RouteModule< tags: [...implicitTags.tags], prerenderResumeDataCache: null, hmrRefreshHash: undefined, - cookies: undefined, }) let responseHandled = false From a415ed5488379a9a51ecc38df857619e510bf45c Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Tue, 6 May 2025 21:22:22 +0200 Subject: [PATCH 28/57] Remove `cookies` from work unit stores --- .../next/src/server/app-render/app-render.tsx | 14 +--------- .../src/server/async-storage/request-store.ts | 26 ++++++++----------- 2 files changed, 12 insertions(+), 28 deletions(-) diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index be693f7e3b43..999c0e86b4ab 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -56,10 +56,7 @@ import { NEXT_HMR_REFRESH_HASH_COOKIE, } from '../../client/components/app-router-headers' import { createMetadataContext } from '../../lib/metadata/metadata-context' -import { - createRequestCookies, - createRequestStoreForRender, -} from '../async-storage/request-store' +import { createRequestStoreForRender } from '../async-storage/request-store' import { createWorkStore } from '../async-storage/work-store' import { getAccessFallbackErrorTypeByStatus, @@ -721,7 +718,6 @@ async function warmupDevRender( tags: [], prerenderResumeDataCache, hmrRefreshHash: req.cookies[NEXT_HMR_REFRESH_HASH_COOKIE], - cookies: createRequestCookies(req), } const rscPayload = await workUnitAsyncStorage.run( @@ -2293,7 +2289,6 @@ async function spawnDynamicValidationInDev( tags: [], prerenderResumeDataCache, hmrRefreshHash, - cookies: undefined, } const initialClientController = new AbortController() @@ -2313,7 +2308,6 @@ async function spawnDynamicValidationInDev( tags: [], prerenderResumeDataCache, hmrRefreshHash, - cookies: undefined, } // We're not going to use the result of this render because the only time it could be used @@ -2460,7 +2454,6 @@ async function spawnDynamicValidationInDev( tags: [], prerenderResumeDataCache, hmrRefreshHash, - cookies: undefined, } const finalClientController = new AbortController() @@ -2484,7 +2477,6 @@ async function spawnDynamicValidationInDev( tags: [], prerenderResumeDataCache, hmrRefreshHash, - cookies: undefined, } const finalServerPayload = await workUnitAsyncStorage.run( @@ -2838,7 +2830,6 @@ async function prerenderToStream( tags: [...implicitTags.tags], prerenderResumeDataCache, hmrRefreshHash: undefined, - cookies: undefined, }) // We're not going to use the result of this render because the only time it could be used @@ -2937,7 +2928,6 @@ async function prerenderToStream( tags: [...implicitTags.tags], prerenderResumeDataCache, hmrRefreshHash: undefined, - cookies: undefined, } const prerender = require('react-dom/static.edge') @@ -3024,7 +3014,6 @@ async function prerenderToStream( tags: [...implicitTags.tags], prerenderResumeDataCache, hmrRefreshHash: undefined, - cookies: undefined, }) const finalAttemptRSCPayload = await workUnitAsyncStorage.run( @@ -3096,7 +3085,6 @@ async function prerenderToStream( tags: [...implicitTags.tags], prerenderResumeDataCache, hmrRefreshHash: undefined, - cookies: undefined, } let clientIsDynamic = false diff --git a/packages/next/src/server/async-storage/request-store.ts b/packages/next/src/server/async-storage/request-store.ts index 2433a7e9fde7..89558398a700 100644 --- a/packages/next/src/server/async-storage/request-store.ts +++ b/packages/next/src/server/async-storage/request-store.ts @@ -202,7 +202,17 @@ function createRequestStoreImpl( }, get cookies() { if (!cache.cookies) { - cache.cookies = createRequestCookies(req) + // if middleware is setting cookie(s), then include those in + // the initial cached cookies so they can be read in render + const requestCookies = new RequestCookies( + HeadersAdapter.from(req.headers) + ) + + mergeMiddlewareCookies(req, requestCookies) + + // Seal the cookies object that'll freeze out any methods that could + // mutate the underlying data. + cache.cookies = RequestCookiesAdapter.seal(requestCookies) } return cache.cookies @@ -258,17 +268,3 @@ export function synchronizeMutableCookies(store: RequestStore) { responseCookiesToRequestCookies(store.mutableCookies) ) } - -export function createRequestCookies( - req: RequestContext['req'] -): ReadonlyRequestCookies { - const requestCookies = new RequestCookies(HeadersAdapter.from(req.headers)) - - // If middleware is setting cookies, then include those so that they can be - // read during render. - mergeMiddlewareCookies(req, requestCookies) - - // Seal the cookies object to freeze out any methods that could mutate the - // underlying data. - return RequestCookiesAdapter.seal(requestCookies) -} From f9108a7f57cdc0db8b38dc522540d5564acd5424 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Tue, 6 May 2025 21:32:38 +0200 Subject: [PATCH 29/57] Revert changes in `use-cache` test fixtures --- .../app-dir/use-cache/app/draft-mode/page.tsx | 18 +++++++++--------- test/e2e/app-dir/use-cache/app/errors/page.tsx | 18 +++++++++++++++++- test/e2e/app-dir/use-cache/app/errors/util.ts | 10 +++++++++- 3 files changed, 35 insertions(+), 11 deletions(-) diff --git a/test/e2e/app-dir/use-cache/app/draft-mode/page.tsx b/test/e2e/app-dir/use-cache/app/draft-mode/page.tsx index 74b2e2f1cde7..e3367334ec80 100644 --- a/test/e2e/app-dir/use-cache/app/draft-mode/page.tsx +++ b/test/e2e/app-dir/use-cache/app/draft-mode/page.tsx @@ -1,6 +1,6 @@ 'use cache' -import { draftMode, headers } from 'next/headers' +import { cookies, draftMode } from 'next/headers' import { Button } from './button' async function getCachedValue( @@ -34,15 +34,15 @@ export default async function Page() { const { isEnabled } = await draftMode() - // Accessing headers in "use cache" should not be allowed, even if draft mode - // is enabled. We expect the access to throw. - let isAccessingHeadersAllowedInUseCache = isEnabled + // Accessing request-scoped data in "use cache" should not be allowed, even if + // draft mode is enabled. We expect the access to throw. + let isAccessingRequestScopedDataAllowedInUseCache = isEnabled - if (isAccessingHeadersAllowedInUseCache) { + if (isAccessingRequestScopedDataAllowedInUseCache) { try { - await headers() + await cookies() } catch { - isAccessingHeadersAllowedInUseCache = false + isAccessingRequestScopedDataAllowedInUseCache = false } } @@ -71,8 +71,8 @@ export default async function Page() { >

{cachedValue}

{await cachedClosure()}

-

- {isAccessingHeadersAllowedInUseCache.toString()} +

+ {isAccessingRequestScopedDataAllowedInUseCache.toString()}

{passthroughFn()}

diff --git a/test/e2e/app-dir/use-cache/app/errors/page.tsx b/test/e2e/app-dir/use-cache/app/errors/page.tsx index e43d1fe29af4..42278f43b4b1 100644 --- a/test/e2e/app-dir/use-cache/app/errors/page.tsx +++ b/test/e2e/app-dir/use-cache/app/errors/page.tsx @@ -2,20 +2,36 @@ import ErrorBoundary from './error-boundary' import { cookies } from 'next/headers' -import { currentReferer } from './util' +import { currentUser, currentReferer, isEditing } from './util' + +async function Cookie() { + 'use cache' + return
User: {currentUser()}
+} async function Header() { 'use cache' return
Referer: {currentReferer()}
} +async function DraftMode() { + 'use cache' + return
Editing: {isEditing()}
+} + export default async function Page() { await cookies() return (
+ + +
+ + +
) } diff --git a/test/e2e/app-dir/use-cache/app/errors/util.ts b/test/e2e/app-dir/use-cache/app/errors/util.ts index 2b60430baca8..42257e2e1c1f 100644 --- a/test/e2e/app-dir/use-cache/app/errors/util.ts +++ b/test/e2e/app-dir/use-cache/app/errors/util.ts @@ -1,5 +1,13 @@ -import { headers } from 'next/headers' +import { cookies, headers, draftMode } from 'next/headers' + +export async function currentUser() { + return (await cookies()).get('user')?.value +} export async function currentReferer() { return (await headers()).get('referer') } + +export async function isEditing() { + return String((await draftMode()).isEnabled) +} From 9d6261137d63e972e23262aebc5be99fef6eda43 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Tue, 6 May 2025 21:44:00 +0200 Subject: [PATCH 30/57] Partially revert the revert in `cookies.ts` --- packages/next/src/server/request/cookies.ts | 199 ++++++-------------- 1 file changed, 55 insertions(+), 144 deletions(-) diff --git a/packages/next/src/server/request/cookies.ts b/packages/next/src/server/request/cookies.ts index 524703b9f4d3..cc148aa6945c 100644 --- a/packages/next/src/server/request/cookies.ts +++ b/packages/next/src/server/request/cookies.ts @@ -23,6 +23,7 @@ import { makeHangingPromise } from '../dynamic-rendering-utils' import { createDedupedByCallsiteServerErrorLoggerDev } from '../create-deduped-by-callsite-server-error-logger' import { scheduleImmediate } from '../../lib/scheduler' import { isRequestAPICallableInsideAfter } from './utils' +import { ReflectAdapter } from '../web/spec-extension/adapters/reflect' /** * In this version of Next.js `cookies()` returns a Promise however you can still reference the properties of the underlying cookies object @@ -166,154 +167,64 @@ function makeDynamicallyTrackedExoticCookies( return cachedPromise } + const abortAndThrow = (property: string) => { + const expression = `\`cookies()${property}\`` + const error = createCookiesAccessError(route, expression) + abortAndThrowOnSynchronousRequestDataAccess( + route, + expression, + error, + prerenderStore + ) + } + const promise = makeHangingPromise( prerenderStore.renderSignal, - '`cookies()`' - ) - CachedCookies.set(prerenderStore, promise) - - Object.defineProperties(promise, { - [Symbol.iterator]: { - value: function () { - const expression = '`cookies()[Symbol.iterator]()`' - const error = createCookiesAccessError(route, expression) - abortAndThrowOnSynchronousRequestDataAccess( - route, - expression, - error, - prerenderStore - ) - }, - }, - size: { - get() { - const expression = '`cookies().size`' - const error = createCookiesAccessError(route, expression) - abortAndThrowOnSynchronousRequestDataAccess( - route, - expression, - error, - prerenderStore - ) - }, - }, - get: { - value: function get() { - let expression: string - if (arguments.length === 0) { - expression = '`cookies().get()`' - } else { - expression = `\`cookies().get(${describeNameArg(arguments[0])})\`` - } - const error = createCookiesAccessError(route, expression) - abortAndThrowOnSynchronousRequestDataAccess( - route, - expression, - error, - prerenderStore - ) - }, - }, - getAll: { - value: function getAll() { - let expression: string - if (arguments.length === 0) { - expression = '`cookies().getAll()`' - } else { - expression = `\`cookies().getAll(${describeNameArg(arguments[0])})\`` - } - const error = createCookiesAccessError(route, expression) - abortAndThrowOnSynchronousRequestDataAccess( - route, - expression, - error, - prerenderStore - ) - }, - }, - has: { - value: function has() { - let expression: string - if (arguments.length === 0) { - expression = '`cookies().has()`' - } else { - expression = `\`cookies().has(${describeNameArg(arguments[0])})\`` - } - const error = createCookiesAccessError(route, expression) - abortAndThrowOnSynchronousRequestDataAccess( - route, - expression, - error, - prerenderStore - ) - }, - }, - set: { - value: function set() { - let expression: string - if (arguments.length === 0) { - expression = '`cookies().set()`' - } else { - const arg = arguments[0] - if (arg) { - expression = `\`cookies().set(${describeNameArg(arg)}, ...)\`` - } else { - expression = '`cookies().set(...)`' - } - } - const error = createCookiesAccessError(route, expression) - abortAndThrowOnSynchronousRequestDataAccess( - route, - expression, - error, - prerenderStore - ) - }, - }, - delete: { - value: function () { - let expression: string - if (arguments.length === 0) { - expression = '`cookies().delete()`' - } else if (arguments.length === 1) { - expression = `\`cookies().delete(${describeNameArg(arguments[0])})\`` - } else { - expression = `\`cookies().delete(${describeNameArg(arguments[0])}, ...)\`` + '`cookies()`', + { + get: function get(target, prop, receiver) { + switch (prop) { + case Symbol.iterator: + return function () { + abortAndThrow('[Symbol.iterator]') + } + case 'size': + abortAndThrow('.size') + break + case 'get': + case 'getAll': + case 'has': + return function () { + abortAndThrow( + arguments.length === 0 + ? `.${prop}()` + : `.${prop}(${describeNameArg(arguments[0])})` + ) + } + case 'set': + case 'delete': + return function () { + abortAndThrow( + arguments.length === 0 + ? `.${prop}()` + : arguments[0] + ? `.${prop}(${describeNameArg(arguments[0])}, ...)` + : `.${prop}(...)` + ) + } + case 'clear': + case 'toString': + return function () { + abortAndThrow(`.${prop}()`) + } + default: + return ReflectAdapter.get(target, prop, receiver) } - const error = createCookiesAccessError(route, expression) - abortAndThrowOnSynchronousRequestDataAccess( - route, - expression, - error, - prerenderStore - ) - }, - }, - clear: { - value: function clear() { - const expression = '`cookies().clear()`' - const error = createCookiesAccessError(route, expression) - abortAndThrowOnSynchronousRequestDataAccess( - route, - expression, - error, - prerenderStore - ) }, - }, - toString: { - value: function toString() { - const expression = '`cookies().toString()`' - const error = createCookiesAccessError(route, expression) - abortAndThrowOnSynchronousRequestDataAccess( - route, - expression, - error, - prerenderStore - ) - }, - }, - } satisfies CookieExtensions) + } + ) + + CachedCookies.set(prerenderStore, promise) return promise } From 7d26d233b48a9348e38744c0831d398a48a79d41 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Tue, 6 May 2025 21:48:00 +0200 Subject: [PATCH 31/57] Revert adding `waitUntil` option --- test/lib/browsers/playwright.ts | 14 +++----------- test/lib/next-webdriver.ts | 13 +------------ 2 files changed, 4 insertions(+), 23 deletions(-) diff --git a/test/lib/browsers/playwright.ts b/test/lib/browsers/playwright.ts index b5b1147b342d..7f4b1ec7fc31 100644 --- a/test/lib/browsers/playwright.ts +++ b/test/lib/browsers/playwright.ts @@ -229,10 +229,6 @@ export class Playwright { cpuThrottleRate?: number pushErrorAsConsoleLog?: boolean beforePageLoad?: (page: Page) => void - /** - * @default "load" - */ - waitUntil?: 'domcontentloaded' | 'load' | 'networkidle' | 'commit' } ) { await this.close() @@ -316,7 +312,7 @@ export class Playwright { opts?.beforePageLoad?.(page) - await page.goto(url, { waitUntil: opts?.waitUntil ?? 'load' }) + await page.goto(url, { waitUntil: 'load' }) } back(options?: Parameters[0]) { @@ -331,14 +327,10 @@ export class Playwright { await page.goForward(options) }) } - refresh({ - waitUntil, - }: { - waitUntil?: 'domcontentloaded' | 'load' | 'networkidle' | 'commit' - } = {}) { + refresh() { // do not preserve the previous chained value, it's likely to be invalid after a reload. return this.startChain(async () => { - await page.reload({ waitUntil }) + await page.reload() }) } setDimensions({ width, height }: { height: number; width: number }) { diff --git a/test/lib/next-webdriver.ts b/test/lib/next-webdriver.ts index 064d3a8720f9..38883ef8bd34 100644 --- a/test/lib/next-webdriver.ts +++ b/test/lib/next-webdriver.ts @@ -45,12 +45,6 @@ if (typeof afterAll === 'function') { } export interface WebdriverOptions { - /** - * When to consider the initial page load succeeded. - * - * @default "load" - */ - waitUntil?: 'domcontentloaded' | 'load' | 'networkidle' | 'commit' /** * whether to wait for React hydration to finish */ @@ -109,7 +103,6 @@ export default async function webdriver( } options = Object.assign(defaultOptions, options) const { - waitUntil, waitHydration, retryWaitHydration, disableCache, @@ -152,12 +145,8 @@ export default async function webdriver( cpuThrottleRate, beforePageLoad, pushErrorAsConsoleLog, - waitUntil, }) - - console.log( - `\n> Loaded browser with ${fullUrl} (waitUntil: ${JSON.stringify(waitUntil)})\n` - ) + console.log(`\n> Loaded browser with ${fullUrl}\n`) browserTeardown.push(browser.close.bind(browser)) From 0ea8f11e6a0b8d4577198d151b69dde9d92c409d Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Tue, 6 May 2025 22:10:10 +0200 Subject: [PATCH 32/57] Update prerender manifest snapshots --- .../e2e/app-dir/app-static/app-static.test.ts | 13 ++ test/e2e/prerender.test.ts | 15 +++ test/integration/i18n-support/test/shared.js | 120 +++++++++--------- 3 files changed, 90 insertions(+), 58 deletions(-) diff --git a/test/e2e/app-dir/app-static/app-static.test.ts b/test/e2e/app-dir/app-static/app-static.test.ts index 227c081ebfa3..92f4a7e1497b 100644 --- a/test/e2e/app-dir/app-static/app-static.test.ts +++ b/test/e2e/app-dir/app-static/app-static.test.ts @@ -2358,6 +2358,7 @@ describe('app-dir static/dynamic handling', () => { expect(curManifest.dynamicRoutes).toMatchInlineSnapshot(` { "/articles/[slug]": { + "allowEmptyStaticShell": false, "allowHeader": [ "host", "x-matched-path", @@ -2383,6 +2384,7 @@ describe('app-dir static/dynamic handling', () => { "routeRegex": "^\\/articles\\/([^\\/]+?)(?:\\/)?$", }, "/blog/[author]": { + "allowEmptyStaticShell": false, "allowHeader": [ "host", "x-matched-path", @@ -2408,6 +2410,7 @@ describe('app-dir static/dynamic handling', () => { "routeRegex": "^\\/blog\\/([^\\/]+?)(?:\\/)?$", }, "/blog/[author]/[slug]": { + "allowEmptyStaticShell": false, "allowHeader": [ "host", "x-matched-path", @@ -2433,6 +2436,7 @@ describe('app-dir static/dynamic handling', () => { "routeRegex": "^\\/blog\\/([^\\/]+?)\\/([^\\/]+?)(?:\\/)?$", }, "/dynamic-error/[id]": { + "allowEmptyStaticShell": false, "allowHeader": [ "host", "x-matched-path", @@ -2458,6 +2462,7 @@ describe('app-dir static/dynamic handling', () => { "routeRegex": "^\\/dynamic\\-error\\/([^\\/]+?)(?:\\/)?$", }, "/force-static/[slug]": { + "allowEmptyStaticShell": false, "allowHeader": [ "host", "x-matched-path", @@ -2483,6 +2488,7 @@ describe('app-dir static/dynamic handling', () => { "routeRegex": "^\\/force\\-static\\/([^\\/]+?)(?:\\/)?$", }, "/gen-params-catch-all-unique/[...slug]": { + "allowEmptyStaticShell": false, "allowHeader": [ "host", "x-matched-path", @@ -2508,6 +2514,7 @@ describe('app-dir static/dynamic handling', () => { "routeRegex": "^\\/gen\\-params\\-catch\\-all\\-unique\\/(.+?)(?:\\/)?$", }, "/gen-params-dynamic-revalidate/[slug]": { + "allowEmptyStaticShell": false, "allowHeader": [ "host", "x-matched-path", @@ -2533,6 +2540,7 @@ describe('app-dir static/dynamic handling', () => { "routeRegex": "^\\/gen\\-params\\-dynamic\\-revalidate\\/([^\\/]+?)(?:\\/)?$", }, "/hooks/use-pathname/[slug]": { + "allowEmptyStaticShell": false, "allowHeader": [ "host", "x-matched-path", @@ -2558,6 +2566,7 @@ describe('app-dir static/dynamic handling', () => { "routeRegex": "^\\/hooks\\/use\\-pathname\\/([^\\/]+?)(?:\\/)?$", }, "/partial-gen-params-no-additional-lang/[lang]/[slug]": { + "allowEmptyStaticShell": false, "allowHeader": [ "host", "x-matched-path", @@ -2583,6 +2592,7 @@ describe('app-dir static/dynamic handling', () => { "routeRegex": "^\\/partial\\-gen\\-params\\-no\\-additional\\-lang\\/([^\\/]+?)\\/([^\\/]+?)(?:\\/)?$", }, "/partial-gen-params-no-additional-slug/[lang]/[slug]": { + "allowEmptyStaticShell": false, "allowHeader": [ "host", "x-matched-path", @@ -2608,6 +2618,7 @@ describe('app-dir static/dynamic handling', () => { "routeRegex": "^\\/partial\\-gen\\-params\\-no\\-additional\\-slug\\/([^\\/]+?)\\/([^\\/]+?)(?:\\/)?$", }, "/prerendered-not-found/[slug]": { + "allowEmptyStaticShell": false, "allowHeader": [ "host", "x-matched-path", @@ -2633,6 +2644,7 @@ describe('app-dir static/dynamic handling', () => { "routeRegex": "^\\/prerendered\\-not\\-found\\/([^\\/]+?)(?:\\/)?$", }, "/ssg-draft-mode/[[...route]]": { + "allowEmptyStaticShell": false, "allowHeader": [ "host", "x-matched-path", @@ -2658,6 +2670,7 @@ describe('app-dir static/dynamic handling', () => { "routeRegex": "^\\/ssg\\-draft\\-mode(?:\\/(.+?))?(?:\\/)?$", }, "/static-to-dynamic-error-forced/[id]": { + "allowEmptyStaticShell": false, "allowHeader": [ "host", "x-matched-path", diff --git a/test/e2e/prerender.test.ts b/test/e2e/prerender.test.ts index 9c9bc8580b88..ac3f7ec9ffad 100644 --- a/test/e2e/prerender.test.ts +++ b/test/e2e/prerender.test.ts @@ -1666,6 +1666,7 @@ describe('Prerender', () => { fallback: '/api-docs/[...slug].html', routeRegex: normalizeRegEx(`^\\/api\\-docs\\/(.+?)(?:\\/)?$`), allowHeader, + allowEmptyStaticShell: false, }, '/blocking-fallback-once/[slug]': { dataRoute: `/_next/data/${next.buildId}/blocking-fallback-once/[slug].json`, @@ -1677,6 +1678,7 @@ describe('Prerender', () => { '^\\/blocking\\-fallback\\-once\\/([^\\/]+?)(?:\\/)?$' ), allowHeader, + allowEmptyStaticShell: false, }, '/blocking-fallback-some/[slug]': { dataRoute: `/_next/data/${next.buildId}/blocking-fallback-some/[slug].json`, @@ -1688,6 +1690,7 @@ describe('Prerender', () => { '^\\/blocking\\-fallback\\-some\\/([^\\/]+?)(?:\\/)?$' ), allowHeader, + allowEmptyStaticShell: false, }, '/blocking-fallback/[slug]': { dataRoute: `/_next/data/${next.buildId}/blocking-fallback/[slug].json`, @@ -1699,6 +1702,7 @@ describe('Prerender', () => { '^\\/blocking\\-fallback\\/([^\\/]+?)(?:\\/)?$' ), allowHeader, + allowEmptyStaticShell: false, }, '/blog/[post]': { fallback: '/blog/[post].html', @@ -1708,6 +1712,7 @@ describe('Prerender', () => { ), routeRegex: normalizeRegEx('^\\/blog\\/([^\\/]+?)(?:\\/)?$'), allowHeader, + allowEmptyStaticShell: false, }, '/blog/[post]/[comment]': { fallback: '/blog/[post]/[comment].html', @@ -1719,6 +1724,7 @@ describe('Prerender', () => { '^\\/blog\\/([^\\/]+?)\\/([^\\/]+?)(?:\\/)?$' ), allowHeader, + allowEmptyStaticShell: false, }, '/dynamic/[slug]': { dataRoute: `/_next/data/${next.buildId}/dynamic/[slug].json`, @@ -1728,6 +1734,7 @@ describe('Prerender', () => { fallback: false, routeRegex: normalizeRegEx(`^\\/dynamic\\/([^\\/]+?)(?:\\/)?$`), allowHeader, + allowEmptyStaticShell: false, }, '/fallback-only/[slug]': { dataRoute: `/_next/data/${next.buildId}/fallback-only/[slug].json`, @@ -1739,6 +1746,7 @@ describe('Prerender', () => { '^\\/fallback\\-only\\/([^\\/]+?)(?:\\/)?$' ), allowHeader, + allowEmptyStaticShell: false, }, '/lang/[lang]/about': { dataRoute: `/_next/data/${next.buildId}/lang/[lang]/about.json`, @@ -1750,6 +1758,7 @@ describe('Prerender', () => { '^\\/lang\\/([^\\/]+?)\\/about(?:\\/)?$' ), allowHeader, + allowEmptyStaticShell: false, }, '/non-json-blocking/[p]': { dataRoute: `/_next/data/${next.buildId}/non-json-blocking/[p].json`, @@ -1761,6 +1770,7 @@ describe('Prerender', () => { '^\\/non\\-json\\-blocking\\/([^\\/]+?)(?:\\/)?$' ), allowHeader, + allowEmptyStaticShell: false, }, '/non-json/[p]': { dataRoute: `/_next/data/${next.buildId}/non-json/[p].json`, @@ -1772,6 +1782,7 @@ describe('Prerender', () => { '^\\/non\\-json\\/([^\\/]+?)(?:\\/)?$' ), allowHeader, + allowEmptyStaticShell: false, }, '/user/[user]/profile': { fallback: '/user/[user]/profile.html', @@ -1783,6 +1794,7 @@ describe('Prerender', () => { `^\\/user\\/([^\\/]+?)\\/profile(?:\\/)?$` ), allowHeader, + allowEmptyStaticShell: false, }, '/catchall/[...slug]': { @@ -1793,6 +1805,7 @@ describe('Prerender', () => { `^\\/_next\\/data\\/${escapedBuildId}\\/catchall\\/(.+?)\\.json$` ), allowHeader, + allowEmptyStaticShell: false, }, '/catchall-optional/[[...slug]]': { dataRoute: `/_next/data/${next.buildId}/catchall-optional/[[...slug]].json`, @@ -1804,6 +1817,7 @@ describe('Prerender', () => { '^\\/catchall\\-optional(?:\\/(.+?))?(?:\\/)?$' ), allowHeader, + allowEmptyStaticShell: false, }, '/catchall-explicit/[...slug]': { dataRoute: `/_next/data/${next.buildId}/catchall-explicit/[...slug].json`, @@ -1815,6 +1829,7 @@ describe('Prerender', () => { '^\\/catchall\\-explicit\\/(.+?)(?:\\/)?$' ), allowHeader, + allowEmptyStaticShell: false, }, }) }) diff --git a/test/integration/i18n-support/test/shared.js b/test/integration/i18n-support/test/shared.js index d846e35b4ce3..716f74828dbe 100644 --- a/test/integration/i18n-support/test/shared.js +++ b/test/integration/i18n-support/test/shared.js @@ -1768,64 +1768,68 @@ export function runTests(ctx) { 'BUILD_ID' ) ).toMatchInlineSnapshot(` - "{ - "/gsp/fallback/[slug]": { - "routeRegex": "^\\/gsp\\/fallback\\/([^\\/]+?)(?:\\/)?$", - "dataRoute": "/_next/data/BUILD_ID/gsp/fallback/[slug].json", - "fallback": "/gsp/fallback/[slug].html", - "dataRouteRegex": "^\\/_next\\/data\\/BUILD_ID\\/gsp\\/fallback\\/([^\\/]+?)\\.json$", - "allowHeader": [ - "host", - "x-matched-path", - "x-prerender-revalidate", - "x-prerender-revalidate-if-generated", - "x-next-revalidated-tags", - "x-next-revalidate-tag-token" - ] - }, - "/gsp/no-fallback/[slug]": { - "routeRegex": "^\\/gsp\\/no\\-fallback\\/([^\\/]+?)(?:\\/)?$", - "dataRoute": "/_next/data/BUILD_ID/gsp/no-fallback/[slug].json", - "fallback": false, - "dataRouteRegex": "^\\/_next\\/data\\/BUILD_ID\\/gsp\\/no\\-fallback\\/([^\\/]+?)\\.json$", - "allowHeader": [ - "host", - "x-matched-path", - "x-prerender-revalidate", - "x-prerender-revalidate-if-generated", - "x-next-revalidated-tags", - "x-next-revalidate-tag-token" - ] - }, - "/not-found/blocking-fallback/[slug]": { - "routeRegex": "^\\/not\\-found\\/blocking\\-fallback\\/([^\\/]+?)(?:\\/)?$", - "dataRoute": "/_next/data/BUILD_ID/not-found/blocking-fallback/[slug].json", - "fallback": null, - "dataRouteRegex": "^\\/_next\\/data\\/BUILD_ID\\/not\\-found\\/blocking\\-fallback\\/([^\\/]+?)\\.json$", - "allowHeader": [ - "host", - "x-matched-path", - "x-prerender-revalidate", - "x-prerender-revalidate-if-generated", - "x-next-revalidated-tags", - "x-next-revalidate-tag-token" - ] - }, - "/not-found/fallback/[slug]": { - "routeRegex": "^\\/not\\-found\\/fallback\\/([^\\/]+?)(?:\\/)?$", - "dataRoute": "/_next/data/BUILD_ID/not-found/fallback/[slug].json", - "fallback": "/not-found/fallback/[slug].html", - "dataRouteRegex": "^\\/_next\\/data\\/BUILD_ID\\/not\\-found\\/fallback\\/([^\\/]+?)\\.json$", - "allowHeader": [ - "host", - "x-matched-path", - "x-prerender-revalidate", - "x-prerender-revalidate-if-generated", - "x-next-revalidated-tags", - "x-next-revalidate-tag-token" - ] - } - }" + "{ + "/gsp/fallback/[slug]": { + "routeRegex": "^\\/gsp\\/fallback\\/([^\\/]+?)(?:\\/)?$", + "dataRoute": "/_next/data/BUILD_ID/gsp/fallback/[slug].json", + "fallback": "/gsp/fallback/[slug].html", + "dataRouteRegex": "^\\/_next\\/data\\/BUILD_ID\\/gsp\\/fallback\\/([^\\/]+?)\\.json$", + "allowHeader": [ + "host", + "x-matched-path", + "x-prerender-revalidate", + "x-prerender-revalidate-if-generated", + "x-next-revalidated-tags", + "x-next-revalidate-tag-token" + ], + "allowEmptyStaticShell": false + }, + "/gsp/no-fallback/[slug]": { + "routeRegex": "^\\/gsp\\/no\\-fallback\\/([^\\/]+?)(?:\\/)?$", + "dataRoute": "/_next/data/BUILD_ID/gsp/no-fallback/[slug].json", + "fallback": false, + "dataRouteRegex": "^\\/_next\\/data\\/BUILD_ID\\/gsp\\/no\\-fallback\\/([^\\/]+?)\\.json$", + "allowHeader": [ + "host", + "x-matched-path", + "x-prerender-revalidate", + "x-prerender-revalidate-if-generated", + "x-next-revalidated-tags", + "x-next-revalidate-tag-token" + ], + "allowEmptyStaticShell": false + }, + "/not-found/blocking-fallback/[slug]": { + "routeRegex": "^\\/not\\-found\\/blocking\\-fallback\\/([^\\/]+?)(?:\\/)?$", + "dataRoute": "/_next/data/BUILD_ID/not-found/blocking-fallback/[slug].json", + "fallback": null, + "dataRouteRegex": "^\\/_next\\/data\\/BUILD_ID\\/not\\-found\\/blocking\\-fallback\\/([^\\/]+?)\\.json$", + "allowHeader": [ + "host", + "x-matched-path", + "x-prerender-revalidate", + "x-prerender-revalidate-if-generated", + "x-next-revalidated-tags", + "x-next-revalidate-tag-token" + ], + "allowEmptyStaticShell": false + }, + "/not-found/fallback/[slug]": { + "routeRegex": "^\\/not\\-found\\/fallback\\/([^\\/]+?)(?:\\/)?$", + "dataRoute": "/_next/data/BUILD_ID/not-found/fallback/[slug].json", + "fallback": "/not-found/fallback/[slug].html", + "dataRouteRegex": "^\\/_next\\/data\\/BUILD_ID\\/not\\-found\\/fallback\\/([^\\/]+?)\\.json$", + "allowHeader": [ + "host", + "x-matched-path", + "x-prerender-revalidate", + "x-prerender-revalidate-if-generated", + "x-next-revalidated-tags", + "x-next-revalidate-tag-token" + ], + "allowEmptyStaticShell": false + } + }" `) }) } From f25680cdc49122b3229075290e083445319a54c9 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Tue, 6 May 2025 22:25:07 +0200 Subject: [PATCH 33/57] Fix compiler error in test file --- .../server/lib/incremental-cache/shared-cache-controls.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/next/src/server/lib/incremental-cache/shared-cache-controls.test.ts b/packages/next/src/server/lib/incremental-cache/shared-cache-controls.test.ts index e6935165c61c..0b964b791872 100644 --- a/packages/next/src/server/lib/incremental-cache/shared-cache-controls.test.ts +++ b/packages/next/src/server/lib/incremental-cache/shared-cache-controls.test.ts @@ -48,6 +48,7 @@ describe('SharedCacheControls', () => { experimentalPPR: undefined, renderingMode: RenderingMode.PARTIALLY_STATIC, allowHeader: [], + allowEmptyStaticShell: false, } satisfies DynamicPrerenderManifestRoute, }, } From 545c9b60f2dab6bacb8c8495b9ecb1190d17d59c Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Tue, 6 May 2025 22:33:46 +0200 Subject: [PATCH 34/57] Increase test setup timeout --- .../empty-fallback-shells/empty-fallback-shells.test.ts | 1 + test/lib/e2e-utils/index.ts | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/test/e2e/app-dir/empty-fallback-shells/empty-fallback-shells.test.ts b/test/e2e/app-dir/empty-fallback-shells/empty-fallback-shells.test.ts index ef4e6b9ed98b..29b246ca4718 100644 --- a/test/e2e/app-dir/empty-fallback-shells/empty-fallback-shells.test.ts +++ b/test/e2e/app-dir/empty-fallback-shells/empty-fallback-shells.test.ts @@ -3,6 +3,7 @@ import { nextTestSetup } from 'e2e-utils' describe('empty-fallback-shells', () => { const { next, isNextDeploy, isNextStart } = nextTestSetup({ files: __dirname, + timeout: 180_000, }) describe('without IO', () => { diff --git a/test/lib/e2e-utils/index.ts b/test/lib/e2e-utils/index.ts index 6df759066b28..0916f55beca7 100644 --- a/test/lib/e2e-utils/index.ts +++ b/test/lib/e2e-utils/index.ts @@ -230,6 +230,7 @@ export function nextTestSetup( options: Parameters[0] & { skipDeployment?: boolean dir?: string + timeout?: number } ): { isNextDev: boolean @@ -255,7 +256,7 @@ export function nextTestSetup( if (!skipped) { beforeAll(async () => { next = await createNext(options) - }) + }, options.timeout) afterAll(async () => { // Gracefully destroy the instance if `createNext` success. // If next instance is not available, it's likely beforeAll hook failed and unnecessarily throws another error From b064f0e473e8a2c4c74f2c2dc238e188a7ba2571 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Wed, 7 May 2025 09:10:06 +0200 Subject: [PATCH 35/57] Fix test expectation for deploy mode --- .../empty-fallback-shells.test.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/test/e2e/app-dir/empty-fallback-shells/empty-fallback-shells.test.ts b/test/e2e/app-dir/empty-fallback-shells/empty-fallback-shells.test.ts index 29b246ca4718..74d8ba7cd94c 100644 --- a/test/e2e/app-dir/empty-fallback-shells/empty-fallback-shells.test.ts +++ b/test/e2e/app-dir/empty-fallback-shells/empty-fallback-shells.test.ts @@ -1,7 +1,7 @@ import { nextTestSetup } from 'e2e-utils' describe('empty-fallback-shells', () => { - const { next, isNextDeploy, isNextStart } = nextTestSetup({ + const { next, isNextDev, isNextDeploy, isNextStart } = nextTestSetup({ files: __dirname, timeout: 180_000, }) @@ -33,10 +33,10 @@ describe('empty-fallback-shells', () => { const html = await res.text() expect(html).toContain('page-bar') - if (isNextStart) { - expect(html).toContain('layout-buildtime') - } else { + if (isNextDev) { expect(html).toContain('layout-runtime') + } else { + expect(html).toContain('layout-buildtime') } if (isNextDeploy) { @@ -58,10 +58,10 @@ describe('empty-fallback-shells', () => { const html = await res.text() expect(html).toContain('page-bar') - if (isNextStart) { - expect(html).toContain('layout-buildtime') - } else { + if (isNextDev) { expect(html).toContain('layout-runtime') + } else { + expect(html).toContain('layout-buildtime') } if (isNextDeploy) { From bf04166cbe55e579a73e0b4fb12a4fad0236681b Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Wed, 7 May 2025 11:28:46 +0200 Subject: [PATCH 36/57] Don't abort on fallback params access if `allowEmptyStaticShell` is false --- packages/next/src/server/request/params.ts | 5 ++++- packages/next/src/server/use-cache/use-cache-wrapper.ts | 2 ++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/next/src/server/request/params.ts b/packages/next/src/server/request/params.ts index 04685f9303cc..e0aa024d1820 100644 --- a/packages/next/src/server/request/params.ts +++ b/packages/next/src/server/request/params.ts @@ -220,7 +220,10 @@ function makeAbortingExoticParams( if ( workUnitStore?.type === 'cache' && - workUnitStore.renderContext?.type === 'prerender' + workUnitStore.renderContext?.type === 'prerender' && + // TODO: We could consider aborting for any kind of fallback shell + // prerendering, including when allowEmptyStaticShell is false. + workUnitStore.renderContext.allowEmptyStaticShell ) { workUnitStore.renderContext.dynamicAccessAbortController.abort( new Error( 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 9ca5ec4245f0..a1ae0810a4d8 100644 --- a/packages/next/src/server/use-cache/use-cache-wrapper.ts +++ b/packages/next/src/server/use-cache/use-cache-wrapper.ts @@ -457,6 +457,8 @@ async function generateCacheEntryImpl( // a hanging promise. This essentially makes the "use cache" function // dynamic in the context of the fallback shell. When there's no suspense // boundary above, the shell will be empty, which is allowed. + // TODO: We could consider doing this for any kind of fallback shell + // prerendering, including when allowEmptyStaticShell is false. if (abortSignal.aborted && renderContext.allowEmptyStaticShell) { if (outerWorkUnitStore?.type === 'prerender') { outerWorkUnitStore.cacheSignal?.endRead() From 6ef003f9c87b10410d6d700f831a858baeb0b4d4 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Sun, 11 May 2025 19:25:19 +0200 Subject: [PATCH 37/57] Introduce `DynamicAccessStorage` --- .vscode/settings.json | 1 + packages/next/src/build/webpack-config.ts | 2 +- .../dynamic-access-async-storage-instance.ts | 5 +++++ .../dynamic-access-async-storage.external.ts | 11 +++++++++++ 4 files changed, 18 insertions(+), 1 deletion(-) create mode 100644 packages/next/src/server/app-render/dynamic-access-async-storage-instance.ts create mode 100644 packages/next/src/server/app-render/dynamic-access-async-storage.external.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 2ab01fe8b8ee..b195325c3742 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -59,6 +59,7 @@ // singleton modules should always use "*.external" instead of "*-instance" "packages/next/src/server/app-render/action-async-storage-instance.ts", "packages/next/src/server/app-render/after-task-async-storage-instance.ts", + "packages/next/src/server/app-render/dynamic-access-async-storage-instance.ts", "packages/next/src/server/app-render/work-async-storage-instance.ts", "packages/next/src/server/app-render/work-unit-async-storage-instance.ts", "packages/next/src/client/components/segment-cache-impl/*" diff --git a/packages/next/src/build/webpack-config.ts b/packages/next/src/build/webpack-config.ts index 1345cdbbc442..609b7a21651f 100644 --- a/packages/next/src/build/webpack-config.ts +++ b/packages/next/src/build/webpack-config.ts @@ -133,7 +133,7 @@ const browserNonTranspileModules = [ const precompileRegex = /[\\/]next[\\/]dist[\\/]compiled[\\/]/ const asyncStoragesRegex = - /next[\\/]dist[\\/](esm[\\/])?server[\\/]app-render[\\/](work-async-storage|action-async-storage|work-unit-async-storage)/ + /next[\\/]dist[\\/](esm[\\/])?server[\\/]app-render[\\/](work-async-storage|action-async-storage|dynamic-access-async-storage|work-unit-async-storage)/ // Support for NODE_PATH const nodePathList = (process.env.NODE_PATH || '') diff --git a/packages/next/src/server/app-render/dynamic-access-async-storage-instance.ts b/packages/next/src/server/app-render/dynamic-access-async-storage-instance.ts new file mode 100644 index 000000000000..4364e4447ae2 --- /dev/null +++ b/packages/next/src/server/app-render/dynamic-access-async-storage-instance.ts @@ -0,0 +1,5 @@ +import { createAsyncLocalStorage } from './async-local-storage' +import type { DynamicAccessStorage } from './dynamic-access-async-storage.external' + +export const dynamicAccessAsyncStorageInstance: DynamicAccessStorage = + createAsyncLocalStorage() diff --git a/packages/next/src/server/app-render/dynamic-access-async-storage.external.ts b/packages/next/src/server/app-render/dynamic-access-async-storage.external.ts new file mode 100644 index 000000000000..b1e39da00050 --- /dev/null +++ b/packages/next/src/server/app-render/dynamic-access-async-storage.external.ts @@ -0,0 +1,11 @@ +import type { AsyncLocalStorage } from 'async_hooks' + +// Share the instance module in the next-shared layer +import { dynamicAccessAsyncStorageInstance } from './dynamic-access-async-storage-instance' with { 'turbopack-transition': 'next-shared' } + +export interface DynamicAccessAsyncStore { + readonly abortController: AbortController +} + +export type DynamicAccessStorage = AsyncLocalStorage +export { dynamicAccessAsyncStorageInstance as dynamicAccessAsyncStorage } From 08e2bfd2ca2813b06e50d0098c8d6b8d49bedcf5 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Sun, 11 May 2025 19:47:28 +0200 Subject: [PATCH 38/57] Non-page `"use cache"` functions accessing fallback params make them dynamic --- .../next/src/server/app-render/app-render.tsx | 24 +++ .../app-render/create-component-tree.tsx | 26 ++- packages/next/src/server/request/params.ts | 15 +- .../src/server/use-cache/use-cache-wrapper.ts | 171 +++++++++++++----- .../empty-fallback-shells.test.ts | 3 +- .../app/fallback-params/[slug]/page.tsx | 14 -- .../use-cache-hanging-inputs.test.ts | 4 - test/lib/e2e-utils/index.ts | 1 - 8 files changed, 174 insertions(+), 84 deletions(-) delete mode 100644 test/e2e/app-dir/use-cache-hanging-inputs/app/fallback-params/[slug]/page.tsx diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index 999c0e86b4ab..a1b3d699167d 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -190,6 +190,7 @@ import { trackPendingImport, trackPendingModules, } from './module-loading/track-module-loading.external' +import { isUseCacheTimeoutError } from '../use-cache/use-cache-errors' export type GetDynamicParamFromSegment = ( // [slug] / [[slug]] / [...slug] @@ -2886,6 +2887,29 @@ async function prerenderToStream( // We don't need to continue the prerender process if we already // detected invalid dynamic usage in the initial prerender phase. if (workStore.invalidDynamicUsageError) { + if ( + isUseCacheTimeoutError(workStore.invalidDynamicUsageError) && + allowEmptyStaticShell + ) { + // If this is a "use cache" timeout error, and empty shells are + // allowed (i.e. we're prerendering a fallback shell, and there are + // also more specific routes prerendered) we return an empty shell. + return { + digestErrorsMap: reactServerErrorsByDigest, + ssrErrors: allCapturedErrors, + stream: new ReadableStream({ + start(controller) { + controller.close() + }, + }), + collectedRevalidate: INFINITE_CACHE, + collectedExpire: INFINITE_CACHE, + collectedStale: selectStaleTime(INFINITE_CACHE), + collectedTags: null, + } + } + + // Otherwise we throw the error to fail the build. throw workStore.invalidDynamicUsageError } 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 af8e0d8ce1f9..7d5e6e326a31 100644 --- a/packages/next/src/server/app-render/create-component-tree.tsx +++ b/packages/next/src/server/app-render/create-component-tree.tsx @@ -21,7 +21,10 @@ import type { LoadingModuleData } from '../../shared/lib/app-router-context.shar import type { Params } from '../request/params' import { workUnitAsyncStorage } from './work-unit-async-storage.external' import { OUTLET_BOUNDARY_NAME } from '../../lib/metadata/metadata-constants' -import type { UseCachePageComponentProps } from '../use-cache/use-cache-wrapper' +import type { + UseCacheLayoutComponentProps, + UseCachePageComponentProps, +} from '../use-cache/use-cache-wrapper' /** * Use the provided loader tree to create the React Component tree. @@ -842,9 +845,24 @@ async function createComponentTreeInternal({ workStore ) - let serverSegment = ( - - ) + let serverSegment: React.ReactNode + + if (isUseCacheFunction(SegmentComponent)) { + const UseCacheLayoutComponent: React.ComponentType = + SegmentComponent + + serverSegment = ( + + ) + } else { + serverSegment = ( + + ) + } if (isRootLayoutWithChildrenSlotAndAtLeastOneMoreSlot) { // TODO-APP: This is a hack to support unmatched parallel routes, which will throw `notFound()`. diff --git a/packages/next/src/server/request/params.ts b/packages/next/src/server/request/params.ts index e0aa024d1820..ff58d227b7d8 100644 --- a/packages/next/src/server/request/params.ts +++ b/packages/next/src/server/request/params.ts @@ -24,6 +24,7 @@ import { import { makeHangingPromise } from '../dynamic-rendering-utils' import { createDedupedByCallsiteServerErrorLoggerDev } from '../create-deduped-by-callsite-server-error-logger' import { scheduleImmediate } from '../../lib/scheduler' +import { dynamicAccessAsyncStorage } from '../app-render/dynamic-access-async-storage.external' export type ParamValue = string | Array | undefined export type Params = Record @@ -216,16 +217,10 @@ function makeAbortingExoticParams( switch (prop) { case 'then': case 'status': - const workUnitStore = workUnitAsyncStorage.getStore() - - if ( - workUnitStore?.type === 'cache' && - workUnitStore.renderContext?.type === 'prerender' && - // TODO: We could consider aborting for any kind of fallback shell - // prerendering, including when allowEmptyStaticShell is false. - workUnitStore.renderContext.allowEmptyStaticShell - ) { - workUnitStore.renderContext.dynamicAccessAbortController.abort( + const dynamicAccessStore = dynamicAccessAsyncStorage.getStore() + + if (dynamicAccessStore) { + dynamicAccessStore.abortController.abort( new Error( `Accessed fallback \`params\` in \`"use cache"\` during prerendering.` ) 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 a1ae0810a4d8..d366298459f2 100644 --- a/packages/next/src/server/use-cache/use-cache-wrapper.ts +++ b/packages/next/src/server/use-cache/use-cache-wrapper.ts @@ -53,6 +53,7 @@ import type { Params } from '../request/params' import React from 'react' import { createLazyResult, isResolvedLazyResult } from '../lib/lazy-result' import { createUseCacheRenderContext } from './render-context' +import { dynamicAccessAsyncStorage } from '../app-render/dynamic-access-async-storage.external' type CacheKeyParts = | [buildId: string, id: string, args: unknown[]] @@ -64,6 +65,15 @@ export interface UseCachePageComponentProps { $$isPageComponent: true } +export type UseCacheLayoutComponentProps = { + params: Promise + $$isLayoutComponent: true +} & { + // The value type should be React.ReactNode. But such an index signature would + // be incompatible with the other two props. + [slot: string]: any +} + const isEdgeRuntime = process.env.NEXT_RUNTIME === 'edge' const debug = process.env.NEXT_PRIVATE_DEBUG_CACHE @@ -181,17 +191,19 @@ function generateCacheEntryWithCacheContext( outerWorkUnitStore && createUseCacheRenderContext(outerWorkUnitStore), } - return workUnitAsyncStorage.run( - cacheStore, - generateCacheEntryImpl, - workStore, - outerWorkUnitStore, - kind, - cacheStore, - clientReferenceManifest, - encodedArguments, - fn, - timeoutError + return workUnitAsyncStorage.run(cacheStore, () => + dynamicAccessAsyncStorage.run( + { abortController: new AbortController() }, + generateCacheEntryImpl, + workStore, + outerWorkUnitStore, + kind, + cacheStore, + clientReferenceManifest, + encodedArguments, + fn, + timeoutError + ) ) } @@ -409,11 +421,10 @@ async function generateCacheEntryImpl( } let stream: ReadableStream - const { renderContext } = innerCacheStore - if (renderContext?.type === 'prerender') { - const { signal: dynamicAccessAbortSignal } = - renderContext.dynamicAccessAbortController + if (outerWorkUnitStore?.type === 'prerender') { + const dynamicAccessAbortSignal = + dynamicAccessAsyncStorage.getStore()?.abortController.signal const timeoutAbortController = new AbortController() @@ -428,7 +439,7 @@ async function generateCacheEntryImpl( const abortSignal = dynamicAccessAbortSignal ? AbortSignal.any([ dynamicAccessAbortSignal, - renderContext.renderSignal, + outerWorkUnitStore.renderSignal, timeoutAbortController.signal, ]) : timeoutAbortController.signal @@ -452,32 +463,32 @@ async function generateCacheEntryImpl( clearTimeout(timer) - // When the prerender is aborted for any reason (including the timeout), and - // we're prerendering a static shell that is allowed to be empty, we return - // a hanging promise. This essentially makes the "use cache" function - // dynamic in the context of the fallback shell. When there's no suspense - // boundary above, the shell will be empty, which is allowed. - // TODO: We could consider doing this for any kind of fallback shell - // prerendering, including when allowEmptyStaticShell is false. - if (abortSignal.aborted && renderContext.allowEmptyStaticShell) { - if (outerWorkUnitStore?.type === 'prerender') { - outerWorkUnitStore.cacheSignal?.endRead() - } - - const hangingPromise = makeHangingPromise( - renderContext.renderSignal, - abortSignal.reason - ) - - return { type: 'prerender-dynamic', hangingPromise } - } - if (timeoutAbortController.signal.aborted) { + // When the timeout is reached we always error the stream. Even for + // fallback shell prerenders we don't want to return a hanging promise, + // which would allow the function to become a dynamic hole. Because that + // would mean that a non-empty shell could be generated which would be + // subject to revalidation, and we don't want to create long revalidation + // times. stream = new ReadableStream({ start(controller) { controller.error(timeoutError) }, }) + } else if (dynamicAccessAbortSignal?.aborted) { + // When the prerender is aborted because of dynamic access (e.g. reading + // fallback params), we return a hanging promise. This essentially makes + // the "use cache" function dynamic. + const hangingPromise = makeHangingPromise( + outerWorkUnitStore.renderSignal, + abortSignal.reason + ) + + if (outerWorkUnitStore?.type === 'prerender') { + outerWorkUnitStore.cacheSignal?.endRead() + } + + return { type: 'prerender-dynamic', hangingPromise } } else { stream = prelude } @@ -646,9 +657,19 @@ export function cache( ? createHangingInputAbortSignal(workUnitStore) : undefined + let isPageOrLayout = false + + // For page and layout components, the cache function is overwritten, + // which allows us to apply special handling for params and searchParams. + // For pages and layouts we're using the original params prop, and not the + // serialized one. While it's not generally true for "use cache" args, in + // the case of `params` the serialized and original object are essentially + // equivalent, so this is safe to do (including fallback params that are + // hanging promises). It allows us to avoid waiting for the timeout, when + // prerendering a fallback shell of a cached page or layout that awaits + // params. if (isPageComponent(args)) { - // For page components, the cache function is overwritten, which allows - // us to apply special handling for params and searchParams (see below). + isPageOrLayout = true const [{ params, searchParams }] = args // Overwrite the props to omit $$isPageComponent. @@ -661,13 +682,6 @@ export function cache( }: Omit) => originalFn.apply(null, [ { - // We're using the original params prop, and not the serialized - // one. While it's not generally true for "use cache" args, in - // the case of `params` the serialized and original object are - // essentially equivalent, so this is safe to do (including - // fallback params that are hanging promises). It allows us to - // avoid waiting for the timeout, when prerendering a fallback - // shell of a cached page that awaits params. params, searchParams: workStore.dynamicIOEnabled ? serializedSearchParams @@ -683,6 +697,20 @@ export function cache( }, ]), }[name] as (...args: unknown[]) => Promise + } else if (isLayoutComponent(args)) { + isPageOrLayout = true + + const [{ params, $$isLayoutComponent, ...slots }] = args + // Overwrite the props to omit $$isLayoutComponent. + args = [{ params, ...slots }] + + fn = { + [name]: async ({ + params: _serializedParams, + ...serializedSlots + }: Omit) => + originalFn.apply(null, [{ params, ...serializedSlots }]), + }[name] as (...args: unknown[]) => Promise } if (boundArgsLength > 0) { @@ -716,10 +744,38 @@ export function cache( ? [buildId, id, args, hmrRefreshHash] : [buildId, id, args] - const encodedCacheKeyParts: FormData | string = await encodeReply( - cacheKeyParts, - { temporaryReferences, signal: hangingInputAbortSignal } - ) + const encodeCacheKeyParts = () => + encodeReply(cacheKeyParts, { + temporaryReferences, + signal: hangingInputAbortSignal, + }) + + let encodedCacheKeyParts: FormData | string + + if (workUnitStore?.type === 'prerender' && !isPageOrLayout) { + // If the "use cache" function is not a page or a layout, we need to + // track dynamic access already when encoding the arguments. If params + // are passed explicitly into a "use cache" function (as opposed to + // receiving them automatically in a page or layout), we assume 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. + const dynamicAccessAbortController = new AbortController() + + encodedCacheKeyParts = await dynamicAccessAsyncStorage.run( + { abortController: dynamicAccessAbortController }, + encodeCacheKeyParts + ) + + if (dynamicAccessAbortController.signal.aborted) { + return makeHangingPromise( + workUnitStore.renderSignal, + dynamicAccessAbortController.signal.reason.message + ) + } + } else { + encodedCacheKeyParts = await encodeCacheKeyParts() + } const serializedCacheKey = typeof encodedCacheKeyParts === 'string' @@ -1057,6 +1113,23 @@ function isPageComponent( ) } +function isLayoutComponent( + args: any[] +): args is [UseCacheLayoutComponentProps, undefined] { + if (args.length !== 2) { + return false + } + + const [props, ref] = args + + return ( + ref === undefined && // server components receive an undefined ref arg + props !== null && + typeof props === 'object' && + (props as UseCacheLayoutComponentProps).$$isLayoutComponent + ) +} + function shouldForceRevalidate( workStore: WorkStore, workUnitStore: WorkUnitStore | undefined diff --git a/test/e2e/app-dir/empty-fallback-shells/empty-fallback-shells.test.ts b/test/e2e/app-dir/empty-fallback-shells/empty-fallback-shells.test.ts index 74d8ba7cd94c..64a21c789a79 100644 --- a/test/e2e/app-dir/empty-fallback-shells/empty-fallback-shells.test.ts +++ b/test/e2e/app-dir/empty-fallback-shells/empty-fallback-shells.test.ts @@ -3,7 +3,6 @@ import { nextTestSetup } from 'e2e-utils' describe('empty-fallback-shells', () => { const { next, isNextDev, isNextDeploy, isNextStart } = nextTestSetup({ files: __dirname, - timeout: 180_000, }) describe('without IO', () => { @@ -97,7 +96,7 @@ describe('empty-fallback-shells', () => { }) describe('and the params accessed in cached non-page function', () => { - it('resumes a postponed fallback shell', async () => { + it('does not resume a postponed fallback shell', async () => { const res = await next.fetch( '/with-cached-io/without-suspense/params-not-in-page/bar' ) diff --git a/test/e2e/app-dir/use-cache-hanging-inputs/app/fallback-params/[slug]/page.tsx b/test/e2e/app-dir/use-cache-hanging-inputs/app/fallback-params/[slug]/page.tsx deleted file mode 100644 index 1f77b26ac270..000000000000 --- a/test/e2e/app-dir/use-cache-hanging-inputs/app/fallback-params/[slug]/page.tsx +++ /dev/null @@ -1,14 +0,0 @@ -'use cache' - -export default async function Page({ - params, -}: { - params: Promise<{ slug: string }> -}) { - const { slug } = await params - - return

slug: {slug}

-} - -// If generateStaticParams would be used here to define at least one set of -// complete params, we would not yield a timeout error. diff --git a/test/e2e/app-dir/use-cache-hanging-inputs/use-cache-hanging-inputs.test.ts b/test/e2e/app-dir/use-cache-hanging-inputs/use-cache-hanging-inputs.test.ts index 196219534525..1a548b39cbb9 100644 --- a/test/e2e/app-dir/use-cache-hanging-inputs/use-cache-hanging-inputs.test.ts +++ b/test/e2e/app-dir/use-cache-hanging-inputs/use-cache-hanging-inputs.test.ts @@ -334,10 +334,6 @@ describe('use-cache-hanging-inputs', () => { createExpectedBuildErrorMessage('/bound-args') ) - expect(cliOutput).toInclude( - createExpectedBuildErrorMessage('/fallback-params/[slug]') - ) - expect(cliOutput).toInclude( createExpectedBuildErrorMessage('/search-params') ) diff --git a/test/lib/e2e-utils/index.ts b/test/lib/e2e-utils/index.ts index 0916f55beca7..c6fc20bb918c 100644 --- a/test/lib/e2e-utils/index.ts +++ b/test/lib/e2e-utils/index.ts @@ -230,7 +230,6 @@ export function nextTestSetup( options: Parameters[0] & { skipDeployment?: boolean dir?: string - timeout?: number } ): { isNextDev: boolean From 8407901a5fe54e2b0cd064b7732b6dab795e9df5 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Sun, 11 May 2025 19:49:23 +0200 Subject: [PATCH 39/57] Remove obsolete `UseCacheRenderContext` --- .../work-unit-async-storage.external.ts | 5 -- .../src/server/use-cache/render-context.ts | 56 ------------------- .../src/server/use-cache/use-cache-wrapper.ts | 3 - 3 files changed, 64 deletions(-) delete mode 100644 packages/next/src/server/use-cache/render-context.ts 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 baa3ecbaf4de..524594a3efe5 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 @@ -17,7 +17,6 @@ import type { Params } from '../request/params' import type { ImplicitTags } from '../lib/implicit-tags' import type { WorkStore } from './work-async-storage.external' import { NEXT_HMR_REFRESH_HASH_COOKIE } from '../../client/components/app-router-headers' -import type { UseCacheRenderContext } from '../use-cache/render-context' export type WorkUnitPhase = 'action' | 'render' | 'after' @@ -193,10 +192,6 @@ export interface UseCacheStore extends CommonCacheStore { readonly isHmrRefresh: boolean readonly serverComponentsHmrCache: ServerComponentsHmrCache | undefined readonly forceRevalidate: boolean - // The render context can only be undefined here because we currently don't - // forbid nesting `"use cache"` in `unstable_cache`, which we probably should. - // This breaks the render context chain. - readonly renderContext: UseCacheRenderContext | undefined } export interface UnstableCacheStore extends CommonCacheStore { diff --git a/packages/next/src/server/use-cache/render-context.ts b/packages/next/src/server/use-cache/render-context.ts deleted file mode 100644 index 03572e4ba939..000000000000 --- a/packages/next/src/server/use-cache/render-context.ts +++ /dev/null @@ -1,56 +0,0 @@ -import type { WorkUnitStore } from '../app-render/work-unit-async-storage.external' - -export interface UseCacheRenderContextPrerender { - readonly type: 'prerender' - readonly renderSignal: AbortSignal - readonly dynamicAccessAbortController: AbortController - readonly allowEmptyStaticShell: boolean -} - -export interface UseCacheRenderContextPrerenderPPR { - readonly type: 'prerender-ppr' -} - -export interface UseCacheRenderContextPrerenderLegacy { - readonly type: 'prerender-legacy' -} - -export interface UseCacheRenderContextRequest { - readonly type: 'request' -} - -export type UseCacheRenderContext = - | UseCacheRenderContextPrerender - | UseCacheRenderContextPrerenderPPR - | UseCacheRenderContextPrerenderLegacy - | UseCacheRenderContextRequest - -export function createUseCacheRenderContext( - workUnitStore: WorkUnitStore -): UseCacheRenderContext | undefined { - switch (workUnitStore.type) { - case 'prerender': - return { - type: 'prerender', - renderSignal: workUnitStore.renderSignal, - dynamicAccessAbortController: new AbortController(), - allowEmptyStaticShell: workUnitStore.allowEmptyStaticShell, - } - case 'prerender-ppr': - case 'prerender-legacy': - case 'request': - return { type: workUnitStore.type } - case 'cache': - if (workUnitStore.renderContext?.type === 'prerender') { - return { - ...workUnitStore.renderContext, - // Each cache scope gets its own abort controller. - dynamicAccessAbortController: new AbortController(), - } - } - - return workUnitStore.renderContext - default: - return undefined - } -} 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 d366298459f2..207c1b426fe0 100644 --- a/packages/next/src/server/use-cache/use-cache-wrapper.ts +++ b/packages/next/src/server/use-cache/use-cache-wrapper.ts @@ -52,7 +52,6 @@ import { import type { Params } from '../request/params' import React from 'react' import { createLazyResult, isResolvedLazyResult } from '../lib/lazy-result' -import { createUseCacheRenderContext } from './render-context' import { dynamicAccessAsyncStorage } from '../app-render/dynamic-access-async-storage.external' type CacheKeyParts = @@ -187,8 +186,6 @@ function generateCacheEntryWithCacheContext( draftMode: outerWorkUnitStore && getDraftModeProviderForCacheScope(workStore, outerWorkUnitStore), - renderContext: - outerWorkUnitStore && createUseCacheRenderContext(outerWorkUnitStore), } return workUnitAsyncStorage.run(cacheStore, () => From af47843d1dbdc11d251031074357452290ca2f4a Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Sun, 11 May 2025 20:12:55 +0200 Subject: [PATCH 40/57] Remove obsolete `allowEmptyStaticShell` from build output --- packages/next/src/build/index.ts | 11 ----------- packages/next/src/server/base-server.ts | 10 ---------- 2 files changed, 21 deletions(-) diff --git a/packages/next/src/build/index.ts b/packages/next/src/build/index.ts index d118c70c4fbc..f352c723fd8d 100644 --- a/packages/next/src/build/index.ts +++ b/packages/next/src/build/index.ts @@ -310,15 +310,6 @@ export interface DynamicPrerenderManifestRoute { */ fallbackSourceRoute: string | undefined - /** - * When true, the page was prerendered as a fallback shell, while allowing any - * dynamic accesses to result in an empty shell. This is the case when there - * are also routes prerendered with a more complete set of params. - * Prerendering those routes would catch any invalid dynamic accesses. This - * flag needs to be considered during revalidation. - */ - allowEmptyStaticShell: boolean - prefetchDataRoute: string | null | undefined prefetchDataRouteRegex: string | null | undefined routeRegex: string @@ -3156,7 +3147,6 @@ export default async function build( }).re.source ), allowHeader: ALLOWED_HEADERS, - allowEmptyStaticShell: !route.throwOnEmptyStaticShell, } } } @@ -3633,7 +3623,6 @@ export default async function build( prefetchDataRoute: undefined, prefetchDataRouteRegex: undefined, allowHeader: ALLOWED_HEADERS, - allowEmptyStaticShell: false, } }) diff --git a/packages/next/src/server/base-server.ts b/packages/next/src/server/base-server.ts index a9872be8cda7..7edf04c790e5 100644 --- a/packages/next/src/server/base-server.ts +++ b/packages/next/src/server/base-server.ts @@ -2536,15 +2536,6 @@ export default abstract class Server< // dynamic RSC request. const isRevalidate = isSSG && !postponed && !isDynamicRSCRequest - // When we're revalidating a fallback shell, we need to set - // `allowEmptyStaticShell` to the same value that was used when the shell - // was prerendered at build time. - const allowEmptyStaticShell = Boolean( - isRevalidate && - fallbackRouteParams && - prerenderManifest.dynamicRoutes[pathname]?.allowEmptyStaticShell - ) - const renderOpts: LoadedRenderOpts = { ...components, ...opts, @@ -2588,7 +2579,6 @@ export default abstract class Server< onAfterTaskError: undefined, // only available in dev setIsrStatus: (this as any).setIsrStatus, - allowEmptyStaticShell, } if (isDebugStaticShell || isDebugDynamicAccesses) { From 1c005a29306ce34b126e61267ada8612dc7b21a5 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Sun, 11 May 2025 20:18:58 +0200 Subject: [PATCH 41/57] Remove `allowEmptyStaticShell` from `PrerenderStoreModern` --- .../next/src/server/app-render/app-render.tsx | 19 ++++--------------- .../work-unit-async-storage.external.ts | 8 -------- .../server/route-modules/app-route/module.ts | 2 -- 3 files changed, 4 insertions(+), 25 deletions(-) diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index a1b3d699167d..9d8c2f12a562 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -666,11 +666,7 @@ async function warmupDevRender( workStore, } = ctx - const { - allowEmptyStaticShell = false, - dev, - onInstrumentationRequestError, - } = renderOpts + const { dev, onInstrumentationRequestError } = renderOpts if (!dev) { throw new InvariantError( @@ -712,7 +708,6 @@ async function warmupDevRender( controller: prerenderController, cacheSignal, dynamicTracking: null, - allowEmptyStaticShell, revalidate: INFINITE_CACHE, expire: INFINITE_CACHE, stale: INFINITE_CACHE, @@ -2248,10 +2243,12 @@ async function spawnDynamicValidationInDev( getDynamicParamFromSegment, implicitTags, nonce, - renderOpts: { allowEmptyStaticShell = false, botType }, + renderOpts, workStore, } = ctx + const { botType } = renderOpts + const rootParams = getRootParams( ComponentMod.tree, getDynamicParamFromSegment @@ -2283,7 +2280,6 @@ async function spawnDynamicValidationInDev( controller: initialServerPrerenderController, cacheSignal, dynamicTracking: null, - allowEmptyStaticShell, revalidate: INFINITE_CACHE, expire: INFINITE_CACHE, stale: INFINITE_CACHE, @@ -2302,7 +2298,6 @@ async function spawnDynamicValidationInDev( controller: initialClientController, cacheSignal, dynamicTracking: null, - allowEmptyStaticShell, revalidate: INFINITE_CACHE, expire: INFINITE_CACHE, stale: INFINITE_CACHE, @@ -2448,7 +2443,6 @@ async function spawnDynamicValidationInDev( // During the final prerender we don't need to track cache access so we omit the signal cacheSignal: null, dynamicTracking: serverDynamicTracking, - allowEmptyStaticShell, revalidate: INFINITE_CACHE, expire: INFINITE_CACHE, stale: INFINITE_CACHE, @@ -2471,7 +2465,6 @@ async function spawnDynamicValidationInDev( // During the final prerender we don't need to track cache access so we omit the signal cacheSignal: null, dynamicTracking: clientDynamicTracking, - allowEmptyStaticShell, revalidate: INFINITE_CACHE, expire: INFINITE_CACHE, stale: INFINITE_CACHE, @@ -2824,7 +2817,6 @@ async function prerenderToStream( controller: initialServerPrerenderController, cacheSignal, dynamicTracking: null, - allowEmptyStaticShell, revalidate: INFINITE_CACHE, expire: INFINITE_CACHE, stale: INFINITE_CACHE, @@ -2945,7 +2937,6 @@ async function prerenderToStream( controller: initialClientController, cacheSignal, dynamicTracking: null, - allowEmptyStaticShell, revalidate: INFINITE_CACHE, expire: INFINITE_CACHE, stale: INFINITE_CACHE, @@ -3031,7 +3022,6 @@ async function prerenderToStream( // During the final prerender we don't need to track cache access so we omit the signal cacheSignal: null, dynamicTracking: serverDynamicTracking, - allowEmptyStaticShell, revalidate: INFINITE_CACHE, expire: INFINITE_CACHE, stale: INFINITE_CACHE, @@ -3102,7 +3092,6 @@ async function prerenderToStream( // For HTML Generation we don't need to track cache reads (RSC only) cacheSignal: null, dynamicTracking: clientDynamicTracking, - allowEmptyStaticShell, revalidate: INFINITE_CACHE, expire: INFINITE_CACHE, stale: INFINITE_CACHE, 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 524594a3efe5..239bb196ce88 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 @@ -106,14 +106,6 @@ export interface PrerenderStoreModern extends CommonWorkUnitStore { readonly rootParams: Params - /** - * When true, the page is prerendered as a fallback shell, while allowing any - * dynamic accesses to result in an empty shell. This is the case when there - * are also routes prerendered with a more complete set of params. - * Prerendering those routes would catch any invalid dynamic accesses. - */ - readonly allowEmptyStaticShell: boolean - // Collected revalidate times and tags for this document during the prerender. revalidate: number // in seconds. 0 means dynamic. INFINITE_CACHE and higher means never revalidate. expire: number // server expiration time diff --git a/packages/next/src/server/route-modules/app-route/module.ts b/packages/next/src/server/route-modules/app-route/module.ts index f662e28510c9..d2ba2b88ce8a 100644 --- a/packages/next/src/server/route-modules/app-route/module.ts +++ b/packages/next/src/server/route-modules/app-route/module.ts @@ -390,7 +390,6 @@ export class AppRouteRouteModule extends RouteModule< // During prospective render we don't use a controller // because we need to let all caches fill. dynamicTracking, - allowEmptyStaticShell: false, revalidate: defaultRevalidate, expire: INFINITE_CACHE, stale: INFINITE_CACHE, @@ -479,7 +478,6 @@ export class AppRouteRouteModule extends RouteModule< controller: finalController, cacheSignal: null, dynamicTracking, - allowEmptyStaticShell: false, revalidate: defaultRevalidate, expire: INFINITE_CACHE, stale: INFINITE_CACHE, From 728d5885ac5c576b21723988e994a6f3824e6b1f Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Sun, 11 May 2025 20:21:12 +0200 Subject: [PATCH 42/57] Generalize error message --- packages/next/src/server/request/params.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/next/src/server/request/params.ts b/packages/next/src/server/request/params.ts index ff58d227b7d8..77cc1629aaf0 100644 --- a/packages/next/src/server/request/params.ts +++ b/packages/next/src/server/request/params.ts @@ -221,9 +221,7 @@ function makeAbortingExoticParams( if (dynamicAccessStore) { dynamicAccessStore.abortController.abort( - new Error( - `Accessed fallback \`params\` in \`"use cache"\` during prerendering.` - ) + new Error(`Accessed fallback \`params\` during prerendering.`) ) } // intentionally fallthrough From d1ae3cfbfd2b097044033510443a507e9bc16186 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Sun, 11 May 2025 20:26:01 +0200 Subject: [PATCH 43/57] Revert changes in `packages/next/src/server/base-server.ts` --- packages/next/src/server/base-server.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/next/src/server/base-server.ts b/packages/next/src/server/base-server.ts index 7edf04c790e5..d9cf2abc4cf6 100644 --- a/packages/next/src/server/base-server.ts +++ b/packages/next/src/server/base-server.ts @@ -2531,18 +2531,17 @@ export default abstract class Server< // When html bots request PPR page, perform the full dynamic rendering. const shouldWaitOnAllReady = isHtmlBot && isRoutePPREnabled - // This is a revalidation request if the request is for a static page, and - // it is not being resumed from a postponed render, and it is not a - // dynamic RSC request. - const isRevalidate = isSSG && !postponed && !isDynamicRSCRequest - const renderOpts: LoadedRenderOpts = { ...components, ...opts, ...(isAppPath ? { incrementalCache, - isRevalidate, + // This is a revalidation request if the request is for a static + // page and it is not being resumed from a postponed render and + // it is not a dynamic RSC request then it is a revalidation + // request. + isRevalidate: isSSG && !postponed && !isDynamicRSCRequest, serverActions: this.nextConfig.experimental.serverActions, } : {}), From cc42a27bef6e790e187aba5edb5213fd21e0d45a Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Sun, 11 May 2025 20:27:05 +0200 Subject: [PATCH 44/57] Remove `allowEmptyStaticShell` from test fixture --- .../server/lib/incremental-cache/shared-cache-controls.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/next/src/server/lib/incremental-cache/shared-cache-controls.test.ts b/packages/next/src/server/lib/incremental-cache/shared-cache-controls.test.ts index 0b964b791872..e6935165c61c 100644 --- a/packages/next/src/server/lib/incremental-cache/shared-cache-controls.test.ts +++ b/packages/next/src/server/lib/incremental-cache/shared-cache-controls.test.ts @@ -48,7 +48,6 @@ describe('SharedCacheControls', () => { experimentalPPR: undefined, renderingMode: RenderingMode.PARTIALLY_STATIC, allowHeader: [], - allowEmptyStaticShell: false, } satisfies DynamicPrerenderManifestRoute, }, } From 99e71548a2e485c81dbac2db54a22405f3d7260d Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Sun, 11 May 2025 20:28:20 +0200 Subject: [PATCH 45/57] Revert "Update prerender manifest snapshots" This reverts commit 67a6d435a964f7bd1a1bdfccbd8e572f169d6702. --- .../e2e/app-dir/app-static/app-static.test.ts | 13 -- test/e2e/prerender.test.ts | 15 --- test/integration/i18n-support/test/shared.js | 120 +++++++++--------- 3 files changed, 58 insertions(+), 90 deletions(-) diff --git a/test/e2e/app-dir/app-static/app-static.test.ts b/test/e2e/app-dir/app-static/app-static.test.ts index 92f4a7e1497b..227c081ebfa3 100644 --- a/test/e2e/app-dir/app-static/app-static.test.ts +++ b/test/e2e/app-dir/app-static/app-static.test.ts @@ -2358,7 +2358,6 @@ describe('app-dir static/dynamic handling', () => { expect(curManifest.dynamicRoutes).toMatchInlineSnapshot(` { "/articles/[slug]": { - "allowEmptyStaticShell": false, "allowHeader": [ "host", "x-matched-path", @@ -2384,7 +2383,6 @@ describe('app-dir static/dynamic handling', () => { "routeRegex": "^\\/articles\\/([^\\/]+?)(?:\\/)?$", }, "/blog/[author]": { - "allowEmptyStaticShell": false, "allowHeader": [ "host", "x-matched-path", @@ -2410,7 +2408,6 @@ describe('app-dir static/dynamic handling', () => { "routeRegex": "^\\/blog\\/([^\\/]+?)(?:\\/)?$", }, "/blog/[author]/[slug]": { - "allowEmptyStaticShell": false, "allowHeader": [ "host", "x-matched-path", @@ -2436,7 +2433,6 @@ describe('app-dir static/dynamic handling', () => { "routeRegex": "^\\/blog\\/([^\\/]+?)\\/([^\\/]+?)(?:\\/)?$", }, "/dynamic-error/[id]": { - "allowEmptyStaticShell": false, "allowHeader": [ "host", "x-matched-path", @@ -2462,7 +2458,6 @@ describe('app-dir static/dynamic handling', () => { "routeRegex": "^\\/dynamic\\-error\\/([^\\/]+?)(?:\\/)?$", }, "/force-static/[slug]": { - "allowEmptyStaticShell": false, "allowHeader": [ "host", "x-matched-path", @@ -2488,7 +2483,6 @@ describe('app-dir static/dynamic handling', () => { "routeRegex": "^\\/force\\-static\\/([^\\/]+?)(?:\\/)?$", }, "/gen-params-catch-all-unique/[...slug]": { - "allowEmptyStaticShell": false, "allowHeader": [ "host", "x-matched-path", @@ -2514,7 +2508,6 @@ describe('app-dir static/dynamic handling', () => { "routeRegex": "^\\/gen\\-params\\-catch\\-all\\-unique\\/(.+?)(?:\\/)?$", }, "/gen-params-dynamic-revalidate/[slug]": { - "allowEmptyStaticShell": false, "allowHeader": [ "host", "x-matched-path", @@ -2540,7 +2533,6 @@ describe('app-dir static/dynamic handling', () => { "routeRegex": "^\\/gen\\-params\\-dynamic\\-revalidate\\/([^\\/]+?)(?:\\/)?$", }, "/hooks/use-pathname/[slug]": { - "allowEmptyStaticShell": false, "allowHeader": [ "host", "x-matched-path", @@ -2566,7 +2558,6 @@ describe('app-dir static/dynamic handling', () => { "routeRegex": "^\\/hooks\\/use\\-pathname\\/([^\\/]+?)(?:\\/)?$", }, "/partial-gen-params-no-additional-lang/[lang]/[slug]": { - "allowEmptyStaticShell": false, "allowHeader": [ "host", "x-matched-path", @@ -2592,7 +2583,6 @@ describe('app-dir static/dynamic handling', () => { "routeRegex": "^\\/partial\\-gen\\-params\\-no\\-additional\\-lang\\/([^\\/]+?)\\/([^\\/]+?)(?:\\/)?$", }, "/partial-gen-params-no-additional-slug/[lang]/[slug]": { - "allowEmptyStaticShell": false, "allowHeader": [ "host", "x-matched-path", @@ -2618,7 +2608,6 @@ describe('app-dir static/dynamic handling', () => { "routeRegex": "^\\/partial\\-gen\\-params\\-no\\-additional\\-slug\\/([^\\/]+?)\\/([^\\/]+?)(?:\\/)?$", }, "/prerendered-not-found/[slug]": { - "allowEmptyStaticShell": false, "allowHeader": [ "host", "x-matched-path", @@ -2644,7 +2633,6 @@ describe('app-dir static/dynamic handling', () => { "routeRegex": "^\\/prerendered\\-not\\-found\\/([^\\/]+?)(?:\\/)?$", }, "/ssg-draft-mode/[[...route]]": { - "allowEmptyStaticShell": false, "allowHeader": [ "host", "x-matched-path", @@ -2670,7 +2658,6 @@ describe('app-dir static/dynamic handling', () => { "routeRegex": "^\\/ssg\\-draft\\-mode(?:\\/(.+?))?(?:\\/)?$", }, "/static-to-dynamic-error-forced/[id]": { - "allowEmptyStaticShell": false, "allowHeader": [ "host", "x-matched-path", diff --git a/test/e2e/prerender.test.ts b/test/e2e/prerender.test.ts index ac3f7ec9ffad..9c9bc8580b88 100644 --- a/test/e2e/prerender.test.ts +++ b/test/e2e/prerender.test.ts @@ -1666,7 +1666,6 @@ describe('Prerender', () => { fallback: '/api-docs/[...slug].html', routeRegex: normalizeRegEx(`^\\/api\\-docs\\/(.+?)(?:\\/)?$`), allowHeader, - allowEmptyStaticShell: false, }, '/blocking-fallback-once/[slug]': { dataRoute: `/_next/data/${next.buildId}/blocking-fallback-once/[slug].json`, @@ -1678,7 +1677,6 @@ describe('Prerender', () => { '^\\/blocking\\-fallback\\-once\\/([^\\/]+?)(?:\\/)?$' ), allowHeader, - allowEmptyStaticShell: false, }, '/blocking-fallback-some/[slug]': { dataRoute: `/_next/data/${next.buildId}/blocking-fallback-some/[slug].json`, @@ -1690,7 +1688,6 @@ describe('Prerender', () => { '^\\/blocking\\-fallback\\-some\\/([^\\/]+?)(?:\\/)?$' ), allowHeader, - allowEmptyStaticShell: false, }, '/blocking-fallback/[slug]': { dataRoute: `/_next/data/${next.buildId}/blocking-fallback/[slug].json`, @@ -1702,7 +1699,6 @@ describe('Prerender', () => { '^\\/blocking\\-fallback\\/([^\\/]+?)(?:\\/)?$' ), allowHeader, - allowEmptyStaticShell: false, }, '/blog/[post]': { fallback: '/blog/[post].html', @@ -1712,7 +1708,6 @@ describe('Prerender', () => { ), routeRegex: normalizeRegEx('^\\/blog\\/([^\\/]+?)(?:\\/)?$'), allowHeader, - allowEmptyStaticShell: false, }, '/blog/[post]/[comment]': { fallback: '/blog/[post]/[comment].html', @@ -1724,7 +1719,6 @@ describe('Prerender', () => { '^\\/blog\\/([^\\/]+?)\\/([^\\/]+?)(?:\\/)?$' ), allowHeader, - allowEmptyStaticShell: false, }, '/dynamic/[slug]': { dataRoute: `/_next/data/${next.buildId}/dynamic/[slug].json`, @@ -1734,7 +1728,6 @@ describe('Prerender', () => { fallback: false, routeRegex: normalizeRegEx(`^\\/dynamic\\/([^\\/]+?)(?:\\/)?$`), allowHeader, - allowEmptyStaticShell: false, }, '/fallback-only/[slug]': { dataRoute: `/_next/data/${next.buildId}/fallback-only/[slug].json`, @@ -1746,7 +1739,6 @@ describe('Prerender', () => { '^\\/fallback\\-only\\/([^\\/]+?)(?:\\/)?$' ), allowHeader, - allowEmptyStaticShell: false, }, '/lang/[lang]/about': { dataRoute: `/_next/data/${next.buildId}/lang/[lang]/about.json`, @@ -1758,7 +1750,6 @@ describe('Prerender', () => { '^\\/lang\\/([^\\/]+?)\\/about(?:\\/)?$' ), allowHeader, - allowEmptyStaticShell: false, }, '/non-json-blocking/[p]': { dataRoute: `/_next/data/${next.buildId}/non-json-blocking/[p].json`, @@ -1770,7 +1761,6 @@ describe('Prerender', () => { '^\\/non\\-json\\-blocking\\/([^\\/]+?)(?:\\/)?$' ), allowHeader, - allowEmptyStaticShell: false, }, '/non-json/[p]': { dataRoute: `/_next/data/${next.buildId}/non-json/[p].json`, @@ -1782,7 +1772,6 @@ describe('Prerender', () => { '^\\/non\\-json\\/([^\\/]+?)(?:\\/)?$' ), allowHeader, - allowEmptyStaticShell: false, }, '/user/[user]/profile': { fallback: '/user/[user]/profile.html', @@ -1794,7 +1783,6 @@ describe('Prerender', () => { `^\\/user\\/([^\\/]+?)\\/profile(?:\\/)?$` ), allowHeader, - allowEmptyStaticShell: false, }, '/catchall/[...slug]': { @@ -1805,7 +1793,6 @@ describe('Prerender', () => { `^\\/_next\\/data\\/${escapedBuildId}\\/catchall\\/(.+?)\\.json$` ), allowHeader, - allowEmptyStaticShell: false, }, '/catchall-optional/[[...slug]]': { dataRoute: `/_next/data/${next.buildId}/catchall-optional/[[...slug]].json`, @@ -1817,7 +1804,6 @@ describe('Prerender', () => { '^\\/catchall\\-optional(?:\\/(.+?))?(?:\\/)?$' ), allowHeader, - allowEmptyStaticShell: false, }, '/catchall-explicit/[...slug]': { dataRoute: `/_next/data/${next.buildId}/catchall-explicit/[...slug].json`, @@ -1829,7 +1815,6 @@ describe('Prerender', () => { '^\\/catchall\\-explicit\\/(.+?)(?:\\/)?$' ), allowHeader, - allowEmptyStaticShell: false, }, }) }) diff --git a/test/integration/i18n-support/test/shared.js b/test/integration/i18n-support/test/shared.js index 716f74828dbe..d846e35b4ce3 100644 --- a/test/integration/i18n-support/test/shared.js +++ b/test/integration/i18n-support/test/shared.js @@ -1768,68 +1768,64 @@ export function runTests(ctx) { 'BUILD_ID' ) ).toMatchInlineSnapshot(` - "{ - "/gsp/fallback/[slug]": { - "routeRegex": "^\\/gsp\\/fallback\\/([^\\/]+?)(?:\\/)?$", - "dataRoute": "/_next/data/BUILD_ID/gsp/fallback/[slug].json", - "fallback": "/gsp/fallback/[slug].html", - "dataRouteRegex": "^\\/_next\\/data\\/BUILD_ID\\/gsp\\/fallback\\/([^\\/]+?)\\.json$", - "allowHeader": [ - "host", - "x-matched-path", - "x-prerender-revalidate", - "x-prerender-revalidate-if-generated", - "x-next-revalidated-tags", - "x-next-revalidate-tag-token" - ], - "allowEmptyStaticShell": false - }, - "/gsp/no-fallback/[slug]": { - "routeRegex": "^\\/gsp\\/no\\-fallback\\/([^\\/]+?)(?:\\/)?$", - "dataRoute": "/_next/data/BUILD_ID/gsp/no-fallback/[slug].json", - "fallback": false, - "dataRouteRegex": "^\\/_next\\/data\\/BUILD_ID\\/gsp\\/no\\-fallback\\/([^\\/]+?)\\.json$", - "allowHeader": [ - "host", - "x-matched-path", - "x-prerender-revalidate", - "x-prerender-revalidate-if-generated", - "x-next-revalidated-tags", - "x-next-revalidate-tag-token" - ], - "allowEmptyStaticShell": false - }, - "/not-found/blocking-fallback/[slug]": { - "routeRegex": "^\\/not\\-found\\/blocking\\-fallback\\/([^\\/]+?)(?:\\/)?$", - "dataRoute": "/_next/data/BUILD_ID/not-found/blocking-fallback/[slug].json", - "fallback": null, - "dataRouteRegex": "^\\/_next\\/data\\/BUILD_ID\\/not\\-found\\/blocking\\-fallback\\/([^\\/]+?)\\.json$", - "allowHeader": [ - "host", - "x-matched-path", - "x-prerender-revalidate", - "x-prerender-revalidate-if-generated", - "x-next-revalidated-tags", - "x-next-revalidate-tag-token" - ], - "allowEmptyStaticShell": false - }, - "/not-found/fallback/[slug]": { - "routeRegex": "^\\/not\\-found\\/fallback\\/([^\\/]+?)(?:\\/)?$", - "dataRoute": "/_next/data/BUILD_ID/not-found/fallback/[slug].json", - "fallback": "/not-found/fallback/[slug].html", - "dataRouteRegex": "^\\/_next\\/data\\/BUILD_ID\\/not\\-found\\/fallback\\/([^\\/]+?)\\.json$", - "allowHeader": [ - "host", - "x-matched-path", - "x-prerender-revalidate", - "x-prerender-revalidate-if-generated", - "x-next-revalidated-tags", - "x-next-revalidate-tag-token" - ], - "allowEmptyStaticShell": false - } - }" + "{ + "/gsp/fallback/[slug]": { + "routeRegex": "^\\/gsp\\/fallback\\/([^\\/]+?)(?:\\/)?$", + "dataRoute": "/_next/data/BUILD_ID/gsp/fallback/[slug].json", + "fallback": "/gsp/fallback/[slug].html", + "dataRouteRegex": "^\\/_next\\/data\\/BUILD_ID\\/gsp\\/fallback\\/([^\\/]+?)\\.json$", + "allowHeader": [ + "host", + "x-matched-path", + "x-prerender-revalidate", + "x-prerender-revalidate-if-generated", + "x-next-revalidated-tags", + "x-next-revalidate-tag-token" + ] + }, + "/gsp/no-fallback/[slug]": { + "routeRegex": "^\\/gsp\\/no\\-fallback\\/([^\\/]+?)(?:\\/)?$", + "dataRoute": "/_next/data/BUILD_ID/gsp/no-fallback/[slug].json", + "fallback": false, + "dataRouteRegex": "^\\/_next\\/data\\/BUILD_ID\\/gsp\\/no\\-fallback\\/([^\\/]+?)\\.json$", + "allowHeader": [ + "host", + "x-matched-path", + "x-prerender-revalidate", + "x-prerender-revalidate-if-generated", + "x-next-revalidated-tags", + "x-next-revalidate-tag-token" + ] + }, + "/not-found/blocking-fallback/[slug]": { + "routeRegex": "^\\/not\\-found\\/blocking\\-fallback\\/([^\\/]+?)(?:\\/)?$", + "dataRoute": "/_next/data/BUILD_ID/not-found/blocking-fallback/[slug].json", + "fallback": null, + "dataRouteRegex": "^\\/_next\\/data\\/BUILD_ID\\/not\\-found\\/blocking\\-fallback\\/([^\\/]+?)\\.json$", + "allowHeader": [ + "host", + "x-matched-path", + "x-prerender-revalidate", + "x-prerender-revalidate-if-generated", + "x-next-revalidated-tags", + "x-next-revalidate-tag-token" + ] + }, + "/not-found/fallback/[slug]": { + "routeRegex": "^\\/not\\-found\\/fallback\\/([^\\/]+?)(?:\\/)?$", + "dataRoute": "/_next/data/BUILD_ID/not-found/fallback/[slug].json", + "fallback": "/not-found/fallback/[slug].html", + "dataRouteRegex": "^\\/_next\\/data\\/BUILD_ID\\/not\\-found\\/fallback\\/([^\\/]+?)\\.json$", + "allowHeader": [ + "host", + "x-matched-path", + "x-prerender-revalidate", + "x-prerender-revalidate-if-generated", + "x-next-revalidated-tags", + "x-next-revalidate-tag-token" + ] + } + }" `) }) } From 6723ee13994ab2736a2f238cd714e3aee330ff15 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Sun, 11 May 2025 20:30:46 +0200 Subject: [PATCH 46/57] Remove `options.timeout` from `test/lib/e2e-utils/index.ts` --- test/lib/e2e-utils/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/lib/e2e-utils/index.ts b/test/lib/e2e-utils/index.ts index c6fc20bb918c..6df759066b28 100644 --- a/test/lib/e2e-utils/index.ts +++ b/test/lib/e2e-utils/index.ts @@ -255,7 +255,7 @@ export function nextTestSetup( if (!skipped) { beforeAll(async () => { next = await createNext(options) - }, options.timeout) + }) afterAll(async () => { // Gracefully destroy the instance if `createNext` success. // If next instance is not available, it's likely beforeAll hook failed and unnecessarily throws another error From ba9f760a712cc7bf980a913edaccc6f3d9e53992 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Sun, 11 May 2025 20:32:06 +0200 Subject: [PATCH 47/57] Remove unused cache kind argument --- packages/next/src/server/use-cache/use-cache-wrapper.ts | 9 --------- 1 file changed, 9 deletions(-) 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 207c1b426fe0..05da09bd29e6 100644 --- a/packages/next/src/server/use-cache/use-cache-wrapper.ts +++ b/packages/next/src/server/use-cache/use-cache-wrapper.ts @@ -82,7 +82,6 @@ const debug = process.env.NEXT_PRIVATE_DEBUG_CACHE function generateCacheEntry( workStore: WorkStore, outerWorkUnitStore: WorkUnitStore | undefined, - kind: string, clientReferenceManifest: DeepReadonly, encodedArguments: FormData | string, fn: (...args: unknown[]) => Promise, @@ -97,7 +96,6 @@ function generateCacheEntry( generateCacheEntryWithRestoredWorkStore, workStore, outerWorkUnitStore, - kind, clientReferenceManifest, encodedArguments, fn, @@ -108,7 +106,6 @@ function generateCacheEntry( function generateCacheEntryWithRestoredWorkStore( workStore: WorkStore, outerWorkUnitStore: WorkUnitStore | undefined, - kind: string, clientReferenceManifest: DeepReadonly, encodedArguments: FormData | string, fn: (...args: unknown[]) => Promise, @@ -126,7 +123,6 @@ function generateCacheEntryWithRestoredWorkStore( generateCacheEntryWithCacheContext, workStore, outerWorkUnitStore, - kind, clientReferenceManifest, encodedArguments, fn, @@ -137,7 +133,6 @@ function generateCacheEntryWithRestoredWorkStore( function generateCacheEntryWithCacheContext( workStore: WorkStore, outerWorkUnitStore: WorkUnitStore | undefined, - kind: string, clientReferenceManifest: DeepReadonly, encodedArguments: FormData | string, fn: (...args: unknown[]) => Promise, @@ -194,7 +189,6 @@ function generateCacheEntryWithCacheContext( generateCacheEntryImpl, workStore, outerWorkUnitStore, - kind, cacheStore, clientReferenceManifest, encodedArguments, @@ -339,7 +333,6 @@ type GenerateCacheEntryResult = async function generateCacheEntryImpl( workStore: WorkStore, outerWorkUnitStore: WorkUnitStore | undefined, - _kind: string, innerCacheStore: UseCacheStore, clientReferenceManifest: DeepReadonly, encodedArguments: FormData | string, @@ -947,7 +940,6 @@ export function cache( const result = await generateCacheEntry( workStore, workUnitStore, - kind, clientReferenceManifest, encodedCacheKeyParts, fn, @@ -1023,7 +1015,6 @@ export function cache( // TODO: We may need to pass in a work unit store that includes // the cookies though. undefined, - kind, clientReferenceManifest, encodedCacheKeyParts, fn, From 9e5eb781b5efaf3c1fce09a4ed81314a2b6c9141 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Sun, 11 May 2025 20:33:38 +0200 Subject: [PATCH 48/57] Make properties in `GenerateCacheEntryResult` readonly --- .../next/src/server/use-cache/use-cache-wrapper.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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 05da09bd29e6..d55122f71ce9 100644 --- a/packages/next/src/server/use-cache/use-cache-wrapper.ts +++ b/packages/next/src/server/use-cache/use-cache-wrapper.ts @@ -321,13 +321,13 @@ async function collectResult( type GenerateCacheEntryResult = | { - type: 'cached' - stream: ReadableStream - pendingCacheEntry: Promise + readonly type: 'cached' + readonly stream: ReadableStream + readonly pendingCacheEntry: Promise } | { - type: 'prerender-dynamic' - hangingPromise: Promise + readonly type: 'prerender-dynamic' + readonly hangingPromise: Promise } async function generateCacheEntryImpl( From 23fc14d80ef58fc99074fed2031f161b963a118c Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Sun, 11 May 2025 20:35:08 +0200 Subject: [PATCH 49/57] Move `dynamicAccessAbortSignal` declaration closer to usage --- packages/next/src/server/use-cache/use-cache-wrapper.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 d55122f71ce9..c9bc06a04620 100644 --- a/packages/next/src/server/use-cache/use-cache-wrapper.ts +++ b/packages/next/src/server/use-cache/use-cache-wrapper.ts @@ -413,9 +413,6 @@ async function generateCacheEntryImpl( let stream: ReadableStream if (outerWorkUnitStore?.type === 'prerender') { - const dynamicAccessAbortSignal = - dynamicAccessAsyncStorage.getStore()?.abortController.signal - const timeoutAbortController = new AbortController() // If we're prerendering, we give you 50 seconds to fill a cache entry. @@ -426,6 +423,9 @@ async function generateCacheEntryImpl( timeoutAbortController.abort(timeoutError) }, 50000) + const dynamicAccessAbortSignal = + dynamicAccessAsyncStorage.getStore()?.abortController.signal + const abortSignal = dynamicAccessAbortSignal ? AbortSignal.any([ dynamicAccessAbortSignal, From 23acd9be2a80f300439d5d64fe5ee7dd48e8e157 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Sun, 11 May 2025 20:39:34 +0200 Subject: [PATCH 50/57] Fix minor issues in `"use cache"` wrapper --- packages/next/src/server/use-cache/use-cache-wrapper.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) 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 c9bc06a04620..5a8dbe4a1537 100644 --- a/packages/next/src/server/use-cache/use-cache-wrapper.ts +++ b/packages/next/src/server/use-cache/use-cache-wrapper.ts @@ -466,7 +466,7 @@ async function generateCacheEntryImpl( }, }) } else if (dynamicAccessAbortSignal?.aborted) { - // When the prerender is aborted because of dynamic access (e.g. reading + // If the prerender is aborted because of dynamic access (e.g. reading // fallback params), we return a hanging promise. This essentially makes // the "use cache" function dynamic. const hangingPromise = makeHangingPromise( @@ -1012,8 +1012,6 @@ export function cache( const result = await generateCacheEntry( workStore, // This is not running within the context of this unit. - // TODO: We may need to pass in a work unit store that includes - // the cookies though. undefined, clientReferenceManifest, encodedCacheKeyParts, @@ -1041,9 +1039,7 @@ export function cache( savedCacheEntry ) - if (!workStore.pendingRevalidateWrites) { - workStore.pendingRevalidateWrites = [] - } + workStore.pendingRevalidateWrites ??= [] workStore.pendingRevalidateWrites.push(promise) await ignoredStream.cancel() From 5e340a42273ab884a0facb6a9af61f40b4e41103 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Sat, 17 May 2025 16:13:23 +0200 Subject: [PATCH 51/57] Add changeset --- .changeset/tricky-planes-worry.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/tricky-planes-worry.md diff --git a/.changeset/tricky-planes-worry.md b/.changeset/tricky-planes-worry.md new file mode 100644 index 000000000000..b8d38dfa9237 --- /dev/null +++ b/.changeset/tricky-planes-worry.md @@ -0,0 +1,5 @@ +--- +"next": patch +--- + +[dynamicIO] Avoid timeout errors with dynamic params in `"use cache"` From 9c19231a5ff779fb39c70b5165f9c3f58f40442a Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Mon, 19 May 2025 10:57:55 +0200 Subject: [PATCH 52/57] Support instrumented `params.then/catch/finally` --- .../src/server/dynamic-rendering-utils.ts | 2 +- packages/next/src/server/request/params.ts | 39 ++++++++++++------- .../params-then-in-page/[slug]/page.jsx | 15 +++++++ .../empty-fallback-shells.test.ts | 22 ++++++++++- 4 files changed, 63 insertions(+), 15 deletions(-) create mode 100644 test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/without-suspense/params-then-in-page/[slug]/page.jsx diff --git a/packages/next/src/server/dynamic-rendering-utils.ts b/packages/next/src/server/dynamic-rendering-utils.ts index e21820c5c946..773a3eca0f8c 100644 --- a/packages/next/src/server/dynamic-rendering-utils.ts +++ b/packages/next/src/server/dynamic-rendering-utils.ts @@ -70,7 +70,7 @@ export function makeHangingPromise( return new Proxy(hangingPromise, { ...handler, get: function get(target, prop, receiver) { - if (prop === 'then' || prop === 'status') { + if (prop === 'then' || prop === 'catch' || prop === 'finally') { // TODO: Maybe annotate dynamic access here. } diff --git a/packages/next/src/server/request/params.ts b/packages/next/src/server/request/params.ts index 77cc1629aaf0..0518eebffa93 100644 --- a/packages/next/src/server/request/params.ts +++ b/packages/next/src/server/request/params.ts @@ -214,20 +214,33 @@ function makeAbortingExoticParams( '`params`', { get: function get(target, prop, receiver) { - switch (prop) { - case 'then': - case 'status': - const dynamicAccessStore = dynamicAccessAsyncStorage.getStore() - - if (dynamicAccessStore) { - dynamicAccessStore.abortController.abort( - new Error(`Accessed fallback \`params\` during prerendering.`) - ) - } - // intentionally fallthrough - default: - return ReflectAdapter.get(target, prop, receiver) + if (prop === 'then' || prop === 'catch' || prop === 'finally') { + const originalMethod = ReflectAdapter.get(target, prop, receiver) + + return { + [prop]: (...args: unknown[]) => { + const store = dynamicAccessAsyncStorage.getStore() + + if (store) + store.abortController.abort( + new Error(`Accessed fallback \`params\` during prerendering.`) + ) + + // Make sure the original method of the proxied promise is called. + // We don't use the returned new promise though. This is mostly + // needed to ensure the hanging promise rejection is triggered + // when the prerender signal is aborted. + originalMethod.apply(target, args) + + // Instead, we return the instrumented proxied promise again, to + // ensure that it's also used when params.then(), params.catch(), + // or params.finally() is passed to another function. + return promise + }, + }[prop] } + + return ReflectAdapter.get(target, prop, receiver) }, } ) diff --git a/test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/without-suspense/params-then-in-page/[slug]/page.jsx b/test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/without-suspense/params-then-in-page/[slug]/page.jsx new file mode 100644 index 000000000000..4e9542fcc603 --- /dev/null +++ b/test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/without-suspense/params-then-in-page/[slug]/page.jsx @@ -0,0 +1,15 @@ +import { CachedLastModified } from '../../../last-modified' + +export default async function Page({ params }) { + return ( + <> + p)} /> + {})} /> + {})} /> + + ) +} + +export async function generateStaticParams() { + return [{ slug: 'foo' }] +} diff --git a/test/e2e/app-dir/empty-fallback-shells/empty-fallback-shells.test.ts b/test/e2e/app-dir/empty-fallback-shells/empty-fallback-shells.test.ts index 64a21c789a79..f8324c55f780 100644 --- a/test/e2e/app-dir/empty-fallback-shells/empty-fallback-shells.test.ts +++ b/test/e2e/app-dir/empty-fallback-shells/empty-fallback-shells.test.ts @@ -95,7 +95,7 @@ describe('empty-fallback-shells', () => { }) }) - describe('and the params accessed in cached non-page function', () => { + describe('and the params accessed in a cached non-page function', () => { it('does not resume a postponed fallback shell', async () => { const res = await next.fetch( '/with-cached-io/without-suspense/params-not-in-page/bar' @@ -114,6 +114,26 @@ describe('empty-fallback-shells', () => { } }) }) + + describe('and params.then/catch/finally passed to a cached function', () => { + it('does not resume a postponed fallback shell', async () => { + const res = await next.fetch( + '/with-cached-io/without-suspense/params-then-in-page/bar' + ) + + const html = await res.text() + expect(html).toIncludeRepeated('data-testid="page-bar"', 3) + expect(html).toContain('layout-runtime') + + if (isNextDeploy) { + expect(res.headers.get('x-matched-path')).toBe( + '/with-cached-io/without-suspense/params-then-in-page/[slug]' + ) + } else { + expect(res.headers.get('x-nextjs-postponed')).not.toBe('1') + } + }) + }) }) }) }) From 95d7c23616204ab602813b836908d8249529ba17 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Tue, 20 May 2025 11:03:12 +0200 Subject: [PATCH 53/57] Avoid `HANGING_PROMISE_REJECTION` being logged --- packages/next/src/server/request/params.ts | 60 +++++++++---------- .../params-then-in-page/[slug]/page.jsx | 3 +- .../empty-fallback-shells.test.ts | 8 ++- 3 files changed, 38 insertions(+), 33 deletions(-) diff --git a/packages/next/src/server/request/params.ts b/packages/next/src/server/request/params.ts index 0518eebffa93..c770e87f7671 100644 --- a/packages/next/src/server/request/params.ts +++ b/packages/next/src/server/request/params.ts @@ -199,6 +199,33 @@ function createRenderParams( interface CacheLifetime {} const CachedParams = new WeakMap>() +const fallbackParamsProxyHandler: ProxyHandler> = { + get: function get(target, prop, receiver) { + if (prop === 'then' || prop === 'catch' || prop === 'finally') { + const originalMethod = ReflectAdapter.get(target, prop, receiver) + + return { + [prop]: (...args: unknown[]) => { + const store = dynamicAccessAsyncStorage.getStore() + + if (store) { + store.abortController.abort( + new Error(`Accessed fallback \`params\` during prerendering.`) + ) + } + + return new Proxy( + originalMethod.apply(target, args), + fallbackParamsProxyHandler + ) + }, + }[prop] + } + + return ReflectAdapter.get(target, prop, receiver) + }, +} + function makeAbortingExoticParams( underlyingParams: Params, route: string, @@ -212,38 +239,9 @@ function makeAbortingExoticParams( const promise = makeHangingPromise( prerenderStore.renderSignal, '`params`', - { - get: function get(target, prop, receiver) { - if (prop === 'then' || prop === 'catch' || prop === 'finally') { - const originalMethod = ReflectAdapter.get(target, prop, receiver) - - return { - [prop]: (...args: unknown[]) => { - const store = dynamicAccessAsyncStorage.getStore() - - if (store) - store.abortController.abort( - new Error(`Accessed fallback \`params\` during prerendering.`) - ) - - // Make sure the original method of the proxied promise is called. - // We don't use the returned new promise though. This is mostly - // needed to ensure the hanging promise rejection is triggered - // when the prerender signal is aborted. - originalMethod.apply(target, args) - - // Instead, we return the instrumented proxied promise again, to - // ensure that it's also used when params.then(), params.catch(), - // or params.finally() is passed to another function. - return promise - }, - }[prop] - } - - return ReflectAdapter.get(target, prop, receiver) - }, - } + fallbackParamsProxyHandler ) + CachedParams.set(underlyingParams, promise) Object.keys(underlyingParams).forEach((prop) => { diff --git a/test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/without-suspense/params-then-in-page/[slug]/page.jsx b/test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/without-suspense/params-then-in-page/[slug]/page.jsx index 4e9542fcc603..6c479b8e4fa3 100644 --- a/test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/without-suspense/params-then-in-page/[slug]/page.jsx +++ b/test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/without-suspense/params-then-in-page/[slug]/page.jsx @@ -3,9 +3,10 @@ import { CachedLastModified } from '../../../last-modified' export default async function Page({ params }) { return ( <> - p)} /> + ({ slug: p.slug }))} /> {})} /> {})} /> + ) } diff --git a/test/e2e/app-dir/empty-fallback-shells/empty-fallback-shells.test.ts b/test/e2e/app-dir/empty-fallback-shells/empty-fallback-shells.test.ts index f8324c55f780..406bffec570b 100644 --- a/test/e2e/app-dir/empty-fallback-shells/empty-fallback-shells.test.ts +++ b/test/e2e/app-dir/empty-fallback-shells/empty-fallback-shells.test.ts @@ -122,7 +122,7 @@ describe('empty-fallback-shells', () => { ) const html = await res.text() - expect(html).toIncludeRepeated('data-testid="page-bar"', 3) + expect(html).toIncludeRepeated('data-testid="page-bar"', 4) expect(html).toContain('layout-runtime') if (isNextDeploy) { @@ -136,4 +136,10 @@ describe('empty-fallback-shells', () => { }) }) }) + + if (isNextStart) { + it('should not log a HANGING_PROMISE_REJECTION error', async () => { + expect(next.cliOutput).not.toContain('HANGING_PROMISE_REJECTION') + }) + } }) From 2b8009848ce3d08e673e511f528026720cece195 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Tue, 20 May 2025 15:23:16 +0200 Subject: [PATCH 54/57] Revert changes in `headers`, `cookies`, and `searchParams` --- .../src/server/dynamic-rendering-utils.ts | 21 +- packages/next/src/server/request/cookies.ts | 199 +++++++++++++----- packages/next/src/server/request/headers.ts | 191 ++++++++++++----- packages/next/src/server/request/params.ts | 5 +- .../next/src/server/request/search-params.ts | 131 ++++++------ 5 files changed, 350 insertions(+), 197 deletions(-) diff --git a/packages/next/src/server/dynamic-rendering-utils.ts b/packages/next/src/server/dynamic-rendering-utils.ts index 773a3eca0f8c..5f76fa1a51df 100644 --- a/packages/next/src/server/dynamic-rendering-utils.ts +++ b/packages/next/src/server/dynamic-rendering-utils.ts @@ -1,5 +1,3 @@ -import { ReflectAdapter } from './web/spec-extension/adapters/reflect' - export function isHangingPromiseRejectionError( err: unknown ): err is HangingPromiseRejectionError { @@ -34,8 +32,7 @@ const abortListenersBySignal = new WeakMap() */ export function makeHangingPromise( signal: AbortSignal, - expression: string, - handler?: ProxyHandler> + expression: string ): Promise { if (signal.aborted) { return Promise.reject(new HangingPromiseRejectionError(expression)) @@ -66,21 +63,7 @@ export function makeHangingPromise( // we attach a noop catch handler here to suppress this warning. If you actually await somewhere or construct // your own promise out of it you'll need to ensure you handle the error when it rejects. hangingPromise.catch(ignoreReject) - - return new Proxy(hangingPromise, { - ...handler, - get: function get(target, prop, receiver) { - if (prop === 'then' || prop === 'catch' || prop === 'finally') { - // TODO: Maybe annotate dynamic access here. - } - - if (handler?.get) { - return handler.get(target, prop, receiver) - } - - return ReflectAdapter.get(target, prop, receiver) - }, - }) + return hangingPromise } } diff --git a/packages/next/src/server/request/cookies.ts b/packages/next/src/server/request/cookies.ts index cc148aa6945c..524703b9f4d3 100644 --- a/packages/next/src/server/request/cookies.ts +++ b/packages/next/src/server/request/cookies.ts @@ -23,7 +23,6 @@ import { makeHangingPromise } from '../dynamic-rendering-utils' import { createDedupedByCallsiteServerErrorLoggerDev } from '../create-deduped-by-callsite-server-error-logger' import { scheduleImmediate } from '../../lib/scheduler' import { isRequestAPICallableInsideAfter } from './utils' -import { ReflectAdapter } from '../web/spec-extension/adapters/reflect' /** * In this version of Next.js `cookies()` returns a Promise however you can still reference the properties of the underlying cookies object @@ -167,65 +166,155 @@ function makeDynamicallyTrackedExoticCookies( return cachedPromise } - const abortAndThrow = (property: string) => { - const expression = `\`cookies()${property}\`` - const error = createCookiesAccessError(route, expression) - abortAndThrowOnSynchronousRequestDataAccess( - route, - expression, - error, - prerenderStore - ) - } - const promise = makeHangingPromise( prerenderStore.renderSignal, - '`cookies()`', - { - get: function get(target, prop, receiver) { - switch (prop) { - case Symbol.iterator: - return function () { - abortAndThrow('[Symbol.iterator]') - } - case 'size': - abortAndThrow('.size') - break - case 'get': - case 'getAll': - case 'has': - return function () { - abortAndThrow( - arguments.length === 0 - ? `.${prop}()` - : `.${prop}(${describeNameArg(arguments[0])})` - ) - } - case 'set': - case 'delete': - return function () { - abortAndThrow( - arguments.length === 0 - ? `.${prop}()` - : arguments[0] - ? `.${prop}(${describeNameArg(arguments[0])}, ...)` - : `.${prop}(...)` - ) - } - case 'clear': - case 'toString': - return function () { - abortAndThrow(`.${prop}()`) - } - default: - return ReflectAdapter.get(target, prop, receiver) - } - }, - } + '`cookies()`' ) - CachedCookies.set(prerenderStore, promise) + Object.defineProperties(promise, { + [Symbol.iterator]: { + value: function () { + const expression = '`cookies()[Symbol.iterator]()`' + const error = createCookiesAccessError(route, expression) + abortAndThrowOnSynchronousRequestDataAccess( + route, + expression, + error, + prerenderStore + ) + }, + }, + size: { + get() { + const expression = '`cookies().size`' + const error = createCookiesAccessError(route, expression) + abortAndThrowOnSynchronousRequestDataAccess( + route, + expression, + error, + prerenderStore + ) + }, + }, + get: { + value: function get() { + let expression: string + if (arguments.length === 0) { + expression = '`cookies().get()`' + } else { + expression = `\`cookies().get(${describeNameArg(arguments[0])})\`` + } + const error = createCookiesAccessError(route, expression) + abortAndThrowOnSynchronousRequestDataAccess( + route, + expression, + error, + prerenderStore + ) + }, + }, + getAll: { + value: function getAll() { + let expression: string + if (arguments.length === 0) { + expression = '`cookies().getAll()`' + } else { + expression = `\`cookies().getAll(${describeNameArg(arguments[0])})\`` + } + const error = createCookiesAccessError(route, expression) + abortAndThrowOnSynchronousRequestDataAccess( + route, + expression, + error, + prerenderStore + ) + }, + }, + has: { + value: function has() { + let expression: string + if (arguments.length === 0) { + expression = '`cookies().has()`' + } else { + expression = `\`cookies().has(${describeNameArg(arguments[0])})\`` + } + const error = createCookiesAccessError(route, expression) + abortAndThrowOnSynchronousRequestDataAccess( + route, + expression, + error, + prerenderStore + ) + }, + }, + set: { + value: function set() { + let expression: string + if (arguments.length === 0) { + expression = '`cookies().set()`' + } else { + const arg = arguments[0] + if (arg) { + expression = `\`cookies().set(${describeNameArg(arg)}, ...)\`` + } else { + expression = '`cookies().set(...)`' + } + } + const error = createCookiesAccessError(route, expression) + abortAndThrowOnSynchronousRequestDataAccess( + route, + expression, + error, + prerenderStore + ) + }, + }, + delete: { + value: function () { + let expression: string + if (arguments.length === 0) { + expression = '`cookies().delete()`' + } else if (arguments.length === 1) { + expression = `\`cookies().delete(${describeNameArg(arguments[0])})\`` + } else { + expression = `\`cookies().delete(${describeNameArg(arguments[0])}, ...)\`` + } + const error = createCookiesAccessError(route, expression) + abortAndThrowOnSynchronousRequestDataAccess( + route, + expression, + error, + prerenderStore + ) + }, + }, + clear: { + value: function clear() { + const expression = '`cookies().clear()`' + const error = createCookiesAccessError(route, expression) + abortAndThrowOnSynchronousRequestDataAccess( + route, + expression, + error, + prerenderStore + ) + }, + }, + toString: { + value: function toString() { + const expression = '`cookies().toString()`' + const error = createCookiesAccessError(route, expression) + abortAndThrowOnSynchronousRequestDataAccess( + route, + expression, + error, + prerenderStore + ) + }, + }, + } satisfies CookieExtensions) + return promise } diff --git a/packages/next/src/server/request/headers.ts b/packages/next/src/server/request/headers.ts index b05558551235..63dc0cee0e30 100644 --- a/packages/next/src/server/request/headers.ts +++ b/packages/next/src/server/request/headers.ts @@ -20,7 +20,6 @@ import { makeHangingPromise } from '../dynamic-rendering-utils' import { createDedupedByCallsiteServerErrorLoggerDev } from '../create-deduped-by-callsite-server-error-logger' import { scheduleImmediate } from '../../lib/scheduler' import { isRequestAPICallableInsideAfter } from './utils' -import { ReflectAdapter } from '../web/spec-extension/adapters/reflect' /** * In this version of Next.js `headers()` returns a Promise however you can still reference the properties of the underlying Headers instance @@ -148,65 +147,147 @@ function makeDynamicallyTrackedExoticHeaders( return cachedHeaders } - const abortAndThrow = (property: string) => { - const expression = `\`headers()${property}\`` - const error = createHeadersAccessError(route, expression) - abortAndThrowOnSynchronousRequestDataAccess( - route, - expression, - error, - prerenderStore - ) - } - const promise = makeHangingPromise( prerenderStore.renderSignal, - '`headers()`', - { - get: function get(target, prop, receiver) { - switch (prop) { - case Symbol.iterator: - return function () { - abortAndThrow('[Symbol.iterator]') - } - case 'delete': - case 'get': - case 'has': - return function () { - abortAndThrow( - arguments.length === 0 - ? `.${prop}()` - : `.${prop}(${describeNameArg(arguments[0])})` - ) - } - case 'append': - case 'set': - return function () { - abortAndThrow( - arguments[0] - ? `.${prop}(${describeNameArg(arguments[0])}, ...)` - : `.${prop}(...)` - ) - } - case 'forEach': - return function () { - abortAndThrow(`.${prop}(...)`) - } - case 'entries': - case 'getSetCookie': - case 'keys': - case 'values': - return function () { - abortAndThrow(`.${prop}()`) - } - default: - return ReflectAdapter.get(target, prop, receiver) - } - }, - } + '`headers()`' ) CachedHeaders.set(prerenderStore, promise) + Object.defineProperties(promise, { + append: { + value: function append() { + const expression = `\`headers().append(${describeNameArg(arguments[0])}, ...)\`` + const error = createHeadersAccessError(route, expression) + abortAndThrowOnSynchronousRequestDataAccess( + route, + expression, + error, + prerenderStore + ) + }, + }, + delete: { + value: function _delete() { + const expression = `\`headers().delete(${describeNameArg(arguments[0])})\`` + const error = createHeadersAccessError(route, expression) + abortAndThrowOnSynchronousRequestDataAccess( + route, + expression, + error, + prerenderStore + ) + }, + }, + get: { + value: function get() { + const expression = `\`headers().get(${describeNameArg(arguments[0])})\`` + const error = createHeadersAccessError(route, expression) + abortAndThrowOnSynchronousRequestDataAccess( + route, + expression, + error, + prerenderStore + ) + }, + }, + has: { + value: function has() { + const expression = `\`headers().has(${describeNameArg(arguments[0])})\`` + const error = createHeadersAccessError(route, expression) + abortAndThrowOnSynchronousRequestDataAccess( + route, + expression, + error, + prerenderStore + ) + }, + }, + set: { + value: function set() { + const expression = `\`headers().set(${describeNameArg(arguments[0])}, ...)\`` + const error = createHeadersAccessError(route, expression) + abortAndThrowOnSynchronousRequestDataAccess( + route, + expression, + error, + prerenderStore + ) + }, + }, + getSetCookie: { + value: function getSetCookie() { + const expression = '`headers().getSetCookie()`' + const error = createHeadersAccessError(route, expression) + abortAndThrowOnSynchronousRequestDataAccess( + route, + expression, + error, + prerenderStore + ) + }, + }, + forEach: { + value: function forEach() { + const expression = '`headers().forEach(...)`' + const error = createHeadersAccessError(route, expression) + abortAndThrowOnSynchronousRequestDataAccess( + route, + expression, + error, + prerenderStore + ) + }, + }, + keys: { + value: function keys() { + const expression = '`headers().keys()`' + const error = createHeadersAccessError(route, expression) + abortAndThrowOnSynchronousRequestDataAccess( + route, + expression, + error, + prerenderStore + ) + }, + }, + values: { + value: function values() { + const expression = '`headers().values()`' + const error = createHeadersAccessError(route, expression) + abortAndThrowOnSynchronousRequestDataAccess( + route, + expression, + error, + prerenderStore + ) + }, + }, + entries: { + value: function entries() { + const expression = '`headers().entries()`' + const error = createHeadersAccessError(route, expression) + abortAndThrowOnSynchronousRequestDataAccess( + route, + expression, + error, + prerenderStore + ) + }, + }, + [Symbol.iterator]: { + value: function () { + const expression = '`headers()[Symbol.iterator]()`' + const error = createHeadersAccessError(route, expression) + abortAndThrowOnSynchronousRequestDataAccess( + route, + expression, + error, + prerenderStore + ) + }, + }, + } satisfies HeadersExtensions) + return promise } diff --git a/packages/next/src/server/request/params.ts b/packages/next/src/server/request/params.ts index c770e87f7671..1425a7381fef 100644 --- a/packages/next/src/server/request/params.ts +++ b/packages/next/src/server/request/params.ts @@ -236,9 +236,8 @@ function makeAbortingExoticParams( return cachedParams } - const promise = makeHangingPromise( - prerenderStore.renderSignal, - '`params`', + const promise = new Proxy( + makeHangingPromise(prerenderStore.renderSignal, '`params`'), fallbackParamsProxyHandler ) diff --git a/packages/next/src/server/request/search-params.ts b/packages/next/src/server/request/search-params.ts index f09fa750b923..70329c3e4665 100644 --- a/packages/next/src/server/request/search-params.ts +++ b/packages/next/src/server/request/search-params.ts @@ -186,71 +186,60 @@ function makeAbortingExoticSearchParams( const promise = makeHangingPromise( prerenderStore.renderSignal, - '`searchParams`', - { - get(target, prop, receiver) { - if (Object.hasOwn(promise, prop)) { - // The promise has this property directly. we must return it. - // We know it isn't a dynamic access because it can only be something - // that was previously written to the promise and thus not an underlying searchParam value + '`searchParams`' + ) + + const proxiedPromise = new Proxy(promise, { + get(target, prop, receiver) { + if (Object.hasOwn(promise, prop)) { + // The promise has this property directly. we must return it. + // We know it isn't a dynamic access because it can only be something + // that was previously written to the promise and thus not an underlying searchParam value + return ReflectAdapter.get(target, prop, receiver) + } + + switch (prop) { + case 'then': { + const expression = + '`await searchParams`, `searchParams.then`, or similar' + annotateDynamicAccess(expression, prerenderStore) + return ReflectAdapter.get(target, prop, receiver) + } + case 'status': { + const expression = + '`use(searchParams)`, `searchParams.status`, or similar' + annotateDynamicAccess(expression, prerenderStore) return ReflectAdapter.get(target, prop, receiver) } - switch (prop) { - case 'then': { - const expression = - '`await searchParams`, `searchParams.then`, or similar' - annotateDynamicAccess(expression, prerenderStore) - return ReflectAdapter.get(target, prop, receiver) - } - case 'status': { - const expression = - '`use(searchParams)`, `searchParams.status`, or similar' - annotateDynamicAccess(expression, prerenderStore) - return ReflectAdapter.get(target, prop, receiver) - } - - default: { - if (typeof prop === 'string' && !wellKnownProperties.has(prop)) { - const expression = describeStringPropertyAccess( - 'searchParams', - prop - ) - const error = createSearchAccessError(route, expression) - abortAndThrowOnSynchronousRequestDataAccess( - route, - expression, - error, - prerenderStore - ) - } - return ReflectAdapter.get(target, prop, receiver) + default: { + if (typeof prop === 'string' && !wellKnownProperties.has(prop)) { + const expression = describeStringPropertyAccess( + 'searchParams', + prop + ) + const error = createSearchAccessError(route, expression) + abortAndThrowOnSynchronousRequestDataAccess( + route, + expression, + error, + prerenderStore + ) } + return ReflectAdapter.get(target, prop, receiver) } - }, - has(target, prop) { - // We don't expect key checking to be used except for testing the existence of - // searchParams so we make all has tests trigger dynamic. this means that `promise.then` - // can resolve to the then function on the Promise prototype but 'then' in promise will assume - // you are testing whether the searchParams has a 'then' property. - if (typeof prop === 'string') { - const expression = describeHasCheckingStringProperty( - 'searchParams', - prop - ) - const error = createSearchAccessError(route, expression) - abortAndThrowOnSynchronousRequestDataAccess( - route, - expression, - error, - prerenderStore - ) - } - return ReflectAdapter.has(target, prop) - }, - ownKeys() { - const expression = - '`{...searchParams}`, `Object.keys(searchParams)`, or similar' + } + }, + has(target, prop) { + // We don't expect key checking to be used except for testing the existence of + // searchParams so we make all has tests trigger dynamic. this means that `promise.then` + // can resolve to the then function on the Promise prototype but 'then' in promise will assume + // you are testing whether the searchParams has a 'then' property. + if (typeof prop === 'string') { + const expression = describeHasCheckingStringProperty( + 'searchParams', + prop + ) const error = createSearchAccessError(route, expression) abortAndThrowOnSynchronousRequestDataAccess( route, @@ -258,12 +247,24 @@ function makeAbortingExoticSearchParams( error, prerenderStore ) - }, - } - ) + } + return ReflectAdapter.has(target, prop) + }, + ownKeys() { + const expression = + '`{...searchParams}`, `Object.keys(searchParams)`, or similar' + const error = createSearchAccessError(route, expression) + abortAndThrowOnSynchronousRequestDataAccess( + route, + expression, + error, + prerenderStore + ) + }, + }) - CachedSearchParams.set(prerenderStore, promise) - return promise + CachedSearchParams.set(prerenderStore, proxiedPromise) + return proxiedPromise } function makeErroringExoticSearchParams( From ec2edd5e5e542cd50cc3b7644f0478c6b390b154 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Tue, 20 May 2025 21:33:31 +0200 Subject: [PATCH 55/57] Add more test cases --- .../params-then-in-page/[slug]/page.jsx | 16 ++++++++++++ .../empty-fallback-shells.test.ts | 25 +++++++++++++++++++ .../app/transformed-params/[slug]/page.jsx | 25 +++++++++++++++++++ .../use-cache-hanging-inputs.test.ts | 4 +++ 4 files changed, 70 insertions(+) create mode 100644 test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/with-suspense/params-then-in-page/[slug]/page.jsx create mode 100644 test/e2e/app-dir/use-cache-hanging-inputs/app/transformed-params/[slug]/page.jsx diff --git a/test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/with-suspense/params-then-in-page/[slug]/page.jsx b/test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/with-suspense/params-then-in-page/[slug]/page.jsx new file mode 100644 index 000000000000..6c479b8e4fa3 --- /dev/null +++ b/test/e2e/app-dir/empty-fallback-shells/app/with-cached-io/with-suspense/params-then-in-page/[slug]/page.jsx @@ -0,0 +1,16 @@ +import { CachedLastModified } from '../../../last-modified' + +export default async function Page({ params }) { + return ( + <> + ({ slug: p.slug }))} /> + {})} /> + {})} /> + + + ) +} + +export async function generateStaticParams() { + return [{ slug: 'foo' }] +} diff --git a/test/e2e/app-dir/empty-fallback-shells/empty-fallback-shells.test.ts b/test/e2e/app-dir/empty-fallback-shells/empty-fallback-shells.test.ts index 406bffec570b..c72980fb040d 100644 --- a/test/e2e/app-dir/empty-fallback-shells/empty-fallback-shells.test.ts +++ b/test/e2e/app-dir/empty-fallback-shells/empty-fallback-shells.test.ts @@ -72,6 +72,31 @@ describe('empty-fallback-shells', () => { } }) }) + + describe('and params.then/catch/finally passed to a cached function', () => { + it('resumes a postponed fallback shell', async () => { + const res = await next.fetch( + '/with-cached-io/with-suspense/params-then-in-page/bar' + ) + + const html = await res.text() + expect(html).toIncludeRepeated('data-testid="page-bar"', 4) + + if (isNextDev) { + expect(html).toContain('layout-runtime') + } else { + expect(html).toContain('layout-buildtime') + } + + if (isNextDeploy) { + expect(res.headers.get('x-matched-path')).toBe( + '/with-cached-io/with-suspense/params-then-in-page/[slug]' + ) + } else if (isNextStart) { + expect(res.headers.get('x-nextjs-postponed')).toBe('1') + } + }) + }) }) describe('and the page not wrapped in Suspense', () => { diff --git a/test/e2e/app-dir/use-cache-hanging-inputs/app/transformed-params/[slug]/page.jsx b/test/e2e/app-dir/use-cache-hanging-inputs/app/transformed-params/[slug]/page.jsx new file mode 100644 index 000000000000..0c2be6ac6c88 --- /dev/null +++ b/test/e2e/app-dir/use-cache-hanging-inputs/app/transformed-params/[slug]/page.jsx @@ -0,0 +1,25 @@ +async function LastModified({ params }) { + 'use cache' + + const { slug } = await params + + return ( +

+ Page /{slug} last modified: {new Date().toISOString()} +

+ ) +} + +async function transformParams(params) { + const { slug } = await params + + return { slug } +} + +export default async function Page({ params }) { + return +} + +export async function generateStaticParams() { + return [{ slug: 'foo' }] +} diff --git a/test/e2e/app-dir/use-cache-hanging-inputs/use-cache-hanging-inputs.test.ts b/test/e2e/app-dir/use-cache-hanging-inputs/use-cache-hanging-inputs.test.ts index 1a548b39cbb9..5ed2ff8e4faf 100644 --- a/test/e2e/app-dir/use-cache-hanging-inputs/use-cache-hanging-inputs.test.ts +++ b/test/e2e/app-dir/use-cache-hanging-inputs/use-cache-hanging-inputs.test.ts @@ -342,6 +342,10 @@ describe('use-cache-hanging-inputs', () => { createExpectedBuildErrorMessage('/search-params-caught') ) + expect(cliOutput).toInclude( + createExpectedBuildErrorMessage('/transformed-params/[slug]') + ) + expect(cliOutput).toInclude( createExpectedBuildErrorMessage('/uncached-promise') ) From f2146557f821c5850ca41b9aafc2c1ffad12a886 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Tue, 20 May 2025 21:40:19 +0200 Subject: [PATCH 56/57] Use "inner"/"outer" wording --- .../src/server/use-cache/use-cache-wrapper.ts | 37 ++++++++++--------- 1 file changed, 19 insertions(+), 18 deletions(-) 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 5a8dbe4a1537..7b1c0ec839c9 100644 --- a/packages/next/src/server/use-cache/use-cache-wrapper.ts +++ b/packages/next/src/server/use-cache/use-cache-wrapper.ts @@ -651,30 +651,30 @@ export function cache( // For page and layout components, the cache function is overwritten, // which allows us to apply special handling for params and searchParams. - // For pages and layouts we're using the original params prop, and not the - // serialized one. While it's not generally true for "use cache" args, in - // the case of `params` the serialized and original object are essentially - // equivalent, so this is safe to do (including fallback params that are - // hanging promises). It allows us to avoid waiting for the timeout, when - // prerendering a fallback shell of a cached page or layout that awaits - // params. + // For pages and layouts we're using the outer params prop, and not the + // inner one that was serialized/deserialized. While it's not generally + // true for "use cache" args, in the case of `params` the inner and outer + // object are essentially equivalent, so this is safe to do (including + // fallback params that are hanging promises). It allows us to avoid + // waiting for the timeout, when prerendering a fallback shell of a cached + // page or layout that awaits params. if (isPageComponent(args)) { isPageOrLayout = true - const [{ params, searchParams }] = args + const [{ params: outerParams, searchParams: outerSearchParams }] = args // Overwrite the props to omit $$isPageComponent. - args = [{ params, searchParams }] + args = [{ params: outerParams, searchParams: outerSearchParams }] fn = { [name]: async ({ - params: _serializedParams, - searchParams: serializedSearchParams, + params: _innerParams, + searchParams: innerSearchParams, }: Omit) => originalFn.apply(null, [ { - params, + params: outerParams, searchParams: workStore.dynamicIOEnabled - ? serializedSearchParams + ? innerSearchParams : // When dynamicIO is not enabled, we can not encode // searchParams as a hanging promise. To still avoid unused // search params from making a page dynamic, we define them @@ -690,16 +690,17 @@ export function cache( } else if (isLayoutComponent(args)) { isPageOrLayout = true - const [{ params, $$isLayoutComponent, ...slots }] = args + const [{ params: outerParams, $$isLayoutComponent, ...outerSlots }] = + args // Overwrite the props to omit $$isLayoutComponent. - args = [{ params, ...slots }] + args = [{ params: outerParams, ...outerSlots }] fn = { [name]: async ({ - params: _serializedParams, - ...serializedSlots + params: _innerParams, + ...innerSlots }: Omit) => - originalFn.apply(null, [{ params, ...serializedSlots }]), + originalFn.apply(null, [{ params: outerParams, ...innerSlots }]), }[name] as (...args: unknown[]) => Promise } From 9b7fd3fa0f0f0bfc3d1d8bc13739b6924a12272b Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Tue, 20 May 2025 10:40:41 +0200 Subject: [PATCH 57/57] Update `errors.json` --- packages/next/errors.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/next/errors.json b/packages/next/errors.json index dc824d03e7e2..e2b659778f99 100644 --- a/packages/next/errors.json +++ b/packages/next/errors.json @@ -682,5 +682,7 @@ "681": "Dynamic imports should not be instrumented in the edge runtime, because `dynamicIO` doesn't support it", "682": "\\`experimental.ppr\\` can not be \\`%s\\` when \\`experimental.dynamicIO\\` is \\`true\\`. PPR is implicitly enabled when Dynamic IO is enabled.", "683": "The \\`compiler.define\\` option is configured to replace the \\`%s\\` variable. This variable is either part of a Next.js built-in or is already configured.", - "684": "The \\`compiler.defineServer\\` option is configured to replace the \\`%s\\` variable. This variable is either part of a Next.js built-in or is already configured." + "684": "The \\`compiler.defineServer\\` option is configured to replace the \\`%s\\` variable. This variable is either part of a Next.js built-in or is already configured.", + "685": "Accessed fallback \\`params\\` during prerendering.", + "686": "Expected clientReferenceManifest to be defined." }