From 29241069a4734e8f0e854fb71e4037b701c2f8b0 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sun, 23 Aug 2026 23:27:56 +1000 Subject: [PATCH] fix(app-router): keep dynamic usage visible across nested request scopes `runWithUnifiedStateMutation()` shallow-clones the unified request context, so `dynamicUsageDetected` forked per nested scope. Cacheability is decided once per request, and nested scopes wrap lazily consumed SSR/RSC streams, so a `cookies()`, `headers()`, or `noStore()` call that lands after the scope callback settles was invisible to `consumeDynamicUsage()`. Alias the flag to the parent context instead of copying it. Scopes that deliberately measure a subtree now opt out through `isolateDynamicUsage()`. --- .../server/app-layout-param-observation.ts | 3 +- packages/vinext/src/shims/headers.ts | 5 ++- .../src/shims/unified-request-context.ts | 38 ++++++++++++++++++ tests/root-params.test.ts | 22 ++++++++++ tests/unified-request-context.test.ts | 40 +++++++++++++++++++ 5 files changed, 105 insertions(+), 3 deletions(-) diff --git a/packages/vinext/src/server/app-layout-param-observation.ts b/packages/vinext/src/server/app-layout-param-observation.ts index b666fc06e4..951ecd9197 100644 --- a/packages/vinext/src/server/app-layout-param-observation.ts +++ b/packages/vinext/src/server/app-layout-param-observation.ts @@ -12,6 +12,7 @@ import { import { peekDynamicUsage, peekRenderRequestApiUsage } from "vinext/shims/headers"; import { isInsideUnifiedScope, + isolateDynamicUsage, runWithUnifiedStateMutation, } from "vinext/shims/unified-request-context"; import type { RenderRequestApiKind } from "./cache-proof.js"; @@ -187,7 +188,7 @@ export function createAppLayoutParamAccessTracker(): AppLayoutParamAccessTracker ctx.currentRequestTags = []; ctx.currentFetchSoftTags = []; ctx.dynamicFetchUrls = new Set(); - ctx.dynamicUsageDetected = false; + isolateDynamicUsage(ctx); ctx.renderRequestApiUsage = new Set(); ctx.requestScopedCacheLife = null; ctx.unstableCacheObservations = new Map(); diff --git a/packages/vinext/src/shims/headers.ts b/packages/vinext/src/shims/headers.ts index 67c99110a3..97c56987f2 100644 --- a/packages/vinext/src/shims/headers.ts +++ b/packages/vinext/src/shims/headers.ts @@ -21,6 +21,7 @@ import { parseEdgeRequestCookieHeader } from "../utils/parse-cookie.js"; import { isInsideUnifiedScope, getRequestContext, + isolateDynamicUsage, runWithUnifiedStateMutation, } from "./unified-request-context.js"; import { createPprFallbackShellSuspensePromise } from "./ppr-fallback-shell.js"; @@ -248,7 +249,7 @@ export async function runWithIsolatedDynamicUsage( let childState: VinextHeadersShimState | null = null; return await runWithUnifiedStateMutation( (context) => { - context.dynamicUsageDetected = false; + isolateDynamicUsage(context); childState = context; }, () => { @@ -594,7 +595,7 @@ export function runWithHeadersContext( if (isInsideUnifiedScope()) { return runWithUnifiedStateMutation((uCtx) => { uCtx.headersContext = ctx; - uCtx.dynamicUsageDetected = false; + isolateDynamicUsage(uCtx); uCtx.renderRequestApiUsage = new Set(); uCtx.connectionProbe = null; uCtx.pendingSetCookies = []; diff --git a/packages/vinext/src/shims/unified-request-context.ts b/packages/vinext/src/shims/unified-request-context.ts index 52a950a55e..92e7113a75 100644 --- a/packages/vinext/src/shims/unified-request-context.ts +++ b/packages/vinext/src/shims/unified-request-context.ts @@ -351,6 +351,40 @@ export function runWithRequestContext( return _als.run(ctx, fn); } +/** + * `dynamicUsageDetected` is request-scoped, not scope-scoped: a nested scope's + * lazily consumed stream can call cookies()/headers()/noStore() long after its + * callback settles, so the child aliases the parent's flag instead of copying + * it. Scopes that measure a subtree opt out with `isolateDynamicUsage()`. + */ +function aliasDynamicUsageToParent( + childCtx: UnifiedRequestContext, + parentCtx: UnifiedRequestContext, +): void { + Object.defineProperty(childCtx, "dynamicUsageDetected", { + configurable: true, + enumerable: true, + get: () => parentCtx.dynamicUsageDetected, + set: (value: boolean) => { + parentCtx.dynamicUsageDetected = value; + }, + }); +} + +/** + * Give a nested scope its own `dynamicUsageDetected` flag, hidden from the + * request. Only for scopes that measure a subtree and report the result + * themselves; every other scope must let dynamic usage reach the request. + */ +export function isolateDynamicUsage(ctx: UnifiedRequestContext): void { + Object.defineProperty(ctx, "dynamicUsageDetected", { + configurable: true, + enumerable: true, + writable: true, + value: false, + }); +} + /** * Run `fn` in a nested unified scope derived from the current request context. * Used by legacy runWith* wrappers to reset or override one sub-state while @@ -393,6 +427,10 @@ export function runWithUnifiedStateMutation( // observe those changes too. Keep this enumeration in sync with // UnifiedRequestContext: when adding a new reference-typed field, add it // here too and verify callers still follow the replace-not-mutate rule. + // + // `dynamicUsageDetected` is the deliberate exception to copy-by-value: it is + // aliased to the parent so cacheability stays a property of the request. + aliasDynamicUsageToParent(childCtx, parentCtx); mutate(childCtx); return _als.run(childCtx, fn); } diff --git a/tests/root-params.test.ts b/tests/root-params.test.ts index 005c94509f..1d0b3cb0d9 100644 --- a/tests/root-params.test.ts +++ b/tests/root-params.test.ts @@ -9,6 +9,7 @@ import { runWithRequestContext, createRequestContext, } from "../packages/vinext/src/shims/unified-request-context.js"; +import { consumeDynamicUsage, markDynamicUsage } from "../packages/vinext/src/shims/headers.js"; import { runWithNavigationContext } from "../packages/vinext/src/shims/navigation-state.js"; import { getNavigationContext, @@ -167,6 +168,27 @@ describe("next/root-params shim", () => { }); }); + it("keeps dynamic usage visible to the request after the scope returns", async () => { + // handleSsr() renders inside this scope, but the HTML/RSC stream it returns + // is consumed later — cookies()/headers() can still fire from that work. + await runWithRequestContext(createRequestContext(), async () => { + let releaseStreamedWork!: () => void; + const streamedWork = new Promise((resolve) => { + releaseStreamedWork = resolve; + }); + + let lateDynamicUsage!: Promise; + await runWithRootParamsScope({ lang: "en" }, () => { + lateDynamicUsage = streamedWork.then(() => markDynamicUsage()); + }); + + releaseStreamedWork(); + await lateDynamicUsage; + + expect(consumeDynamicUsage()).toBe(true); + }); + }); + it("proves sibling standalone state survives runWithRootParamsScope", async () => { await runWithNavigationContext(async () => { setNavigationContext({ diff --git a/tests/unified-request-context.test.ts b/tests/unified-request-context.test.ts index 70476ad1cd..41d4ea8045 100644 --- a/tests/unified-request-context.test.ts +++ b/tests/unified-request-context.test.ts @@ -8,7 +8,9 @@ import { } from "../packages/vinext/src/shims/unified-request-context.js"; import { consumeRenderRequestApiUsage, + markDynamicUsage, markRenderRequestApiUsage, + runWithIsolatedDynamicUsage, } from "../packages/vinext/src/shims/headers.js"; import { getRequestExecutionContext, @@ -551,6 +553,44 @@ describe("unified-request-context", () => { }); }); + describe("dynamic usage across nested scopes", () => { + it("keeps a nested scope's late dynamic usage visible to the request", async () => { + // Nested scopes wrap lazily consumed streams: the work they spawn keeps + // running (and can mark dynamic usage) after their callback settles. + await runWithRequestContext(createRequestContext(), async () => { + let releaseLateWork!: () => void; + const lateWork = new Promise((resolve) => { + releaseLateWork = resolve; + }); + + let lateDynamicUsage!: Promise; + await runWithUnifiedStateMutation( + () => {}, + () => { + lateDynamicUsage = lateWork.then(() => markDynamicUsage()); + }, + ); + + expect(getRequestContext().dynamicUsageDetected).toBe(false); + releaseLateWork(); + await lateDynamicUsage; + expect(getRequestContext().dynamicUsageDetected).toBe(true); + }); + }); + + it("runWithIsolatedDynamicUsage measures a subtree without marking the request", async () => { + await runWithRequestContext(createRequestContext(), async () => { + const { dynamicDetected } = await runWithIsolatedDynamicUsage(() => { + markDynamicUsage(); + return "measured"; + }); + + expect(dynamicDetected).toBe(true); + expect(getRequestContext().dynamicUsageDetected).toBe(false); + }); + }); + }); + describe("createRequestContext", () => { it("creates context with all defaults", () => { const ctx = createRequestContext();