From 77e7ce0e15913fcace588ad1e1f11bc3af5ca2e0 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Wed, 5 Aug 2026 23:54:45 -0700 Subject: [PATCH] fix: respect "type" when inferring module format from exports detectModuleFormat infers module format from file extensions in the exports tree, but .js was treated as neutral and the package's "type" field was never consulted. An ESM package whose conditions point at .js files therefore produced no ESM signal at all, and a single .cjs subpath anywhere in the tree was enough to classify the whole package as CJS. Thread isTypeModule into analyzeExports so a bare .js resolves the way Node resolves it, against "type". Also stop counting "./package.json": "./package.json" as an ESM signal. It is boilerplate that packages ship regardless of format, and its .json target was setting hasImport for plain CJS packages. Fixes #2837 --- shared/utils/package-analysis.ts | 31 ++++++-- .../shared/utils/package-analysis.spec.ts | 78 +++++++++++++++++++ 2 files changed, 104 insertions(+), 5 deletions(-) diff --git a/shared/utils/package-analysis.ts b/shared/utils/package-analysis.ts index d9c0a39f15..eff9452277 100644 --- a/shared/utils/package-analysis.ts +++ b/shared/utils/package-analysis.ts @@ -54,7 +54,7 @@ export function detectModuleFormat(pkg: ExtendedPackageJson): ModuleFormat { // Check exports field for dual format indicators if (hasExports && pkg.exports) { - const exportInfo = analyzeExports(pkg.exports) + const exportInfo = analyzeExports(pkg.exports, isTypeModule) if (exportInfo.hasImport && exportInfo.hasRequire) { return 'dual' @@ -114,7 +114,11 @@ interface ExportsAnalysis { /** * Recursively analyze exports field for module format indicators */ -function analyzeExports(exports: PackageExports, depth = 0): ExportsAnalysis { +function analyzeExports( + exports: PackageExports, + isTypeModule: boolean, + depth = 0, +): ExportsAnalysis { const result: ExportsAnalysis = { hasImport: false, hasRequire: false, @@ -130,11 +134,28 @@ function analyzeExports(exports: PackageExports, depth = 0): ExportsAnalysis { } if (typeof exports === 'string') { + // Re-exposing the manifest (`"./package.json": "./package.json"`) is + // boilerplate that packages ship regardless of format. Counting it as an + // ESM signal makes plain CJS packages look like they expose an ESM entry. + if (exports === './package.json') { + return result + } + // Check file extension for format hints if (exports.endsWith('.mjs') || exports.endsWith('.mts') || exports.endsWith('.json')) { result.hasImport = true } else if (exports.endsWith('.cjs') || exports.endsWith('.cts')) { result.hasRequire = true + } else if (exports.endsWith('.js')) { + // A bare .js file has no format of its own. Node resolves it against the + // package's "type" field. Without this, an ESM package that points its + // conditions at .js files gives off no ESM signal at all, and a single + // .cjs subpath elsewhere in the tree is enough to classify it as CJS. + if (isTypeModule) { + result.hasImport = true + } else { + result.hasRequire = true + } } if (exports.endsWith('.d.ts') || exports.endsWith('.d.mts') || exports.endsWith('.d.cts')) { result.hasTypes = true @@ -144,7 +165,7 @@ function analyzeExports(exports: PackageExports, depth = 0): ExportsAnalysis { if (Array.isArray(exports)) { for (const item of exports) { - const subResult = analyzeExports(item, depth + 1) + const subResult = analyzeExports(item, isTypeModule, depth + 1) mergeExportsAnalysis(result, subResult) } return result @@ -164,7 +185,7 @@ function analyzeExports(exports: PackageExports, depth = 0): ExportsAnalysis { } // Recurse into nested exports - const subResult = analyzeExports(value, depth + 1) + const subResult = analyzeExports(value, isTypeModule, depth + 1) mergeExportsAnalysis(result, subResult) } } @@ -358,7 +379,7 @@ export function hasBuiltInTypes(pkg: ExtendedPackageJson): boolean { // Check exports field for types if (pkg.exports) { - const exportInfo = analyzeExports(pkg.exports) + const exportInfo = analyzeExports(pkg.exports, pkg.type === 'module') if (exportInfo.hasTypes) { return true } diff --git a/test/unit/shared/utils/package-analysis.spec.ts b/test/unit/shared/utils/package-analysis.spec.ts index 9be81e232d..dced5ae925 100644 --- a/test/unit/shared/utils/package-analysis.spec.ts +++ b/test/unit/shared/utils/package-analysis.spec.ts @@ -123,6 +123,84 @@ describe('detectModuleFormat', () => { ).toBe('esm') }) + it('detects ESM when a type: module package uses default conditions', () => { + expect( + detectModuleFormat({ + type: 'module', + exports: { + '.': { + types: './dist/index.d.ts', + default: './dist/index.js', + }, + }, + }), + ).toBe('esm') + }) + + it('preserves type: module through export fallback arrays', () => { + // The .js sits behind a fallback array, so this only resolves to ESM if the + // package type survives the recursion into the array + expect( + detectModuleFormat({ + type: 'module', + exports: { + '.': ['./dist/index.js'], + './init': './dist/init.cjs', + }, + }), + ).toBe('dual') + }) + + it('detects dual when a type: module package also exports a .cjs subpath', () => { + // A .js entry in a type: module package is ESM, so a CJS subpath elsewhere + // makes the package dual rather than reclassifying the whole thing as CJS + expect( + detectModuleFormat({ + type: 'module', + exports: { + '.': { + types: './dist/index.d.ts', + default: './dist/index.js', + }, + './init': { + types: './dist/init.d.cts', + default: './dist/init.cjs', + }, + }, + }), + ).toBe('dual') + }) + + it('detects CJS when a commonjs package uses default conditions', () => { + expect( + detectModuleFormat({ + type: 'commonjs', + exports: { + '.': { + types: './dist/index.d.ts', + default: './dist/index.js', + }, + }, + }), + ).toBe('cjs') + }) + + it('ignores a re-exported package.json when inferring format', () => { + // "./package.json": "./package.json" is boilerplate, not an ESM entry point + expect( + detectModuleFormat({ + type: 'commonjs', + exports: { + '.': { + types: './dist/index.d.ts', + default: './dist/index.js', + }, + './package.json': './package.json', + }, + }), + ).toBe('cjs') + }) + it('detects WASM from main field', () => { expect(detectModuleFormat({ main: 'main.wasm' })).toBe('wasm') })