Files
cloudflare__vinext/tests/app-ssr-stream.test.ts
T
Jim Geurts 3d5fc8dd84 fix(app-router): hoist inline beforeInteractive scripts above resource hints (#1558)
* fix(app-router): hoist inline beforeInteractive scripts above resource hints (#1557)

React Fizz puts every stylesheet, modulepreload, and preload link emitted via React Float above user-rendered <script> children of <head>, so an inline `<Script strategy="beforeInteractive">` placed first in the root layout lands near the bottom of head instead — well after styles begin to parse. This breaks the standard no-flash dark-mode pattern.

Capture inline beforeInteractive content via a new BeforeInteractiveContext that the App Router SSR pipeline wraps the rendered tree with. The Script shim registers the escaped innerHTML + nonce + attributes and returns null from render. The stream transform splices the captured tag immediately after `<head ...>` opens — before any chunk that contains React-emitted resource hints. On the client the shim suppresses the inline render when the App Router runtime is present (detected via the new hasAppNavigationRuntimeBootstrap helper) so hydration matches.

External-src and non-App-Router rendering paths are unchanged: external scripts still preload + render in source order, and Pages Router falls through to the existing inline render.

* fix: address PR feedback

- e2e spec now reads `self.__vinextThemeInitRan` (matches the fixture's actual marker; the prior `documentElement.dataset` assertion would always have been undefined)
- gate `renderBeforeInteractiveInlineScripts` on a `/^[a-zA-Z][\w.-]*$/` attribute-name regex so unknown developer-supplied prop keys can't break out of the unquoted name position
- drop unused `BeforeInteractiveProvider` export — the SSR entry uses `BeforeInteractiveContext.Provider` directly
- correct the fixture layout docstring to describe the actual `self.__vinextThemeInitRan` marker and explain why we avoid `<html>` dataset attributes here
- consolidate the duplicate CSP-nonce middleware blocks for `/revalidate-test` and `/beforeinteractive-head-ordering`

* docs/test: address PR nit feedback

- correct the createTickBufferedTransform jsdoc: only `injectHTML` falls back to end-of-stream; `injectAfterHeadOpenHTML` is silently dropped when `<head ...>` is never found in a discoverable chunk (covered by the existing unit test)
- drop the dead pre-request scaffolding from the CSP-nonce test; the meaningful assertions live in the second request, so the first one was just `void html` noise
2026-05-22 16:12:08 +01:00

306 lines
12 KiB
TypeScript

import { describe, it, expect } from "vite-plus/test";
import {
createRscEmbedTransform,
createTickBufferedTransform,
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=="])');
});
});
// ── beforeInteractive pre-head splice ────────────────────────────────────────
/**
* Pipe an HTML string (or chunks) through `createTickBufferedTransform` and
* collect the output. Uses a no-op RscEmbedTransform so we can exercise the
* pre-head splice path in isolation.
*/
async function runTransform(
chunks: string[],
options: {
injectHTML?: string;
injectAfterHeadOpenHTML?: string;
} = {},
): Promise<string> {
const noopRsc = {
flush: () => "",
finalize: async () => "",
getRawBuffer: async () => new ArrayBuffer(0),
};
const transform = createTickBufferedTransform(
noopRsc,
options.injectHTML ?? "",
options.injectAfterHeadOpenHTML ?? "",
);
const source = createTextStream(chunks);
const piped = source.pipeThrough(transform);
const reader = piped.getReader();
const decoder = new TextDecoder();
let out = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
out += decoder.decode(value, { stream: true });
}
out += decoder.decode();
return out;
}
describe("createTickBufferedTransform pre-head splice", () => {
it("emits injectAfterHeadOpenHTML immediately after <head> opens", async () => {
const html =
'<!DOCTYPE html><html><head><link rel="stylesheet" href="/a.css"/></head><body>x</body></html>';
const out = await runTransform([html], {
injectAfterHeadOpenHTML: '<script id="hoisted">init()</script>',
});
// The script must appear AFTER <head> and BEFORE the first link.
const headOpen = out.indexOf("<head>");
const scriptIdx = out.indexOf('<script id="hoisted">');
const stylesheetIdx = out.indexOf('<link rel="stylesheet"');
expect(headOpen).toBeGreaterThan(-1);
expect(scriptIdx).toBeGreaterThan(headOpen);
expect(scriptIdx).toBeLessThan(stylesheetIdx);
});
it("preserves existing injection point before </head>", async () => {
const html = "<!DOCTYPE html><html><head></head><body></body></html>";
const out = await runTransform([html], {
injectHTML: "<meta name='end-of-head'/>",
injectAfterHeadOpenHTML: "<meta name='start-of-head'/>",
});
const startIdx = out.indexOf("<meta name='start-of-head'/>");
const endIdx = out.indexOf("<meta name='end-of-head'/>");
const closeIdx = out.indexOf("</head>");
expect(startIdx).toBeGreaterThan(-1);
expect(endIdx).toBeGreaterThan(-1);
expect(startIdx).toBeLessThan(endIdx);
expect(endIdx).toBeLessThan(closeIdx);
});
it("handles <head> with attributes", async () => {
const html = '<!DOCTYPE html><html><head class="dark"></head><body></body></html>';
const out = await runTransform([html], {
injectAfterHeadOpenHTML: "<script>x</script>",
});
expect(out).toContain('<head class="dark"><script>x</script></head>');
});
it("never re-splices on subsequent chunks", async () => {
const out = await runTransform(
[
"<!DOCTYPE html><html><head>",
'<link rel="stylesheet" href="/a.css"/>',
"</head><body></body></html>",
],
{
injectAfterHeadOpenHTML: "<script>once</script>",
},
);
// Only one occurrence — the marker must not be re-emitted when later
// chunks arrive after the splice already happened.
const matches = [...out.matchAll(/<script>once<\/script>/g)];
expect(matches).toHaveLength(1);
});
it("does not splice when injectAfterHeadOpenHTML is empty", async () => {
const html = "<!DOCTYPE html><html><head></head><body></body></html>";
const out = await runTransform([html], { injectAfterHeadOpenHTML: "" });
expect(out).toBe(html);
});
it("ignores the splice when <head> is missing", async () => {
const html = "<!DOCTYPE html><html><body>no head</body></html>";
const out = await runTransform([html], {
injectAfterHeadOpenHTML: "<script>x</script>",
});
expect(out).not.toContain("<script>x</script>");
});
it("re-evaluates the insertion getter only when splice runs", async () => {
// For the function-shaped getter we need to confirm we read it lazily —
// once at splice time — so callers can pass a getter that snapshots state
// (e.g. captured Script content) that may not be populated until the
// tree has rendered.
let calls = 0;
const noopRsc = {
flush: () => "",
finalize: async () => "",
getRawBuffer: async () => new ArrayBuffer(0),
};
const transform = createTickBufferedTransform(noopRsc, "", () => {
calls++;
return "<script>fn</script>";
});
const source = createTextStream(["<!DOCTYPE html><html><head></head><body></body></html>"]);
const out = await new Response(source.pipeThrough(transform)).text();
expect(out).toContain("<script>fn</script>");
// One call at splice time, one at end-of-stream `flush` for the
// post-injected emit fallback. Don't pin an exact count — just confirm
// it's a small, bounded number rather than per-chunk.
expect(calls).toBeGreaterThanOrEqual(1);
expect(calls).toBeLessThan(10);
});
});