Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
1 change: 0 additions & 1 deletion packages/vinext/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,6 @@
"@vinext/types": "workspace:^",
"ipaddr.js": "catalog:",
"magic-string": "catalog:",
"vite-plugin-commonjs": "catalog:",
"web-vitals": "catalog:"
},
"devDependencies": {
Expand Down
23 changes: 8 additions & 15 deletions packages/vinext/src/config/next-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import fs from "node:fs";
import { fileURLToPath } from "node:url";
import { randomUUID } from "node:crypto";
import type { PluginOption } from "vite";
import commonjs from "vite-plugin-commonjs";
import { createCommonJsPlugin } from "../plugins/commonjs.js";
import { PHASE_DEVELOPMENT_SERVER } from "vinext/shims/constants";
import { normalizePageExtensions } from "../routing/file-matcher.js";
import { getHtmlLimitedBotRegex } from "../utils/html-limited-bots.js";
Expand Down Expand Up @@ -1018,7 +1018,7 @@ export async function loadNextConfig(
// name; it does not shadow an installed package with a baseUrl-local file.
const useNativeTsconfigPaths = !!tsconfigBaseUrl;

// Symlink-resolved config path, used by the `commonjs()` filter below to
// Symlink-resolved config path, used by the CommonJS filter below to
// exclude the config file itself. macOS uses /private/var symlinks, so
// string-compare without realpath would falsely include the config.
const normalizedConfigPath = safeRealpath(path.resolve(configPath));
Expand Down Expand Up @@ -1046,11 +1046,8 @@ export async function loadNextConfig(
// externalized, so a baseUrl-local file does not shadow a package of the
// same name.
...(useNativeTsconfigPaths ? { tsconfigPaths: true } : {}),
// Include `.cjs` and `.cts` so `vite-plugin-commonjs` recognises
// those extensions (the plugin keys off `config.resolve.extensions`,
// which on Vite defaults to `[.mjs, .js, .mts, .ts, .jsx, .tsx,
// .json]` — no CJS extensions). This also lets the runner's resolver
// find `./foo` style imports that resolve to a `.cjs`/`.cts` sibling.
// Include `.cjs` and `.cts` so the runner's resolver and the CommonJS
// transform recognise extensionless sibling imports in those formats.
extensions: [".mjs", ".js", ".cjs", ".mts", ".ts", ".cts", ".jsx", ".tsx", ".json"],
},
// Only inject CJS globals for TypeScript config flavours. Next.js
Expand All @@ -1059,7 +1056,7 @@ export async function loadNextConfig(
// configs are loaded through Node and already have `require`/`module`,
// and `.mjs` configs are explicitly ESM-only.
//
// Pair that with `vite-plugin-commonjs` (the same plugin used for
// Pair that with the same internal CommonJS transform used for
// application code in index.ts) so sibling imports like `.cjs`/`.cts`,
// or `.js`/`.ts` files that assign to `module.exports`, are converted
// to ESM before Vite's runner evaluates them. The default `filter`
Expand Down Expand Up @@ -1093,16 +1090,12 @@ export async function loadNextConfig(
},
},
...(isTypeScriptConfig ? [cjsGlobalsInjectorPlugin(configPath)] : []),
commonjs({
filter: (id: string) => {
createCommonJsPlugin({
shouldTransform: (_environment, _code, id) => {
const idPath = id.startsWith("file://") ? fileURLToPath(id) : id.split("?")[0];
const resolvedId = safeRealpath(path.resolve(idPath));
if (resolvedId === normalizedConfigPath) return false;
// Returning `true` forces the transform to run even for ids
// inside `node_modules` (default behaviour skips them);
// `undefined` falls through to the plugin's default for
// user code.
return id.includes("node_modules") ? true : undefined;
return true;
},
}),
],
Expand Down
88 changes: 36 additions & 52 deletions packages/vinext/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type {
Alias,
CSSModulesOptions,
DevEnvironment,
Environment,
HotUpdateOptions,
Logger,
Plugin,
Expand Down Expand Up @@ -276,7 +277,7 @@ import { createRequire } from "node:module";
import fs from "node:fs";
import { createHash, randomBytes } from "node:crypto";
import { getPagesPreviewModeId } from "./server/pages-preview.js";
import commonjs from "vite-plugin-commonjs";
import { createCommonJsPlugin } from "./plugins/commonjs.js";
import { createIgnoreDynamicRequestsPlugin } from "./plugins/ignore-dynamic-requests.js";
import { createTransformCache } from "./plugins/transform-cache.js";
import {
Expand Down Expand Up @@ -554,6 +555,32 @@ function commonjsTransformFilter(
return undefined;
}

function shouldTransformCommonJs(
environment: Environment,
code: string,
id: string,
isBundledCommonJsDependency: (id: string) => boolean,
): boolean {
if (
environment.mode === "dev" &&
(environment as DevEnvironment).depsOptimizer?.isOptimizedDepFile(id)
) {
return false;
}
const cleanId = toSlash(stripViteModuleQuery(id));
if (isConditionalRequireScriptModuleId(cleanId)) return true;
const isDev = environment.mode === "dev";
const bundledDependency =
isDev &&
environment.config.consumer === "server" &&
(code.includes("__filename") || code.includes("__dirname")) &&
isBundledCommonJsDependency(cleanId);
if (bundledDependency) return true;
if (cleanId.includes("/node_modules/")) return false;
if (/\.c[jt]s$/i.test(cleanId)) return isDev;
return true;
}

function hasOnlyTypeSpecifiers(statement: AstStaticDependencyDeclaration): boolean {
return (
statement.specifiers !== undefined &&
Expand Down Expand Up @@ -1859,59 +1886,16 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
ReturnType<typeof replaceConsumerEnvironmentConditions>
>();

// vite-plugin-commonjs calls its user filter synchronously, before its first
// async boundary, but the filter itself receives only an id. Bridge the
// current Vite environment into that call without creating per-environment
// plugin instances: environment plugins cannot run the configResolved hook
// that vite-plugin-commonjs requires to initialize its resolver.
let transformProjectLocalCommonJs = false;
let transformBundledCommonJsDependencies = false;
const commonJsPlugin = commonjs({
filter(id: string) {
return commonjsTransformFilter(
const commonJsPlugin = createCommonJsPlugin({
shouldTransform(environment, code, id) {
return shouldTransformCommonJs(
environment,
code,
id,
transformProjectLocalCommonJs,
transformBundledCommonJsDependencies,
importMetaUrlCapability.isBundledCommonJsDependencyId,
);
},
});
const commonJsTransform = commonJsPlugin.transform;
if (typeof commonJsTransform === "function") {
commonJsPlugin.transform = function environmentAwareCommonJsTransform(code, id, ...args) {
// The independent optimizeDeps Rolldown build already converted these
// files to ESM. Running vite-plugin-commonjs over its output would append
// a second export facade (including a duplicate default export).
if (
this.environment.mode === "dev" &&
(this.environment as DevEnvironment).depsOptimizer?.isOptimizedDepFile(id)
) {
return null;
}
const isDev = this.environment.mode === "dev";
const isServer = this.environment.config.consumer === "server";
const bundledDependency =
isDev &&
isServer &&
(code.includes("__filename") || code.includes("__dirname")) &&
importMetaUrlCapability.isBundledCommonJsDependencyId(id);
const projectLocal =
!bundledDependency && !id.includes("/node_modules/") && !id.includes("\\node_modules\\");
const previousProjectLocal = transformProjectLocalCommonJs;
const previous = transformBundledCommonJsDependencies;
transformProjectLocalCommonJs = projectLocal && isDev;
transformBundledCommonJsDependencies = bundledDependency;
try {
// Do not await here: the filter is consulted synchronously while this
// environment-scoped flag is set. The remaining async transform work
// does not read it, so concurrent module transforms cannot cross-talk.
return commonJsTransform.call(this, code, id, ...args);
} finally {
transformProjectLocalCommonJs = previousProjectLocal;
transformBundledCommonJsDependencies = previous;
}
};
}

const plugins: PluginOption[] = [
// Resolve tsconfig paths/baseUrl aliases so real-world Next.js repos
Expand All @@ -1935,12 +1919,12 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
// Skip project-local `.cjs`/`.cts` files during builds. `vinext init` renames CJS config
// files to `.cjs` (e.g. `tailwind.config.js` → `tailwind.config.cjs`) when
// it adds `"type": "module"`, and app code imports them extensionlessly
// (`import cfg from "../tailwind.config"`). If `vite-plugin-commonjs`
// (`import cfg from "../tailwind.config"`). If the CommonJS transform
// rewrites their `module.exports` to ESM `export {}`, rolldown still infers
// `moduleType: "cjs"` from the `.cjs`/`.cts` extension and re-parses the
// rewritten output as CommonJS, failing with "Cannot use export statement
// outside a module". Returning `false` during builds makes vite-plugin-commonjs
// skip these project-local files so Rolldown's own CJS interop bundles them
// outside a module". Skip these project-local files during builds so
// Rolldown's own CJS interop bundles them
// instead. Dev has no later CJS lowering pass, so transform them here.
// Conditional `require` targets use a synthetic `.js` identity so their
// CJS source can be converted before plugin-rsc injects ESM proxy imports.
Expand Down
Loading
Loading