Files
cloudflare__vinext/tests/nextjs-compat/script-preload.test.ts
James Anderson 558cec0328 fix(shims/script): preload <script> sources during SSR via ReactDOM.preload (#1309)
Mirror Next.js's App Router behavior at
.nextjs-ref/packages/next/src/client/script.tsx:298-376: when SSR-rendering
`<Script src={...} />` with `strategy="afterInteractive"` (the default)
or `strategy="beforeInteractive"`, call `ReactDOM.preload(src, { as: 'script', ... })`
so React Float hoists `<link rel="preload" as="script" href={src} />` into
`<head>`. The script is then fetched in parallel with the streamed HTML.

Before this change, the SSR branch of the Script shim only emitted a
`<script>` tag for `beforeInteractive` and returned `null` for every other
strategy, with no preload. The matching Next.js fixture test
test/e2e/app-dir/app-esm-js/index.test.ts ("should be able to render
nextjs api in app router") asserts:

  expect($('head link[href="/test-ext.js"]').length).toBe(1)
  expect($('head link[href="/test.js"]').length).toBe(1)

…which fails today because no preload links are emitted.

The existing Image shim already uses this exact React Float pattern
(packages/vinext/src/shims/image.tsx:285-286), so the SSR pipeline
already supports Float hoisting.

Tests:
- Updated existing Script SSR tests in tests/script.test.ts and
  tests/shims.test.ts that asserted afterInteractive/default rendered
  empty — they now assert the preload <link> is emitted (no <script>
  tag), matching Next.js.
- Added tests/nextjs-compat/script-preload.test.ts that ports the
  app-esm-js fixture's preload assertions against the app-basic
  /script-nonce page (which already exercises afterInteractive and
  beforeInteractive strategies via the existing fixture).
- crossOrigin handling matches React's type contract
  ("anonymous" | "use-credentials" | undefined). React normalises
  "anonymous" to `crossorigin=""` in HTML; tests accept both forms.

All shim / script / page / document / nonce tests pass (1151 tests).
2026-05-18 15:29:07 +01:00

62 lines
2.5 KiB
TypeScript

/**
* Next.js Compatibility Tests: next/script preload behavior
*
* Ported from Next.js: test/e2e/app-dir/app-esm-js/index.test.ts
* https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/app-esm-js/index.test.ts
*
* Verifies App Router SSR emits `<link rel="preload" as="script">` for every
* `<Script>` with a `src` and `strategy="afterInteractive"` (the default) or
* `strategy="beforeInteractive"`. This is React Float behavior driven by
* `ReactDOM.preload()` and matches Next.js's implementation at
* .nextjs-ref/packages/next/src/client/script.tsx:298-376.
*/
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";
describe("Next.js compat: next/script preload (React Float)", () => {
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("App Router emits preload <link> for afterInteractive scripts", async () => {
const { html } = await fetchHtml(baseUrl, "/script-nonce");
// /test1.js uses afterInteractive — should preload, no <script src=>.
expect(html).toMatch(/<link\b[^>]*rel="preload"[^>]*href="\/test1\.js"/);
expect(html).toMatch(/<link\b[^>]*href="\/test1\.js"[^>]*as="script"/);
expect(html).not.toMatch(/<script\b[^>]*src="\/test1\.js"/);
});
it("App Router emits preload <link> AND <script> tag for beforeInteractive scripts", async () => {
const { html } = await fetchHtml(baseUrl, "/script-nonce");
// /test2.js uses beforeInteractive — should preload AND emit the <script> tag.
expect(html).toMatch(/<link\b[^>]*rel="preload"[^>]*href="\/test2\.js"/);
expect(html).toMatch(/<script\b[^>]*src="\/test2\.js"/);
});
it("does not emit preload for inline (no-src) scripts", async () => {
const { html } = await fetchHtml(baseUrl, "/script-nonce");
// id="3" is an inline beforeInteractive script with no src — no preload link.
// The <script id="3"> tag itself still renders.
expect(html).toMatch(/<script\b[^>]*id="3"/);
const preloads = [...html.matchAll(/<link\b[^>]*rel="preload"[^>]*>/g)].map((m) => m[0]);
for (const link of preloads) {
// No preload link should have an "id" attribute matching the inline script.
expect(link).not.toMatch(/id="3"/);
}
});
});