Skip to content

ModuleMap: key the module registry on the host-defined import attribute string - #258

Open
robobun wants to merge 1 commit into
mainfrom
farm/5cc6cbfd/module-map-import-attribute-key
Open

ModuleMap: key the module registry on the host-defined import attribute string#258
robobun wants to merge 1 commit into
mainfrom
farm/5cc6cbfd/module-map-import-attribute-key

Conversation

@robobun

@robobun robobun commented Jun 28, 2026

Copy link
Copy Markdown
Collaborator

What

Importing the same file with two different with { type: ... } attributes returns one shared module; whichever import evaluated first decides what both callers get, and reversing the import order flips the result.

// data.txt contains "hello\n"
const a = await import("./data.txt", { with: { type: "text" } });
const b = await import("./data.txt", { with: { type: "file" } });
console.log(a.default, b.default, a === b);
// today:    "hello\n" "hello\n" true   (reverse the order: both become the file path)
// expected: "hello\n" "/abs/data.txt" false

Why

ModuleMapKey is (UniquedStringImpl* specifier, ScriptFetchParameters::Type). ScriptFetchParameters::parseType maps every non-json / non-webassembly attribute value onto the single Type::HostDefined enum value, so with { type: "text" } and with { type: "file" } hash to the same bucket (ModuleMap.h:34-49). The string that distinguishes them already exists on ScriptFetchParameters::m_hostDefinedImportType; it just never reaches the key, the hash, or the equality check.

The tc39 import-attributes proposal requires the same specifier imported with different attribute lists to designate different modules. The existing (specifier, Type) key was already trying to do that; it just cannot tell the HostDefined types apart.

