Skip to content

runtime: share one parsed value between import and require() of a JSON/TOML file - #35973

Open
robobun wants to merge 9 commits into
mainfrom
farm/dc16e277/json-require-import-identity
Open

runtime: share one parsed value between import and require() of a JSON/TOML file#35973
robobun wants to merge 9 commits into
mainfrom
farm/dc16e277/json-require-import-identity

Conversation

@robobun

@robobun robobun commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

A .json file that is both imported and require()d in one process returned two different objects in Bun, and when the ESM import happened first the require() result was the module namespace rather than the plain data.

Reproduction

// cfg.json: {"a":1,"b":{"c":[1,2]}}
import def from './cfg.json' with { type: 'json' };
import { createRequire } from 'node:module';
const req = createRequire(import.meta.url)('./cfg.json');
JSON.stringify(req);
// node: {"a":1,"b":{"c":[1,2]}}
// bun:  {"a":1,"b":{"c":[1,2]},"default":{"a":1,"b":{"c":[1,2]}}}
req === def;            // node: true   bun: false

For a JSON array it was worse: Array.isArray(require('./arr.json')) was false after an ESM import of the same file, because the namespace object (not the array) came back. The reverse order (require first, then import) was clean on shape but still produced two distinct objects. Node.js guarantees identity for JSON in both orders.

Cause

fetchESMSourceCode handles JSON/TOML/JSONC/YAML by parsing the value and wrapping it in a synthetic module record (generateJSValueModuleSourceCode in ObjectModule.cpp). It never touches require.cache. A later require() reaches fetchCommonJSModule, sees the specifier already in the ESM registry, returns -1, and overridableRequire falls back to namespace["module.exports"] ?? namespace, which for these records is the namespace itself.

A require() without a prior import takes a different branch (fetchCommonJSModuleNonBuiltintarget.exports = JSON.parse(src)) and is unaffected, but that parse result is private to require.cache and never reused by a later import.

Fix

reconcileDataModuleWithRequireCache is called from the two data-module branches of fetchESMSourceCode (JSONForObjectLoader for .json, ExportsObject for TOML/JSONC/YAML). It seeds require.cache[specifier] with a JSCommonJSModule whose exports is the parsed value, or reuses the exports an earlier require() already put there. Both loaders now hand back the same object, and require() returns the plain data because it finds the entry in $requireMap before the ESM-registry short-circuit.

The ExportDefaultObject branch (HTML bundles, CSS stubs, the file loader) is intentionally left alone: seeding require.cache there made the inspector report .html dev-server routes as CJS modules.

The ESM namespace shape is unchanged (no extra export names), and delete require.cache[key] still forces a re-read.

Verification

USE_SYSTEM_BUN=1 bun test test/js/bun/resolve/json-require-import-identity.test.ts: 2 pass (regression guards) / 6 fail.

bun bd test test/js/bun/resolve/json-require-import-identity.test.ts: 8 pass / 0 fail (also under BUN_JSC_validateExceptionChecks=1).

Also green on the debug build: jsonc.test.ts, import-meta.test.js, esModule.test.ts, esModule-annotation.test.js, resolve.test.ts, import-query.test.ts, import-attributes.test.ts, toml/toml.test.js, require-and-import-trailing.test.ts, node-module-module.test.js, require-extensions.test.ts, BunFrontendDevServer.test.ts.

Related

#35914 is about a .json imported with and without with { type: 'json' } producing two ESM records; this PR is about the ESM record and the CJS cache not sharing a value. The two are independent.


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

fails on main (without fix)
ASAN without fix: 6 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-require-import-identity.test.ts
bun test v1.4.0 (081bbdd93)

test/js/bun/resolve/json-require-import-identity.test.ts:
33 |       console.log("same:", req === def);
34 |       console.log("own keys:", Object.getOwnPropertyNames(req).sort().join(","));
35 |     `,
36 |     });
37 |     expect(stderr).toBe("");
38 |     expect(stdout).toMatchInlineSnapshot(`
                        ^
error: expect(received).toMatchInlineSnapshot(expected)

  
- "{"a":1,"b":{"c":[1,2]}}
- same: true
- own keys: a,b"
- 
+ "{"a":1,"b":{"c":[1,2]},"default":{"a":1,"b":{"c":[1,2]}}}
+ same: false
+ own keys: a,b,default"
+ 

- Expected  - 4
+ Received  + 4

      at <anonymous> (/workspace/bun/test/js/bun/resolve/json-require-import-identity.test.ts:38:20)
