Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/vinext/src/server/app-layout-param-observation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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();
Expand Down
5 changes: 3 additions & 2 deletions packages/vinext/src/shims/headers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -248,7 +249,7 @@ export async function runWithIsolatedDynamicUsage<T>(
let childState: VinextHeadersShimState | null = null;
return await runWithUnifiedStateMutation(
(context) => {
context.dynamicUsageDetected = false;
isolateDynamicUsage(context);
childState = context;
},
() => {
Expand Down Expand Up @@ -594,7 +595,7 @@ export function runWithHeadersContext<T>(
if (isInsideUnifiedScope()) {
return runWithUnifiedStateMutation((uCtx) => {
uCtx.headersContext = ctx;
uCtx.dynamicUsageDetected = false;
isolateDynamicUsage(uCtx);
uCtx.renderRequestApiUsage = new Set();
uCtx.connectionProbe = null;
uCtx.pendingSetCookies = [];
Expand Down
38 changes: 38 additions & 0 deletions packages/vinext/src/shims/unified-request-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,40 @@ export function runWithRequestContext<T>(
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
Expand Down Expand Up @@ -393,6 +427,10 @@ export function runWithUnifiedStateMutation<T>(
// 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);
}
Expand Down
22 changes: 22 additions & 0 deletions tests/root-params.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<void>((resolve) => {
releaseStreamedWork = resolve;
});

let lateDynamicUsage!: Promise<void>;
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({
Expand Down
40 changes: 40 additions & 0 deletions tests/unified-request-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<void>((resolve) => {
releaseLateWork = resolve;
});

let lateDynamicUsage!: Promise<void>;
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();
Expand Down
Loading