diff --git a/packages/next/src/server/after/after-context.test.ts b/packages/next/src/server/after/after-context.test.ts index 0f0eaee8c0a98..acef5a48822fa 100644 --- a/packages/next/src/server/after/after-context.test.ts +++ b/packages/next/src/server/after/after-context.test.ts @@ -2,16 +2,19 @@ import { DetachedPromise } from '../../lib/detached-promise' import { AsyncLocalStorage } from 'async_hooks' import type { WorkStore } from '../app-render/work-async-storage.external' +import type { WorkUnitStore } from '../app-render/work-unit-async-storage.external' import type { AfterContext } from './after-context' describe('AfterContext', () => { // 'async-local-storage.ts' needs `AsyncLocalStorage` on `globalThis` at import time, // so we have to do some contortions here to set it up before running anything else type WASMod = typeof import('../app-render/work-async-storage.external') + type WSMod = typeof import('../app-render/work-unit-async-storage.external') type AfterMod = typeof import('./after') type AfterContextMod = typeof import('./after-context') let workAsyncStorage: WASMod['workAsyncStorage'] + let workUnitAsyncStorage: WSMod['workUnitAsyncStorage'] let AfterContext: AfterContextMod['AfterContext'] let after: AfterMod['unstable_after'] @@ -22,6 +25,9 @@ describe('AfterContext', () => { const WASMod = await import('../app-render/work-async-storage.external') workAsyncStorage = WASMod.workAsyncStorage + const WSMod = await import('../app-render/work-unit-async-storage.external') + workUnitAsyncStorage = WSMod.workUnitAsyncStorage + const AfterContextMod = await import('./after-context') AfterContext = AfterContextMod.AfterContext @@ -32,7 +38,9 @@ describe('AfterContext', () => { const createRun = (_afterContext: AfterContext, workStore: WorkStore) => (cb: () => T): T => { - return workAsyncStorage.run(workStore, cb) + return workAsyncStorage.run(workStore, () => + workUnitAsyncStorage.run(createMockWorkUnitStore(), cb) + ) } it('runs after() callbacks from a run() callback that resolves', async () => { @@ -362,11 +370,13 @@ describe('AfterContext', () => { const promise3 = new DetachedPromise() const afterCallback3 = jest.fn(() => promise3.promise) - workAsyncStorage.run(workStore, () => { - after(afterCallback1) - after(afterCallback2) - after(afterCallback3) - }) + workAsyncStorage.run(workStore, () => + workUnitAsyncStorage.run(createMockWorkUnitStore(), () => { + after(afterCallback1) + after(afterCallback2) + after(afterCallback3) + }) + ) expect(afterCallback1).not.toHaveBeenCalled() expect(afterCallback2).not.toHaveBeenCalled() @@ -557,3 +567,7 @@ const createMockWorkStore = (afterContext: AfterContext): WorkStore => { }, }) } + +const createMockWorkUnitStore = () => { + return { phase: 'render' } as WorkUnitStore +} diff --git a/packages/next/src/server/after/after-context.ts b/packages/next/src/server/after/after-context.ts index b908fd9e89676..4bc6cdee2bb5f 100644 --- a/packages/next/src/server/after/after-context.ts +++ b/packages/next/src/server/after/after-context.ts @@ -6,6 +6,10 @@ import { isThenable } from '../../shared/lib/is-thenable' import { workAsyncStorage } from '../app-render/work-async-storage.external' import { withExecuteRevalidates } from './revalidation-utils' import { bindSnapshot } from '../app-render/async-local-storage' +import { + workUnitAsyncStorage, + type WorkUnitStore, +} from '../app-render/work-unit-async-storage.external' export type AfterContextOpts = { waitUntil: RequestLifecycleOpts['waitUntil'] | undefined @@ -18,6 +22,7 @@ export class AfterContext { private runCallbacksOnClosePromise: Promise | undefined private callbackQueue: PromiseQueue + private workUnitStores = new Set() constructor({ waitUntil, onClose }: AfterContextOpts) { this.waitUntil = waitUntil @@ -55,6 +60,14 @@ export class AfterContext { ) } + const workUnitStore = workUnitAsyncStorage.getStore() + if (!workUnitStore) { + throw new InvariantError( + 'Missing workUnitStore in AfterContext.addCallback' + ) + } + this.workUnitStores.add(workUnitStore) + // this should only happen once. if (!this.runCallbacksOnClosePromise) { this.runCallbacksOnClosePromise = this.runCallbacksOnClose() @@ -89,9 +102,15 @@ export class AfterContext { private async runCallbacks(): Promise { if (this.callbackQueue.size === 0) return + for (const workUnitStore of this.workUnitStores) { + workUnitStore.phase = 'after' + } + const workStore = workAsyncStorage.getStore() + if (!workStore) { + throw new InvariantError('Missing workStore in AfterContext.runCallbacks') + } - // TODO(after): Change phase in workUnitStore to disable e.g. `cookies().set()` return withExecuteRevalidates(workStore, () => { this.callbackQueue.start() return this.callbackQueue.onIdle() diff --git a/packages/next/src/server/after/after.ts b/packages/next/src/server/after/after.ts index 38381dd96b6a8..85eaab71e9371 100644 --- a/packages/next/src/server/after/after.ts +++ b/packages/next/src/server/after/after.ts @@ -14,26 +14,29 @@ export function unstable_after(task: AfterTask): void { const workStore = workAsyncStorage.getStore() const workUnitStore = workUnitAsyncStorage.getStore() - if (workStore) { - const { afterContext } = workStore - if (!afterContext) { - throw new Error( - '`unstable_after()` must be explicitly enabled by setting `experimental.after: true` in your next.config.js.' - ) - } + if (!workStore) { + // TODO(after): the linked docs page talks about *dynamic* APIs, which unstable_after soon won't be anymore + throw new Error( + '`unstable_after` was called outside a request scope. Read more: https://nextjs.org/docs/messages/next-dynamic-api-wrong-context' + ) + } - // TODO: After should not cause dynamic. - const callingExpression = 'unstable_after' - if (workStore.forceStatic) { - throw new StaticGenBailoutError( - `Route ${workStore.route} with \`dynamic = "force-static"\` couldn't be rendered statically because it used \`${callingExpression}\`. See more info here: https://nextjs.org/docs/app/building-your-application/rendering/static-and-dynamic#dynamic-rendering` - ) - } else { - markCurrentScopeAsDynamic(workStore, workUnitStore, callingExpression) - } + const { afterContext } = workStore + if (!afterContext) { + throw new Error( + '`unstable_after` must be explicitly enabled by setting `experimental.after: true` in your next.config.js.' + ) + } - afterContext.after(task) + // TODO: After should not cause dynamic. + const callingExpression = 'unstable_after' + if (workStore.forceStatic) { + throw new StaticGenBailoutError( + `Route ${workStore.route} with \`dynamic = "force-static"\` couldn't be rendered statically because it used \`${callingExpression}\`. See more info here: https://nextjs.org/docs/app/building-your-application/rendering/static-and-dynamic#dynamic-rendering` + ) } else { - // TODO: Error for pages? + markCurrentScopeAsDynamic(workStore, workUnitStore, callingExpression) } + + afterContext.after(task) } diff --git a/packages/next/src/server/app-render/action-handler.ts b/packages/next/src/server/app-render/action-handler.ts index 975cf777f225d..b551145b90bd8 100644 --- a/packages/next/src/server/app-render/action-handler.ts +++ b/packages/next/src/server/app-render/action-handler.ts @@ -459,6 +459,8 @@ export async function handleAction({ ) } + requestStore.phase = 'action' + // When running actions the default is no-store, you can still `cache: 'force-cache'` workStore.fetchCache = 'default-no-store' diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index 87c95c60cad2b..62523bb83a454 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -375,6 +375,7 @@ async function generateDynamicRSCPayload( skipFlight: boolean } ): Promise { + ctx.requestStore.phase = 'render' // Flight data that is going to be passed to the browser. // Currently a single item array but in the future multiple patches might be combined in a single request. @@ -1283,6 +1284,7 @@ export const renderToHTMLOrFlight: AppPageRender = ( req, url, res, + phase: 'render', renderOpts, isHmrRefresh, serverComponentsHmrCache, @@ -1722,6 +1724,8 @@ async function prerenderToStream( workStore: WorkStore, tree: LoaderTree ): Promise { + ctx.requestStore.phase = 'render' + // When prerendering formState is always null. We still include it // because some shared APIs expect a formState value and this is slightly // more explicit than making it an optional function argument @@ -1843,6 +1847,7 @@ async function prerenderToStream( const prospectiveRenderPrerenderStore: PrerenderStore = (prerenderStore = { type: 'prerender', + phase: 'render', implicitTags: ctx.requestStore.implicitTags, renderSignal: flightController.signal, cacheSignal, @@ -1936,6 +1941,7 @@ async function prerenderToStream( const finalRenderPrerenderStore: PrerenderStore = (prerenderStore = { type: 'prerender', + phase: 'render', implicitTags: ctx.requestStore.implicitTags, renderSignal: flightController.signal, // During the final prerender we don't need to track cache access so we omit the signal @@ -1995,6 +2001,7 @@ async function prerenderToStream( const SSRController = new AbortController() const ssrPrerenderStore: PrerenderStore = { type: 'prerender', + phase: 'render', implicitTags: ctx.requestStore.implicitTags, renderSignal: SSRController.signal, // For HTML Generation we don't need to track cache reads (RSC only) @@ -2210,6 +2217,7 @@ async function prerenderToStream( const prospectiveRenderPrerenderStore: PrerenderStore = (prerenderStore = { type: 'prerender', + phase: 'render', implicitTags: ctx.requestStore.implicitTags, renderSignal: flightController.signal, cacheSignal, @@ -2292,6 +2300,7 @@ async function prerenderToStream( const finalRenderPrerenderStore: PrerenderStore = (prerenderStore = { type: 'prerender', + phase: 'render', implicitTags: ctx.requestStore.implicitTags, renderSignal: flightController.signal, // During the final prerender we don't need to track cache access so we omit the signal @@ -2305,6 +2314,7 @@ async function prerenderToStream( const SSRController = new AbortController() const ssrPrerenderStore: PrerenderStore = { type: 'prerender', + phase: 'render', implicitTags: ctx.requestStore.implicitTags, renderSignal: SSRController.signal, // For HTML Generation we don't need to track cache reads (RSC only) @@ -2492,6 +2502,7 @@ async function prerenderToStream( ) const reactServerPrerenderStore: PrerenderStore = (prerenderStore = { type: 'prerender-ppr', + phase: 'render', implicitTags: ctx.requestStore.implicitTags, dynamicTracking, revalidate: INFINITE_CACHE, @@ -2520,6 +2531,7 @@ async function prerenderToStream( const ssrPrerenderStore: PrerenderStore = { type: 'prerender-ppr', + phase: 'render', implicitTags: ctx.requestStore.implicitTags, dynamicTracking, revalidate: INFINITE_CACHE, @@ -2686,6 +2698,7 @@ async function prerenderToStream( } else { const prerenderLegacyStore: PrerenderStore = (prerenderStore = { type: 'prerender-legacy', + phase: 'render', implicitTags: ctx.requestStore.implicitTags, revalidate: INFINITE_CACHE, tags: [...ctx.requestStore.implicitTags], @@ -2842,6 +2855,7 @@ async function prerenderToStream( const prerenderLegacyStore: PrerenderStore = (prerenderStore = { type: 'prerender-legacy', + phase: 'render', implicitTags: ctx.requestStore.implicitTags, revalidate: INFINITE_CACHE, tags: [...ctx.requestStore.implicitTags], 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 28a49666b5b31..9b7e93525ffdb 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 @@ -10,6 +10,13 @@ import type { DynamicTrackingState } from './dynamic-rendering' import { workUnitAsyncStorage } from './work-unit-async-storage-instance' with { 'turbopack-transition': 'next-shared' } import type { ServerComponentsHmrCache } from '../response-cache' +type WorkUnitPhase = 'action' | 'render' | 'after' + +type PhasePartial = { + /** NOTE: Will be mutated as phases change */ + phase: WorkUnitPhase +} + export type RequestStore = { type: 'request' @@ -41,7 +48,7 @@ export type RequestStore = { // DEV-only usedDynamic?: boolean -} +} & PhasePartial /** * The Prerender store is for tracking information related to prerenders. @@ -81,7 +88,7 @@ export type PrerenderStoreModern = { // Collected revalidate times and tags for this document during the prerender. revalidate: number // in seconds. 0 means dynamic. INFINITE_CACHE and higher means never revalidate. tags: null | string[] -} +} & PhasePartial export type PrerenderStorePPR = { type: 'prerender-ppr' @@ -90,7 +97,7 @@ export type PrerenderStorePPR = { // Collected revalidate times and tags for this document during the prerender. revalidate: number // in seconds. 0 means dynamic. INFINITE_CACHE and higher means never revalidate. tags: null | string[] -} +} & PhasePartial export type PrerenderStoreLegacy = { type: 'prerender-legacy' @@ -98,7 +105,7 @@ export type PrerenderStoreLegacy = { // Collected revalidate times and tags for this document during the prerender. revalidate: number // in seconds. 0 means dynamic. INFINITE_CACHE and higher means never revalidate. tags: null | string[] -} +} & PhasePartial export type PrerenderStore = | PrerenderStoreLegacy @@ -112,11 +119,11 @@ export type UseCacheStore = { revalidate: number // implicit revalidate time from inner caches / fetches explicitRevalidate: undefined | number // explicit revalidate time from cacheLife() calls tags: null | string[] -} +} & PhasePartial export type UnstableCacheStore = { type: 'unstable-cache' -} +} & PhasePartial /** * The Cache store is for tracking information inside a "use cache" or unstable_cache context. diff --git a/packages/next/src/server/async-storage/with-request-store.ts b/packages/next/src/server/async-storage/with-request-store.ts index 2433132d39fb2..81d3df310ae2a 100644 --- a/packages/next/src/server/async-storage/with-request-store.ts +++ b/packages/next/src/server/async-storage/with-request-store.ts @@ -64,6 +64,7 @@ export type RequestContext = RequestResponsePair & { */ search?: string } + phase: RequestStore['phase'] renderOpts?: WrapperRenderOpts isHmrRefresh?: boolean serverComponentsHmrCache?: ServerComponentsHmrCache @@ -111,6 +112,7 @@ export const withRequestStore: WithStore = < req, url, res, + phase, renderOpts, isHmrRefresh, serverComponentsHmrCache, @@ -133,6 +135,7 @@ export const withRequestStore: WithStore = < const store: RequestStore = { type: 'request', + phase, implicitTags: implicitTags ?? [], // Rather than just using the whole `url` here, we pull the parts we want // to ensure we don't use parts of the URL that we shouldn't. This also diff --git a/packages/next/src/server/route-modules/app-route/module.ts b/packages/next/src/server/route-modules/app-route/module.ts index 875b14cdaebdf..0709a774b3aef 100644 --- a/packages/next/src/server/route-modules/app-route/module.ts +++ b/packages/next/src/server/route-modules/app-route/module.ts @@ -343,6 +343,7 @@ export class AppRouteRouteModule extends RouteModule< const prospectiveRoutePrerenderStore: PrerenderStore = (prerenderStore = { type: 'prerender', + phase: 'action', implicitTags: implicitTags, renderSignal: prospectiveController.signal, cacheSignal, @@ -415,6 +416,7 @@ export class AppRouteRouteModule extends RouteModule< const finalRoutePrerenderStore: PrerenderStore = (prerenderStore = { type: 'prerender', + phase: 'action', implicitTags: implicitTags, renderSignal: finalController.signal, cacheSignal: null, @@ -490,6 +492,7 @@ export class AppRouteRouteModule extends RouteModule< } else { prerenderStore = { type: 'prerender-legacy', + phase: 'action', implicitTags: implicitTags, revalidate: defaultRevalidate, tags: [...implicitTags], @@ -599,6 +602,7 @@ export class AppRouteRouteModule extends RouteModule< req, res: undefined, url: req.nextUrl, + phase: 'action', renderOpts: { previewProps: context.prerenderManifest.preview, }, 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 5a32a65fc02d7..3385efd528262 100644 --- a/packages/next/src/server/use-cache/use-cache-wrapper.ts +++ b/packages/next/src/server/use-cache/use-cache-wrapper.ts @@ -154,6 +154,7 @@ function generateCacheEntryWithCacheContext( // Initialize the Store for this Cache entry. const cacheStore: UseCacheStore = { type: 'cache', + phase: 'render', implicitTags: outerWorkUnitStore === undefined || outerWorkUnitStore.type === 'unstable-cache' diff --git a/packages/next/src/server/web/adapter.ts b/packages/next/src/server/web/adapter.ts index a8704eec22b76..4fe1aa56bfa68 100644 --- a/packages/next/src/server/web/adapter.ts +++ b/packages/next/src/server/web/adapter.ts @@ -267,6 +267,7 @@ export async function adapter( { req: request, res: undefined, + phase: 'action', url: request.nextUrl, renderOpts: { onUpdateCookies: (cookies) => { diff --git a/packages/next/src/server/web/spec-extension/unstable-cache.ts b/packages/next/src/server/web/spec-extension/unstable-cache.ts index 1204ad04a2297..64a291f17b7fe 100644 --- a/packages/next/src/server/web/spec-extension/unstable-cache.ts +++ b/packages/next/src/server/web/spec-extension/unstable-cache.ts @@ -236,6 +236,7 @@ export function unstable_cache( } const innerCacheStore: UnstableCacheStore = { type: 'unstable-cache', + phase: 'render', } // We run the cache function asynchronously and save the result when it completes workStore.pendingRevalidates[invocationKey] = @@ -268,6 +269,7 @@ export function unstable_cache( const innerCacheStore: UnstableCacheStore = { type: 'unstable-cache', + phase: 'render', } // If we got this far then we had an invalid cache entry and need to generate a new one const result = await workUnitAsyncStorage.run( @@ -334,6 +336,7 @@ export function unstable_cache( const innerCacheStore: UnstableCacheStore = { type: 'unstable-cache', + phase: 'render', } // If we got this far then we had an invalid cache entry and need to generate a new one const result = await workUnitAsyncStorage.run(