Skip to content

bundler: keep a read off a require() / import() local a property access until the linker binds it - #45296

Draft
robobun wants to merge 18 commits into
mainfrom
robobun/b7bdb581/namespace-local-read-stays-property-access
Draft

robobun wants to merge 18 commits into
mainfrom
robobun/b7bdb581/namespace-local-read-stays-property-access

Conversation

@robobun

@robobun robobun commented Oct 15, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • Since bundler: read import() / require() exports without a namespace object #41186 (1.4.1), a bundle can call a CommonJS export with the wrong this. For const x_1 = require("./x"), (0, x_1.f)() prints x_1.f() under --minify-syntax. (1 && x_1.f)() prints it with no flag.
  • An unused getter read ns.a; is removed.
  • The cause is maybe_rewrite_property_access (src/js_parser/fold.rs). It made ns.f an import item before the linker knew if it binds.

Fix

  • The parser keeps the read a property access and marks it (is_import_property_use).
  • The linker records each read that it resolves, and the printer prints the export in its place.
  • Correct because a property access is right for both link results.
  • Verified: test/bundler/bundler_dynamic_import_dce.test.ts and react-compiler.test.ts (42 new tests fail on main), all of test/bundler. Self-reviewed: 23 concerns raised, 20 addressed (Notes).

Background

Downsides

  • A read that binds keeps its wrapper: (0, ns.f)() prints (0, f)() under --minify-syntax, +4 B per call. 28 of 30 zod, effect and lodash-es bundles are byte-identical to main.
  • A file with 2,000 such reads builds with 4.4% more instructions. A file with none pays 14 instructions per top-level statement: three.js builds in 217.57M (main 217.76M).
  • The release binary grows by 9,216 B (.text).
Notes

Repro (bundle with bun build ./entry.js --target=node, run the bundle under node 26.3.0):

// util.js (CommonJS)
"use strict";
exports.who = who;
let boots = 0;
function who() { return this === undefined ? "undefined" : this === exports ? "exports" : typeof this; }
Object.defineProperty(exports, "boot", { enumerable: true, get() { return ++boots; } });
Object.defineProperty(exports, "boots", { enumerable: true, get() { return boots; } });

// entry.js (what TypeScript prints for module commonjs)
"use strict";
const util_1 = require("./util");
const ns = require("./util");
const out = [(0, util_1.who)(), (true ? util_1.who : 0)(), (1 && util_1.who)(), (0 || util_1.who)(), (null ?? util_1.who)()];
ns.boot;
void ns.boot;
const unused = ns.boot;
out.push(ns.boots);
console.log(out.join(" "));
no flag --minify-syntax / --minify
node on the source, bun 1.4.0 undefined undefined undefined undefined undefined 3 same
bun 1.4.1 to 1.4.3, main 71bc818 undefined exports exports exports exports 0 exports exports exports exports exports 0
this branch undefined undefined undefined undefined undefined 3 same

It was found by fuzzing. There is no user report. A second face: a gettext style JSON file keeps its header under the key "", and const messages = require("./fr.json"); messages[""] gave the whole object with no flag.

What each part of the diff does.

  • fold.rs: for a namespace local, maybe_rewrite_property_access still creates the item symbol and counts the use, then leaves the node alone. The static import * as ns path is unchanged. The item symbol no longer takes a name in the module scope, because it never prints under its own name. On main that name made a nested class HttpError print as HttpError2 in a CommonJS file.
  • p.rs: record_import_property_use marks the node when the part counts a use of the item. It takes a marked read as the base of a chained read, so m.z.object() still binds at both levels. Single-use substitution does not move the initializer of the local into its own marked read.
  • LinkerContext.rs: bind_namespace_local_read records a bound item, and a missing one (it prints undefined). An item of ns.name() that must keep ns as this records nothing and prints as written.
  • js_printer/lib.rs: import_member_binding takes the printed node. Its base can be a namespace local or a marked read, and it prints the binding at the position of the member, as main does. A member of an enum through such a local is still inlined (m.Color.Red): the marked branch prints it, and try_to_get_imported_enum_value is as on main. The declarator merge (const a = ns.f, b = ns.k to {f: a, k: b} = ns) skips a local that has bindings. A marked read is printed at call level as the base of **, so ns.missing ** 2 loads.
  • React Compiler: its code generator prints its own copy of a property read. A new Host::namespace_local_read lets the parser count that copy like the read it visited, so a read in a compiled function binds as on main.
  • Part::removable_if_namespace_reads_bind (own commit): a top-level statement that the parser keeps only for such reads. The side-effect walk marks the part use of each item that it relied on (symbol::Use::runs_namespace_read). The linker removes the part like an unused one when it bound each marked item. The same flag keeps a split require() lazy, as on main. A part that leaves no statement takes back the namespace uses that it counted.
  • scan_imports.rs: the import-equals walk stops at a marked read off import x = require(), where main had an import identifier.
  • The code for these cases is in leaf helpers, not in print_expr, the substitution walk or the side-effect walk. Those functions call themselves along the nesting of the source, so each byte of their frames counts once per level.

