diff --git a/packages/vinext/src/cli.ts b/packages/vinext/src/cli.ts index 88598bb0a..914aae24c 100644 --- a/packages/vinext/src/cli.ts +++ b/packages/vinext/src/cli.ts @@ -13,7 +13,7 @@ * needed for most Next.js apps. */ -import vinext, { clientOutputConfig, clientTreeshakeConfig } from "./index.js"; +import vinext, { getClientBuildOptionsWithInput, getViteMajorVersion } from "./index.js"; import { printBuildReport } from "./build/report.js"; import path from "node:path"; import fs from "node:fs"; @@ -350,6 +350,7 @@ async function buildApp() { console.log(`\n vinext build (Vite ${getViteVersion()})\n`); const isApp = hasAppDir(); + const viteMajorVersion = getViteMajorVersion(); // In verbose mode, skip the custom logger so raw Vite/Rollup output is shown. const logger = parsed.verbose ? vite.createLogger("info", { allowClearScreen: false }) @@ -385,11 +386,9 @@ async function buildApp() { outDir: "dist/client", manifest: true, ssrManifest: true, - rollupOptions: { - input: "virtual:vinext-client-entry", - output: clientOutputConfig, - treeshake: clientTreeshakeConfig, - }, + ...getClientBuildOptionsWithInput(viteMajorVersion, { + index: "virtual:vinext-client-entry", + }), }, }, logger, diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 50da4b418..3ec381e35 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -241,7 +241,7 @@ function extractStaticValue(node: any): unknown { * by a project that has Vite 8 — so we resolve from cwd, not from * the plugin's own location. */ -function getViteMajorVersion(): number { +export function getViteMajorVersion(): number { try { const require = createRequire(path.join(process.cwd(), "package.json")); const vitePkg = require("vite/package.json"); @@ -489,6 +489,8 @@ function clientManualChunks(id: string): string | undefined { * their importers. This reduces HTTP request count and improves gzip * compression efficiency — small files restart the compression dictionary, * adding ~5-15% wire overhead vs fewer larger chunks. + * + * @deprecated Use `getClientOutputConfig()` instead — applies version-gated config. */ const clientOutputConfig = { manualChunks: clientManualChunks, @@ -519,12 +521,124 @@ const clientOutputConfig = { * tryCatchDeoptimization: false, which can break specific libraries * that rely on property access side effects or try/catch for feature detection * - 'recommended' + 'no-external' gives most of the benefit with less risk + * + * @deprecated Use `getClientTreeshakeConfig()` instead — applies version-gated config. */ const clientTreeshakeConfig = { preset: "recommended" as const, moduleSideEffects: "no-external" as const, }; +/** + * Get Rollup-compatible output config for client builds. + * Returns config without Vite 8/Rolldown-incompatible options. + */ +function getClientOutputConfig(viteVersion: number): { + manualChunks: typeof clientManualChunks; + experimentalMinChunkSize?: number; +} { + if (viteVersion >= 8) { + // Vite 8+ uses Rolldown which doesn't support experimentalMinChunkSize + return { + manualChunks: clientManualChunks, + }; + } + // Vite 7 uses Rollup with experimentalMinChunkSize support + return clientOutputConfig; +} + +/** + * Get Rollup-compatible treeshake config for client builds. + * Returns config without Vite 8/Rolldown-incompatible options. + */ +function getClientTreeshakeConfig(viteVersion: number): { + preset?: "recommended"; + moduleSideEffects: "no-external"; +} { + if (viteVersion >= 8) { + // Vite 8+ uses Rolldown which doesn't support `preset` option + // moduleSideEffects is still supported in Rolldown + return { + moduleSideEffects: "no-external" as const, + }; + } + // Vite 7 uses Rollup with preset support + return clientTreeshakeConfig; +} + +/** + * Get build options config for client builds, version-gated for Vite 8/Rolldown. + * Vite 7 uses build.rollupOptions, Vite 8+ uses build.rolldownOptions. + */ +function getClientBuildOptions(viteVersion: number): { + rollupOptions?: { + input?: Record; + output: { manualChunks: typeof clientManualChunks; experimentalMinChunkSize?: number }; + treeshake: { preset?: "recommended"; moduleSideEffects: "no-external" }; + }; + rolldownOptions?: { + input?: Record; + output: { manualChunks: typeof clientManualChunks }; + treeshake: { moduleSideEffects: "no-external" }; + }; +} { + if (viteVersion >= 8) { + // Vite 8+ uses Rolldown - config goes under rolldownOptions + return { + rolldownOptions: { + output: getClientOutputConfig(viteVersion), + treeshake: getClientTreeshakeConfig(viteVersion), + }, + }; + } + // Vite 7 uses Rollup - config goes under rollupOptions + return { + rollupOptions: { + output: getClientOutputConfig(viteVersion), + treeshake: getClientTreeshakeConfig(viteVersion), + }, + }; +} + +/** + * Get build options config for client builds with custom input, version-gated for Vite 8/Rolldown. + * Vite 7 uses build.rollupOptions, Vite 8+ uses build.rolldownOptions. + */ +function getClientBuildOptionsWithInput( + viteVersion: number, + input: Record, +): { + rollupOptions?: { + input: Record; + output: { manualChunks: typeof clientManualChunks; experimentalMinChunkSize?: number }; + treeshake: { preset?: "recommended"; moduleSideEffects: "no-external" }; + }; + rolldownOptions?: { + input: Record; + output: { manualChunks: typeof clientManualChunks }; + treeshake: { moduleSideEffects: "no-external" }; + }; +} { + if (viteVersion >= 8) { + // Vite 8+ uses Rolldown - config goes under rolldownOptions + return { + rolldownOptions: { + input, + output: getClientOutputConfig(viteVersion), + treeshake: getClientTreeshakeConfig(viteVersion), + }, + }; + } + // Vite 7 uses Rollup - config goes under rollupOptions + return { + rollupOptions: { + input, + output: getClientOutputConfig(viteVersion), + treeshake: getClientTreeshakeConfig(viteVersion), + }, + }; +} + type BuildManifestChunk = { file: string; isEntry?: boolean; @@ -1199,50 +1313,75 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { const viteConfig: UserConfig = { // Disable Vite's default HTML serving - we handle all routing appType: "custom", - build: { - rollupOptions: { - // Suppress "Module level directives cause errors when bundled" - // warnings for "use client" / "use server" directives. Our shims - // and third-party libraries legitimately use these directives; - // they are handled by the RSC plugin and are harmless in the - // final bundle. We preserve any user-supplied onwarn so custom - // warning handling is not lost. - onwarn: (() => { - const userOnwarn = config.build?.rollupOptions?.onwarn; - return (warning, defaultHandler) => { - if ( - warning.code === "MODULE_LEVEL_DIRECTIVE" && - (warning.message?.includes('"use client"') || - warning.message?.includes('"use server"')) - ) { - return; - } - if (userOnwarn) { - userOnwarn(warning, defaultHandler); - } else { - defaultHandler(warning); - } - }; - })(), - // Enable aggressive tree-shaking for client builds. - // See clientTreeshakeConfig for rationale. - // Only apply globally for standalone client builds (Pages Router - // CLI). For multi-environment builds (App Router, Cloudflare), - // treeshake is set per-environment on the client env below to - // avoid leaking into RSC/SSR environments where - // moduleSideEffects: 'no-external' could drop server packages - // that rely on module-level side effects. - ...(!isSSR && !isMultiEnv ? { treeshake: clientTreeshakeConfig } : {}), - // Code-split client bundles: separate framework (React/ReactDOM), - // vinext runtime (shims), and vendor packages into their own - // chunks so pages only load the JS they need. - // Only apply globally for standalone client builds (CLI Pages - // Router). For multi-environment builds (App Router, Cloudflare), - // manualChunks is set per-environment on the client env below - // to avoid leaking into RSC/SSR environments. - ...(!isSSR && !isMultiEnv ? { output: clientOutputConfig } : {}), - }, - }, + // For standalone client builds (Pages Router CLI), apply version-gated + // rollup/rolldown options. Vite 7 uses rollupOptions, Vite 8+ uses rolldownOptions. + // Multi-environment builds (App Router, Cloudflare) set these per-environment + // on the client env below to avoid leaking into RSC/SSR environments. + ...(isSSR || isMultiEnv + ? { + build: {}, + } + : viteMajorVersion >= 8 + ? { + build: { + rolldownOptions: { + ...getClientBuildOptions(viteMajorVersion).rolldownOptions, + // Suppress "Module level directives cause errors when bundled" + // warnings for "use client" / "use server" directives. Our shims + // and third-party libraries legitimately use these directives; + // they are handled by the RSC plugin and are harmless in the + // final bundle. We preserve any user-supplied onwarn so custom + // warning handling is not lost. + onwarn: (() => { + const userOnwarn = config.build?.rollupOptions?.onwarn; + return (warning: any, defaultHandler: (warning: any) => void) => { + if ( + warning.code === "MODULE_LEVEL_DIRECTIVE" && + (warning.message?.includes('"use client"') || + warning.message?.includes('"use server"')) + ) { + return; + } + if (userOnwarn) { + userOnwarn(warning, defaultHandler); + } else { + defaultHandler(warning); + } + }; + })(), + }, + } as any, + } + : { + build: { + rollupOptions: { + ...getClientBuildOptions(viteMajorVersion).rollupOptions, + // Suppress "Module level directives cause errors when bundled" + // warnings for "use client" / "use server" directives. Our shims + // and third-party libraries legitimately use these directives; + // they are handled by the RSC plugin and are harmless in the + // final bundle. We preserve any user-supplied onwarn so custom + // warning handling is not lost. + onwarn: (() => { + const userOnwarn = config.build?.rollupOptions?.onwarn; + return (warning: any, defaultHandler: (warning: any) => void) => { + if ( + warning.code === "MODULE_LEVEL_DIRECTIVE" && + (warning.message?.includes('"use client"') || + warning.message?.includes('"use server"')) + ) { + return; + } + if (userOnwarn) { + userOnwarn(warning, defaultHandler); + } else { + defaultHandler(warning); + } + }; + })(), + }, + }, + }), // Let OPTIONS requests pass through Vite's CORS middleware to our // route handlers so they can set the Allow header and run user-defined // OPTIONS handlers. Without this, Vite's CORS middleware responds to @@ -1442,11 +1581,9 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // on every page — defeating code-splitting for React.lazy() and // next/dynamic boundaries. ...(hasCloudflarePlugin ? { manifest: true } : {}), - rollupOptions: { - input: { index: VIRTUAL_APP_BROWSER_ENTRY }, - output: clientOutputConfig, - treeshake: clientTreeshakeConfig, - }, + ...getClientBuildOptionsWithInput(viteMajorVersion, { + index: VIRTUAL_APP_BROWSER_ENTRY, + }), }, }, }; @@ -1461,11 +1598,9 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { build: { manifest: true, ssrManifest: true, - rollupOptions: { - input: { index: VIRTUAL_CLIENT_ENTRY }, - output: clientOutputConfig, - treeshake: clientTreeshakeConfig, - }, + ...getClientBuildOptionsWithInput(viteMajorVersion, { + index: VIRTUAL_CLIENT_ENTRY, + }), }, }, }; @@ -3752,7 +3887,16 @@ export type { export type { NextConfig } from "./config/next-config.js"; // Exported for CLI and testing -export { clientManualChunks, clientOutputConfig, clientTreeshakeConfig, computeLazyChunks }; +export { + clientManualChunks, + clientOutputConfig, + clientTreeshakeConfig, + computeLazyChunks, + getClientBuildOptions, + getClientBuildOptionsWithInput, + getClientOutputConfig, + getClientTreeshakeConfig, +}; export { augmentSsrManifestFromBundle as _augmentSsrManifestFromBundle }; export { resolvePostcssStringPlugins as _resolvePostcssStringPlugins }; export { _postcssCache }; diff --git a/tests/build-optimization.test.ts b/tests/build-optimization.test.ts index 845acc67f..0f8efc0f1 100644 --- a/tests/build-optimization.test.ts +++ b/tests/build-optimization.test.ts @@ -8,11 +8,14 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { clientManualChunks, + clientOutputConfig, clientTreeshakeConfig, computeLazyChunks, _augmentSsrManifestFromBundle, _stripServerExports, _asyncHooksStubPlugin, + getClientOutputConfig, + getClientTreeshakeConfig, } from "../packages/vinext/src/index.js"; // The vinext config hook mutates process.env.NODE_ENV as a side effect (matching @@ -47,6 +50,50 @@ describe("clientTreeshakeConfig", () => { }); }); +// ─── getClientOutputConfig / getClientTreeshakeConfig ──────────────────────── + +describe("getClientOutputConfig", () => { + it("returns full config with experimentalMinChunkSize for Vite 7", () => { + const result = getClientOutputConfig(7); + expect(result).toEqual(clientOutputConfig); + expect((result as any).experimentalMinChunkSize).toBe(10_000); + expect(result.manualChunks).toBe(clientManualChunks); + }); + + it("returns config without experimentalMinChunkSize for Vite 8", () => { + const result = getClientOutputConfig(8); + expect(result).toEqual({ manualChunks: clientManualChunks }); + expect(result).not.toHaveProperty("experimentalMinChunkSize"); + }); + + it("returns config without experimentalMinChunkSize for Vite 9", () => { + const result = getClientOutputConfig(9); + expect(result).toEqual({ manualChunks: clientManualChunks }); + expect(result).not.toHaveProperty("experimentalMinChunkSize"); + }); +}); + +describe("getClientTreeshakeConfig", () => { + it("returns full config with preset for Vite 7", () => { + const result = getClientTreeshakeConfig(7); + expect(result).toEqual(clientTreeshakeConfig); + expect((result as any).preset).toBe("recommended"); + expect(result.moduleSideEffects).toBe("no-external"); + }); + + it("returns config without preset for Vite 8", () => { + const result = getClientTreeshakeConfig(8); + expect(result).toEqual({ moduleSideEffects: "no-external" }); + expect(result).not.toHaveProperty("preset"); + }); + + it("returns config without preset for Vite 9", () => { + const result = getClientTreeshakeConfig(9); + expect(result).toEqual({ moduleSideEffects: "no-external" }); + expect(result).not.toHaveProperty("preset"); + }); +}); + // ─── clientManualChunks ─────────────────────────────────────────────────────── describe("clientManualChunks", () => { @@ -336,7 +383,7 @@ describe("treeshake config integration", () => { const result = await (mainPlugin as any).config(mockConfig, { command: "build" }); // treeshake should NOT be set for SSR builds - expect(result.build.rollupOptions.treeshake).toBeUndefined(); + expect(result.build.rollupOptions?.treeshake).toBeUndefined(); } finally { await fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => {}); } @@ -383,7 +430,7 @@ describe("treeshake config integration", () => { const result = await (mainPlugin as any).config(mockConfig, { command: "build" }); // Global rollupOptions should NOT have treeshake (would leak into RSC/SSR) - expect(result.build.rollupOptions.treeshake).toBeUndefined(); + expect(result.build.rollupOptions?.treeshake).toBeUndefined(); // Client environment should have treeshake expect(result.environments.client.build.rollupOptions.treeshake).toEqual({