Skip to content

bundler: keep require() of a .json specifier a require() under the CommonJS unwrap list - #46386

Draft
robobun wants to merge 4 commits into
mainfrom
robobun/906fb844/json-require-stays-require
Draft

robobun wants to merge 4 commits into
mainfrom
robobun/906fb844/json-require-stays-require

Conversation

@robobun

@robobun robobun commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • bun build --target=node --external react prints require("react/package.json") as import * as pkg from "react/package.json". Node throws TypeError [ERR_IMPORT_ATTRIBUTE_MISSING]: Module ".../react/package.json" needs an import attribute of "type: json".
  • Bundled, pkg.version is right on main. The whole value is not: Object.keys(pkg) gains default.
  • Cause: transpose_require (src/js_parser/p.rs:1649) looks at the package name only.

Fix

  • A require() whose specifier ends in .json stays a require(), as for every package off the list.
  • It is the last operand: a require() that main does not unwrap runs no new code.
  • Verified: test/bundler/bundler_cjs2esm.test.ts (17 new cases, 15 fail on main). 280 of 280 React builds stay byte-identical.
  • Self-reviewed: 23 concerns raised, 17 addressed in full, 6 in part (Notes).

Background

  • Unwrap list: in ESM output, require() of react and 6 more packages becomes import * as ns.
  • No user reported this. @frontegg/react reaches the Node error. A try body or --format=cjs avoids it in your own code.
  • Not taken (Notes): a decision in the linker, a gate on the target or on --external.

Downsides

Notes