Measurements (release builds of main 71bc818 and of this branch, same machine):

  • Bound reads: zod 4.5.4 m.z.object() with --minify is 78,253 B on both. 28 of 30 bundles (zod 4.5.4, effect 3.22.2, lodash-es 4.17.21 through await import, .then, Promise.all, a pattern and require, with and without --minify) are byte-identical. The other two require the CommonJS builds of zod and effect with --minify, and they hold the fix. zod keeps 116 (0,x.y)( call sites in 536,763 B (main 26 in 536,417 B). effect keeps 4,004 in 1,615,705 B (main 3,150 in 1,612,513 B).
  • Unbound reads: rhea-promise 3.0.3 connection.js with --minify-syntax has 24 (0, x.y)( call sites and 236,222 B (main 0 and 236,164 B). With no flag both have 24. 2,277 bundles of 600 random programs (three importee kinds, six local forms, two formats, three flag states): 0 differ from node on the source (main 1,344).
  • Files with no namespace local: three.js x10 (bench/bundle/index10.ts) is byte-identical, 13,142,725 B, and 7,367,588 B with --minify. One copy of three.js builds in 217.57M instructions (main 217.76M). A file of 7,000 top-level statements with 21,000 member reads builds in 68.95M (main 68.99M). By function, append_part takes 11 more instructions per top-level statement and the tree-shaking loop 3. The visit and the print of a member read take about 4 fewer.
  • Files with many such reads: 2,000 distinct reads off one await import() local take 55.13M to 55.70M instructions when all bind (main 52.82M to 53.62M) and 73.87M to 74.08M when none bind (main 72.61M to 72.72M), five runs each. That is about 1,150 and 650 instructions per read. The bundles are byte-identical.
  • Stack: the frame of print_expr is 456 to 488 bytes (main 504 to 536, by instantiation), and 12,288 bytes in a debug build (main 12,512). visit_stmt::s_expr and s_local are 16 bytes larger, s_if is 16 smaller. The deepest nesting that builds was measured for 11 shapes (calls, conditionals, member chains, declarations, call statements, if, functions, object and array literals, JSX, arrow functions). None is lower. Three are higher: conditionals 7,619 (main 6,984), JSX 3,224 (main 2,958), if 3,899 (main 3,839).
  • Instructions are counted with qemu-x86_64 -one-insn-per-tb -d exec and mapped to functions with nm. A frame is the sub rsp of a prologue. valgrind, perf and bloaty are not available in this environment. Wall clock on three.js x10, interleaved main / branch / main, n = 25: the medians are 575 ms, 506 ms and 579 ms, and each set has an interquartile range of about 450 to 690 ms, so it shows no difference.
  • size_of (-Zprint-type-sizes): Part 200 bytes, E::Dot and E::Index 36, Expr 16, Symbol 48, symbol::Use 4. All are as on main.
  • Barrel export const a = ns.a, b = ns.b, c = ns.c with one export used: byte-identical to main, 49 B with --minify, one export kept.
  • React Compiler: lodash-es read in a compiled component is byte-identical to main (26,272 B). With --minify it is 10,370 B (main 10,369 B). The short names differ, because the namespace local has one more counted use for each read in a compiled function.
  • Release binary: .text 81,913,957 B (main 81,904,741 B), .rodata 22,623,180 B (main 22,619,084 B), from size -A.
  • One existing expectation changes: two entries of the bundler_bytecode_portable.test.ts snapshot move (undici and the libraries corpus). The undici bundle differs from main in one local name (connect, not connect2). bun build --no-bundle of the repro is byte-identical in all three flag states.

Output for a read that binds (bundled ES-module importee), against main:

source main this branch
ns.f(), ns.inner.g(), ns.missing, m.Color.Red f(), g(), undefined, 1 /* Red */ same
(0, ns.f)() under --minify-syntax, (true ? ns.f : 0)() f() (0, f)()
const f = ns.f; f() under --minify-syntax f() let f2 = f; f2()
{ k: ns.k } { k } { k: k }
in-function ns.a; void ns.a; a; a; a;
if (ns.flag) {} under --minify-syntax removed if (flag);
top-level ns.a;, void ns.a;, const unused = ns.a, class U extends ns.Base {}, const { h } = ns.inner removed removed

The existing path for X.name on a static import already prints (0, f)() and { k: k }. A printer step that drops (0, ...) around a bound name would serve both.

Other changes against main.

  • --react-fast-refresh: by reading the code, a hook that is called through a namespace local is no longer in the _s list, as before 1.4.1.
  • A type-only import Request = ext.Request off import ext = require("ext") with an external ext prints var Request = ext.Request;. Main removes it.
  • Classic JSX with the factory on a namespace local, after a dead JSX statement in the same file: the call prints ns.h(...) and keeps the namespace object. Main binds it because the dead statement leaves a count behind.
  • A static import { E } enum with a negative member as the base of ** now prints (-1) ** 2. Main prints -1 ** 2, which does not parse.
  • A write to an enum member through a namespace local (m.Color.Red = 5) prints Color.Red = 5. Main prints 1 /* Red */ = 5, which does not parse.

Not changed. These are wrong on 1.4.0 too and are handed off as separate work: (0, ns.f)() for import * as ns or a named import of a CommonJS module, (0, exports.f)() inside a CommonJS module, a wrapper used as a template tag, ([] ? o.m : 0)(), and ns[""] on a star import of an external module. #40829 is the candidate for the this shapes. Under ESM output, require() of a package on the unwrap list (react, react-dom, scheduler and others) becomes an import statement before this code runs, so it keeps the 1.4.0 output. Inside a React-compiled function, the compiler drops (0, o.f)() wrappers and unused reads for any property access. The import-equals scanner appends a file's last statement twice when one alias of a namespace is kept and another is removed. This PR does not touch that. ns.f() written as a method call on a read that binds still prints f(), as #41186 states.

The differential run found two more wrong outputs. Both reproduce on 1.4.2 and this PR does not touch them. --minify-syntax folds [f][0]() to f() for any f, so this is undefined and not the array. --format=cjs drops the "use strict" directive of a bundled CommonJS module when the entry is not strict, so a plain call in it has this === globalThis.

Self-review. A review of the first build raised 23 concerns. Addressed: the React Compiler binding, the enum fallback, the split require() classification, the import-equals aliases, the classic JSX dead statement, source-map positions, the read that substitution creates, per-name binding tests, the smaller fold.rs diff, one side-effect walk, mutant tests, the unused-read matrix and the cost list. Not taken: a fuzz generator in the tree (the differential run above was done by hand), a fix for the import-equals scanner, and a test for the fast-refresh hook list.

The rebase on 71bc818 brought a test that nests 280 calls on a debug build, where main has room for 282. It failed. The first cost list did not have the stack: the frame of print_expr was 160 bytes larger in a debug build. The last commit removes that.

Suites run. All of test/bundler with a release build of the branch: 7,825 pass. The one failure is the libraries entry of the bytecode snapshot. The test/node_modules of this machine differ from those of CI, so that entry also fails with main's binary and main's snapshot. On the debug build: bundler_dynamic_import_dce (405 pass) and transpiler/react-compiler (123 pass). In one of five runs, nesting tests of react-compiler.test.ts hit the 5 s timeout on the debug build while the machine was busy.


no test proof · iteration 1 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/bundler/bundler_bytecode_portable.test.ts

@robobun

robobun commented Oct 15, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 4:01 PM PT - Oct 21st, 2026

✅ @robobun, your commit 0b9cc62bd32016d01ba689aeb1a7e4310b6e37a3 passed in Build #130218! 🎉


🧪   To try this PR locally:

bunx bun-pr 45296

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

bun-45296 --bun

Comment thread src/ast/ast_result.rs Resolved
Comment thread src/ast/e.rs Resolved
Comment thread src/ast/nodes.rs Resolved
Comment thread src/ast/symbol.rs Resolved
Comment thread src/ast/symbol.rs Resolved
Comment thread src/bundler/LinkerContext.rs Resolved
Comment thread src/bundler/LinkerContext.rs Resolved
Comment thread src/bundler/LinkerContext.rs Resolved
Comment thread src/js_parser/fold.rs Resolved
Comment thread src/js_parser/fold.rs Resolved
Comment thread src/js_parser/p.rs Resolved
Comment thread src/js_parser/p.rs Resolved
Comment thread src/js_parser/p.rs Resolved
Comment thread src/js_parser/p.rs Resolved
Comment thread src/js_parser/p.rs Resolved
Comment thread src/js_parser/p.rs Resolved
Comment thread src/js_parser/p.rs Resolved
Comment thread src/js_parser/scan/scan_imports.rs Resolved
Comment thread src/js_printer/lib.rs Resolved
Comment thread src/js_printer/lib.rs Resolved
Comment thread src/js_printer/lib.rs Resolved
Comment thread src/js_printer/lib.rs Resolved
Comment thread src/react_compiler/program.rs Resolved
@robobun
robobun force-pushed the robobun/b7bdb581/namespace-local-read-stays-property-access branch from 58d8b98 to 8a8e59e Compare October 15, 2026 03:39
@robobun
robobun force-pushed the robobun/b7bdb581/namespace-local-read-stays-property-access branch from 9136c70 to b01e9ad Compare October 18, 2026 01:09
The linker does not always bind such a read to an export. The read is
then a property read: a call through it passes no receiver, and a getter
runs. The UnboundItem tests and the React Compiler test fail at this
commit. The Elide tests pin the output of a read that binds.
…ccess

The parser made an import item node of ns.f when ns holds the result of
require() or import(). Only the linker knows whether that item binds to
an export. When it does not bind, the read is a property read of an
object. But the parser had already handled the node as an ES import. It
removed the (0, ns.f) wrapper of a call, moved the read and dropped an
unused read.

The read now stays an E::Dot or E::Index with is_import_property_use
set. The parser makes and counts the import item symbol as before. The
flag is set only when the part counts a use of that item.

Single-use substitution does not move the initializer of the local into
such a read. The import item no longer takes a name in the module scope,
because it never prints under its own name. The TypeScript import-equals
scan stops at such a read, as it stopped at the import item node.
The linker matches the import item of ns.name with an export. When it
binds the item, or finds that the importee has no such name, it records
the item in import_member_bindings under the local and the name. A read
with no record prints as written.
… local

A marked E::Dot or E::Index whose target is a require() or import() local
prints the import item that the linker recorded for it. A read with no
record prints as written. The binding prints at the location of the
member name, so the source map does not change.

An enum member read through such a local is still inlined. The lookup
runs only for a marked node whose base is a read off such a local.

The same-target destructuring merge skips a local that has a bound read.
A marked read on the left of ** prints at call level, so undefined or a
negative enum value gets parentheses.
…ocal

Codegen prints its own copy of a property read. The copy had no
is_import_property_use flag, and its use of the local made the local
look escaped. A new Host method tells codegen whether the read has an
import item. The host then counts the local as a tracked use and counts
the item again, so the read binds as a read outside a compiled function
does.
A host that generates no code has no such locals. The default answer is
false.
… bind

A read off a require() or import() local is now a property access, so
the parser keeps a top-level statement that holds one. When the linker
binds every such read of the statement, the statement has no side effect
and tree shaking can remove it again.

The parser sets Part::removable_if_namespace_reads_bind on such a part
and marks the part uses of the import items that those reads name. The
linker removes the part only if it recorded a binding for each marked
item. A part with the flag does not make a split require() load early.

A part that leaves no statement takes back the tracked uses of a
namespace local that it counted.
The new cases cover a TypeScript import-equals local, a name that binds
beside one that does not, a split require(), unused reads in each
statement form, a read that single-use substitution makes, classic JSX,
a server component, the source map of a bound read, and the names of
nested bindings in a wrapped CommonJS file.
…function

A compiled function, a function that only has a component name and a
function that the compiler rejects read the local. The file also reads
it at the top level, above or below the functions. Each read prints the
export, and the namespace object is not in the bundle.
… local

The scan of import A = x.A walks to the identifier that the alias names.
It now stops at a marked read only when the target of that read is a
local that holds a require() or import() namespace. A marked read off a
local that a pattern binds is walked through as before.

Two comments describe the read as a property access with an import item.
A needless borrow in the printer is gone.
…nused reads

An enum member is inlined only through a read that the parser counted as
a read of the member. A read through an import, through a local that a
pattern binds or through two members prints the enum object.

The unused reads now include a statement that also has a read that
binds, and a read through a local with one use.
A local with a name that a pattern binds, and no bound read, still gets
its declarators merged into a pattern.
The guard that a helper function held is the one main has in place again,
and the read off a require() / import() local is answered after it.
main no longer has that option or its useState diagnostic.
…names change

The undici bundle and the libraries corpus bundle read members off a
require() local. Those reads now print the export under its own name
where the bundle printed a renamed copy (connect2, error2). The hashes
of the two bundles move. No other entry changes.
…nctions

A debug build gives each value in a function a stack slot. The code for a
read off a require() or import() local sat in `print_expr`, in the
substitution walk and in the side-effect walk, which all call themselves
along the nesting of the source. The frame of `print_expr` was 160 bytes
larger than on main in a debug build, so 280 nested calls overflowed where
main has room for 282.

- js_printer: `import_member_binding` takes the node. The value of a member
  of an enum that a namespace local holds prints from the marked branch,
  and `try_to_get_imported_enum_value` is as on main. The declarator merge
  and the base of `**` ask one helper each.
- js_parser: the substitution walk and the side-effect walk call one helper
  each. `is_marked_read_off` is a free function, so it compiles once.
- react_compiler: `property_load_expr` builds the marked read.
- bundler: `part_namespace_reads_are_bound` is cold.

The frame of `print_expr` is now 224 bytes smaller than on main in a debug
build and 48 bytes smaller in a release build. The test for a computed
enum key through a namespace local pins the output of main.

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.

2 participants