Files
cloudflare__vinext/tests/unified-request-context.test.ts
T
Nathan Nguyen 1d01d2989a feat(cache): record app render observations (#1198)
* feat(cache): record app render observations

App Router cache artifacts and payloads did not carry the render-observation metadata required by #726-CACHE-05/07. That left later cache-proof work without an attached record of request APIs, dynamic fetches, output scope, cache tags, and boundary outcome.

The missing boundary was that render-scoped observations lived only as dynamic/cache state, not as metadata owned by each produced payload or artifact.

Record render request API usage and dynamic fetch observations in request-scoped state, build redacted RenderObservation payloads for AppElements and APP_PAGE cache writes, and preserve complete observations for HTML/RSC ISR artifacts including stale regeneration.

Targeted coverage asserts payload metadata, artifact metadata, redaction, and existing cache-proof behavior.

* test(cache): cover search param render observation usage

The CI unit shard failed because app-page-element-builder tests fully mocked the headers shim and omitted the new markRenderRequestApiUsage export. The production path now records searchParams as a render observation whenever populated search params make the page dynamic.

Update the mock to expose the new shim export and assert that populated search params mark both dynamic usage and render-observation usage, while empty search params mark neither.

* refactor(cache): tighten render observation review handling

Render observation metadata now reuses the app page observation state shape instead of duplicating cache-local types. The deferred cache writes also document the stream-consumption boundary that preserves late request API observations.

Dynamic fetch observations are now stored in a Set so repeated no-store fetches do not inflate metadata, and the collector has focused regression coverage. The Link transition test also patches and restores the runtime React default so the full unit project is not order-sensitive when other files instantiate the module graph first.

* fix(cache): preserve parent render observations
2026-05-15 11:51:50 +01:00

571 lines
20 KiB
TypeScript

import { describe, it, expect } from "vite-plus/test";
import {
createRequestContext,
runWithRequestContext,
getRequestContext,
isInsideUnifiedScope,
runWithUnifiedStateMutation,
} from "../packages/vinext/src/shims/unified-request-context.js";
import {
consumeRenderRequestApiUsage,
markRenderRequestApiUsage,
} from "../packages/vinext/src/shims/headers.js";
import {
getRequestExecutionContext,
runWithExecutionContext,
} from "../packages/vinext/src/shims/request-context.js";
describe("unified-request-context", () => {
describe("isInsideUnifiedScope", () => {
it("returns false outside any scope", () => {
expect(isInsideUnifiedScope()).toBe(false);
});
it("returns true inside a runWithRequestContext scope", () => {
const ctx = createRequestContext();
void runWithRequestContext(ctx, () => {
expect(isInsideUnifiedScope()).toBe(true);
});
});
});
describe("getRequestContext", () => {
it("returns default values outside any scope", () => {
const ctx = getRequestContext();
expect(ctx).toBeDefined();
expect(ctx.headersContext).toBeNull();
expect(ctx.dynamicUsageDetected).toBe(false);
expect(ctx.pendingSetCookies).toEqual([]);
expect(ctx.draftModeCookieHeader).toBeNull();
expect(ctx.phase).toBe("render");
expect(ctx.i18nContext).toBeNull();
expect(ctx.serverContext).toBeNull();
expect(ctx.serverInsertedHTMLCallbacks).toEqual([]);
expect(ctx.requestScopedCacheLife).toBeNull();
expect(ctx._privateCache).toBeNull();
expect(ctx.currentRequestTags).toEqual([]);
expect(ctx.executionContext).toBeNull();
expect(ctx.ssrContext).toBeNull();
expect(ctx.ssrHeadChildren).toEqual([]);
});
it("returns a fresh detached context on each call outside any scope", () => {
const first = getRequestContext();
first.dynamicUsageDetected = true;
first.pendingSetCookies.push("first=1");
const second = getRequestContext();
expect(second).not.toBe(first);
expect(second.dynamicUsageDetected).toBe(false);
expect(second.pendingSetCookies).toEqual([]);
});
it("inherits the standalone ExecutionContext ALS in detached fallback mode", () => {
const outerCtx = {
waitUntil() {},
};
void runWithExecutionContext(outerCtx, () => {
expect(isInsideUnifiedScope()).toBe(false);
expect(getRequestContext().executionContext).toBe(outerCtx);
});
});
});
describe("runWithRequestContext", () => {
it("makes all fields accessible inside the scope", () => {
const headers = new Headers({ "x-test": "1" });
const cookies = new Map([["session", "abc"]]);
const fakeCtx = { waitUntil: () => {} };
const reqCtx = createRequestContext({
headersContext: { headers, cookies },
executionContext: fakeCtx,
});
void runWithRequestContext(reqCtx, () => {
const ctx = getRequestContext();
expect((ctx.headersContext as any).headers.get("x-test")).toBe("1");
expect((ctx.headersContext as any).cookies.get("session")).toBe("abc");
expect(ctx.executionContext).toBe(fakeCtx);
expect(ctx.dynamicUsageDetected).toBe(false);
expect(ctx.phase).toBe("render");
expect(ctx.i18nContext).toBeNull();
expect(ctx.pendingSetCookies).toEqual([]);
expect(ctx.currentRequestTags).toEqual([]);
expect(ctx._privateCache).toBeNull();
});
});
it("returns the value from fn (sync)", () => {
const ctx = createRequestContext();
const result = runWithRequestContext(ctx, () => 42);
expect(result).toBe(42);
});
it("returns the value from fn (async)", async () => {
const ctx = createRequestContext();
const result = await runWithRequestContext(ctx, async () => {
await new Promise<void>((resolve) => setTimeout(resolve, 1));
return 99;
});
expect(result).toBe(99);
});
it("scope is exited after fn completes", async () => {
const ctx = createRequestContext({
headersContext: { headers: new Headers(), cookies: new Map() },
});
await runWithRequestContext(ctx, async () => {
expect(isInsideUnifiedScope()).toBe(true);
});
expect(isInsideUnifiedScope()).toBe(false);
});
});
describe("concurrent isolation", () => {
it("20 parallel requests each see their own headers/navigation/tags", async () => {
const results = await Promise.all(
Array.from({ length: 20 }, (_, i) => {
const reqCtx = createRequestContext({
headersContext: {
headers: new Headers({ "x-id": String(i) }),
cookies: new Map(),
},
currentRequestTags: [`tag-${i}`],
serverContext: {
pathname: `/path-${i}`,
searchParams: new URLSearchParams(),
params: {},
},
});
return runWithRequestContext(reqCtx, async () => {
const delayMs = (i % 10) + 1;
await new Promise<void>((resolve) => setTimeout(resolve, delayMs));
const ctx = getRequestContext();
return {
headerId: (ctx.headersContext as any)?.headers?.get("x-id"),
tag: ctx.currentRequestTags[0],
pathname: (ctx.serverContext as any)?.pathname,
};
});
}),
);
for (let i = 0; i < 20; i++) {
expect(results[i].headerId).toBe(String(i));
expect(results[i].tag).toBe(`tag-${i}`);
expect(results[i].pathname).toBe(`/path-${i}`);
}
});
it("mutations in one scope don't leak to another", async () => {
const ctxA = createRequestContext();
const ctxB = createRequestContext();
const pA = runWithRequestContext(ctxA, async () => {
getRequestContext().dynamicUsageDetected = true;
getRequestContext().pendingSetCookies.push("a=1");
await new Promise<void>((resolve) => setTimeout(resolve, 5));
return {
dynamic: getRequestContext().dynamicUsageDetected,
cookies: [...getRequestContext().pendingSetCookies],
};
});
const pB = runWithRequestContext(ctxB, async () => {
await new Promise<void>((resolve) => setTimeout(resolve, 1));
return {
dynamic: getRequestContext().dynamicUsageDetected,
cookies: [...getRequestContext().pendingSetCookies],
};
});
const [a, b] = await Promise.all([pA, pB]);
expect(a.dynamic).toBe(true);
expect(a.cookies).toEqual(["a=1"]);
expect(b.dynamic).toBe(false);
expect(b.cookies).toEqual([]);
});
});
describe("privateCache lazy initialization", () => {
it("is null by default", () => {
const ctx = createRequestContext();
expect(ctx._privateCache).toBeNull();
});
it("stays null until explicitly set", () => {
const ctx = createRequestContext();
void runWithRequestContext(ctx, () => {
expect(getRequestContext()._privateCache).toBeNull();
});
});
});
describe("nested scopes", () => {
it("inner runWithRequestContext overrides outer, restores on exit", () => {
const outerCtx = createRequestContext({
headersContext: {
headers: new Headers({ "x-id": "outer" }),
cookies: new Map(),
},
});
const innerCtx = createRequestContext({
headersContext: {
headers: new Headers({ "x-id": "inner" }),
cookies: new Map(),
},
});
void runWithRequestContext(outerCtx, () => {
expect((getRequestContext().headersContext as any).headers.get("x-id")).toBe("outer");
void runWithRequestContext(innerCtx, () => {
expect((getRequestContext().headersContext as any).headers.get("x-id")).toBe("inner");
});
// Outer scope restored
expect((getRequestContext().headersContext as any).headers.get("x-id")).toBe("outer");
});
});
it("consuming render request API usage in a shallow child scope preserves parent observations", async () => {
const outerCtx = createRequestContext();
await runWithRequestContext(outerCtx, async () => {
markRenderRequestApiUsage("headers");
await runWithUnifiedStateMutation(
() => {},
async () => {
expect(consumeRenderRequestApiUsage()).toEqual(["headers"]);
expect(consumeRenderRequestApiUsage()).toEqual([]);
},
);
expect(consumeRenderRequestApiUsage()).toEqual(["headers"]);
});
});
});
describe("executionContext", () => {
it("is null by default", () => {
const ctx = createRequestContext();
void runWithRequestContext(ctx, () => {
expect(getRequestContext().executionContext).toBeNull();
});
});
it("is accessible when provided", () => {
const calls: Promise<unknown>[] = [];
const fakeCtx = {
waitUntil(p: Promise<unknown>) {
calls.push(p);
},
};
const ctx = createRequestContext({ executionContext: fakeCtx });
void runWithRequestContext(ctx, () => {
const ec = getRequestContext().executionContext as any;
expect(ec).toBe(fakeCtx);
ec.waitUntil(Promise.resolve("done"));
});
expect(calls).toHaveLength(1);
});
it("inherits the outer ExecutionContext ALS when none is provided", () => {
const outerCtx = {
waitUntil() {},
};
void runWithExecutionContext(outerCtx, () => {
void runWithRequestContext(createRequestContext(), () => {
expect(getRequestContext().executionContext).toBe(outerCtx);
expect(getRequestExecutionContext()).toBe(outerCtx);
});
});
});
});
describe("sub-state field access", () => {
it("each sub-state getter returns correct sub-fields", () => {
const reqCtx = createRequestContext({
headersContext: { headers: new Headers(), cookies: new Map() },
dynamicUsageDetected: true,
pendingSetCookies: ["a=b"],
draftModeCookieHeader: "c=d",
phase: "action",
i18nContext: { locale: "fr", defaultLocale: "en" },
serverContext: { pathname: "/test", searchParams: new URLSearchParams(), params: {} },
serverInsertedHTMLCallbacks: [() => "html"],
requestScopedCacheLife: { stale: 10, revalidate: 20 },
currentRequestTags: ["tag1"],
executionContext: { waitUntil: () => {} },
});
void runWithRequestContext(reqCtx, () => {
const ctx = getRequestContext();
expect(ctx.dynamicUsageDetected).toBe(true);
expect(ctx.pendingSetCookies).toEqual(["a=b"]);
expect(ctx.draftModeCookieHeader).toBe("c=d");
expect(ctx.phase).toBe("action");
expect(ctx.i18nContext).toEqual({ locale: "fr", defaultLocale: "en" });
expect((ctx.serverContext as any).pathname).toBe("/test");
expect(ctx.serverInsertedHTMLCallbacks).toHaveLength(1);
expect(ctx.requestScopedCacheLife).toEqual({
stale: 10,
revalidate: 20,
});
expect(ctx.currentRequestTags).toEqual(["tag1"]);
expect(ctx.executionContext).not.toBeNull();
expect(ctx.ssrContext).toBeNull();
expect(ctx.ssrHeadChildren).toEqual([]);
});
});
});
describe("legacy wrapper semantics inside unified scope", () => {
it("runWithHeadersContext restores the outer headers sub-state", async () => {
const { runWithHeadersContext } = await import("../packages/vinext/src/shims/headers.js");
const outerHeaders = {
headers: new Headers({ "x-id": "outer" }),
cookies: new Map([["outer", "1"]]),
};
const innerHeaders = {
headers: new Headers({ "x-id": "inner" }),
cookies: new Map([["inner", "1"]]),
};
void runWithRequestContext(
createRequestContext({
headersContext: outerHeaders,
dynamicUsageDetected: true,
pendingSetCookies: ["outer=1"],
draftModeCookieHeader: "outer=draft",
phase: "action",
}),
() => {
void runWithHeadersContext(innerHeaders as any, () => {
const ctx = getRequestContext();
expect((ctx.headersContext as any).headers.get("x-id")).toBe("inner");
expect(ctx.dynamicUsageDetected).toBe(false);
expect(ctx.pendingSetCookies).toEqual([]);
expect(ctx.draftModeCookieHeader).toBeNull();
expect(ctx.phase).toBe("render");
ctx.dynamicUsageDetected = true;
ctx.pendingSetCookies.push("inner=1");
ctx.draftModeCookieHeader = "inner=draft";
ctx.phase = "route-handler";
});
const ctx = getRequestContext();
expect(ctx.headersContext).toBe(outerHeaders);
expect(ctx.dynamicUsageDetected).toBe(true);
expect(ctx.pendingSetCookies).toEqual(["outer=1"]);
expect(ctx.draftModeCookieHeader).toBe("outer=draft");
expect(ctx.phase).toBe("action");
},
);
});
it("runWithHeadersContext keeps spawned async work on the inner sub-state", async () => {
const { runWithHeadersContext } = await import("../packages/vinext/src/shims/headers.js");
let releaseInnerRead!: () => void;
const waitForInnerRead = new Promise<void>((resolve) => {
releaseInnerRead = resolve;
});
let innerRead!: Promise<string | null>;
await runWithRequestContext(
createRequestContext({
headersContext: {
headers: new Headers({ "x-id": "outer" }),
cookies: new Map(),
},
}),
async () => {
void runWithHeadersContext(
{
headers: new Headers({ "x-id": "inner" }),
cookies: new Map(),
},
() => {
innerRead = (async () => {
await waitForInnerRead;
return (getRequestContext().headersContext as any)?.headers?.get("x-id") ?? null;
})();
},
);
expect((getRequestContext().headersContext as any)?.headers?.get("x-id")).toBe("outer");
releaseInnerRead();
await expect(innerRead).resolves.toBe("inner");
},
);
});
it("runWithNavigationContext restores the outer navigation sub-state", async () => {
const { runWithNavigationContext } =
await import("../packages/vinext/src/shims/navigation-state.js");
const { setNavigationContext, getNavigationContext } =
await import("../packages/vinext/src/shims/navigation.js");
const outerCallback = () => "outer";
void runWithRequestContext(
createRequestContext({
serverContext: { pathname: "/outer", searchParams: new URLSearchParams(), params: {} },
serverInsertedHTMLCallbacks: [outerCallback],
}),
() => {
void runWithNavigationContext(() => {
expect(getNavigationContext()).toBeNull();
expect(getRequestContext().serverInsertedHTMLCallbacks).toEqual([]);
setNavigationContext({
pathname: "/inner",
searchParams: new URLSearchParams("q=1"),
params: { id: "1" },
});
getRequestContext().serverInsertedHTMLCallbacks.push(() => "inner");
});
expect((getNavigationContext() as any)?.pathname).toBe("/outer");
expect(getRequestContext().serverInsertedHTMLCallbacks).toEqual([outerCallback]);
},
);
});
it("runWithI18nState restores the outer i18n sub-state", async () => {
const { runWithI18nState } = await import("../packages/vinext/src/shims/i18n-state.js");
const { getI18nContext, setI18nContext } =
await import("../packages/vinext/src/shims/i18n-context.js");
const outerI18n = {
locale: "fr",
locales: ["en", "fr"],
defaultLocale: "en",
};
void runWithRequestContext(
createRequestContext({
i18nContext: outerI18n,
}),
() => {
void runWithI18nState(() => {
expect(getI18nContext()).toBeNull();
setI18nContext({
locale: "de",
locales: ["en", "de"],
defaultLocale: "en",
});
expect(getRequestContext().i18nContext).toEqual({
locale: "de",
locales: ["en", "de"],
defaultLocale: "en",
});
});
expect(getI18nContext()).toEqual(outerI18n);
expect(getRequestContext().i18nContext).toEqual(outerI18n);
},
);
});
it("cache/private/fetch/router/head sub-scopes reset and restore correctly", async () => {
const { _runWithCacheState } = await import("../packages/vinext/src/shims/cache.js");
const { runWithPrivateCache } = await import("../packages/vinext/src/shims/cache-runtime.js");
const { runWithFetchCache, getCollectedFetchTags } =
await import("../packages/vinext/src/shims/fetch-cache.js");
const { runWithRouterState } = await import("../packages/vinext/src/shims/router-state.js");
const { setSSRContext } = await import("../packages/vinext/src/shims/router.js");
const { runWithHeadState } = await import("../packages/vinext/src/shims/head-state.js");
await runWithRequestContext(
createRequestContext({
requestScopedCacheLife: { revalidate: 60 },
_privateCache: new Map([["outer", 1]]),
currentRequestTags: ["outer-tag"],
ssrContext: { pathname: "/outer", query: {}, asPath: "/outer" },
ssrHeadChildren: ["<meta data-outer />"],
}),
async () => {
void _runWithCacheState(() => {
expect(getRequestContext().requestScopedCacheLife).toBeNull();
getRequestContext().requestScopedCacheLife = { revalidate: 1 } as any;
});
expect(getRequestContext().requestScopedCacheLife).toEqual({ revalidate: 60 });
void runWithPrivateCache(() => {
expect(getRequestContext()._privateCache).toBeInstanceOf(Map);
expect(getRequestContext()._privateCache?.size).toBe(0);
getRequestContext()._privateCache?.set("inner", 2);
});
expect([...getRequestContext()._privateCache!.entries()]).toEqual([["outer", 1]]);
await runWithFetchCache(async () => {
expect(getCollectedFetchTags()).toEqual([]);
getRequestContext().currentRequestTags.push("inner-tag");
});
expect(getCollectedFetchTags()).toEqual(["outer-tag"]);
void runWithRouterState(() => {
expect(getRequestContext().ssrContext).toBeNull();
setSSRContext({ pathname: "/inner", query: {}, asPath: "/inner" } as any);
});
expect((getRequestContext().ssrContext as any).pathname).toBe("/outer");
void runWithHeadState(() => {
expect(getRequestContext().ssrHeadChildren).toEqual([]);
getRequestContext().ssrHeadChildren.push("<meta data-inner />");
});
expect(getRequestContext().ssrHeadChildren).toEqual(["<meta data-outer />"]);
},
);
});
});
describe("createRequestContext", () => {
it("creates context with all defaults", () => {
const ctx = createRequestContext();
expect(ctx.headersContext).toBeNull();
expect(ctx.dynamicUsageDetected).toBe(false);
expect(ctx.pendingSetCookies).toEqual([]);
expect(ctx.draftModeCookieHeader).toBeNull();
expect(ctx.phase).toBe("render");
expect(ctx.i18nContext).toBeNull();
expect(ctx.serverContext).toBeNull();
expect(ctx.serverInsertedHTMLCallbacks).toEqual([]);
expect(ctx.requestScopedCacheLife).toBeNull();
expect(ctx._privateCache).toBeNull();
expect(ctx.currentRequestTags).toEqual([]);
expect(ctx.executionContext).toBeNull();
expect(ctx.ssrContext).toBeNull();
expect(ctx.ssrHeadChildren).toEqual([]);
expect(ctx.requestCache).toBeInstanceOf(WeakMap);
});
it("merges partial overrides", () => {
const ctx = createRequestContext({
phase: "action",
dynamicUsageDetected: true,
});
expect(ctx.phase).toBe("action");
expect(ctx.dynamicUsageDetected).toBe(true);
// Other fields get defaults
expect(ctx.i18nContext).toBeNull();
expect(ctx.headersContext).toBeNull();
expect(ctx.currentRequestTags).toEqual([]);
expect(ctx.ssrContext).toBeNull();
expect(ctx.ssrHeadChildren).toEqual([]);
});
});
});