How

  • ModuleMapKey becomes std::tuple<UniquedStringImpl*, ScriptFetchParameters::Type, String>. The third component is the host-defined attribute string, null for every non-HostDefined type.
  • All key construction goes through makeModuleMapKey() / ModuleRequest::moduleMapKey(), so a key can never be built with a dropped attribute string, and (key, JSON, "json") vs (key, JSON, null) can never diverge.
  • ensureRegistered / getRegisteredMayBeNull take a const ScriptFetchParameters* so the string reaches the key.
  • provideFetch's existing (key, Type, source) overloads keep their signature; every caller is an attribute-less entry point. New (key, Type, const ScriptFetchParameters*, source) overloads exist for the one internal path that registers a HostDefined entry, the post-fetch registration in moduleLoadTopSettled.
  • AbstractModuleRecord::ModuleRequest::operator== (the spec's ModuleRequestsEqual) compared only the Type enum and had the same gap; it now compares the host-defined string too.
  • The per-referrer [[LoadedModules]] maps and the realm-level map use the same ModuleMapKey, so they are fixed by the same change.
  • AbstractModuleRecord::hostResolveImportedModule() (which Remove the December 2025 LTO de-inlining stopgap now that WTF::opaque() is volatile #403 moved onto [[LoadedModules]] keyed by (specifier, Type)) receives only the attribute Type from import/export entries, so for HostDefined it recovers the full key from the record's own [[RequestedModules]] (source order). A binding therefore resolves to the module's first request for that specifier and type, which is the module the previous specifier-keyed lookup produced; non-HostDefined lookups are unchanged.
  • ModuleAnalyzer::appendRequestedModule() dedupes requested modules with the same key, so it builds it the same way: two imports of one specifier with different host-defined types now both reach [[RequestedModules]] and both get loaded, instead of the second being dropped at parse time.
  • makeModuleMapKey() asserts that a HostDefined key is never built without its parameters, so the invariant is enforced at the one chokepoint rather than at individual callers (the earlier per-overload asserts in provideFetch are gone).

registryEntry() / removeEntry() / clearAll() already match on the specifier component only and are unaffected beyond the std::get<0> spelling.

Embedder impact

ModuleMapKey gained a third component, so code that brace-constructs one from { impl, type } no longer compiles; it should use ModuleRequest::moduleMapKey() or makeModuleMapKey(). Bun has three such sites (one key construction in NodeVMSourceTextModule.cpp, two diagnostic .first reads in InspectorLifecycleAgent.cpp / ZigGlobalObject.cpp); oven-sh/bun#32999 fixes them together with the WEBKIT_VERSION bump. provideFetch(key, Type, source) callers are unchanged.

What this fixes, and what it does not

Dynamic import() is fully fixed: the registry is the only lookup on that path, so two dynamic imports of one path with different attributes return two distinct modules regardless of order.

Static imports are fixed except for one narrow case. With #403's moduleRequestType on import/export entries plus this PR, a static import or re-export with { type: "text" } loads and binds the right module, and two differently-attributed imports of different paths are independent. What remains is two static imports of the same path with different host-defined types inside one module: both modules now load (see the ModuleAnalyzer change), but the entries carry only the Type, so both bindings resolve to the first such request. Finishing that means carrying the attribute string through ImportEntry, ExportEntry and the star-export set, plus the embedder's add*Entry bindings (seven FFI entry points on Bun's side), and is a separate PR; this one makes the loader hold the distinct modules it will bind to.

Other out-of-scope notes

  • Only the type attribute participates in the key (it is the only attribute that decides what a module is). with { type: "sqlite", embed: "true" } vs with { type: "sqlite" } still share an entry; ScriptFetchParameters::m_attributes is not populated consistently enough to key on today.
  • JSModuleLoader::ModuleFailure still tags errors with (key, Type). That only matters when two HostDefined variants of the same path are both real executing modules that both throw, and the worst outcome is a setEvaluationError vs setFetchError misclassification.

Verification

I will point a Bun build at this PR's preview artifact and run a two-attribute dynamic import test end to end, then link the Bun PR here. Bun's bundler has the same collapse in its own module graph and is being reported separately on the Bun side.

@coderabbitai

coderabbitai Bot commented Jun 28, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 39 minutes

Limit details: You’ve used all 5 included reviews currently available under your plan.

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: dd7208cf-68f8-41d8-a3b9-5c9d41b4899b

📥 Commits

Reviewing files that changed from the base of the PR and between 0cbb4a1 and b30b5ab.

📒 Files selected for processing (7)
  • Source/JavaScriptCore/parser/ModuleAnalyzer.cpp
  • Source/JavaScriptCore/runtime/AbstractModuleRecord.cpp
  • Source/JavaScriptCore/runtime/AbstractModuleRecord.h
  • Source/JavaScriptCore/runtime/JSMicrotask.cpp
  • Source/JavaScriptCore/runtime/JSModuleLoader.cpp
  • Source/JavaScriptCore/runtime/JSModuleLoader.h
  • Source/JavaScriptCore/runtime/ModuleMap.h

Walkthrough

Changes

Module map keys now include host-defined import attributes. Registry and module-loading operations propagate these parameters through lookup, registration, fetch, evaluation, and imported-module tracking paths.

Attribute-aware module loading

Layer / File(s) Summary
Module map key contract
Source/JavaScriptCore/runtime/ModuleMap.h, Source/JavaScriptCore/runtime/AbstractModuleRecord.*
ModuleMapKey includes the specifier, fetch type, and host-defined attribute string; ModuleRequest centralizes key construction and attribute-aware equality.
Attribute-aware registry plumbing
Source/JavaScriptCore/runtime/JSModuleLoader.*
Registry lookup, registration, fetch provisioning, evaluation, and removal use parameter-aware module map keys.
Import request propagation
Source/JavaScriptCore/runtime/JSModuleLoader.cpp, Source/JavaScriptCore/runtime/JSMicrotask.cpp
Imported-module tracking and microtask fetch/error paths use canonical request keys and pass request attributes to loader operations.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description explains the bug, implementation, scope, limitations, and verification, but it omits the required Bugzilla link, review line, and changed-file list. Add the required bug title and Bugzilla URL, “Reviewed by NOBODY (OOPS!).”, and the formatted explanation and changed-file entries from the repository template.
✅ Passed checks (3 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely describes the main change: adding the host-defined import attribute string to module map keys.

Comment @coderabbitai help to get the list of available commands.

Comment thread Source/JavaScriptCore/runtime/AbstractModuleRecord.cpp
@github-actions

github-actions Bot commented Jun 28, 2026

Copy link
Copy Markdown

Preview Builds

Commit Release Date
b30b5abf autobuild-preview-pr-258-b30b5abf 2026-08-16 19:47:09 UTC
a024b4c8 autobuild-preview-pr-258-a024b4c8 2026-08-13 00:09:39 UTC
41bae541 autobuild-preview-pr-258-41bae541 2026-07-14 09:19:03 UTC
9435ca11 autobuild-preview-pr-258-9435ca11 2026-07-01 10:06:16 UTC
adc8aebb autobuild-preview-pr-258-adc8aebb 2026-06-28 05:42:00 UTC

@robobun

robobun commented Jun 28, 2026

Copy link
Copy Markdown
Collaborator Author

End-to-end verification against this PR's preview artifact (autobuild-preview-pr-258-adc8aebb) is up in oven-sh/bun#32999, which bumps Bun's WEBKIT_VERSION to it and adds the regression test.

  • USE_SYSTEM_BUN=1 bun test test/js/bun/resolve/import-attributes.test.ts (released Bun, unkeyed registry): 0 pass, 4 fail
  • bun bd test test/js/bun/resolve/import-attributes.test.ts against this PR's WebKit: 4 pass, 0 fail

Bun's existing module loader suite (import-empty, import-defer, import-query, esModule, regression/issue/16476, require-esm-gc-roots, require-esm-microtask-order) also passes against it.

For the record, the one red lane in the first Preview Build attempt (bun-webkit-windows-amd64-debug) was a runner setup flake: its build step ran for 46 seconds before failing, versus 12 minutes for a successful run, and re-running just that job passed with no changes. Every other lane, including the Linux and macOS debug + ASAN ones, was green on the first attempt.

robobun added a commit to oven-sh/bun that referenced this pull request Jun 28, 2026
Importing the same path with two different `with { type }` attributes
returned one shared module: JSC's ModuleMapKey was
(specifier, ScriptFetchParameters::Type), and every Bun-defined attribute
value ("text", "file", "toml", "html", ...) maps onto the single
Type::HostDefined enum value, so whichever import evaluated first decided
what both callers got.

The key now also carries the host-defined attribute string. The fix lives
in oven-sh/WebKit (oven-sh/WebKit#258); this commit
bumps WEBKIT_VERSION, updates Bun's three ModuleMapKey users to the widened
key (NodeVMSourceTextModule uses the new ModuleRequest::moduleMapKey()
helper; two diagnostic iterations switch from .first to std::get<0>), and
adds the regression test, including the HTML reproduction from issue 19834.

Fixes #19834
@robobun

robobun commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator Author

Cross-referencing a sibling bug found while reproducing a concurrent variant of this one. moduleLoadTopSettled's rejected branch drops a non-ErrorInstance rejection value entirely (the classification is inside the dynamicDowncast<ErrorInstance> block), so moduleLoadTopRejected falls back to setEvaluationError. Bun rejects a single-message transpile failure with a BuildMessage, which is not an ErrorInstance, so this is the common case. It strands later importers of the same key with no import attributes involved at all:

// bad.ts contains `import {`  (one parser error -> a BuildMessage)
// other.ts contains `import "./bad.ts"`
await import("./bad.ts").catch(() => {});
await import("./other.ts"); // never settles

Fixed separately in #262; it is independent of this PR (this one threads the attribute into the ensureRegistered call on that line but keeps the ErrorInstance gate, and the hang needs no attributes). The diffs apply cleanly together.

Two data points that may be useful here:

  1. The ASSERTION FAILED: m_status == Status::Fetching that shows up on an asserts build when two import forms of one path race (the toml loader failing, the text loader succeeding) comes from the setEvaluationError fallthrough, not from the key collapse: setEvaluationError puts the shared entry in a state fetchComplete does not guard, while setFetchError lands it at FetchFailed, which fetchComplete already early-returns on. Either PR stops that assert (this one by splitting the entries, ModuleLoader: record a non-Error fetch rejection as a fetch failure #262 by classifying the error), but ModuleLoader: record a non-Error fetch rejection as a fetch failure #262 is the one that fixes the underlying mis-recording.

  2. I built JSCOnly from source locally with both diffs applied and re-ran the reproductions for each: the attribute collapse cases here and ModuleLoader: record a non-Error fetch rejection as a fetch failure #262's hang both pass together, and a build with only ModuleLoader: record a non-Error fetch rejection as a fetch failure #262 leaves the attribute collapse this PR fixes unchanged, so neither subsumes the other.

Also confirmed empirically that the static-import gap described in "What this fixes, and what it does not" is real: with both diffs, import text from "./x.toml" with { type: "text" }; import toml from "./x.toml" with { type: "toml" } still binds both names to one module (the m_dependencies / hostResolveImportedModule path this PR documents). The analysis in this PR's body is right to scope that out.

Jarred-Sumner pushed a commit that referenced this pull request Jun 30, 2026
…262)

In moduleLoadTopSettled's rejected branch, the entire fetch/evaluation-error
classification is gated on dynamicDowncast<ErrorInstance>. Bun rejects a
single-message transpile failure with a BuildMessage, which is a plain
JSDestructibleObject, so nothing is recorded on the registry entry at all.
moduleLoadTopRejected then falls through its fetchError() check and records
the value with setEvaluationError.

That leaves the entry at EvaluationFailed with no fetch, module, or load
promise. The next importer of the same key that goes through
hostLoadImportedModule (a static import, or a dynamic import inside another
module's graph load) finds a non-New entry with no fetchError and no
loadPromise, creates a fresh pending fetchPromise via ensureModulePromise,
and waits on it forever. Nothing ever settles it.

  // bad.ts contains "import {" (exactly one parser error, so the
  // rejection value is a BuildMessage, not an AggregateError)
  await import("./bad.ts").catch(() => {});
  await import("./other.ts"); // other.ts statically imports bad.ts; hangs

This microtask reacts to the embedder fetch promise, so every rejection that
reaches it is a fetch failure by construction: the promise is either a fresh
fetch() or a cached entry->ensureFetchPromise(), and the only rejector of an
entry's fetch promise is setFetchError. Add the else branch so a non-Error
rejection value is recorded the same way. This matches what the sibling
nested-import path already does: moduleRegistryFetchSettled calls
setFetchError unconditionally and only gates the attachErrorInfo metadata on
the value being an ErrorInstance.

With the entry at FetchFailed, moduleLoadTopRejected rejects with the cached
fetchError() instead of calling setEvaluationError, and later importers take
hostLoadImportedModule's fetchError short circuit instead of parking on a
promise nothing settles.

Independent of #258 (the import-attribute module map key):
that change threads the attribute into ensureRegistered here but keeps the
ErrorInstance gate, and this hang reproduces with no import attributes at
all. The two apply cleanly together.
@robobun
robobun force-pushed the farm/5cc6cbfd/module-map-import-attribute-key branch from adc8aeb to 9435ca1 Compare July 1, 2026 09:30
robobun added a commit to oven-sh/bun that referenced this pull request Jul 1, 2026
Importing the same path with two different `with { type }` attributes
returned one shared module: JSC's ModuleMapKey was
(specifier, ScriptFetchParameters::Type), and every Bun-defined attribute
value ("text", "file", "toml", "html", ...) maps onto the single
Type::HostDefined enum value, so whichever import evaluated first decided
what both callers got.

The key now also carries the host-defined attribute string. The fix lives
in oven-sh/WebKit (oven-sh/WebKit#258); this commit
bumps WEBKIT_VERSION, updates Bun's three ModuleMapKey users to the widened
key (NodeVMSourceTextModule uses the new ModuleRequest::moduleMapKey()
helper; two diagnostic iterations switch from .first to std::get<0>), and
adds the regression test, including the HTML reproduction from issue 19834.

Fixes #19834
@robobun

robobun commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto main (c9ad581, the d81bcc3d833c upstream WebKit upgrade). The cherry-pick was clean: of the six files this PR touches, only JSMicrotask.cpp changed in the intervening range (#262's setFetchError branch, +5 lines) and it auto-merged. The other five (ModuleMap.h, JSModuleLoader.{h,cpp}, AbstractModuleRecord.{h,cpp}) were byte-identical across the upgrade. No new callers of the widened registry API were introduced.

The new preview tag is autobuild-preview-pr-258-9435ca11 (all lanes green on the first attempt this time). oven-sh/bun#32999 is rebased onto Bun main, pinned to it, and re-verified end to end: USE_SYSTEM_BUN=1 bun test test/js/bun/resolve/import-attributes.test.ts is 0 pass, 6 fail; bun bd test against this PR's artifact is 6 pass, 0 fail; and 48 existing module-loader tests pass against it.

@robobun
robobun force-pushed the farm/5cc6cbfd/module-map-import-attribute-key branch from 9435ca1 to 41bae54 Compare July 14, 2026 08:31

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for reworking the description — the new "What this fixes, and what it does not" section fully addresses my earlier note on static imports, and the end-to-end verification against the preview artifact is convincing. I didn't find any correctness issues on this pass, but this rekeys the module registry and widens ensureRegistered/provideFetch in a way that requires a coordinated embedder change, so I'd like a human to sign off.

What was reviewed:

  • makeModuleMapKey normalization — non-HostDefined types always get a null String, so (key, JSON, params) and (key, JSON, nullptr) cannot diverge; ModuleMapHash::equal uses WTF String::operator== (content compare, null==null).
  • All former ModuleMapKey{impl, type} / .first sites now go through moduleMapKey() / makeModuleMapKey() / std::get<0>; m_resolutionFailures (still a std::pair key) correctly kept .first/.second.
  • ModuleRequest::operator=='s new hostDefinedImportType() compare — for non-HostDefined params both sides return null String, so behavior is unchanged there.
  • provideFetch Type-only overloads assert non-HostDefined and every existing caller passes an entry-point/attribute-less type.
Extended reasoning...

Overview

Widens ModuleMapKey from std::pair<UniquedStringImpl*, ScriptFetchParameters::Type> to std::tuple<..., ..., String>, adding the host-defined with { type: "..." } attribute string as a third key component so that e.g. type: "text" and type: "file" (both Type::HostDefined) land in distinct registry buckets. Threads const ScriptFetchParameters* through ensureRegistered / getRegisteredMayBeNull / new provideFetch overloads, centralizes key construction in makeModuleMapKey() / ModuleRequest::moduleMapKey(), updates ModuleMapHash and ModuleRequest::operator==, and adjusts every construction/lookup site in JSModuleLoader.{h,cpp}, JSMicrotask.cpp, and AbstractModuleRecord.{h,cpp}. Six files changed.

Security risks

None identified. This is a module-identity/caching change; it does not touch auth, sandboxing, memory layout, or untrusted-input parsing. The added String in the key is the already-parsed hostDefinedImportType from ScriptFetchParameters, hashed via StringImpl::hash().

Level of scrutiny

High. The module registry is on the hot path for every ESM import in Bun, the key shape governs module identity per the tc39 import-attributes proposal, and the ModuleMapKey type change is embedder-visible (Bun's NodeVMSourceTextModule.cpp must be updated in lockstep with the WEBKIT_VERSION bump). Hash/equality changes on a core map are exactly the kind of thing where a subtle mismatch (e.g. two spellings of "no attribute") silently splits or merges cache entries. The makeModuleMapKey helper is designed to prevent that, and I traced every call site, but a maintainer familiar with the loader's history should confirm the API shape and the !USE(BUN_JSC_ADDITIONS) behavior (where the third component is always null and the change is effectively a no-op).

Other factors

  • My earlier inline comment (static-import binding path not fixed) was addressed by rewriting the description to explicitly scope it out; the comment is resolved and robobun independently confirmed the analysis empirically.
  • End-to-end verified via oven-sh/bun#32999 against both preview artifacts: 6/6 new attribute tests pass, 48 existing module-loader tests pass, all CI lanes (incl. debug + ASAN) green after rebase.
  • No bugs found by the automated bug-hunting pass on this revision.
  • safeToCompareToEmptyOrDeleted = false was preserved, appropriate now that the key carries a String.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Source/JavaScriptCore/runtime/ModuleMap.h`:
- Around line 48-57: The HostDefined/null-parameters invariant is not enforced
at the ModuleMapKey construction chokepoint. In
Source/JavaScriptCore/runtime/ModuleMap.h#L48-L57, add an ASSERT_WITH_MESSAGE in
makeModuleMapKey’s fallback path rejecting HostDefined when parameters is null;
in Source/JavaScriptCore/runtime/JSModuleLoader.cpp#L337-L360, the existing
caller asserts are redundant and may be removed or retained as early
diagnostics.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 422fab78-2fbf-4367-9d56-ebcf05b60b8e

📥 Commits

Reviewing files that changed from the base of the PR and between 4895f45 and 41bae54.

📒 Files selected for processing (6)
  • Source/JavaScriptCore/runtime/AbstractModuleRecord.cpp
  • Source/JavaScriptCore/runtime/AbstractModuleRecord.h
  • Source/JavaScriptCore/runtime/JSMicrotask.cpp
  • Source/JavaScriptCore/runtime/JSModuleLoader.cpp
  • Source/JavaScriptCore/runtime/JSModuleLoader.h
  • Source/JavaScriptCore/runtime/ModuleMap.h

Comment thread Source/JavaScriptCore/runtime/ModuleMap.h
robobun added a commit to oven-sh/bun that referenced this pull request Jul 14, 2026
Importing the same path with two different `with { type }` attributes
returned one shared module: JSC's ModuleMapKey was
(specifier, ScriptFetchParameters::Type), and every Bun-defined attribute
value ("text", "file", "toml", "html", ...) maps onto the single
Type::HostDefined enum value, so whichever import evaluated first decided
what both callers got.

The key now also carries the host-defined attribute string. The fix lives
in oven-sh/WebKit (oven-sh/WebKit#258); this commit
bumps WEBKIT_VERSION, updates Bun's three ModuleMapKey users to the widened
key (NodeVMSourceTextModule uses the new ModuleRequest::moduleMapKey()
helper; two diagnostic iterations switch from .first to std::get<0>), and
adds the regression test, including the HTML reproduction from issue 19834.

Fixes #19834
@robobun

robobun commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto main again (4895f45, after the shared-allocator build matrix additions). Clean cherry-pick: none of the six files this PR touches changed in the intervening five commits. New preview tag is autobuild-preview-pr-258-41bae541 (all lanes green, first attempt). oven-sh/bun#32999 is rebased, pinned to it, and re-verified end to end: 0/6 on a released Bun, 6/6 against this artifact, and 48 existing module-loader tests pass.

robobun added a commit to oven-sh/bun that referenced this pull request Jul 14, 2026
Importing the same path with two different `with { type }` attributes
returned one shared module: JSC's ModuleMapKey was
(specifier, ScriptFetchParameters::Type), and every Bun-defined attribute
value ("text", "file", "toml", "html", ...) maps onto the single
Type::HostDefined enum value, so whichever import evaluated first decided
what both callers got.

The key now also carries the host-defined attribute string. The fix lives
in oven-sh/WebKit (oven-sh/WebKit#258); this commit
bumps WEBKIT_VERSION, updates Bun's three ModuleMapKey users to the widened
key (NodeVMSourceTextModule uses the new ModuleRequest::moduleMapKey()
helper; two diagnostic iterations switch from .first to std::get<0>), and
adds the regression test, including the HTML reproduction from issue 19834.

Fixes #19834
@robobun
robobun force-pushed the farm/5cc6cbfd/module-map-import-attribute-key branch 2 times, most recently from ac29374 to a024b4c Compare August 12, 2026 23:37
Comment thread Source/JavaScriptCore/runtime/ModuleMap.h

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ModuleAnalyzer.cpp build break I flagged last pass is now fixed (it uses makeModuleMapKey() with attributes.get()), and this run found no further issues. Given the scope — a widened ModuleMapKey threaded through the realm registry, per-record [[LoadedModules]], hostResolveImportedModule, and the provideFetch/ensureRegistered API surface, plus a coordinated Bun-side change — a human sign-off is still worthwhile.

What was reviewed:

  • Confirmed no remaining 2-arg ModuleMapKey { … } brace-init sites in-tree; every construction goes through makeModuleMapKey() / ModuleRequest::moduleMapKey().
  • ModuleMapHash::equal compares the String component by value (WTF String::operator==), so two hostDefinedImportType() copies with distinct StringImpl* still match.
  • The new hostResolveImportedModule HostDefined branch scans m_requestedModules and falls through to nullptr rather than hitting the fallthrough assert; non-HostDefined lookups are unchanged.
  • .first/.second accesses on ResolutionMapKey in removeEntry / ResolutionMapHash are unaffected (still a std::pair).
Extended reasoning...

Overview

The PR widens ModuleMapKey from (UniquedStringImpl*, ScriptFetchParameters::Type) to a 3-tuple that also carries the host-defined with { type: … } attribute string, and threads that key through every registry and [[LoadedModules]] lookup: ensureRegistered, getRegisteredMayBeNull, provideFetch (two new overloads), hostLoadImportedModule, innerModuleLoading, finishLoadingImportedModule, getImportedModule, setImportedModule, and ModuleRequest::operator==. hostResolveImportedModule gains a Bun-only linear scan of m_requestedModules to recover the attribute string that ImportEntry/ExportEntry do not carry. ModuleAnalyzer::appendRequestedModule now builds its dedup key through the same helper. Seven files, roughly +90/−30.

What changed since my last review

My previous run flagged a build break: ModuleAnalyzer.cpp:46 still brace-constructed a 2-arg ModuleMapKey after the type became a 3-tuple. The current diff adds ModuleAnalyzer.cpp to the change set with exactly the suggested fix (makeModuleMapKey(specifier.impl(), type, attributes.get())). I re-grepped the tree for ModuleMapKey\s*\{ and there are no remaining 2-arg sites. Both earlier inline threads (the static-import scope note and the CodeRabbit assert-placement nit) are resolved and reflected in the current diff and PR body.

Security risks

None identified. This is module-identity bookkeeping; it does not touch auth, crypto, permissions, or untrusted parsing. The change narrows a key (fewer collisions), which if anything reduces the surface for one import observing another's module instance.

Level of scrutiny

High. This is core JSModuleLoader / AbstractModuleRecord machinery that every ES module import flows through, it modifies spec-mapped algorithms (GetImportedModule, FinishLoadingImportedModule, ModuleRequestsEqual, HostLoadImportedModule), and it changes an embedder-visible type in a way that requires a coordinated Bun-side commit (NodeVMSourceTextModule.cpp and the WEBKIT_VERSION bump in oven-sh/bun#32999). The PR body also explicitly scopes out the static-import binding path, which is a design boundary a maintainer should ratify.

Other factors

The author has done thorough end-to-end verification against three preview artifacts (6/6 new tests pass, 48 existing module-loader tests pass, ASAN/debug lanes green), and the diffs interact cleanly with #262. That gives good confidence the change is correct, but the combination of registry-key semantics, the new HostDefined recovery loop in hostResolveImportedModule, and the cross-repo coordination is more than I'd approve without a human look.

robobun added a commit to oven-sh/bun that referenced this pull request Aug 13, 2026
Importing the same path with two different `with { type }` attributes
returned one shared module: JSC's ModuleMapKey was
(specifier, ScriptFetchParameters::Type), and every Bun-defined attribute
value ("text", "file", "toml", "html", ...) maps onto the single
Type::HostDefined enum value, so whichever import evaluated first decided
what both callers got.

The key now also carries the host-defined attribute string. The fix lives
in oven-sh/WebKit (oven-sh/WebKit#258); this commit
bumps WEBKIT_VERSION, updates Bun's three ModuleMapKey users to the widened
key (NodeVMSourceTextModule uses the new ModuleRequest::moduleMapKey()
helper; two diagnostic iterations switch from .first to std::get<0>), and
adds the regression test, including the HTML reproduction from issue 19834.

Fixes #19834
@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto main (3547373). This one was not a clean cherry-pick, so summarizing what changed:

  • Remove the December 2025 LTO de-inlining stopgap now that WTF::opaque() is volatile #403 landed under this PR and moved static-binding resolution onto the same ModuleMapKey (ImportEntry / ExportEntry / star exports now carry moduleRequestType; m_dependencies is gone; hostResolveImportedModule() looks up [[LoadedModules]] by (specifier, Type)). That is the same HostDefined collapse one layer down, so this PR now also makes hostResolveImportedModule() recover the full key from the record's own [[RequestedModules]] for HostDefined requests. Behavior-preserving for the one case the entries cannot yet express (same path, two host-defined types, one module: binds to the first, as before); everything else binds to the right module. Described in the updated "What this fixes" section.
  • Remove the December 2025 LTO de-inlining stopgap now that WTF::opaque() is volatile #403 also added a ModuleMapKey construction in ModuleAnalyzer::appendRequestedModule() (the parse-time requested-modules dedup). The first rebased push (ac293742) missed it and failed to compile on every lane; a024b4c8 routes it through makeModuleMapKey() too, which is also what makes both differently-attributed requests reach [[RequestedModules]].
  • Took the review suggestion to assert the HostDefined-needs-parameters invariant inside makeModuleMapKey() and dropped the per-overload asserts in provideFetch.

Preview autobuild-preview-pr-258-a024b4c8 is green on every lane. Before pushing it I also type-checked all 11 translation units that reference the module-loader types with clang -fsyntax-only against this branch's headers (the harness reproduces the ac293742 error on the old ModuleAnalyzer line and is clean on this one). oven-sh/bun#32999 is rebased onto Bun main, pinned to this artifact, and verified: 7/7 new tests (6 dynamic-import cases that fail on a released Bun, plus a static import / re-export guard for the hostResolveImportedModule path), and the existing module-loader, loader-attribute, and node:vm module suites pass against it.

ModuleMapKey was (specifier, ScriptFetchParameters::Type). Every Bun-defined
`with { type }` value ("text", "file", "toml", ...) maps onto the single
Type::HostDefined enum value, so two imports of the same path with different
attribute types collided on one registry entry and whichever ran first
decided what both got:

    // data.txt contains "hello\n"
    const a = await import("./data.txt", { with: { type: "text" } });
    const b = await import("./data.txt", { with: { type: "file" } });
    // both a.default and b.default were "hello\n", and a === b

The distinguishing string already lives on
ScriptFetchParameters::m_hostDefinedImportType; it just never reached the
key. Widen ModuleMapKey to (specifier, Type, hostDefinedImportType) and
route every key construction through makeModuleMapKey() (or
ModuleRequest::moduleMapKey()) so the string component is null for every
non-HostDefined type and never dropped for a HostDefined one.
makeModuleMapKey() asserts that a HostDefined key is never built without
its parameters, so the invariant is enforced at the one chokepoint instead
of at individual callers.

tc39/proposal-import-attributes requires the same specifier imported with
different attribute lists to designate different modules, which is what the
(specifier, Type) key was already trying to do; it just could not tell the
HostDefined types apart. ModuleRequest::operator== (ModuleRequestsEqual) had
the same gap and is fixed the same way.

Import, export and star-export entries carry only the attribute Type, so
AbstractModuleRecord::hostResolveImportedModule() cannot build a HostDefined
key directly. It recovers the full key from the record's own requested
modules (source order), so a binding resolves to the first request for its
specifier and type: the same module the old specifier-keyed lookup produced.
Threading the attribute string through the entries themselves, so that two
differently-attributed static imports of one specifier bind independently,
is a follow-up; this change makes the loading graph and registry hold the
distinct modules that follow-up will bind to.

ModuleAnalyzer deduplicates a module's requested modules with the same key,
so it builds the key the same way: two imports of one specifier with
different host-defined types now both reach [[RequestedModules]] and both
get loaded, instead of the second being dropped at parse time.

provideFetch's existing (key, Type, source) overloads keep their signature;
all their callers are attribute-less entry points. The post-fetch
registration in moduleLoadTopSettled is the one path that registers a
HostDefined entry and now passes the request's ScriptFetchParameters
through. ensureRegistered and getRegisteredMayBeNull gained a
`const ScriptFetchParameters*` parameter.
@robobun
robobun force-pushed the farm/5cc6cbfd/module-map-import-attribute-key branch from a024b4c to b30b5ab Compare August 16, 2026 19:07

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both issues I raised earlier are addressed in the current revision (b30b5abf): ModuleAnalyzer::appendRequestedModule now goes through makeModuleMapKey(), and hostResolveImportedModule recovers the full HostDefined key from [[RequestedModules]]. This automated pass found no further bugs. Given the scope — reshaping ModuleMapKey and threading it through the registry, [[LoadedModules]], and the binding-resolution path, plus the coordinated Bun-side change — a human sign-off on the overall approach would still be worthwhile.

What was reviewed:

  • makeModuleMapKey() chokepoint and the ASSERT guarding HostDefined without parameters; verified all in-tree key constructions route through it or ModuleRequest::moduleMapKey().
  • ModuleMapHash::hash/equal for the third component — null String handled; String::operator== gives value equality across the WTF null/empty distinction.
  • The new hostResolveImportedModule HostDefined branch — linear scan over m_requestedModules in source order, fallthrough to nullptr matches prior specifier-keyed miss behavior; non-HostDefined path unchanged.
  • provideFetch overload fan-out and the JSMicrotask.cpp call sites — m_attributes.get() is threaded consistently to ensureRegistered.
Extended reasoning...

Overview

The PR widens ModuleMapKey from (UniquedStringImpl*, ScriptFetchParameters::Type) to a 3-tuple adding the host-defined import-attribute string, so that with { type: "text" } and with { type: "file" } (both of which collapse onto Type::HostDefined) get distinct registry entries. It routes every key construction through makeModuleMapKey() / ModuleRequest::moduleMapKey(), updates ensureRegistered / getRegisteredMayBeNull / provideFetch to accept a ScriptFetchParameters*, extends ModuleRequest::operator== to compare hostDefinedImportType(), makes ModuleAnalyzer::appendRequestedModule dedupe on the full key, and adds a HostDefined-specific recovery path in hostResolveImportedModule that scans m_requestedModules to reconstruct the key that ImportEntry/ExportEntry cannot yet carry. Seven files, ~150 lines net.

Security risks

None identified. The change is confined to how module-registry keys are hashed and compared; there is no new parsing, no new external input surface, and the attribute string was already stored on ScriptFetchParameters. The String in the key is held by value, and the map's lifetime is bounded by JSModuleLoader / AbstractModuleRecord, both GC-owned.

Level of scrutiny

High. Module loading is core runtime — the registry key shape determines identity for every ES module, and hostResolveImportedModule sits on the binding-resolution path used by initializeEnvironment and namespace construction. The change also has cross-repo coupling: ModuleMapKey is part of the embedder-visible surface, and Bun has three sites (per the description) plus a paired WEBKIT_VERSION bump in oven-sh/bun#32999 that must land together. There is also an explicitly documented residual behavior gap (two static imports of the same specifier with different host-defined types in one file bind to the first request), which is a design trade-off a human should ratify.

Other factors

  • Both of my earlier findings (the over-claimed static-import scope, and the ModuleAnalyzer.cpp 2-arg brace-init build break) are addressed in the current revision; both threads are resolved.
  • CodeRabbit's suggestion to centralize the HostDefined-needs-parameters invariant inside makeModuleMapKey is applied.
  • The author reports green preview builds on all lanes for a024b4c8, 7/7 new regression tests passing on Bun pinned to that artifact, and 48 existing module-loader tests passing. That is good empirical coverage but does not substitute for a human reading the hostResolveImportedModule recovery approach and the operator== change.
  • I did not approve because the change is neither small nor mechanical: it modifies a fundamental identity key, adds a new lookup strategy, and requires coordinated embedder changes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant