Files
cloudflare__vinext/tests/app-ssr-stream.test.ts
T
Nathan Nguyen 0d8d3e3c60 refactor(router): quarantine navigation runtime topology (#1322)
* refactor(router): quarantine navigation runtime topology

* fix(router): restore manifest-backed interception runtime

App Router browser navigations lost first-hop intercepted route context after snapshot topology stopped acting as fallback proof. That made /feed to /photos/:id request the direct page payload instead of the declared modal payload.

The violated invariant was that route topology must come from the manifest, while snapshots only carry runtime proof. Resolve first-hop interception from the manifest, move RSC bootstrap streams onto the symbol-backed runtime, teach prerender extraction that script shape, and harden the runtime contract around route manifest and RSC data.

Coverage includes runtime seam tests, prerender extraction tests, planner/browser navigation tests, package rebuild, and the app-router intercepted routes E2E slice.

* fix(router): narrow prerender RSC script parsing

The prerender extractor accepted any script starting with the symbol-runtime bootstrap expression, then parsed it as though it had the exact RSC chunk-push prefix. That was harmless at the current head, but it made future runtime scripts a foot-gun.

Only the exact runtime chunk prefix and exact done marker are now accepted. A focused extractor test covers non-chunk runtime scripts that share the bootstrap expression prefix.

* fix(router): address navigation runtime review

* fix(router): resolve navigation runtime review threads
2026-05-20 10:29:13 +01:00

169 lines
6.4 KiB
TypeScript

import { describe, it, expect } from "vite-plus/test";
import {
createRscEmbedTransform,
fixFlightHints,
fixPreloadAs,
} from "../packages/vinext/src/server/app-ssr-stream.js";
describe("App SSR stream helpers", () => {
describe("fixPreloadAs", () => {
it('replaces as="stylesheet" with as="style" for preload links', () => {
expect(
fixPreloadAs('<link rel="preload" href="/assets/index-hG1v95Xi.css" as="stylesheet"/>'),
).toBe('<link rel="preload" href="/assets/index-hG1v95Xi.css" as="style"/>');
expect(fixPreloadAs('<link as="stylesheet" rel="preload" href="/file.css"/>')).toBe(
'<link as="style" rel="preload" href="/file.css"/>',
);
});
it("leaves non-preload links and other preload types unchanged", () => {
expect(fixPreloadAs('<link rel="stylesheet" href="/file.css" as="stylesheet"/>')).toBe(
'<link rel="stylesheet" href="/file.css" as="stylesheet"/>',
);
expect(fixPreloadAs('<link rel="preload" href="/font.woff2" as="font"/>')).toBe(
'<link rel="preload" href="/font.woff2" as="font"/>',
);
expect(fixPreloadAs('<link rel="preload" href="/a.css" as="style"/>')).toBe(
'<link rel="preload" href="/a.css" as="style"/>',
);
});
it("handles multiple preload links in a single chunk", () => {
const html =
'<link rel="preload" href="/a.css" as="stylesheet"/><link rel="preload" href="/b.css" as="stylesheet"/>';
expect(fixPreloadAs(html)).toBe(
'<link rel="preload" href="/a.css" as="style"/><link rel="preload" href="/b.css" as="style"/>',
);
});
});
describe("fixFlightHints", () => {
it("rewrites stylesheet hints in Flight HL records", () => {
expect(fixFlightHints(':HL["/assets/index.css","stylesheet"]')).toBe(
':HL["/assets/index.css","style"]',
);
expect(fixFlightHints('2:HL["/assets/index.css","stylesheet",{"crossOrigin":""}]')).toBe(
'2:HL["/assets/index.css","style",{"crossOrigin":""}]',
);
});
it("leaves unrelated content unchanged", () => {
expect(
fixFlightHints(
'0:D{"name":"index"}\n1:["$","link",null,{"rel":"stylesheet","href":"/file.css"}]',
),
).toBe('0:D{"name":"index"}\n1:["$","link",null,{"rel":"stylesheet","href":"/file.css"}]');
expect(fixFlightHints('2:HL["/font.woff2","font"]')).toBe('2:HL["/font.woff2","font"]');
expect(fixFlightHints(':HL["/font.woff2","font"]')).toBe(':HL["/font.woff2","font"]');
});
it("handles multiple hints in a single chunk", () => {
expect(fixFlightHints('2:HL["/a.css","stylesheet"]\n3:HL["/b.css","stylesheet"]')).toBe(
'2:HL["/a.css","style"]\n3:HL["/b.css","style"]',
);
expect(fixFlightHints(':HL["/a.css","stylesheet"]\n:HL["/b.css","stylesheet"]')).toBe(
':HL["/a.css","style"]\n:HL["/b.css","style"]',
);
});
});
});
function createTextStream(chunks: string[]): ReadableStream<Uint8Array> {
return new ReadableStream({
start(controller) {
for (const chunk of chunks) {
controller.enqueue(new TextEncoder().encode(chunk));
}
controller.close();
},
});
}
function createByteStream(chunks: Uint8Array[]): ReadableStream<Uint8Array> {
return new ReadableStream({
start(controller) {
for (const chunk of chunks) {
controller.enqueue(chunk);
}
controller.close();
},
});
}
describe("createRscEmbedTransform raw buffer (#981)", () => {
it("accumulates raw bytes while producing embed scripts", async () => {
const sideStream = createTextStream(["chunk1", "chunk2"]);
const transform = createRscEmbedTransform(sideStream);
// Let the reader pump all chunks
const rawBuffer = await transform.getRawBuffer();
expect(rawBuffer).toBeInstanceOf(ArrayBuffer);
expect(new TextDecoder().decode(rawBuffer)).toBe("chunk1chunk2");
// Embed scripts still work
const finalScripts = await transform.finalize();
expect(finalScripts).toContain('Symbol.for("vinext.navigationRuntime")');
expect(finalScripts).toContain(".done=true");
expect(finalScripts).toContain('.rsc.push("chunk1")');
expect(finalScripts).toContain('.rsc.push("chunk2")');
});
it("rejects getRawBuffer when the stream errors (#1002)", async () => {
const errorStream = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode("partial"));
controller.error(new Error("stream broke"));
},
});
const transform = createRscEmbedTransform(errorStream);
await expect(transform.getRawBuffer()).rejects.toThrow("stream broke");
});
it("preserves raw bytes before fixFlightHints transform", async () => {
// Flight hints use as="stylesheet" which get fixed to as="style" in the
// embed transform. Raw bytes must be the unmodified originals.
const sideStream = createTextStream([':HL["/a.css","stylesheet"]']);
const transform = createRscEmbedTransform(sideStream);
const rawBuffer = await transform.getRawBuffer();
const rawText = new TextDecoder().decode(rawBuffer);
// Raw bytes: unmodified originals (not fixed)
expect(rawText).toBe(':HL["/a.css","stylesheet"]');
// finalize() returns the embed scripts with fixed hints
const finalScripts = await transform.finalize();
// The fixed text "as=\"style\"" appears in the embed script after JSON escaping.
// fixFlightHints turns "stylesheet" → "style" before the chunk is script-wrapped.
expect(finalScripts).not.toContain("stylesheet");
expect(finalScripts).toContain(".done=true");
});
it("embeds non-UTF-8 RSC chunks as base64 binary chunks", async () => {
// Ported from Next.js: test/e2e/app-dir/binary/rsc-binary.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/binary/rsc-binary.test.ts
const transform = createRscEmbedTransform(
createByteStream([new Uint8Array([0xff, 0, 1, 2, 3])]),
);
const finalScripts = await transform.finalize();
expect(finalScripts).toContain('.rsc.push([3,"/wABAgM="])');
});
it("does not lose incomplete UTF-8 bytes before a binary chunk", async () => {
const transform = createRscEmbedTransform(
createByteStream([new Uint8Array([0x41, 0xc3]), new Uint8Array([0xff])]),
);
const finalScripts = await transform.finalize();
expect(finalScripts).toContain('.rsc.push([3,"QcM="])');
expect(finalScripts).toContain('.rsc.push([3,"/w=="])');
});
});