fix: fall back to require(esm) when CJS parses .js with ESM syntax (#16078)

This commit is contained in:
Simen Bekkhus
2026-05-02 00:17:24 +02:00
committed by GitHub
parent 9e30c48088
commit ecd1af78ac
7 changed files with 100 additions and 7 deletions
+1
View File
@@ -18,6 +18,7 @@
- `[jest-circus, jest-jasmine2]` Include `Error.cause` in JSON `failureMessages` output ([#15949](https://github.com/jestjs/jest/issues/15949))
- `[jest-runtime]` Improve CJS-from-ESM interop: `__esModule`/Babel default unwrap, broader named-export coverage, and shared CJS singleton across importers ([#16050](https://github.com/jestjs/jest/pull/16050))
- `[jest-runtime]` Load `.js` files with ESM syntax but no `"type":"module"` marker as native ESM ([#16050](https://github.com/jestjs/jest/pull/16050))
- `[jest-runtime]` Extend the `.js`-with-ESM-syntax fallback to `require()` on Node v24.9+ - falls back to `require(esm)` when the CJS parser rejects ESM syntax ([#16078](https://github.com/jestjs/jest/pull/16078))
- `[jest-runtime]` Fix deadlocks and double-evaluation in concurrent ESM and wasm imports ([#16050](https://github.com/jestjs/jest/pull/16050))
- `[jest-runtime]` Fix error when `require()` is called after the Jest environment has been torn down ([#15951](https://github.com/jestjs/jest/pull/15951))
@@ -20,6 +20,17 @@ onNodeVersions('>=24.9.0', () => {
expect(stderr).toContain('4 passed');
expect(exitCode).toBe(0);
});
test('require()/import() fall back to ESM for .js with ESM syntax and no "type":"module" marker', () => {
const {exitCode, stderr} = runJest(
DIR,
['__tests__/syntax-fallback.test.js'],
{nodeOptions: '--experimental-vm-modules --no-warnings'},
);
expect(stderr).toContain('2 passed');
expect(exitCode).toBe(0);
});
});
onNodeVersions('<24.9.0', () => {
@@ -0,0 +1,18 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
'use strict';
test('require() of .js with ESM syntax falls back to require(esm)', () => {
const ns = require('../fake-esm-js.js');
expect(ns.fakeEsmValue).toBe(123);
});
test('await import() of .js with ESM syntax falls back to ESM', async () => {
const ns = await import('../fake-esm-js.js');
expect(ns.fakeEsmValue).toBe(123);
});
@@ -0,0 +1,8 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
export const fakeEsmValue = 123;
@@ -17,9 +17,15 @@
import * as path from 'path';
// SyntheticModule is only available when --experimental-vm-modules is active.
const {SyntheticModule} = require('vm') as typeof import('vm');
const {SourceTextModule, SyntheticModule} =
require('vm') as typeof import('vm');
const vmAvailable = typeof SyntheticModule === 'function';
const itVm = vmAvailable ? it : it.skip;
// `require(esm)` needs the v24.9+ sync ESM core.
const syncCoreAvailable =
// @ts-expect-error - hasAsyncGraph is in Node v24.9+, not yet typed
typeof SourceTextModule?.prototype.hasAsyncGraph === 'function';
const itSyncOnly = syncCoreAvailable ? it : it.skip;
const ROOT_DIR = path.join(__dirname, 'test_esm_interop_root');
const FROM = path.join(ROOT_DIR, 'test.js');
@@ -125,4 +131,26 @@ describe('Runtime loadCjsAsEsm SyntaxError fallback', () => {
expect(m.namespace.fakeEsmValue).toBe(123);
},
);
itSyncOnly(
'require()s a .js file with ESM syntax that has no "type":"module" marker',
async () => {
const runtime = await createRuntime(__filename, {rootDir: ROOT_DIR});
const ns = runtime.requireModule(FROM, './fake-esm-js.js');
expect(ns.fakeEsmValue).toBe(123);
},
);
// Runtime SyntaxError from inside the CJS body (vs. a parse-time one)
// must not trigger the ESM fallback — surfacing the original error
// unchanged is the right behavior.
itVm(
'does not retry as ESM when the CJS body throws a runtime SyntaxError',
async () => {
const runtime = await createRuntime(__filename, {rootDir: ROOT_DIR});
expect(() =>
runtime.requireModule(FROM, './throws-syntaxerror.cjs'),
).toThrow('user-thrown SyntaxError from CJS body');
},
);
});
@@ -0,0 +1,8 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
throw new SyntaxError('user-thrown SyntaxError from CJS body');
+25 -6
View File
@@ -64,6 +64,14 @@ import {
const esmIsAvailable = typeof SourceTextModule === 'function';
const supportsDynamicImport = esmIsAvailable;
// Marker used by the CJS-as-ESM SyntaxError fallback paths to distinguish
// parse-time errors (where retrying as ESM is correct) from runtime errors
// a user might throw from inside a module body.
const CJS_PARSE_ERROR = Symbol('jest-runtime CJS parse error');
const isCjsParseError = (error: unknown): error is Error =>
isError(error) &&
(error as unknown as Record<symbol, unknown>)[CJS_PARSE_ERROR] === true;
const dataURIRegex =
/^data:(?<mime>text\/javascript|application\/json|application\/wasm)(?:;(?<encoding>charset=utf-8|base64))?,(?<code>.*)$/;
@@ -1663,20 +1671,16 @@ export default class Runtime {
try {
synthetic = this._buildCjsAsEsmSyntheticModule(from, modulePath, context);
} catch (error) {
// Use .name check instead of instanceof: the error comes from
// vm.compileFunction with a sandbox parsingContext, so its prototype
// may cross context boundaries.
if ((error as Error).name !== 'SyntaxError') {
if (!isCjsParseError(error)) {
throw error;
}
// The file may contain ESM syntax with no ESM marker (.mjs /
// "type":"module") - try loading as native ESM. If the ESM parser also
// rejects it, the original CJS error was the genuine one; surface it
// instead of the (less useful) ESM diagnostic.
const cjsSyntaxError = error as Error;
return this.loadEsmModule(modulePath).catch(esmError => {
throw isError(esmError) && esmError.name === 'SyntaxError'
? cjsSyntaxError
? error
: esmError;
});
}
@@ -1877,6 +1881,15 @@ export default class Runtime {
);
} catch (error) {
moduleRegistry.delete(modulePath);
// Mirror of `loadCjsAsEsm`'s SyntaxError fallback for `require()`.
if (supportsSyncEvaluate && isCjsParseError(error)) {
try {
return this._requireEsmModule<T>(modulePath);
} catch (esmError) {
if (isError(esmError) && esmError.name === 'SyntaxError') throw error;
throw esmError;
}
}
throw error;
}
@@ -2629,6 +2642,12 @@ export default class Runtime {
},
) as ModuleWrapper;
} catch (error: any) {
// Tag so callers can distinguish parse-time SyntaxErrors (where the
// ESM-syntax-in-CJS fallback applies) from runtime SyntaxErrors a user
// might throw from inside a CJS module body.
if (isError(error)) {
(error as unknown as Record<symbol, unknown>)[CJS_PARSE_ERROR] = true;
}
throw handlePotentialSyntaxError(error);
}
}