Reach. No user report exists. The package-name test dates from e05cc8d (2023-04-16, first in bun-v0.6.0). On the tracker, ERR_IMPORT_ATTRIBUTE_MISSING matches no issue and two PRs (#40836, #35914). The older code ERR_IMPORT_ASSERTION_TYPE_MISSING matches #4257 (closed), which is about a user-written import assertion. Another session found this while it worked on #46072, which lists the case under "What stays as on main".

Published uses that I checked, both a read of .version:

  • @frontegg/react 7.19.0, index.js:15, CommonJS only. An application bundle for Node reaches the error through it.
  • @parcel/runtime-react-refresh 2.13.3, lib/ReactRefreshRuntime.js:21. It is a Parcel runtime.

Workaround on released versions. Put the require() in a try body (try { var pkg = require("react/package.json"); } finally {}), or build with --format=cjs. Both print react 18.3.1 under Node on 1.4.3 and on main. This does not reach a third-party package in the bundle, and the build exits 0, so nothing says that a workaround is necessary.

Before and after (bun build entry.js --target=node --external react, then node out.mjs)

// main: TypeError [ERR_IMPORT_ATTRIBUTE_MISSING]
import * as pkg from "react/package.json";
console.log(pkg.name, pkg.version);

// this PR: react 18.3.1
import { createRequire } from "node:module";
var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
var __require = /* @__PURE__ */ createRequire(import.meta.url);
var require_entry = __commonJS(function() {
  var pkg = __require("react/package.json");
  console.log(pkg.name, pkg.version);
});
export default require_entry();

A read of pkg.version, by target and delivery (main, then this PR)

Target Package bundled Package external
node right, right (31 B to 906 B) Node error, right
bun right, right (39 B to 914 B) right, right
browser right, right (31 B to 906 B) an import, the __require shim

A use of the whole value is wrong on main in every bundled cell and right with this PR: Object.keys(require("react/package.json")).length is 15 on main and 14 in Node, with --format=cjs and with this PR. A JSON array or a default key shows the same defect (Array.isArray is false, .default is the whole object). No listed package ships such a file, so those need an alias or a first-party package with a listed name.

The question: --target=browser with the package external. Measured with @frontegg/react 7.19.0 (its other dependencies stubbed) and --external react --external react-dom:

  • --target=node: Node throws on main and loads with this PR.
  • Default target: main prints import * as _package from "react/package.json" and no __require( call. A browser rejects that import. An esbuild 0.21.5 bundle of the output loads. This PR prints one __require("react/package.json") call, the build exits 0, and the esbuild bundle throws Dynamic require of "react/package.json" is not supported at load.
  • The shim is what a package off the list gets on main, and what esbuild prints.
  • If the answer is no: a flag in Runtime::Features, set from the target in ParseTask.rs, keeps browser output as on main. A use of the whole value then stays wrong there.

The two shapes that wait for other PRs. Both builds exit 0.

What changes for a .json require of a listed package. A package off the list and esbuild already behave this way.

  • The record is a require: require-call in onResolve and the metafile, and the require condition of an exports map.
  • A file whose only imports are such requires is CommonJS (__commonJS wrapper, export default).
  • The load happens at the call. A require() in a function that never runs loads nothing.
  • A write through the result builds (main: Cannot assign to import "extra"), and the next require() sees it.

Measurements. Output bytes and the test runs without the fix are from debug builds of 9634520 and of this diff on it. The operand count and the instruction, size and allocator numbers are from builds of the same diff on 0c864a0, six commits earlier. The 280 builds are byte-identical between the two bases. The branch has merged main since, and all 130 tests of the file pass on that build.

  • New operand executed 0 times for bun run, cjs, iife and off-list ESM requires, and 6 / 8 times in the React 18.3.1 / 19.3.1 client builds (gdb hit counts, debug build).
  • transpose_require: +0 instructions per call when the gate fails (cjs 314, off-list ESM 378 and 489), +48 when it passes for a JavaScript target (1095 to 1143), 1426 to 678 for a .json specifier (gdb stepi, third call, release). Most of the +48 is inlining: new_symbol is a call in the PR build and inline in the base build.
  • Release binary: -256 B .text. transpose_require 1808 + 1883 B to 1690 + 1690 B (size -A, nm -S). The stripped binary has the same size.
  • React matrix: 280 of 280 cells byte-identical (67,842,402 B). React and react-dom 18.3.1 and 19.3.1, 7 entries, production and development, --minify on and off, targets browser, bun and node, plus --external, --packages=external and --splitting. npm/ReactSSR stays at its pinned 222012 B.
  • Bundled .version read of react/package.json, minified: 31 B to 906 B (off-list copy 908 B, --format=cjs 866 B). Unused TypeScript import pkg = require("react/package.json"): 24 B to 870 B (off-list 872 B). Reported external entry: 91 B to 385 B. A use of the whole value: 1454 B to 918 B.
  • require("react/package.json"): onResolve and metafile kind import-statement to require-call, exports condition import to require. require("react"): 0 differing lines.
  • Allocator calls for a build with one .json require of a listed package: 7342 to 7429. The same build for an off-list package: 7428. For require("react"): 7342 to 7342 (gdb hit counts on the mimalloc entry points, release).
  • 17 new itBundled cases: 15 fail on debug and release builds without the fix and on 1.4.3, 17 pass on the PR build. The cases run their output in Bun, so the cases that read one key assert the printed call. The format: "cjs" control and the react-dom/server.node guard pass on both. 0 existing lines of the test file changed.

Left as on main. The defect is larger than .json: an unwrapped require() gives a namespace where module.exports is owed. This PR fixes one spelling of it. I ran each line below on the PR build.

Bundled:

External:

Runtime: after an import of the same data file, require() returns the module namespace under bun run, for every package (#35973).

Alternatives not taken. Each needs a maintainer's agreement first (REVIEW.md:66), and each keeps this operand for an external .json specifier. This PR does not depend on them.

  • Keep every unwrapped require() a require until the linker knows the file. 55 sites decide on the record kind, and 34 of them change branch.
  • A rule in linker step 1 for a marked record whose bundled file is a data file. It follows bundler: skip __toESM for an unwrapped require() of a package that keeps its CommonJS wrapper #46215 and cannot reach an external record.
  • Keep a require() of a package that the options mark external. It changes require("scheduler") too, which cjs2esm/ReactSpecificUnwrappingTargetIsExternal pins, and the parser cannot see an external that a plugin declares.
  • The target flag above.

Order. This PR lands after #46072 and #42026. It merges clean with #46072, #46215, #46216, #42026 and #45830 (git merge-tree). #45343 is about a rebound variable, which is a different decision.

Self-review. Changed after it: the tests moved below cjs2esm/ReactSpecificUnwrappingTargetIsExternal, a case with an ES-module importer, a case that pins the browser output, this body, and the order. Not done: a tracking issue (this PR is the record), a comment on #46072 (with this order its text stays true), the lazy-export hunk of #42500 (it belongs there), a second question about the list members (a separate change), and counts of sibling shapes that I did not reproduce.

Suites run with the debug build: bundler_cjs2esm (130 pass), bundler_npm, bundler_edgecase, bundler_splitting, bundler_html, bundler_cjs, bundler_loader, esbuild/default and bundler_compile. The last two have 2 and 3 failures that are 5 s timeouts. The same tests time out on the debug build of the merge base. Only linux-x64 was run.

…mmonJS unwrap list

In ESM output, require() of a package on the CommonJS unwrap list becomes
`import * as ns`. The decision looked at the package name only, so
require("react/package.json") was unwrapped too. With the package external
the import has no `with { type: "json" }` and Node rejects it. With the
package bundled the result is a module namespace object, not the parsed
value.

transpose_require now leaves a require() whose specifier ends in .json a
require(), as for every package that is not on the list.
@robobun

robobun commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 8:05 PM PT - Oct 21st, 2026

❌ @robobun, your commit 7b4a72f has some failures in Build #130391 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 46386

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

bun-46386 --bun

@robobun

robobun commented Oct 21, 2026

Copy link
Copy Markdown
Collaborator Author

Status: draft. It waits for #46072 and #42026, and for a maintainer's yes or no on the --target=browser output (first bullet of Downsides).

How I reproduced it (1.4.3 and main print the import):

mkdir -p node_modules/react
echo '{"name":"react","version":"19.3.1"}' > node_modules/react/package.json
echo 'const pkg = require("react/package.json"); console.log(pkg.name, pkg.version);' > entry.js
bun build entry.js --target=node --external react --outfile out.mjs && node out.mjs
# main: TypeError [ERR_IMPORT_ATTRIBUTE_MISSING]: Module ".../react/package.json" needs an import attribute of "type: json"
# this PR: react 19.3.1

The first spawn of node after a build can take more than the 5 s test
timeout. The cases keep their proof: each one fails without the fix.
Comment thread src/js_parser/p.rs Outdated

This branch has not been deployed

No deployments
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.

1 participant