Commit c89ec290f0f for nodejs

commit c89ec290f0f82f544e39e6e5f2eab3b6de2c1cca
Author: Geoffrey Booth <webadmin@geoffreybooth.com>
Date:   Tue Oct 6 10:40:45 2026 -0700

    module: synchronously load most ES modules

    Signed-off-by: Geoffrey Booth <webadmin@geoffreybooth.com>
    PR-URL: https://github.com/nodejs/node/pull/62530
    Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
    Reviewed-By: Jacob Smith <jacob@frende.me>
    Reviewed-By: Filip Skokan <panva.ip@gmail.com>
    Reviewed-By: Marco Ippolito <marcoippolito54@gmail.com>
    Reviewed-By: Joyee Cheung <joyeec9h3@gmail.com>
    Reviewed-By: Gürgün Dayıoğlu <hey@gurgun.day>

diff --git a/benchmark/esm/startup-esm-graph.js b/benchmark/esm/startup-esm-graph.js
new file mode 100644
index 00000000000..7684ba67f6a
--- /dev/null
+++ b/benchmark/esm/startup-esm-graph.js
@@ -0,0 +1,66 @@
+'use strict';
+
+const common = require('../common.js');
+const fs = require('fs');
+const path = require('path');
+const { pathToFileURL } = require('url');
+const tmpdir = require('../../test/common/tmpdir');
+
+const BRANCHING_FACTOR = 10;
+const benchmarkDirectory = tmpdir.resolve('esm-graph');
+
+// Each measured iteration has to load a graph the ESM cache has never seen, so the
+// fixture holds one independent copy of the graph per iteration.
+function graphDirectory(modules, copy) {
+  return path.join(benchmarkDirectory, `g${modules}-${copy}`);
+}
+
+function entryURL(modules, copy) {
+  return pathToFileURL(path.join(graphDirectory(modules, copy), 'mod0.mjs')).href;
+}
+
+// Build a complete BRANCHING_FACTOR-ary tree of `modules` + 1 modules rooted at mod0:
+// module i imports modules BRANCHING_FACTOR*i+1 through BRANCHING_FACTOR*i+BRANCHING_FACTOR,
+// capped at the total, so the shape approximates a real dependency tree rather than
+// one module with hundreds of direct imports.
+function createGraph(modules, copy) {
+  const dir = graphDirectory(modules, copy);
+  fs.mkdirSync(dir, { recursive: true });
+  const total = modules + 1;
+  for (let i = 0; i < total; i++) {
+    let source = '';
+    for (let c = 1; c <= BRANCHING_FACTOR; c++) {
+      const child = BRANCHING_FACTOR * i + c;
+      if (child < total) {
+        source += `import './mod${child}.mjs';\n`;
+      }
+    }
+    source += `export const value${i} = ${i};\n`;
+    fs.writeFileSync(path.join(dir, `mod${i}.mjs`), source);
+  }
+}
+
+const bench = common.createBenchmark(main, {
+  modules: [250, 500, 1000, 2000],
+  n: [10],
+}, {
+  setup(configs) {
+    // Build every fixture once here rather than per configuration: writing tens of
+    // thousands of files is far more expensive than the work being measured.
+    tmpdir.refresh();
+    const maxN = configs.reduce((max, c) => Math.max(max, c.n), 0);
+    for (const modules of new Set(configs.map((c) => c.modules))) {
+      for (let copy = 0; copy < maxN; copy++) {
+        createGraph(modules, copy);
+      }
+    }
+  },
+});
+
+async function main({ n, modules }) {
+  bench.start();
+  for (let i = 0; i < n; i++) {
+    await import(entryURL(modules, i));
+  }
+  bench.end(n);
+}
diff --git a/lib/internal/modules/esm/loader.js b/lib/internal/modules/esm/loader.js
index f92e87785ce..489d8ff91e3 100644
--- a/lib/internal/modules/esm/loader.js
+++ b/lib/internal/modules/esm/loader.js
@@ -11,6 +11,7 @@ const {
   PromisePrototypeThen,
   RegExpPrototypeSymbolReplace,
   encodeURIComponent,
+  globalThis,
   hardenRegExp,
 } = primordials;

@@ -22,6 +23,11 @@ const {
   kRequiredModuleSymbol,
 } = require('internal/modules/cjs/loader');
 const { imported_cjs_symbol } = internalBinding('symbols');
