Files
cloudflare__vinext/tests/inline-css-build.test.ts
Nathan Nguyen 7be428dbc3 feat(app-router): support inline CSS parity (#1595)
* feat(app-router): support inline CSS parity

App Router production builds ignored Next.js experimental.inlineCss, so HTML responses kept stylesheet links, next/font CSS lived in a second style tag, and Next-style RSC requests with RSC: 1 plus _rsc could return HTML.

The missing invariant was that inlineCss changes the production CSS delivery boundary: non-RSC HTML gets inlined style resources, RSC payloads keep CSS bodies out, and client navigations must not reintroduce duplicate stylesheet resources for CSS already present inline.

Resolve the config flag into the generated RSC entry, collect emitted CSS assets into an inline manifest for Node and Workers production, rewrite matching React stylesheet links into hoistable style tags, merge next/font CSS into the first inline style with a fallback path, and recognize Next-style RSC requests.

Regression coverage now ports the upstream app-inline-css behavior at production-browser level and adds focused tests for request normalization, stream rewriting, font merging, and fallback emission.

* feat(app-router): preserve inline CSS import ordering

Inline CSS font merging could prepend generated font rules before a stylesheet import. CSS imports must stay at the beginning of their stylesheet, so that ordering caused imported styles to be ignored when experimental.inlineCss and local font CSS were combined.

Keep import-sensitive stylesheet preambles untouched and let the existing font fallback style carry the font rules instead. Add stream-helper coverage for the import case.

* feat(app-router): harden inline CSS review edges

Inline CSS rewriting only saw complete link tags in a single decoded chunk and treated rel as an exact value. That left valid stylesheet links un-inlined when rel had multiple tokens or when streaming split the tag across chunks.

The production manifest injection also assumed the RSC worker bundle was always at dist/server/index.js, so custom rscOutDir builds missed the inline CSS manifest.

Buffer incomplete link tags across stream flushes, match stylesheet as a rel token, and inject the manifest into the configured RSC output entry. Add regressions for the streaming and custom-output cases.

* feat(app-router): match inline CSS cleanup semantics

Client navigation cleanup only pruned links whose rel was exactly stylesheet and whose precedence marker used data-precedence. That diverged from the server inline CSS rewrite, so tokenized rel values or legacy precedence links could survive after navigation as duplicate stylesheet resources.

The fix moves the cleanup into a testable helper, reuses shared HTML token-list matching for server and browser paths, accepts both precedence marker forms, and only buffers split link tags when an inline CSS manifest is present.

Regression coverage now covers browser cleanup and non-inlineCSS streaming chunk granularity.

* feat(app-router): harden inline CSS HTML rewriting

Inline CSS rewriting could prepend SSR font CSS ahead of namespace-bearing stylesheets and could treat stylesheet-like text inside script bodies as real link tags. That violates CSS ordering constraints and corrupts streamed script text when experimental.inlineCss is enabled.

The stream rewrite now treats @namespace as an unsafe preamble, skips link replacement inside raw-text and comment regions, and buffers incomplete rewrite boundaries across flushes. Regression coverage pins namespace fallback, complete script text, split script text, and unterminated script text.

* test(e2e): specify junction type for fs.symlink to support Windows

* refactor(app-router): drop write-only __PAGE__ wire marker

Every outgoing App Router payload carried `__PAGE__: true` in its wire
metadata, but nothing in the runtime ever read it. Its only consumer was
an inline-css E2E assertion checking that a dynamic navigation returned an
RSC payload rather than HTML, so the marker added permanent per-response
wire overhead with no behavioural effect.

The `__route` key is already present on every payload and proves the same
thing: the response carries page metadata. Remove the marker key, its type
field, and its re-export, and point the unit and E2E assertions at
`__route` instead.

* perf(app-router): skip inline-css manifest scan on the streaming hot path

The tick-buffered SSR transform recomputed `Object.keys(manifest).length`
on every flush through `rewriteInlineCssStylesheetLinks`, even when inline
CSS was disabled and the manifest empty. Streaming flushes are a hot path,
so this ran per chunk of every response for no benefit.

Compute the manifest-present flag once at transform creation and reuse it
to gate both the split-link boundary buffering and the link rewrite, so
non-inline-css responses skip the rewrite entirely. Also document the
shared-lastIndex constraint on the module-level rewrite regexes and the
startup/build-time population contract for `__VINEXT_INLINE_CSS__`.

* fix(app-router): forward SSR script nonce to inline-css <style> tags

Sites running `Content-Security-Policy: style-src 'nonce-…'` saw the
inlined `<style>` blocks blocked at parse time and rendered unstyled
when `experimental.inlineCss` was enabled. The `<link rel="stylesheet">`
tags the feature replaces aren't subject to inline-style CSP, but the
new inline blocks are — and React Fizz typically doesn't put a nonce on
the `<link>`, so the rewrite path had no nonce to carry over.

Thread the SSR-time `scriptNonce` through `createTickBufferedTransform`
into `rewriteInlineCssStylesheetLinks`, and prefer the link's own nonce
when present (otherwise fall back to the SSR nonce). Add unit coverage
for the SSR-only, link-only, and missing-nonce cases.

Also replace `html.toLowerCase().lastIndexOf("<link")` in
`splitTrailingIncompleteLinkTag` with a forward `g+i` regex scan so the
streaming hot path no longer allocates a lowercased copy of every flush
(can be tens of KB on large responses).

* chore(check): register experimental.inlineCss as supported

So `vp check` advertises App Router CSS inlining alongside the other
experimental flags. Notes that next/font CSS is folded into the first
inline `<style>` block.

---------

Co-authored-by: James <james@eli.cx>
2026-05-28 18:17:06 +00:00

88 lines
2.9 KiB
TypeScript

import fsp from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { createBuilder } from "vite";
import { describe, expect, it } from "vite-plus/test";
import vinext from "../packages/vinext/src/index.js";
const ROOT_NODE_MODULES = path.resolve(import.meta.dirname, "../node_modules");
async function writeFile(file: string, source: string): Promise<void> {
await fsp.mkdir(path.dirname(file), { recursive: true });
await fsp.writeFile(file, source, "utf8");
}
async function findRscEntry(rscOutDir: string): Promise<string> {
const entries = await fsp.readdir(rscOutDir);
const entry = entries.find((file) => /^index\.m?js$/.test(file));
if (!entry) {
throw new Error(`No RSC entry found in ${rscOutDir}. Contents: ${entries.join(", ")}`);
}
return path.join(rscOutDir, entry);
}
describe("inline CSS production build", () => {
it("injects the inline CSS manifest into a custom App Router RSC output directory", async () => {
const fixtureRoot = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-inline-css-build-"));
const outRoot = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-inline-css-build-out-"));
try {
await fsp.symlink(ROOT_NODE_MODULES, path.join(fixtureRoot, "node_modules"), "junction");
await writeFile(
path.join(fixtureRoot, "package.json"),
`${JSON.stringify({ type: "module", dependencies: {} }, null, 2)}\n`,
);
await writeFile(
path.join(fixtureRoot, "app", "global.css"),
".inline-css-build-marker { color: rgb(1, 2, 3); }\n",
);
await writeFile(
path.join(fixtureRoot, "app", "layout.tsx"),
`import "./global.css";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return <html><body>{children}</body></html>;
}
`,
);
await writeFile(
path.join(fixtureRoot, "app", "page.tsx"),
`export default function Page() {
return <p className="inline-css-build-marker">home</p>;
}
`,
);
const rscOutDir = path.join(outRoot, "custom-rsc");
const ssrOutDir = path.join(outRoot, "custom-ssr");
const builder = await createBuilder({
root: fixtureRoot,
configFile: false,
plugins: [
vinext({
appDir: fixtureRoot,
rscOutDir,
ssrOutDir,
nextConfig: {
experimental: {
inlineCss: true,
},
},
}),
],
logLevel: "silent",
});
await builder.buildApp();
const rscEntry = await findRscEntry(rscOutDir);
const code = await fsp.readFile(rscEntry, "utf8");
expect(code).toContain("globalThis.__VINEXT_INLINE_CSS__");
expect(code).toContain("_next/static");
} finally {
await fsp.rm(fixtureRoot, { recursive: true, force: true }).catch(() => {});
await fsp.rm(outRoot, { recursive: true, force: true }).catch(() => {});
}
}, 120_000);
});