Files
cloudflare__vinext/tests/nextjs-compat/rsc-context-lazy-stream.test.ts
2026-08-18 00:10:32 +00:00

448 lines
20 KiB
TypeScript

/**
* Regression tests: RSC lazy stream context-clear bug + navigation hydration bootstrap
*
* ── Bug 1: RSC lazy stream context-clear ────────────────────────────────────
*
* renderHTTPAccessFallbackPage() and renderErrorBoundaryPage() in
* entries/app-rsc-entry.ts called setHeadersContext(null) / setNavigationContext(null)
* immediately after renderToReadableStream() returned — before the RSC stream
* was consumed by the client.
*
* setHeadersContext(null) mutates the live ALS store in-place:
* state.headersContext = null
*
* The ALS store is still alive — the ReadableStream holds a reference to it
* through its async continuation chain. So any async server component that
* runs during lazy stream consumption (e.g. a layout that calls headers(), like
* NextIntlClientProviderServer calling getMessages()) sees headersContext = null
* and throws, producing a broken RSC stream instead of rendered content.
*
* Fix: remove the early setHeadersContext(null) / setNavigationContext(null)
* calls from the RSC branches of renderHTTPAccessFallbackPage() and
* renderErrorBoundaryPage(). The ALS scope from runWithHeadersContext() unwinds
* naturally when all async continuations (including stream consumption) complete.
*
* Fixture: tests/fixtures/app-basic/app/nextjs-compat/rsc-context-lazy-stream/
* layout.tsx — async layout that calls headers(), reads x-rsc-context-test
* page.tsx — calls notFound(), triggering renderHTTPAccessFallbackPage()
* not-found.tsx — rendered by renderHTTPAccessFallbackPage() inside the layout
*
* The test sends a canonical .rsc request with x-rsc-context-test: <sentinel>
* and asserts the sentinel value appears in the RSC flight response, proving
* the layout's headers() call ran successfully during lazy stream consumption.
*
* Bug 2: navigation state missing from HTML (hydration mismatch)
*
* useSyncExternalStore requires that the getServerSnapshot callback returns the
* same value that was rendered by the server. For usePathname() and
* useSearchParams(), the server snapshot is read from the navigation context
* (setNavigationContext) which is populated from the request URL during RSC
* rendering and passed through to the SSR environment.
*
* Without the fix, the browser entry had no way to know what pathname/
* searchParams were used on the server — so getServerSnapshot fell back to "/"
* and empty URLSearchParams regardless of the actual request URL. This caused
* React hydration mismatch error #418 whenever any "use client" component
* called usePathname() or useSearchParams().
*
* Fix: the SSR entry embeds the pathname, searchParams, and params in the
* symbol-backed navigation runtime RSC bootstrap in <head>. The browser entry
* restores that state before hydrateRoot() so the client snapshot matches the
* server.
*
* The tests verify:
* 1. The HTML <head> contains the navigation runtime RSC bootstrap
* 2. The embedded pathname matches the actual request path
* 3. The embedded searchParams matches the actual query string
* 4. The bootstrap params carry dynamic segment values
* 5. The SSR-rendered values from usePathname()/useSearchParams() agree with
* the navigation bootstrap — ensuring getServerSnapshot will match
* the HTML React tries to hydrate
*
* Fixture: tests/fixtures/app-basic/app/nextjs-compat/nav-context-hydration/
* page.tsx — RSC page rendering <NavInfo /> (a "use client" component)
* nav-info.tsx — "use client": renders usePathname(), useSearchParams()
* [id]/page.tsx — dynamic-segment variant for bootstrap params testing
*/
import { describe, it, expect, beforeAll, afterAll } from "vite-plus/test";
import type { ViteDevServer } from "vite-plus";
import { APP_FIXTURE_DIR, startFixtureServer } from "../helpers.js";
// ── Shared server ─────────────────────────────────────────────────────────────
//
// Both suites target the same fixture (app-basic, appRouter: true).
// A single server instance is shared so the test run stays fast — spinning up
// a second Vite dev server would add ~5-10 s of cold-start compile time.
let _server: ViteDevServer;
let _baseUrl: string;
// Module-level beforeAll / afterAll manage the shared server lifetime.
beforeAll(async () => {
({ server: _server, baseUrl: _baseUrl } = await startFixtureServer(APP_FIXTURE_DIR, {
appRouter: true,
}));
// Warm up both fixtures so the first real test in each suite doesn't pay
// the cold-start compilation cost.
await Promise.all([
fetch(`${_baseUrl}/nextjs-compat/rsc-context-lazy-stream.rsc`, {
headers: {
Accept: "text/x-component",
RSC: "1",
"x-rsc-context-test": "warmup",
},
}),
fetch(`${_baseUrl}/nextjs-compat/nav-context-hydration`),
]);
}, 60_000);
afterAll(async () => {
await _server?.close();
});
// ── Suite 1: RSC lazy stream context-clear ─────────────────────────────────
const LAZY_ROUTE = "/nextjs-compat/rsc-context-lazy-stream";
const SENTINEL = "rsc-lazy-stream-sentinel-xyz";
describe("RSC lazy stream: headers() context survives until stream is consumed", () => {
// ── Baseline: full-page HTML load ──────────────────────────────────────────
it("full-page HTML: layout headers() context is available (baseline)", async () => {
const res = await fetch(`${_baseUrl}${LAZY_ROUTE}`, {
headers: { "x-rsc-context-test": SENTINEL },
});
expect(res.status).toBe(404);
const html = await res.text();
// The layout reads x-rsc-context-test from headers() and puts it in
// data-request-id. Confirms the fixture works on the HTML path.
expect(html).toContain(`data-request-id="${SENTINEL}"`);
// not-found.tsx rendered
expect(html).toContain('id="rsc-context-not-found"');
});
// ── Regression: RSC request path ───────────────────────────────────────────
//
// This is the path where the bug manifested. The flow is:
//
// 1. Request arrives with Accept: text/x-component → isRscRequest = true
// 2. page.tsx calls notFound() → throws NEXT_NOT_FOUND digest
// 3. vinext catches it → calls renderHTTPAccessFallbackPage(route, 404, true, ...)
// 4. Builds element: not-found.tsx wrapped in ancestor layouts (including
// layout.tsx which calls headers() asynchronously)
// 5. renderToReadableStream(element) → returns RSC stream immediately
// (the stream shell is ready but async layout body hasn't run yet)
// 6. BUG: setHeadersContext(null) called here — wipes ALS store in-place
// 7. Stream is consumed: layout.tsx async body runs NOW, calls headers()
// 8. headers() returns null context → x-rsc-context-test = "missing"
//
// With the fix, step 6 does not happen. headers() in step 7 returns the
// correct context and SENTINEL appears in the RSC flight response.
it("RSC request: layout headers() context survives lazy stream consumption (regression)", async () => {
const res = await fetch(`${_baseUrl}${LAZY_ROUTE}.rsc`, {
headers: {
Accept: "text/x-component",
RSC: "1",
"x-rsc-context-test": SENTINEL,
},
});
expect(res.status).toBe(404);
expect(res.headers.get("content-type")).toContain("text/x-component");
const body = await res.text();
// The layout sets data-request-id from headers("x-rsc-context-test").
// In the RSC flight format this appears as a prop in the serialized tree.
// If the early-clear bug is present, the layout sees null context and
// data-request-id is "missing" — SENTINEL is absent from the flight body.
expect(body).toContain(SENTINEL);
// Also confirm not-found.tsx was rendered (not a raw error response)
expect(body).toContain("rsc-context-not-found");
});
it("RSC request: data-request-id is not 'missing' (context was not null)", async () => {
const res = await fetch(`${_baseUrl}${LAZY_ROUTE}.rsc`, {
headers: {
Accept: "text/x-component",
RSC: "1",
"x-rsc-context-test": SENTINEL,
},
});
const body = await res.text();
// Explicit negative assertion: if the bug were present, the layout would
// fall back to "missing" because headers() returned null context.
expect(body).not.toContain('"missing"');
expect(body).not.toContain('data-request-id="missing"');
});
});
// Suite 2: navigation runtime hydration bootstrap
//
// These tests verify the server embeds the correct navigation bootstrap so
// useSyncExternalStore's getServerSnapshot agrees with the SSR-rendered HTML.
//
// Fixture: tests/fixtures/app-basic/app/nextjs-compat/nav-context-hydration/
// page.tsx — RSC page that renders <NavInfo />
// nav-info.tsx — "use client": usePathname() → #nav-pathname,
// useSearchParams() → #nav-search-q, etc.
// [id]/page.tsx — dynamic-segment variant
const NAV_ROUTE = "/nextjs-compat/nav-context-hydration";
const RSC_BOOTSTRAP_PREFIX =
'Object.assign(((self[Symbol.for("vinext.navigationRuntime")]??={bootstrap:{routeManifest:null},functions:{}}).bootstrap.rsc??={rsc:[]}),{params:';
function extractRscBootstrap(html: string): {
nav: { pathname: string; searchParams: [string, string][] };
params: Record<string, string | string[]>;
} {
const start = html.indexOf(RSC_BOOTSTRAP_PREFIX);
expect(start, "navigation runtime RSC bootstrap script not found").toBeGreaterThan(-1);
const paramsStart = start + RSC_BOOTSTRAP_PREFIX.length;
const navSeparator = html.indexOf(",nav:", paramsStart);
expect(navSeparator, "navigation runtime nav payload not found").toBeGreaterThan(-1);
const end = html.indexOf("})</script>", navSeparator);
expect(end, "navigation runtime RSC bootstrap script was not closed").toBeGreaterThan(-1);
return {
params: JSON.parse(html.slice(paramsStart, navSeparator)),
nav: JSON.parse(html.slice(navSeparator + ",nav:".length, end)),
};
}
describe("navigation runtime RSC bootstrap: nav context embedded for hydration snapshot consistency", () => {
// ── 1. Script tag is present ──────────────────────────────────────────────
it("HTML contains a navigation runtime nav bootstrap payload", async () => {
const res = await fetch(`${_baseUrl}${NAV_ROUTE}`);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain(RSC_BOOTSTRAP_PREFIX);
expect(html).toContain(",nav:");
});
it("HTML contains a navigation runtime params bootstrap payload", async () => {
const res = await fetch(`${_baseUrl}${NAV_ROUTE}`);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain(RSC_BOOTSTRAP_PREFIX);
expect(html).toContain("{params:");
});
// ── 2. Pathname is correct ────────────────────────────────────────────────
it("navigation runtime pathname matches the request pathname", async () => {
const res = await fetch(`${_baseUrl}${NAV_ROUTE}`);
const html = await res.text();
const { nav } = extractRscBootstrap(html);
expect(nav.pathname).toBe(NAV_ROUTE);
});
it("navigation runtime pathname agrees with SSR-rendered usePathname() output", async () => {
const res = await fetch(`${_baseUrl}${NAV_ROUTE}`);
const html = await res.text();
// The "use client" NavInfo component renders usePathname() into #nav-pathname.
// During SSR this is resolved from the navigation context (the same source
// as the embedded bootstrap). Both must agree — if getServerSnapshot returns the
// embedded pathname and it matches the SSR-rendered value, React won't detect
// a mismatch.
expect(html).toContain(`<span id="nav-pathname">${NAV_ROUTE}</span>`);
const { nav } = extractRscBootstrap(html);
expect(nav.pathname).toBe(NAV_ROUTE);
});
// ── 3. searchParams are correct (no query string) ─────────────────────────
it("navigation runtime searchParams is empty array when no query string", async () => {
const res = await fetch(`${_baseUrl}${NAV_ROUTE}`);
const html = await res.text();
const { nav } = extractRscBootstrap(html);
// searchParams is serialised as [...urlSearchParams.entries()] — an array of
// [key, value] pairs — to preserve duplicate keys (e.g. ?tag=a&tag=b).
// With no query string it should be an empty array.
expect(nav.searchParams).toEqual([]);
});
it("SSR-rendered useSearchParams() returns empty string when no query string", async () => {
const res = await fetch(`${_baseUrl}${NAV_ROUTE}`);
const html = await res.text();
// #nav-search-q renders searchParams.get("q") ?? "" — should be ""
expect(html).toContain('<span id="nav-search-q"></span>');
// #nav-search-string renders searchParams.toString() — should be ""
expect(html).toContain('<span id="nav-search-string"></span>');
});
// ── 4. searchParams are correct (with query string) ───────────────────────
//
// This is the critical hydration parity test. Without the navigation bootstrap:
// - SSR renders: <span id="nav-search-q">hello</span>
// - getServerSnapshot returns: new URLSearchParams() → ""
// → React sees "hello" ≠ "" → hydration mismatch error #418
//
// With the navigation bootstrap:
// - SSR renders: <span id="nav-search-q">hello</span>
// - browser entry calls setNavigationContext with new URLSearchParams([["q","hello"]])
// - getServerSnapshot returns: "hello"
// → React sees "hello" = "hello" → no mismatch
it("navigation runtime searchParams carries query params from request URL", async () => {
const res = await fetch(`${_baseUrl}${NAV_ROUTE}?q=hello&page=3`);
const html = await res.text();
const { nav } = extractRscBootstrap(html);
// Serialised as array of [key, value] pairs to preserve duplicates.
expect(nav.searchParams).toEqual([
["q", "hello"],
["page", "3"],
]);
});
it("navigation runtime searchParams agrees with SSR-rendered useSearchParams() output", async () => {
const res = await fetch(`${_baseUrl}${NAV_ROUTE}?q=hello&page=3`);
const html = await res.text();
// SSR-rendered output from the "use client" component
expect(html).toContain('<span id="nav-search-q">hello</span>');
expect(html).toContain('<span id="nav-search-page">3</span>');
// Embedded payload must match
const { nav } = extractRscBootstrap(html);
// new URLSearchParams(nav.searchParams) reconstructs the same params
const sp = new URLSearchParams(nav.searchParams);
expect(sp.get("q")).toBe("hello");
expect(sp.get("page")).toBe("3");
});
it("SSR-rendered useSearchParams() reflects query params (confirms parity source)", async () => {
const res = await fetch(`${_baseUrl}${NAV_ROUTE}?q=hello&page=3`);
const html = await res.text();
// The SSR path: RSC environment sets navigation context from request URL,
// passes it to SSR environment via handleSsr(rscStream, navContext, ...).
// The SSR environment's useSearchParams() reads navContext.searchParams.
// The embedded navigation payload comes from the same navContext.
// Both should reflect the request query string.
expect(html).toContain('<span id="nav-search-q">hello</span>');
expect(html).toContain('<span id="nav-search-page">3</span>');
expect(html).toContain(`<span id="nav-pathname">${NAV_ROUTE}</span>`);
});
// ── 5. Special characters in searchParams are safely serialised ───────────
it("navigation runtime searchParams handles special characters without XSS", async () => {
// Ensure the JSON serialisation (safeJsonStringify) encodes characters
// that could break out of a <script> tag if embedded raw.
// The server uses safeJsonStringify which encodes < > & / to unicode escapes.
const specialQ = "foo<bar>&baz";
const res = await fetch(`${_baseUrl}${NAV_ROUTE}?q=${encodeURIComponent(specialQ)}`);
const html = await res.text();
// The raw string must NOT appear literally inside the <script> tag —
// safeJsonStringify encodes < and > as \u003c / \u003e.
// A naive JSON.stringify would emit: {"q":"foo<bar>&baz"} which allows
// </script> injection.
expect(html).not.toContain(`"q":"${specialQ}"`);
// But when we parse the embedded JSON, the value round-trips correctly.
const { nav } = extractRscBootstrap(html);
const sp = new URLSearchParams(nav.searchParams);
expect(sp.get("q")).toBe(specialQ);
});
// 6. Bootstrap params for dynamic segment routes
//
// When the route has a dynamic segment (e.g. /nav-context-hydration/hello),
// The bootstrap params must carry { id: "hello" }. The browser entry passes
// them to setNavigationContext(), so useParams() returns the right value
// during client hydration.
it("navigation runtime params contains dynamic segment value for [id] route", async () => {
const res = await fetch(`${_baseUrl}${NAV_ROUTE}/hello`);
expect(res.status).toBe(200);
const html = await res.text();
const { params } = extractRscBootstrap(html);
expect(params.id).toBe("hello");
});
it("navigation runtime pathname is correct for dynamic segment route", async () => {
const dynamicPath = `${NAV_ROUTE}/hello`;
const res = await fetch(`${_baseUrl}${dynamicPath}`);
const html = await res.text();
const { nav } = extractRscBootstrap(html);
expect(nav.pathname).toBe(dynamicPath);
});
it("navigation runtime pathname agrees with SSR-rendered usePathname() for dynamic route", async () => {
const dynamicPath = `${NAV_ROUTE}/hello`;
const res = await fetch(`${_baseUrl}${dynamicPath}`);
const html = await res.text();
// The "use client" NavInfo component also renders on the dynamic page.
// Its usePathname() output must match the embedded bootstrap pathname.
expect(html).toContain(`<span id="nav-pathname">${dynamicPath}</span>`);
const { nav } = extractRscBootstrap(html);
expect(nav.pathname).toBe(dynamicPath);
});
// ── 7. Script tags appear before </head> ─────────────────────────────────
//
// Navigation and params are injected into <head> as one runtime bootstrap.
// It must appear before </head> so the state is always
// available by the time the bootstrap script runs and calls main().
// If they were injected into the body stream they could arrive after
// hydrateRoot() is called, making the hydration snapshot stale.
it("navigation runtime nav and params are injected before </head>", async () => {
const res = await fetch(`${_baseUrl}${NAV_ROUTE}?q=timing`);
const html = await res.text();
const headEnd = html.indexOf("</head>");
expect(headEnd).toBeGreaterThan(-1);
const bootstrapIdx = html.indexOf(RSC_BOOTSTRAP_PREFIX);
expect(bootstrapIdx).toBeGreaterThan(-1);
expect(bootstrapIdx).toBeLessThan(headEnd);
});
// ── 8. pathname uses the clean path, not the raw URL ─────────────────────
//
// The navigation context is built from cleanPathname (the URL pathname with
// the trailing slash normalised). This must be the value embedded in
// the navigation bootstrap, not the raw URL including query string.
it("navigation runtime pathname does not include query string", async () => {
const res = await fetch(`${_baseUrl}${NAV_ROUTE}?q=shouldnotbehere`);
const html = await res.text();
const { nav } = extractRscBootstrap(html);
// pathname must be just the path, not "…?q=shouldnotbehere"
expect(nav.pathname).not.toContain("?");
expect(nav.pathname).not.toContain("shouldnotbehere");
expect(nav.pathname).toBe(NAV_ROUTE);
});
});