Files
cloudflare__vinext/tests/app-browser-stream.test.ts
T
Nathan Nguyen 5d622369dc fix(rsc): preserve binary inlined Flight chunks (#1245)
Inlined App Router Flight data could only travel through string chunks. That corrupted arbitrary binary RSC chunks during SSR hydration and static prerender extraction because non-UTF-8 bytes were decoded to replacement characters before React read them.

Encode non-UTF-8 embedded chunks as base64 binary records, decode those records back to bytes in the browser, and write prerendered .rsc payloads as bytes instead of UTF-8 text.

Regression coverage exercises server embedding, browser reconstruction, and prerender extraction for binary RSC chunks.
2026-05-16 17:21:10 +01:00

200 lines
6.8 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
chunksToReadableStream,
createProgressiveRscStream,
getVinextBrowserGlobal,
} from "../packages/vinext/src/server/app-browser-stream.js";
const originalDocument = globalThis.document;
const vinext = getVinextBrowserGlobal();
function resetBrowserGlobals(): void {
delete vinext.__VINEXT_RSC__;
delete vinext.__VINEXT_RSC_CHUNKS__;
delete vinext.__VINEXT_RSC_DONE__;
delete vinext.__VINEXT_RSC_PARAMS__;
delete vinext.__VINEXT_RSC_NAV__;
}
function setGlobalDocument(value: Document | undefined): void {
if (value === undefined) {
Reflect.deleteProperty(globalThis, "document");
return;
}
Object.defineProperty(globalThis, "document", {
configurable: true,
writable: true,
value,
});
}
async function readText(
reader: ReadableStreamDefaultReader<Uint8Array>,
): Promise<{ done: boolean; text?: string }> {
const result = await reader.read();
return {
done: result.done,
text: result.value ? new TextDecoder().decode(result.value) : undefined,
};
}
async function readBytes(
reader: ReadableStreamDefaultReader<Uint8Array>,
): Promise<{ done: boolean; bytes?: number[] }> {
const result = await reader.read();
return {
done: result.done,
bytes: result.value ? Array.from(result.value) : undefined,
};
}
describe("App browser stream helpers", () => {
beforeEach(() => {
resetBrowserGlobals();
});
afterEach(() => {
resetBrowserGlobals();
setGlobalDocument(originalDocument);
vi.useRealTimers();
});
it("turns embedded chunks into a readable byte stream", async () => {
const reader = chunksToReadableStream(["alpha", "beta"]).getReader();
expect(await readText(reader)).toEqual({ done: false, text: "alpha" });
expect(await readText(reader)).toEqual({ done: false, text: "beta" });
expect(await readText(reader)).toEqual({ done: true, text: undefined });
});
it("turns embedded binary chunks into exact readable bytes", 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 reader = chunksToReadableStream(["text", [3, "/wABAgM="]]).getReader();
expect(await readText(reader)).toEqual({ done: false, text: "text" });
expect(await readBytes(reader)).toEqual({ done: false, bytes: [255, 0, 1, 2, 3] });
expect(await readBytes(reader)).toEqual({ done: true, bytes: undefined });
});
it("replays existing chunks and streams future pushes immediately", async () => {
const listeners = new Map<string, () => void>();
setGlobalDocument({
readyState: "loading",
addEventListener: vi.fn((event: string, callback: EventListenerOrEventListenerObject) => {
listeners.set(event, callback as () => void);
}),
removeEventListener: vi.fn(),
} as unknown as Document);
vinext.__VINEXT_RSC_CHUNKS__ = ["shell"];
vinext.__VINEXT_RSC_DONE__ = false;
const reader = createProgressiveRscStream().getReader();
expect(await readText(reader)).toEqual({ done: false, text: "shell" });
vinext.__VINEXT_RSC_CHUNKS__!.push("delta");
expect(await readText(reader)).toEqual({ done: false, text: "delta" });
vinext.__VINEXT_RSC_DONE__ = true;
vinext.__VINEXT_RSC_CHUNKS__!.push("final");
expect(await readText(reader)).toEqual({ done: false, text: "final" });
expect(await readText(reader)).toEqual({ done: true, text: undefined });
expect(listeners.has("DOMContentLoaded")).toBe(true);
});
it("streams every chunk when push receives multiple arguments", async () => {
setGlobalDocument({
readyState: "loading",
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
} as unknown as Document);
vinext.__VINEXT_RSC_CHUNKS__ = [];
vinext.__VINEXT_RSC_DONE__ = false;
const reader = createProgressiveRscStream().getReader();
vinext.__VINEXT_RSC_CHUNKS__!.push("alpha", "beta");
expect(await readText(reader)).toEqual({ done: false, text: "alpha" });
expect(await readText(reader)).toEqual({ done: false, text: "beta" });
vinext.__VINEXT_RSC_DONE__ = true;
vinext.__VINEXT_RSC_CHUNKS__!.push("omega");
expect(await readText(reader)).toEqual({ done: false, text: "omega" });
expect(await readText(reader)).toEqual({ done: true, text: undefined });
});
it("streams progressive binary chunks without UTF-8 replacement", 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
setGlobalDocument({
readyState: "loading",
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
} as unknown as Document);
vinext.__VINEXT_RSC_CHUNKS__ = [];
vinext.__VINEXT_RSC_DONE__ = false;
const reader = createProgressiveRscStream().getReader();
vinext.__VINEXT_RSC_CHUNKS__!.push([3, "/wABAgM="]);
expect(await readBytes(reader)).toEqual({ done: false, bytes: [255, 0, 1, 2, 3] });
vinext.__VINEXT_RSC_DONE__ = true;
vinext.__VINEXT_RSC_CHUNKS__!.push("final");
expect(await readText(reader)).toEqual({ done: false, text: "final" });
expect(await readBytes(reader)).toEqual({ done: true, bytes: undefined });
});
it("errors truncated streams on DOMContentLoaded before the done marker", async () => {
let onDomContentLoaded: (() => void) | undefined;
setGlobalDocument({
readyState: "loading",
addEventListener: vi.fn((event: string, callback: EventListenerOrEventListenerObject) => {
if (event === "DOMContentLoaded") {
onDomContentLoaded = callback as () => void;
}
}),
removeEventListener: vi.fn(),
} as unknown as Document);
vinext.__VINEXT_RSC_CHUNKS__ = [];
vinext.__VINEXT_RSC_DONE__ = false;
const reader = createProgressiveRscStream().getReader();
const pendingRead = readText(reader);
expect(onDomContentLoaded).toBeTypeOf("function");
onDomContentLoaded!();
await expect(pendingRead).rejects.toThrow("The connection to the page was unexpectedly closed");
});
it("defers already-loaded document errors so the done marker can close cleanly", async () => {
vi.useFakeTimers();
setGlobalDocument({
readyState: "complete",
} as unknown as Document);
vinext.__VINEXT_RSC_CHUNKS__ = [];
vinext.__VINEXT_RSC_DONE__ = false;
const reader = createProgressiveRscStream().getReader();
const pendingRead = readText(reader);
vinext.__VINEXT_RSC_DONE__ = true;
vinext.__VINEXT_RSC_CHUNKS__!.push("final");
expect(await pendingRead).toEqual({ done: false, text: "final" });
expect(await readText(reader)).toEqual({ done: true, text: undefined });
vi.runAllTimers();
expect(await readText(reader)).toEqual({ done: true, text: undefined });
});
});