Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions packages/vinext/src/build/client-build-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,69 @@ export function createClientCodeSplittingConfig(
};
}

/**
* Matches React framework packages (and the RSC flight runtime) inside
* `node_modules`. Used to split them into a dedicated "framework" chunk in the
* RSC server build.
*
* Why the RSC build needs this: without an explicit framework chunk, the
* bundler colocates React into whichever chunk first reaches it — typically the
* RSC entry chunk, which also carries the root layout's CSS. Modules that only
* import React for runtime helpers (notably `app/global-not-found.tsx`, which
* replaces the root layout for route-miss 404s) then import that entry chunk
* and inherit the root layout's CSS in their `serverResources` metadata. The
* 404 document ends up linking the layout's stylesheet last, so the layout's
* rules win the cascade over global-not-found's — the bug tracked in
* https://github.com/cloudflare/vinext/issues/1549.
*
* Splitting React into its own (CSS-free) chunk means global-not-found imports
* the framework chunk instead of the layout-bearing entry chunk, so it no
* longer inherits the root layout's CSS. The match list mirrors the client
* build's `framework` chunk, plus `react-server-dom-webpack` for the RSC flight
* runtime that the server environment bundles.
*
* Uses `[\\/]` rather than `/` for the path separator so it matches on Windows
* too, per the rolldown `codeSplitting` docs.
*/
const FRAMEWORK_PACKAGES = ["react", "react-dom", "scheduler", "react-server-dom-webpack"] as const;

/**
* Regex matching any {@link FRAMEWORK_PACKAGES} package inside `node_modules`.
* Derived from the package list so the regex and {@link isRscFrameworkModule}
* predicate can't drift.
*/
export const RSC_FRAMEWORK_CHUNK_TEST = new RegExp(
`[\\\\/]node_modules[\\\\/](${FRAMEWORK_PACKAGES.join("|")})[\\\\/]`,
);

export function isRscFrameworkModule(id: string): boolean {
if (!id.includes("node_modules")) return false;
const pkg = getPackageName(id);
return pkg !== null && (FRAMEWORK_PACKAGES as readonly string[]).includes(pkg);
}

/**
* Output config that isolates React (and the RSC flight runtime) into a
* dedicated "framework" chunk in the RSC server build. Returns the bundler-
* appropriate shape: rolldown's `codeSplitting` for Vite 8+, Rollup's
* `manualChunks` for Vite 7. See {@link RSC_FRAMEWORK_CHUNK_TEST} for the
* motivation (issue #1549).
*/
export function createRscFrameworkChunkOutputConfig(viteMajorVersion: number) {
if (viteMajorVersion >= 8) {
return {
codeSplitting: {
groups: [{ name: "framework", test: RSC_FRAMEWORK_CHUNK_TEST }],
},
};
}
return {
manualChunks(id: string): string | undefined {
return isRscFrameworkModule(id) ? "framework" : undefined;
},
};
}

/**
* Rollup treeshake configuration for production client builds.
*
Expand Down
9 changes: 9 additions & 0 deletions packages/vinext/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ import {
createClientOutputConfig,
createClientCodeSplittingConfig,
createClientAssetFileNames,
createRscFrameworkChunkOutputConfig,
getClientTreeshakeConfigForVite,
getBuildBundlerOptions,
withBuildBundlerOptions,
Expand Down Expand Up @@ -2082,6 +2083,14 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
outDir: options.rscOutDir ?? "dist/server",
...withBuildBundlerOptions(viteMajorVersion, {
input: { index: VIRTUAL_RSC_ENTRY },
// Split React (and the RSC flight runtime) into a dedicated
// CSS-free "framework" chunk so `app/global-not-found.tsx`
// imports that chunk for its React helpers instead of the RSC
// entry chunk — which carries the root layout's CSS. Without
// this, global-not-found inherits the layout's stylesheet and
// the route-miss 404 document resolves the cascade to the
// layout's rules instead of global-not-found's (issue #1549).
output: createRscFrameworkChunkOutputConfig(viteMajorVersion),
}),
},
},
Expand Down
25 changes: 25 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

144 changes: 144 additions & 0 deletions tests/app-router-global-not-found-css-order.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
/**
* Regression test for issue #1549 — production CSS ordering for
* `app/global-not-found.tsx`.
*
* Ported from Next.js:
* test/e2e/app-dir/initial-css-order/initial-css-order.test.ts
* (the `should serve styles in the correct order for global-not-found` case)
* https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/initial-css-order/initial-css-order.test.ts
*
* Why a *production* build test (not the dev-server SSR test in
* tests/nextjs-compat/global-not-found.test.ts):
*
* The bug only manifests in the production RSC build. Without React split into
* its own chunk, the bundler colocates the root layout's CSS into the shared
* RSC entry chunk. `app/global-not-found.tsx` imports that entry chunk for its
* React runtime helpers and so inherits the layout's stylesheet in its
* `serverResources` metadata. The 404 document then links the layout's CSS
* (green) *after* global-not-found's own CSS (red), and green wins the cascade
* — the wrong colour. The fix (createRscFrameworkChunkOutputConfig in
* packages/vinext/src/build/client-build-config.ts) isolates React into a
* CSS-free "framework" chunk so global-not-found no longer drags in the
* layout's CSS.
*
* Fixture: tests/fixtures/global-not-found-css-order/
* - layout.tsx imports layout.css → body green (matched routes)
* - global-not-found.tsx imports gnf-a.css then gnf-b.css → blue then red,
* so red must win on route-miss 404s.
*
* Assertions mirror upstream: the 404 document must link ONLY
* global-not-found's stylesheets, in import order (gnf-a before gnf-b), and
* must NOT carry the root layout's stylesheet.
*/

import fs from "node:fs";
import path from "node:path";
import { createBuilder, preview } from "vite";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import vinext from "../packages/vinext/src/index.js";

const FIXTURE_DIR = path.resolve(import.meta.dirname, "./fixtures/global-not-found-css-order");

/**
* Extract the contents of every `<link rel="stylesheet">` href in document
* order. CSS cascade is order-sensitive, so order matters for the assertion.
*/
function extractCssLinks(html: string): string[] {
const hrefs: string[] = [];
const linkRe = /<link\b[^>]*\brel="stylesheet"[^>]*>/gi;
for (const m of html.matchAll(linkRe)) {
const hrefMatch = /\bhref="([^"]+)"/i.exec(m[0]);
if (hrefMatch) hrefs.push(hrefMatch[1]);
}
return hrefs;
}

/**
* Read the built CSS bundle for a given stylesheet href so we can assert on
* the *rule* that wins, independent of hashed filenames. Hrefs look like
* `/_next/static/<hash>.css`; map them onto the client output tree.
*/
function readCssAsset(clientDir: string, href: string): string {
const rel = href.replace(/^\//, "");
const full = path.join(clientDir, rel);
return fs.readFileSync(full, "utf-8");
}

describe("App Router: global-not-found CSS order (production, #1549)", () => {
const distDir = path.resolve(FIXTURE_DIR, "dist");
const clientDir = path.join(distDir, "client");
let previewServer: Awaited<ReturnType<typeof preview>>;
let baseUrl: string;

beforeAll(async () => {
const builder = await createBuilder({
root: FIXTURE_DIR,
configFile: false,
plugins: [vinext({ appDir: FIXTURE_DIR })],
logLevel: "silent",
});
await builder.buildApp();

previewServer = await preview({
root: FIXTURE_DIR,
configFile: false,
plugins: [vinext({ appDir: FIXTURE_DIR })],
preview: { port: 0 },
logLevel: "silent",
});
const addr = previewServer.httpServer.address();
baseUrl = addr && typeof addr === "object" ? `http://localhost:${addr.port}` : "";
expect(baseUrl).not.toBe("");
}, 120_000);

afterAll(() => {
previewServer?.httpServer.close();
fs.rmSync(distDir, { recursive: true, force: true });
});

it("matched routes serve the root layout's CSS (green wins)", async () => {
const res = await fetch(`${baseUrl}/`);
expect(res.status).toBe(200);
const html = await res.text();
const links = extractCssLinks(html);
// The home page is wrapped by the root layout, so its CSS must be present.
expect(links.length).toBeGreaterThanOrEqual(1);
const css = links.map((h) => readCssAsset(clientDir, h)).join("\n");
expect(css).toContain("green");
// The home page must NOT carry global-not-found's stylesheets.
expect(css).not.toContain("blue");
expect(css).not.toContain("red");
});

it("route-miss 404 serves global-not-found's CSS with red winning, and no layout CSS leak", async () => {
const res = await fetch(`${baseUrl}/does-not-exist`);
expect(res.status).toBe(404);
const html = await res.text();
// global-not-found.tsx ships its own document.
expect(html).toContain('data-global-not-found="true"');

const links = extractCssLinks(html);
expect(links.length).toBeGreaterThanOrEqual(1);

const allCss = links.map((h) => readCssAsset(clientDir, h)).join("\n");

// The winning `background-color` on the 404 document must be red. gnf-a.css
// (blue) is imported before gnf-b.css (red); both target the identical
// `body { background-color }`, so production minification collapses them
// into a single rule and source order decides the winner — gnf-b's red.
// Asserting on the collapsed rule (rather than substring presence of both
// colours) is what actually proves the import-order cascade resolved
// correctly.
expect(allCss).toContain("background-color:red");
// gnf-a's blue lost the cascade and was minified away.
expect(allCss).not.toContain("blue");

// The root layout's CSS must NOT leak onto the 404 document — this is the
// #1549 regression. If React is colocated with the layout's CSS chunk, the
// RSC entry chunk global-not-found imports for its React helpers also
// carries `layout.css`, so `green` reappears here last and overrides red.
// The framework-chunk split (createRscFrameworkChunkOutputConfig) prevents
// that leak.
expect(allCss).not.toContain("green");
});
});
75 changes: 75 additions & 0 deletions tests/build-optimization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ import {
createClientManualChunks,
clientTreeshakeConfig,
getClientTreeshakeConfigForVite,
createRscFrameworkChunkOutputConfig,
RSC_FRAMEWORK_CHUNK_TEST,
isRscFrameworkModule,
} from "../packages/vinext/src/build/client-build-config.js";
import { computeLazyChunks } from "../packages/vinext/src/utils/lazy-chunks.js";
import { asyncHooksStubPlugin as _asyncHooksStubPlugin } from "../packages/vinext/src/plugins/async-hooks-stub.js";
Expand Down Expand Up @@ -2167,3 +2170,75 @@ describe("getClientTreeshakeConfigForVite", () => {
});
});
});

// ─── createRscFrameworkChunkOutputConfig ──────────────────────────────────────

describe("createRscFrameworkChunkOutputConfig", () => {
it("returns manualChunks for Vite 7 (Rollup) routing framework modules to 'framework'", () => {
const config = createRscFrameworkChunkOutputConfig(7);
expect(config).not.toHaveProperty("codeSplitting");
expect(config).toHaveProperty("manualChunks");
const manualChunks = (config as { manualChunks: (id: string) => string | undefined })
.manualChunks;
expect(manualChunks("/app/node_modules/react/index.js")).toBe("framework");
expect(manualChunks("/app/node_modules/react-server-dom-webpack/client.js")).toBe("framework");
// Non-framework node_modules and local files are left to the default algo.
expect(manualChunks("/app/node_modules/react-icons/lib/index.js")).toBeUndefined();
expect(manualChunks("/app/src/page.tsx")).toBeUndefined();
});

it("returns codeSplitting for Vite 8+ (Rolldown), not the deprecated advancedChunks", () => {
const config = createRscFrameworkChunkOutputConfig(8);
expect(config).not.toHaveProperty("advancedChunks");
expect(config).not.toHaveProperty("manualChunks");
expect(config).toEqual({
codeSplitting: {
groups: [{ name: "framework", test: RSC_FRAMEWORK_CHUNK_TEST }],
},
});

// Vite 9+ uses the same Rolldown shape.
expect(createRscFrameworkChunkOutputConfig(9)).toEqual({
codeSplitting: {
groups: [{ name: "framework", test: RSC_FRAMEWORK_CHUNK_TEST }],
},
});
});
});

// ─── RSC framework package matching (single source of truth) ──────────────────

describe("RSC framework package matching", () => {
const matching = [
"/app/node_modules/react/index.js",
"/app/node_modules/react-dom/server.js",
"/app/node_modules/scheduler/index.js",
"/app/node_modules/react-server-dom-webpack/client.js",
// pnpm-style nested path.
"/app/node_modules/.pnpm/react@19.0.0/node_modules/react/index.js",
];
const notMatching = [
"/app/node_modules/react-icons/lib/index.js",
"/app/node_modules/@react-aria/utils/dist/index.js",
"/app/node_modules/@react-aria/focus/dist/index.js",
"/app/src/components/react-thing.tsx",
];

it("RSC_FRAMEWORK_CHUNK_TEST matches framework packages only", () => {
for (const id of matching) {
expect(RSC_FRAMEWORK_CHUNK_TEST.test(id)).toBe(true);
}
for (const id of notMatching) {
expect(RSC_FRAMEWORK_CHUNK_TEST.test(id)).toBe(false);
}
});

it("isRscFrameworkModule matches framework packages only", () => {
for (const id of matching) {
expect(isRscFrameworkModule(id)).toBe(true);
}
for (const id of notMatching) {
expect(isRscFrameworkModule(id)).toBe(false);
}
});
});
29 changes: 29 additions & 0 deletions tests/fixtures/global-not-found-css-order/app/global-not-found.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// `global-not-found.tsx` owns its own <html>/<body> and replaces the root
// layout for route-miss 404s (Next.js 16 `experimental.globalNotFound`).
//
// It imports two stylesheets whose order matters: `gnf-a.css` paints the body
// blue, then `gnf-b.css` paints it red. Because gnf-b is imported last, red
// must win the cascade — this asserts global-not-found's own CSS import order
// is preserved in production.
//
// Crucially, global-not-found imports CSS files the root layout does NOT, so
// its red rule survives minification (it is never merged with the layout's
// green). The remaining failure mode the fix addresses is global-not-found
// inheriting the root layout's CSS chunk in production: without React split
// into its own chunk, the bundler colocates the layout's stylesheet with the
// shared RSC entry chunk that global-not-found imports for React helpers, so
// the 404 document links the layout's green last and green wins. Mirrors:
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/initial-css-order/app/global-not-found.tsx
// See https://github.com/cloudflare/vinext/issues/1549.
import "./gnf-a.css";
import "./gnf-b.css";

export default function GlobalNotFound() {
return (
<html data-global-not-found="true">
<body>
<h1 id="global-error-title">global-not-found</h1>
</body>
</html>
);
}
3 changes: 3 additions & 0 deletions tests/fixtures/global-not-found-css-order/app/gnf-a.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
body {
background-color: blue;
}
3 changes: 3 additions & 0 deletions tests/fixtures/global-not-found-css-order/app/gnf-b.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
body {
background-color: red;
}
3 changes: 3 additions & 0 deletions tests/fixtures/global-not-found-css-order/app/layout.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
body {
background-color: green;
}
Loading
Loading