Files
cloudflare__vinext/tests/rsc-streaming.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

666 lines
26 KiB
TypeScript

/**
* Behavioral tests for tick-buffered RSC streaming.
*
* The tick-buffered TransformStream in server/app-ssr-stream.ts interleaves RSC
* <script> tags into the HTML stream between React Fizz flush cycles. These
* tests exercise the real production helpers directly to verify:
*
* 1. RSC scripts are interleaved between HTML flush cycles
* 2. No RSC scripts are injected mid-HTML-chunk (DOM corruption case)
* 3. The navigation runtime RSC done signal appears after all content
* 4. Head injection happens correctly
* 5. Multiple HTML chunks in the same macrotask are batched correctly
*/
import { describe, it, expect } from "vite-plus/test";
import {
createRscEmbedTransform,
createTickBufferedTransform,
} from "../packages/vinext/src/server/app-ssr-stream.js";
import { createSsrErrorMetaRenderer } from "../packages/vinext/src/server/app-ssr-error-meta.js";
/**
* Create a ReadableStream from an array of string chunks, with optional
* delay between groups to simulate React Fizz flush cycles.
*
* Each entry in `chunkGroups` is an array of strings that are written
* synchronously (same macrotask), simulating how Fizz flushes multiple
* chunks within one flushCompletedQueues call.
*
* Between groups, a macrotask boundary is inserted via setTimeout(0).
*/
function createMockHtmlStream(chunkGroups: string[][]): ReadableStream<Uint8Array> {
const encoder = new TextEncoder();
return new ReadableStream({
async start(controller) {
for (let i = 0; i < chunkGroups.length; i++) {
// Write all chunks in this group synchronously (same macrotask)
for (const chunk of chunkGroups[i]) {
controller.enqueue(encoder.encode(chunk));
}
// Wait for next macrotask between groups (simulates Fizz flush boundary)
if (i < chunkGroups.length - 1) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
controller.close();
},
});
}
/**
* Create a mock RSC embed stream that emits chunks with controllable timing.
* Returns the stream and a controller to push chunks / close.
*/
function createMockRscStream() {
let streamController: ReadableStreamDefaultController<Uint8Array>;
const encoder = new TextEncoder();
const stream = new ReadableStream<Uint8Array>({
start(controller) {
streamController = controller;
},
});
return {
stream,
push(data: string) {
streamController.enqueue(encoder.encode(data));
},
close() {
streamController.close();
},
};
}
/**
* Collect all output from a ReadableStream into a single string.
*/
async function collectStream(stream: ReadableStream<Uint8Array>): Promise<string> {
const reader = stream.getReader();
const decoder = new TextDecoder();
let result = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
result += decoder.decode(value, { stream: true });
}
return result;
}
/**
* Collect output from a ReadableStream as individual string chunks,
* preserving the chunk boundaries from the TransformStream output.
*/
async function collectStreamChunks(stream: ReadableStream<Uint8Array>): Promise<string[]> {
const reader = stream.getReader();
const decoder = new TextDecoder();
const chunks: string[] = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(decoder.decode(value, { stream: true }));
}
return chunks;
}
const RSC_RUNTIME_KEY = 'Symbol.for("vinext.navigationRuntime")';
const RSC_RUNTIME_PUSH = ".rsc.push(";
const RSC_RUNTIME_DONE = ".done=true";
// ─── Tests ──────────────────────────────────────────────────────────────────
describe("Tick-buffered RSC streaming (behavioral)", () => {
it("interleaves RSC scripts between HTML flush cycles", async () => {
// Set up: RSC stream with two chunks pushed before HTML starts
const rsc = createMockRscStream();
rsc.push('0:D{"name":"page"}\n');
rsc.push('1:["$","div",null,{"children":"Hello"}]\n');
const rscEmbed = createRscEmbedTransform(rsc.stream);
// Give the RSC reader time to consume the chunks
await new Promise((resolve) => setTimeout(resolve, 10));
// HTML stream: two flush cycles
// Cycle 1: shell with head
// Cycle 2: body content after Suspense resolves
const htmlStream = createMockHtmlStream([
["<html><head></head><body><div id='root'>"],
["<div>Suspense resolved</div></div></body></html>"],
]);
// Close RSC stream before second flush cycle completes
setTimeout(() => rsc.close(), 5);
const transform = createTickBufferedTransform(rscEmbed);
const output = await collectStream(htmlStream.pipeThrough(transform));
// RSC scripts should be present in output
expect(output).toContain(RSC_RUNTIME_PUSH);
// Done signal should be present
expect(output).toContain(RSC_RUNTIME_DONE);
// HTML content should be intact
expect(output).toContain("<div id='root'>");
expect(output).toContain("<div>Suspense resolved</div>");
});
it("adds nonce attributes to progressive RSC scripts when provided", async () => {
const rsc = createMockRscStream();
rsc.push('0:D{"name":"page"}\n');
const rscEmbed = createRscEmbedTransform(rsc.stream, "vinext-test-nonce");
await new Promise((resolve) => setTimeout(resolve, 10));
const htmlStream = createMockHtmlStream([["<html><head></head><body></body></html>"]]);
rsc.close();
const transform = createTickBufferedTransform(rscEmbed);
const output = await collectStream(htmlStream.pipeThrough(transform));
expect(output).toContain(`<script nonce="vinext-test-nonce">((self[${RSC_RUNTIME_KEY}]`);
expect(output).toContain(`<script nonce="vinext-test-nonce">((self[${RSC_RUNTIME_KEY}]`);
expect(output).toContain(RSC_RUNTIME_DONE);
});
it("does not inject scripts mid-HTML-chunk (DOM corruption prevention)", async () => {
// This tests the core safety invariant: when React Fizz flushes multiple
// chunks synchronously (same macrotask), scripts must NOT appear between them.
// For example, Fizz might split an SVG element across chunks:
// chunk1: "<svg><linearGradi"
// chunk2: "ent></linearGradient></svg>"
// Injecting a <script> between these would corrupt the DOM.
const rsc = createMockRscStream();
rsc.push('0:D{"name":"page"}\n');
const rscEmbed = createRscEmbedTransform(rsc.stream);
await new Promise((resolve) => setTimeout(resolve, 10));
// Simulate Fizz splitting an element across chunks in the SAME macrotask
const htmlStream = createMockHtmlStream([
["<html><head></head><body><svg><linearGradi", "ent id='g1'></linearGradient></svg>"],
]);
rsc.close();
const transform = createTickBufferedTransform(rscEmbed);
const chunks = await collectStreamChunks(htmlStream.pipeThrough(transform));
// Reconstruct the full output
const output = chunks.join("");
// The split SVG element must be intact — no script between the two parts
expect(output).toContain("<svg><linearGradient id='g1'></linearGradient></svg>");
// RSC scripts should still be present (after the HTML, not mid-element)
expect(output).toContain(RSC_RUNTIME_PUSH);
// Verify no <script> tag appears between the split HTML fragments
// by checking that the linearGradient element is contiguous
const svgStart = output.indexOf("<svg>");
const svgEnd = output.indexOf("</svg>") + "</svg>".length;
const svgContent = output.slice(svgStart, svgEnd);
expect(svgContent).not.toContain("<script>");
});
it("batches multiple same-macrotask HTML chunks before injecting RSC scripts", async () => {
const rsc = createMockRscStream();
rsc.push('0:D{"name":"layout"}\n');
const rscEmbed = createRscEmbedTransform(rsc.stream);
await new Promise((resolve) => setTimeout(resolve, 10));
// Three HTML chunks in the same macrotask (simulates large Fizz flush)
const htmlStream = createMockHtmlStream([
["<html><head></head><body>", "<div>chunk1</div>", "<div>chunk2</div>"],
]);
rsc.close();
const transform = createTickBufferedTransform(rscEmbed);
const chunks = await collectStreamChunks(htmlStream.pipeThrough(transform));
const output = chunks.join("");
// All three HTML chunks should appear contiguously (no script between them)
expect(output).toContain("<body><div>chunk1</div><div>chunk2</div>");
// RSC scripts should appear AFTER all the HTML chunks
const htmlEnd = output.indexOf("<div>chunk2</div>") + "<div>chunk2</div>".length;
const scriptStart = output.indexOf(RSC_RUNTIME_PUSH);
expect(scriptStart).toBeGreaterThan(htmlEnd);
});
it("delivers RSC chunks progressively across multiple flush cycles", async () => {
const rsc = createMockRscStream();
const rscEmbed = createRscEmbedTransform(rsc.stream);
// Push RSC chunk before first HTML flush
rsc.push('0:D{"name":"layout"}\n');
await new Promise((resolve) => setTimeout(resolve, 10));
// We need to manually orchestrate timing between HTML flushes and RSC pushes.
const encoder = new TextEncoder();
const htmlStream = new ReadableStream<Uint8Array>({
async start(controller) {
// Flush cycle 1: HTML shell
controller.enqueue(encoder.encode("<html><head></head><body>"));
// Wait for macrotask boundary (so transform flushes cycle 1)
await new Promise((resolve) => setTimeout(resolve, 20));
// Push second RSC chunk between flush cycles
rsc.push('1:["$","div",null,{"children":"Resolved boundary 1"}]\n');
await new Promise((resolve) => setTimeout(resolve, 10));
// Flush cycle 2: first Suspense boundary resolves
controller.enqueue(encoder.encode("<div>Boundary 1</div>"));
await new Promise((resolve) => setTimeout(resolve, 20));
// Push third RSC chunk
rsc.push('2:["$","span",null,{"children":"Resolved boundary 2"}]\n');
await new Promise((resolve) => setTimeout(resolve, 10));
// Flush cycle 3: second Suspense boundary resolves
controller.enqueue(encoder.encode("<div>Boundary 2</div></body></html>"));
rsc.close();
controller.close();
},
});
const transform = createTickBufferedTransform(rscEmbed);
const output = await collectStream(htmlStream.pipeThrough(transform));
// All HTML should be present
expect(output).toContain("<html><head></head><body>");
expect(output).toContain("<div>Boundary 1</div>");
expect(output).toContain("<div>Boundary 2</div>");
// RSC scripts should be present for all chunks
expect(output).toContain(RSC_RUNTIME_PUSH);
// Done signal at the end
expect(output).toContain(RSC_RUNTIME_DONE);
// The done signal should appear after the body content but BEFORE the
// document-close suffix. See #1532 — `</body></html>` is now suffix-moved
// to the very end of the stream so the document terminates with a
// well-formed close (mirrors Next.js's `createMoveSuffixStream`).
const bodyContentEnd = output.indexOf("<div>Boundary 2</div>") + "<div>Boundary 2</div>".length;
const donePos = output.indexOf(RSC_RUNTIME_DONE);
const lastHtmlPos = output.indexOf("</html>");
expect(donePos).toBeGreaterThan(bodyContentEnd);
expect(lastHtmlPos).toBeGreaterThan(donePos);
});
it("injects head content before </head>", async () => {
const rsc = createMockRscStream();
rsc.close(); // No RSC chunks needed for this test
const rscEmbed = createRscEmbedTransform(rsc.stream);
await new Promise((resolve) => setTimeout(resolve, 10));
const injectHTML = '<script>self.__VINEXT_RSC_PARAMS__={"slug":"test"}</script>';
const htmlStream = createMockHtmlStream([
["<html><head><title>Test</title></head><body>content</body></html>"],
]);
const transform = createTickBufferedTransform(rscEmbed, injectHTML);
const output = await collectStream(htmlStream.pipeThrough(transform));
// Injected content should appear before </head>
const injectPos = output.indexOf("__VINEXT_RSC_PARAMS__");
const headEndPos = output.indexOf("</head>");
expect(injectPos).toBeGreaterThan(-1);
expect(injectPos).toBeLessThan(headEndPos);
// Original head content should be preserved
expect(output).toContain("<title>Test</title>");
});
it("handles head injection when </head> is in a later chunk", async () => {
const rsc = createMockRscStream();
rsc.close();
const rscEmbed = createRscEmbedTransform(rsc.stream);
await new Promise((resolve) => setTimeout(resolve, 10));
const injectHTML = '<meta name="injected" content="true">';
// </head> appears in the second flush cycle
const htmlStream = createMockHtmlStream([
["<html><head><title>Test</title>"],
["</head><body>content</body></html>"],
]);
const transform = createTickBufferedTransform(rscEmbed, injectHTML);
const output = await collectStream(htmlStream.pipeThrough(transform));
// Injected content should appear before </head>
const injectPos = output.indexOf('name="injected"');
const headEndPos = output.indexOf("</head>");
expect(injectPos).toBeGreaterThan(-1);
expect(injectPos).toBeLessThan(headEndPos);
});
it("emits new server-inserted HTML before streamed Suspense chunks after head injection", async () => {
// Ported from Next.js: test/e2e/app-dir/use-server-inserted-html/use-server-inserted-html.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/use-server-inserted-html/use-server-inserted-html.test.ts
const rsc = createMockRscStream();
rsc.close();
const rscEmbed = createRscEmbedTransform(rsc.stream);
await new Promise((resolve) => setTimeout(resolve, 10));
const insertions = [
'<style data-styled="shell">.shell { color: blue; }</style>',
'<style data-styled="suspense">.suspense { color: orange; }</style>',
"",
];
let insertCalls = 0;
const getServerInsertedHTML = () => {
insertCalls++;
return insertions.shift() ?? "";
};
const encoder = new TextEncoder();
const htmlStream = new ReadableStream<Uint8Array>({
async start(controller) {
controller.enqueue(
encoder.encode("<html><head><title>Test</title></head><body><main>shell"),
);
await new Promise((resolve) => setTimeout(resolve, 20));
controller.enqueue(
encoder.encode(
'<span id="footer-inner">resolved</span><script>$RC=function(){}</script></main></body></html>',
),
);
controller.close();
},
});
const transform = createTickBufferedTransform(rscEmbed, getServerInsertedHTML);
const output = await collectStream(htmlStream.pipeThrough(transform));
const shellStylePos = output.indexOf('data-styled="shell"');
const headEndPos = output.indexOf("</head>");
expect(shellStylePos).toBeGreaterThan(-1);
expect(shellStylePos).toBeLessThan(headEndPos);
const suspenseStylePos = output.indexOf('data-styled="suspense"');
const refreshScriptPos = output.indexOf("$RC=function");
expect(suspenseStylePos).toBeGreaterThan(headEndPos);
expect(suspenseStylePos).toBeLessThan(refreshScriptPos);
expect(insertCalls).toBeGreaterThanOrEqual(2);
});
it("emits final server-inserted HTML even after buffered chunks were already drained", async () => {
const rsc = createMockRscStream();
rsc.close();
const rscEmbed = createRscEmbedTransform(rsc.stream);
await new Promise((resolve) => setTimeout(resolve, 10));
const insertions = [
'<style data-styled="shell">.shell { color: blue; }</style>',
'<style data-styled="trailing">.trailing { color: green; }</style>',
];
const getServerInsertedHTML = () => insertions.shift() ?? "";
const encoder = new TextEncoder();
const htmlStream = new ReadableStream<Uint8Array>({
async start(controller) {
controller.enqueue(encoder.encode("<html><head></head><body>shell</body></html>"));
await new Promise((resolve) => setTimeout(resolve, 20));
controller.close();
},
});
const transform = createTickBufferedTransform(rscEmbed, getServerInsertedHTML);
const output = await collectStream(htmlStream.pipeThrough(transform));
const shellStylePos = output.indexOf('data-styled="shell"');
const trailingStylePos = output.indexOf('data-styled="trailing"');
const donePos = output.indexOf(RSC_RUNTIME_DONE);
expect(shellStylePos).toBeGreaterThan(-1);
expect(trailingStylePos).toBeGreaterThan(shellStylePos);
expect(trailingStylePos).toBeLessThan(donePos);
});
it("emits SSR-captured redirect meta tags after the shell has already flushed", async () => {
// Ported from Next.js: test/e2e/app-dir/navigation/navigation.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/navigation/navigation.test.ts
const rsc = createMockRscStream();
rsc.close();
const rscEmbed = createRscEmbedTransform(rsc.stream);
await new Promise((resolve) => setTimeout(resolve, 10));
const errorMetaRenderer = createSsrErrorMetaRenderer();
const encoder = new TextEncoder();
const htmlStream = new ReadableStream<Uint8Array>({
async start(controller) {
controller.enqueue(encoder.encode("<html><head></head><body><main>shell</main>"));
await new Promise((resolve) => setTimeout(resolve, 20));
errorMetaRenderer.capture(
Object.assign(new Error("NEXT_REDIRECT"), {
digest: "NEXT_REDIRECT;replace;/redirect/result;307",
}),
);
controller.enqueue(encoder.encode("<template>resolved boundary</template></body></html>"));
controller.close();
},
});
const transform = createTickBufferedTransform(rscEmbed, () => errorMetaRenderer.flush());
const output = await collectStream(htmlStream.pipeThrough(transform));
const shellPos = output.indexOf("<main>shell</main>");
const redirectMetaPos = output.indexOf(
'<meta id="__next-page-redirect" http-equiv="refresh" content="1;url=/redirect/result"/>',
);
const boundaryPos = output.indexOf("<template>resolved boundary</template>");
expect(shellPos).toBeGreaterThan(-1);
expect(redirectMetaPos).toBeGreaterThan(shellPos);
expect(redirectMetaPos).toBeLessThan(boundaryPos);
});
it("still injects head content even without </head> in stream", async () => {
const rsc = createMockRscStream();
rsc.close();
const rscEmbed = createRscEmbedTransform(rsc.stream);
await new Promise((resolve) => setTimeout(resolve, 10));
const injectHTML = '<meta name="fallback">';
// No </head> in the HTML at all (edge case)
const htmlStream = createMockHtmlStream([["<body>content</body>"]]);
const transform = createTickBufferedTransform(rscEmbed, injectHTML);
const output = await collectStream(htmlStream.pipeThrough(transform));
// Should still inject the content (at the end, via flush handler fallback)
expect(output).toContain('name="fallback"');
});
it("emits navigation runtime RSC done signal even with empty RSC stream", async () => {
const rsc = createMockRscStream();
rsc.close(); // Close immediately — no RSC chunks
const rscEmbed = createRscEmbedTransform(rsc.stream);
await new Promise((resolve) => setTimeout(resolve, 10));
const htmlStream = createMockHtmlStream([["<html><head></head><body>Hello</body></html>"]]);
const transform = createTickBufferedTransform(rscEmbed);
const output = await collectStream(htmlStream.pipeThrough(transform));
// Done signal must always be present so the browser knows streaming is complete
expect(output).toContain(RSC_RUNTIME_DONE);
// No RSC chunks should be present
expect(output).not.toContain(RSC_RUNTIME_PUSH);
});
it("handles RSC chunks arriving after HTML stream closes", async () => {
// Edge case: RSC stream outlives HTML stream (slow async server components)
const rsc = createMockRscStream();
rsc.push('0:D{"name":"page"}\n');
const rscEmbed = createRscEmbedTransform(rsc.stream);
await new Promise((resolve) => setTimeout(resolve, 10));
const htmlStream = createMockHtmlStream([["<html><head></head><body>Shell</body></html>"]]);
// Push more RSC data after a delay, then close
setTimeout(() => {
rsc.push('1:["$","div",null,{"children":"Late data"}]\n');
rsc.close();
}, 30);
const transform = createTickBufferedTransform(rscEmbed);
const output = await collectStream(htmlStream.pipeThrough(transform));
// HTML should be present
expect(output).toContain("Shell");
// The late RSC chunk should still be emitted (finalize waits for RSC stream)
expect(output).toContain(RSC_RUNTIME_PUSH);
// Done signal must be present
expect(output).toContain(RSC_RUNTIME_DONE);
});
it("preserves RSC chunk ordering", async () => {
const rsc = createMockRscStream();
const rscEmbed = createRscEmbedTransform(rsc.stream);
// Push chunks in specific order
rsc.push('0:D{"name":"layout"}\n');
rsc.push('1:D{"name":"page"}\n');
rsc.push('2:["$","div",null,{}]\n');
rsc.close();
await new Promise((resolve) => setTimeout(resolve, 10));
const htmlStream = createMockHtmlStream([["<html><head></head><body>content</body></html>"]]);
const transform = createTickBufferedTransform(rscEmbed);
const output = await collectStream(htmlStream.pipeThrough(transform));
// Extract RSC script contents to verify ordering
const scriptRegex = /\.rsc\.push\(([^)]+)\)/g;
const matches: string[] = [];
let match;
while ((match = scriptRegex.exec(output)) !== null) {
matches.push(match[1]);
}
// Should have 3 RSC chunks in order
expect(matches.length).toBe(3);
// Chunks are now text strings, so just parse the JSON strings directly
const chunk0 = JSON.parse(matches[0]);
const chunk1 = JSON.parse(matches[1]);
const chunk2 = JSON.parse(matches[2]);
expect(chunk0).toContain('0:D{"name":"layout"}');
expect(chunk1).toContain('1:D{"name":"page"}');
expect(chunk2).toContain('2:["$","div"');
});
it("handles large number of interleaved flush cycles correctly", async () => {
const rsc = createMockRscStream();
const rscEmbed = createRscEmbedTransform(rsc.stream);
// Push initial RSC chunk
rsc.push('0:D{"name":"root"}\n');
await new Promise((resolve) => setTimeout(resolve, 10));
// Create an HTML stream with many flush cycles (simulates deeply nested Suspense)
const encoder = new TextEncoder();
const htmlStream = new ReadableStream<Uint8Array>({
async start(controller) {
controller.enqueue(encoder.encode("<html><head></head><body>"));
for (let i = 0; i < 5; i++) {
await new Promise((resolve) => setTimeout(resolve, 15));
// Push RSC chunk for this boundary
rsc.push(`${i + 1}:["$","div",null,{"children":"Boundary ${i}"}]\n`);
await new Promise((resolve) => setTimeout(resolve, 5));
controller.enqueue(encoder.encode(`<div data-boundary="${i}">Content ${i}</div>`));
}
await new Promise((resolve) => setTimeout(resolve, 10));
rsc.close();
controller.enqueue(encoder.encode("</body></html>"));
controller.close();
},
});
const transform = createTickBufferedTransform(rscEmbed);
const output = await collectStream(htmlStream.pipeThrough(transform));
// All 5 boundaries should be in the HTML
for (let i = 0; i < 5; i++) {
expect(output).toContain(`data-boundary="${i}"`);
expect(output).toContain(`Content ${i}`);
}
// All RSC chunks should be present (initial + 5 boundaries = 6 total)
// Count RSC chunk push calls, not runtime initialization occurrences.
const scriptCount = (output.match(/\.rsc\.push\(/g) || []).length;
expect(scriptCount).toBe(6);
// Done signal at the end
expect(output).toContain(RSC_RUNTIME_DONE);
// Done signal should be the last script
const lastChunksPos = output.lastIndexOf(RSC_RUNTIME_PUSH);
const donePos = output.indexOf(RSC_RUNTIME_DONE);
expect(donePos).toBeGreaterThan(lastChunksPos);
});
it("XSS-safe: safeJsonStringify prevents </script> breakout in RSC data", async () => {
const rsc = createMockRscStream();
// Push RSC data that contains a </script> payload
const malicious =
'0:["$","div",null,{"dangerouslySetInnerHTML":{"__html":"</script><script>alert(1)</script>"}}]\n';
rsc.push(malicious);
rsc.close();
const rscEmbed = createRscEmbedTransform(rsc.stream);
await new Promise((resolve) => setTimeout(resolve, 10));
const htmlStream = createMockHtmlStream([["<html><head></head><body>content</body></html>"]]);
const transform = createTickBufferedTransform(rscEmbed);
const output = await collectStream(htmlStream.pipeThrough(transform));
// The output should contain the RSC chunk
expect(output).toContain(RSC_RUNTIME_PUSH);
// RSC data is now stored as a JSON text string. safeJsonStringify escapes
// <, >, and & characters so that </script> in the RSC data cannot break
// out of the enclosing <script> tag. The < and > characters are escaped
// to \\u003c and \\u003e respectively.
// The raw string "</script>" should NOT appear outside of the proper
// script tags we control. Count actual <script> and </script> tags —
// they should be balanced (our tags only, not the malicious payload).
// lgtm[js/bad-tag-filter] — counting tags to verify XSS protection, not filtering HTML
const openScripts = (output.match(/<script>/g) || []).length;
const closeScripts = (output.match(/<\/script>/g) || []).length;
expect(openScripts).toBe(closeScripts);
// The malicious raw HTML should NOT appear as actual HTML in the output
expect(output).not.toContain("alert(1)</script>");
// Verify the </script> characters are escaped in the JSON string output.
// safeJsonStringify escapes '<' to '\\u003c' and '>' to '\\u003e',
// so the malicious payload is safely neutralized.
expect(output).toContain("\\u003c/script\\u003e");
expect(output).not.toContain(",60,47,115,99,114,105,112,116,62,");
});
});