From 9392c8155efe24df54e8eedf12bc8057854c06a5 Mon Sep 17 00:00:00 2001 From: Janka Uryga Date: Wed, 8 Oct 2025 20:28:51 +0200 Subject: [PATCH] [Cache Components] separate runtime stage in dev render --- packages/next/errors.json | 3 +- .../app-render/app-render-render-utils.ts | 37 +++-- .../next/src/server/app-render/app-render.tsx | 127 +++++++++++++----- .../server/app-render/dynamic-rendering.ts | 8 +- .../src/server/app-render/staged-rendering.ts | 67 +++++++++ .../work-unit-async-storage.external.ts | 3 +- .../src/server/dynamic-rendering-utils.ts | 15 ++- packages/next/src/server/lib/patch-fetch.ts | 81 ++++------- .../node-environment-extensions/utils.tsx | 2 +- .../next/src/server/request/connection.ts | 7 +- packages/next/src/server/request/cookies.ts | 10 +- packages/next/src/server/request/headers.ts | 14 +- packages/next/src/server/request/params.ts | 30 +++-- .../next/src/server/request/search-params.ts | 55 +++++--- .../src/server/use-cache/use-cache-wrapper.ts | 19 ++- .../app/apis/[param]/page.tsx | 4 +- .../cache-components.dev-warmup.test.ts | 33 +++-- 17 files changed, 356 insertions(+), 159 deletions(-) create mode 100644 packages/next/src/server/app-render/staged-rendering.ts diff --git a/packages/next/errors.json b/packages/next/errors.json index 2e1bdc9e14cca8..212059aa17505f 100644 --- a/packages/next/errors.json +++ b/packages/next/errors.json @@ -859,5 +859,6 @@ "858": "`lockfileUnlockSync` is not supported by the wasm bindings.", "859": "An IO error occurred while attempting to create and acquire the lockfile", "860": "`pipelineInSequentialTasks` should not be called in edge runtime.", - "861": "dynamicInDevStagedRendering should only be used in development mode and when Cache Components is enabled." + "861": "dynamicInDevStagedRendering should only be used in development mode and when Cache Components is enabled.", + "862": "Invalid render stage: %s" } diff --git a/packages/next/src/server/app-render/app-render-render-utils.ts b/packages/next/src/server/app-render/app-render-render-utils.ts index 7e6f991a2e9d50..2fee7eacb1fef1 100644 --- a/packages/next/src/server/app-render/app-render-render-utils.ts +++ b/packages/next/src/server/app-render/app-render-render-utils.ts @@ -35,30 +35,45 @@ export function scheduleInSequentialTasks( * We schedule on the same queue (setTimeout) at the same time to ensure no other events can sneak in between. * The function that runs in the second task gets access to the first tasks's result. */ -export function pipelineInSequentialTasks( - render: () => A, - followup: (a: A) => B | Promise -): Promise { +export function pipelineInSequentialTasks3( + one: () => A, + two: (a: A) => B, + three: (b: B) => C | Promise +): Promise { if (process.env.NEXT_RUNTIME === 'edge') { throw new InvariantError( '`pipelineInSequentialTasks` should not be called in edge runtime.' ) } else { return new Promise((resolve, reject) => { - let renderResult: A | undefined = undefined + let oneResult: A | undefined = undefined setTimeout(() => { try { - renderResult = render() + oneResult = one() } catch (err) { - clearTimeout(followupId) + clearTimeout(twoId) + clearTimeout(threeId) reject(err) } }, 0) - const followupId = setTimeout(() => { - // if `render` threw, then the `followup` timeout would've been cleared, - // so if we got here, we're guaranteed to have a `renderResult`. + + let twoResult: B | undefined = undefined + const twoId = setTimeout(() => { + // if `one` threw, then this timeout would've been cleared, + // so if we got here, we're guaranteed to have a value. + try { + twoResult = two(oneResult!) + } catch (err) { + clearTimeout(threeId) + reject(err) + } + }, 0) + + const threeId = setTimeout(() => { + // if `two` threw, then this timeout would've been cleared, + // so if we got here, we're guaranteed to have a value. try { - resolve(followup(renderResult!)) + resolve(three(twoResult!)) } catch (err) { reject(err) } diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index 0970f7c8d237f4..4aa70d4a24e79e 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -168,10 +168,7 @@ import { prerenderAndAbortInSequentialTasks, } from './app-render-prerender-utils' import { printDebugThrownValueForProspectiveRender } from './prospective-render-utils' -import { - pipelineInSequentialTasks, - scheduleInSequentialTasks, -} from './app-render-render-utils' +import { pipelineInSequentialTasks3 } from './app-render-render-utils' import { waitAtLeastOneReactRenderTask } from '../../lib/scheduler' import { workUnitAsyncStorage, @@ -209,6 +206,7 @@ import { getDynamicParam } from '../../shared/lib/router/utils/get-dynamic-param import type { ExperimentalConfig } from '../config-shared' import type { Params } from '../request/params' import { createPromiseWithResolvers } from '../../shared/lib/promise-with-resolvers' +import { RenderStage, StagedRenderingController } from './staged-rendering' export type GetDynamicParamFromSegment = ( // [slug] / [[slug]] / [...slug] @@ -2175,8 +2173,21 @@ async function renderToStream( ) } - const environmentName = () => - requestStore.prerenderPhase === true ? 'Prerender' : 'Server' + const environmentName = () => { + const currentStage = requestStore.stagedRendering!.currentStage + switch (currentStage) { + case RenderStage.Static: + return 'Prerender' + case RenderStage.Runtime: + // TODO: only label as "Prefetch" if the page has a `prefetch` config. + return 'Prefetch' + case RenderStage.Dynamic: + return 'Server' + default: + currentStage satisfies never + throw new InvariantError(`Invalid render stage: ${currentStage}`) + } + } // Try to render the page and see if there's any cache misses. // If there are, wait for caches to finish and restart the render. @@ -2192,14 +2203,19 @@ async function renderToStream( const prerenderResumeDataCache = createPrerenderResumeDataCache() + const initialRenderReactController = new AbortController() // Controls the react render + const initialRenderDataController = new AbortController() // Controls hanging promises we create + const initialRenderStageController = new StagedRenderingController( + initialRenderDataController.signal + ) + requestStore.prerenderResumeDataCache = prerenderResumeDataCache // `getRenderResumeDataCache` will fall back to using `prerenderResumeDataCache` as `renderResumeDataCache`, // so not having a resume data cache won't break any expectations in case we don't need to restart. requestStore.renderResumeDataCache = null + requestStore.stagedRendering = initialRenderStageController requestStore.cacheSignal = cacheSignal - const initialRenderReactController = new AbortController() - const intialRenderDebugChannel = setReactDebugChannel && createDebugChannel() @@ -2207,11 +2223,10 @@ async function renderToStream( const maybeInitialServerStream = await workUnitAsyncStorage.run( requestStore, () => - pipelineInSequentialTasks( + pipelineInSequentialTasks3( () => { // Static stage - requestStore.prerenderPhase = true - return ComponentMod.renderToReadableStream( + const stream = ComponentMod.renderToReadableStream( initialRscPayload, clientReferenceManifest.clientModules, { @@ -2222,25 +2237,57 @@ async function renderToStream( signal: initialRenderReactController.signal, } ) + // If we abort the render, we want to reject the stage-dependent promises as well. + // Note that we want to install this listener after the render is started + // so that it runs after react is finished running its abort code. + initialRenderReactController.signal.addEventListener( + 'abort', + () => { + initialRenderDataController.abort( + initialRenderReactController.signal.reason + ) + } + ) + return stream }, - async (stream) => { - // Dynamic stage - // Note: if we had cache misses, things that would've happened statically otherwise - // may be marked as dynamic instead. - requestStore.prerenderPhase = false + (stream) => { + // Runtime stage + initialRenderStageController.advanceStage(RenderStage.Runtime) // If all cache reads initiated in the static stage have completed, // then all of the necessary caches have to be warm (or there's no caches on the page). // On the other hand, if we still have pending cache reads, then we had a cache miss, // and the static stage didn't render all the content that it normally would have. - const hadCacheMiss = cacheSignal.hasPendingReads() - if (!hadCacheMiss) { + if (!cacheSignal.hasPendingReads()) { // No cache misses. We can use the stream as is. return stream } else { // Cache miss. We'll discard this stream, and render again. return null } + }, + async (maybeStream) => { + // Dynamic stage + + if (maybeStream === null) { + // If we had cache misses in either of the previous stages, then we'll only use this render for filling caches. + // We won't advance the stage, and thus leave dynamic APIs hanging, + // because they won't be cached anyway, so it'd be wasted work. + return null + } + + // Note: if we had cache misses, things that would've happened statically otherwise + // may be marked as dynamic instead. + initialRenderStageController.advanceStage(RenderStage.Dynamic) + + // Analogous to the previous stage. + if (!cacheSignal.hasPendingReads()) { + // No cache misses. We can use the stream as is. + return maybeStream + } else { + // Cache miss. We'll discard this stream, and render again. + return null + } } ) ) @@ -2273,11 +2320,14 @@ async function renderToStream( // Now, we need to do another render. requestStore = createRequestStore() + const finalRenderStageController = new StagedRenderingController() + // We've filled the caches, so now we can render as usual. requestStore.prerenderResumeDataCache = null requestStore.renderResumeDataCache = createRenderResumeDataCache( prerenderResumeDataCache ) + requestStore.stagedRendering = finalRenderStageController requestStore.cacheSignal = null // The initial render already wrote to its debug channel. We're not using it, @@ -2292,25 +2342,32 @@ async function renderToStream( const finalRscPayload = await getPayload() const finalServerStream = await workUnitAsyncStorage.run( requestStore, - scheduleInSequentialTasks, - () => { - // Static stage - requestStore.prerenderPhase = true - return ComponentMod.renderToReadableStream( - finalRscPayload, - clientReferenceManifest.clientModules, - { - onError: serverComponentsErrorHandler, - environmentName, - filterStackFrame, - debugChannel: finalRenderDebugChannel?.serverSide, + () => + pipelineInSequentialTasks3( + () => { + // Static stage + return ComponentMod.renderToReadableStream( + finalRscPayload, + clientReferenceManifest.clientModules, + { + onError: serverComponentsErrorHandler, + environmentName, + filterStackFrame, + debugChannel: finalRenderDebugChannel?.serverSide, + } + ) + }, + (stream) => { + // Runtime stage + finalRenderStageController.advanceStage(RenderStage.Runtime) + return stream + }, + (stream) => { + // Dynamic stage + finalRenderStageController.advanceStage(RenderStage.Dynamic) + return stream } ) - }, - () => { - // Dynamic stage - requestStore.prerenderPhase = false - } ) reactServerResult = new ReactServerResult(finalServerStream) diff --git a/packages/next/src/server/app-render/dynamic-rendering.ts b/packages/next/src/server/app-render/dynamic-rendering.ts index a675c0f85a7974..64a7149f28108c 100644 --- a/packages/next/src/server/app-render/dynamic-rendering.ts +++ b/packages/next/src/server/app-render/dynamic-rendering.ts @@ -50,6 +50,7 @@ import { import { scheduleOnNextTick } from '../../lib/scheduler' import { BailoutToCSRError } from '../../shared/lib/lazy-dynamic/bailout-to-csr' import { InvariantError } from '../../shared/lib/invariant-error' +import { RenderStage } from './staged-rendering' const hasPostpone = typeof React.unstable_postpone === 'function' @@ -298,8 +299,11 @@ export function trackSynchronousPlatformIOAccessInDev( requestStore: RequestStore ): void { // We don't actually have a controller to abort but we do the semantic equivalent by - // advancing the request store out of prerender mode - requestStore.prerenderPhase = false + // advancing the request store out of the prerender stage + if (requestStore.stagedRendering) { + // TODO: this doesn't seem like it'll actually do what we need? + requestStore.stagedRendering.advanceStage(RenderStage.Dynamic) + } } /** diff --git a/packages/next/src/server/app-render/staged-rendering.ts b/packages/next/src/server/app-render/staged-rendering.ts new file mode 100644 index 00000000000000..f3132c14f0d71f --- /dev/null +++ b/packages/next/src/server/app-render/staged-rendering.ts @@ -0,0 +1,67 @@ +import { InvariantError } from '../../shared/lib/invariant-error' +import { createPromiseWithResolvers } from '../../shared/lib/promise-with-resolvers' + +export enum RenderStage { + Static = 1, + Runtime = 2, + Dynamic = 3, +} + +export type NonStaticRenderStage = RenderStage.Runtime | RenderStage.Dynamic + +export class StagedRenderingController { + currentStage: RenderStage = RenderStage.Static + + private runtimeStagePromise = createPromiseWithResolvers() + private dynamicStagePromise = createPromiseWithResolvers() + + constructor(abortSignal?: AbortSignal) { + if (abortSignal) { + abortSignal.addEventListener( + 'abort', + () => { + const { reason } = abortSignal + if (this.currentStage < RenderStage.Runtime) { + this.runtimeStagePromise.reject(reason) + } + if (this.currentStage < RenderStage.Dynamic) { + this.dynamicStagePromise.reject(reason) + } + }, + { once: true } + ) + } + } + + advanceStage(stage: NonStaticRenderStage) { + // If we're already at the target stage or beyond, do nothing. + // (this can happen e.g. if sync IO advanced us to the dynamic stage) + if (this.currentStage >= stage) { + return + } + this.currentStage = stage + // Note that we might be going directly from Static to Dynamic, + // so we need to resolve the runtime stage as well. + if (stage >= RenderStage.Runtime) { + this.runtimeStagePromise.resolve() + } + if (stage >= RenderStage.Dynamic) { + this.dynamicStagePromise.resolve() + } + } + + waitForStage(stage: NonStaticRenderStage) { + switch (stage) { + case RenderStage.Runtime: { + return this.runtimeStagePromise.promise + } + case RenderStage.Dynamic: { + return this.dynamicStagePromise.promise + } + default: { + stage satisfies never + throw new InvariantError(`Invalid render stage: ${stage}`) + } + } + } +} 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 04bdf4b5d62fe1..8cc66c2cf07f18 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 @@ -19,6 +19,7 @@ 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 { InvariantError } from '../../shared/lib/invariant-error' +import type { StagedRenderingController } from './staged-rendering' export type WorkUnitPhase = 'action' | 'render' | 'after' @@ -67,8 +68,8 @@ export interface RequestStore extends CommonWorkUnitStore { // DEV-only usedDynamic?: boolean - prerenderPhase?: boolean devFallbackParams?: OpaqueFallbackRouteParams | null + stagedRendering?: StagedRenderingController | null cacheSignal?: CacheSignal | null prerenderResumeDataCache?: PrerenderResumeDataCache | null } diff --git a/packages/next/src/server/dynamic-rendering-utils.ts b/packages/next/src/server/dynamic-rendering-utils.ts index 17e4fe608bd970..e5b51d17515c86 100644 --- a/packages/next/src/server/dynamic-rendering-utils.ts +++ b/packages/next/src/server/dynamic-rendering-utils.ts @@ -1,3 +1,6 @@ +import type { NonStaticRenderStage } from './app-render/staged-rendering' +import type { RequestStore } from './app-render/work-unit-async-storage.external' + export function isHangingPromiseRejectionError( err: unknown ): err is HangingPromiseRejectionError { @@ -73,7 +76,17 @@ export function makeHangingPromise( function ignoreReject() {} -export function makeDevtoolsIOAwarePromise(underlying: T): Promise { +export function makeDevtoolsIOAwarePromise( + underlying: T, + requestStore: RequestStore, + stage: NonStaticRenderStage +): Promise { + if (requestStore.stagedRendering) { + // We resolve each stage in a timeout, so React DevTools will pick this up as IO. + return requestStore.stagedRendering + .waitForStage(stage) + .then(() => underlying) + } // in React DevTools if we resolve in a setTimeout we will observe // the promise resolution as something that can suspend a boundary or root. return new Promise((resolve) => { diff --git a/packages/next/src/server/lib/patch-fetch.ts b/packages/next/src/server/lib/patch-fetch.ts index f9784cffe01bd9..4e381428b759a2 100644 --- a/packages/next/src/server/lib/patch-fetch.ts +++ b/packages/next/src/server/lib/patch-fetch.ts @@ -17,7 +17,6 @@ import type { FetchMetric } from '../base-http' import { createDedupeFetch } from './dedupe-fetch' import { getCacheSignal, - type RequestStore, type RevalidateStore, type WorkUnitAsyncStorage, } from '../app-render/work-unit-async-storage.external' @@ -30,7 +29,7 @@ import { } from '../response-cache' import { cloneResponse } from './clone-response' import type { IncrementalCache } from './incremental-cache' -import { InvariantError } from '../../shared/lib/invariant-error' +import { RenderStage } from '../app-render/staged-rendering' const isEdgeRuntime = process.env.NEXT_RUNTIME === 'edge' @@ -291,14 +290,6 @@ export function createPatchedFetcher( cacheSignal.beginRead() } - const isStagedRenderingInDev = !!( - process.env.NODE_ENV === 'development' && - process.env.__NEXT_CACHE_COMPONENTS && - workUnitStore && - // eslint-disable-next-line no-restricted-syntax - workUnitStore.type === 'request' - ) - const result = getTracer().trace( isInternal ? NextNodeServerSpan.internalFetch : AppRenderSpan.fetch, { @@ -566,14 +557,15 @@ export function createPatchedFetcher( case 'request': if ( process.env.NODE_ENV === 'development' && - isStagedRenderingInDev + workUnitStore.stagedRendering ) { if (cacheSignal) { cacheSignal.endRead() cacheSignal = null } - // TODO(restart-on-cache-miss): block dynamic when filling caches - await dynamicInDevStagedRendering(workUnitStore) + await workUnitStore.stagedRendering.waitForStage( + RenderStage.Dynamic + ) } break case 'prerender-ppr': @@ -691,14 +683,15 @@ export function createPatchedFetcher( case 'request': if ( process.env.NODE_ENV === 'development' && - isStagedRenderingInDev + workUnitStore.stagedRendering ) { if (cacheSignal) { cacheSignal.endRead() cacheSignal = null } - // TODO(restart-on-cache-miss): block dynamic when filling caches - await dynamicInDevStagedRendering(workUnitStore) + await workUnitStore.stagedRendering.waitForStage( + RenderStage.Dynamic + ) } break case 'prerender-ppr': @@ -877,7 +870,7 @@ export function createPatchedFetcher( case 'request': if ( process.env.NODE_ENV === 'development' && - isStagedRenderingInDev && + workUnitStore.stagedRendering && workUnitStore.cacheSignal ) { // We're filling caches for a staged render, @@ -966,9 +959,11 @@ export function createPatchedFetcher( case 'request': if ( process.env.NODE_ENV === 'development' && - isStagedRenderingInDev + workUnitStore.stagedRendering ) { - await dynamicInDevStagedRendering(workUnitStore) + await workUnitStore.stagedRendering.waitForStage( + RenderStage.Dynamic + ) } break case 'prerender-ppr': @@ -1054,7 +1049,13 @@ export function createPatchedFetcher( } if ( - (workStore.isStaticGeneration || isStagedRenderingInDev) && + (workStore.isStaticGeneration || + (process.env.NODE_ENV === 'development' && + process.env.__NEXT_CACHE_COMPONENTS && + workUnitStore && + // eslint-disable-next-line no-restricted-syntax + workUnitStore.type === 'request' && + workUnitStore.stagedRendering)) && init && typeof init === 'object' ) { @@ -1082,14 +1083,15 @@ export function createPatchedFetcher( case 'request': if ( process.env.NODE_ENV === 'development' && - isStagedRenderingInDev + workUnitStore.stagedRendering ) { if (cacheSignal) { cacheSignal.endRead() cacheSignal = null } - // TODO(restart-on-cache-miss): block dynamic when filling caches - await dynamicInDevStagedRendering(workUnitStore) + await workUnitStore.stagedRendering.waitForStage( + RenderStage.Dynamic + ) } break case 'prerender-ppr': @@ -1131,10 +1133,11 @@ export function createPatchedFetcher( case 'request': if ( process.env.NODE_ENV === 'development' && - isStagedRenderingInDev + workUnitStore.stagedRendering ) { - // TODO(restart-on-cache-miss): block dynamic when filling caches - await dynamicInDevStagedRendering(workUnitStore) + await workUnitStore.stagedRendering.waitForStage( + RenderStage.Dynamic + ) } break case 'cache': @@ -1286,29 +1289,3 @@ function getTimeoutBoundary() { } return currentTimeoutBoundary } - -async function dynamicInDevStagedRendering(requestStore: RequestStore) { - if ( - process.env.NODE_ENV === 'development' && - process.env.__NEXT_CACHE_COMPONENTS - ) { - if (requestStore.cacheSignal) { - // TODO(restart-on-cache-miss): block dynamic more effectively. - // Ideally, we'd hang here -- if the render acts as a warmup, there's no need to execute dynamic requests. - // But we can *only* hang if the render ends up being a warmup and gets discarded -- - // if it's used as is, we have to resolve the fetch eventually. - // This coordination mechanism will be implemented in a follow-up, - // for now it's enough to delay. - await getTimeoutBoundary() - await getTimeoutBoundary() - } else { - // We don't have a cacheSignal, so this is the final (restarted) render. - // Don't block, but delay to prevent this from resolving in the static stage. - await getTimeoutBoundary() - } - } else { - throw new InvariantError( - 'dynamicInDevStagedRendering should only be used in development mode and when Cache Components is enabled.' - ) - } -} diff --git a/packages/next/src/server/node-environment-extensions/utils.tsx b/packages/next/src/server/node-environment-extensions/utils.tsx index f630ad1cc98661..3b7840bd186cb2 100644 --- a/packages/next/src/server/node-environment-extensions/utils.tsx +++ b/packages/next/src/server/node-environment-extensions/utils.tsx @@ -86,7 +86,7 @@ export function io(expression: string, type: ApiType) { break } case 'request': - if (workUnitStore.prerenderPhase === true) { + if (process.env.NODE_ENV === 'development') { trackSynchronousPlatformIOAccessInDev(workUnitStore) } break diff --git a/packages/next/src/server/request/connection.ts b/packages/next/src/server/request/connection.ts index 1ef8f185e9d0c1..78920f8bcd94ba 100644 --- a/packages/next/src/server/request/connection.ts +++ b/packages/next/src/server/request/connection.ts @@ -14,6 +14,7 @@ import { makeDevtoolsIOAwarePromise, } from '../dynamic-rendering-utils' import { isRequestAPICallableInsideAfter } from './utils' +import { RenderStage } from '../app-render/staged-rendering' /** * This function allows you to indicate that you require an actual user Request before continuing. @@ -105,7 +106,11 @@ export function connection(): Promise { // Semantically we only need the dev tracking when running in `next dev` // but since you would never use next dev with production NODE_ENV we use this // as a proxy so we can statically exclude this code from production builds. - return makeDevtoolsIOAwarePromise(undefined) + return makeDevtoolsIOAwarePromise( + undefined, + workUnitStore, + RenderStage.Dynamic + ) } else { return Promise.resolve(undefined) } diff --git a/packages/next/src/server/request/cookies.ts b/packages/next/src/server/request/cookies.ts index 6b5100b4d762a4..477ac5cd050032 100644 --- a/packages/next/src/server/request/cookies.ts +++ b/packages/next/src/server/request/cookies.ts @@ -12,6 +12,7 @@ import { throwForMissingRequestStore, workUnitAsyncStorage, type PrerenderStoreModern, + type RequestStore, } from '../app-render/work-unit-async-storage.external' import { delayUntilRuntimeStage, @@ -28,6 +29,7 @@ import { createDedupedByCallsiteServerErrorLoggerDev } from '../create-deduped-b import { isRequestAPICallableInsideAfter } from './utils' import { InvariantError } from '../../shared/lib/invariant-error' import { ReflectAdapter } from '../web/spec-extension/adapters/reflect' +import { RenderStage } from '../app-render/staged-rendering' export function cookies(): Promise { const callingExpression = 'cookies' @@ -123,6 +125,7 @@ export function cookies(): Promise { // but since you would never use next dev with production NODE_ENV we use this // as a proxy so we can statically exclude this code from production builds. return makeUntrackedCookiesWithDevWarnings( + workUnitStore, underlyingCookies, workStore?.route ) @@ -183,6 +186,7 @@ function makeUntrackedCookies( } function makeUntrackedCookiesWithDevWarnings( + requestStore: RequestStore, underlyingCookies: ReadonlyRequestCookies, route?: string ): Promise { @@ -191,7 +195,11 @@ function makeUntrackedCookiesWithDevWarnings( return cachedCookies } - const promise = makeDevtoolsIOAwarePromise(underlyingCookies) + const promise = makeDevtoolsIOAwarePromise( + underlyingCookies, + requestStore, + RenderStage.Runtime + ) const proxiedPromise = new Proxy(promise, { get(target, prop, receiver) { diff --git a/packages/next/src/server/request/headers.ts b/packages/next/src/server/request/headers.ts index 48e99793ee21ce..842cd23a88b674 100644 --- a/packages/next/src/server/request/headers.ts +++ b/packages/next/src/server/request/headers.ts @@ -10,6 +10,7 @@ import { throwForMissingRequestStore, workUnitAsyncStorage, type PrerenderStoreModern, + type RequestStore, } from '../app-render/work-unit-async-storage.external' import { delayUntilRuntimeStage, @@ -26,6 +27,7 @@ import { createDedupedByCallsiteServerErrorLoggerDev } from '../create-deduped-b import { isRequestAPICallableInsideAfter } from './utils' import { InvariantError } from '../../shared/lib/invariant-error' import { ReflectAdapter } from '../web/spec-extension/adapters/reflect' +import { RenderStage } from '../app-render/staged-rendering' /** * This function allows you to read the HTTP incoming request headers in @@ -139,7 +141,8 @@ export function headers(): Promise { // as a proxy so we can statically exclude this code from production builds. return makeUntrackedHeadersWithDevWarnings( workUnitStore.headers, - workStore?.route + workStore?.route, + workUnitStore ) } else { return makeUntrackedHeaders(workUnitStore.headers) @@ -193,14 +196,19 @@ function makeUntrackedHeaders( function makeUntrackedHeadersWithDevWarnings( underlyingHeaders: ReadonlyHeaders, - route?: string + route: string | undefined, + requestStore: RequestStore ): Promise { const cachedHeaders = CachedHeaders.get(underlyingHeaders) if (cachedHeaders) { return cachedHeaders } - const promise = makeDevtoolsIOAwarePromise(underlyingHeaders) + const promise = makeDevtoolsIOAwarePromise( + underlyingHeaders, + requestStore, + RenderStage.Runtime + ) const proxiedPromise = new Proxy(promise, { get(target, prop, receiver) { diff --git a/packages/next/src/server/request/params.ts b/packages/next/src/server/request/params.ts index 2e0485b0538d4f..a66b714e97a928 100644 --- a/packages/next/src/server/request/params.ts +++ b/packages/next/src/server/request/params.ts @@ -19,6 +19,7 @@ import { type StaticPrerenderStore, throwInvariantForMissingStore, type PrerenderStoreModernRuntime, + type RequestStore, } from '../app-render/work-unit-async-storage.external' import { InvariantError } from '../../shared/lib/invariant-error' import { @@ -31,6 +32,7 @@ import { } from '../dynamic-rendering-utils' import { createDedupedByCallsiteServerErrorLoggerDev } from '../create-deduped-by-callsite-server-error-logger' import { dynamicAccessAsyncStorage } from '../app-render/dynamic-access-async-storage.external' +import { RenderStage } from '../app-render/staged-rendering' export type ParamValue = string | Array | undefined export type Params = Record @@ -70,7 +72,8 @@ export function createParamsFromClient( return createRenderParamsInDev( underlyingParams, devFallbackParams, - workStore + workStore, + workUnitStore ) } else { return createRenderParamsInProd(underlyingParams) @@ -120,7 +123,8 @@ export function createServerParamsForRoute( return createRenderParamsInDev( underlyingParams, devFallbackParams, - workStore + workStore, + workUnitStore ) } else { return createRenderParamsInProd(underlyingParams) @@ -165,7 +169,8 @@ export function createServerParamsForServerSegment( return createRenderParamsInDev( underlyingParams, devFallbackParams, - workStore + workStore, + workUnitStore ) } else { return createRenderParamsInProd(underlyingParams) @@ -298,7 +303,8 @@ function createRenderParamsInProd(underlyingParams: Params): Promise { function createRenderParamsInDev( underlyingParams: Params, devFallbackParams: OpaqueFallbackRouteParams | null | undefined, - workStore: WorkStore + workStore: WorkStore, + requestStore: RequestStore ): Promise { let hasFallbackParams = false if (devFallbackParams) { @@ -313,7 +319,8 @@ function createRenderParamsInDev( return makeDynamicallyTrackedParamsWithDevWarnings( underlyingParams, hasFallbackParams, - workStore + workStore, + requestStore ) } @@ -445,7 +452,8 @@ function makeUntrackedParams(underlyingParams: Params): Promise { function makeDynamicallyTrackedParamsWithDevWarnings( underlyingParams: Params, hasFallbackParams: boolean, - store: WorkStore + workStore: WorkStore, + requestStore: RequestStore ): Promise { const cachedParams = CachedParams.get(underlyingParams) if (cachedParams) { @@ -456,7 +464,11 @@ function makeDynamicallyTrackedParamsWithDevWarnings( // supports copying with spread and we don't want to unnecessarily // instrument the promise with spreadable properties of ReactPromise. const promise = hasFallbackParams - ? makeDevtoolsIOAwarePromise(underlyingParams) + ? makeDevtoolsIOAwarePromise( + underlyingParams, + requestStore, + RenderStage.Runtime + ) : // We don't want to force an environment transition when this params is not part of the fallback params set Promise.resolve(underlyingParams) @@ -480,7 +492,7 @@ function makeDynamicallyTrackedParamsWithDevWarnings( proxiedProperties.has(prop) ) { const expression = describeStringPropertyAccess('params', prop) - warnForSyncAccess(store.route, expression) + warnForSyncAccess(workStore.route, expression) } } return ReflectAdapter.get(target, prop, receiver) @@ -493,7 +505,7 @@ function makeDynamicallyTrackedParamsWithDevWarnings( }, ownKeys(target) { const expression = '`...params` or similar expression' - warnForSyncAccess(store.route, expression) + warnForSyncAccess(workStore.route, expression) return Reflect.ownKeys(target) }, }) diff --git a/packages/next/src/server/request/search-params.ts b/packages/next/src/server/request/search-params.ts index 65718648d86e7d..eb6a863c0d1946 100644 --- a/packages/next/src/server/request/search-params.ts +++ b/packages/next/src/server/request/search-params.ts @@ -16,6 +16,7 @@ import { type PrerenderStoreModernRuntime, type StaticPrerenderStore, throwInvariantForMissingStore, + type RequestStore, } from '../app-render/work-unit-async-storage.external' import { InvariantError } from '../../shared/lib/invariant-error' import { @@ -32,6 +33,7 @@ import { throwWithStaticGenerationBailoutErrorWithDynamicError, throwForSearchParamsAccessInUseCache, } from './utils' +import { RenderStage } from '../app-render/staged-rendering' export type SearchParams = { [key: string]: string | string[] | undefined } @@ -58,7 +60,11 @@ export function createSearchParamsFromClient( 'createSearchParamsFromClient should not be called in cache contexts.' ) case 'request': - return createRenderSearchParams(underlyingSearchParams, workStore) + return createRenderSearchParams( + underlyingSearchParams, + workStore, + workUnitStore + ) default: workUnitStore satisfies never } @@ -94,7 +100,11 @@ export function createServerSearchParamsForServerPage( workUnitStore ) case 'request': - return createRenderSearchParams(underlyingSearchParams, workStore) + return createRenderSearchParams( + underlyingSearchParams, + workStore, + workUnitStore + ) default: workUnitStore satisfies never } @@ -181,7 +191,8 @@ function createRuntimePrerenderSearchParams( function createRenderSearchParams( underlyingSearchParams: SearchParams, - workStore: WorkStore + workStore: WorkStore, + requestStore: RequestStore ): Promise { if (workStore.forceStatic) { // When using forceStatic we override all other logic and always just return an empty @@ -194,7 +205,8 @@ function createRenderSearchParams( // as a proxy so we can statically exclude this code from production builds. return makeUntrackedSearchParamsWithDevWarnings( underlyingSearchParams, - workStore + workStore, + requestStore ) } else { return makeUntrackedSearchParams(underlyingSearchParams) @@ -371,9 +383,10 @@ function makeUntrackedSearchParams( function makeUntrackedSearchParamsWithDevWarnings( underlyingSearchParams: SearchParams, - store: WorkStore + workStore: WorkStore, + requestStore: RequestStore ): Promise { - const cachedSearchParams = CachedSearchParams.get(underlyingSearchParams) + const cachedSearchParams = CachedSearchParams.get(requestStore) if (cachedSearchParams) { return cachedSearchParams } @@ -391,10 +404,10 @@ function makeUntrackedSearchParamsWithDevWarnings( const proxiedUnderlying = new Proxy(underlyingSearchParams, { get(target, prop, receiver) { if (typeof prop === 'string' && promiseInitialized) { - if (store.dynamicShouldError) { + if (workStore.dynamicShouldError) { const expression = describeStringPropertyAccess('searchParams', prop) throwWithStaticGenerationBailoutErrorWithDynamicError( - store.route, + workStore.route, expression ) } @@ -403,13 +416,13 @@ function makeUntrackedSearchParamsWithDevWarnings( }, has(target, prop) { if (typeof prop === 'string') { - if (store.dynamicShouldError) { + if (workStore.dynamicShouldError) { const expression = describeHasCheckingStringProperty( 'searchParams', prop ) throwWithStaticGenerationBailoutErrorWithDynamicError( - store.route, + workStore.route, expression ) } @@ -417,11 +430,11 @@ function makeUntrackedSearchParamsWithDevWarnings( return Reflect.has(target, prop) }, ownKeys(target) { - if (store.dynamicShouldError) { + if (workStore.dynamicShouldError) { const expression = '`{...searchParams}`, `Object.keys(searchParams)`, or similar' throwWithStaticGenerationBailoutErrorWithDynamicError( - store.route, + workStore.route, expression ) } @@ -432,7 +445,11 @@ function makeUntrackedSearchParamsWithDevWarnings( // We don't use makeResolvedReactPromise here because searchParams // supports copying with spread and we don't want to unnecessarily // instrument the promise with spreadable properties of ReactPromise. - const promise = makeDevtoolsIOAwarePromise(proxiedUnderlying) + const promise = makeDevtoolsIOAwarePromise( + proxiedUnderlying, + requestStore, + RenderStage.Runtime + ) promise.then(() => { promiseInitialized = true }) @@ -448,10 +465,10 @@ function makeUntrackedSearchParamsWithDevWarnings( const proxiedPromise = new Proxy(promise, { get(target, prop, receiver) { - if (prop === 'then' && store.dynamicShouldError) { + if (prop === 'then' && workStore.dynamicShouldError) { const expression = '`searchParams.then`' throwWithStaticGenerationBailoutErrorWithDynamicError( - store.route, + workStore.route, expression ) } @@ -464,7 +481,7 @@ function makeUntrackedSearchParamsWithDevWarnings( Reflect.has(target, prop) === false) ) { const expression = describeStringPropertyAccess('searchParams', prop) - warnForSyncAccess(store.route, expression) + warnForSyncAccess(workStore.route, expression) } } return ReflectAdapter.get(target, prop, receiver) @@ -488,19 +505,19 @@ function makeUntrackedSearchParamsWithDevWarnings( 'searchParams', prop ) - warnForSyncAccess(store.route, expression) + warnForSyncAccess(workStore.route, expression) } } return Reflect.has(target, prop) }, ownKeys(target) { const expression = '`Object.keys(searchParams)` or similar' - warnForSyncAccess(store.route, expression) + warnForSyncAccess(workStore.route, expression) return Reflect.ownKeys(target) }, }) - CachedSearchParams.set(underlyingSearchParams, proxiedPromise) + CachedSearchParams.set(requestStore, proxiedPromise) return proxiedPromise } 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 d01ba9e31ab79f..a284ef44b9f139 100644 --- a/packages/next/src/server/use-cache/use-cache-wrapper.ts +++ b/packages/next/src/server/use-cache/use-cache-wrapper.ts @@ -71,6 +71,7 @@ import { createLazyResult, isResolvedLazyResult } from '../lib/lazy-result' import { dynamicAccessAsyncStorage } from '../app-render/dynamic-access-async-storage.external' import { isReactLargeShellError } from '../app-render/react-large-shell-error' import type { CacheLife } from './cache-life' +import { RenderStage } from '../app-render/staged-rendering' interface PrivateCacheContext { readonly kind: 'private' @@ -1016,7 +1017,11 @@ export function cache( // of a dev request, so we delay them. // When we implement the 3-task render, this will change to match the codepath above. // (to resolve them in the runtime stage, and not later) - await makeDevtoolsIOAwarePromise(undefined) + await makeDevtoolsIOAwarePromise( + undefined, + outerWorkUnitStore, + RenderStage.Runtime + ) } break } @@ -1276,7 +1281,11 @@ export function cache( // TODO(restart-on-cache-miss): Optimize this to avoid unnecessary restarts. // We don't end the cache read here, so this will always appear as a cache miss in the static stage, // and thus will cause a restart even if all caches are filled. - await makeDevtoolsIOAwarePromise(undefined) + await makeDevtoolsIOAwarePromise( + undefined, + workUnitStore, + RenderStage.Runtime + ) } break } @@ -1473,7 +1482,11 @@ export function cache( // TODO(restart-on-cache-miss): Optimize this to avoid unnecessary restarts. // We don't end the cache read here, so this will always appear as a cache miss in the static stage, // and thus will cause a restart even if all caches are filled. - await makeDevtoolsIOAwarePromise(undefined) + await makeDevtoolsIOAwarePromise( + undefined, + workUnitStore, + RenderStage.Runtime + ) } break } diff --git a/test/development/app-dir/cache-components-dev-warmup/app/apis/[param]/page.tsx b/test/development/app-dir/cache-components-dev-warmup/app/apis/[param]/page.tsx index 676f45a73340d0..f76bc35ce2b678 100644 --- a/test/development/app-dir/cache-components-dev-warmup/app/apis/[param]/page.tsx +++ b/test/development/app-dir/cache-components-dev-warmup/app/apis/[param]/page.tsx @@ -34,7 +34,7 @@ export default function Page({ params, searchParams }) { function LogAfter({ label, api }: { label: string; api: () => Promise }) { return ( - + Waiting for {label}...}> ) @@ -49,5 +49,5 @@ async function LogAfterInner({ }) { await api() console.log(`after ${label}`) - return null + return
Finished {label}
} diff --git a/test/development/app-dir/cache-components-dev-warmup/cache-components.dev-warmup.test.ts b/test/development/app-dir/cache-components-dev-warmup/cache-components.dev-warmup.test.ts index 1de489beb3bb34..c4b21c8c9416ae 100644 --- a/test/development/app-dir/cache-components-dev-warmup/cache-components.dev-warmup.test.ts +++ b/test/development/app-dir/cache-components-dev-warmup/cache-components.dev-warmup.test.ts @@ -12,7 +12,7 @@ describe('cache-components-dev-warmup', () => { ) { // Match logs that contain the message, with any environment. const logPattern = new RegExp( - `^(?=.*\\b${message}\\b)(?=.*\\b(Cache|Prerender|Server)\\b).*` + `^(?=.*\\b${message}\\b)(?=.*\\b(Cache|Prerender|Prefetch|Server)\\b).*` ) const logMessages = logs.map((log) => log.message) const messages = logMessages.filter((message) => logPattern.test(message)) @@ -81,8 +81,9 @@ describe('cache-components-dev-warmup', () => { // Private caches are dynamic holes in static prerenders, // so they shouldn't resolve in the static stage. - assertLog(logs, 'after private cache read - page', 'Server') // TODO: 'Runtime Prerender' - assertLog(logs, 'after private cache read - layout', 'Server') // TODO: 'Runtime Prerender' + // TODO: we should only label this as "Prefetch" if there's a prefetch config. + assertLog(logs, 'after private cache read - page', 'Prefetch') + assertLog(logs, 'after private cache read - layout', 'Prefetch') assertLog(logs, 'after uncached fetch - layout', 'Server') assertLog(logs, 'after uncached fetch - page', 'Server') @@ -116,8 +117,11 @@ describe('cache-components-dev-warmup', () => { // Short lived caches are dynamic holes in static prerenders, // so they shouldn't resolve in the static stage. - assertLog(logs, 'after short-lived cache read - page', 'Server') - assertLog(logs, 'after short-lived cache read - layout', 'Server') + // TODO: we should only label this as "Prefetch" if there's a prefetch config. + assertLog(logs, 'after short-lived cache read - page', 'Prefetch') + assertLog(logs, 'after short-lived cache read - layout', 'Prefetch') + // assertLog(logs, 'after short-lived cache read - page', 'Server') + // assertLog(logs, 'after short-lived cache read - layout', 'Server') assertLog(logs, 'after uncached fetch - layout', 'Server') assertLog(logs, 'after uncached fetch - page', 'Server') @@ -148,18 +152,13 @@ describe('cache-components-dev-warmup', () => { const logs = await browser.log() assertLog(logs, 'after cache read - page', 'Prerender') - for (const apiName of [ - 'cookies', - 'headers', - // TODO(restart-on-cache-miss): these two are currently broken/flaky, - // because they're created outside of render and can resolve too early. - // This will be fixed in a follow-up. - // 'params', - // 'searchParams', - 'connection', - ]) { - assertLog(logs, `after ${apiName}`, 'Server') - } + // TODO: we should only label this as "Prefetch" if there's a prefetch config. + assertLog(logs, `after cookies`, 'Prefetch') + assertLog(logs, `after headers`, 'Prefetch') + assertLog(logs, `after params`, 'Prefetch') + assertLog(logs, `after searchParams`, 'Prefetch') + + assertLog(logs, 'after connection', 'Server') } // Initial load.