diff --git a/packages/vinext/src/plugins/ast-utils.ts b/packages/vinext/src/plugins/ast-utils.ts index 0c206a58ad..fc27a3416f 100644 --- a/packages/vinext/src/plugins/ast-utils.ts +++ b/packages/vinext/src/plugins/ast-utils.ts @@ -159,18 +159,22 @@ export function staticStringValue(value: unknown): string | null { } export function forEachAstChild(node: AstRecord, callback: (child: AstRecord) => void): void { - for (const [key, value] of Object.entries(node)) { + for (const key of Object.keys(node)) { if (SKIP_CHILD_KEYS.has(key)) continue; - const child = toAstRecord(value); - if (child) { - callback(child); - continue; - } + const value = node[key]; + if (typeof value !== "object" || value === null) continue; if (Array.isArray(value)) { for (const item of value) { - const itemNode = toAstRecord(item); - if (itemNode) callback(itemNode); + if ( + typeof item === "object" && + item !== null && + typeof (item as AstRecord).type === "string" + ) { + callback(item as AstRecord); + } } + } else if (typeof (value as AstRecord).type === "string") { + callback(value as AstRecord); } } } diff --git a/packages/vinext/src/plugins/commonjs.ts b/packages/vinext/src/plugins/commonjs.ts new file mode 100644 index 0000000000..5c300d64a0 --- /dev/null +++ b/packages/vinext/src/plugins/commonjs.ts @@ -0,0 +1,231 @@ +import MagicString from "magic-string"; +import { parseAst } from "vite"; +import { + collectBindingNames, + forEachAstChild, + getAstName, + hasRange, + isAstRecord, + isIdentifierNamed, + nodeArray, + scriptParserLanguage, + staticStringValue, + unwrapExpression, + type AstRecord, +} from "./ast-utils.js"; +import { + collectDirectScopeBindings, + collectLoopScopeBindings, + collectSwitchScopeBindings, + collectVarScopeBindings, + createAstScope, + hasAstBinding, + isFunctionNode, + type AstScope, +} from "./ast-scope.js"; +import { magicStringTransformResult, type MagicStringTransformResult } from "./transform-result.js"; + +const COMMONJS_PRESCAN = /\b(?:require\s*\(|module\s*\.|exports\s*[.[])/; +const IDENTIFIER_NAME_RE = /^[$_\p{ID_Start}][$\u200C\u200D\p{ID_Continue}]*$/u; + +type StaticRequire = { + node: AstRecord & { start: number; end: number }; + specifier: string; +}; + +type CommonJsAnalysis = { + requires: StaticRequire[]; + hasExports: boolean; + namedExports: string[]; + rootBindings: Set; +}; + +function memberPropertyName(node: AstRecord): string | null { + const property = unwrapExpression(node.property); + if (!property) return null; + if (node.computed === true) return staticStringValue(property); + return getAstName(property); +} + +function isUnboundModuleExports(node: AstRecord | null, scope: AstScope): boolean { + if (node?.type !== "MemberExpression" || hasAstBinding(scope, "module")) return false; + return ( + isIdentifierNamed(unwrapExpression(node.object), "module") && + memberPropertyName(node) === "exports" + ); +} + +function commonJsExportName(node: AstRecord, scope: AstScope): string | null | undefined { + if (node.type !== "MemberExpression") return undefined; + const object = unwrapExpression(node.object); + if (isIdentifierNamed(object, "exports") && !hasAstBinding(scope, "exports")) { + return memberPropertyName(node); + } + if (isUnboundModuleExports(node, scope)) return null; + if (isUnboundModuleExports(object, scope)) return memberPropertyName(node); + 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; + } + const root = isAstRecord(ast) ? ast : null; + if (!root) return null; + + const rootScope = createAstScope(null); + collectDirectScopeBindings(root, rootScope); + collectVarScopeBindings(root, rootScope); + const requires: StaticRequire[] = []; + const namedExports = new Set(); + let hasExports = false; + + function visit(node: AstRecord, parentScope: AstScope): void { + let scope = parentScope; + if (isFunctionNode(node)) { + const parameterScope = createAstScope(parentScope); + collectBindingNames(node.id, parameterScope.bindings); + for (const parameter of nodeArray(node.params)) { + collectBindingNames(parameter, parameterScope.bindings); + if (isAstRecord(parameter)) visit(parameter, parameterScope); + } + const body = isAstRecord(node.body) ? node.body : null; + if (body) { + const bodyScope = createAstScope(parameterScope); + collectDirectScopeBindings(body, bodyScope); + collectVarScopeBindings(body, bodyScope); + if (body.type === "BlockStatement") { + for (const statement of nodeArray(body.body)) { + if (isAstRecord(statement)) visit(statement, bodyScope); + } + } else { + visit(body, bodyScope); + } + } + return; + } + if (node.type === "SwitchStatement") { + if (isAstRecord(node.discriminant)) visit(node.discriminant, parentScope); + const switchScope = createAstScope(parentScope); + collectSwitchScopeBindings(node, switchScope); + for (const switchCase of nodeArray(node.cases)) { + if (isAstRecord(switchCase)) visit(switchCase, switchScope); + } + return; + } + if ( + (node.type === "BlockStatement" && node !== root) || + node.type === "StaticBlock" || + node.type === "TSModuleBlock" + ) { + scope = createAstScope(parentScope); + collectDirectScopeBindings(node, scope); + if (node.type === "StaticBlock" || node.type === "TSModuleBlock") { + collectVarScopeBindings(node, scope); + } + } else if (node.type === "CatchClause") { + scope = createAstScope(parentScope); + collectBindingNames(node.param, scope.bindings); + } else if ( + node.type === "ForStatement" || + node.type === "ForInStatement" || + node.type === "ForOfStatement" + ) { + scope = createAstScope(parentScope); + collectLoopScopeBindings(node, scope); + } else if (node.type === "ClassExpression" && node.id) { + scope = createAstScope(parentScope); + collectBindingNames(node.id, scope.bindings); + } + + if (node.type === "CallExpression" && hasRange(node)) { + const callee = unwrapExpression(node.callee); + 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; + } + } else if (node.type === "AssignmentExpression") { + const left = unwrapExpression(node.left); + const exportName = left ? commonJsExportName(left, scope) : undefined; + if (exportName !== undefined) { + hasExports = true; + if (exportName && exportName !== "default" && IDENTIFIER_NAME_RE.test(exportName)) { + namedExports.add(exportName); + } + } + } + + forEachAstChild(node, (child) => visit(child, scope)); + } + + for (const statement of nodeArray(root.body)) { + if (isAstRecord(statement)) visit(statement, rootScope); + } + return { + requires, + hasExports, + namedExports: [...namedExports], + rootBindings: rootScope.bindings, + }; +} + +function unusedBinding(bindings: Set, base: string): string { + let name = base; + let suffix = 0; + while (bindings.has(name)) name = `${base}_${++suffix}`; + bindings.add(name); + 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; + + const output = new MagicString(code); + const bindings = new Set(analysis.rootBindings); + const imports: string[] = []; + for (const { node, specifier } of analysis.requires) { + const importName = unusedBinding(bindings, "__vinext_cjs_import__"); + imports.push(`import * as ${importName} from ${JSON.stringify(specifier)};`); + output.overwrite(node.start, node.end, `(${importName}.default || ${importName})`); + } + + const preamble: string[] = []; + 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`); + } + + if (analysis.hasExports) { + const defaultBinding = unusedBinding(bindings, "__vinext_cjs_default__"); + const declarations = [ + `const ${defaultBinding} = (module.exports == null ? {} : module.exports).default || module.exports;`, + ]; + const exports = [`${defaultBinding} as default`]; + for (const name of analysis.namedExports) { + const binding = unusedBinding(bindings, `__vinext_cjs_export_${name}__`); + declarations.push( + `const ${binding} = (module.exports == null ? {} : module.exports).${name};`, + ); + exports.push(`${binding} as ${name}`); + } + output.append(`\n${declarations.join("\n")}\nexport { ${exports.join(", ")} };\n`); + } + + return magicStringTransformResult(output, { hires: true, source: id }); +} diff --git a/tests/cjs.test.ts b/tests/cjs.test.ts index a060f6ff5c..82d423ea4c 100644 --- a/tests/cjs.test.ts +++ b/tests/cjs.test.ts @@ -88,6 +88,14 @@ describe("CJS interop (Pages Router)", () => { // expressions (e.g. "Random: 4"), so use a regex. expect(html).toMatch(/Random:.*4/); }); + + 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 + const { res, html } = await fetchHtml(baseUrl, "/cjs/dynamic-require"); + expect(res.status).toBe(200); + expect(html).toContain("loaded"); + }); }); // Ported from Next.js: test/e2e/app-dir/client-module-with-package-type/index.test.ts diff --git a/tests/commonjs-transform.test.ts b/tests/commonjs-transform.test.ts new file mode 100644 index 0000000000..8b47d08544 --- /dev/null +++ b/tests/commonjs-transform.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from "vite-plus/test"; +import { transformCommonJs } from "../packages/vinext/src/plugins/commonjs.js"; + +async function evaluateCommonJs(code: string): Promise> { + const result = transformCommonJs(code, "/app/value.js"); + if (!result) throw new Error("Expected transformed code"); + const url = `data:text/javascript;base64,${Buffer.from(result.code).toString("base64")}`; + return import(url) as Promise>; +} + +describe("transformCommonJs", () => { + it("hoists literal require calls with the existing default-first interop", () => { + const result = transformCommonJs( + `const { join } = require("node:path");\nexport const value = join("a", "b");`, + "/app/page.tsx", + ); + expect(result?.code).toContain('import * as __vinext_cjs_import__ from "node:path";'); + expect(result?.code).toContain( + `const { join } = (__vinext_cjs_import__.default || __vinext_cjs_import__);`, + ); + }); + + it("exposes module.exports as the default export", () => { + const result = transformCommonJs(`module.exports = () => "cjs";`, "/app/value.js"); + expect(result?.code).toContain("var module = { exports: {} };"); + expect(result?.code).toContain("__vinext_cjs_default__ as default"); + }); + + it("exposes statically named exports", () => { + const result = transformCommonJs( + `exports.Component = () => "component";\nmodule.exports.value = 42;`, + "/app/value.js", + ); + expect(result?.code).toContain("__vinext_cjs_export_Component__ as Component"); + expect(result?.code).toContain("__vinext_cjs_export_value__ as value"); + }); + + // 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 () => { + const module = await evaluateCommonJs(`exports.π = 3; exports.你好 = 4;`); + expect(module.π).toBe(3); + expect(module.你好).toBe(4); + }); + + it("supports computed static export names but not invalid ESM names", () => { + const result = transformCommonJs( + `exports["valid"] = 1; exports["not-valid"] = 2;`, + "/app/value.js", + ); + expect(result?.code).toContain("__vinext_cjs_export_valid__ as valid"); + expect(result?.code).not.toContain("not-valid as"); + }); + + it("does not rewrite shadowed CommonJS bindings", () => { + const source = ` +const require = (value) => value; +const module = { exports: {} }; +const exports = {}; +require("local"); +module.exports = "local"; +exports.value = "local"; +`; + expect(transformCommonJs(source, "/app/value.js")).toBeNull(); + }); + + it("honors function and block scope shadowing", () => { + const source = ` +function local(require) { return require("local"); } +{ + const exports = {}; + exports.value = 1; +} +const external = require("external"); +`; + const result = transformCommonJs(source, "/app/value.ts"); + expect(result?.code).toContain('require("local")'); + expect(result?.code).toContain("exports.value = 1"); + expect(result?.code).toContain('from "external"'); + }); + + it("uses collision-safe helper bindings", () => { + const result = transformCommonJs( + `const __vinext_cjs_import__ = 1; const value = require("value");`, + "/app/value.js", + ); + expect(result?.code).toContain('import * as __vinext_cjs_import___1 from "value";'); + expect(result?.code).toContain("(__vinext_cjs_import___1.default || __vinext_cjs_import___1)"); + }); + + // 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 + it("ignores additional require arguments", () => { + for (const source of [`require("value", "ignored");`, `require(\`value\`, "ignored");`]) { + const result = transformCommonJs(source, "/app/value.js"); + expect(result?.code).toContain('from "value"'); + expect(result?.code).not.toContain("ignored"); + } + }); + + it("parses TypeScript and JSX source", () => { + const result = transformCommonJs( + `const value = require("value") as { default: string }; export default

{value.default}

;`, + "/app/page.tsx", + ); + expect(result?.code).toContain('from "value"'); + }); + + it("leaves dynamic and non-CommonJS modules unchanged", () => { + expect( + transformCommonJs(`require(\`./messages/${"${locale}"}.js\`);`, "/app/page.js"), + ).toBeNull(); + expect(transformCommonJs(`export default 42;`, "/app/page.js")).toBeNull(); + }); +}); diff --git a/tests/e2e/pages-router-prod/production.spec.ts b/tests/e2e/pages-router-prod/production.spec.ts index 95ea404a8d..bf520d27cf 100644 --- a/tests/e2e/pages-router-prod/production.spec.ts +++ b/tests/e2e/pages-router-prod/production.spec.ts @@ -10,6 +10,14 @@ import { test, expect } from "@playwright/test"; const BASE = "http://localhost:4175"; test.describe("Pages Router Production Build", () => { + test("renders a patterned dynamic require", async ({ page }) => { + // 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 + const response = await page.goto(`${BASE}/cjs/dynamic-require`); + expect(response?.status()).toBe(200); + await expect(page.getByTestId("dynamic-require-message")).toHaveText("loaded"); + }); + test("index page renders with correct content", async ({ page }) => { const response = await page.goto(`${BASE}/`); expect(response?.status()).toBe(200); diff --git a/tests/e2e/pages-router/cjs.spec.ts b/tests/e2e/pages-router/cjs.spec.ts index bc86820d88..bfd772c54a 100644 --- a/tests/e2e/pages-router/cjs.spec.ts +++ b/tests/e2e/pages-router/cjs.spec.ts @@ -8,4 +8,10 @@ test.describe("CJS interop", () => { await expect(page.getByTestId("cjs-basic")).toContainText("Random: 4"); }); + + 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"); + }); }); diff --git a/tests/fixtures/pages-basic/locales/en.js b/tests/fixtures/pages-basic/locales/en.js new file mode 100644 index 0000000000..52dcd98b4b --- /dev/null +++ b/tests/fixtures/pages-basic/locales/en.js @@ -0,0 +1 @@ +module.exports = { message: "Hello" }; diff --git a/tests/fixtures/pages-basic/locales/ru.js b/tests/fixtures/pages-basic/locales/ru.js new file mode 100644 index 0000000000..be7fcd56e3 --- /dev/null +++ b/tests/fixtures/pages-basic/locales/ru.js @@ -0,0 +1 @@ +module.exports = { message: "Привет" }; diff --git a/tests/fixtures/pages-basic/pages/cjs/dynamic-require.tsx b/tests/fixtures/pages-basic/pages/cjs/dynamic-require.tsx new file mode 100644 index 0000000000..6d16e9f198 --- /dev/null +++ b/tests/fixtures/pages-basic/pages/cjs/dynamic-require.tsx @@ -0,0 +1,6 @@ +const locale = "ru"; +const messages = require(`../../locales/${locale}`); + +export default function DynamicRequirePage() { + return

{messages ? "loaded" : "missing"}

; +} diff --git a/tests/plugin-utils.test.ts b/tests/plugin-utils.test.ts index 12b2f572e8..0201459828 100644 --- a/tests/plugin-utils.test.ts +++ b/tests/plugin-utils.test.ts @@ -62,8 +62,13 @@ describe("plugin AST utilities", () => { const prunedChild = { type: "Literal", value: "hidden" }; const pruned = { type: "CallExpression", arguments: [prunedChild] }; const visible = { type: "Identifier", name: "visible" }; + const direct = { + type: "ExpressionStatement", + expression: { type: "Identifier", name: "direct" }, + }; const root = { type: "Program", body: [pruned, visible] } as Record; root.parent = root; + root.direct = direct; const visited: string[] = []; walkAst(root, (node) => { @@ -71,7 +76,13 @@ describe("plugin AST utilities", () => { return node === pruned ? false : undefined; }); - expect(visited).toEqual(["Program", "CallExpression", "Identifier"]); + expect(visited).toEqual([ + "Program", + "CallExpression", + "Identifier", + "ExpressionStatement", + "Identifier", + ]); }); });