Repository: electron/electron
Pull request: feat: dynamic ESM import in preload without context isolation

#### Description of Change

Extend `HostImportModuleWithPhaseDynamically`'s routing to support Node.js import resolution in non-context-isolated preloads through `v8_host_defined_options` length check. The length of host defined options is distinct between Blink and Node.js and we can use it to determine which resolver to use.
#### Checklist
<!-- Remove items that do not apply. For completed items, change [ ] to [x]. -->

- [x] PR description included and stakeholders cc'd
- [x] `npm test` passes
- [x] tests are [changed or added](https://github.com/electron/electron/blob/main/docs/development/testing.md)
- [x] relevant API documentation, tutorials, and examples are updated and follow the [documentation style guide](https://github.com/electron/electron/blob/main/docs/development/style-guide.md)
- [x] [PR release notes](https://github.com/electron/clerk/blob/main/README.md) describe the change in a way relevant to app developers, and are [capitalized, punctuated, and past tense](https://github.com/electron/clerk/blob/main/README.md#examples).

#### Release Notes

Notes: Support dynamic ESM imports in non-context isolated preloads

cc @MarshallOfSound 

Changed files:
filename: docs/tutorial/esm.md
patch: @@ -32,7 +32,7 @@ This table gives a general overview of where ESM is supported and which ESM load
 | Main                 | Node.js    | N/A                   | <ul><li> [You must use `await` generously before the app's `ready` event](#you-must-use-await-generously-before-the-apps-ready-event) </li></ul> |
 | Renderer (Sandboxed) | Chromium   | Unsupported           | <ul><li> [Sandboxed preload scripts can't use ESM imports](#sandboxed-preload-scripts-cant-use-esm-imports) </li></ul> |
 | Renderer (Unsandboxed & Context Isolated) | Chromium | Node.js | <ul><li> [Unsandboxed ESM preload scripts will run after page load on pages with no content](#unsandboxed-esm-preload-scripts-will-run-after-page-load-on-pages-with-no-content) </li> <li>[ESM Preload Scripts must have the `.mjs` extension](#esm-preload-scripts-must-have-the-mjs-extension)</li></ul> |
-| Renderer (Unsandboxed & Non Context Isolated) | Chromium | Node.js | <ul><li>[Unsandboxed ESM preload scripts will run after page load on pages with no content](#unsandboxed-esm-preload-scripts-will-run-after-page-load-on-pages-with-no-content)</li><li>[ESM Preload Scripts must have the `.mjs` extension](#esm-preload-scripts-must-have-the-mjs-extension)</li><li>[ESM preload scripts must be context isolated to use dynamic Node.js ESM imports](#esm-preload-scripts-must-be-context-isolated-to-use-dynamic-nodejs-esm-imports)</li></ul> |
+| Renderer (Unsandboxed & Non Context Isolated) | Chromium | Node.js | <ul><li>[Unsandboxed ESM preload scripts will run after page load on pages with no content](#unsandboxed-esm-preload-scripts-will-run-after-page-load-on-pages-with-no-content)</li><li>[ESM Preload Scripts must have the `.mjs` extension](#esm-preload-scripts-must-have-the-mjs-extension)</li></ul> |
 
 ## Main process
 

filename: patches/chromium/.patches
patch: @@ -142,3 +142,4 @@ chore_expose_isolate_parameter_in_script_lifecycle_observers.patch
 revert_partial_remove_unused_prehandlemouseevent.patch
 allow_electron_to_depend_on_components_os_crypt_sync.patch
 disable_nsautofillheuristiccontroller_on_macos_26.patch
+expose_referrerscriptinfo_hostdefinedoptionsindex.patch

filename: patches/chromium/expose_referrerscriptinfo_hostdefinedoptionsindex.patch
patch: @@ -0,0 +1,53 @@
+From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
+From: Fedor Indutny <indutny@signal.org>
+Date: Wed, 24 Sep 2025 10:08:48 -0700
+Subject: Expose ReferrerScriptInfo::HostDefinedOptionsIndex
+
+In `shell/common/node_bindings.cc`'s
+`HostImportModuleWithPhaseDynamically` we route dynamic imports to
+either Node.js's or Blink's resolver based on presence of Node.js
+environment, process type, etc. Exporting `HostDefinedOptionsIndex`
+allows us to route based on the size of `v8_host_defined_options` data
+which enables us to support dynamic imports in non-context-isolated
+preload scripts.
+
+diff --git a/third_party/blink/renderer/bindings/core/v8/referrer_script_info.cc b/third_party/blink/renderer/bindings/core/v8/referrer_script_info.cc
+index 1b797783987255622735047bd78ca0e8bb635d5e..b209c736bb80c186ed51999af1dac0a1d50fc232 100644
+--- a/third_party/blink/renderer/bindings/core/v8/referrer_script_info.cc
++++ b/third_party/blink/renderer/bindings/core/v8/referrer_script_info.cc
+@@ -12,15 +12,6 @@ namespace blink {
+ 
+ namespace {
+ 
+-enum HostDefinedOptionsIndex : size_t {
+-  kBaseURL,
+-  kCredentialsMode,
+-  kNonce,
+-  kParserState,
+-  kReferrerPolicy,
+-  kLength
+-};
+-
+ // Omit storing base URL if it is same as ScriptOrigin::ResourceName().
+ // Note: This improves chance of getting into a fast path in
+ //       ReferrerScriptInfo::ToV8HostDefinedOptions.
+diff --git a/third_party/blink/renderer/bindings/core/v8/referrer_script_info.h b/third_party/blink/renderer/bindings/core/v8/referrer_script_info.h
+index 0119624a028bec3e53e4e402938a98fe6def1483..743865839448748fe00e3e7d5027587cb65393c9 100644
+--- a/third_party/blink/renderer/bindings/core/v8/referrer_script_info.h
++++ b/third_party/blink/renderer/bindings/core/v8/referrer_script_info.h
+@@ -23,6 +23,15 @@ class CORE_EXPORT ReferrerScriptInfo {
+   STACK_ALLOCATED();
+ 
+  public:
++  enum HostDefinedOptionsIndex : size_t {
++    kBaseURL,
++    kCredentialsMode,
++    kNonce,
++    kParserState,
++    kReferrerPolicy,
++    kLength
++  };
++
+   ReferrerScriptInfo() {}
+   ReferrerScriptInfo(const KURL& base_url,
+                      network::mojom::CredentialsMode credentials_mode,

filename: shell/common/node_bindings.cc
patch: @@ -24,6 +24,7 @@
 #include "base/trace_event/trace_event.h"
 #include "chrome/common/chrome_version.h"
 #include "content/public/common/content_paths.h"
+#include "content/public/renderer/render_frame.h"
 #include "electron/buildflags/buildflags.h"
 #include "electron/electron_version.h"
 #include "electron/fuses.h"
@@ -41,7 +42,9 @@
 #include "shell/common/node_util.h"
 #include "shell/common/process_util.h"
 #include "shell/common/world_ids.h"
+#include "third_party/blink/public/common/web_preferences/web_preferences.h"
 #include "third_party/blink/public/web/web_local_frame.h"
+#include "third_party/blink/renderer/bindings/core/v8/referrer_script_info.h"  // nogncheck
 #include "third_party/blink/renderer/bindings/core/v8/v8_initializer.h"  // nogncheck
 #include "third_party/electron_node/src/debug_utils.h"
 #include "third_party/electron_node/src/module_wrap.h"
@@ -211,40 +214,84 @@ bool AllowWasmCodeGenerationCallback(v8::Local<v8::Context> context,
   return node::AllowWasmCodeGenerationCallback(context, source);
 }
 
+enum ESMHandlerPlatform {
+  kNone,
+  kNodeJS,
+  kBlink,
+};
+
+static ESMHandlerPlatform SelectESMHandlerPlatform(
+    v8::Local<v8::Context> context,
+    v8::Local<v8::Data> raw_host_defined_options) {
+  if (node::Environment::GetCurrent(context) == nullptr) {
+    if (electron::IsBrowserProcess() || electron::IsUtilityProcess())
+      return ESMHandlerPlatform::kNone;
+
+    return ESMHandlerPlatform::kBlink;
+  }
+
+  if (!electron::IsRendererProcess())
+    return ESMHandlerPlatform::kNodeJS;
+
+  blink::WebLocalFrame* frame = blink::WebLocalFrame::FrameForContext(context);
+
+  if (frame == nullptr)
+    return ESMHandlerPlatform::kBlink;
+
+  auto prefs = content::RenderFrame::FromWebFrame(frame)->GetBlinkPreferences();
+
+  // If we're running with contextIsolation enabled in the renderer process,
+  // fall back to Blink's logic when the frame is not in the isolated world.
+  if (prefs.context_isolation) {
+    return frame->GetScriptContextWorldId(context) ==
+                   electron::WorldIDs::ISOLATED_WORLD_ID
+               ? ESMHandlerPlatform::kNodeJS
+               : ESMHandlerPlatform::kBlink;
+  }
+
+  if (raw_host_defined_options.IsEmpty() ||
+      !raw_host_defined_options->IsFixedArray()) {
+    return ESMHandlerPlatform::kBlink;
+  }
+
+  // Since the routing is based on the `host_defined_options` length -
+  // make sure that Node's host defined options are different from Blink's.
+  static_assert(
+      static_cast<size_t>(node::loader::HostDefinedOptions::kLength) !=
+      blink::ReferrerScriptInfo::HostDefinedOptionsIndex::kLength);
+
+  // Use Node.js resolver only if host options were created by it.
+  auto options = v8::Local<v8::FixedArray>::Cast(raw_host_defined_options);
+  if (options->Length() == node::loader::HostDefinedOptions::kLength) {
+    return ESMHandlerPlatform::kNodeJS;
+  }
+
+  return ESMHandlerPlatform::kBlink;
+}
+
 v8::MaybeLocal<v8::Promise> HostImportModuleWithPhaseDynamically(
     v8::Local<v8::Context> context,
     v8::Local<v8::Data> v8_host_defined_options,
     v8::Local<v8::Value> v8_referrer_resource_url,
     v8::Local<v8::String> v8_specifier,
     v8::ModuleImportPhase import_phase,
     v8::Local<v8::FixedArray> v8_import_attributes) {
-  if (node::Environment::GetCurrent(context) == nullptr) {
-    if (electron::IsBrowserProcess() || electron::IsUtilityProcess())
-      return {};
-    return blink::V8Initializer::HostImportModuleWithPhaseDynamically(
-        context, v8_host_defined_options, v8_referrer_resource_url,
-        v8_specifier, import_phase, v8_import_attributes);
-  }
-
-  // If we're running with contextIsolation enabled in the renderer process,
-  // fall back to Blink's logic.
-  if (electron::IsRendererProcess()) {
-    blink::WebLocalFrame* frame =
-        blink::WebLocalFrame::FrameForContext(context);
-    if (!frame || frame->GetScriptContextWorldId(context) !=
-                      electron::WorldIDs::ISOLATED_WORLD_ID) {
+  switch (SelectESMHandlerPlatform(context, v8_host_defined_options)) {
+    case ESMHandlerPlatform::kBlink:
       return blink::V8Initializer::HostImportModuleWithPhaseDynamically(
           context, v8_host_defined_options, v8_referrer_resource_url,
           v8_specifier, import_phase, v8_import_attributes);
-    }
+    case ESMHandlerPlatform::kNodeJS:
+      // TODO: Switch to node::loader::ImportModuleDynamicallyWithPhase
+      // once we land the Node.js version that has it in upstream.
+      CHECK(import_phase == v8::ModuleImportPhase::kEvaluation);
+      return node::loader::ImportModuleDynamically(
+          context, v8_host_defined_options, v8_referrer_resource_url,
+          v8_specifier, v8_import_attributes);
+    case ESMHandlerPlatform::kNone:
+    default:
+      return {};
   }
-
-  // TODO: Switch to node::loader::ImportModuleDynamicallyWithPhase
-  // once we land the Node.js version that has it in upstream.
-  CHECK(import_phase == v8::ModuleImportPhase::kEvaluation);
-  return node::loader::ImportModuleDynamically(
-      context, v8_host_defined_options, v8_referrer_resource_url, v8_specifier,
-      v8_import_attributes);
 }
 
 v8::MaybeLocal<v8::Promise> HostImportModuleDynamically(

filename: spec/esm-spec.ts
patch: @@ -130,6 +130,17 @@ describe('esm', () => {
     }
 
     describe('nodeIntegration', () => {
+      let badFilePath = '';
+
+      beforeEach(async () => {
+        badFilePath = path.resolve(path.resolve(os.tmpdir(), 'bad-file.badjs'));
+        await fs.promises.writeFile(badFilePath, 'const foo = "bar";');
+      });
+
+      afterEach(async () => {
+        await fs.promises.unlink(badFilePath);
+      });
+
       it('should support an esm entrypoint', async () => {
         const [webContents] = await loadWindowWithPreload('import { resolve } from "path"; window.resolvePath = resolve;', {
           nodeIntegration: true,
@@ -189,24 +200,25 @@ describe('esm', () => {
           expect(error?.message).to.include('Failed to fetch dynamically imported module');
         });
 
+        it('should use Node.js ESM dynamic loader in the preload', async () => {
+          const [, preloadError] = await loadWindowWithPreload(`await import(${JSON.stringify((pathToFileURL(badFilePath)))})`, {
+            nodeIntegration: true,
+            sandbox: false,
+            contextIsolation: false
+          });
+
+          expect(preloadError).to.not.equal(null);
+          // This is a node.js specific error message
+          expect(preloadError!.toString()).to.include('Unknown file extension');
+        });
+
         it('should use import.meta callback handling from Node.js for Node.js modules', async () => {
           const result = await runFixture(path.resolve(fixturePath, 'import-meta'));
           expect(result.code).to.equal(0);
         });
       });
 
       describe('with context isolation', () => {
-        let badFilePath = '';
-
-        beforeEach(async () => {
-          badFilePath = path.resolve(path.resolve(os.tmpdir(), 'bad-file.badjs'));
-          await fs.promises.writeFile(badFilePath, 'const foo = "bar";');
-        });
-
-        afterEach(async () => {
-          await fs.promises.unlink(badFilePath);
-        });
-
         it('should use Node.js ESM dynamic loader in the isolated context', async () => {
           const [, preloadError] = await loadWindowWithPreload(`await import(${JSON.stringify((pathToFileURL(badFilePath)))})`, {
             nodeIntegration: true,