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"` 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/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." } 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/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/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 dd4803930bdb..9d8c2f12a562 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] @@ -665,7 +666,9 @@ async function warmupDevRender( workStore, } = ctx - if (!renderOpts.dev) { + const { dev, onInstrumentationRequestError } = renderOpts + + if (!dev) { throw new InvariantError( 'generateDynamicFlightRenderResult should never be called in `next start` mode.' ) @@ -677,7 +680,7 @@ async function warmupDevRender( ) function onFlightDataRenderError(err: DigestedError) { - return renderOpts.onInstrumentationRequestError?.( + return onInstrumentationRequestError?.( err, req, createErrorContext(ctx, 'react-server-components-payload') @@ -1022,6 +1025,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, @@ -1183,6 +1196,7 @@ async function renderToHTMLOrFlightImpl( const requestTimestamp = Date.now() const { + clientReferenceManifest, serverActionsManifest, ComponentMod, nextFontManifest, @@ -1274,8 +1288,7 @@ async function renderToHTMLOrFlightImpl( const appUsingSizeAdjustment = !!nextFontManifest?.appUsingSizeAdjust - // TODO: fix this typescript - const clientReferenceManifest = renderOpts.clientReferenceManifest! + assertClientReferenceManifest(clientReferenceManifest) const serverModuleMap = createServerModuleMap({ serverActionsManifest }) @@ -1729,69 +1742,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') @@ -1800,8 +1830,8 @@ async function renderToStream( const allCapturedErrors: Array = [] const htmlRendererErrorHandler = createHTMLErrorHandler( - !!renderOpts.dev, - !!renderOpts.nextExport, + dev, + nextExport, reactServerErrorsByDigest, allCapturedErrors, silenceLogger, @@ -1816,13 +1846,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 & { @@ -1911,7 +1941,7 @@ async function renderToStream( // in the static prelude. const inlinedReactServerDataStream = createInlinedDataReadableStream( reactServerResult.tee(), - ctx.nonce, + nonce, formState ) @@ -1935,27 +1965,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, @@ -1977,18 +2004,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, } @@ -1998,7 +2025,7 @@ async function renderToStream( polyfills, renderServerInsertedHTML, serverCapturedErrors: allCapturedErrors, - basePath: renderOpts.basePath, + basePath, tracingMetadata: tracingMetadata, }) /** @@ -2019,20 +2046,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 ( @@ -2055,7 +2080,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 @@ -2070,10 +2095,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. @@ -2088,12 +2110,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' ) @@ -2135,12 +2157,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, @@ -2165,16 +2187,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, @@ -2182,11 +2202,11 @@ async function renderToStream( polyfills, renderServerInsertedHTML, serverCapturedErrors: [], - basePath: renderOpts.basePath, + basePath, tracingMetadata: tracingMetadata, }), getServerInsertedMetadata, - validateRootLayout, + validateRootLayout: dev, }) } catch (finalErr: any) { if ( @@ -2218,10 +2238,20 @@ async function spawnDynamicValidationInDev( clientReferenceManifest: NonNullable, requestStore: RequestStore ): Promise { - const { componentMod: ComponentMod, implicitTags, workStore } = ctx + const { + componentMod: ComponentMod, + getDynamicParamFromSegment, + implicitTags, + nonce, + renderOpts, + workStore, + } = ctx + + const { botType } = renderOpts + const rootParams = getRootParams( ComponentMod.tree, - ctx.getDynamicParamFromSegment + getDynamicParamFromSegment ) const hmrRefreshHash = requestStore.cookies.get( @@ -2334,7 +2364,6 @@ async function spawnDynamicValidationInDev( } } - const nonce = '1' const { ServerInsertedHTMLProvider } = createServerInsertedHTML() const { ServerInsertedMetadataProvider } = createServerInsertedMetadata(nonce) @@ -2350,7 +2379,7 @@ async function spawnDynamicValidationInDev( clientReferenceManifest={clientReferenceManifest} ServerInsertedHTMLProvider={ServerInsertedHTMLProvider} ServerInsertedMetadataProvider={ServerInsertedMetadataProvider} - gracefullyDegrade={!!ctx.renderOpts.botType} + gracefullyDegrade={!!botType} nonce={nonce} />, { @@ -2496,8 +2525,8 @@ async function spawnDynamicValidationInDev( clientReferenceManifest={clientReferenceManifest} ServerInsertedHTMLProvider={ServerInsertedHTMLProvider} ServerInsertedMetadataProvider={ServerInsertedMetadataProvider} - gracefullyDegrade={!!ctx.renderOpts.botType} - nonce={ctx.nonce} + gracefullyDegrade={!!botType} + nonce={nonce} />, { signal: finalClientController.signal, @@ -2613,10 +2642,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 } = @@ -2626,11 +2672,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') @@ -2640,44 +2686,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') @@ -2685,8 +2731,8 @@ async function prerenderToStream( } const allCapturedErrors: Array = [] const htmlRendererErrorHandler = createHTMLErrorHandler( - !!renderOpts.dev, - !!renderOpts.nextExport, + dev, + nextExport, reactServerErrorsByDigest, allCapturedErrors, silenceLogger, @@ -2719,14 +2765,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 * @@ -2833,6 +2879,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 } @@ -2887,7 +2956,7 @@ async function prerenderToStream( clientReferenceManifest={clientReferenceManifest} ServerInsertedHTMLProvider={ServerInsertedHTMLProvider} ServerInsertedMetadataProvider={ServerInsertedMetadataProvider} - gracefullyDegrade={!!ctx.renderOpts.botType} + gracefullyDegrade={!!botType} nonce={nonce} />, { @@ -2940,7 +3009,7 @@ async function prerenderToStream( let serverIsDynamic = false const finalServerController = new AbortController() const serverDynamicTracking = createDynamicTrackingState( - renderOpts.isDebugDynamicAccesses + isDebugDynamicAccesses ) const finalRenderPrerenderStore: PrerenderStore = (prerenderStore = { @@ -3010,7 +3079,7 @@ async function prerenderToStream( )) const clientDynamicTracking = createDynamicTrackingState( - renderOpts.isDebugDynamicAccesses + isDebugDynamicAccesses ) const finalClientController = new AbortController() const finalClientPrerenderStore: PrerenderStore = { @@ -3048,7 +3117,7 @@ async function prerenderToStream( clientReferenceManifest={clientReferenceManifest} ServerInsertedHTMLProvider={ServerInsertedHTMLProvider} ServerInsertedMetadataProvider={ServerInsertedMetadataProvider} - gracefullyDegrade={!!ctx.renderOpts.botType} + gracefullyDegrade={!!botType} nonce={nonce} />, { @@ -3080,7 +3149,7 @@ async function prerenderToStream( appendHeader(key, value) }) }, - maxHeadersLength: renderOpts.reactMaxHeadersLength, + maxHeadersLength: reactMaxHeadersLength, bootstrapScripts: [bootstrapScript], } ), @@ -3095,7 +3164,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 (!allowEmptyStaticShell) { throwIfDisallowedDynamic( workStore, preludeIsEmpty, @@ -3109,7 +3178,7 @@ async function prerenderToStream( polyfills, renderServerInsertedHTML, serverCapturedErrors: allCapturedErrors, - basePath: renderOpts.basePath, + basePath, tracingMetadata: tracingMetadata, }) @@ -3181,7 +3250,7 @@ async function prerenderToStream( clientReferenceManifest={clientReferenceManifest} ServerInsertedHTMLProvider={ServerInsertedHTMLProvider} ServerInsertedMetadataProvider={ServerInsertedMetadataProvider} - gracefullyDegrade={!!ctx.renderOpts.botType} + gracefullyDegrade={!!botType} nonce={nonce} />, JSON.parse(JSON.stringify(postponed)), @@ -3219,11 +3288,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 = { @@ -3282,7 +3349,7 @@ async function prerenderToStream( clientReferenceManifest={clientReferenceManifest} ServerInsertedHTMLProvider={ServerInsertedHTMLProvider} ServerInsertedMetadataProvider={ServerInsertedMetadataProvider} - gracefullyDegrade={!!ctx.renderOpts.botType} + gracefullyDegrade={!!botType} nonce={nonce} />, { @@ -3292,7 +3359,7 @@ async function prerenderToStream( appendHeader(key, value) }) }, - maxHeadersLength: renderOpts.reactMaxHeadersLength, + maxHeadersLength: reactMaxHeadersLength, bootstrapScripts: [bootstrapScript], } ) @@ -3300,7 +3367,7 @@ async function prerenderToStream( polyfills, renderServerInsertedHTML, serverCapturedErrors: allCapturedErrors, - basePath: renderOpts.basePath, + basePath, tracingMetadata: tracingMetadata, }) @@ -3415,7 +3482,7 @@ async function prerenderToStream( clientReferenceManifest={clientReferenceManifest} ServerInsertedHTMLProvider={ServerInsertedHTMLProvider} ServerInsertedMetadataProvider={ServerInsertedMetadataProvider} - gracefullyDegrade={!!ctx.renderOpts.botType} + gracefullyDegrade={!!botType} nonce={nonce} />, JSON.parse(JSON.stringify(postponed)), @@ -3495,7 +3562,7 @@ async function prerenderToStream( clientReferenceManifest={clientReferenceManifest} ServerInsertedHTMLProvider={ServerInsertedHTMLProvider} ServerInsertedMetadataProvider={ServerInsertedMetadataProvider} - gracefullyDegrade={!!ctx.renderOpts.botType} + gracefullyDegrade={!!botType} nonce={nonce} />, { @@ -3521,7 +3588,7 @@ async function prerenderToStream( polyfills, renderServerInsertedHTML, serverCapturedErrors: allCapturedErrors, - basePath: renderOpts.basePath, + basePath, tracingMetadata: tracingMetadata, }) return { @@ -3592,10 +3659,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) { @@ -3603,10 +3667,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' @@ -3660,7 +3724,7 @@ async function prerenderToStream( ServerInsertedHTMLProvider={ServerInsertedHTMLProvider} preinitScripts={errorPreinitScripts} clientReferenceManifest={clientReferenceManifest} - gracefullyDegrade={!!ctx.renderOpts.botType} + gracefullyDegrade={!!botType} nonce={nonce} /> ), @@ -3686,8 +3750,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 = @@ -3711,11 +3773,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/create-component-tree.tsx b/packages/next/src/server/app-render/create-component-tree.tsx index 9c2f4b7a72ac..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. @@ -677,16 +680,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 = ( ) @@ -845,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/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 } 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/app-render/work-unit-async-storage.external.ts b/packages/next/src/server/app-render/work-unit-async-storage.external.ts index db8fd037a5c5..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 @@ -163,6 +163,11 @@ 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 { @@ -179,21 +184,20 @@ 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 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 } /** - * 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 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 } } diff --git a/packages/next/src/server/request/params.ts b/packages/next/src/server/request/params.ts index f0ee12bd034a..1425a7381fef 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 @@ -198,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, @@ -208,10 +236,11 @@ function makeAbortingExoticParams( return cachedParams } - const promise = makeHangingPromise( - prerenderStore.renderSignal, - '`params`' + const promise = new Proxy( + makeHangingPromise(prerenderStore.renderSignal, '`params`'), + fallbackParamsProxyHandler ) + CachedParams.set(underlyingParams, promise) Object.keys(underlyingParams).forEach((prop) => { 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..7b1c0ec839c9 100644 --- a/packages/next/src/server/use-cache/use-cache-wrapper.ts +++ b/packages/next/src/server/use-cache/use-cache-wrapper.ts @@ -52,6 +52,7 @@ import { import type { Params } from '../request/params' import React from 'react' import { createLazyResult, isResolvedLazyResult } from '../lib/lazy-result' +import { dynamicAccessAsyncStorage } from '../app-render/dynamic-access-async-storage.external' type CacheKeyParts = | [buildId: string, id: string, args: unknown[]] @@ -63,6 +64,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 @@ -76,7 +86,7 @@ function generateCacheEntry( 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(). @@ -173,16 +183,18 @@ function generateCacheEntryWithCacheContext( getDraftModeProviderForCacheScope(workStore, outerWorkUnitStore), } - return workUnitAsyncStorage.run( - cacheStore, - generateCacheEntryImpl, - workStore, - outerWorkUnitStore, - cacheStore, - clientReferenceManifest, - encodedArguments, - fn, - timeoutError + return workUnitAsyncStorage.run(cacheStore, () => + dynamicAccessAsyncStorage.run( + { abortController: new AbortController() }, + generateCacheEntryImpl, + workStore, + outerWorkUnitStore, + cacheStore, + clientReferenceManifest, + encodedArguments, + fn, + timeoutError + ) ) } @@ -307,6 +319,17 @@ async function collectResult( return entry } +type GenerateCacheEntryResult = + | { + readonly type: 'cached' + readonly stream: ReadableStream + readonly pendingCacheEntry: Promise + } + | { + readonly type: 'prerender-dynamic' + readonly hangingPromise: Promise + } + async function generateCacheEntryImpl( workStore: WorkStore, outerWorkUnitStore: WorkUnitStore | undefined, @@ -315,7 +338,7 @@ async function generateCacheEntryImpl( encodedArguments: FormData | string, fn: (...args: unknown[]) => Promise, timeoutError: UseCacheTimeoutError -): Promise<[ReadableStream, Promise]> { +): Promise { const temporaryReferences = createServerTemporaryReferenceSet() const [, , args] = @@ -400,12 +423,16 @@ async function generateCacheEntryImpl( timeoutAbortController.abort(timeoutError) }, 50000) - const { renderSignal } = outerWorkUnitStore + const dynamicAccessAbortSignal = + dynamicAccessAsyncStorage.getStore()?.abortController.signal - const abortSignal = AbortSignal.any([ - renderSignal, - timeoutAbortController.signal, - ]) + const abortSignal = dynamicAccessAbortSignal + ? AbortSignal.any([ + dynamicAccessAbortSignal, + outerWorkUnitStore.renderSignal, + timeoutAbortController.signal, + ]) + : timeoutAbortController.signal const { prelude } = await prerender( resultPromise, @@ -427,11 +454,31 @@ async function generateCacheEntryImpl( clearTimeout(timer) 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) { + // 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( + outerWorkUnitStore.renderSignal, + abortSignal.reason + ) + + if (outerWorkUnitStore?.type === 'prerender') { + outerWorkUnitStore.cacheSignal?.endRead() + } + + return { type: 'prerender-dynamic', hangingPromise } } else { stream = prelude } @@ -449,7 +496,7 @@ async function generateCacheEntryImpl( const [returnStream, savedStream] = stream.tee() - const promiseOfCacheEntry = collectResult( + const pendingCacheEntry = collectResult( savedStream, workStore, outerWorkUnitStore, @@ -458,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] { @@ -596,31 +647,61 @@ 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)) { - const [{ params, searchParams }] = args + 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 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: outerParams, searchParams: outerSearchParams }] = args // Overwrite the props to omit $$isPageComponent. - args = [{ params, searchParams }] + args = [{ params: outerParams, searchParams: outerSearchParams }] fn = { [name]: async ({ - params: serializedParams, + params: _innerParams, + searchParams: innerSearchParams, }: Omit) => originalFn.apply(null, [ { - params: serializedParams, - searchParams: - makeErroringExoticSearchParamsForUseCache(workStore), + params: outerParams, + searchParams: workStore.dynamicIOEnabled + ? 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 + // 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 + } else if (isLayoutComponent(args)) { + isPageOrLayout = true + + const [{ params: outerParams, $$isLayoutComponent, ...outerSlots }] = + args + // Overwrite the props to omit $$isLayoutComponent. + args = [{ params: outerParams, ...outerSlots }] + + fn = { + [name]: async ({ + params: _innerParams, + ...innerSlots + }: Omit) => + originalFn.apply(null, [{ params: outerParams, ...innerSlots }]), + }[name] as (...args: unknown[]) => Promise } if (boundArgsLength > 0) { @@ -654,10 +735,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' @@ -829,7 +938,7 @@ export function cache( } } - const [newStream, pendingCacheEntry] = await generateCacheEntry( + const result = await generateCacheEntry( workStore, workUnitStore, clientReferenceManifest, @@ -838,6 +947,12 @@ export function cache( timeoutError ) + 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 @@ -892,40 +1007,44 @@ 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, - undefined, // This is not running within the context of this unit. + // This is not running within the context of this unit. + undefined, clientReferenceManifest, encodedCacheKeyParts, fn, timeoutError ) - let savedCacheEntry: Promise - if (prerenderResumeDataCache) { - const split = clonePendingCacheEntry(pendingCacheEntry) - savedCacheEntry = getNthCacheEntry(split, 0) - prerenderResumeDataCache.cache.set( + if (result.type === 'cached') { + const { stream: ignoredStream, pendingCacheEntry } = result + 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 - ) + workStore.pendingRevalidateWrites ??= [] + workStore.pendingRevalidateWrites.push(promise) - if (!workStore.pendingRevalidateWrites) { - workStore.pendingRevalidateWrites = [] + await ignoredStream.cancel() } - workStore.pendingRevalidateWrites.push(promise) - - await ignoredStream.cancel() } } } @@ -979,6 +1098,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/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/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/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/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..6c479b8e4fa3 --- /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,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/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..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 @@ -1,21 +1,170 @@ import { nextTestSetup } from 'e2e-utils' describe('empty-fallback-shells', () => { - const { next, isNextDeploy } = nextTestSetup({ + const { next, isNextDev, 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 (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-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 (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-not-in-page/[slug]' + ) + } else if (isNextStart) { + expect(res.headers.get('x-nextjs-postponed')).toBe('1') + } + }) + }) + + 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', () => { + 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 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' + ) + + 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') + } + }) + }) + + 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"', 4) + 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') + } + }) + }) + }) + }) + + if (isNextStart) { + it('should not log a HANGING_PROMISE_REJECTION error', async () => { + expect(next.cliOutput).not.toContain('HANGING_PROMISE_REJECTION') + }) + } }) 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, }, } 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/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 196219534525..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 @@ -335,15 +335,15 @@ describe('use-cache-hanging-inputs', () => { ) expect(cliOutput).toInclude( - createExpectedBuildErrorMessage('/fallback-params/[slug]') + createExpectedBuildErrorMessage('/search-params') ) expect(cliOutput).toInclude( - createExpectedBuildErrorMessage('/search-params') + createExpectedBuildErrorMessage('/search-params-caught') ) expect(cliOutput).toInclude( - createExpectedBuildErrorMessage('/search-params-caught') + createExpectedBuildErrorMessage('/transformed-params/[slug]') ) expect(cliOutput).toInclude(