diff --git a/packages/vinext/src/build/client-build-config.ts b/packages/vinext/src/build/client-build-config.ts index 9b3c00767..4d90db255 100644 --- a/packages/vinext/src/build/client-build-config.ts +++ b/packages/vinext/src/build/client-build-config.ts @@ -111,6 +111,25 @@ function getPackageName(id: string): string | null { */ export function createClientManualChunks(shimsDir: string, preserveRouteBoundaries = false) { return function clientManualChunks(id: string): string | undefined { + // Check the shims prefix before the node_modules branch: for an installed + // copy the shims live under /node_modules/vinext/dist/shims/, so the + // node_modules early return would otherwise swallow them and the "vinext" + // chunk would never form, leaving the shims to graph-based splitting that + // can place them in a static import cycle with the browser entry chunk. + // + // `shimsDir` is slash-normalized with a trailing slash; the bundler-provided + // id can carry native backslashes on Windows, so slash it before matching. + const slashedId = toSlash(id); + if (slashedId.startsWith(shimsDir)) { + if (preserveRouteBoundaries) { + const relativeId = slashedId.slice(shimsDir.length).split("?", 1)[0] ?? ""; + const extensionIndex = relativeId.lastIndexOf("."); + const shimName = extensionIndex === -1 ? relativeId : relativeId.slice(0, extensionIndex); + if (ROUTE_OWNED_CLIENT_SHIMS.has(shimName)) return undefined; + } + return "vinext"; + } + // React framework — always loaded, shared across all pages. // Isolating React into its own chunk is the single highest-value // split: it's ~130KB compressed, loaded on every page, and its @@ -129,9 +148,6 @@ export function createClientManualChunks(shimsDir: string, preserveRouteBoundari // example that does). Split those entrypoints into their own chunk // so the server renderer loads lazily, only on routes that use it, // instead of weighing down first paint on every page. - // Windows ids carry backslashes, so slash-normalize before matching - // the "react-dom/" separator (same convention as getPackageName). - const slashedId = toSlash(id); const sub = slashedId.slice(slashedId.lastIndexOf("react-dom/") + "react-dom/".length); if ( sub.startsWith("server.") || @@ -152,19 +168,6 @@ export function createClientManualChunks(shimsDir: string, preserveRouteBoundari return undefined; } - // `shimsDir` is slash-normalized with a trailing slash; the bundler-provided - // id can carry native backslashes on Windows, so slash it before matching. - const slashedId = toSlash(id); - if (slashedId.startsWith(shimsDir)) { - if (preserveRouteBoundaries) { - const relativeId = slashedId.slice(shimsDir.length).split("?", 1)[0] ?? ""; - const extensionIndex = relativeId.lastIndexOf("."); - const shimName = extensionIndex === -1 ? relativeId : relativeId.slice(0, extensionIndex); - if (ROUTE_OWNED_CLIENT_SHIMS.has(shimName)) return undefined; - } - return "vinext"; - } - return undefined; }; } diff --git a/tests/build-optimization.test.ts b/tests/build-optimization.test.ts index 04fe0b880..c245787d4 100644 --- a/tests/build-optimization.test.ts +++ b/tests/build-optimization.test.ts @@ -195,6 +195,43 @@ describe("clientManualChunks", () => { }); }); +describe("createClientManualChunks (installed layout)", () => { + // The shimsDir MUST contain node_modules — that's the regression: an installed + // copy's shims were swallowed by the node_modules early return. + const installedShimsDir = "/app/node_modules/vinext/dist/shims/"; + + it("groups shims under node_modules into the vinext chunk", () => { + const chunks = createClientManualChunks(installedShimsDir); + expect(chunks("/app/node_modules/vinext/dist/shims/slot.js")).toBe("vinext"); + expect(chunks("/app/node_modules/vinext/dist/shims/navigation-context-state.js")).toBe( + "vinext", + ); + expect(chunks("/app/node_modules/vinext/dist/shims/slot.js?v=abc")).toBe("vinext"); + }); + + it("still excludes route-owned shims when preserving route boundaries", () => { + const chunks = createClientManualChunks(installedShimsDir, true); + expect(chunks("/app/node_modules/vinext/dist/shims/link.js")).toBeUndefined(); + expect( + chunks("/app/node_modules/vinext/dist/shims/internal/hybrid-client-route-owner.js"), + ).toBeUndefined(); + expect(chunks("/app/node_modules/vinext/dist/shims/slot.js")).toBe("vinext"); + }); + + it("leaves framework and vendor grouping untouched", () => { + const chunks = createClientManualChunks(installedShimsDir); + expect(chunks("/app/node_modules/react/index.js")).toBe("framework"); + expect(chunks("/app/node_modules/scheduler/index.js")).toBe("framework"); + expect(chunks("/app/node_modules/react-dom/client.js")).toBe("framework"); + expect(chunks("/app/node_modules/react-dom/server.browser.js")).toBe("react-dom-server"); + expect(chunks("/app/node_modules/.pnpm/react@19.2.8/node_modules/react/index.js")).toBe( + "framework", + ); + expect(chunks("/app/node_modules/lodash/map.js")).toBeUndefined(); + expect(chunks("/app/src/components/Button.tsx")).toBeUndefined(); + }); +}); + // ─── optimizeDeps.exclude — prevents esbuild scanning virtual module imports ─ describe("optimizeDeps.exclude for vinext", () => {