Skip to content
Open
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
11 changes: 5 additions & 6 deletions packages/vinext/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 })
Expand Down Expand Up @@ -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,
Expand Down
256 changes: 200 additions & 56 deletions packages/vinext/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same feedback as getClientOutputConfig — the Vite 8 branch strips preset and returns { moduleSideEffects: "no-external" } under rollupOptions.treeshake. However, the issue (#540) and Vite 8 migration docs suggest that Rolldown's treeshake config should be under build.rolldownOptions.treeshake, not build.rollupOptions.treeshake. If Vite 8/Rolldown ignores rollupOptions.treeshake entirely, then moduleSideEffects: "no-external" has no effect and the optimization is silently lost.

Please verify that rollupOptions.treeshake.moduleSideEffects is actually respected by Vite 8/Rolldown. If not, the Vite 8 path may need to return the config under a different key, or the caller sites need to conditionally place it under rolldownOptions instead.

// Vite 8+ uses Rolldown which doesn't support `preset` option
// moduleSideEffects is still supported in Rolldown
return {
moduleSideEffects: "no-external" as const,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the critical question for the whole PR: does Vite 8 / Rolldown actually respect rollupOptions.treeshake.moduleSideEffects?

The issue (#540) and the Vite 8 migration docs both indicate that Rolldown treeshake config should go under build.rolldownOptions.treeshake, not build.rollupOptions.treeshake. If Rolldown ignores rollupOptions.treeshake entirely, this returns a config object that has no effect — the warnings are gone but so is the optimization.

Could you verify this with an actual Vite 8 build? If rollupOptions.treeshake is ignored, the Vite 8 path may need to return the config under a different key, or the call sites in the plugin/CLI need to conditionally place it under rolldownOptions.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment says "moduleSideEffects is still supported in Rolldown" — has this been verified? The previous bot reviews raised the same concern. If Rolldown ignores rollupOptions.treeshake entirely (requiring rolldownOptions.treeshake instead), then getClientBuildOptions() correctly places it under rolldownOptions for Vite 8 — good. But the standalone (non-multi-env) path in the config hook at line ~1328 also places it correctly.

The remaining question is whether Rolldown actually supports the moduleSideEffects: "no-external" value at all. If it doesn't, the barrel-library DCE optimization is silently lost. A quick test with a Vite 8 build would confirm — consider documenting the verification result in a comment here.

};
}
// 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<string, string>;
output: { manualChunks: typeof clientManualChunks; experimentalMinChunkSize?: number };
treeshake: { preset?: "recommended"; moduleSideEffects: "no-external" };
};
rolldownOptions?: {
input?: Record<string, string>;
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<string, string>,
): {
rollupOptions?: {
input: Record<string, string>;
output: { manualChunks: typeof clientManualChunks; experimentalMinChunkSize?: number };
treeshake: { preset?: "recommended"; moduleSideEffects: "no-external" };
};
rolldownOptions?: {
input: Record<string, string>;
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),
},
};
}
Comment on lines +569 to +640
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getClientBuildOptions and getClientBuildOptionsWithInput are near-identical — they only differ in whether input is included. Consider collapsing them into a single function with an optional parameter:

function getClientBuildOptions(
  viteVersion: number,
  input?: Record<string, string>,
) { ... }

This halves the type signature and branching duplication, and eliminates a whole exported function.


type BuildManifestChunk = {
file: string;
isEntry?: boolean;
Expand Down Expand Up @@ -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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: In the Vite 8 branch, this reads config.build?.rollupOptions?.onwarn to preserve user-supplied warning handling. But a Vite 8 user would configure onwarn under rolldownOptions, not rollupOptions. This silently drops the user's onwarn.

This should read from rolldownOptions in the Vite 8 branch:

Suggested change
const userOnwarn = config.build?.rollupOptions?.onwarn;
const userOnwarn = config.build?.rolldownOptions?.onwarn ?? config.build?.rollupOptions?.onwarn;

(Checking both covers the transition period where users might still have rollupOptions config.)

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,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The as any cast here disables all type checking for the entire Vite 8 build config object. This means if you misspell a property name or pass the wrong shape, the compiler won't catch it.

Consider a narrower cast, e.g.:

build: {
  ["rolldownOptions" as string]: { ... },
}

Or define a minimal type for the Vite 8 config shape.

}
: {
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);
}
};
})(),
},
},
}),
Comment on lines +1324 to +1384
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The onwarn factory is duplicated verbatim across the Vite 8 and Vite 7 branches (15 lines each). Extract it into a helper:

function createDirectiveOnwarn(userOnwarn?: (warning: any, handler: (w: any) => void) => void) {
  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);
    }
  };
}

Then both branches become a single line: onwarn: createDirectiveOnwarn(config.build?.rollupOptions?.onwarn)

This also makes the onwarn source (rollupOptions vs rolldownOptions) easier to vary per branch.

// 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
Expand Down Expand Up @@ -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,
}),
},
},
};
Expand All @@ -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,
}),
},
},
};
Expand Down Expand Up @@ -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,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good that both the old constants and the new getters are exported — preserves backwards compatibility for any external consumers importing clientOutputConfig / clientTreeshakeConfig directly. Worth adding a brief @deprecated JSDoc on the old constants so consumers know to migrate to the getters:

/** @deprecated Use `getClientOutputConfig()` instead — applies version-gated config. */
const clientOutputConfig = { ... };

};
export { augmentSsrManifestFromBundle as _augmentSsrManifestFromBundle };
export { resolvePostcssStringPlugins as _resolvePostcssStringPlugins };
export { _postcssCache };
Expand Down
Loading
Loading