From b1e2ce286c1b97ad15de240bd704b6fcf3e0fe37 Mon Sep 17 00:00:00 2001 From: James Date: Mon, 8 Jun 2026 21:29:40 +0100 Subject: [PATCH 1/4] fix(build): correct CSS ordering for global-not-found (#1549) --- .../vinext/src/build/client-build-config.ts | 60 +++++++++++++++++++ packages/vinext/src/index.ts | 9 +++ .../app/global-not-found.tsx | 29 +++++++++ .../global-not-found-css-order/app/gnf-a.css | 3 + .../global-not-found-css-order/app/gnf-b.css | 3 + .../global-not-found-css-order/app/layout.css | 3 + .../global-not-found-css-order/app/layout.tsx | 19 ++++++ .../global-not-found-css-order/app/page.tsx | 3 + .../global-not-found-css-order/package.json | 16 +++++ .../global-not-found-css-order/tsconfig.json | 14 +++++ .../global-not-found-css-order/vite.config.ts | 6 ++ 11 files changed, 165 insertions(+) create mode 100644 tests/fixtures/global-not-found-css-order/app/global-not-found.tsx create mode 100644 tests/fixtures/global-not-found-css-order/app/gnf-a.css create mode 100644 tests/fixtures/global-not-found-css-order/app/gnf-b.css create mode 100644 tests/fixtures/global-not-found-css-order/app/layout.css create mode 100644 tests/fixtures/global-not-found-css-order/app/layout.tsx create mode 100644 tests/fixtures/global-not-found-css-order/app/page.tsx create mode 100644 tests/fixtures/global-not-found-css-order/package.json create mode 100644 tests/fixtures/global-not-found-css-order/tsconfig.json create mode 100644 tests/fixtures/global-not-found-css-order/vite.config.ts diff --git a/packages/vinext/src/build/client-build-config.ts b/packages/vinext/src/build/client-build-config.ts index 47dfec9de..aa39473a1 100644 --- a/packages/vinext/src/build/client-build-config.ts +++ b/packages/vinext/src/build/client-build-config.ts @@ -158,6 +158,66 @@ 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 `advancedChunks` docs. + */ +const RSC_FRAMEWORK_CHUNK_TEST = + /[\\/]node_modules[\\/](react|react-dom|scheduler|react-server-dom-webpack)[\\/]/; + +function isRscFrameworkModule(id: string): boolean { + if (!id.includes("node_modules")) return false; + const pkg = getPackageName(id); + return ( + pkg === "react" || + pkg === "react-dom" || + pkg === "scheduler" || + pkg === "react-server-dom-webpack" + ); +} + +/** + * 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 `advancedChunks` 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 { + advancedChunks: { + 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/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

hello world

; +} diff --git a/tests/fixtures/global-not-found-css-order/package.json b/tests/fixtures/global-not-found-css-order/package.json new file mode 100644 index 000000000..0392270ab --- /dev/null +++ b/tests/fixtures/global-not-found-css-order/package.json @@ -0,0 +1,16 @@ +{ + "name": "global-not-found-css-order-fixture", + "private": true, + "type": "module", + "dependencies": { + "@vitejs/plugin-rsc": "catalog:", + "react": "catalog:", + "react-dom": "catalog:", + "react-server-dom-webpack": "catalog:", + "vinext": "workspace:*", + "vite": "catalog:" + }, + "devDependencies": { + "vite-plus": "catalog:" + } +} diff --git a/tests/fixtures/global-not-found-css-order/tsconfig.json b/tests/fixtures/global-not-found-css-order/tsconfig.json new file mode 100644 index 000000000..944e73c86 --- /dev/null +++ b/tests/fixtures/global-not-found-css-order/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "preserve", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "isolatedModules": true, + "allowJs": true, + "noEmit": true + } +} diff --git a/tests/fixtures/global-not-found-css-order/vite.config.ts b/tests/fixtures/global-not-found-css-order/vite.config.ts new file mode 100644 index 000000000..d0a0fd505 --- /dev/null +++ b/tests/fixtures/global-not-found-css-order/vite.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from "vite"; +import vinext from "vinext"; + +export default defineConfig({ + plugins: [vinext({ appDir: import.meta.dirname })], +}); From 123dfe2386c730d0807a57e85732604229b99c4c Mon Sep 17 00:00:00 2001 From: James Date: Mon, 8 Jun 2026 21:40:37 +0100 Subject: [PATCH 2/4] test(build): assert global-not-found CSS ordering in production (#1549) Adds a production-build regression test for #1549: builds the global-not-found-css-order fixture, serves it via the Vite preview server, and asserts the route-miss 404 document serves global-not-found's own CSS (red wins) without leaking the root layout's stylesheet (green), while matched routes still serve the layout's CSS. Verified the test fails without the framework-chunk split in client-build-config.ts (the 404 leaks the layout's green) and passes with it. --- ...-router-global-not-found-css-order.test.ts | 144 ++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 tests/app-router-global-not-found-css-order.test.ts 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"); + }); +}); From 42cb7cdd4f2aae154f045a0962dff29cbb8c19f3 Mon Sep 17 00:00:00 2001 From: James Date: Mon, 8 Jun 2026 21:52:10 +0100 Subject: [PATCH 3/4] chore: update lockfile for global-not-found-css-order test fixture (#1549) --- pnpm-lock.yaml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) 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: From ab1a7ea09a05786baad6a7f4cc6454116fbd2cc2 Mon Sep 17 00:00:00 2001 From: James Date: Tue, 9 Jun 2026 09:49:53 +0100 Subject: [PATCH 4/4] refactor(build): migrate RSC framework chunk to codeSplitting; add unit tests Address bonk review on #1858 (issue #1549): - Replace the deprecated rolldown `advancedChunks` output config with the non-deprecated `codeSplitting` form for Vite 8+, mirroring the client build. Stops the per-prod-build deprecation warning. - Derive both RSC_FRAMEWORK_CHUNK_TEST (regex) and isRscFrameworkModule (predicate) from a single FRAMEWORK_PACKAGES list so they can't drift. - Add focused unit tests for createRscFrameworkChunkOutputConfig (both the Vite 7 manualChunks and Vite 8+ codeSplitting branches) and for the framework package matching (matches react/react-dom/scheduler/ react-server-dom-webpack, excludes react-icons / @react-aria/*). --- .../vinext/src/build/client-build-config.ts | 27 ++++--- tests/build-optimization.test.ts | 75 +++++++++++++++++++ 2 files changed, 90 insertions(+), 12 deletions(-) diff --git a/packages/vinext/src/build/client-build-config.ts b/packages/vinext/src/build/client-build-config.ts index aa39473a1..184488131 100644 --- a/packages/vinext/src/build/client-build-config.ts +++ b/packages/vinext/src/build/client-build-config.ts @@ -180,33 +180,36 @@ export function createClientCodeSplittingConfig( * runtime that the server environment bundles. * * Uses `[\\/]` rather than `/` for the path separator so it matches on Windows - * too, per the rolldown `advancedChunks` docs. + * too, per the rolldown `codeSplitting` docs. */ -const RSC_FRAMEWORK_CHUNK_TEST = - /[\\/]node_modules[\\/](react|react-dom|scheduler|react-server-dom-webpack)[\\/]/; +const FRAMEWORK_PACKAGES = ["react", "react-dom", "scheduler", "react-server-dom-webpack"] as const; -function isRscFrameworkModule(id: string): boolean { +/** + * 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 === "react" || - pkg === "react-dom" || - pkg === "scheduler" || - pkg === "react-server-dom-webpack" - ); + 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 `advancedChunks` for Vite 8+, Rollup's + * 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 { - advancedChunks: { + codeSplitting: { groups: [{ name: "framework", test: RSC_FRAMEWORK_CHUNK_TEST }], }, }; 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); + } + }); +});