Skip to content

Commit 498f5a9

Browse files
committed
fix: CJS detection regression for packages with "export" in comments
The content-based heuristic (`code.includes("export ")`) was fooled by CJS files containing the word "export" in comments — e.g. `@opentelemetry/api`'s CJS entry has a comment: // TODO: Remove ProxyTracerProvider export in the next major version. This caused the CJS shim to be skipped, leaving `exports` undefined when Vite's ESModulesEvaluator ran the file. Replace the naive string check with two-layer detection: 1. Package.json "type" field (Node.js semantics): .cjs is always CJS, .js is CJS unless the nearest package.json has "type": "module". Results are cached per directory. 2. ESM statement regex fallback: for dual CJS/ESM packages that ship ESM in .js without "type": "module" (e.g. @opentelemetry/api), detect actual ESM syntax (export/import statements) including minified forms like `import{` and `export*from`. Also adds a test that imports from a real CJS npm package (qs) in node_modules, since the existing CJS test fixtures were local ESM files that never exercised the shim path.
1 parent 71e849c commit 498f5a9

3 files changed

Lines changed: 94 additions & 38 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import qs from "qs";
2+
3+
export default function Page() {
4+
const parsed = qs.parse("a=1&b=2");
5+
return <h1>{parsed.a === "1" ? "qs-ok" : "qs-fail"}</h1>;
6+
}

packages/plugin-vite/src/plugins/deno.ts

