diff --git a/packages/vinext/package.json b/packages/vinext/package.json index f39ebb6da7..858a2f885c 100644 --- a/packages/vinext/package.json +++ b/packages/vinext/package.json @@ -179,7 +179,6 @@ "@vinext/types": "workspace:^", "ipaddr.js": "catalog:", "magic-string": "catalog:", - "vite-plugin-commonjs": "catalog:", "web-vitals": "catalog:" }, "devDependencies": { diff --git a/packages/vinext/src/config/next-config.ts b/packages/vinext/src/config/next-config.ts index eb4d64b415..7f657522ea 100644 --- a/packages/vinext/src/config/next-config.ts +++ b/packages/vinext/src/config/next-config.ts @@ -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"; @@ -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)); @@ -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 @@ -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` @@ -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; }, }), ], diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index ceb3598284..1196f792e3 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -2,6 +2,7 @@ import type { Alias, CSSModulesOptions, DevEnvironment, + Environment, HotUpdateOptions, Logger, Plugin, @@ -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 { @@ -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 && @@ -1859,59 +1886,16 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { ReturnType >(); - // 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 @@ -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. diff --git a/packages/vinext/src/plugins/commonjs.ts b/packages/vinext/src/plugins/commonjs.ts index 5c300d64a0..53823cb93e 100644 --- a/packages/vinext/src/plugins/commonjs.ts +++ b/packages/vinext/src/plugins/commonjs.ts @@ -1,5 +1,14 @@ import MagicString from "magic-string"; -import { parseAst } from "vite"; +import { realpath, stat } from "node:fs/promises"; +import path, { toSlash } from "pathslash"; +import { + createFilter, + parseAst, + parseAstAsync, + type Alias, + type Environment, + type Plugin, +} from "vite"; import { collectBindingNames, forEachAstChild, @@ -24,22 +33,179 @@ import { type AstScope, } from "./ast-scope.js"; import { magicStringTransformResult, type MagicStringTransformResult } from "./transform-result.js"; +import { hasDynamicRequestIgnoreDirective } from "./dynamic-request-utils.js"; +import { stripViteModuleQuery } from "../utils/path.js"; +import { packageNameFromSpecifier } from "../utils/package-name.js"; +import { listFilesFollowingSymlinks } from "../utils/list-files.js"; -const COMMONJS_PRESCAN = /\b(?:require\s*\(|module\s*\.|exports\s*[.[])/; +const COMMONJS_PRESCAN = /\b(?:require|module|exports)\b/; const IDENTIFIER_NAME_RE = /^[$_\p{ID_Start}][$\u200C\u200D\p{ID_Continue}]*$/u; +const DEFAULT_COMMONJS_EXTENSIONS = [ + ".mjs", + ".js", + ".cjs", + ".mts", + ".ts", + ".cts", + ".jsx", + ".tsx", + ".json", +] as const; +const DYNAMIC_REQUIRE_EXTENSIONS = [ + ".vue", + ".svelte", + ".png", + ".jpg", + ".jpeg", + ".jfif", + ".pjpeg", + ".pjp", + ".gif", + ".svg", + ".ico", + ".webp", + ".avif", + ".mp4", + ".webm", + ".ogg", + ".mp3", + ".wav", + ".flac", + ".aac", + ".woff", + ".woff2", + ".eot", + ".ttf", + ".otf", + ".webmanifest", + ".pdf", + ".txt", + ".css", + ".less", + ".sass", + ".scss", + ".styl", + ".stylus", + ".pcss", + ".postcss", +] as const; + +function commonJsExtensionFilter(extensions: readonly string[]): RegExp { + const patterns = [...new Set(extensions)] + .filter(Boolean) + .map((extension) => extension.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")); + return patterns.length > 0 ? new RegExp(`(?:${patterns.join("|")})(?:[?#].*)?$`, "i") : /a^/; +} type StaticRequire = { node: AstRecord & { start: number; end: number }; specifier: string; }; +type DynamicRequire = { + argument: AstRecord & { start: number; end: number }; + callee: AstRecord & { start: number; end: number }; + ignored: boolean; + node: AstRecord & { start: number; end: number }; +}; + type CommonJsAnalysis = { requires: StaticRequire[]; + dynamicRequires: DynamicRequire[]; + argumentlessRequires: Array; + bindings: Set; hasExports: boolean; namedExports: string[]; - rootBindings: Set; }; +type WrapperVarClassification = { + initialized: Set; + noOp: Set; +}; + +function wrapperVarName(value: unknown): "require" | "module" | "exports" | null { + const name = getAstName(value); + return name === "require" || name === "module" || name === "exports" ? name : null; +} + +function isWrapperPreservingInitializer( + name: "require" | "module" | "exports", + value: unknown, +): boolean { + const initializer = unwrapExpression(value); + if (!initializer) return true; + if (isIdentifierNamed(initializer, name)) return true; + if ( + name === "exports" && + initializer.type === "MemberExpression" && + isIdentifierNamed(unwrapExpression(initializer.object), "module") && + memberPropertyName(initializer) === "exports" + ) { + return true; + } + if ( + (initializer.type === "LogicalExpression" || initializer.type === "BinaryExpression") && + (initializer.operator === "||" || initializer.operator === "??") && + isIdentifierNamed(unwrapExpression(initializer.left), name) + ) { + return true; + } + return false; +} + +function classifyRootWrapperVars(root: AstRecord): WrapperVarClassification { + const classification: WrapperVarClassification = { + initialized: new Set(), + noOp: new Set(), + }; + + function visit(node: AstRecord, isRoot = false): void { + if ( + !isRoot && + (isFunctionNode(node) || node.type === "StaticBlock" || node.type === "TSModuleBlock") + ) { + return; + } + if (node.type === "VariableDeclaration" && node.kind === "var" && node.declare !== true) { + for (const declarator of nodeArray(node.declarations)) { + if (!isAstRecord(declarator)) continue; + const name = wrapperVarName(declarator.id); + if (!name) continue; + classification.noOp.add(name); + } + } + forEachAstChild(node, (child) => visit(child)); + } + + visit(root, true); + // Preserve the existing scope-aware improvement for direct, unconditional + // wrapper replacements. Nested initializers are control-flow dependent, so + // transforming them matches the original plugin and CommonJS wrapper model. + for (const statement of nodeArray(root.body)) { + if (!isAstRecord(statement) || statement.type !== "VariableDeclaration") continue; + if (statement.kind !== "var" || statement.declare === true) continue; + for (const declarator of nodeArray(statement.declarations)) { + if (!isAstRecord(declarator)) continue; + const name = wrapperVarName(declarator.id); + if (name && !isWrapperPreservingInitializer(name, declarator.init)) { + classification.initialized.add(name); + } + } + } + for (const name of classification.initialized) classification.noOp.delete(name); + return classification; +} + +function removeNoOpDirectVarBindings( + scope: AstScope, + noOpWrapperVars: ReadonlySet, + directVarBindings: ReadonlySet, +): void { + for (const name of noOpWrapperVars) { + if (directVarBindings.has(name)) scope.bindings.delete(name); + } +} + function memberPropertyName(node: AstRecord): string | null { const property = unwrapExpression(node.property); if (!property) return null; @@ -66,31 +232,38 @@ function commonJsExportName(node: AstRecord, scope: AstScope): string | null | u return undefined; } -function analyzeCommonJs(code: string, id: string): CommonJsAnalysis | null { - if (!COMMONJS_PRESCAN.test(code)) return null; - let ast: ReturnType; - try { - ast = parseAst(code, { lang: scriptParserLanguage(id) ?? "jsx" }); - } catch { - return null; - } +function analyzeCommonJsAst( + code: string, + ast: ReturnType, +): CommonJsAnalysis | null { const root = isAstRecord(ast) ? ast : null; if (!root) return null; const rootScope = createAstScope(null); collectDirectScopeBindings(root, rootScope); collectVarScopeBindings(root, rootScope); + // In Node's CommonJS wrapper, a top-level `var require`, `var module`, or + // `var exports` without an initializer redeclares the wrapper parameter and + // leaves its value intact. Treat that narrow form as the ambient CJS binding. + const { noOp: noOpWrapperVars } = classifyRootWrapperVars(root); + for (const name of noOpWrapperVars) rootScope.bindings.delete(name); + const bindings = new Set(rootScope.bindings); const requires: StaticRequire[] = []; + const dynamicRequires: DynamicRequire[] = []; + const argumentlessRequires: Array = []; const namedExports = new Set(); let hasExports = false; function visit(node: AstRecord, parentScope: AstScope): void { + if (node.type === "Identifier" && typeof node.name === "string") bindings.add(node.name); let scope = parentScope; if (isFunctionNode(node)) { const parameterScope = createAstScope(parentScope); collectBindingNames(node.id, parameterScope.bindings); + for (const binding of parameterScope.bindings) bindings.add(binding); for (const parameter of nodeArray(node.params)) { collectBindingNames(parameter, parameterScope.bindings); + for (const binding of parameterScope.bindings) bindings.add(binding); if (isAstRecord(parameter)) visit(parameter, parameterScope); } const body = isAstRecord(node.body) ? node.body : null; @@ -98,6 +271,7 @@ function analyzeCommonJs(code: string, id: string): CommonJsAnalysis | null { const bodyScope = createAstScope(parameterScope); collectDirectScopeBindings(body, bodyScope); collectVarScopeBindings(body, bodyScope); + for (const binding of bodyScope.bindings) bindings.add(binding); if (body.type === "BlockStatement") { for (const statement of nodeArray(body.body)) { if (isAstRecord(statement)) visit(statement, bodyScope); @@ -111,7 +285,12 @@ function analyzeCommonJs(code: string, id: string): CommonJsAnalysis | null { if (node.type === "SwitchStatement") { if (isAstRecord(node.discriminant)) visit(node.discriminant, parentScope); const switchScope = createAstScope(parentScope); - collectSwitchScopeBindings(node, switchScope); + const directVarBindings = new Set(); + collectSwitchScopeBindings(node, switchScope, (declaration, declarator) => { + if (declaration.kind === "var") collectBindingNames(declarator.id, directVarBindings); + }); + removeNoOpDirectVarBindings(switchScope, noOpWrapperVars, directVarBindings); + for (const binding of switchScope.bindings) bindings.add(binding); for (const switchCase of nodeArray(node.cases)) { if (isAstRecord(switchCase)) visit(switchCase, switchScope); } @@ -123,23 +302,35 @@ function analyzeCommonJs(code: string, id: string): CommonJsAnalysis | null { node.type === "TSModuleBlock" ) { scope = createAstScope(parentScope); - collectDirectScopeBindings(node, scope); + const directVarBindings = new Set(); + collectDirectScopeBindings(node, scope, (declaration, declarator) => { + if (declaration.kind === "var") collectBindingNames(declarator.id, directVarBindings); + }); + removeNoOpDirectVarBindings(scope, noOpWrapperVars, directVarBindings); if (node.type === "StaticBlock" || node.type === "TSModuleBlock") { collectVarScopeBindings(node, scope); } + for (const binding of scope.bindings) bindings.add(binding); } else if (node.type === "CatchClause") { scope = createAstScope(parentScope); collectBindingNames(node.param, scope.bindings); + for (const binding of scope.bindings) bindings.add(binding); } else if ( node.type === "ForStatement" || node.type === "ForInStatement" || node.type === "ForOfStatement" ) { scope = createAstScope(parentScope); - collectLoopScopeBindings(node, scope); + const directVarBindings = new Set(); + collectLoopScopeBindings(node, scope, (declaration, declarator) => { + if (declaration.kind === "var") collectBindingNames(declarator.id, directVarBindings); + }); + removeNoOpDirectVarBindings(scope, noOpWrapperVars, directVarBindings); + for (const binding of scope.bindings) bindings.add(binding); } else if (node.type === "ClassExpression" && node.id) { scope = createAstScope(parentScope); collectBindingNames(node.id, scope.bindings); + for (const binding of scope.bindings) bindings.add(binding); } if (node.type === "CallExpression" && hasRange(node)) { @@ -147,14 +338,22 @@ function analyzeCommonJs(code: string, id: string): CommonJsAnalysis | null { const args = nodeArray(node.arguments); const argument = unwrapExpression(args[0]); const specifier = staticStringValue(argument); - if ( - isIdentifierNamed(callee, "require") && - !hasAstBinding(scope, "require") && - argument && - specifier !== null - ) { - requires.push({ node, specifier }); - return; + if (isIdentifierNamed(callee, "require") && !hasAstBinding(scope, "require")) { + if (!argument) { + argumentlessRequires.push(node); + return; + } + if (specifier !== null) { + requires.push({ node, specifier }); + return; + } else if (hasRange(argument) && hasRange(callee)) { + dynamicRequires.push({ + argument, + callee, + ignored: hasDynamicRequestIgnoreDirective(code, node, argument), + node, + }); + } } } else if (node.type === "AssignmentExpression") { const left = unwrapExpression(node.left); @@ -175,12 +374,422 @@ function analyzeCommonJs(code: string, id: string): CommonJsAnalysis | null { } return { requires, + dynamicRequires, + argumentlessRequires, + bindings, hasExports, namedExports: [...namedExports], - rootBindings: rootScope.bindings, }; } +function analyzeCommonJs(code: string, id: string): CommonJsAnalysis | null { + if (!COMMONJS_PRESCAN.test(code)) return null; + try { + return analyzeCommonJsAst(code, parseAst(code, { lang: scriptParserLanguage(id) ?? "jsx" })); + } catch { + return null; + } +} + +async function analyzeCommonJsAsync(code: string, id: string): Promise { + if (!COMMONJS_PRESCAN.test(code)) return null; + try { + return analyzeCommonJsAst( + code, + await parseAstAsync(code, { lang: scriptParserLanguage(id) ?? "jsx" }), + ); + } catch { + return null; + } +} + +type DynamicRequireCandidate = { + cases: string[]; + depth: number; + specifier: string; +}; + +type ResolvedDynamicRequire = DynamicRequire & { + candidates: DynamicRequireCandidate[]; +}; + +type DynamicPatternResolution = { + cwd: string; + globPattern: string; + runtimePattern: string; + importSpecifier(absolute: string): string; + resolvedMatch(absolute: string): string; +}; + +type DynamicGlobPattern = { + globPattern: string; + resolvedPattern: string; + runtimePattern: string; +}; + +function templateElementValue(node: AstRecord): string | null { + if (node.type !== "TemplateElement" || typeof node.value !== "object" || !node.value) { + return null; + } + const cooked = Reflect.get(node.value, "cooked"); + const raw = Reflect.get(node.value, "raw"); + return typeof cooked === "string" ? cooked : typeof raw === "string" ? raw : null; +} + +function dynamicRequirePattern(value: unknown): string | null { + const node = unwrapExpression(value); + if (!node) return null; + const literal = staticStringValue(node); + if (literal !== null) return literal; + if (node.type === "TemplateLiteral") { + const quasis = nodeArray(node.quasis).filter(isAstRecord); + const expressions = nodeArray(node.expressions); + let pattern = ""; + for (let index = 0; index < quasis.length; index++) { + const text = templateElementValue(quasis[index]); + if (text === null || text.includes("*")) return null; + pattern += text; + if (index < expressions.length) pattern += "*"; + } + return pattern; + } + if (node.type === "BinaryExpression" && node.operator === "+") { + const left = dynamicRequirePattern(node.left); + const right = dynamicRequirePattern(node.right); + return left === null || right === null ? null : left + right; + } + if (node.type === "CallExpression") { + const callee = unwrapExpression(node.callee); + if (callee?.type !== "MemberExpression" || memberPropertyName(callee) !== "concat") return "*"; + const object = dynamicRequirePattern(callee.object); + if (object === null) return null; + let pattern = object; + for (const argument of nodeArray(node.arguments)) { + const part = dynamicRequirePattern(argument); + if (part === null) return null; + pattern += part; + } + return pattern; + } + return "*"; +} + +function normalizedDynamicRequirePattern(value: unknown): string | null { + return dynamicRequirePattern(value)?.replace(/\*+/g, "*") ?? null; +} + +function assertSupportedDynamicRequires(code: string, analysis: CommonJsAnalysis): void { + const unsupportedDynamic = analysis.dynamicRequires.find((request) => { + if (request.ignored) return false; + const pattern = normalizedDynamicRequirePattern(request.argument); + return pattern === null || pattern.startsWith("*") || pattern.replaceAll("*", "") === ""; + }); + const range = analysis.argumentlessRequires[0] ?? unsupportedDynamic?.node; + if (!range) return; + + const source = code.slice(range.start, range.end); + throw new Error( + `invalid import ${JSON.stringify(source)}. It cannot be statically analyzed. ` + + "Dynamic requires must include a statically known path segment.", + ); +} + +function extensionlessCases(specifier: string): string[] { + const extension = path.extname(specifier); + const cases = new Set([specifier]); + if (extension) cases.add(specifier.slice(0, -extension.length)); + const basename = extension ? path.basename(specifier, extension) : path.basename(specifier); + if (basename === "index") { + const directory = path.dirname(specifier); + cases.add(directory === "." ? "." : directory); + } + return [...cases]; +} + +function looseGlobPatterns(pattern: string): string[] { + if (pattern.includes("**")) return [pattern]; + const lastWildcard = pattern.lastIndexOf("*"); + if (lastWildcard === -1) return [pattern]; + const head = pattern.slice(0, lastWildcard + 1); + const tail = pattern.slice(lastWildcard + 1); + return [pattern, head.endsWith("/*") ? `${head}*/*${tail}` : `${head}/**/*${tail}`]; +} + +function dynamicGlobPatterns( + resolvedPattern: string, + runtimePattern: string, + extensions: readonly string[], +): DynamicGlobPattern[] { + const patterns = path.extname(resolvedPattern) + ? [{ resolvedPattern, runtimePattern }] + : [ + { resolvedPattern, runtimePattern }, + ...extensions.flatMap((extension) => [ + { + resolvedPattern: resolvedPattern + extension, + runtimePattern: runtimePattern + extension, + }, + { + resolvedPattern: path.join(resolvedPattern, `index${extension}`), + runtimePattern: path.join(runtimePattern, `index${extension}`), + }, + ]), + ]; + const results = new Map(); + for (const pattern of patterns) { + for (const globPattern of looseGlobPatterns(pattern.resolvedPattern)) { + const result = { globPattern, ...pattern }; + results.set(`${globPattern}\0${pattern.runtimePattern}`, result); + } + } + return [...results.values()]; +} + +function firstGlobMagicIndex(pattern: string): number { + const simpleMagic = pattern.search(/[?*[{]/); + const extglobMagic = pattern.search(/[+@!]\(/); + if (simpleMagic === -1) return extglobMagic; + if (extglobMagic === -1) return simpleMagic; + return Math.min(simpleMagic, extglobMagic); +} + +function globStaticPrefix(pattern: string): string { + const firstMagic = firstGlobMagicIndex(pattern); + return firstMagic === -1 ? pattern : pattern.slice(0, firstMagic); +} + +export function globTraversalRoot(absolutePattern: string): string { + const firstMagic = firstGlobMagicIndex(absolutePattern); + if (firstMagic === -1) return path.dirname(absolutePattern); + const staticPrefix = absolutePattern.slice(0, firstMagic); + if (!staticPrefix.endsWith("/")) return path.dirname(staticPrefix); + const root = path.parse(absolutePattern).root; + return staticPrefix === root ? root : staticPrefix.slice(0, -1) || root; +} + +function generatedGlobMatcher(pattern: string): (value: string) => boolean { + if (/[?[\]{}()!]/.test(pattern)) return createFilter(pattern); + let source = "^"; + for (let index = 0; index < pattern.length; index++) { + const character = pattern[index]; + if (character !== "*") { + source += character.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&"); + continue; + } + if (pattern[index + 1] !== "*") { + source += index === 0 || pattern[index - 1] === "/" ? "(?!\\.)[^/]*" : "[^/]*"; + continue; + } + index++; + if (pattern[index + 1] === "/") { + source += "(?:(?!\\.)[^/]+/)*"; + index++; + } else { + source += ".*"; + } + } + const regexp = new RegExp(`${source}$`); + return (value) => regexp.test(value); +} + +function runtimeMatchForPattern( + resolvedPattern: string, + runtimePattern: string, + resolvedMatch: string, +): string { + const resolvedPrefix = globStaticPrefix(resolvedPattern); + const runtimePrefix = globStaticPrefix(runtimePattern); + return `${runtimePrefix}${resolvedMatch.slice(resolvedPrefix.length)}`; +} + +function patternIncludesExplicitDotEntry(pattern: string): boolean { + return pattern + .split("/") + .some((segment) => segment.startsWith(".") && segment !== "." && segment !== ".."); +} + +async function findPackageDirectory( + importerDirectory: string, + packageName: string, + preserveSymlinks: boolean, +): Promise { + let directory = importerDirectory; + for (;;) { + const candidate = path.join(directory, "node_modules", packageName); + try { + if ((await stat(candidate)).isDirectory()) { + return preserveSymlinks ? candidate : toSlash(await realpath(candidate)); + } + } catch {} + const parent = path.dirname(directory); + if (parent === directory) return null; + directory = parent; + } +} + +function barePatternBase(pattern: string): string | null { + const packageName = packageNameFromSpecifier(pattern); + if (packageName) return packageName; + const wildcard = pattern.indexOf("*"); + const separator = wildcard === -1 ? -1 : pattern.lastIndexOf("/", wildcard); + if (separator <= 0) return null; + const staticBase = pattern.slice(0, separator); + if (/^@[A-Za-z0-9._~-]+$/.test(staticBase)) return staticBase; + return packageNameFromSpecifier(`${staticBase}/placeholder`) ? staticBase : null; +} + +async function resolveDynamicPattern( + pattern: string, + importerDirectory: string, + aliases: readonly Alias[], + root: string, + preserveSymlinks: boolean, +): Promise { + for (const alias of aliases) { + const matches = + typeof alias.find === "string" + ? pattern === alias.find || pattern.startsWith(`${alias.find}/`) + : new RegExp(alias.find.source, alias.find.flags).test(pattern); + if (!matches) continue; + const replaced = pattern.replace(alias.find, toSlash(alias.replacement)); + const resolvedPattern = path.isAbsolute(replaced) ? replaced : path.resolve(root, replaced); + return { + cwd: path.parse(resolvedPattern).root, + globPattern: resolvedPattern, + runtimePattern: pattern, + importSpecifier: toSlash, + resolvedMatch: toSlash, + }; + } + if (pattern.startsWith("./") || pattern.startsWith("../")) { + return { + cwd: importerDirectory, + globPattern: pattern, + runtimePattern: pattern, + importSpecifier(absolute) { + const relative = toSlash(path.relative(importerDirectory, absolute)); + return relative.startsWith(".") ? relative : `./${relative}`; + }, + resolvedMatch(absolute) { + const relative = toSlash(path.relative(importerDirectory, absolute)); + return pattern.startsWith("./") && !relative.startsWith(".") ? `./${relative}` : relative; + }, + }; + } + if (path.isAbsolute(pattern)) { + return { + cwd: path.parse(pattern).root, + globPattern: pattern, + runtimePattern: pattern, + importSpecifier: toSlash, + resolvedMatch: toSlash, + }; + } + const packageBase = barePatternBase(pattern); + if (!packageBase) { + throw new Error( + `invalid import ${JSON.stringify(pattern)}. It cannot be statically analyzed because ` + + "it is not a relative, absolute, aliased, or bare-package request.", + ); + } + const packageDirectory = await findPackageDirectory( + importerDirectory, + packageBase, + preserveSymlinks, + ); + if (!packageDirectory) { + throw new Error( + `invalid import ${JSON.stringify(pattern)}. It cannot be statically analyzed because ` + + `package prefix ${JSON.stringify(packageBase)} could not be resolved.`, + ); + } + const suffix = pattern.slice(packageBase.length).replace(/^\/+/, ""); + return { + cwd: path.parse(packageDirectory).root, + globPattern: path.join(packageDirectory, suffix), + runtimePattern: pattern, + importSpecifier: toSlash, + resolvedMatch: toSlash, + }; +} + +async function resolveDynamicRequire( + request: DynamicRequire, + id: string, + extensions: readonly string[], + aliases: readonly Alias[], + root: string, + preserveSymlinks: boolean, +): Promise { + if (request.ignored) return null; + const pattern = normalizedDynamicRequirePattern(request.argument); + if (!pattern?.includes("*")) return null; + + const cleanId = toSlash(stripViteModuleQuery(id)); + const importerDirectory = path.dirname(cleanId); + const resolvedPattern = await resolveDynamicPattern( + pattern, + importerDirectory, + aliases, + root, + preserveSymlinks, + ); + if (!resolvedPattern) return null; + const candidates = new Map(); + const patterns = dynamicGlobPatterns( + resolvedPattern.globPattern, + resolvedPattern.runtimePattern, + extensions, + ).map((pattern) => { + const absoluteGlobPattern = path.isAbsolute(pattern.globPattern) + ? pattern.globPattern + : path.resolve(resolvedPattern.cwd, pattern.globPattern); + return { + ...pattern, + matches: generatedGlobMatcher(absoluteGlobPattern), + }; + }); + const firstGlobPattern = patterns[0]?.globPattern; + if (!firstGlobPattern) return null; + const absoluteGlobPattern = path.isAbsolute(firstGlobPattern) + ? firstGlobPattern + : path.resolve(resolvedPattern.cwd, firstGlobPattern); + const traversalRoot = globTraversalRoot(absoluteGlobPattern); + for (const relative of await listFilesFollowingSymlinks(traversalRoot, true, { + includeDotEntries: patternIncludesExplicitDotEntry(resolvedPattern.globPattern), + })) { + const absolute = path.join(traversalRoot, relative); + if (absolute === cleanId) continue; + const resolvedMatch = resolvedPattern.resolvedMatch(absolute); + for (const pattern of patterns) { + if (!pattern.matches(absolute)) continue; + const specifier = resolvedPattern.importSpecifier(absolute); + const runtimeMatch = runtimeMatchForPattern( + resolvedPattern.globPattern, + resolvedPattern.runtimePattern, + resolvedMatch, + ); + const cases = extensionlessCases(runtimeMatch); + candidates.set(absolute, { cases, depth: specifier.split("/").length, specifier }); + break; + } + } + return candidates.size > 0 + ? { + ...request, + candidates: [...candidates.values()].sort((left, right) => + left.depth !== right.depth + ? left.depth - right.depth + : left.specifier < right.specifier + ? -1 + : left.specifier > right.specifier + ? 1 + : 0, + ), + } + : null; +} + function unusedBinding(bindings: Set, base: string): string { let name = base; let suffix = 0; @@ -189,26 +798,71 @@ function unusedBinding(bindings: Set, base: string): string { return name; } -/** Convert the project-local mixed CommonJS syntax that Vite's ESM module runner cannot execute. */ -export function transformCommonJs(code: string, id: string): MagicStringTransformResult | null { - const analysis = analyzeCommonJs(code, id); - if (!analysis || (analysis.requires.length === 0 && !analysis.hasExports)) return null; +function renderCommonJs( + code: string, + id: string, + analysis: CommonJsAnalysis, + dynamicRequires: readonly ResolvedDynamicRequire[], +): MagicStringTransformResult | null { + if (analysis.requires.length === 0 && dynamicRequires.length === 0 && !analysis.hasExports) { + return null; + } const output = new MagicString(code); - const bindings = new Set(analysis.rootBindings); - const imports: string[] = []; - for (const { node, specifier } of analysis.requires) { + const bindings = new Set(analysis.bindings); + const imports: Array<{ code: string; dynamicOrder: number | null }> = []; + const importBindings = new Map(); + let nextDynamicImportOrder = 0; + function importBinding(specifier: string, dynamic = false): string { + const existing = importBindings.get(specifier); + if (existing) { + if (dynamic && existing.record.dynamicOrder === null) { + existing.record.dynamicOrder = nextDynamicImportOrder++; + } + return existing.name; + } const importName = unusedBinding(bindings, "__vinext_cjs_import__"); - imports.push(`import * as ${importName} from ${JSON.stringify(specifier)};`); + const record = { + code: `import * as ${importName} from ${JSON.stringify(specifier)};`, + dynamicOrder: dynamic ? nextDynamicImportOrder++ : null, + }; + imports.push(record); + importBindings.set(specifier, { name: importName, record }); + return importName; + } + + for (const { node, specifier } of analysis.requires) { + const importName = importBinding(specifier); output.overwrite(node.start, node.end, `(${importName}.default || ${importName})`); } const preamble: string[] = []; + for (const dynamicRequire of dynamicRequires) { + const runtimeName = unusedBinding(bindings, "__vinext_dynamic_require__"); + const cases = dynamicRequire.candidates.flatMap((candidate) => { + const importName = importBinding(candidate.specifier, true); + return candidate.cases.map((value) => `case ${JSON.stringify(value)}: return ${importName};`); + }); + preamble.push( + `function ${runtimeName}(request) { switch (request) { ${cases.join(" ")} default: { const error = new Error("Cannot find module '" + request + "'"); error.code = "MODULE_NOT_FOUND"; throw error; } } }`, + ); + output.overwrite(dynamicRequire.callee.start, dynamicRequire.callee.end, runtimeName); + } if (analysis.hasExports) { preamble.push("var module = { exports: {} };", "var exports = module.exports;"); } if (imports.length > 0 || preamble.length > 0) { - output.prepend(`${[...imports, ...preamble].join("\n")}\n`); + const importCode = [...imports] + .sort((left, right) => { + if (left.dynamicOrder !== null && right.dynamicOrder !== null) { + return left.dynamicOrder - right.dynamicOrder; + } + if (left.dynamicOrder !== null) return -1; + if (right.dynamicOrder !== null) return 1; + return 0; + }) + .map((record) => record.code); + output.prepend(`${[...importCode, ...preamble].join("\n")}\n`); } if (analysis.hasExports) { @@ -229,3 +883,58 @@ export function transformCommonJs(code: string, id: string): MagicStringTransfor return magicStringTransformResult(output, { hires: true, source: id }); } + +/** Convert the project-local mixed CommonJS syntax that Vite's ESM module runner cannot execute. */ +export function transformCommonJs(code: string, id: string): MagicStringTransformResult | null { + const analysis = analyzeCommonJs(code, id); + if (analysis) assertSupportedDynamicRequires(code, analysis); + return analysis ? renderCommonJs(code, id, analysis, []) : null; +} + +export type CommonJsPluginOptions = { + shouldTransform?: (environment: Environment, code: string, id: string) => boolean; +}; + +/** Vite lifecycle adapter for the shared CommonJS transform. */ +export function createCommonJsPlugin(options: CommonJsPluginOptions = {}): Plugin { + let extensions: readonly string[] = [ + ...DEFAULT_COMMONJS_EXTENSIONS, + ...DYNAMIC_REQUIRE_EXTENSIONS, + ]; + let aliases: readonly Alias[] = []; + let preserveSymlinks = false; + let root = toSlash(process.cwd()); + const transformFilter = { + id: commonJsExtensionFilter(DEFAULT_COMMONJS_EXTENSIONS), + code: COMMONJS_PRESCAN, + }; + return { + name: "vinext:commonjs", + configResolved(config) { + extensions = [...new Set([...config.resolve.extensions, ...DYNAMIC_REQUIRE_EXTENSIONS])]; + aliases = config.resolve.alias; + preserveSymlinks = config.resolve.preserveSymlinks; + root = toSlash(config.root); + transformFilter.id = commonJsExtensionFilter(config.resolve.extensions); + }, + transform: { + filter: transformFilter, + async handler(code, id) { + if (options.shouldTransform && !options.shouldTransform(this.environment, code, id)) { + return null; + } + const analysis = await analyzeCommonJsAsync(code, id); + if (!analysis) return null; + assertSupportedDynamicRequires(code, analysis); + const dynamicRequires = ( + await Promise.all( + analysis.dynamicRequires.map((request) => + resolveDynamicRequire(request, id, extensions, aliases, root, preserveSymlinks), + ), + ) + ).filter((value): value is ResolvedDynamicRequire => value !== null); + return renderCommonJs(code, id, analysis, dynamicRequires); + }, + }, + }; +} diff --git a/packages/vinext/src/plugins/dynamic-request-utils.ts b/packages/vinext/src/plugins/dynamic-request-utils.ts new file mode 100644 index 0000000000..288d691121 --- /dev/null +++ b/packages/vinext/src/plugins/dynamic-request-utils.ts @@ -0,0 +1,80 @@ +import { hasRange, isAstRecord, type AstRecord } from "./ast-utils.js"; + +function astNode(value: unknown): AstRecord | null { + return isAstRecord(value) ? value : null; +} + +export function hasDynamicRequestIgnoreDirective( + code: string, + requestNode: AstRecord, + argumentNode: AstRecord, +): boolean { + if (!hasRange(requestNode) || !hasRange(argumentNode)) return false; + const comments: string[] = []; + const callee = astNode(requestNode.callee); + let index = + callee && hasRange(callee) + ? callee.end + : requestNode.type === "ImportExpression" + ? requestNode.start + "import".length + : requestNode.start; + + while (index < argumentNode.start) { + if (/\s/.test(code[index])) { + index++; + continue; + } + if (code.startsWith("/*", index)) { + const end = code.indexOf("*/", index + 2); + if (end === -1 || end + 2 > argumentNode.start) return false; + index = end + 2; + continue; + } + if (code.startsWith("//", index)) { + while (index < argumentNode.start && code[index] !== "\n" && code[index] !== "\r") index++; + continue; + } + break; + } + if (code[index] !== "(") return false; + index++; + + while (index < argumentNode.start) { + if (/\s/.test(code[index])) { + index++; + continue; + } + if (code.startsWith("/*", index)) { + const end = code.indexOf("*/", index + 2); + if (end === -1 || end + 2 > argumentNode.start) return false; + comments.push(code.slice(index + 2, end)); + index = end + 2; + continue; + } + if (code.startsWith("//", index)) { + let end = index + 2; + while (end < argumentNode.start && code[end] !== "\n" && code[end] !== "\r") end++; + comments.push(code.slice(index + 2, end)); + index = end; + continue; + } + return false; + } + + let ignore: boolean | undefined; + for (const comment of comments) { + const text = comment.trim(); + if (text === "@vite-ignore" && requestNode.type === "ImportExpression") { + ignore = true; + continue; + } + const separator = text.indexOf(":"); + if (separator === -1) continue; + const directive = text.slice(0, separator).trim(); + if (directive !== "webpackIgnore" && directive !== "turbopackIgnore") continue; + const value = text.slice(separator + 1).trim(); + if (value === "true") ignore = true; + else if (value === "false") ignore = false; + } + return ignore === true; +} diff --git a/packages/vinext/src/plugins/ignore-dynamic-requests.ts b/packages/vinext/src/plugins/ignore-dynamic-requests.ts index 3488497abc..207eb90f3d 100644 --- a/packages/vinext/src/plugins/ignore-dynamic-requests.ts +++ b/packages/vinext/src/plugins/ignore-dynamic-requests.ts @@ -19,6 +19,7 @@ import { } from "./ast-utils.js"; import { createTransformCache } from "./transform-cache.js"; import { magicStringTransformResult } from "./transform-result.js"; +import { hasDynamicRequestIgnoreDirective } from "./dynamic-request-utils.js"; import { collectDirectScopeBindings, collectLoopScopeBindings, @@ -177,81 +178,6 @@ function isUnboundStringRawTag(value: unknown, scope: Scope): boolean { ); } -function hasDynamicRequestIgnoreDirective( - code: string, - requestNode: AstRecord, - argumentNode: AstRecord, -): boolean { - if (!hasRange(requestNode) || !hasRange(argumentNode)) return false; - const comments: string[] = []; - const callee = astNode(requestNode.callee); - let index = - callee && hasRange(callee) - ? callee.end - : requestNode.type === "ImportExpression" - ? requestNode.start + "import".length - : requestNode.start; - - while (index < argumentNode.start) { - if (/\s/.test(code[index])) { - index++; - continue; - } - if (code.startsWith("/*", index)) { - const end = code.indexOf("*/", index + 2); - if (end === -1 || end + 2 > argumentNode.start) return false; - index = end + 2; - continue; - } - if (code.startsWith("//", index)) { - while (index < argumentNode.start && code[index] !== "\n" && code[index] !== "\r") index++; - continue; - } - break; - } - if (code[index] !== "(") return false; - index++; - - while (index < argumentNode.start) { - if (/\s/.test(code[index])) { - index++; - continue; - } - if (code.startsWith("/*", index)) { - const end = code.indexOf("*/", index + 2); - if (end === -1 || end + 2 > argumentNode.start) return false; - comments.push(code.slice(index + 2, end)); - index = end + 2; - continue; - } - if (code.startsWith("//", index)) { - let end = index + 2; - while (end < argumentNode.start && code[end] !== "\n" && code[end] !== "\r") end++; - comments.push(code.slice(index + 2, end)); - index = end; - continue; - } - return false; - } - - let ignore: boolean | undefined; - for (const comment of comments) { - const text = comment.trim(); - if (text === "@vite-ignore" && requestNode.type === "ImportExpression") { - ignore = true; - continue; - } - const separator = text.indexOf(":"); - if (separator === -1) continue; - const directive = text.slice(0, separator).trim(); - if (directive !== "webpackIgnore" && directive !== "turbopackIgnore") continue; - const value = text.slice(separator + 1).trim(); - if (value === "true") ignore = true; - else if (value === "false") ignore = false; - } - return ignore === true; -} - function templateHasStaticPart( node: AstRecord, scope: Scope, diff --git a/packages/vinext/src/plugins/require-condition-resolution.ts b/packages/vinext/src/plugins/require-condition-resolution.ts index 7f6a90bf08..547816ecd4 100644 --- a/packages/vinext/src/plugins/require-condition-resolution.ts +++ b/packages/vinext/src/plugins/require-condition-resolution.ts @@ -171,7 +171,7 @@ function collectLiteralRequires(code: string, id: string): LiteralRequire[] { /** * Resolve literal package `require()` calls while Vite still knows they are - * CommonJS references. `vite-plugin-commonjs` subsequently hoists each call + * CommonJS references. The vinext CommonJS transform subsequently hoists each call * into a static import; without this pre-resolution, Vite sees an * `import-statement` and selects the package's `import` export condition. Use * Vite's explicit `isRequire` resolver because the dev plugin container does @@ -227,7 +227,7 @@ export function createRequireConditionResolutionPlugin( async handler(code, id) { const cleanId = stripViteModuleQuery(id); const commonjsDisposition = commonjsTransformFilter?.(cleanId); - // Only pre-resolve calls that the following vite-plugin-commonjs pass + // Only pre-resolve calls that the following vinext CommonJS pass // will turn into static imports. Ordinary dependencies and project // .cjs/.cts files are left to Vite/Rolldown, which already preserves // require conditions. Synthetic targets return true from the shared diff --git a/packages/vinext/src/plugins/require-context.ts b/packages/vinext/src/plugins/require-context.ts index aad575fc92..4e8850d8c5 100644 --- a/packages/vinext/src/plugins/require-context.ts +++ b/packages/vinext/src/plugins/require-context.ts @@ -18,8 +18,6 @@ // // Only literal forms with a static string directory are rewritten; anything // dynamic is left untouched so we never silently break unrelated code. -import type { Dirent } from "node:fs"; -import { readdir, realpath, stat } from "node:fs/promises"; import path, { toSlash } from "pathslash"; import { parseAst, type Plugin } from "vite"; import MagicString from "magic-string"; @@ -36,6 +34,7 @@ import { type AstRecord, } from "./ast-utils.js"; import { stripViteModuleQuery } from "../utils/path.js"; +import { listFilesFollowingSymlinks } from "../utils/list-files.js"; import { magicStringTransformResult } from "./transform-result.js"; type ParsedCall = { @@ -365,7 +364,7 @@ async function resolveContextModules( const regexp = call.pattern ? new RegExp(call.pattern, context.flags) : null; const accepted: Omit[] = []; - for (const candidate of await listContextFiles(directory, call.recursive)) { + for (const candidate of await listFilesFollowingSymlinks(directory, call.recursive)) { const key = `./${candidate}`; if (regexp && !regexp.test(key)) continue; accepted.push({ key, specifier: `${stripTrailingSlash(call.dir)}/${candidate}` }); @@ -382,64 +381,6 @@ async function resolveContextModules( return { context, modules }; } -// Enumerates candidate files like webpack's context walk: dot-entries are -// skipped (matching the prior glob semantics), symlinks resolve through stats — -// including symlinked directories in recursive contexts, which `fs.glob` does -// not descend into — and a missing context directory yields an empty context -// rather than an error. Cycles are broken by tracking realpaths along the -// current recursion path only, so distinct symlink aliases of the same target -// still enumerate under their own keys. -async function listContextFiles(directory: string, recursive: boolean): Promise { - const files: string[] = []; - const ancestorRealPaths = new Set(); - - async function walk(currentDirectory: string, prefix: string): Promise { - let realDirectory: string; - let entries: Dirent[]; - try { - realDirectory = await realpath(currentDirectory); - entries = await readdir(currentDirectory, { withFileTypes: true }); - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code === "ENOENT" || code === "ENOTDIR") return; - throw error; - } - if (ancestorRealPaths.has(realDirectory)) return; - ancestorRealPaths.add(realDirectory); - - try { - for (const entry of entries) { - if (entry.name.startsWith(".")) continue; - const entryPath = path.join(currentDirectory, entry.name); - let isFile = entry.isFile(); - let isDirectory = entry.isDirectory(); - // Covers symlinks and filesystems without dirent type info (NFS, SMB, - // FUSE), where entries report neither file nor directory. Broken links - // (ENOENT) and self-referential link loops (ELOOP) are unresolvable, - // so they cannot become context entries. - if (!isFile && !isDirectory) { - try { - const stats = await stat(entryPath); - isFile = stats.isFile(); - isDirectory = stats.isDirectory(); - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code === "ENOENT" || code === "ELOOP") continue; - throw error; - } - } - if (isFile) files.push(`${prefix}${entry.name}`); - else if (isDirectory && recursive) await walk(entryPath, `${prefix}${entry.name}/`); - } - } finally { - ancestorRealPaths.delete(realDirectory); - } - } - - await walk(directory, ""); - return files; -} - function matchesWatchedContext(file: string, context: WatchedContext): boolean { const candidate = path.relative(context.directory, file); if ( diff --git a/packages/vinext/src/utils/list-files.ts b/packages/vinext/src/utils/list-files.ts new file mode 100644 index 0000000000..75524a3d9f --- /dev/null +++ b/packages/vinext/src/utils/list-files.ts @@ -0,0 +1,61 @@ +import type { Dirent } from "node:fs"; +import { readdir, realpath, stat } from "node:fs/promises"; +import path from "pathslash"; + +/** + * Enumerate non-dot files while following directory symlinks. Missing directories + * yield an empty list. Real paths are tracked only along the current recursion + * chain, which breaks cycles while preserving distinct aliases of the same target. + */ +export async function listFilesFollowingSymlinks( + directory: string, + recursive: boolean, + options: { includeDotEntries?: boolean } = {}, +): Promise { + const files: string[] = []; + const ancestorRealPaths = new Set(); + + async function walk(currentDirectory: string, prefix: string): Promise { + let realDirectory: string; + let entries: Dirent[]; + try { + realDirectory = await realpath(currentDirectory); + entries = await readdir(currentDirectory, { withFileTypes: true }); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT" || code === "ENOTDIR") return; + throw error; + } + if (ancestorRealPaths.has(realDirectory)) return; + ancestorRealPaths.add(realDirectory); + + try { + for (const entry of entries) { + if (!options.includeDotEntries && entry.name.startsWith(".")) continue; + const entryPath = path.join(currentDirectory, entry.name); + let isFile = entry.isFile(); + let isDirectory = entry.isDirectory(); + // Symlinks and some network filesystems do not expose a useful dirent + // type, so resolve them before deciding whether to recurse. + if (!isFile && !isDirectory) { + try { + const stats = await stat(entryPath); + isFile = stats.isFile(); + isDirectory = stats.isDirectory(); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT" || code === "ELOOP") continue; + throw error; + } + } + if (isFile) files.push(`${prefix}${entry.name}`); + else if (isDirectory && recursive) await walk(entryPath, `${prefix}${entry.name}/`); + } + } finally { + ancestorRealPaths.delete(realDirectory); + } + } + + await walk(directory, ""); + return files; +} diff --git a/packages/vinext/src/vite-plugin-commonjs.d.ts b/packages/vinext/src/vite-plugin-commonjs.d.ts deleted file mode 100644 index 7e733de60d..0000000000 --- a/packages/vinext/src/vite-plugin-commonjs.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -declare module "vite-plugin-commonjs" { - import type { Plugin } from "vite"; - - export type CommonJsPluginOptions = { - [key: string]: unknown; - }; - - export default function commonjs(options?: CommonJsPluginOptions): Plugin; -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a200ad118e..69d063e0da 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -225,9 +225,6 @@ catalogs: validator: specifier: ^13.15.26 version: 13.15.26 - vite-plugin-commonjs: - specifier: ^0.10.4 - version: 0.10.4 vite-plus: specifier: 0.2.6 version: 0.2.6 @@ -1071,9 +1068,6 @@ importers: vite: specifier: npm:@voidzero-dev/vite-plus-core@0.2.6 version: '@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0)' - vite-plugin-commonjs: - specifier: 'catalog:' - version: 0.10.4 web-vitals: specifier: 'catalog:' version: 4.2.4 @@ -6039,9 +6033,6 @@ packages: error-stack-parser-es@1.0.5: resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} - es-module-lexer@1.7.0: - resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} - es-module-lexer@2.1.0: resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} @@ -8264,12 +8255,6 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} - vite-plugin-commonjs@0.10.4: - resolution: {integrity: sha512-eWQuvQKCcx0QYB5e5xfxBNjQKyrjEWZIR9UOkOV6JAgxVhtbZvCOF+FNC2ZijBJ3U3Px04ZMMyyMyFBVWIJ5+g==} - - vite-plugin-dynamic-import@1.6.0: - resolution: {integrity: sha512-TM0sz70wfzTIo9YCxVFwS8OA9lNREsh+0vMHGSkWDTZ7bgd1Yjs5RV8EgB634l/91IsXJReg0xtmuQqP0mf+rg==} - vite-plus@0.2.6: resolution: {integrity: sha512-fFX8GLENhtzvnE4NmTPC8INRxjD2kZKcqUW0p7jOdawmHTNZqM5FiieyWOG6WH1a/axu9FCsbUNb918grfzDTw==} engines: {node: ^20.19.0 || ^22.18.0 || >=24.11.0} @@ -11671,8 +11656,6 @@ snapshots: error-stack-parser-es@1.0.5: {} - es-module-lexer@1.7.0: {} - es-module-lexer@2.1.0: {} es-module-lexer@2.3.1: {} @@ -14284,19 +14267,6 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite-plugin-commonjs@0.10.4: - dependencies: - acorn: 8.15.0 - magic-string: 0.30.21 - vite-plugin-dynamic-import: 1.6.0 - - vite-plugin-dynamic-import@1.6.0: - dependencies: - acorn: 8.15.0 - es-module-lexer: 1.7.0 - fast-glob: 3.3.3 - magic-string: 0.30.21 - vite-plus@0.2.6(@opentelemetry/api@1.9.1)(@types/node@25.9.2)(@vitest/coverage-istanbul@4.1.10(vitest@4.1.10))(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0))(esbuild@0.27.3)(jiti@2.7.0)(msw@2.14.6(@types/node@25.9.2)(typescript@7.0.2))(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0): dependencies: '@oxc-project/types': 0.141.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 971ebc9980..33a30f3c03 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -88,7 +88,6 @@ catalog: use-count-up: 3.0.1 validator: ^13.15.26 vite: npm:@voidzero-dev/vite-plus-core@0.2.6 - vite-plugin-commonjs: ^0.10.4 vite-plus: 0.2.6 vitest: 4.1.10 web-vitals: ^4.2.4 diff --git a/tests/cjs-globals-runtime.test.ts b/tests/cjs-globals-runtime.test.ts index 5b5ab09cc4..2baf6c1eaf 100644 --- a/tests/cjs-globals-runtime.test.ts +++ b/tests/cjs-globals-runtime.test.ts @@ -83,6 +83,7 @@ async function createHybridFixture( ? [] : [fs.mkdir(path.join(root, "app"), { recursive: true })]), fs.mkdir(path.join(root, "pages"), { recursive: true }), + fs.mkdir(path.join(root, "locales"), { recursive: true }), fs.mkdir(path.join(root, "lib/node_modules"), { recursive: true }), fs.mkdir(cjsPackageDir, { recursive: true }), fs.mkdir(esmPackageDir, { recursive: true }), @@ -97,6 +98,18 @@ async function createHybridFixture( ); await Promise.all([ fs.writeFile(path.join(root, "package.json"), JSON.stringify({ type: "module" })), + fs.writeFile(path.join(root, "locales/en.js"), 'module.exports = "en";\n'), + fs.writeFile(path.join(root, "locales/ru.js"), 'module.exports = "ru";\n'), + fs.writeFile( + path.join(root, "pages/dynamic-require.tsx"), + `const locale = "ru"; +const messages = require(\`../locales/${"${locale}"}\`).default; + +export default function Page() { + return

{messages}

; +} +`, + ), ...(options.includeApp === false ? [] : [ @@ -856,6 +869,13 @@ function expectAppEsmIdentity( return { filename, readable: htmlValue(html, "app-esm-filename-readable") }; } +async function expectPatternedDynamicRequire(baseUrl: string): Promise { + const response = await fetch(`${baseUrl}/dynamic-require`); + const html = await response.text(); + expect(response.status, html).toBe(200); + expect(htmlValue(html, "dynamic-require-message")).toBe("ru"); +} + describe("bundled module identity on the hybrid Node development runtime", () => { let root = ""; let canonicalRoot = ""; @@ -909,6 +929,7 @@ export default function Page() { }); it("uses optimizer output identity without losing unbundled project source identity", async () => { + await expectPatternedDynamicRequire(baseUrl); for (const environment of ["rsc", "ssr"] as const) { expect( server?.config.environments[environment]?.optimizeDeps?.rolldownOptions?.plugins, @@ -960,7 +981,7 @@ export default function Page() { const clientModule = await server?.environments.client.transformRequest( "/lib/node_modules/linked-cjs-identity/client-safe.cjs", ); - expect(clientModule?.code).toContain("[vite-plugin-commonjs] export-runtime-S"); + expect(clientModule?.code).toContain("__vinext_cjs_default__ as default"); const esmClientModule = await server?.environments.client.transformRequest( "/vendor/node_modules/esm-import-meta-identity/client.js", ); @@ -1032,6 +1053,7 @@ describe("bundled module identity on the hybrid Node production runtime", () => }); it("uses emitted chunk identity for dependency and project modules", async () => { + await expectPatternedDynamicRequire(baseUrl); const response = await fetch(`${baseUrl}/cjs-dependency-globals`); expect(response.status).toBe(200); const html = await response.text(); @@ -1156,6 +1178,7 @@ describe("bundled module identity on the Cloudflare development runtime", () => }); it("executes the real CJS and ESM graphs in workerd", async () => { + await expectPatternedDynamicRequire(baseUrl); const response = await fetch(`${baseUrl}/cjs-dependency-globals`); const html = await response.text(); expect(response.status, html).toBe(200); @@ -1274,6 +1297,7 @@ describe("bundled module identity on the Cloudflare Workers runtime", () => { }); it("uses emitted workerd bundle identity without leaking host paths", async () => { + await expectPatternedDynamicRequire(baseUrl); const res = await fetch(`${baseUrl}/cjs-dependency-globals`); const html = await res.text(); expect(res.status, html).toBe(200); @@ -1434,6 +1458,7 @@ describe("bundled module identity on a Pages-only Cloudflare Workers runtime", ( }); it("uses top-level emitted identities for runtime and prerendered Pages routes", async () => { + await expectPatternedDynamicRequire(baseUrl); const response = await fetch(`${baseUrl}/cjs-dependency-globals`); const html = await response.text(); expect(response.status, html).toBe(200); @@ -1538,6 +1563,7 @@ describe("bundled module identity on the Nitro Node runtime", () => { }); it("keeps chunk-relative identity through Nitro's final bundle", async () => { + await expectPatternedDynamicRequire(baseUrl); const res = await fetch(`${baseUrl}/cjs-dependency-globals`); expect(res.status).toBe(200); const html = await res.text(); diff --git a/tests/cjs.test.ts b/tests/cjs.test.ts index 82d423ea4c..25d183ff68 100644 --- a/tests/cjs.test.ts +++ b/tests/cjs.test.ts @@ -92,9 +92,12 @@ describe("CJS interop (Pages Router)", () => { it("renders a page with a patterned dynamic require", async () => { // Ported from Next.js: test/integration/dynamic-require/test/index.test.ts // https://github.com/vercel/next.js/blob/canary/test/integration/dynamic-require/test/index.test.ts + // The `.default` access matches vite-plugin-commonjs's canonical patterned-require fixture: + // https://github.com/vite-plugin/vite-plugin-commonjs/blob/v0.10.4/test/fixtures/src/dynamic.tsx const { res, html } = await fetchHtml(baseUrl, "/cjs/dynamic-require"); expect(res.status).toBe(200); - expect(html).toContain("loaded"); + expect(html).toContain("Привет"); + expect(html).toContain("extra:true"); }); }); diff --git a/tests/commonjs-transform.test.ts b/tests/commonjs-transform.test.ts index 8b47d08544..94aeda7513 100644 --- a/tests/commonjs-transform.test.ts +++ b/tests/commonjs-transform.test.ts @@ -1,5 +1,46 @@ import { describe, expect, it } from "vite-plus/test"; -import { transformCommonJs } from "../packages/vinext/src/plugins/commonjs.js"; +import path from "node:path"; +import os from "node:os"; +import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from "node:fs/promises"; +import { toSlash } from "pathslash"; +import { createBuilder, createServer, type Alias } from "vite"; +import { + createCommonJsPlugin, + globTraversalRoot, + transformCommonJs, +} from "../packages/vinext/src/plugins/commonjs.js"; + +async function runPluginTransform( + code: string, + id: string, + aliases: Alias[] = [], + preserveSymlinks = false, +) { + const plugin = createCommonJsPlugin(); + const configResolved = plugin.configResolved; + if (typeof configResolved !== "function") throw new Error("Expected configResolved hook"); + await configResolved.call( + {} as never, + { + root: import.meta.dirname, + resolve: { + alias: aliases, + extensions: [".mjs", ".js", ".cjs", ".mts", ".ts", ".cts", ".jsx", ".tsx", ".json"], + preserveSymlinks, + }, + } as never, + ); + const hook = plugin.transform; + if (!hook || typeof hook === "function") throw new Error("Expected object transform hook"); + return await hook.handler.call( + { + addWatchFile() {}, + environment: { mode: "dev", config: { consumer: "server" } }, + } as never, + code, + id, + ); +} async function evaluateCommonJs(code: string): Promise> { const result = transformCommonJs(code, "/app/value.js"); @@ -20,6 +61,79 @@ describe("transformCommonJs", () => { ); }); + it("transforms dependency CommonJS modules selected by the plugin", async () => { + const result = await runPluginTransform( + `const value = require("dependency"); module.exports = value;`, + "/app/node_modules/example/index.js", + ); + if (!result || typeof result === "string" || !("code" in result)) { + throw new Error("Expected transformed dependency code"); + } + expect(String(result.code)).toContain('from "dependency"'); + expect(String(result.code)).toContain("__vinext_cjs_default__ as default"); + }); + + // vite-plugin-commonjs uses the resolved Vite extension list to select transform inputs: + // https://github.com/vite-plugin/vite-plugin-commonjs/blob/v0.10.4/src/index.ts#L65-L68 + it("transforms CommonJS modules with custom resolve extensions", async () => { + const root = await mkdtemp(path.join(import.meta.dirname, ".tmp-commonjs-extension-")); + const server = await createServer({ + root, + logLevel: "silent", + resolve: { extensions: [".foo"] }, + plugins: [createCommonJsPlugin()], + server: { middlewareMode: true }, + }); + try { + await writeFile(path.join(root, "value.foo"), `module.exports = { value: "custom" };\n`); + const module = await server.ssrLoadModule("/value.foo"); + expect(module.default).toEqual({ value: "custom" }); + } finally { + await server.close(); + } + try { + await Promise.all([ + writeFile(path.join(root, "index.html"), ``), + writeFile( + path.join(root, "main.js"), + `import value from "./value.foo"; globalThis.customExtensionValue = value.value;\n`, + ), + ]); + const builder = await createBuilder({ + root, + logLevel: "silent", + resolve: { extensions: [".foo"] }, + plugins: [createCommonJsPlugin()], + build: { write: false }, + }); + await builder.buildApp(); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + // Ported from vite-plugin-commonjs v0.10.4 historical require-form coverage: + // https://github.com/vite-plugin/vite-plugin-commonjs/blob/v0.10.4/test/fixtures/v0.4.7/input.js + it("rewrites repeated requires in side-effect, member, and collection positions", () => { + const result = transformCommonJs( + ` +require("foo"); +require("foo").bar(); +const foo = require("foo"); +const fooDefault = require("foo").default; +const { value } = require("foo"); +const routes = [{ component: require("@/views/home.vue") }]; +export { foo, fooDefault, value, routes }; +`, + "/app/value.js", + ); + expect(result?.code.match(/from "foo"/g)).toHaveLength(1); + expect(result?.code.match(/from "@\/views\/home\.vue"/g)).toHaveLength(1); + expect(result?.code).toContain(".bar();"); + expect(result?.code).toContain(".default;"); + expect(result?.code).toContain("const { value }"); + }); + it("exposes module.exports as the default export", () => { const result = transformCommonJs(`module.exports = () => "cjs";`, "/app/value.js"); expect(result?.code).toContain("var module = { exports: {} };"); @@ -35,6 +149,89 @@ describe("transformCommonJs", () => { expect(result?.code).toContain("__vinext_cjs_export_value__ as value"); }); + // Ported from vite-plugin-commonjs v0.10.4: + // https://github.com/vite-plugin/vite-plugin-commonjs/blob/v0.10.4/test/fixtures/src/cjs.js + it("evaluates guarded module and exports reassignment", async () => { + const module = await evaluateCommonJs(` +if (typeof exports !== "undefined") { + if (typeof module !== "undefined" && module.exports) { + exports = module.exports = { cjs: "cjs" }; + } +} +`); + expect(module.default).toEqual({ cjs: "cjs" }); + }); + + it("preserves uninitialized top-level var redeclarations of CommonJS globals", async () => { + const moduleRedeclaration = await evaluateCommonJs( + `var module; module.exports = "var-module-ok";`, + ); + expect(moduleRedeclaration.default).toBe("var-module-ok"); + + const exportsRedeclaration = await evaluateCommonJs( + `var exports; exports.value = "var-exports-ok";`, + ); + expect(exportsRedeclaration.value).toBe("var-exports-ok"); + + const requireRedeclaration = await evaluateCommonJs( + `var require; module.exports = require("node:path").sep;`, + ); + expect(requireRedeclaration.default).toBe(path.sep); + + const nestedModuleRedeclaration = await evaluateCommonJs( + `if (false) { var module; } module.exports = "nested-var-module-ok";`, + ); + expect(nestedModuleRedeclaration.default).toBe("nested-var-module-ok"); + + const loopExportsRedeclaration = await evaluateCommonJs( + `for (var exports; false;) {} exports.value = "loop-var-exports-ok";`, + ); + expect(loopExportsRedeclaration.value).toBe("loop-var-exports-ok"); + + const blockRequireRedeclaration = await evaluateCommonJs( + `{ var require; module.exports = require("node:path").sep; }`, + ); + expect(blockRequireRedeclaration.default).toBe(path.sep); + + const selfRequireRedeclaration = await evaluateCommonJs( + `var require = require; module.exports = require("node:path").sep;`, + ); + expect(selfRequireRedeclaration.default).toBe(path.sep); + + const selfModuleRedeclaration = await evaluateCommonJs( + `var module = module || { exports: {} }; module.exports = "self-module-ok";`, + ); + expect(selfModuleRedeclaration.default).toBe("self-module-ok"); + + const exportsAliasRedeclaration = await evaluateCommonJs( + `var exports = module.exports; exports.value = "exports-alias-ok";`, + ); + expect(exportsAliasRedeclaration.value).toBe("exports-alias-ok"); + + const unreachableInitializer = await evaluateCommonJs( + `if (false) { var module = {}; } module.exports = "unreachable-init-ok";`, + ); + expect(unreachableInitializer.default).toBe("unreachable-init-ok"); + }); + + // Ported from vite-plugin-commonjs v0.10.4 historical export fixtures: + // https://github.com/vite-plugin/vite-plugin-commonjs/blob/v0.10.4/test/fixtures/v0.4.0/input.js + it("evaluates repeated and nested named export assignments", async () => { + const module = await evaluateCommonJs(` +exports.foo = "first"; +exports.foo = "foo"; +function assignNestedExport() { + exports.bar = exports.foo; +} +assignNestedExport(); +exports.obj = { foo: "foo" }; +`); + expect(module.default).toEqual({ foo: "foo", bar: "foo", obj: { foo: "foo" } }); + expect(module.foo).toBe("foo"); + expect(module.bar).toBe("foo"); + expect(module.obj).toEqual({ foo: "foo" }); + }); + // Ported from vite-plugin-commonjs v0.10.4's unrestricted named-export generation: // https://github.com/vite-plugin/vite-plugin-commonjs/blob/v0.10.4/src/generate-export.ts it("exposes Unicode identifier names as named exports", async () => { @@ -52,6 +249,15 @@ describe("transformCommonJs", () => { expect(result?.code).not.toContain("not-valid as"); }); + it("recognises computed module exports and comments before require calls", () => { + const result = transformCommonJs( + `module["exports"] = require /* keep this comment */ ("value");`, + "/app/value.js", + ); + expect(result?.code).toContain('from "value"'); + expect(result?.code).toContain("__vinext_cjs_default__ as default"); + }); + it("does not rewrite shadowed CommonJS bindings", () => { const source = ` const require = (value) => value; @@ -62,6 +268,9 @@ module.exports = "local"; exports.value = "local"; `; expect(transformCommonJs(source, "/app/value.js")).toBeNull(); + expect( + transformCommonJs(`var require = (value) => value; require("local");`, "/app/value.js"), + ).toBeNull(); }); it("honors function and block scope shadowing", () => { @@ -88,6 +297,59 @@ const external = require("external"); expect(result?.code).toContain("(__vinext_cjs_import___1.default || __vinext_cjs_import___1)"); }); + it("does not capture free references with generated helper bindings", async () => { + Object.assign(globalThis, { __vinext_cjs_import__: "global" }); + try { + const result = transformCommonJs( + `export const value = __vinext_cjs_import__; require("node:path");`, + "/app/value.js", + ); + if (!result) throw new Error("Expected transformed code"); + expect(result.code).toContain('import * as __vinext_cjs_import___1 from "node:path";'); + const url = `data:text/javascript;base64,${Buffer.from(result.code).toString("base64")}`; + const module = await import(url); + expect(module.value).toBe("global"); + } finally { + delete (globalThis as Record).__vinext_cjs_import__; + } + }); + + it("avoids generated bindings shadowed in descendant scopes", async () => { + const root = await mkdtemp(path.join(import.meta.dirname, ".tmp-commonjs-descendant-scope-")); + await mkdir(path.join(root, "locales"), { recursive: true }); + await Promise.all([ + writeFile(path.join(root, "static.js"), `export default "static";\n`), + writeFile(path.join(root, "locales/en.js"), `export default "dynamic";\n`), + writeFile( + path.join(root, "entry.js"), + `export function loadStatic() { + const __vinext_cjs_import__ = { default: "shadowed-static" }; + return require("./static.js"); +} +export function loadDynamic() { + const __vinext_dynamic_require__ = () => ({ default: "shadowed-dynamic" }); + const locale = "en"; + return require(\`./locales/${"${locale}"}.js\`).default; +} +`, + ), + ]); + const server = await createServer({ + root, + logLevel: "silent", + plugins: [createCommonJsPlugin()], + server: { middlewareMode: true }, + }); + try { + const module = await server.ssrLoadModule("/entry.js"); + expect(module.loadStatic()).toBe("static"); + expect(module.loadDynamic()).toBe("dynamic"); + } finally { + await server.close(); + await rm(root, { recursive: true, force: true }); + } + }); + // Ported from vite-plugin-commonjs v0.10.4, which reads the first require argument // without rejecting additional arguments: // https://github.com/vite-plugin/vite-plugin-commonjs/blob/v0.10.4/src/generate-import.ts @@ -113,4 +375,589 @@ const external = require("external"); ).toBeNull(); expect(transformCommonJs(`export default 42;`, "/app/page.js")).toBeNull(); }); + + it("rejects require calls without a statically known path segment", async () => { + for (const source of [ + `require(name);`, + `require();`, + `require(0);`, + `require(name + "./messages/en.js");`, + "require(`${name}/messages/en.js`);", + `require(\`./messages/*/${"${name}"}.js\`);`, + ]) { + await expect(runPluginTransform(source, "/app/page.js")).rejects.toThrow( + /cannot be statically analyzed/, + ); + expect(() => transformCommonJs(source, "/app/page.js")).toThrow( + /cannot be statically analyzed/, + ); + } + }); + + it("rejects patterned requires for missing bare packages", async () => { + await expect( + runPluginTransform("require(`vinext-definitely-missing/${name}.js`);", "/app/page.js"), + ).rejects.toThrow(/package .* could not be resolved/); + }); + + it("preserves explicitly ignored unsupported require expressions", async () => { + const source = `require(/* webpackIgnore: true */ name);`; + await expect(runPluginTransform(source, "/app/page.js")).resolves.toBeNull(); + expect(transformCommonJs(source, "/app/page.js")).toBeNull(); + }); + + it("expands patterned dynamic requires with Node's glob implementation", async () => { + const importer = path.join( + import.meta.dirname, + "fixtures/pages-basic/pages/cjs/dynamic-require.tsx", + ); + const result = await runPluginTransform( + `const messages = require(\`../../locales/${'${require("../../locale-name.js")}'}.js\`, sideEffect());`, + importer, + ); + if (!result || typeof result === "string" || !("code" in result)) { + throw new Error("Expected transformed code"); + } + const transformed = String(result.code); + expect(transformed).toContain('from "../../locale-name.js"'); + expect(transformed).toContain('from "../../locales/en.js"'); + expect(transformed).toContain('from "../../locales/ru.js"'); + expect(transformed).toContain('case "../../locales/ru.js"'); + // vite-plugin-commonjs rewrites only the dynamic callee, preserving evaluation of extra args: + // https://github.com/vite-plugin/vite-plugin-commonjs/blob/v0.10.4/src/index.ts#L217-L220 + expect(transformed).toContain( + "__vinext_dynamic_require__(`../../locales/${(__vinext_cjs_import__.default || __vinext_cjs_import__)}.js`, sideEffect())", + ); + // vite-plugin-commonjs returns the imported namespace so callers can use `.default`: + // https://github.com/vite-plugin/vite-plugin-commonjs/blob/v0.10.4/test/fixtures/src/dynamic.tsx + const ruCase = transformed.match(/case "\.\.\/\.\.\/locales\/ru\.js": return ([^;]+);/)?.[1]; + expect(ruCase).toMatch(/^__vinext_cjs_import__/); + expect(ruCase).not.toContain(".default"); + }); + + it("expands concatenated dynamic require patterns", async () => { + const importer = path.join( + import.meta.dirname, + "fixtures/pages-basic/pages/cjs/dynamic-require.tsx", + ); + for (const expression of [ + '"../../locales/" + locale + ".js"', + '"../../locales/".concat(locale, ".js")', + ]) { + const result = await runPluginTransform(`const messages = require(${expression});`, importer); + if (!result || typeof result === "string" || !("code" in result)) { + throw new Error("Expected transformed code"); + } + const transformed = String(result.code); + expect(transformed).toContain('from "../../locales/ru.js"'); + expect(transformed).toContain('case "../../locales/ru.js"'); + } + }); + + it("expands aliased dynamic require patterns", async () => { + const importer = path.join( + import.meta.dirname, + "fixtures/pages-basic/pages/cjs/dynamic-require.tsx", + ); + const replacement = path.join(import.meta.dirname, "fixtures/pages-basic/locales"); + const result = await runPluginTransform( + `const messages = require(\`@messages/${"${locale}"}.js\`);`, + importer, + [{ find: "@messages", replacement }], + ); + if (!result || typeof result === "string" || !("code" in result)) { + throw new Error("Expected transformed code"); + } + const transformed = String(result.code); + expect(transformed).toContain(JSON.stringify(toSlash(path.join(replacement, "ru.js")))); + expect(transformed).toContain('case "@messages/ru.js"'); + }); + + it("expands regex-aliased dynamic require patterns", async () => { + const importer = path.join( + import.meta.dirname, + "fixtures/pages-basic/pages/cjs/dynamic-require.tsx", + ); + const replacement = path.join(import.meta.dirname, "fixtures/pages-basic/locales"); + const result = await runPluginTransform( + `const messages = require(\`@messages/${"${locale}"}.js\`);`, + importer, + [{ find: /^@messages/, replacement }], + ); + if (!result || typeof result === "string" || !("code" in result)) { + throw new Error("Expected transformed code"); + } + const transformed = String(result.code); + expect(transformed).toContain(JSON.stringify(toSlash(path.join(replacement, "ru.js")))); + expect(transformed).toContain('case "@messages/ru.js"'); + }); + + // Ported from vite-plugin-commonjs v0.10.4 and its transitive dynamic-import fixture: + // https://github.com/vite-plugin/vite-plugin-commonjs/blob/v0.10.4/test/fixtures/src/dynamic.tsx + // https://github.com/vite-plugin/vite-plugin-dynamic-import/blob/v1.6.0/test/fixtures/src/main.ts + it("expands alias-root patterns whose variables include directories or extensions", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "vinext-commonjs-alias-root-")); + try { + const sourceDirectory = path.join(root, "src"); + await Promise.all([ + mkdir(path.join(sourceDirectory, "module-exports"), { recursive: true }), + mkdir(path.join(sourceDirectory, "views/baz"), { recursive: true }), + ]); + await Promise.all([ + writeFile( + path.join(sourceDirectory, "module-exports/hello.cjs"), + 'module.exports = "hello";\n', + ), + writeFile( + path.join(sourceDirectory, "views/baz/index.tsx"), + 'export const value = "baz";\n', + ), + ]); + const importer = path.join(sourceDirectory, "main.ts"); + const aliases: Alias[] = [{ find: "@", replacement: sourceDirectory }]; + + const extensionResult = await runPluginTransform( + `const value = require(\`@/module-exports/${"${name}"}\`);`, + importer, + aliases, + ); + if (!extensionResult || typeof extensionResult === "string" || !("code" in extensionResult)) { + throw new Error("Expected transformed code"); + } + expect(String(extensionResult.code)).toContain('case "@/module-exports/hello.cjs"'); + + const directoryResult = await runPluginTransform( + `const value = require(\`@/${"${id}"}\`);`, + importer, + aliases, + ); + if (!directoryResult || typeof directoryResult === "string" || !("code" in directoryResult)) { + throw new Error("Expected transformed code"); + } + const transformed = String(directoryResult.code); + expect(transformed).toContain('case "@/views/baz"'); + expect(transformed).toContain('case "@/views/baz/index"'); + expect(transformed).toContain('case "@/views/baz/index.tsx"'); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("matches extensionless dynamic requires recursively", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "vinext-commonjs-loose-pattern-")); + try { + await mkdir(path.join(root, "views/nested"), { recursive: true }); + await Promise.all([ + writeFile(path.join(root, "views/flat.js"), 'module.exports = "flat";\n'), + writeFile(path.join(root, "views/nested/index.js"), 'module.exports = "nested";\n'), + writeFile(path.join(root, "views/nested/component.js"), 'module.exports = "component";\n'), + ]); + const result = await runPluginTransform( + `const view = require(\`./views/${"${name}"}\`);`, + path.join(root, "page.js"), + ); + if (!result || typeof result === "string" || !("code" in result)) { + throw new Error("Expected transformed code"); + } + const transformed = String(result.code); + expect(transformed).toContain('case "./views/flat"'); + expect(transformed).toContain('case "./views/flat.js"'); + expect(transformed).toContain('case "./views/nested"'); + expect(transformed).toContain('case "./views/nested/index"'); + expect(transformed).toContain('case "./views/nested/index.js"'); + expect(transformed).not.toContain('from "./views/nested";'); + + const staticSuffixResult = await runPluginTransform( + `const view = require(\`./views/${"${name}"}/component\`);`, + path.join(root, "page.js"), + ); + if ( + !staticSuffixResult || + typeof staticSuffixResult === "string" || + !("code" in staticSuffixResult) + ) { + throw new Error("Expected transformed code"); + } + const staticSuffix = String(staticSuffixResult.code); + expect(staticSuffix).toContain('from "./views/nested/component.js"'); + expect(staticSuffix).toContain('case "./views/nested/component"'); + expect(staticSuffix).toContain('case "./views/nested/component.js"'); + + await mkdir(path.join(root, "views/one/component"), { recursive: true }); + await writeFile( + path.join(root, "views/one/component/index.js"), + 'export default "suffix-index";\n', + ); + await writeFile( + path.join(root, "suffix-index.js"), + 'const name = "one"; export default require(`./views/${name}/component`).default;\n', + ); + const server = await createServer({ + root, + logLevel: "silent", + plugins: [createCommonJsPlugin()], + server: { middlewareMode: true }, + }); + try { + const module = await server.ssrLoadModule("/suffix-index.js"); + expect(module.default).toBe("suffix-index"); + } finally { + await server.close(); + } + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("matches extensionless dynamic asset requires", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "vinext-commonjs-asset-pattern-")); + try { + await mkdir(path.join(root, "assets"), { recursive: true }); + await writeFile(path.join(root, "assets/logo.png"), "fixture"); + const result = await runPluginTransform( + `const asset = require(\`./assets/${"${name}"}\`);`, + path.join(root, "page.js"), + ); + if (!result || typeof result === "string" || !("code" in result)) { + throw new Error("Expected transformed code"); + } + const transformed = String(result.code); + expect(transformed).toContain('from "./assets/logo.png"'); + expect(transformed).toContain('case "./assets/logo"'); + expect(transformed).toContain('case "./assets/logo.png"'); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("expands bare-package dynamic require patterns", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "vinext-commonjs-pattern-")); + try { + const packageDirectory = path.join(root, "node_modules/messages"); + await mkdir(packageDirectory, { recursive: true }); + await Promise.all([ + writeFile(path.join(packageDirectory, "package.json"), '{"name":"messages"}\n'), + writeFile(path.join(packageDirectory, "ru.js"), 'module.exports = "loaded";\n'), + ]); + const result = await runPluginTransform( + `const messages = require(\`messages/${"${locale}"}.js\`);`, + path.join(root, "page.js"), + ); + if (!result || typeof result === "string" || !("code" in result)) { + throw new Error("Expected transformed code"); + } + const transformed = String(result.code); + expect(transformed).toContain( + JSON.stringify(toSlash(path.join(await realpath(packageDirectory), "ru.js"))), + ); + expect(transformed).toContain('case "messages/ru.js"'); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + // vite-plugin-dynamic-import keeps patterned bare-package imports on the nearest + // node_modules path so Vite's preserveSymlinks setting remains authoritative: + // https://github.com/vite-plugin/vite-plugin-dynamic-import/blob/v1.6.0/src/resolve.ts#L97-L128 + it("honors preserveSymlinks for patterned bare-package requires", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "vinext-commonjs-symlink-pattern-")); + try { + const packageDirectory = path.join(root, "store/messages"); + const linkedPackageDirectory = path.join(root, "node_modules/messages"); + await Promise.all([ + mkdir(packageDirectory, { recursive: true }), + mkdir(path.dirname(linkedPackageDirectory), { recursive: true }), + ]); + await Promise.all([ + writeFile(path.join(packageDirectory, "package.json"), '{"name":"messages"}\n'), + writeFile(path.join(packageDirectory, "ru.js"), "module.exports = import.meta.url;\n"), + writeFile( + path.join(root, "page.js"), + `const locale = "ru"; export default require(\`messages/${"${locale}"}.js\`).default;\n`, + ), + ]); + await symlink( + packageDirectory, + linkedPackageDirectory, + process.platform === "win32" ? "junction" : "dir", + ); + + const importer = path.join(root, "page.js"); + const source = `const messages = require(\`messages/${"${locale}"}.js\`);`; + const preserved = await runPluginTransform(source, importer, [], true); + const resolved = await runPluginTransform(source, importer); + if ( + !preserved || + typeof preserved === "string" || + !("code" in preserved) || + !resolved || + typeof resolved === "string" || + !("code" in resolved) + ) { + throw new Error("Expected transformed code"); + } + expect(String(preserved.code)).toContain( + JSON.stringify(toSlash(path.join(linkedPackageDirectory, "ru.js"))), + ); + expect(String(resolved.code)).toContain( + JSON.stringify(toSlash(await realpath(path.join(packageDirectory, "ru.js")))), + ); + + const server = await createServer({ + root, + logLevel: "silent", + resolve: { preserveSymlinks: true }, + plugins: [createCommonJsPlugin()], + server: { middlewareMode: true }, + }); + try { + const module = await server.ssrLoadModule(toSlash(path.join(root, "page.js"))); + expect(module.default).toContain("/node_modules/messages/ru.js"); + expect(module.default).not.toContain("/store/messages/ru.js"); + } finally { + await server.close(); + } + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + // vite-plugin-dynamic-import v1.6.0 used fast-glob's default symlink traversal: + // https://github.com/vite-plugin/vite-plugin-dynamic-import/blob/v1.6.0/src/index.ts + it("follows symlinked directories matched by patterned requires", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "vinext-commonjs-symlink-directory-")); + try { + const realTheme = path.join(root, "real-theme"); + const linkedTheme = path.join(root, "themes/linked"); + await Promise.all([ + mkdir(path.join(realTheme, "nested"), { recursive: true }), + mkdir(path.dirname(linkedTheme), { recursive: true }), + ]); + await Promise.all([ + writeFile(path.join(realTheme, "nested/value.js"), 'export default "symlink-ok";\n'), + writeFile( + path.join(root, "entry.js"), + 'const theme = "linked"; export default require(`./themes/${theme}/nested/value.js`).default;\n', + ), + ]); + await symlink(realTheme, linkedTheme, process.platform === "win32" ? "junction" : "dir"); + + const server = await createServer({ + root, + logLevel: "silent", + plugins: [createCommonJsPlugin()], + server: { middlewareMode: true }, + }); + try { + const module = await server.ssrLoadModule("/entry.js"); + expect(module.default).toBe("symlink-ok"); + } finally { + await server.close(); + } + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("matches explicitly patterned dotfiles", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "vinext-commonjs-dotfile-pattern-")); + try { + await mkdir(path.join(root, "locales"), { recursive: true }); + await Promise.all([ + writeFile(path.join(root, "locales/.en.js"), 'export default "dotfile-ok";\n'), + writeFile( + path.join(root, "entry.js"), + 'const locale = "en"; export default require("./locales/." + locale + ".js").default;\n', + ), + ]); + const server = await createServer({ + root, + logLevel: "silent", + plugins: [createCommonJsPlugin()], + server: { middlewareMode: true }, + }); + try { + const module = await server.ssrLoadModule("/entry.js"); + expect(module.default).toBe("dotfile-ok"); + } finally { + await server.close(); + } + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("enumerates candidates selected by static extglob syntax", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "vinext-commonjs-extglob-pattern-")); + try { + await Promise.all([ + mkdir(path.join(root, "views/foo"), { recursive: true }), + mkdir(path.join(root, "views/bar"), { recursive: true }), + ]); + await Promise.all([ + writeFile(path.join(root, "views/foo/en.js"), 'export default "foo";\n'), + writeFile(path.join(root, "views/bar/en.js"), 'export default "bar";\n'), + ]); + const result = await runPluginTransform( + 'const name = "en"; require(`./views/+(foo|bar)/${name}.js`);', + path.join(root, "entry.js"), + ); + if (!result || typeof result === "string" || !("code" in result)) { + throw new Error("Expected transformed code"); + } + expect(String(result.code)).toContain('from "./views/foo/en.js"'); + expect(String(result.code)).toContain('from "./views/bar/en.js"'); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + // Ported from vite-plugin-dynamic-import v1.6.0's absolute-looking alias fixture: + // https://github.com/vite-plugin/vite-plugin-dynamic-import/blob/v1.6.0/test/fixtures/src/main.ts + it("resolves aliases before treating patterns as absolute filesystem paths", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "vinext-commonjs-absolute-alias-")); + try { + const sourceRoot = path.join(root, "src"); + await mkdir(path.join(sourceRoot, "views"), { recursive: true }); + await Promise.all([ + writeFile(path.join(sourceRoot, "views/value.js"), 'export default "absolute-alias";\n'), + writeFile( + path.join(root, "entry.js"), + 'const id = "value"; export default require(`/root/src/views/${id}.js`).default; export const relative = require(`./views/${id}.js`).default;\n', + ), + ]); + const server = await createServer({ + root, + logLevel: "silent", + resolve: { + alias: [ + { find: "/root/src", replacement: sourceRoot }, + { find: ".", replacement: sourceRoot }, + ], + }, + plugins: [createCommonJsPlugin()], + server: { middlewareMode: true }, + }); + try { + const module = await server.ssrLoadModule("/entry.js"); + expect(module.default).toBe("absolute-alias"); + expect(module.relative).toBe("absolute-alias"); + } finally { + await server.close(); + } + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("preserves eager dynamic-before-static import evaluation order", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "vinext-commonjs-import-order-")); + const orderKey = "__vinext_commonjs_import_order__"; + try { + await mkdir(path.join(root, "dynamic"), { recursive: true }); + await mkdir(path.join(root, "dynamic/a"), { recursive: true }); + await Promise.all([ + writeFile( + path.join(root, "static.js"), + `globalThis.${orderKey}.push("static"); export default "static";\n`, + ), + ...["B", "a", "z", "á"].map((name) => + writeFile( + path.join(root, `dynamic/${name}.js`), + `globalThis.${orderKey}.push(${JSON.stringify(name)}); export default ${JSON.stringify(name)};\n`, + ), + ), + writeFile( + path.join(root, "dynamic/a/nested.js"), + `globalThis.${orderKey}.push("nested"); export default "nested";\n`, + ), + writeFile( + path.join(root, "entry.js"), + 'require("./dynamic/z.js"); require("./static.js"); const name = "a"; require(`./dynamic/${name}.js`); export default true;\n', + ), + ]); + Object.assign(globalThis, { [orderKey]: [] }); + const server = await createServer({ + root, + logLevel: "silent", + plugins: [createCommonJsPlugin()], + server: { middlewareMode: true }, + }); + try { + await server.ssrLoadModule("/entry.js"); + expect((globalThis as Record)[orderKey]).toEqual([ + "B", + "a", + "z", + "á", + "nested", + "static", + ]); + } finally { + await server.close(); + } + } finally { + delete (globalThis as Record)[orderKey]; + await rm(root, { recursive: true, force: true }); + } + }); + + // Ported from vite-plugin-dynamic-import v1.6.0 bare-package resolution coverage: + // https://github.com/vite-plugin/vite-plugin-dynamic-import/blob/v1.6.0/test/resolve.test.ts + it("expands scoped bare-package dynamic require patterns", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "vinext-commonjs-scoped-pattern-")); + try { + const packageDirectory = path.join(root, "node_modules/@scope/messages"); + await mkdir(packageDirectory, { recursive: true }); + await Promise.all([ + writeFile(path.join(packageDirectory, "package.json"), '{"name":"@scope/messages"}\n'), + writeFile(path.join(packageDirectory, "ru.js"), 'module.exports = "loaded";\n'), + ]); + const result = await runPluginTransform( + `const messages = require(\`@scope/messages/${"${locale}"}\`);`, + path.join(root, "page.js"), + ); + if (!result || typeof result === "string" || !("code" in result)) { + throw new Error("Expected transformed code"); + } + const transformed = String(result.code); + expect(transformed).toContain('case "@scope/messages/ru"'); + expect(transformed).toContain('case "@scope/messages/ru.js"'); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("expands patterns whose scoped package name is dynamic", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "vinext-commonjs-dynamic-package-")); + try { + const packageDirectory = path.join(root, "node_modules/@scope/pkg-a"); + await mkdir(packageDirectory, { recursive: true }); + await Promise.all([ + writeFile(path.join(packageDirectory, "file.js"), 'export default "scoped-package";\n'), + writeFile( + path.join(root, "entry.js"), + 'const variant = "a"; export default require(`@scope/pkg-${variant}/file.js`).default;\n', + ), + ]); + const server = await createServer({ + root, + logLevel: "silent", + plugins: [createCommonJsPlugin()], + server: { middlewareMode: true }, + }); + try { + const module = await server.ssrLoadModule("/entry.js"); + expect(module.default).toBe("scoped-package"); + } finally { + await server.close(); + } + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it.runIf(process.platform === "win32")("preserves Windows drive traversal roots", () => { + expect(globTraversalRoot("C:/*.js")).toBe("C:/"); + }); }); diff --git a/tests/e2e/pages-router-prod/production.spec.ts b/tests/e2e/pages-router-prod/production.spec.ts index bf520d27cf..c9cab219eb 100644 --- a/tests/e2e/pages-router-prod/production.spec.ts +++ b/tests/e2e/pages-router-prod/production.spec.ts @@ -15,7 +15,7 @@ test.describe("Pages Router Production Build", () => { // https://github.com/vercel/next.js/blob/canary/test/integration/dynamic-require/test/index.test.ts const response = await page.goto(`${BASE}/cjs/dynamic-require`); expect(response?.status()).toBe(200); - await expect(page.getByTestId("dynamic-require-message")).toHaveText("loaded"); + await expect(page.getByTestId("dynamic-require-message")).toHaveText("Привет|extra:true"); }); test("index page renders with correct content", async ({ page }) => { diff --git a/tests/e2e/pages-router/cjs.spec.ts b/tests/e2e/pages-router/cjs.spec.ts index bfd772c54a..3f382ce9bf 100644 --- a/tests/e2e/pages-router/cjs.spec.ts +++ b/tests/e2e/pages-router/cjs.spec.ts @@ -12,6 +12,6 @@ test.describe("CJS interop", () => { test("page with a patterned dynamic require renders correctly", async ({ page }) => { await page.goto(`${BASE}/cjs/dynamic-require`); - await expect(page.getByTestId("dynamic-require-message")).toHaveText("loaded"); + await expect(page.getByTestId("dynamic-require-message")).toHaveText("Привет|extra:true"); }); }); diff --git a/tests/fixtures/pages-basic/locale-name.js b/tests/fixtures/pages-basic/locale-name.js new file mode 100644 index 0000000000..0f5d5506e1 --- /dev/null +++ b/tests/fixtures/pages-basic/locale-name.js @@ -0,0 +1 @@ +module.exports = "ru"; diff --git a/tests/fixtures/pages-basic/pages/cjs/dynamic-require.tsx b/tests/fixtures/pages-basic/pages/cjs/dynamic-require.tsx index 6d16e9f198..94022853ad 100644 --- a/tests/fixtures/pages-basic/pages/cjs/dynamic-require.tsx +++ b/tests/fixtures/pages-basic/pages/cjs/dynamic-require.tsx @@ -1,6 +1,11 @@ -const locale = "ru"; -const messages = require(`../../locales/${locale}`); +let extraArgumentEvaluated = false; +const messages = + require(`../../locales/${require("../../locale-name")}`, (extraArgumentEvaluated = true)).default; export default function DynamicRequirePage() { - return

{messages ? "loaded" : "missing"}

; + return ( +

+ {messages.message}|extra:{String(extraArgumentEvaluated)} +

+ ); }