+const {
+  privateSymbols: {
+    entry_point_module_private_symbol,
+  },
+} = internalBinding('util');

 const assert = require('internal/assert');
 const {
@@ -350,6 +356,39 @@ class ModuleLoader {
     return { wrap: job.module, namespace: job.runSync(parent).namespace };
   }

+  /**
+   * Load and evaluate the entry point module.
+   * The graph is always loaded and linked synchronously. When it contains no top-level
+   * await it is evaluated synchronously too, so startup creates no promises at all.
+   * When it does contain top-level await, evaluation falls back to `job.run()` here,
+   * reusing the job that was just built rather than making the caller re-enter the
+   * loader and redo the resolution and linking for the same module.
+   * @param {string} url The URL of the entry point module.
+   * @returns {ModuleJobSync|undefined} The job still needing asynchronous evaluation when
+   *   the graph has top-level await, otherwise `undefined` - the module is then already
+   *   fully evaluated and no promise was ever created.
+   */
+  importForEntryPoint(url) {
+    return onImport.traceSync(() => {
+      const request = { specifier: url, phase: kEvaluationPhase, attributes: kEmptyObject, __proto__: null };
+      const job = this.getOrCreateModuleJob(undefined, request, kImportInImportedESM);
+      job.instantiateSync();
+      if (job.module.hasAsyncGraph) {
+        // Hand the job back so the caller can evaluate it through the ordinary
+        // asynchronous entry point machinery, without re-resolving and re-linking
+        // the module the way re-entering `import()` would.
+        return job;
+      }
+      globalThis[entry_point_module_private_symbol] = job.module;
+      job.runSync();
+      return undefined;
+    }, {
+      __proto__: null,
+      parentURL: undefined,
+      url,
+    });
+  }
+
   /**
    * Check invariants on a cached module job when require()'d from ESM.
    * @param {string} specifier The first parameter of require().
@@ -572,10 +611,14 @@ class ModuleLoader {
       assert(moduleOrModulePromise instanceof ModuleWrap, `Expected ModuleWrap for loading ${url}`);
     }

-    // TODO(joyeecheung): use ModuleJobSync for kRequireInImportedCJS too.
-    const ModuleJobCtor = (requestType === kImportInRequiredESM ? ModuleJobSync : ModuleJob);
     const isMain = (parentURL === undefined);
     const inspectBrk = (isMain && getOptionValue('--inspect-brk'));
+    // Use ModuleJobSync whenever we're on the main thread (not the async loader hook worker),
+    // except for kRequireInImportedCJS (TODO: consolidate that case too).
+    // TODO(joyeecheung): use ModuleJobSync for kRequireInImportedCJS too.
+    const ModuleJobCtor = (this.isForAsyncLoaderHookWorker ||
+                           requestType === kRequireInImportedCJS) ?
+      ModuleJob : ModuleJobSync;
     job = new ModuleJobCtor(
       this,
       url,
@@ -602,8 +645,9 @@ class ModuleLoader {
    */
   getOrCreateModuleJob(parentURL, request, requestType) {
     let maybePromise;
-    if (requestType === kRequireInImportedCJS || requestType === kImportInRequiredESM) {
-      // In these two cases, resolution must be synchronous.
+    if (!this.isForAsyncLoaderHookWorker) {
+      // On the main thread, always resolve synchronously;
+      // `resolveSync` coordinates with the async loader hook worker if needed.
       maybePromise = this.resolveSync(parentURL, request);
       assert(!isPromise(maybePromise));
     } else {
diff --git a/lib/internal/modules/esm/module_job.js b/lib/internal/modules/esm/module_job.js
index c54706a9e0a..c8506b42ff3 100644
--- a/lib/internal/modules/esm/module_job.js
+++ b/lib/internal/modules/esm/module_job.js
@@ -54,7 +54,7 @@ const {
   getSourceMapsSupport,
 } = require('internal/source_map/source_map_cache');
 const assert = require('internal/assert');
-const resolvedPromise = PromiseResolve();
+let resolvedPromise;
 const {
   setHasStartedUserESMExecution,
   urlToFilename,
@@ -65,7 +65,6 @@ const {
   ERR_REQUIRE_ASYNC_MODULE,
   ERR_REQUIRE_ESM_RACE_CONDITION,
 } = require('internal/errors').codes;
-let hasPausedEntry = false;

 const CJSGlobalLike = [
   'require',
@@ -241,6 +240,58 @@ function getTopLevelAwaitLocations(root) {
   return locations;
 }

+/**
+ * If `error` is a SyntaxError from V8 for a missing named export on a CJS module, rewrite its message to the friendlier
+ * "Named export '...' not found..." form. Must be called after `decorateErrorStack(error)` so that the arrow (source
+ * context with the import statement text) has been prepended to `error.stack`.
+ * @param {Error} error
+ * @param {ModuleWrap} module The parent module that triggered the instantiation.
+ * @param {boolean[]} commonJsDeps Per-request array indicating whether each dependency is a CJS module, aligned with
+ *   `module.getModuleRequests()`.
+ */
+const handleCJSNamedExportError = (error, module, commonJsDeps) => {
+  // TODO(@bcoe): Add source map support to exception that occurs as result
+  // of missing named export. This is currently not possible because
+  // stack trace originates in module_job, not the file itself. A hidden
+  // symbol with filename could be set in node_errors.cc to facilitate this.
+  if (!getSourceMapsSupport().enabled &&
+      StringPrototypeIncludes(error.message,
+                              ' does not provide an export named')) {
+    const splitStack = StringPrototypeSplit(error.stack, '\n', 2);
+    const { 1: childSpecifier, 2: name } = RegExpPrototypeExec(
+      /module '(.*)' does not provide an export named '(.+)'/,
+      error.message);
+    const moduleRequests = module.getModuleRequests();
+    let isCommonJS = false;
+    for (let i = 0; i < moduleRequests.length; ++i) {
+      if (moduleRequests[i].specifier === childSpecifier) {
+        isCommonJS = commonJsDeps[i];
+        break;
+      }
+    }
+    if (isCommonJS) {
+      const importStatement = splitStack[1];
+      // TODO(@ctavan): The original error stack only provides the single
+      // line which causes the error. For multi-line import statements we
+      // cannot generate an equivalent object destructuring assignment by
+      // just parsing the error stack.
+      const oneLineNamedImports = RegExpPrototypeExec(/{.*}/, importStatement);
+      const destructuringAssignment = oneLineNamedImports &&
+        RegExpPrototypeSymbolReplace(/\s+as\s+/g, oneLineNamedImports, ': ');
+      error.message = `Named export '${name}' not found. The requested module` +
+        ` '${childSpecifier}' is a CommonJS module, which may not support` +
+        ' all module.exports as named exports.\nCommonJS modules can ' +
+        'always be imported via the default export, for example using:' +
+        `\n\nimport pkg from '${childSpecifier}';\n${
+          destructuringAssignment ?
+            `const ${destructuringAssignment} = pkg;\n` : ''}`;
+      const newStack = StringPrototypeSplit(error.stack, '\n');
+      newStack[3] = `SyntaxError: ${error.message}`;
+      error.stack = ArrayPrototypeJoin(newStack, '\n');
+    }
+  }
+};
+
 class ModuleJobBase {
   constructor(loader, url, importAttributes, phase, isMain, inspectBrk) {
     assert(typeof phase === 'number');
@@ -455,63 +506,17 @@ class ModuleJob extends ModuleJobBase {
     await addJobsToDependencyGraph(this);

     try {
-      if (!hasPausedEntry && this.inspectBrk) {
-        hasPausedEntry = true;
-        const initWrapper = internalBinding('inspector').callAndPauseOnStart;
-        initWrapper(this.module.instantiate, this.module);
-      } else {
-        this.module.instantiate();
-      }
-    } catch (e) {
-      decorateErrorStack(e);
-      // TODO(@bcoe): Add source map support to exception that occurs as result
-      // of missing named export. This is currently not possible because
-      // stack trace originates in module_job, not the file itself. A hidden
-      // symbol with filename could be set in node_errors.cc to facilitate this.
-      if (!getSourceMapsSupport().enabled &&
-          StringPrototypeIncludes(e.message,
-                                  ' does not provide an export named')) {
-        const splitStack = StringPrototypeSplit(e.stack, '\n', 2);
-        const { 1: childSpecifier, 2: name } = RegExpPrototypeExec(
-          /module '(.*)' does not provide an export named '(.+)'/,
-          e.message);
-        const moduleRequests = this.module.getModuleRequests();
-        let isCommonJS = false;
-        for (let i = 0; i < moduleRequests.length; ++i) {
-          if (moduleRequests[i].specifier === childSpecifier) {
-            isCommonJS = this.commonJsDeps[i];
-            break;
-          }
-        }
-
-        if (isCommonJS) {
-          const importStatement = splitStack[1];
-          // TODO(@ctavan): The original error stack only provides the single
-          // line which causes the error. For multi-line import statements we
-          // cannot generate an equivalent object destructuring assignment by
-          // just parsing the error stack.
-          const oneLineNamedImports = RegExpPrototypeExec(/{.*}/, importStatement);
-          const destructuringAssignment = oneLineNamedImports &&
-            RegExpPrototypeSymbolReplace(/\s+as\s+/g, oneLineNamedImports, ': ');
-          e.message = `Named export '${name}' not found. The requested module` +
-            ` '${childSpecifier}' is a CommonJS module, which may not support` +
-            ' all module.exports as named exports.\nCommonJS modules can ' +
-            'always be imported via the default export, for example using:' +
-            `\n\nimport pkg from '${childSpecifier}';\n${
-              destructuringAssignment ?
-                `const ${destructuringAssignment} = pkg;\n` : ''}`;
-          const newStack = StringPrototypeSplit(e.stack, '\n');
-          newStack[3] = `SyntaxError: ${e.message}`;
-          e.stack = ArrayPrototypeJoin(newStack, '\n');
-        }
-      }
-      throw e;
+      this.module.instantiate();
+    } catch (error) {
+      decorateErrorStack(error);
+      handleCJSNamedExportError(error, this.module, this.commonJsDeps);
+      throw error;
     }

     for (const dependencyJob of jobsInGraph) {
       // Calling `this.module.instantiate()` instantiates not only the
       // ModuleWrap in this module, but all modules in the graph.
-      dependencyJob.instantiated = resolvedPromise;
+      dependencyJob.instantiated = resolvedPromise ??= PromiseResolve();
     }
   }

@@ -577,12 +582,14 @@ class ModuleJob extends ModuleJobBase {

 /**
  * This is a fully synchronous job and does not spawn additional threads in any way.
- * All the steps are ensured to be synchronous and it throws on instantiating
- * an asynchronous graph. It also disallows CJS <-> ESM cycles.
+ * Loading and linking are always synchronous. Evaluation via runSync() throws on an
+ * asynchronous graph; evaluation via run() falls back to async for top-level await.
+ * It also disallows CJS <-> ESM cycles.
  *
- * This is used for ES modules loaded via require(esm). Modules loaded by require() in
- * imported CJS are handled by ModuleJob with the isForRequireInImportedCJS set to true instead.
- * The two currently have different caching behaviors.
+ * Used for all ES module imports on the main thread, regardless of how the import was
+ * triggered (entry point, import(), require(esm), --import, etc.).
+ * Modules loaded by require() in imported CJS are handled by ModuleJob with the
+ * isForRequireInImportedCJS set to true instead. The two currently have different caching behaviors.
  * TODO(joyeecheung): consolidate this with the isForRequireInImportedCJS variant of ModuleJob.
  */
 class ModuleJobSync extends ModuleJobBase {
@@ -623,38 +630,76 @@ class ModuleJobSync extends ModuleJobBase {
     return PromiseResolve(this.module);
   }

-  async run() {
+  instantiateSync() {
+    try {
+      this.module.instantiate();
+    } catch (error) {
+      decorateErrorStack(error);
+      handleCJSNamedExportError(error, this.module, this.commonJsDeps);
+      throw error;
+    }
+  }
+
+  async run(isEntryPoint = false) {
     assert(this.shouldRunModule(this.phase));
-    // This path is hit by a require'd module that is imported again.
     const status = this.module.getStatus();
     debug('ModuleJobSync.run()', status, this.module);
     // If the module was previously required and errored, reject from import() again.
     if (status === kErrored) {
       throw this.module.getError();
-    } else if (status > kInstantiated) {
+    }
+    if (status > kInstantiated) {
+      // Already evaluated (e.g. previously require()'d and now import()'d again).
       if (this.evaluationPromise) {
         await this.evaluationPromise;
       }
       return { __proto__: null, module: this.module };
-    } else if (status === kInstantiated || status === kUninstantiated) {
-      // If we get here, the module was initially required and is now being imported.
-      // The require() module failed either because the graph has TLA (kInstantiated),
-      // or instantiation failed (kUninstantiated, e.g. missing named export).
-      // Try finishing the instantiation - if it succeeds, proceed to evaluation,
-      // otherwise the branch below re-throw any instantiation error.
-      if (status === kUninstantiated) {
-        this.module.instantiate();
-      }
+    }
+    if (status < kInstantiated) {
+      // Either a fresh module - links were already resolved synchronously in the constructor -
+      // or one that was initially require()'d and is now being imported after its instantiation
+      // failed (e.g. a missing named export). Try finishing the instantiation: if it succeeds,
+      // proceed to evaluation, otherwise re-throw the instantiation error.
+      this.instantiateSync();
+    }
+    // `status === kInstantiated`: either just instantiated above, or previously instantiated
+    // but evaluation was deferred (e.g. TLA detected by a prior `runSync()` call)
+    if (isEntryPoint) {
+      globalThis[entry_point_module_private_symbol] = this.module;
+    }
+    setHasStartedUserESMExecution();
+    if (this.module.hasAsyncGraph) {
+      // Has top-level `await`: fall back to async evaluation
       const timeout = -1;
       const breakOnSigint = false;
-      this.evaluationPromise = this.module.evaluate(timeout, breakOnSigint);
+      if (this.inspectBrk) {
+        const { callAndPauseOnStart } = internalBinding('inspector');
+        this.evaluationPromise = callAndPauseOnStart(this.module.evaluate, this.module, timeout, breakOnSigint);
+      } else {
+        this.evaluationPromise = this.module.evaluate(timeout, breakOnSigint);
+      }
       await this.evaluationPromise;
       this.evaluationPromise = undefined;
       return { __proto__: null, module: this.module };
     }
+    // No top-level `await`: evaluate synchronously
+    try {
+      this.#evaluateSync();
+    } catch (evaluateError) {
+      explainCommonJSGlobalLikeNotDefinedError(evaluateError, this.module.url, this.module.hasTopLevelAwait);
+      throw evaluateError;
+    }
+    return { __proto__: null, module: this.module };
+  }

-    assert.fail('Unexpected status of a module that is imported again after being required. ' +
-                `Status = ${status}`);
+  // Evaluate this module synchronously, pausing at the first line of user land JS
+  // that runs if --inspect-brk is active - that may well be inside a dependency.
+  #evaluateSync() {
+    if (this.inspectBrk) {
+      const { callAndPauseOnStart } = internalBinding('inspector');
+      return callAndPauseOnStart(this.module.evaluateSync, this.module);
+    }
+    return this.module.evaluateSync();
   }

   runSync(parent) {
@@ -662,15 +707,14 @@ class ModuleJobSync extends ModuleJobBase {
     assert(this.shouldRunModule(this.phase));
     // TODO(joyeecheung): Reject graphs with top-level await _before_ instantiation, so that the
     // async graph error supersedes instantiation (mismatch export) errors in the graph.
-    // TODO(joyeecheung): add the error decoration logic from the async instantiate.
-    this.module.instantiate();
+    this.instantiateSync();
     // On the deprecated async loader hook worker thread, dependencies linked by an
     // earlier import may not be walkable synchronously, so double-check with
     // V8 now that the graph is instantiated.
     this.throwIfAsyncGraph(parent);
     setHasStartedUserESMExecution();
     try {
-      const namespace = this.module.evaluateSync();
+      const namespace = this.#evaluateSync();
       return { __proto__: null, module: this.module, namespace };
     } catch (e) {
       explainCommonJSGlobalLikeNotDefinedError(e, this.module.url, this.module.hasTopLevelAwait);
diff --git a/lib/internal/modules/run_main.js b/lib/internal/modules/run_main.js
index 87319e6a94c..b0d7d4845ea 100644
--- a/lib/internal/modules/run_main.js
+++ b/lib/internal/modules/run_main.js
@@ -140,6 +140,8 @@ function runEntryPointWithESMLoader(callback) {
  * Because of module detection, this function will attempt to run ambiguous (no explicit extension, no
  * `package.json` type field) entry points as CommonJS first; under certain conditions, it will retry running as ESM.
  * @param {string} main - First positional CLI argument, such as `'entry.js'` from `node entry.js`
+ * @returns {Promise|undefined} A promise when the entry point is still being evaluated
+ *   asynchronously because its graph contains top-level await, otherwise `undefined`.
  */
 function executeUserEntryPoint(main = process.argv[1]) {
   if (getOptionValue('[vfs_load_set]')) {
@@ -173,6 +175,31 @@ function executeUserEntryPoint(main = process.argv[1]) {
     const mainPath = resolvedMain || main;
     const mainURL = getOptionValue('--entry-url') ? new URL(mainPath, getCWDURL()) : pathToFileURL(mainPath);

+    // When no async loader hooks or --import are registered, load the entry point
+    // directly. The graph is loaded and linked synchronously, and evaluated
+    // synchronously too unless it contains top-level await.
+    if (getOptionValue('--experimental-loader').length === 0 &&
+        getOptionValue('--import').length === 0) {
+      const cascadedLoader = require('internal/modules/esm/loader').getOrInitializeCascadedLoader();
+      const job = cascadedLoader.importForEntryPoint(mainURL.href);
+      if (job === undefined) {
+        // Evaluated synchronously; no promise was created at any point.
+        return;
+      }
+      // Top-level await: evaluate through the usual asynchronous entry point path,
+      // reusing the job that was already resolved, linked and instantiated above.
+      return runEntryPointWithESMLoader(async () => {
+        // Begin evaluation inside a microtask, as `import()` does by awaiting the
+        // module job before running it, so that a top-level await in the entry point
+        // resumes in the same microtask drain instead of unwinding to the main stack
+        // and letting the `process.nextTick` queue run first.
+        await null;
+        // Await rather than returning the job's promise: resolving a promise *with* a
+        // thenable performs a `then` lookup on it, which user code can observe.
+        await job.run(true);
+      });
+    }
+
     runEntryPointWithESMLoader((cascadedLoader) => {
       // Note that if the graph contains unsettled TLA, this may never resolve
       // even after the event loop stops running.
diff --git a/test/es-module/test-esm-cjs-named-error-entrypoint.mjs b/test/es-module/test-esm-cjs-named-error-entrypoint.mjs
new file mode 100644
index 00000000000..e1939934d2c
--- /dev/null
+++ b/test/es-module/test-esm-cjs-named-error-entrypoint.mjs
@@ -0,0 +1,21 @@
+import '../common/index.mjs';
+import { spawnSyncAndAssert } from '../common/child_process.js';
+import * as fixtures from '../common/fixtures.mjs';
+import assert from 'assert';
+
+const entryPoint = fixtures.path('es-modules', 'package-cjs-named-error', 'single-quote.mjs');
+spawnSyncAndAssert(process.execPath, [entryPoint], {
+  status: 1,
+  stderr(stderr) {
+    assert(stderr.includes("Named export 'comeOn' not found."),
+           'entry point should show the missing named export');
+    assert(stderr.includes('CommonJS modules can always be imported via the default export'),
+           'entry point should show the CommonJS named export hint');
+    assert(stderr.includes("import pkg from './fail.cjs';"),
+           'entry point hint should recommend the default import');
+    assert(stderr.includes('const { comeOn } = pkg;'),
+           'entry point hint should show the named import as destructuring');
+    assert(stderr.includes("import { comeOn } from './fail.cjs';"),
+           'entry point error should include the source import statement');
+  },
+});
diff --git a/test/es-module/test-esm-sync-import.mjs b/test/es-module/test-esm-sync-import.mjs
new file mode 100644
index 00000000000..98896649104
--- /dev/null
+++ b/test/es-module/test-esm-sync-import.mjs
@@ -0,0 +1,40 @@
+// Flags: --no-warnings
+import '../common/index.mjs';
+import { spawnSyncAndAssert } from '../common/child_process.js';
+import * as fixtures from '../common/fixtures.mjs';
+import { describe, it } from 'node:test';
+import assert from 'node:assert';
+
+// Promise counts are implementation details tracked for regressions, not API guarantees.
+
+describe('synchronous ESM loading', () => {
+  it('should create minimal promises for ESM importing ESM', async () => {
+    const count = getPromiseCount(fixtures.path('es-modules', 'import-esm.mjs'));
+    assert.strictEqual(count, 1);
+  });
+
+  it('should create minimal promises for ESM importing CJS', async () => {
+    const count = getPromiseCount(fixtures.path('es-modules', 'builtin-imports-case.mjs'));
+    assert.strictEqual(count, 4);
+  });
+
+  it('should fall back to async evaluation for top-level await', async () => {
+    const count = getPromiseCount(fixtures.path('es-modules', 'tla', 'resolved.mjs'));
+    assert(count > 1, `Expected TLA fallback to create multiple promises, got ${count}`);
+  });
+
+  it('should create minimal promises when entry point is CJS importing ESM', async () => {
+    const count = getPromiseCount(fixtures.path('es-modules', 'require-esm-entry.cjs'));
+    assert.strictEqual(count, 1);
+  });
+});
+
+function getPromiseCount(entry) {
+  const { stderr } = spawnSyncAndAssert(process.execPath, [
+    '--trace-promises',
+    entry,
+  ], {
+    stderr: /created promise #/,
+  });
+  return stderr.match(/created promise #/g)?.length ?? 0;
+}
diff --git a/test/fixtures/es-modules/require-esm-entry.cjs b/test/fixtures/es-modules/require-esm-entry.cjs
new file mode 100644
index 00000000000..ce03a24bece
--- /dev/null
+++ b/test/fixtures/es-modules/require-esm-entry.cjs
@@ -0,0 +1 @@
+require('./imported-esm.mjs');
diff --git a/test/fixtures/source-map/output/source_map_assert_no_source_map.snapshot b/test/fixtures/source-map/output/source_map_assert_no_source_map.snapshot
index 0a2ea1a45fe..cc9ba484910 100644
--- a/test/fixtures/source-map/output/source_map_assert_no_source_map.snapshot
+++ b/test/fixtures/source-map/output/source_map_assert_no_source_map.snapshot
@@ -1,6 +1,6 @@
-node:internal/modules/run_main:<line>
-    triggerUncaughtException(
-    ^
+node:internal/modules/esm/module_job:<line>
+    return this.module.evaluateSync();
+                       ^

 AssertionError [ERR_ASSERTION]: The expression evaluated to a falsy value:

diff --git a/test/parallel/test-inspector-debug-brk-flag-esm-tla.js b/test/parallel/test-inspector-debug-brk-flag-esm-tla.js
new file mode 100644
index 00000000000..b03c2dd25ca
--- /dev/null
+++ b/test/parallel/test-inspector-debug-brk-flag-esm-tla.js
@@ -0,0 +1,46 @@
+'use strict';
+const common = require('../common');
+
+// Test that --inspect-brk pauses at the first executable line of an ESM entry
+// point that uses top-level await (which takes the async evaluation path).
+
+common.skipIfInspectorDisabled();
+
+const assert = require('assert');
+const fixtures = require('../common/fixtures');
+const { NodeInstance } = require('../common/inspector-helper.js');
+
+async function testBreakpointOnStart(session) {
+  const commands = [
+    { 'method': 'Runtime.enable' },
+    { 'method': 'Debugger.enable' },
+    { 'method': 'Debugger.setPauseOnExceptions',
+      'params': { 'state': 'none' } },
+    { 'method': 'Debugger.setAsyncCallStackDepth',
+      'params': { 'maxDepth': 0 } },
+    { 'method': 'Debugger.setBlackboxPatterns',
+      'params': { 'patterns': [] } },
+    { 'method': 'Runtime.runIfWaitingForDebugger' },
+  ];
+
+  await session.send({ method: 'NodeRuntime.enable' });
+  await session.waitForNotification('NodeRuntime.waitingForDebugger');
+  await session.send(commands);
+  await session.send({ method: 'NodeRuntime.disable' });
+  // Static import declarations are not executable statements, so the break
+  // happens at the first executable line (await setTimeout(9), line index 4).
+  await session.waitForBreakOnLine(4, session.scriptURL());
+}
+
+async function runTests() {
+  const child = new NodeInstance(['--inspect-brk=0'], '',
+                                 fixtures.path('es-modules', 'esm-top-level-await.mjs'));
+  const session = await child.connectInspectorSession();
+
+  await testBreakpointOnStart(session);
+  await session.runToCompletion();
+
+  assert.strictEqual((await child.expectShutdown()).exitCode, 0);
+}
+
+runTests().then(common.mustCall());