(fail) require() of a .json already imported via ESM returns the parsed data (no spurious default key) [411.17ms]
57 |       console.log("isArray:", Array.isArray(req));
58 |       console.log("same:", req === def);
59 |     `,
60 |     });
61 |     expect(stderr).toBe("");
62 |    
... (truncated)

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

test/js/bun/resolve/json-require-import-identity.test.ts:
(pass) require() of a .json already imported via ESM returns the parsed data (no spurious default key) [53.00ms]
(pass) import default of a .json already require()d returns the same object [38.04ms]
(pass) require() of a .json array already imported via ESM returns the array, not a namespace wrapper [43.22ms]
(pass) require.cache[path].exports is the parsed JSON value after an ESM import [37.04ms]
(pass) a plain-object require.cache entry for a .json is not clobbered by a later ESM import [35.77ms]
(pass) require() of a .json alone (no prior import) still returns the plain data [37.68ms]
(pass) require() and import default of a .toml file share one object [38.80ms]
(pass) import * as ns from a .json has no extra synthetic export names [34.61ms]
(pass) a require.extensions['.json'] override is not clobbered by a later ESM import of the same file [37.78ms]
(pass) delete require.cache[path] after ESM import lets a subsequent require() re-read from disk [53.44ms]

 10 pass
 0 fail
 10 snapshots, 30 expect() calls
Ran 10 tests across 1 file. [275.00ms]
__F:0:S:0
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-require-import-identity.test.ts
bun test v1.4.0 (081bbdd93)

test/js/bun/resolve/json-require-import-identity.test.ts:
(pass) require() of a .json already imported via ESM returns the parsed data (no spurious default key) [390.65ms]
(pass) require() of a .json array already imported via ESM returns the array, not a namespace wrapper [385.45ms]
(pass) import default of a .json already require()d returns the same object [416.70ms]
(pass) require.cache[path].exports is the parsed JSON value after an ESM import [420.85ms]
(pass) require() and import default of a .toml file share one object [474.99ms]
(pass) require() of a .json alone (no prior import) still returns the plain data [298.86ms]
(pass) a require.extensions['.json'] override is not clobbered by a later ESM import of the same file [397.49ms]
(pass) a plain-object require.cache entry for a .json is not clobbered by a later ESM import [360.46ms]
(pass) import * as ns from a .json has no extra synthetic export names [535.06ms]
(pass) delete require.cache[pat
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 957ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/7] gen cpp.rs (cppbind)
[1/7] 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_picohttp v0.0.0 (/workspace/bun/src/picohttp)
�[1m�[92m   Compiling�[0m bun_output v0.0.0 (/workspace/bun/src/output)
�[1m�[92m   Compiling�[0m bun_clap v0.0.0 (/workspace/bun/src/clap)
�[1m�[92m   
... (truncated)
diff hotspot
src/jsc/bindings/ModuleLoader.cpp                  |  39 ++++
 .../resolve/json-require-import-identity.test.ts   | 256 +++++++++++++++++++++
 2 files changed, 295 insertions(+)

gate history · 3 passed · 1 rejected · iteration 3

evidence per changed file
file                                                      reads  edits  tests
src/jsc/bindings/ModuleLoader.cpp                            11     10      0
test/js/bun/resolve/json-require-import-identity.test.ts      2      4      0

…N/TOML file

A .json file that was first imported via ESM and then require()d
returned the module namespace (with a self-referencing `default` key
and, for arrays, `Array.isArray(req) === false`) instead of the plain
parsed data. The ESM default and the require() result were also two
distinct objects in both load orders, where Node.js returns one.

The ESM data-module path (JSONForObjectLoader / ExportsObject /
ExportDefaultObject) built a synthetic module record but never touched
require.cache, so a later require() hit the "already in the ESM
registry" short-circuit in fetchCommonJSModule and the JS side handed
back the namespace object.

reconcileDataModuleWithRequireCache now seeds require.cache with the
parsed value (or reuses the value an earlier require() already put
there), so both loaders expose the same object.
@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

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: 11 minutes

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: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 59b47184-acab-4fac-a8a4-1e5975083bf4

📥 Commits

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

📒 Files selected for processing (2)
  • src/jsc/bindings/ModuleLoader.cpp
  • test/js/bun/resolve/json-require-import-identity.test.ts

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

@robobun

robobun commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 3:06 PM PT - Jul 26th, 2026

@robobun, your commit 081bbdd has 1 failures in Build #82676 (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+572.0 KB
    bun-windows-aarch6470.86 MB70.34 MB+534.0 KB

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


🧪   To try this PR locally:

bunx bun-pr 35973

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

bun-35973 --bun

Comment thread src/jsc/bindings/ModuleLoader.cpp Outdated
Comment thread src/jsc/bindings/ModuleLoader.cpp Outdated
robobun added 2 commits July 26, 2026 15:49
ExportDefaultObject covers HTML bundles, CSS stubs and the file loader;
seeding require.cache for those made the inspector report .html routes
as CJS modules (BunFrontendDevServer.test.ts). JSONForObjectLoader and
ExportsObject cover every data format the reported bug applies to.
Comment thread src/jsc/bindings/ModuleLoader.cpp
robobun added 2 commits July 26, 2026 16:11
JSMap::get() returns jsUndefined() for a missing key, so the previous
ternary never distinguished "no entry" from "entry is a plain object
the user installed". Leave user-installed entries alone so a mock like
`require.cache[key] = { exports: x }` survives a later ESM import.
Comment thread src/jsc/bindings/ModuleLoader.cpp Outdated
… output

A custom require.extensions['.json'] handler leaves the cached module
with hasEvaluated=false, so the previous gate fell through and replaced
the handler's exports with the on-disk parse. The only ways a Module for
a data file reaches requireMap before this helper runs are a prior
require() or a user-installed entry; in both cases its exports are the
value require() would return, so reuse them unconditionally.
@robobun

robobun commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator Author

CI: the diff is green. json-require-import-identity.test.ts passes on every lane across build 82647 (3b85d58) and build 82676 (081bbdd), and BunFrontendDevServer.test.ts is back to green after 4dd327e scoped the require-cache seeding away from ExportDefaultObject. Automated review on 081bbdd found nothing new.

Remaining red is unrelated:

  • binary-size compares against main's last-passing canary #79916, which is 12 commits behind this PR's base (44f6469). The ~550 KB delta is those 12 main commits, not the ~40 net lines here.
  • 82676: complex-workspace.test.ts (install failed, debian asan), inspect.test.ts (EADDRINUSE on hardcoded port 6499, ubuntu), spawn.test.ts (alpine), in-process-cron.test.ts (ubuntu aarch64), no-orphans.test.ts (darwin x64), fetch.stream.test.ts (darwin aarch64). 82647 had a disjoint set (proxy-stress-protocol, bun-install-registry, terminal-platform-gaps, next-pages, filter-workspace). All marked [flaky], none touch module loading.

Ready for review.

Comment thread src/jsc/bindings/ModuleLoader.cpp Outdated
Comment thread test/js/bun/resolve/json-require-import-identity.test.ts 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.

All four earlier findings are addressed in 081bbdd and I found nothing new on this revision. Deferring to a human for final sign-off since this changes ESM↔CJS cache interop in the core module loader.

What was reviewed:

  • reconcileDataModuleWithRequireCache: exception-scope discipline matches neighboring fetchESMSourceCode branches; JSCommonJSModule::create(global, JSString*, exports, hasEvaluated=true, parent=undefined) matches the header overload.
  • Confirmed the plain-object require.cache guard, the dropped hasEvaluated gate, and the removed dead ternary/unused fs require all landed as described.
  • ExportDefaultObject branch is untouched (per the dev-server regression noted in the description); the two seeded call sites cover JSONForObjectLoader and ExportsObject only.
Extended reasoning...

Overview

The PR adds a ~25-line helper reconcileDataModuleWithRequireCache in src/jsc/bindings/ModuleLoader.cpp and calls it from the two data-module branches of fetchESMSourceCode (JSONForObjectLoader for .json, ExportsObject for TOML/JSONC/YAML). The helper either reuses an existing require.cache entry's exports or seeds a fresh JSCommonJSModule so that import default and require() of the same data file share one object, matching Node.js. A new 256-line test file with 10 subprocess tests covers both orders, arrays, TOML, require.cache mocking, require.extensions overrides, cache deletion, and namespace shape.

Security risks

None identified. The change reads/writes requireMap (an internal JSMap) keyed by resolved specifier strings and creates JSCommonJSModule instances via the existing overload. No untrusted-size arithmetic, no new parsing, no file I/O; the parsed value already existed and is now shared rather than duplicated.

Level of scrutiny

High. ModuleLoader.cpp is the ESM/CJS interop boundary — changes here affect every data-file load in the runtime. Over three prior review rounds this PR needed two behavior fixes (plain-object require.cache clobber, require.extensions clobber via the hasEvaluated gate) and two dead-code removals, and an earlier iteration broke BunFrontendDevServer.test.ts before ExportDefaultObject was carved out. That iteration history is exactly why a human should give the final look, even though the current revision reads correctly.

Other factors

Exception handling follows local convention (same scope.exception() → tryClearException() → reject(exception) shape as the JSON-parse block immediately above each call site). dynamicDowncast<JSCommonJSModule>(JSValue) matches existing usage in this file (e.g. dynamicDowncast<JSC::Exception>(objectValue)). The PR description reports the new test file passes under BUN_JSC_validateExceptionChecks=1 and lists a dozen adjacent suites re-run green on the debug build; CI on the predecessor commit was green modulo unrelated flakes, and 081bbdd only removed dead code relative to it.

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