Skip to content

runtime: one module record for a .json imported with and without the type attribute - #35914

Open
robobun wants to merge 6 commits into
mainfrom
farm/edab56f9/json-import-attr-identity
Open

runtime: one module record for a .json imported with and without the type attribute#35914
robobun wants to merge 6 commits into
mainfrom
farm/edab56f9/json-import-attr-identity

Conversation

@robobun

@robobun robobun commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

Importing the same .json file both without an attribute and with with { type: "json" } produced two live module instances, so a mutation via one was invisible via the other.

Reproduction

// plain.mjs
import a from "./cfg.json"; export default a;
// attr.mjs
import b from "./cfg.json" with { type: "json" }; export default b;
// index.mjs
const plain = (await import("./plain.mjs")).default;
const attr  = (await import("./attr.mjs")).default;
plain.n = 42;
console.log(plain === attr, attr.n);   // bun: false 1   expected: true 42

Deterministic on main. Within a single source file the two static forms coalesce (the printer deduplicates requestedModules by specifier), so the fork only shows across files or via dynamic import().

Node cannot exhibit it because the attribute-less form is ERR_IMPORT_ATTRIBUTE_MISSING there; Bun accepts both, so both forms resolve to the same JSON loader and must be one module.

Cause

JSC's module map is keyed on (specifier, ScriptFetchParameters::Type). An attribute-less request falls back to Type::JavaScript (AbstractModuleRecord::ModuleRequest::type() / JSModuleLoader::loadModule), while with { type: "json" } parses to Type::JSON, so the two requests hash to different slots and fetch twice.

Fix

Normalize the attribute-less form to Type::JSON at the two points Bun controls before JSC's key is built, keying the decision on the as-written specifier so both sides agree:

  • Static imports / re-exports (src/js_printer/lib.rs): for target: "bun", when an import / export ... from record has no with { type } and its specifier ends in .json, emit with { type: "json" } in the printed source (and mirror FetchParameters::Json in module_info so the isolation-cache path and the debug fallbackParse diff stay consistent). JSC's ModuleAnalyzer then produces Type::JSON for both forms.
  • Dynamic import() (src/jsc/bindings/ZigGlobalObject.cpp): in moduleLoaderImportModule, before resolving, if the caller supplied no attributes and the specifier ends in .json, synthesize ScriptFetchParameters::create(Type::JSON).

Both predicates skip the same cases a synthesized type: "json" would change the loader for:

  • ?raw, which selects the text loader.
  • The filenames loader_for_path routes to jsonc (package.json, tsconfig.*, jsconfig.*), where it would force strict JSON and break empty/commented config files.

An explicit user-written with { type: ... } is never rewritten, and other target values are untouched (the printer path is behind IS_BUN_PLATFORM).

The dynamic-import side originally inspected the resolved path; that forked a specifier like pkg/data or #cfg (resolves to a .json, does not end in one) between static and dynamic, which the printer cannot match because the runtime transpiler never resolves. Both sides now inspect the same representation and are covered by tests.

Verification

