diff --git a/packages/vinext/src/build/client-build-config.ts b/packages/vinext/src/build/client-build-config.ts
index 47dfec9de..184488131 100644
--- a/packages/vinext/src/build/client-build-config.ts
+++ b/packages/vinext/src/build/client-build-config.ts
@@ -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.
*
diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts
index 5b21e8560..3b495d9c0 100644
--- a/packages/vinext/src/index.ts
+++ b/packages/vinext/src/index.ts
@@ -142,6 +142,7 @@ import {
createClientOutputConfig,
createClientCodeSplittingConfig,
createClientAssetFileNames,
+ createRscFrameworkChunkOutputConfig,
getClientTreeshakeConfigForVite,
getBuildBundlerOptions,
withBuildBundlerOptions,
@@ -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),
}),
},
},
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 79f6d657b..2b1cf9c64 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1418,6 +1418,31 @@ importers:
specifier: 'catalog:'
version: 0.1.24(@opentelemetry/api@1.9.1)(@types/node@25.9.2)(@vitest/coverage-istanbul@4.1.6(@voidzero-dev/vite-plus-test@0.1.24))(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(esbuild@0.27.3)(jiti@2.7.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0)
+ tests/fixtures/global-not-found-css-order:
+ dependencies:
+ '@vitejs/plugin-rsc':
+ specifier: 'catalog:'
+ version: 0.5.27(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)
+ react:
+ specifier: 'catalog:'
+ version: 19.2.7
+ react-dom:
+ specifier: 'catalog:'
+ version: 19.2.7(react@19.2.7)
+ react-server-dom-webpack:
+ specifier: 'catalog:'
+ version: 19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
+ vinext:
+ specifier: workspace:*
+ version: link:../../../packages/vinext
+ vite:
+ specifier: npm:@voidzero-dev/vite-plus-core@0.1.24
+ version: '@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0)'
+ devDependencies:
+ vite-plus:
+ specifier: 'catalog:'
+ version: 0.1.24(@opentelemetry/api@1.9.1)(@types/node@25.9.2)(@vitest/coverage-istanbul@4.1.6(@voidzero-dev/vite-plus-test@0.1.24))(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(esbuild@0.27.3)(jiti@2.7.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0)
+
tests/fixtures/pages-basic:
dependencies:
react:
diff --git a/tests/app-router-global-not-found-css-order.test.ts b/tests/app-router-global-not-found-css-order.test.ts
new file mode 100644
index 000000000..8132ecbe0
--- /dev/null
+++ b/tests/app-router-global-not-found-css-order.test.ts
@@ -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 `` 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 = /]*\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/.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>;
+ 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");
+ });
+});
diff --git a/tests/build-optimization.test.ts b/tests/build-optimization.test.ts
index b953f7b2e..ebe90f529 100644
--- a/tests/build-optimization.test.ts
+++ b/tests/build-optimization.test.ts
@@ -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";
@@ -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);
+ }
+ });
+});
diff --git a/tests/fixtures/global-not-found-css-order/app/global-not-found.tsx b/tests/fixtures/global-not-found-css-order/app/global-not-found.tsx
new file mode 100644
index 000000000..93c627ac7
--- /dev/null
+++ b/tests/fixtures/global-not-found-css-order/app/global-not-found.tsx
@@ -0,0 +1,29 @@
+// `global-not-found.tsx` owns its own / 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 (
+
+
+
global-not-found
+
+
+ );
+}
diff --git a/tests/fixtures/global-not-found-css-order/app/gnf-a.css b/tests/fixtures/global-not-found-css-order/app/gnf-a.css
new file mode 100644
index 000000000..837191701
--- /dev/null
+++ b/tests/fixtures/global-not-found-css-order/app/gnf-a.css
@@ -0,0 +1,3 @@
+body {
+ background-color: blue;
+}
diff --git a/tests/fixtures/global-not-found-css-order/app/gnf-b.css b/tests/fixtures/global-not-found-css-order/app/gnf-b.css
new file mode 100644
index 000000000..aa1634c25
--- /dev/null
+++ b/tests/fixtures/global-not-found-css-order/app/gnf-b.css
@@ -0,0 +1,3 @@
+body {
+ background-color: red;
+}
diff --git a/tests/fixtures/global-not-found-css-order/app/layout.css b/tests/fixtures/global-not-found-css-order/app/layout.css
new file mode 100644
index 000000000..9d9d772fb
--- /dev/null
+++ b/tests/fixtures/global-not-found-css-order/app/layout.css
@@ -0,0 +1,3 @@
+body {
+ background-color: green;
+}
diff --git a/tests/fixtures/global-not-found-css-order/app/layout.tsx b/tests/fixtures/global-not-found-css-order/app/layout.tsx
new file mode 100644
index 000000000..dce81ec5a
--- /dev/null
+++ b/tests/fixtures/global-not-found-css-order/app/layout.tsx
@@ -0,0 +1,19 @@
+// Root layout for the global-not-found CSS-ordering fixture.
+//
+// The layout imports its own stylesheet that paints the body green. On matched
+// routes (e.g. `/`) this is the only background rule, so the page is green.
+//
+// On route-miss 404s the global-not-found document replaces this layout
+// entirely (see createAppFallbackRenderer in app-fallback-renderer.ts), so the
+// layout's green must NOT appear on the 404 — otherwise it would override
+// global-not-found's own CSS and break the cascade. Mirrors Next.js:
+// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/initial-css-order/app/layout.tsx
+import "./layout.css";
+
+export default function RootLayout({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/tests/fixtures/global-not-found-css-order/app/page.tsx b/tests/fixtures/global-not-found-css-order/app/page.tsx
new file mode 100644
index 000000000..dab69e234
--- /dev/null
+++ b/tests/fixtures/global-not-found-css-order/app/page.tsx
@@ -0,0 +1,3 @@
+export default function Page() {
+ return