Files
cloudflare__vinext/tests/app-ssr-stream.test.ts
T
James Anderson 4ffd5bf225 fix(app-router): ensure streamed SSR body ends with </body></html> (#1532) (#1624)
* fix(app-router): ensure SSR stream ends with </body></html> suffix (#1532)

Trailing flight chunks and preinit scripts emitted by `rscEmbed.finalize()`
in the tick-buffered transform's `flush()` were being appended after React
Fizz's `</body></html>` closing tags, leaving the streamed document body
ending in `<script>...</script>` rather than a well-formed close.

Strip the first `</body></html>` we see in the buffered HTML chunks and
re-emit it at the very end of `flush()`, after the final RSC scripts.
Mirrors Next.js's `createMoveSuffixStream` behaviour
(packages/next/src/server/stream-utils/node-web-streams-helper.ts).

Ported from Next.js test:
test/e2e/app-dir/app/index.test.ts — "should ensure the </body></html>
suffix is at the end of the stream".

Fixes #1532.

* test(rsc-streaming): adjust progressive-delivery assertion for #1532

The 'delivers RSC chunks progressively' case still asserted the old order
where the navigation-runtime done signal trailed </html>. After #1532
suffix-moved </body></html> to the very end of the stream, the done
signal sits *between* the body content and the document close. Update
the assertion to match: done > last body content, and </html> > done.

* style: apply biome formatter to rsc-streaming.test.ts
2026-06-03 22:42:26 +01:00

755 lines
29 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();
},
});
}
function createNoopRscEmbedTransform() {
return {
flush: () => "",
finalize: async () => "",
getRawBuffer: async () => new ArrayBuffer(0),
};
}
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;
inlineCss?: Record<string, string>;
inlineCssPrependCss?: string;
inlineCssPrependFallbackHTML?: string;
inlineCssScriptNonce?: string;
} = {},
): Promise<string> {
const transform = createTickBufferedTransform(
createNoopRscEmbedTransform(),
options.injectHTML ?? "",
options.injectAfterHeadOpenHTML ?? "",
options.inlineCss,
options.inlineCssPrependCss,
options.inlineCssPrependFallbackHTML,
options.inlineCssScriptNonce,
);
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;
}
async function runDelayedTransform(
chunks: string[],
options: {
inlineCss?: Record<string, string>;
} = {},
): Promise<string> {
const encoder = new TextEncoder();
const source = new ReadableStream<Uint8Array>({
start(controller) {
let index = 0;
const enqueueNext = (): void => {
const chunk = chunks[index++];
if (chunk === undefined) {
controller.close();
return;
}
controller.enqueue(encoder.encode(chunk));
setTimeout(enqueueNext, 5);
};
enqueueNext();
},
});
return new Response(
source.pipeThrough(
createTickBufferedTransform(createNoopRscEmbedTransform(), "", "", options.inlineCss),
),
).text();
}
async function readSingleTransformChunk(
transform: TransformStream<Uint8Array, Uint8Array>,
chunk: string,
): Promise<string> {
const source = new TransformStream<Uint8Array, Uint8Array>();
const reader = source.readable.pipeThrough(transform).getReader();
const writer = source.writable.getWriter();
await writer.write(new TextEncoder().encode(chunk));
const result = await reader.read();
await writer.close();
await reader.cancel();
if (result.done) {
throw new Error("Expected transform to emit a chunk");
}
return new TextDecoder().decode(result.value);
}
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>");
});
// Ported from Next.js: test/e2e/app-dir/app/index.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/app/index.test.ts
// (regression for #1532)
describe("</body></html> suffix is the last thing in the stream", () => {
it("moves the </body></html> suffix to the end after trailing scripts and RSC chunks", async () => {
// Simulate React Fizz emitting the closing tags BEFORE flush appends
// trailing flight chunks / preinit scripts.
const rsc = {
flush: () => "",
finalize: async () => '<script id="trailing-rsc">rsc()</script>',
getRawBuffer: async () => new ArrayBuffer(0),
};
const transform = createTickBufferedTransform(rsc, "", "");
const source = createTextStream([
"<!DOCTYPE html><html><head></head><body><div>hi</div></body></html>",
]);
const out = await new Response(source.pipeThrough(transform)).text();
const suffix = "</body></html>";
expect(out.endsWith(suffix)).toBe(true);
// Only one occurrence — the suffix must not appear in the middle.
expect(out.slice(0, -suffix.length)).not.toContain(suffix);
// Trailing scripts land before the suffix, not after it.
expect(out).toContain('<script id="trailing-rsc">rsc()</script></body></html>');
});
it("ensures the suffix is at the end even when injectHTML fallback fires", async () => {
// When `<head>` never appears, injectHTML falls back to end-of-stream
// emission. The closing tags must still come last.
const rsc = {
flush: () => "",
finalize: async () => "",
getRawBuffer: async () => new ArrayBuffer(0),
};
const transform = createTickBufferedTransform(rsc, "<meta data-injected='1'/>", "");
const source = createTextStream(["<!DOCTYPE html><html><body></body></html>"]);
const out = await new Response(source.pipeThrough(transform)).text();
const suffix = "</body></html>";
expect(out.endsWith(suffix)).toBe(true);
expect(out.slice(0, -suffix.length)).not.toContain(suffix);
expect(out).toContain("<meta data-injected='1'/>");
});
it("adds the suffix at the end even when the source stream omits it", async () => {
// Defense-in-depth: if React Fizz somehow ends without `</body></html>`,
// we still emit a well-formed document close.
const rsc = {
flush: () => "",
finalize: async () => "",
getRawBuffer: async () => new ArrayBuffer(0),
};
const transform = createTickBufferedTransform(rsc, "", "");
const source = createTextStream(["<!DOCTYPE html><html><head></head><body>oops"]);
const out = await new Response(source.pipeThrough(transform)).text();
expect(out.endsWith("</body></html>")).toBe(true);
});
});
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 transform = createTickBufferedTransform(createNoopRscEmbedTransform(), "", () => {
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);
});
});
describe("createTickBufferedTransform inline CSS", () => {
it("replaces React stylesheet links with hoistable inline style tags", async () => {
const out = await runTransform(
[
'<html><head><link rel="stylesheet" href="/_next/static/app.css" data-precedence="vite-rsc/importer-resources"/></head><body></body></html>',
],
{
inlineCss: {
"/_next/static/app.css": "p { color: yellow; }",
},
},
);
expect(out).not.toContain('rel="stylesheet"');
expect(out).toContain(
'<style data-vinext-inline-css data-precedence="vite-rsc/importer-resources" data-href="/_next/static/app.css">p { color: yellow; }</style>',
);
});
it("replaces stylesheet links when rel contains multiple tokens", async () => {
const out = await runTransform(
[
'<html><head><link rel="preload stylesheet" as="stylesheet" href="/_next/static/app.css" data-precedence="next"/></head></html>',
],
{
inlineCss: {
"/_next/static/app.css": "p { color: yellow; }",
},
},
);
expect(out).toContain("data-vinext-inline-css");
expect(out).toContain("p { color: yellow; }");
expect(out).not.toContain('rel="preload stylesheet"');
});
it("replaces stylesheet links split across stream chunks", async () => {
const out = await runTransform(
[
'<html><head><link rel="style',
'sheet" href="/_next/static/app.css" data-precedence="next"/></head></html>',
],
{
inlineCss: {
"/_next/static/app.css": "p { color: yellow; }",
},
},
);
expect(out).toContain("data-vinext-inline-css");
expect(out).toContain("p { color: yellow; }");
expect(out).not.toContain('<link rel="stylesheet"');
});
it("does not buffer incomplete link-like text when inline CSS is disabled", async () => {
const transform = createTickBufferedTransform(createNoopRscEmbedTransform());
const out = await readSingleTransformChunk(
transform,
"<html><body><script>const marker = '<link'",
);
expect(out).toContain("const marker = '<link'");
});
it("leaves stylesheet links intact when the emitted CSS asset is not in the inline manifest", async () => {
const html =
'<html><head><link rel="stylesheet" href="/_next/static/missing.css" data-precedence="vite-rsc/importer-resources"/></head><body></body></html>';
await expect(
runTransform([html], {
inlineCss: {
"/_next/static/app.css": "p { color: yellow; }",
},
}),
).resolves.toContain('href="/_next/static/missing.css"');
});
it("does not rewrite preload or modulepreload links", async () => {
const html =
'<html><head><link rel="preload" href="/_next/static/app.css" as="stylesheet"/><link rel="modulepreload" href="/_next/static/app.js"/></head></html>';
const out = await runTransform([html], {
inlineCss: {
"/_next/static/app.css": "p { color: yellow; }",
},
});
expect(out).toContain('<link rel="preload" href="/_next/static/app.css" as="style"/>');
expect(out).toContain('<link rel="modulepreload" href="/_next/static/app.js"/>');
expect(out).not.toContain("data-vinext-inline-css");
});
it("escapes CSS closing style tags before embedding in HTML", async () => {
const out = await runTransform(
[
'<html><head><link rel="stylesheet" href="/_next/static/app.css" data-precedence="next"/></head></html>',
],
{
inlineCss: {
"/_next/static/app.css": "body::before { content: '</style><script>x</script>'; }",
},
},
);
expect(out).toContain("<\\/style><script>x</script>");
expect(out).not.toContain("</style><script>x</script>");
});
it("matches absolute assetPrefix stylesheet URLs by pathname fallback", async () => {
const out = await runTransform(
[
'<html><head><link rel="stylesheet" href="https://cdn.example.com/_next/static/app.css" data-precedence="next"/></head></html>',
],
{
inlineCss: {
"/_next/static/app.css": "p { color: yellow; }",
},
},
);
expect(out).toContain('data-href="https://cdn.example.com/_next/static/app.css"');
expect(out).toContain("p { color: yellow; }");
});
it("prepends SSR font CSS to the first inlined stylesheet", async () => {
const out = await runTransform(
[
'<html><head><link rel="stylesheet" href="/_next/static/app.css" data-precedence="next"/><link rel="stylesheet" href="/_next/static/route.css" data-precedence="next"/></head></html>',
],
{
inlineCss: {
"/_next/static/app.css": "p { color: yellow; }",
"/_next/static/route.css": ".page { font-size: 100px; }",
},
inlineCssPrependCss:
"@font-face { font-family: '__local_font_0'; src: url('/_next/static/font.woff2'); }",
inlineCssPrependFallbackHTML:
"<style data-vinext-fonts>@font-face { font-family: '__local_font_0'; src: url('/_next/static/font.woff2'); }</style>",
},
);
const firstStyleStart = out.indexOf("<style");
const secondStyleStart = out.indexOf("<style", firstStyleStart + 1);
expect(firstStyleStart).toBeGreaterThanOrEqual(0);
expect(secondStyleStart).toBeGreaterThan(firstStyleStart);
expect(out.slice(firstStyleStart, secondStyleStart)).toContain("@font-face");
expect(out.slice(secondStyleStart)).not.toContain("@font-face");
expect(out).not.toContain("data-vinext-fonts");
});
it("does not prepend SSR font CSS before stylesheet imports", async () => {
const out = await runTransform(
[
'<html><head><link rel="stylesheet" href="/_next/static/app.css" data-precedence="next"/></head></html>',
],
{
inlineCss: {
"/_next/static/app.css": '@import url("/reset.css");\np { color: yellow; }',
},
inlineCssPrependCss:
"@font-face { font-family: '__local_font_0'; src: url('/_next/static/font.woff2'); }",
inlineCssPrependFallbackHTML:
"<style data-vinext-fonts>@font-face { font-family: '__local_font_0'; src: url('/_next/static/font.woff2'); }</style>",
},
);
const inlineStyleStart = out.indexOf("<style data-vinext-inline-css");
const inlineStyleEnd = out.indexOf("</style>", inlineStyleStart);
const inlineStyle = out.slice(inlineStyleStart, inlineStyleEnd);
const fallbackStyleStart = out.indexOf("<style data-vinext-fonts>");
expect(inlineStyle).toContain('>@import url("/reset.css");');
expect(inlineStyle).not.toContain("@font-face");
expect(fallbackStyleStart).toBeGreaterThan(inlineStyleEnd);
});
it("does not prepend SSR font CSS before stylesheet namespaces", async () => {
const out = await runTransform(
[
'<html><head><link rel="stylesheet" href="/_next/static/app.css" data-precedence="next"/></head></html>',
],
{
inlineCss: {
"/_next/static/app.css":
'@namespace svg url("http://www.w3.org/2000/svg");\nsvg|a { color: red; }',
},
inlineCssPrependCss:
"@font-face { font-family: '__local_font_0'; src: url('/_next/static/font.woff2'); }",
inlineCssPrependFallbackHTML:
"<style data-vinext-fonts>@font-face { font-family: '__local_font_0'; src: url('/_next/static/font.woff2'); }</style>",
},
);
const inlineStyleStart = out.indexOf("<style data-vinext-inline-css");
const inlineStyleEnd = out.indexOf("</style>", inlineStyleStart);
const inlineStyle = out.slice(inlineStyleStart, inlineStyleEnd);
const fallbackStyleStart = out.indexOf("<style data-vinext-fonts>");
expect(inlineStyle).toContain('>@namespace svg url("http://www.w3.org/2000/svg");');
expect(inlineStyle).not.toContain("@font-face");
expect(fallbackStyleStart).toBeGreaterThan(inlineStyleEnd);
});
it("does not rewrite link-like text inside inline scripts", async () => {
const fakeLink = '<link rel="stylesheet" href="/_next/static/app.css" data-precedence="next">';
const out = await runTransform(
[
`<html><head><script>const linkMarkup = '${fakeLink}';</script><link rel="stylesheet" href="/_next/static/app.css" data-precedence="next"/></head></html>`,
],
{
inlineCss: {
"/_next/static/app.css": "p { color: yellow; }",
},
},
);
const scriptStart = out.indexOf("<script>");
const scriptEnd = out.indexOf("</script>", scriptStart);
const scriptHtml = out.slice(scriptStart, scriptEnd);
const afterScript = out.slice(scriptEnd);
expect(scriptHtml).toContain(fakeLink);
expect(scriptHtml).not.toContain("data-vinext-inline-css");
expect(afterScript).toContain(
'<style data-vinext-inline-css data-precedence="next" data-href="/_next/static/app.css">p { color: yellow; }</style>',
);
});
it("does not rewrite link-like text inside inline scripts split across stream chunks", async () => {
const fakeLink = '<link rel="stylesheet" href="/_next/static/app.css" data-precedence="next">';
const out = await runDelayedTransform(
[
`<html><head><script>const linkMarkup = '${fakeLink.slice(0, 30)}`,
`${fakeLink.slice(30)}';</script><link rel="stylesheet" href="/_next/static/app.css" data-precedence="next"/></head></html>`,
],
{
inlineCss: {
"/_next/static/app.css": "p { color: yellow; }",
},
},
);
const scriptStart = out.indexOf("<script>");
const scriptEnd = out.indexOf("</script>", scriptStart);
const scriptHtml = out.slice(scriptStart, scriptEnd);
const afterScript = out.slice(scriptEnd);
expect(scriptHtml).toContain(fakeLink);
expect(scriptHtml).not.toContain("data-vinext-inline-css");
expect(afterScript).toContain(
'<style data-vinext-inline-css data-precedence="next" data-href="/_next/static/app.css">p { color: yellow; }</style>',
);
});
it("does not rewrite link-like text inside unterminated inline scripts", async () => {
const fakeLink = '<link rel="stylesheet" href="/_next/static/app.css" data-precedence="next">';
const out = await runTransform([`<html><head><script>const linkMarkup = '${fakeLink}';`], {
inlineCss: {
"/_next/static/app.css": "p { color: yellow; }",
},
});
expect(out).toContain(fakeLink);
expect(out).not.toContain("data-vinext-inline-css");
});
it("emits fallback SSR font CSS when no stylesheet link is inlined", async () => {
const out = await runTransform(["<html><head></head><body>No CSS link</body></html>"], {
inlineCss: {
"/_next/static/app.css": "p { color: yellow; }",
},
inlineCssPrependCss:
"@font-face { font-family: '__local_font_0'; src: url('/_next/static/font.woff2'); }",
inlineCssPrependFallbackHTML:
"<style data-vinext-fonts>@font-face { font-family: '__local_font_0'; src: url('/_next/static/font.woff2'); }</style>",
});
expect(out).toContain("<style data-vinext-fonts>");
expect(out).toContain("@font-face");
});
it("forwards the SSR script nonce onto inline style tags when the link has none", async () => {
// Without this, sites running `Content-Security-Policy: style-src 'nonce-…'`
// see the inlined `<style>` blocked at parse and render unstyled. React Fizz
// typically doesn't emit a nonce on the `<link>` it rewrites, so the SSR
// nonce is the only source for the inline style.
const out = await runTransform(
[
'<html><head><link rel="stylesheet" href="/_next/static/app.css" data-precedence="next"/></head></html>',
],
{
inlineCss: { "/_next/static/app.css": "p { color: yellow; }" },
inlineCssScriptNonce: "abc123",
},
);
expect(out).toContain('<style data-vinext-inline-css nonce="abc123"');
expect(out).toContain("p { color: yellow; }");
});
it("prefers the link's own nonce over the SSR script nonce when both are present", async () => {
// If Fizz ever does emit a nonce on the source `<link>`, that nonce was
// chosen for this resource specifically and should win.
const out = await runTransform(
[
'<html><head><link rel="stylesheet" nonce="link-nonce" href="/_next/static/app.css" data-precedence="next"/></head></html>',
],
{
inlineCss: { "/_next/static/app.css": "p { color: yellow; }" },
inlineCssScriptNonce: "ssr-nonce",
},
);
expect(out).toContain('nonce="link-nonce"');
expect(out).not.toContain('nonce="ssr-nonce"');
});
it("omits the nonce attribute when neither the link nor the SSR provides one", async () => {
const out = await runTransform(
[
'<html><head><link rel="stylesheet" href="/_next/static/app.css" data-precedence="next"/></head></html>',
],
{
inlineCss: { "/_next/static/app.css": "p { color: yellow; }" },
},
);
expect(out).toContain("<style data-vinext-inline-css");
expect(out).not.toContain("nonce=");
});
});