Files
cloudflare__vinext/tests/server-inserted-html.test.ts
Nathan Nguyen ebeb044e01 fix(app-router): flush server-inserted HTML during SSR streaming (#926)
App Router SSR rendered useServerInsertedHTML callbacks only once before returning the HTML stream. CSS-in-JS registries registered at shell render were cleared before later Suspense chunks, so styles collected after the shell never reached streamed HTML.

Keep callback registrations alive for the stream lifetime and let the tick-buffered HTML transform request insertion HTML on each flush after the initial head injection. This matches Next.js per-chunk server inserted HTML transform and preserves cleanup on stream completion or cancellation.

Adds regression coverage for persistent callback rendering and a Next.js-ported Suspense CSS-in-JS streaming order case.
2026-04-28 19:35:10 +01:00

168 lines
5.9 KiB
TypeScript

import { describe, it, expect, beforeEach } from "vite-plus/test";
describe("useServerInsertedHTML", () => {
beforeEach(async () => {
// Clear any stale callbacks between tests
const { clearServerInsertedHTML } = await import("../packages/vinext/src/shims/navigation.js");
clearServerInsertedHTML();
});
it("is exported from next/navigation shim", async () => {
const mod = await import("../packages/vinext/src/shims/navigation.js");
expect(typeof mod.useServerInsertedHTML).toBe("function");
});
it("flushServerInsertedHTML is exported", async () => {
const mod = await import("../packages/vinext/src/shims/navigation.js");
expect(typeof mod.flushServerInsertedHTML).toBe("function");
});
it("clearServerInsertedHTML is exported", async () => {
const mod = await import("../packages/vinext/src/shims/navigation.js");
expect(typeof mod.clearServerInsertedHTML).toBe("function");
});
it("collects callbacks registered during SSR (no document)", async () => {
const { useServerInsertedHTML, flushServerInsertedHTML } =
await import("../packages/vinext/src/shims/navigation.js");
// In test environment, document is undefined (SSR-like)
const results: string[] = [];
useServerInsertedHTML(() => {
results.push("called");
return "<style>.a { color: red; }</style>";
});
const flushed = flushServerInsertedHTML();
expect(flushed).toHaveLength(1);
expect(flushed[0]).toBe("<style>.a { color: red; }</style>");
expect(results).toEqual(["called"]);
});
it("collects multiple callbacks", async () => {
const { useServerInsertedHTML, flushServerInsertedHTML } =
await import("../packages/vinext/src/shims/navigation.js");
useServerInsertedHTML(() => "<style>.a {}</style>");
useServerInsertedHTML(() => "<style>.b {}</style>");
useServerInsertedHTML(() => "<style>.c {}</style>");
const flushed = flushServerInsertedHTML();
expect(flushed).toHaveLength(3);
expect(flushed[0]).toBe("<style>.a {}</style>");
expect(flushed[1]).toBe("<style>.b {}</style>");
expect(flushed[2]).toBe("<style>.c {}</style>");
});
it("flush clears the callback list", async () => {
const { useServerInsertedHTML, flushServerInsertedHTML } =
await import("../packages/vinext/src/shims/navigation.js");
useServerInsertedHTML(() => "first");
const first = flushServerInsertedHTML();
expect(first).toHaveLength(1);
// Second flush should be empty
const second = flushServerInsertedHTML();
expect(second).toHaveLength(0);
});
it("renderServerInsertedHTML keeps callbacks registered for later streaming flushes", async () => {
const { useServerInsertedHTML, renderServerInsertedHTML, flushServerInsertedHTML } =
await import("../packages/vinext/src/shims/navigation.js");
let flushCount = 0;
useServerInsertedHTML(() => `style-${++flushCount}`);
expect(renderServerInsertedHTML()).toEqual(["style-1"]);
expect(renderServerInsertedHTML()).toEqual(["style-2"]);
expect(flushServerInsertedHTML()).toEqual(["style-3"]);
});
it("clearServerInsertedHTML discards callbacks without calling them", async () => {
const { useServerInsertedHTML, clearServerInsertedHTML, flushServerInsertedHTML } =
await import("../packages/vinext/src/shims/navigation.js");
let called = false;
useServerInsertedHTML(() => {
called = true;
return "should not appear";
});
clearServerInsertedHTML();
const flushed = flushServerInsertedHTML();
expect(flushed).toHaveLength(0);
expect(called).toBe(false);
});
it("ignores null/undefined returns from callbacks", async () => {
const { useServerInsertedHTML, flushServerInsertedHTML } =
await import("../packages/vinext/src/shims/navigation.js");
useServerInsertedHTML(() => null);
useServerInsertedHTML(() => undefined);
useServerInsertedHTML(() => "<style>.valid {}</style>");
const flushed = flushServerInsertedHTML();
expect(flushed).toHaveLength(1);
expect(flushed[0]).toBe("<style>.valid {}</style>");
});
it("handles callback errors gracefully", async () => {
const { useServerInsertedHTML, flushServerInsertedHTML } =
await import("../packages/vinext/src/shims/navigation.js");
useServerInsertedHTML(() => {
throw new Error("CSS extraction failed");
});
useServerInsertedHTML(() => "<style>.ok {}</style>");
// Should not throw, should skip the erroring callback
const flushed = flushServerInsertedHTML();
expect(flushed).toHaveLength(1);
expect(flushed[0]).toBe("<style>.ok {}</style>");
});
it("callbacks can return React-like elements", async () => {
const { useServerInsertedHTML, flushServerInsertedHTML } =
await import("../packages/vinext/src/shims/navigation.js");
// Simulate a React element (like styled-components would return)
const fakeElement = {
$$typeof: Symbol.for("react.element"),
type: "style",
props: { children: ".x{}" },
};
useServerInsertedHTML(() => fakeElement);
const flushed = flushServerInsertedHTML();
expect(flushed).toHaveLength(1);
expect(flushed[0]).toBe(fakeElement);
});
it("supports the styled-components SSR pattern", async () => {
const { useServerInsertedHTML, flushServerInsertedHTML } =
await import("../packages/vinext/src/shims/navigation.js");
// Simulate styled-components ServerStyleSheet pattern
const collectedStyles: string[] = [];
let extractCount = 0;
// This simulates what a StyleRegistry component does
useServerInsertedHTML(() => {
extractCount++;
collectedStyles.push(
`<style data-styled="sc-${extractCount}">.sc-${extractCount} { color: red; }</style>`,
);
return collectedStyles[collectedStyles.length - 1];
});
const flushed = flushServerInsertedHTML();
expect(flushed).toHaveLength(1);
expect(flushed[0]).toContain("data-styled");
expect(extractCount).toBe(1);
});
});