Lines changed: 77 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,37 @@ export function deno(): Plugin {
2323

2424
let isDev = false;
2525

26+
// Cache for package.json "type" field lookups. Per Node.js semantics,
27+
// .js files in node_modules are CJS unless the nearest package.json
28+
// has "type": "module".
29+
const pkgTypeCache = new Map<string, boolean>();
30+
async function isEsmPackage(filePath: string): Promise<boolean> {
31+
let dir = path.dirname(filePath);
32+
while (true) {
33+
const cached = pkgTypeCache.get(dir);
34+
if (cached !== undefined) return cached;
35+
36+
try {
37+
const text = await Deno.readTextFile(path.join(dir, "package.json"));
38+
const isEsm = JSON.parse(text).type === "module";
39+
pkgTypeCache.set(dir, isEsm);
40+
return isEsm;
41+
} catch {
42+
const parent = path.dirname(dir);
43+
if (parent === dir) break;
44+
dir = parent;
45+
}
46+
}
47+
return false;
48+
}
49+
50+
// Detect actual ESM export/import statements at the statement level.
51+
// More robust than code.includes("export ") which matches comments
52+
// and strings (e.g. "// Remove export in next version" would trick it).
53+
// Handles both formatted and minified ESM (e.g. "import{" with no space).
54+
const ESM_STMT_RE =
55+
/(?:^|[\n;])\s*(?:export\s*[{*]|export\s+(?:default|const|let|var|function|class|async)\b|import\s*[{*"']|import\s+[a-zA-Z_$])/;
56+
2657
return {
2758
name: "deno",
2859
sharedDuringBuild: true,
@@ -186,27 +217,34 @@ export function deno(): Plugin {
186217
// - SSR: module runner evaluates as ESM, needs module/exports/require
187218
// - Client: browser evaluates as ESM, needs module/exports
188219
// In build mode, Rollup's @rollup/plugin-commonjs handles CJS.
220+
//
221+
// CJS detection uses Node.js semantics (package.json "type" field)
222+
// instead of content heuristics, which can be fooled by comments
223+
// or strings containing "export"/"import".
189224
if (
190225
isDev &&
191226
!id.startsWith("\0") &&
192227
id.includes("node_modules") &&
193228
/\.(c?js|cjs)$/.test(id)
194229
) {
195-
try {
196-
const code = await Deno.readTextFile(id);
197-
// Quick heuristic: if file has CJS patterns and no ESM
198-
if (
199-
!code.includes("export ") &&
200-
!code.includes("import ") &&
201-
(code.includes("module.exports") ||
202-
code.includes("exports.") ||
203-
code.includes("require("))
204-
) {
205-
const isServer = this.environment.config.consumer === "server";
206-
207-
if (isServer) {
208-
// SSR: use Node.js createRequire for full CJS compat
209-
const wrapped = `
230+
// .cjs is always CJS. For .js files, check the nearest
231+
// package.json "type" field first (Node.js semantics), then
232+
// fall back to content-based detection for dual CJS/ESM
233+
// packages that ship ESM in .js without "type": "module".
234+
if (id.endsWith(".cjs") || !(await isEsmPackage(id))) {
235+
try {
236+
const code = await Deno.readTextFile(id);
237+
238+
// Skip if the file contains actual ESM syntax. Some packages
239+
// (e.g. @opentelemetry/api) ship both CJS and ESM as .js
240+
// without "type": "module" in package.json.
241+
if (!ESM_STMT_RE.test(code)) {
242+
const isServer =
243+
this.environment.config.consumer === "server";
244+
245+
if (isServer) {
246+
// SSR: use Node.js createRequire for full CJS compat
247+
const wrapped = `
210248
import { createRequire as __cjs_createRequire } from "node:module";
211249
import { fileURLToPath as __cjs_fileURLToPath } from "node:url";
212250
import { dirname as __cjs_dirname } from "node:path";
@@ -220,26 +258,26 @@ ${code}
220258
221259
export default module.exports;
222260
`;
223-
return { code: wrapped };
224-
}
225-
226-
// Client: convert require() calls to ESM imports so
227-
// browsers can load them. Hoist static require() calls
228-
// to import statements at the top.
229-
const imports: string[] = [];
230-
let idx = 0;
231-
const transformed = code.replace(
232-
/\brequire\(["']([^"']+)["']\)/g,
233-
(_match: string, spec: string) => {
234-
const varName = `__cjs_import_${idx++}`;
235-
imports.push(
236-
`import ${varName} from ${JSON.stringify(spec)};`,
237-
);
238-
return `(${varName}.default ?? ${varName})`;
239-
},
240-
);
241-
242-
const wrapped = `${imports.join("\n")}
261+
return { code: wrapped };
262+
}
263+
264+
// Client: convert require() calls to ESM imports so
265+
// browsers can load them. Hoist static require() calls
266+
// to import statements at the top.
267+
const imports: string[] = [];
268+
let idx = 0;
269+
const transformed = code.replace(
270+
/\brequire\(["']([^"']+)["']\)/g,
271+
(_match: string, spec: string) => {
272+
const varName = `__cjs_import_${idx++}`;
273+
imports.push(
274+
`import ${varName} from ${JSON.stringify(spec)};`,
275+
);
276+
return `(${varName}.default ?? ${varName})`;
277+
},
278+
);
279+
280+
const wrapped = `${imports.join("\n")}
243281
var module = { exports: {} };
244282
var exports = module.exports;
245283
var __filename = "";
@@ -249,10 +287,11 @@ ${transformed}
249287
250288
export default module.exports;
251289
`;
252-
return { code: wrapped };
290+
return { code: wrapped };
291+
}
292+
} catch {
293+
// Fall through to default loading
253294
}
254-
} catch {
255-
// Fall through to default loading
256295
}
257296
}
258297

packages/plugin-vite/tests/dev_server_test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,6 +452,17 @@ Deno.test({
452452
sanitizeResources: false,
453453
});
454454

455+
Deno.test({
456+
name: "vite dev - CJS npm package (qs)",
457+
fn: async () => {
458+
const res = await fetch(`${demoServer.address()}/tests/cjs_npm`);
459+
const text = await res.text();
460+
expect(text).toContain("<h1>qs-ok</h1>");
461+
},
462+
sanitizeOps: false,
463+
sanitizeResources: false,
464+
});
465+
455466
// issue: https://github.com/denoland/fresh/issues/3666
456467
integrationTest(
457468
"vite dev - basePath does not intercept Vite URLs",

0 commit comments

Comments
 (0)