Files
cloudflare__vinext/tests/app-rsc-cache-busting.test.ts
T
Nathan Nguyen 49d2f0966d feat(skip): plumb client reuse manifests through the app request path (#1717)
* feat(skip): plumb client reuse manifests through the app request path

The browser never sends a client reuse manifest and the server has no path
to receive one, so the static-layout skip planner (#1674) and observation
foundations (#1672) have nothing to act on. Skip transport stays fully off,
but the transport wiring those slices depend on is missing.

Add a bounded browser producer that derives a client reuse manifest from the
visible AppElements state and attaches it as X-Vinext-Client-Reuse-Manifest
only on soft navigations. Parse the header into a render/send-by-default
disposition and thread the parsed result through the RSC handler, the
generated app entry, page dispatch, and the render lifecycle. Wire the
per-layout observation tracker and segment-config classification into dispatch
so the enable-transport slice can read them.

No RSC payload is omitted and no response cache-control changes: the parsed
manifest is carried but never authorizes a skip yet. Unproven and absent-
manifest requests render and send exactly as before.

Slice 3 of 4 from #1437; enable-transport follows.

Refs #726

* fix(skip): address review feedback on manifest plumbing

- app-page-execution: defer layout debug classification until after the
  observation override so the emitted reason agrees with the final flag;
  emit a runtime-probe reason when an observation flips a build-time static
  layout to dynamic.
- app-page-dispatch: document classifyLayoutSegmentConfigFromModule as the
  request-time counterpart to the build-time classifier, noting the
  intentional revalidate===false divergence and the dev-mode classification.
- app-render-dependency: mark registerAppElementRenderDependencies as
  write-only until the enable slice's consumer lands.

* docs(skip): clarify layout classification comments per review

- app-page-dispatch: correct the classifyLayoutSegmentConfigFromModule
  docblock — the build-time classifier already normalizes revalidate=false
  to Infinity, so both agree on that case; the real difference is scope
  (request-time classifies dev-mode layouts not captured at build time).
- app-page-dispatch: document the merge precedence in
  createEffectiveLayoutClassifications — module segment config is
  authoritative over the build-time map.

* test(skip): pin observation override flipping build-time static layout to dynamic

Adds a parity assertion for the request-time behavioral change the review
flagged: isLayoutObservationDynamic downgrades a build-time `static` layout
to `d` (and emits a runtime-probe reason) when the observed render is unsafe
for static reuse, while a safe observation keeps it static. Complements the
existing children-subtree probe assertion in app-page-route-wiring.test.ts.

* fix(skip): fold probe-scoped dynamic usage into layout observation

The probe rewrite runs layout probes inside runWithIsolatedProbeDependencies,
which opens a child unified scope and resets dynamicUsageDetected. A
markDynamicUsage() during the probe that leaves no other observable trace
(e.g. "use cache: private") was therefore discarded on scope exit, so the
parent's consumeDynamicUsage() in the Layer-3 path returned false and the
layout could classify static where the pre-PR direct probe classified dynamic.

recordProbeDependencies now peeks the child scope's dynamic-usage flag before
it is reset and folds it into the per-layout observation
(dynamicUsageObserved), which isAppLayoutObservationUnsafeForStaticReuse
treats as unsafe. This keeps the observation signal a superset of the
markDynamicUsage() triggers the Layer-3 dynamicDetected path covered, so the
two signals can no longer diverge into a false-static. Adds peekDynamicUsage()
(non-resetting read) and a regression test.

* perf(skip): defer client reuse manifest production to the cache-miss path

The manifest header is excluded from VINEXT_RSC_VARY_HEADER, so it never
affects the cache-busting URL or the visited-response cache lookup. Producing
it up front ran createClientReuseManifestHeaderFromVisibleAppState (iterates
visible layout ids, builds a cache-variant proof per static layout, then
binary-searches the byte budget) on every soft navigate — including ones that
resolve entirely from the visited-response cache or a prefetch and never send
a request, where the work is pure waste.

Move production to just before the network fetch, gated on the same
`navigationKind === "navigate"` condition and computed from the captured
nav-start router state, so the emitted header is unchanged on the request path
while the cache-hit/prefetch-hit hot paths skip it entirely.

---------

Co-authored-by: James <james@eli.cx>
2026-06-02 20:25:25 +01:00

350 lines
14 KiB
TypeScript

import { describe, expect, it } from "vite-plus/test";
import {
applyRscCompatibilityIdHeader,
computeRscCacheBustingSearchParam,
createRscRequestHeaders,
createRscRequestUrl,
createServerActionRequestUrl,
isRscCompatibilityIdCompatible,
resolveInvalidRscCacheBustingRequest,
setRscCacheBustingSearchParam,
stripRscCacheBustingSearchParam,
VINEXT_RSC_COMPATIBILITY_ID_HEADER,
VINEXT_RSC_CACHE_BUSTING_SEARCH_PARAM,
VINEXT_RSC_RENDER_MODE_HEADER,
VINEXT_RSC_VARY_HEADER,
} from "../packages/vinext/src/server/app-rsc-cache-busting.js";
import {
APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL,
APP_RSC_RENDER_MODE_REFRESH_PRESERVE_UI,
} from "../packages/vinext/src/server/app-rsc-render-mode.js";
import { VINEXT_CLIENT_REUSE_MANIFEST_HEADER } from "../packages/vinext/src/server/headers.js";
import { fnv1a64 } from "../packages/vinext/src/utils/hash.js";
import { withEnvVar } from "./env-test-helpers.js";
const textEncoder = new TextEncoder();
function encodeBase64Url(bytes: Uint8Array): string {
let binary = "";
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
}
async function sha256CacheBustingHash(input: string): Promise<string> {
const digest = await globalThis.crypto.subtle.digest("SHA-256", textEncoder.encode(input));
return encodeBase64Url(new Uint8Array(digest).subarray(0, 12));
}
describe("App Router RSC cache-busting", () => {
it("adds a bare _rsc search param when no variant headers are present", async () => {
const headers = createRscRequestHeaders();
await expect(createRscRequestUrl("/dashboard?tab=activity", headers)).resolves.toBe(
"/dashboard?tab=activity&_rsc",
);
});
it("uses the canonical route URL for root RSC navigations", async () => {
// Ported from Next.js: test/e2e/app-dir/navigation/navigation.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/navigation/navigation.test.ts
// Client-side App Router navigations fetch the route URL with RSC: 1 and
// _rsc cache busting, not Vinext's legacy /.rsc transport path.
const headers = createRscRequestHeaders();
await expect(createRscRequestUrl("/", headers)).resolves.toBe("/?_rsc");
});
it("preserves the route pathname trailing slash when building canonical RSC URLs", async () => {
const headers = createRscRequestHeaders();
await expect(createRscRequestUrl("/docs/", headers)).resolves.toBe("/docs/?_rsc");
});
it("hashes Vinext RSC variant headers into the request URL", async () => {
const headers = createRscRequestHeaders({
interceptionContext: "/feed",
mountedSlotsHeader: "slot:modal:/ slot:sidebar:/",
});
const hash = await computeRscCacheBustingSearchParam(headers);
expect(hash).not.toBe("");
await expect(createRscRequestUrl("/photos/42", headers)).resolves.toBe(
`/photos/42?${VINEXT_RSC_CACHE_BUSTING_SEARCH_PARAM}=${hash}`,
);
});
it("keeps server action POSTs on the visible route URL", () => {
// Ported from Next.js: test/e2e/app-dir/actions/app-action.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/actions/app-action.test.ts
expect(createServerActionRequestUrl("/server?name=alice#section")).toBe("/server?name=alice");
});
it("attaches client reuse manifests without making them shared cache variants", async () => {
const manifestHeader = '{"entries":[]}';
const headers = createRscRequestHeaders({ clientReuseManifestHeader: manifestHeader });
expect(headers.get(VINEXT_CLIENT_REUSE_MANIFEST_HEADER)).toBe(manifestHeader);
await expect(createRscRequestUrl("/dashboard", headers)).resolves.toBe("/dashboard?_rsc");
});
it("changes the hash when a varying header changes", async () => {
const feedHash = await computeRscCacheBustingSearchParam(
createRscRequestHeaders({ interceptionContext: "/feed" }),
);
const galleryHash = await computeRscCacheBustingSearchParam(
createRscRequestHeaders({ interceptionContext: "/gallery" }),
);
expect(feedHash).not.toBe(galleryHash);
});
it("varies preserve-current-UI refresh payloads from normal navigations", async () => {
const navigationHash = await computeRscCacheBustingSearchParam(createRscRequestHeaders());
const refreshHash = await computeRscCacheBustingSearchParam(
createRscRequestHeaders({ renderMode: APP_RSC_RENDER_MODE_REFRESH_PRESERVE_UI }),
);
expect(navigationHash).toBe("");
expect(refreshHash).not.toBe("");
});
it("varies loading-shell prefetch payloads from normal navigations", async () => {
const navigationHash = await computeRscCacheBustingSearchParam(createRscRequestHeaders());
const prefetchShellHash = await computeRscCacheBustingSearchParam(
createRscRequestHeaders({ renderMode: APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL }),
);
expect(navigationHash).toBe("");
expect(prefetchShellHash).not.toBe("");
});
it("normalizes invalid render modes to normal navigation for cache-busting", async () => {
const headers = createRscRequestHeaders();
headers.set(VINEXT_RSC_RENDER_MODE_HEADER, "invalid");
await expect(computeRscCacheBustingSearchParam(headers)).resolves.toBe("");
});
it("preserves existing query params while replacing stale _rsc values", () => {
const url = new URL("https://example.com/photos/42.rsc?tab=latest&_rsc=stale");
setRscCacheBustingSearchParam(url, "fresh");
expect(`${url.pathname}${url.search}`).toBe("/photos/42.rsc?tab=latest&_rsc=fresh");
});
it("replaces encoded reserved _rsc query keys", () => {
const url = new URL("https://example.com/photos/42.rsc?%5Frsc=stale&tab=latest");
setRscCacheBustingSearchParam(url, "fresh");
expect(`${url.pathname}${url.search}`).toBe("/photos/42.rsc?tab=latest&_rsc=fresh");
});
it("does not treat query keys containing _rsc as cache-busting params", () => {
const url = new URL("https://example.com/photos/42.rsc?filter_rsc=1&_rsc=stale");
setRscCacheBustingSearchParam(url, "fresh");
expect(`${url.pathname}${url.search}`).toBe("/photos/42.rsc?filter_rsc=1&_rsc=fresh");
});
it("strips internal _rsc params before exposing response URLs to browser navigation", () => {
const url = new URL("https://example.com/photos/42.rsc?tab=latest&_rsc=fresh&view=modal");
stripRscCacheBustingSearchParam(url);
expect(`${url.pathname}${url.search}`).toBe("/photos/42.rsc?tab=latest&view=modal");
});
it("strips encoded reserved _rsc query keys before exposing response URLs", () => {
const url = new URL("https://example.com/photos/42.rsc?filter_rsc=1&%5Frsc=stale");
stripRscCacheBustingSearchParam(url);
expect(`${url.pathname}${url.search}`).toBe("/photos/42.rsc?filter_rsc=1");
});
it("strips bare internal _rsc params without rewriting unrelated query encoding", () => {
const url = new URL("https://example.com/search.rsc?q=custom%20spacing&_rsc");
stripRscCacheBustingSearchParam(url);
expect(`${url.pathname}${url.search}`).toBe("/search.rsc?q=custom%20spacing");
});
it("redirects RSC requests with missing cache-busting params to the canonical URL", async () => {
const headers = createRscRequestHeaders({ interceptionContext: "/feed" });
const request = new Request("https://example.com/photos/42.rsc?tab=latest", { headers });
const hash = await computeRscCacheBustingSearchParam(headers);
const response = await resolveInvalidRscCacheBustingRequest({
isRscRequest: true,
request,
});
expect(response?.status).toBe(307);
expect(response?.headers.get("Location")).toBe(`/photos/42.rsc?tab=latest&_rsc=${hash}`);
});
it("redirects encoded stale _rsc keys to a canonical non-looping URL", async () => {
const headers = createRscRequestHeaders();
const request = new Request("https://example.com/photos/42.rsc?%5Frsc=stale", { headers });
const response = await resolveInvalidRscCacheBustingRequest({
isRscRequest: true,
request,
});
expect(response?.status).toBe(307);
expect(response?.headers.get("Location")).toBe("/photos/42.rsc?_rsc");
});
it("accepts RSC requests without cache-busting params when no variant headers are present", async () => {
const headers = createRscRequestHeaders();
const request = new Request("https://example.com/photos/42.rsc?tab=latest", { headers });
await expect(
resolveInvalidRscCacheBustingRequest({ isRscRequest: true, request }),
).resolves.toBeNull();
});
it("accepts RSC requests whose cache-busting param matches the request headers", async () => {
const headers = createRscRequestHeaders({ mountedSlotsHeader: "slot:modal:/" });
const url = await createRscRequestUrl("/photos/42", headers);
const request = new Request(`https://example.com${url}`, { headers });
await expect(
resolveInvalidRscCacheBustingRequest({ isRscRequest: true, request }),
).resolves.toBeNull();
});
it("accepts legacy FNV cache-busting params during rolling upgrades", async () => {
const headers = createRscRequestHeaders({ mountedSlotsHeader: "slot:modal:/" });
const legacyHash = fnv1a64("0,0,0,0,0,slot:modal:/");
const request = new Request(`https://example.com/photos/42.rsc?_rsc=${legacyHash}`, {
headers,
});
await expect(
resolveInvalidRscCacheBustingRequest({ isRscRequest: true, request }),
).resolves.toBeNull();
});
it("accepts previous SHA cache-busting params after adding a varying header", async () => {
const headers = createRscRequestHeaders({ mountedSlotsHeader: "slot:modal:/" });
const previousHash = await sha256CacheBustingHash("0,0,0,0,0,slot:modal:/");
const request = new Request(`https://example.com/photos/42.rsc?_rsc=${previousHash}`, {
headers,
});
await expect(
resolveInvalidRscCacheBustingRequest({ isRscRequest: true, request }),
).resolves.toBeNull();
});
it("does not accept a bare previous hash for preserve-current-UI payloads", async () => {
const headers = createRscRequestHeaders({
renderMode: APP_RSC_RENDER_MODE_REFRESH_PRESERVE_UI,
});
const request = new Request("https://example.com/photos/42.rsc?_rsc", { headers });
const hash = await computeRscCacheBustingSearchParam(headers);
const response = await resolveInvalidRscCacheBustingRequest({
isRscRequest: true,
request,
});
expect(response?.status).toBe(307);
expect(response?.headers.get("Location")).toBe(`/photos/42.rsc?_rsc=${hash}`);
});
it("does not accept previous mounted-slot hashes for preserve-current-UI payloads", async () => {
const headers = createRscRequestHeaders({
mountedSlotsHeader: "slot:modal:/",
renderMode: APP_RSC_RENDER_MODE_REFRESH_PRESERVE_UI,
});
const previousHash = await sha256CacheBustingHash("0,0,0,0,0,slot:modal:/");
const currentHash = await computeRscCacheBustingSearchParam(headers);
const request = new Request(`https://example.com/photos/42.rsc?_rsc=${previousHash}`, {
headers,
});
const response = await resolveInvalidRscCacheBustingRequest({
isRscRequest: true,
request,
});
expect(response?.status).toBe(307);
expect(response?.headers.get("Location")).toBe(`/photos/42.rsc?_rsc=${currentHash}`);
});
it("ignores non-RSC and mutating requests", async () => {
const headers = createRscRequestHeaders({ interceptionContext: "/feed" });
await expect(
resolveInvalidRscCacheBustingRequest({
isRscRequest: false,
request: new Request("https://example.com/photos/42", { headers }),
}),
).resolves.toBeNull();
await expect(
resolveInvalidRscCacheBustingRequest({
isRscRequest: true,
request: new Request("https://example.com/photos/42.rsc", { headers, method: "POST" }),
}),
).resolves.toBeNull();
});
it("exports the full Vary value for RSC-bearing App Router responses", () => {
expect(VINEXT_RSC_VARY_HEADER).toBe(
"RSC, Accept, Next-Router-State-Tree, Next-Router-Prefetch, Next-Router-Segment-Prefetch, Next-Url, X-Vinext-Interception-Context, X-Vinext-Mounted-Slots, X-Vinext-Rsc-Render-Mode",
);
});
it("applies the current compatibility ID to RSC response headers when available", () => {
const headers = new Headers();
applyRscCompatibilityIdHeader(headers, "compat-a");
expect(headers.get(VINEXT_RSC_COMPATIBILITY_ID_HEADER)).toBe("compat-a");
});
it("uses the injected RSC compatibility ID by default", () => {
const headers = new Headers();
withEnvVar("__VINEXT_RSC_COMPATIBILITY_ID", "compat-env", () =>
applyRscCompatibilityIdHeader(headers),
);
expect(headers.get(VINEXT_RSC_COMPATIBILITY_ID_HEADER)).toBe("compat-env");
});
it("removes a spoofed compatibility ID header when no framework ID is available", () => {
const headers = new Headers({
[VINEXT_RSC_COMPATIBILITY_ID_HEADER]: "spoofed-compat",
});
applyRscCompatibilityIdHeader(headers, "");
expect(headers.has(VINEXT_RSC_COMPATIBILITY_ID_HEADER)).toBe(false);
});
it("classifies mismatched RSC compatibility IDs as incompatible", () => {
expect(isRscCompatibilityIdCompatible("compat-a", "compat-a")).toBe(true);
expect(isRscCompatibilityIdCompatible("compat-b", "compat-a")).toBe(false);
});
it("treats missing response compatibility IDs as incompatible when the client has one", () => {
expect(isRscCompatibilityIdCompatible(null, "compat-a")).toBe(false);
});
it("treats missing response compatibility IDs as compatible only when the client has none", () => {
expect(isRscCompatibilityIdCompatible("compat-a", null)).toBe(true);
});
});