Files
cloudflare__vinext/tests/script-head-ordering.test.ts
Xplod13 33be48648d fix(script): register src beforeInteractive scripts and mark hoisted output (#2019)
## Summary
- Register `beforeInteractive` Scripts with `src` through
  BeforeInteractiveContext so they are hoisted into <head> ahead of
  interactive, exactly like the inline form (previously they bypassed the
  registry and rendered in source order, after React Float's head content).
- Suppress the client React render for both inline AND src beforeInteractive
  Scripts in the App Router so the hoisted tag is never duplicated.
- Tag every hoisted script with Next.js's `data-nscript="beforeInteractive"`
  marker.
- Map `crossOrigin` -> `crossorigin` and `referrerPolicy` -> `referrerpolicy`
  on hoisted scripts (parity with Next.js set-attributes-from-props).
- Extract `renderBeforeInteractiveInlineScripts` into a dedicated, pure module
  (before-interactive-head.ts) so the emit path is unit-testable.

## Root Cause
The beforeInteractive SSR branch computed `inlineContent = src ? null : ...`
and only registered when `inlineContent !== null`, so any Script with a `src`
was never captured by the registry and never spliced into <head>; it stayed
where the user wrote it, behind React Float's hoisted resource hints, defeating
the before-interactive guarantee. Hoisting src scripts then requires the client
to stop rendering them through React (the React-created <script> never executes
and would only add a duplicate DOM node), so the client dedupe condition is
aligned with the SSR registration condition. The hoisted output also lacked
Next.js's `data-nscript` marker, and REACT_TO_HTML_ATTR omitted
crossOrigin/referrerPolicy, so those attribute names did not match Next.js's
lowercased output.

## References
- Fixes #2016
- Next.js: .nextjs-ref/packages/next/src/client/script.tsx (data-nscript,
  src/inline registry parity), set-attributes-from-props.ts (lowercased attrs)
- Ported: test/e2e/app-dir/script-before-interactive/script-before-interactive.test.ts

## Verification
- CI=true pnpm test tests/script.test.ts tests/script-head-ordering.test.ts
  tests/nextjs-compat/script-nonce.test.ts tests/shims.test.ts -> 1139 passed
- CI=true npx vp check on all changed files -> pass

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 23:38:06 +01:00

183 lines
8.0 KiB
TypeScript

/**
* Regression tests for inline `<Script strategy="beforeInteractive">` head
* ordering in the App Router.
*
* Background
* ----------
* The standard no-flash dark-mode pattern relies on a tiny inline initializer
* running before stylesheets parse. React Float hoists stylesheet/preload/
* modulepreload links to the top of `<head>` and pushes any user-rendered
* `<script>` children below them — so a Script written first in source order
* still ends up near the bottom of the head section, defeating the pattern.
*
* vinext captures inline `<Script strategy="beforeInteractive">` content via
* `BeforeInteractiveContext` (set up in `app-ssr-entry.ts`) and the SSR stream
* transform splices the captured tag in immediately after `<head ...>` opens,
* before any React-emitted resource hints. These tests pin that ordering:
*
* - inline beforeInteractive script appears before the first stylesheet
* - inline beforeInteractive script appears before the first modulepreload
* - inline beforeInteractive script appears before any other preload link
* - CSP nonce flows through to the hoisted tag
* - The Script's id/data-* attributes round-trip
* - No duplicate inline `<script>` appears later in head
*
* Failing the assertion below before the fix
* ------------------------------------------
* Before this fix, the inline `<Script strategy="beforeInteractive">` was
* rendered inline by React in source order, where React Float's hoisted
* links sat above it. `expect(scriptIndex).toBeLessThan(stylesheetIndex)`
* would fail because Fizz reordered the head emit.
*/
import { describe, it, expect, beforeAll, afterAll } from "vite-plus/test";
import type { ViteDevServer } from "vite-plus";
import { APP_FIXTURE_DIR, fetchHtml, startFixtureServer } from "./helpers.js";
const ROUTE = "/beforeinteractive-head-ordering";
/**
* Extract the substring between `<head ...>` and `</head>`. Tests only care
* about ordering inside the head, and constraining the search there keeps
* matches from drifting into the `<body>` content (e.g. the bootstrap
* `<script type="module">` near the closing body tag).
*/
function extractHeadHtml(html: string): string {
const openMatch = /<head\b[^>]*>/.exec(html);
const closeIndex = html.indexOf("</head>");
if (!openMatch || closeIndex === -1) {
throw new Error("Response HTML did not contain a complete <head>...</head> section");
}
return html.slice(openMatch.index + openMatch[0].length, closeIndex);
}
function indexOfMatch(html: string, pattern: RegExp): number {
const match = pattern.exec(html);
return match ? match.index : -1;
}
describe("inline beforeInteractive head ordering", () => {
let server: ViteDevServer;
let baseUrl: string;
beforeAll(async () => {
({ server, baseUrl } = await startFixtureServer(APP_FIXTURE_DIR, {
appRouter: true,
}));
await fetch(`${baseUrl}/`).catch(() => {});
}, 60_000);
afterAll(async () => {
await server?.close();
});
it("emits the inline beforeInteractive <script> before the first stylesheet link", async () => {
const { res, html } = await fetchHtml(baseUrl, ROUTE);
expect(res.status).toBe(200);
const head = extractHeadHtml(html);
const scriptIndex = indexOfMatch(head, /<script\b[^>]*id="vinext-test-theme-init"/);
const stylesheetIndex = indexOfMatch(head, /<link\b[^>]*rel="stylesheet"/);
expect(
scriptIndex,
"expected the inline beforeInteractive script to appear in <head>",
).toBeGreaterThanOrEqual(0);
// When the test fixture has at least one stylesheet (Vite dev injects one
// for HMR styles in dev, and the production build emits one per CSS
// import), the script must precede it. We skip the assertion when no
// stylesheet exists — the script still has to be present, asserted above.
if (stylesheetIndex !== -1) {
expect(scriptIndex).toBeLessThan(stylesheetIndex);
}
});
it("emits the inline beforeInteractive <script> before the first modulepreload link", async () => {
const { html } = await fetchHtml(baseUrl, ROUTE);
const head = extractHeadHtml(html);
const scriptIndex = indexOfMatch(head, /<script\b[^>]*id="vinext-test-theme-init"/);
const modulePreloadIndex = indexOfMatch(head, /<link\b[^>]*rel="modulepreload"/);
expect(scriptIndex).toBeGreaterThanOrEqual(0);
// The bootstrap modulepreload is always emitted, so this assertion is
// unconditional. If the bootstrap is ever made optional, fall back to the
// same "skip when absent" pattern used for stylesheets above.
expect(modulePreloadIndex).toBeGreaterThanOrEqual(0);
expect(scriptIndex).toBeLessThan(modulePreloadIndex);
});
it("emits the inline beforeInteractive <script> before every preload link", async () => {
const { html } = await fetchHtml(baseUrl, ROUTE);
const head = extractHeadHtml(html);
const scriptIndex = indexOfMatch(head, /<script\b[^>]*id="vinext-test-theme-init"/);
expect(scriptIndex).toBeGreaterThanOrEqual(0);
// Any preload link (stylesheet, script, font) emitted by React Float or
// vinext must follow the inline script.
for (const match of head.matchAll(
/<link\b[^>]*rel="(?:preload|modulepreload|stylesheet)"[^>]*>/g,
)) {
expect(
match.index,
`link "${match[0]}" must appear after the theme-init script`,
).toBeGreaterThan(scriptIndex);
}
});
it("does not duplicate the inline script inside <head>", async () => {
const { html } = await fetchHtml(baseUrl, ROUTE);
const head = extractHeadHtml(html);
const matches = [...head.matchAll(/<script\b[^>]*id="vinext-test-theme-init"/g)];
expect(matches).toHaveLength(1);
});
it("preserves the inline script's body content", async () => {
const { html } = await fetchHtml(baseUrl, ROUTE);
expect(html).toMatch(
/<script\b[^>]*id="vinext-test-theme-init"[^>]*>self\.__vinextThemeInitRan = true;<\/script>/,
);
});
it("preserves passthrough attributes like data-*", async () => {
const { html } = await fetchHtml(baseUrl, ROUTE);
const head = extractHeadHtml(html);
const tagMatch = /<script\b[^>]*id="vinext-test-theme-init"[^>]*>/.exec(head);
expect(tagMatch?.[0]).toContain('data-vinext-test="theme-init"');
});
it("applies the CSP nonce header to the hoisted inline script", async () => {
// The fixture's middleware reads `?csp-nonce=…` and sets a CSP header
// with that nonce. vinext extracts the nonce and threads it through
// ScriptNonceProvider, so the hoisted tag must carry the same value.
const { res, html } = await fetchHtml(baseUrl, `${ROUTE}?csp-nonce=ordering-nonce`);
expect(res.status).toBe(200);
expect(res.headers.get("content-security-policy")).toMatch(/nonce-ordering-nonce/);
const head = extractHeadHtml(html);
const tagMatch = /<script\b[^>]*id="vinext-test-theme-init"[^>]*>/.exec(head);
expect(tagMatch).not.toBeNull();
expect(tagMatch?.[0]).toContain('nonce="ordering-nonce"');
});
it("hoists external src beforeInteractive scripts into <head> with the data-nscript marker", async () => {
// Regression for cloudflare/vinext#2016: external src beforeInteractive
// scripts (the /script-nonce page renders Script src="/test2.js") are now
// registered through BeforeInteractiveContext and hoisted into <head> just
// like the inline form, mirroring Next.js which routes inline and src
// beforeInteractive scripts equally through the App Router runtime. The
// hoisted tag carries Next.js's `data-nscript="beforeInteractive"` marker.
const { html } = await fetchHtml(baseUrl, "/script-nonce");
const head = extractHeadHtml(html);
const tagMatch = /<script\b[^>]*src="\/test2\.js"[^>]*>/.exec(head);
expect(
tagMatch,
"expected the src beforeInteractive script to be hoisted into <head>",
).not.toBeNull();
expect(tagMatch?.[0]).toContain('data-nscript="beforeInteractive"');
// Exactly one tag — the client must not re-render a duplicate.
const matches = [...html.matchAll(/<script\b[^>]*src="\/test2\.js"/g)];
expect(matches).toHaveLength(1);
});
});