Against the installed canary (/usr/local/bin/bun, 1.3.14): 9 of 20 tests in test/js/bun/resolve/json-import-identity.test.ts fail; the passing half is the regression-guard set (?raw, jsonc filenames, pkg/data / #cfg static-vs-dynamic, the negative type: "text" contract).

bun bd test test/js/bun/resolve/json-import-identity.test.ts: 20 pass / 0 fail.

Also green on the debug build: jsonc.test.ts, import-query.test.ts, import-defer.test.ts, esModule.test.ts, import-attributes.test.ts, resolve.test.ts, require.test.ts, import-meta.test.js, bundler_loader.test.ts, transpiler.test.js, regression/issue/16476.

Known limitation

A specifier that does not literally end in .json but resolves to one (e.g. "pkg/data" via package exports) keys on Type::JavaScript, while the literal path to the same file keys on Type::JSON. On main both were Type::JavaScript, so an attribute-less import "pkg/data" and an attribute-less import "./node_modules/pkg/data.json" shared a module; now the literal form joins the Type::JSON slot alongside every attributed form instead. Net module count is unchanged, the partition moved. Closing that completely needs the key's type component rewritten after resolve() inside hostLoadImportedModule, which is a WebKit change (same territory as #32999).

A bunfig [loader] ".json" = "jsonc" remap is not touched here: it already fails on main the same way (the fetch-time type override is unaware of the bunfig remap), verified against 1.3.14.

Related

#32999 fixes the adjacent collapse where two different with { type } values hash to one slot (HostDefined string not in the key). This PR is the converse: no-attribute vs type: "json" on a .json specifier hashing to two slots when Bun loads them identically. The two are independent.


[review] gate passed · iteration 2 · 4 files touched

fails on main (without fix)
ASAN without fix: 10 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/resolve/json-import-identity.test.ts
bun test v1.4.0 (280ee2d73)

test/js/bun/resolve/json-import-identity.test.ts:
33 |       console.log("same:", plain === attr);
34 |       plain.n = 42;
35 |       console.log("mutation:", attr.n);
36 |     `,
37 |   });
38 |   expect(stdout).toMatchInlineSnapshot(`
                      ^
error: expect(received).toMatchInlineSnapshot(expected)

  
- "same: true
- mutation: 42"
- 
+ "same: false
+ mutation: 1"
+ 

- Expected  - 3
+ Received  + 3

      at <anonymous> (/workspace/bun/test/js/bun/resolve/json-import-identity.test.ts:38:18)
(fail) static .json imports with and without the type attribute share one module across files [364.45ms]
84 |       const attr = await import("./cfg.json", { with: { type: "json" } });
85 |       const plain = await import("./cfg.json");
86 |       console.log("ns:", plain === attr);
87 |     `,
88 |   });
89 |   expect(stdout).toMatchInlineSnapshot(`"ns: true"`);
                      ^
error: expect(received).toMatchInlineSnapshot(expected)

Expected: 
... (truncated)

release without fix: all passed
bun test v1.4.0-canary.1 (ce0b6665b)

test/js/bun/resolve/json-import-identity.test.ts:
(pass) dynamic import() of a .json with and without the type attribute returns one module [8.83ms]
(pass) dynamic import() of a .json shares one module regardless of order [8.43ms]
(pass) static .json imports share one module regardless of load order [10.06ms]
(pass) static .json imports with and without the type attribute share one module across files [12.33ms]
(pass) a static attribute-less .json import and a dynamic attributed one share one module [8.30ms]
(pass) export-from of a .json shares one module with an attributed import [7.67ms]
(pass) export * as of a .json shares one module with an attributed import [7.42ms]
(pass) the bun-target transpiler emits `with { type: "json" }` for attribute-less .json specifiers [0.60ms]
(pass) an explicit non-json type attribute still produces a distinct module [15.57ms]
(pass) a .json specifier with a query string still normalizes to one module [14.91ms]
(pass) specifiers that resolve to a .json but don't end in one keep a shared module > package exports: `pkg/data` -> data.json, static vs dynamic [14.37ms]
(pass) specifiers that resolve
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/resolve/json-import-identity.test.ts
bun test v1.4.0 (280ee2d73)

test/js/bun/resolve/json-import-identity.test.ts:
(pass) dynamic import() of a .json with and without the type attribute returns one module [326.99ms]
(pass) dynamic import() of a .json shares one module regardless of order [371.32ms]
(pass) static .json imports with and without the type attribute share one module across files [472.11ms]
(pass) a static attribute-less .json import and a dynamic attributed one share one module [391.11ms]
(pass) static .json imports share one module regardless of load order [422.10ms]
(pass) export-from of a .json shares one module with an attributed import [338.19ms]
(pass) export * as of a .json shares one module with an attributed import [340.36ms]
(pass) the bun-target transpiler emits `with { type: "json" }` for attribute-less .json specifiers [16.89ms]
(pass) an explicit non-json type attribute still produces a distinct module [291.17ms]
(pass) a .json specifier with a query string still normalizes to one module [291.52ms
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 690ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/8] cxx obj/src/jsc/bindings/ZigGlobalObject.cpp.o
[2/8] gen cpp.rs (cppbind)
[3/8] gen generated_host_exports.rs
generated_host_exports.rs: 94 exports (host=3, lazy=10, generic=81, rust=0); 240 extern-C blocks audited
[3/8] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

�[1m�[92m   Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92m   Compiling�[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
�[1m�[92m   Compiling�[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr)
�[1m�[92m   Compiling�[0m bun_boringssl_sys v0.0.0 (/workspace/bun/src/boringssl_sys)
�[1m�[92m   Compiling�[0m bun_safety v0.0.0 (/workspace/bun/src/safety)
�[1m�[92m   Compiling�[0m bun_zlib_sys v0.0.0 (/workspace/bun/src/zlib_sys)
�[1m�[92m   Compiling�[0m bun_cares_sys v0.0.0 (/workspace/bun/src/cares_sys)
�[1m�[92m   Compiling�[0m bun_zstd v0.0.0 (/workspace/bun/src/zstd)
�[1m�[92m   Compiling�[0m bun_pico
... (truncated)
diff hotspot
src/js_printer/lib.rs                            |  50 +++-
 src/jsc/RuntimeTranspilerCache.rs                |   4 +-
 src/jsc/bindings/ZigGlobalObject.cpp             |  28 ++
 test/js/bun/resolve/json-import-identity.test.ts | 321 +++++++++++++++++++++++
 4 files changed, 396 insertions(+), 7 deletions(-)

gate history · 4 passed · 0 rejected · iteration 2

evidence per changed file
file                                              reads  edits  tests
src/js_printer/lib.rs                                13     16      0
src/jsc/RuntimeTranspilerCache.rs                     1      1      0
src/jsc/bindings/ZigGlobalObject.cpp                  6     12      0
test/js/bun/resolve/json-import-identity.test.ts      3      8      0

…type attribute

JSC keys the module map on (specifier, ScriptFetchParameters::Type). An
attribute-less request for a .json falls back to Type::JavaScript and
`with { type: "json" }` keys on Type::JSON, so the same file became two
live module instances and a mutation via one was invisible via the
other.

For the Bun target the printer now emits `with { type: "json" }` for a
.json specifier that has no attribute (and mirrors that in module_info),
so both forms hash to the (path, JSON) slot. For dynamic import()
moduleLoaderImportModule synthesizes Type::JSON after resolving when
the caller supplied no attributes. Filenames Bun routes to the jsonc
loader (package.json, tsconfig.*, jsconfig.*) are excluded so the
synthesized attribute cannot force strict JSON there.
@robobun

robobun commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 9:50 AM PT - Jul 26th, 2026

@robobun, your commit 280ee2d has 1 failures in Build #82418 (All Failures):

  • 📦 Binary size — 12 over 0.50 MB
  • targetthis build canary: main #79916
    sizeΔ
    bun-darwin-aarch6458.13 MB57.58 MB+564.9 KB
    bun-darwin-x6463.48 MB62.95 MB+544.5 KB
    bun-linux-aarch6470.98 MB70.42 MB+576.0 KB
    bun-linux-x6472.48 MB71.95 MB+544.0 KB
    bun-linux-aarch64-musl64.88 MB64.32 MB+576.0 KB
    bun-linux-x64-musl66.98 MB66.45 MB+544.0 KB
    bun-linux-aarch64-android78.47 MB77.97 MB+512.0 KB
    bun-linux-x64-android80.62 MB80.10 MB+529.2 KB
    bun-freebsd-x6483.07 MB82.56 MB+528.0 KB
    bun-freebsd-aarch6484.84 MB84.31 MB+544.0 KB
    bun-windows-x6480.26 MB79.70 MB+573.5 KB
    bun-windows-aarch6470.86 MB70.34 MB+536.0 KB

    Add [skip size check] to the commit message if this increase is intentional.


🧪   To try this PR locally:

bunx bun-pr 35914

That installs a local version of the PR into your bun-35914 executable, so you can run:

bun-35914 --bun

Comment thread src/js_printer/lib.rs Outdated
Comment thread src/jsc/bindings/ZigGlobalObject.cpp Outdated
@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

The Bun JS printer now marks eligible attribute-less .json imports and exports as JSON. JSC normalizes missing JSON fetch parameters, and tests cover module identity across import forms, query strings, explicit types, and JSONC-handled filenames.

JSON module identity

Layer / File(s) Summary
Printer JSON detection and emission
src/js_printer/lib.rs
The printer detects eligible JSON records, emits with { type: "json" }, and records JSON fetch parameters for imports and exports.
JSC resolved-path normalization
src/jsc/bindings/ZigGlobalObject.cpp
Resolved attribute-less .json imports receive JSON fetch parameters in virtual-module and filesystem resolution paths, excluding special JSONC filenames.
Module identity regression coverage
test/js/bun/resolve/json-import-identity.test.ts
Tests cover static, dynamic, re-exported, query-string, explicit-type, and JSONC-handled imports.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 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: normalizing module records for .json imports with and without a type attribute.
Description check ✅ Passed The description covers the problem, cause, fix, and verification, though it doesn't use the template's exact headings.

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

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 `@test/js/bun/resolve/json-import-identity.test.ts`:
- Around line 110-155: Extend the tests in the JSON import identity suite with
build-output assertions covering static JSON imports and re-exports, verifying
the printer still emits with { type: "json" }, plus a static explicit non-JSON
attribute that remains unchanged. Add an export * as cfg from "./cfg.json"
runtime identity case to ensure it shares the attributed JSON module, covering
paths not exercised by the existing dynamic-import fixtures.
🪄 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: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 34603d0d-f63c-409e-a427-4cc4c0492ee2

📥 Commits

Reviewing files that changed from the base of the PR and between 44f6469 and a0bcfa1.

📒 Files selected for processing (3)
  • src/js_printer/lib.rs
  • src/jsc/bindings/ZigGlobalObject.cpp
  • test/js/bun/resolve/json-import-identity.test.ts

Comment thread test/js/bun/resolve/json-import-identity.test.ts Outdated
@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. Key the ES module registry on the host-defined import attribute type #32999 - Both fix module identity divergence when the same .json file is imported with and without with { type: "json" }, overlapping in ZigGlobalObject.cpp; Key the ES module registry on the host-defined import attribute type #32999 widens the JSC module-map key while this PR normalizes FetchParameters

🤖 Generated with Claude Code

Comment thread src/js_printer/lib.rs Outdated
Comment thread src/jsc/bindings/ZigGlobalObject.cpp Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Additional findings (outside current diff — PR may have been updated during review):

  • 🔴 src/jsc/bindings/ZigGlobalObject.cpp:3791 — The two normalization sites inspect different inputs: record_implies_json_type() checks record.path.text (the source-as-written specifier — the runtime transpiler never resolves it), while normalizeFetchParametersForResolvedPath() checks resolvedIdentifier (the absolute path after Zig__GlobalObject__resolve). So when a specifier does not literally end in .json but resolves to a .json file — e.g. package "exports": { "./data": "./data.json" } or a subpath import "#cfg" → "./cfg.json" — a static import keys on Type::JavaScript while a dynamic import() of the same specifier now keys on Type::JSON, forking one file into two module instances where before this PR both keyed JavaScript and shared one. Either the printer needs to see the resolved path (it can't in the runtime transpiler) or the normalization needs to move to a post-resolve point that both static and dynamic requests flow through — as-is this introduces a new instance of the exact bug class the PR fixes.

    Extended reasoning...

    What the bug is

    This PR normalizes attribute-less .json imports to ScriptFetchParameters::Type::JSON at two sites, but the two sites key on different representations of the specifier:

    • Printer side (record_implies_json_type(), src/js_printer/lib.rs:6208): inspects record.path.text. In the runtime transpiler this is the literal text the user wrote — RuntimeTranspilerStore.rs:1019-1045 only rewrites import_record.path.text for HardcodedAlias entries and bun: prefix stripping; it never runs the resolver. So for import a from "pkg/data" the printer sees the string "pkg/data".
    • C++ side (normalizeFetchParametersForResolvedPath(), ZigGlobalObject.cpp:3791): runs after Zig__GlobalObject__resolve and inspects resolvedIdentifier, i.e. the absolute on-disk path.

    When a specifier does not literally end in .json but resolves to a .json file, the two disagree on whether to synthesize Type::JSON.

    Concrete trigger

    node_modules/pkg/package.json:  { "name": "pkg", "exports": { "./data": "./data.json" } }
    node_modules/pkg/data.json:     { "n": 1 }
    
    // a.mjs
    import a from "pkg/data"; export default a;
    
    // index.mjs
    const s = (await import("./a.mjs")).default;
    const d = (await import("pkg/data")).default;
    console.log(s === d);
    

    The same shape applies to subpath imports ("imports": { "#cfg": "./cfg.json" }) and to bare specifiers whose main is a .json file.

    Step-by-step

    Static path (a.mjsimport a from "pkg/data"):

    1. Runtime transpiler parses a.mjs; add_import_record stores path.text = "pkg/data".
    2. RuntimeTranspilerStore does not resolve import records; "pkg/data" reaches the printer unchanged.
    3. record_implies_json_type(): has_suffix_comptime("pkg/data", ".json")false → no attribute emitted.
    4. JSC's ModuleAnalyzer records the requested module with Type::JavaScript.
    5. moduleLoaderResolve resolves "pkg/data"/abs/.../data.json, but returns only an Identifier — it cannot alter the fetch parameters.
    6. Module map key: (/abs/.../data.json, JavaScript).

    Dynamic path (index.mjsawait import("pkg/data")):

    1. moduleLoaderImportModule is called with moduleName = "pkg/data", parameters = null.
    2. Zig__GlobalObject__resolve returns resolvedIdentifier = /abs/.../data.json.
    3. normalizeFetchParametersForResolvedPath: parameters is null, resolved path ends in .json, filename is data.json → synthesizes Type::JSON.
    4. Module map key: (/abs/.../data.json, JSON).

    Before this PR: neither side synthesized anything; both keyed (/abs/.../data.json, JavaScript)s === d was true.
    After this PR: static → JavaScript, dynamic → JSON → two live module instances → s === d is false; a mutation via one is invisible via the other.

    The subpath-import case ("#cfg") is guaranteed to hit this: record_implies_json_type() treats # as a fragment delimiter (lib.rs:6215), so "#cfg" truncates to the empty string and the printer side never fires, while the C++ side sees the resolved .json absolute path and does.

    Why nothing prevents it

    The static-import fetch parameters come from parsed source text — moduleLoaderResolve returns only a JSC::Identifier and has no channel to rewrite them post-resolve. The printer cannot see the resolved path because the runtime transpiler does not resolve. So the two normalization sites are architecturally looking at different-shaped inputs, and any specifier where "as-written" ≠ "resolved" wrt the .json suffix diverges.

    Impact

    This is a regression: attribute-less static and dynamic imports of the same specifier that shared one module on main now fork into two after this PR — the exact identity bug the PR sets out to fix, newly introduced for .json files reached via package exports, subpath imports, or a bare package whose main is a .json. That's a realistic pattern (config packages, generated data packages).

    How to fix

    The safe options are:

    • Normalize at a post-resolve point both static and dynamic requests flow through (e.g. in moduleLoaderFetch / at the module-map lookup where the resolved key is known for both), rather than in the printer + moduleLoaderImportModule; or
    • Drop the C++ resolved-path normalization so both sides continue to key on the as-written specifier consistently (accepting that await import("pkg/data") vs await import("pkg/data", { with: { type: "json" } }) still fork — but at least no new fork is introduced).

    Either way, the two sides must key on the same representation.

  • 🔴 src/js_printer/lib.rs:6208-6224 — The synthesized with { type: "json" } reaches the fetch hook as an explicit type override (jsc_hooks.rs:4037-4041), which clobbers any other loader-selection mechanism for the same specifier. Concretely, import x from "./cfg.json?raw" now returns the parsed object instead of the raw string (the query is stripped before the .json check, so the attribute is emitted, and Loader::Json overwrites the Loader::Text that ?raw set); a bunfig [loader] ".json" = "jsonc" remap is likewise silently forced back to strict JSON. record_implies_json_type and normalizeFetchParametersForResolvedPath should at minimum bail when the stripped query is ?raw (same mechanism the jsonc-filename exclusion already guards against).

    Extended reasoning...

    What breaks

    record_implies_json_type() (and its C++ twin normalizeFetchParametersForResolvedPath) decides purely on the specifier ending in .json after stripping the first ?/#, with a hardcoded exclusion list for the built-in jsonc filenames. The synthesized attribute then reaches get_loader_and_virtual_source as type_attribute_str = Some(b"json"), and at jsc_hooks.rs:4037-4041 that unconditionally overwrites whatever loader was chosen by any earlier mechanism:

    if query == b"?raw" {
        loader = Some(Loader::Text);
    }
    if let Some(attr_str) = type_attribute_str {
        if let Some(attr_loader) = Loader::from_string(attr_str) {
            loader = Some(attr_loader);   // <-- overwrites Text with Json
        }
    }

    The PR description already names this override as the reason for excluding package.json/tsconfig.*/jsconfig.* — but the exclusion list only covers Bun's built-in jsonc special-cases, not the two other loader-selection mechanisms that key on .json specifiers.

    Regression 1: ?raw on a .json file

    Step-by-step for import x from "./cfg.json?raw":

    1. Parser produces an import record with path.text = "./cfg.json?raw" and record.loader = None (?raw is a query, not a with-attribute; record.loader is only set from a parsed with { type } at js_parser/parse/mod.rs:1397).
    2. record_implies_json_type runs: record.loader.is_some() is false; it strips at the first ?path = "./cfg.json"; .json suffix matches; filename is not in the exclusion list → returns true.
    3. Printer emits import x from "./cfg.json?raw" with { type: "json" }. (The PR's own ?v=1 test proves the query is present in record.path.text at print time.)
    4. JSC's ModuleAnalyzer parses the attribute → ScriptFetchParameters::Type::JSON.
    5. moduleLoaderFetch (ZigGlobalObject.cpp:3843-3844) maps Type::JSONtypeAttributeString = "json"_s → passed to Bun__transpileFile.
    6. get_loader_and_virtual_source: query == b"?raw" sets loader = Some(Loader::Text); then type_attribute_str = Some(b"json") overwrites it with Loader::Json.
    7. Result: x is the parsed JSON object. Before this PR, no attribute was emitted → type_attribute_str = None → step 6's overwrite didn't fire → x was the raw file contents as a string.

    The dynamic-import path is identical: moduleLoaderImportModule concatenates queryString back onto the resolved path (ZigGlobalObject.cpp:3779), so resolvedIdentifier for await import("./cfg.json?raw") contains ?raw; normalizeFetchParametersForResolvedPath strips at ?, sees .json, and synthesizes Type::JSON — same override chain follows.

    ?raw is a documented built-in feature tested at test/js/bun/import-attributes/import-attributes.test.ts:346, but that test only exercises .js?raw, not .json?raw, so CI won't catch this.

    Regression 2: bunfig [loader] remap of .json

    loader_for_path (jsc_hooks.rs:3862-3863) consults the user's loader table firstloaders.get(ext) — before any built-in default. A user with

    [loader]
    ".json" = "jsonc"   # or "json5", "text"

    previously had every attribute-less import x from "./data.json" honor that remap. After this PR the printer emits with { type: "json" } (it has no access to the loader table, and record.loader is None since bunfig config never sets it), which forces strict JSON at fetch time via the same override at :4037-4041 — either a parse error (comments/trailing commas) or silently the wrong content type. This is the exact failure mode the PR's jsonc-filename exclusion guards against, applied to the user-configurable equivalent (documented at docs/runtime/bunfig.mdx:87).

    Fix

    For ?raw the fix is local: in both record_implies_json_type and normalizeFetchParametersForResolvedPath, bail (return false / early-return) when the stripped query is exactly ?raw. That preserves the ?v=1 cache-buster behavior the new test asserts while leaving ?raw untouched.

    The bunfig case is harder because the printer can't see the loader table. Options: (a) have the fetch hook not treat the synthesized attribute as a hard override when the user's loader table remaps .json (e.g. only apply the type_attribute_str override when it differs from what loader_for_path already returned, or thread a "synthesized" bit through), or (b) skip synthesis entirely when transpiler.options.loaders maps .json to something other than Loader::Json on the dynamic-import side and accept the printer-side gap as a known limitation. At minimum the ?raw case should be excluded before merge; the bunfig case is rarer but worth noting since the exclusion list is demonstrably incomplete relative to the mechanism it guards against.

Comment thread test/js/bun/resolve/json-import-identity.test.ts Outdated
…est.concurrent

The dynamic-import hook was inspecting the resolved path while the
printer can only see the as-written specifier, so a specifier like
`pkg/data` or `#cfg` that resolves to a .json forked static vs dynamic.
Both sides now inspect the same representation. Also skip `?raw`, which
selects the text loader and must not be overridden.
Comment thread src/js_printer/lib.rs
Comment thread src/jsc/bindings/ZigGlobalObject.cpp
@robobun

robobun commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed the two findings in bfbc4cf:

Specifier-vs-resolved-path mismatch. moduleLoaderImportModule now inspects the as-written specifier (the same representation the printer sees) instead of the resolved path, so "pkg/data" / "#cfg" no longer fork static vs dynamic. Both are covered by new tests. The one case that still moves is an attribute-less bare specifier vs an attribute-less literal path to the same file; there the literal form now joins the Type::JSON partition with every attributed form (net module count unchanged). Closing that needs the key's type rewritten after resolve() inside hostLoadImportedModule, which is a WebKit change; noted in the PR body under Known limitation.

?raw on a .json specifier. Both predicates now bail on ?raw, and the static and dynamic cases are covered by new tests.

The bunfig [loader] ".json" = "jsonc" remap is unchanged: it already fails the same way on 1.3.14 (SyntaxError: JSON Parse error) because the fetch-time type override is independent of the bunfig remap, so this PR neither breaks nor fixes it.

Also switched the suite to test.concurrent.

Comment thread src/js_printer/lib.rs
Comment thread src/js_printer/lib.rs
Comment thread src/js_printer/lib.rs Outdated
Bumping EXPECTED_VERSION to 24: the bun-target printer output and the
serialized esm_record changed for attribute-less .json import records,
both of which are persisted in .pile entries.

Dropping `#` from the query split: a leading `#` is a Node subpath
marker, not a URL fragment, so `#cfg/data.json` must be seen as ending
in `.json`. `normalize_specifier_for_loader` already splits on `?` only.
Comment thread src/jsc/RuntimeTranspilerCache.rs
Comment thread test/js/bun/resolve/json-import-identity.test.ts
@robobun

robobun commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator Author

CI status: the diff itself is green; json-import-identity.test.ts has passed on every completed lane across builds 82355 / 82376 / 82400 / 82418.

The only error-level annotation on all four runs is :package: binary-size, which is comparing against a stale baseline (canary main #79916; current main is 81770). Adjacent PR builds 82350 / 82353 / 82354 hit the same ~540 KB delta, so it is not this diff. A retrigger would not clear it.

The remaining annotations are [flaky]-tagged (passed on retry) in unrelated suites: webview-chrome.test.ts, install/migration/complex-workspace.test.ts, install/bun-upgrade.test.ts, install/bun-install-registry.test.ts, install/bun-security-scanner-matrix-*.test.ts, hot/watch-many-dirs.test.ts.

Ready for review.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

All prior review feedback is addressed (including the stderr nit — expect(stderr).toBe("") is now folded into run()). No further issues found, but this changes module-identity semantics in the JSC loader with a duplicated predicate across the printer and ZigGlobalObject.cpp and an acknowledged partition shift for bare specifiers resolving to .json, so it's worth a maintainer look at the approach.

What was reviewed:

  • Verified all three printer sites (SImport, SExportFrom, SExportStar) and their module_info twins are gated on IS_BUN_PLATFORM; browser/node targets untouched (transpiler snapshot covers this).
  • Checked record_implies_json_type and specifierImpliesJsonType agree on ?-only splitting, ?raw skip, and jsonc-filename exclusions.
  • Confirmed the ?raw, #subpath, and cache-version issues from earlier rounds are fixed and covered by tests; a candidate concern about export-from forking on explicit type: "json" was examined and ruled out.
Extended reasoning...

Overview

Normalizes attribute-less .json imports so they share a JSC module record with the with { type: "json" } form. Touches four files: src/js_printer/lib.rs (emit with { type: "json" } + FetchParameters::Json for bun-target .json specifiers across SImport/SExportFrom/SExportStar), src/jsc/bindings/ZigGlobalObject.cpp (synthesize ScriptFetchParameters::Type::JSON in moduleLoaderImportModule for attribute-less dynamic import()), src/jsc/RuntimeTranspilerCache.rs (version bump 23→24), and a new 20-test file.

Security risks

None identified. No untrusted-input parsing beyond an extension check on the as-written specifier; no auth/crypto/permissions surface. The StringView operations in specifierImpliesJsonType are bounds-safe (find/substring/left/reverseFind), and moduleNameValue->value(globalObject) already has a RETURN_IF_EXCEPTION before the new predicate runs.

Level of scrutiny

High. This is module-loader semantics — silent duplicate-module bugs and silent loader-override bugs (the ?raw regression caught in an earlier round) are exactly the failure mode here. The fix is a heuristic on the as-written specifier with a hardcoded jsonc-filename exclusion list mirrored across Rust and C++; the PR body acknowledges the structural fix lives in WebKit's hostLoadImportedModule and that a partition boundary moves for bare specifiers that resolve to .json. Whether that tradeoff is acceptable, and whether the jsonc exclusion list will stay in sync with loader_for_path, are maintainer-level calls.

Other factors

The PR has iterated well: three review rounds surfaced real issues (?raw override, #-subpath truncation, missing cache-version bump, specifier-vs-resolved-path mismatch, test.concurrent, stderr assertion) and each was fixed with matching test coverage. The 20 tests cover static/dynamic, both load orders, export-from/export-star, query strings, ?raw, #subpath, package-exports indirection, jsonc filenames, and a negative type: "text" contract; the transpiler snapshot pins printer output for all three targets. The mechanized gate confirms the suite fails on main and passes on the PR under both debug+ASAN and release. Given the semantic subtlety and the disclosed known limitation, deferring to a human rather than auto-approving.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

All prior findings from my earlier passes are addressed and the bug-hunt found nothing new this round. Deferring to a maintainer because this changes bun-target transpiler output and JSC's module-map keying via a duplicated predicate across the printer and moduleLoaderImportModule, with an acknowledged known-limitation partition shift — that's a design call worth a human sign-off.

What was reviewed:

  • record_implies_json_type / specifierImpliesJsonType now split on ? only, skip ?raw, and exclude the jsonc filenames — verified the two predicates agree on the as-written specifier.
  • EXPECTED_VERSION bumped to 24 so warm .pile entries can't serve pre-fix output.
  • run() asserts empty stderr; suite uses test.concurrent and covers static/dynamic/export-from/export-star, #cfg/data.json, ?raw, and the jsonc guard set.
Extended reasoning...

Overview

The PR normalizes attribute-less .json imports to ScriptFetchParameters::Type::JSON so they share a module-map slot with the with { type: "json" } form. It touches four files: src/js_printer/lib.rs (emit with { type: "json" } and record FetchParameters::Json for SImport / SExportFrom / SExportStar under IS_BUN_PLATFORM), src/jsc/bindings/ZigGlobalObject.cpp (synthesize Type::JSON in moduleLoaderImportModule when parameters is null), src/jsc/RuntimeTranspilerCache.rs (version 23 → 24), and a new 20-test file.

Security risks

None identified. No untrusted-input parsing beyond a .json suffix / basename check on the user-written specifier, no allocation sized from external data, and no auth/crypto/permissions surface. The StringView operations in specifierImpliesJsonType are bounds-safe WTF helpers.

Level of scrutiny

High. This sits directly on the module-identity path: the printer change alters what every bun-target transpile of an attribute-less .json import emits (visible via Bun.Transpiler output), and the C++ change alters how every dynamic import() of a .json specifier keys into JSC's module map. The two predicates must stay byte-for-byte in agreement or static and dynamic imports fork — the first revision of this PR got that wrong, and the invariant now lives only in cross-referencing comments. The PR body's Known limitation also documents that the partition of module identity moves for the bare-specifier-vs-literal-path case; net module count is unchanged but the grouping differs from main. That's a deliberate tradeoff (the true fix is inside hostLoadImportedModule in WebKit, per the PR body and #32999), and per REVIEW.md's "fix at the layer that owns the invariant" guidance a maintainer should confirm the printer-side approach is the one they want to ship.

Other factors

Test coverage is thorough: 20 tests including both load orders, export-from / export-star, #cfg/data.json, ?raw static+dynamic, query-string, the jsonc filename guard set, a negative type: "text" contract, and a Bun.Transpiler snapshot pinning the printed output for bun vs browser/node targets. The suite is verified to fail on the installed canary and pass on the debug build. All four of my prior findings (test.concurrent, cache-version bump, ?raw override, #-prefix truncation) and CodeRabbit's earlier note are resolved, and expect(stderr).toBe("") is folded into run(). CI is green on the diff per the author's build summary. Given the module-loading criticality and the visible transpiler-output change, I'm deferring rather than approving.

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants