esm: link modules synchronously when no async loader hooks are used

When no async loader hooks are registered, perform the linking as
synchronously as possible to reduce the chance of races from the
the shared module loading cache.

PR-URL: https://github.com/nodejs/node/pull/59519
Fixes: https://github.com/nodejs/node/issues/59366
Refs: https://github.com/abejfehr/node-22.18-issue-repro
Reviewed-By: Geoffrey Booth <webadmin@geoffreybooth.com>
Reviewed-By: Filip Skokan <panva.ip@gmail.com>
Reviewed-By: Chengzhong Wu <legendecas@gmail.com>
This commit is contained in:
Joyee Cheung 2025-08-18 16:08:59 +02:00 committed by Node.js GitHub Bot
parent db70ceb49f
commit 7535aa1f72
5 changed files with 25 additions and 15 deletions

View File

@ -65,6 +65,8 @@ let debug = require('internal/util/debuglog').debuglog('esm', (fn) => {
debug = fn;
});
const { isPromise } = require('internal/util/types');
/**
* @typedef {import('./hooks.js').HooksProxy} HooksProxy
* @typedef {import('./module_job.js').ModuleJobBase} ModuleJobBase
@ -592,15 +594,21 @@ class ModuleLoader {
/**
* Load a module and translate it into a ModuleWrap for ordinary imported ESM.
* This is run asynchronously.
* This may be run asynchronously if there are asynchronous module loader hooks registered.
* @param {string} url URL of the module to be translated.
* @param {object} loadContext See {@link load}
* @param {boolean} isMain Whether the module to be translated is the entry point.
* @returns {Promise<ModuleWrap>}
* @returns {Promise<ModuleWrap>|ModuleWrap}
*/
async loadAndTranslate(url, loadContext, isMain) {
const { format, source } = await this.load(url, loadContext);
loadAndTranslate(url, loadContext, isMain) {
const maybePromise = this.load(url, loadContext);
const afterLoad = ({ format, source }) => {
return this.#translate(url, format, source, isMain);
};
if (isPromise(maybePromise)) {
return maybePromise.then(afterLoad);
}
return afterLoad(maybePromise);
}
/**

View File

@ -37,6 +37,7 @@ const {
},
} = internalBinding('util');
const { decorateErrorStack, kEmptyObject } = require('internal/util');
const { isPromise } = require('internal/util/types');
const {
getSourceMapsSupport,
} = require('internal/source_map/source_map_cache');
@ -138,12 +139,11 @@ class ModuleJob extends ModuleJobBase {
this.#loader = loader;
// Expose the promise to the ModuleWrap directly for linking below.
if (isForRequireInImportedCJS) {
this.module = moduleOrModulePromise;
assert(this.module instanceof ModuleWrap);
this.modulePromise = PromiseResolve(this.module);
} else {
if (isPromise(moduleOrModulePromise)) {
this.modulePromise = moduleOrModulePromise;
} else {
this.module = moduleOrModulePromise;
this.modulePromise = PromiseResolve(moduleOrModulePromise);
}
if (this.phase === kEvaluationPhase) {

View File

@ -19,7 +19,9 @@ let error;
await assert.rejects(
() => import(file),
(e) => {
assert.strictEqual(error, e);
// The module may be compiled again and a new SyntaxError would be thrown but
// with the same content.
assert.deepStrictEqual(error, e);
return true;
}
);

View File

@ -28,7 +28,7 @@ async function test() {
await rejects(
import(jsModuleDataUrl, { with: { type: 'json', other: 'unsupported' } }),
{ code: 'ERR_IMPORT_ATTRIBUTE_TYPE_INCOMPATIBLE' }
{ code: 'ERR_IMPORT_ATTRIBUTE_UNSUPPORTED' }
);
await rejects(
@ -48,7 +48,7 @@ async function test() {
await rejects(
import(jsonModuleDataUrl, { with: { foo: 'bar' } }),
{ code: 'ERR_IMPORT_ATTRIBUTE_MISSING' }
{ code: 'ERR_IMPORT_ATTRIBUTE_UNSUPPORTED' }
);
await rejects(

View File

@ -23,7 +23,7 @@ await rejects(
await rejects(
import(jsModuleDataUrl, { with: { type: 'json', other: 'unsupported' } }),
{ code: 'ERR_IMPORT_ATTRIBUTE_TYPE_INCOMPATIBLE' }
{ code: 'ERR_IMPORT_ATTRIBUTE_UNSUPPORTED' }
);
await rejects(
@ -43,7 +43,7 @@ await rejects(
await rejects(
import(jsonModuleDataUrl, { with: { foo: 'bar' } }),
{ code: 'ERR_IMPORT_ATTRIBUTE_MISSING' }
{ code: 'ERR_IMPORT_ATTRIBUTE_UNSUPPORTED' }
);
await rejects(