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 17 commits into
mainfrom
robobun/b7bdb581/namespace-local-read-stays-property-access
Draft

robobun wants to merge 17 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 the item 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), 20 other bundler suites. 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. 29 of 30 zod, effect and lodash-es bundles are byte-identical to main.
  • A file with 2,000 such reads takes +4.4% instructions to build. A file with none: no change.
  • The release binary grows by 6,144 B of .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 f841e36 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 a namespace local and a marked read as base, and prints the binding at the position of the member, as main does. Enum members through such a local are still inlined (m.Color.Red). 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.

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

  • Bound reads: zod 4.5.4 m.z.object() with --minify is 78,229 B on both. 29 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 one requires zod's CommonJS build: 471,008 B with 116 (0,x.y)( call sites (main 470,662 B with 26).
  • 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. 870 bundles of 290 random programs (three importee kinds, all local forms, three formats, three flag states): 0 differ from node on the source (main 365).
  • Files with no namespace local: three.js x10 (bench/bundle/index10.ts) is byte-identical, 13,142,725 B. A file with 21,000 member reads builds in 58.39M and 58.41M instructions (main 58.39M and 58.48M, two runs each).
  • Files with many such reads: 2,000 distinct reads off one await import() local take 55.54M instructions when all bind (main 53.21M) and 73.8M when none bind (main 72.5M). That is about 1,170 and 610 instructions per read. The bundles are byte-identical.
  • Instructions are counted with qemu-x86_64 -one-insn-per-tb -d exec. valgrind, perf and bloaty are not available in this environment. Wall clock on three.js x10, interleaved main / branch / main, n = 25: the branch is within the A/A noise (2 to 5%).
  • size_of: Part 200 bytes, unchanged (the new bool takes padding). E::Dot, E::Index, Expr and Symbol are unchanged. symbol::Use stays one u32.
  • Barrel export const a = ns.a, b = ns.b, c = ns.c with one export used: byte-identical to main, 41 B with --minify, one export kept.
  • React Compiler: lodash-es read in a compiled component is byte-identical to main (80,076 B, 36,509 B with --minify). A CommonJS importee read in a compiled component prints 3 undefined first,second (main 3 exports second,first).
  • Release binary: .text 80,867,813 B (main 80,861,669 B), .rodata 22,557,420 B (main 22,553,324 B), from size -A.
  • One existing expectation changes: two entries of the bundler_bytecode_portable.test.ts snapshot move (undici and the libraries corpus). Their bundles differ from main only in local names (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.

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.

Suites run on the debug build: bundler_dynamic_import_dce (404 pass), transpiler/react-compiler (122), bundler_cjs2esm (113), bundler_barrel (70), bundler_minify (65), bundler_cjs (62), bundler_promiseall_deadcode (3), bundler_regressions (9), bundler_npm (2), bundler_jsx (64), bundler_bun (12), bundler_browser (23), bundler_plugin (65), bundler_drop (14), bundler_feature_flag (41), esbuild/dce (87), esbuild/importstar (99), esbuild/importstar_ts (23), esbuild/ts (59), esbuild/splitting (26), transpiler/transpiler.test.js (237). bundler_edgecase, bundler_splitting and esbuild/default have tests that hit the 5 s or 90 s timeout on a debug build. The same tests time out on a debug build of main, and each passes alone.


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 11:53 AM PT - Oct 19th, 2026

✅ @robobun, your commit ab5079aa4dd0d55c6b688843f6395dd4427866fc passed in Build #128926! 🎉


🧪   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 Outdated
Comment thread src/ast/e.rs Outdated
Comment thread src/ast/nodes.rs Outdated
Comment thread src/ast/symbol.rs Outdated
Comment thread src/ast/symbol.rs Outdated
Comment thread src/bundler/LinkerContext.rs Outdated
Comment thread src/bundler/LinkerContext.rs Outdated
Comment thread src/bundler/LinkerContext.rs Outdated
Comment thread src/js_parser/fold.rs Outdated
Comment thread src/js_parser/fold.rs Outdated
Comment thread src/js_parser/p.rs Outdated
Comment thread src/js_parser/p.rs Outdated
Comment thread src/js_parser/p.rs Outdated
Comment thread src/js_parser/p.rs Outdated
Comment thread src/js_parser/p.rs Outdated
Comment thread src/js_parser/p.rs Outdated
Comment thread src/js_parser/p.rs Outdated
Comment thread src/js_parser/scan/scan_imports.rs Outdated
Comment thread src/js_printer/lib.rs Outdated
Comment thread src/js_printer/lib.rs Outdated
Comment thread src/js_printer/lib.rs Outdated
Comment thread src/js_printer/lib.rs Outdated
Comment thread src/react_compiler/program.rs Outdated
@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.

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