Repository navigation
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
Draft
bundler: keep a read off a require() / import() local a property access until the linker binds it#45296robobun wants to merge 17 commits into
robobun wants to merge 17 commits into
Conversation
Collaborator
Author
|
Updated 11:53 AM PT - Oct 19th, 2026
✅ @robobun, your commit ab5079aa4dd0d55c6b688843f6395dd4427866fc passed in 🧪 To try this PR locally: bunx bun-pr 45296That installs a local version of the PR into your bun-45296 --bun |
robobun
force-pushed
the
robobun/b7bdb581/namespace-local-read-stays-property-access
branch
from
October 15, 2026 03:39
58d8b98 to
8a8e59e
Compare
This was referenced Oct 15, 2026
robobun
force-pushed
the
robobun/b7bdb581/namespace-local-read-stays-property-access
branch
from
October 18, 2026 01:09
9136c70 to
b01e9ad
Compare
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.
…off a require() local names
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.
robobun
force-pushed
the
robobun/b7bdb581/namespace-local-read-stays-property-access
branch
from
October 19, 2026 15:07
b01e9ad to
ab5079a
Compare
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
this. Forconst x_1 = require("./x"),(0, x_1.f)()printsx_1.f()under--minify-syntax.(1 && x_1.f)()prints it with no flag. An unused getter readns.a;is removed.maybe_rewrite_property_access(src/js_parser/fold.rs). It madens.fan import item before the linker knew if the item binds.Fix
is_import_property_use).test/bundler/bundler_dynamic_import_dce.test.tsandreact-compiler.test.ts(42 new tests fail on main), 20 other bundler suites. Self-reviewed: 23 concerns raised, 20 addressed (Notes).Background
require()orimport().a.b()passesaasthis.(0, a.b)()passesundefined. TypeScript prints imported calls in that form.thisand getter for an unbound item of a require() / import() local #42402, which this replaces): it keeps the rewrite and patches three readers.Downsides
(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..text.Notes
Repro (bundle with
bun build ./entry.js --target=node, run the bundle under node 26.3.0):--minify-syntax/--minifyundefined undefined undefined undefined undefined 3undefined exports exports exports exports 0exports exports exports exports exports 0undefined undefined undefined undefined undefined 3It was found by fuzzing. There is no user report. A second face: a gettext style JSON file keeps its header under the key
"", andconst 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_accessstill creates the item symbol and counts the use, then leaves the node alone. The staticimport * as nspath 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 nestedclass HttpErrorprint asHttpError2in a CommonJS file.p.rs:record_import_property_usemarks the node when the part counts a use of the item. It takes a marked read as the base of a chained read, som.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_readrecords a bound item, and a missing one (it printsundefined). An item ofns.name()that must keepnsasthisrecords nothing and prints as written.js_printer/lib.rs:import_member_bindingtakes 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.kto{f: a, k: b} = ns) skips a local that has bindings. A marked read is printed at call level as the base of**, sons.missing ** 2loads.Host::namespace_local_readlets 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 splitrequire()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 offimport x = require(), where main had an import identifier.Measurements (release builds of main f841e36 and of this branch, same machine):
m.z.object()with--minifyis 78,229 B on both. 29 of 30 bundles (zod 4.5.4, effect 3.22.2, lodash-es 4.17.21 throughawait import,.then,Promise.all, a pattern andrequire, 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).connection.jswith--minify-syntaxhas 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).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).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.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:Part200 bytes, unchanged (the new bool takes padding).E::Dot,E::Index,ExprandSymbolare unchanged.symbol::Usestays oneu32.export const a = ns.a, b = ns.b, c = ns.cwith one export used: byte-identical to main, 41 B with--minify, one export kept.--minify). A CommonJS importee read in a compiled component prints3 undefined first,second(main3 exports second,first)..text80,867,813 B (main 80,861,669 B),.rodata22,557,420 B (main 22,553,324 B), fromsize -A.bundler_bytecode_portable.test.tssnapshot move (undici and the libraries corpus). Their bundles differ from main only in local names (connect, notconnect2).bun build --no-bundleof the repro is byte-identical in all three flag states.Output for a read that binds (bundled ES-module importee), against main:
ns.f(),ns.inner.g(),ns.missing,m.Color.Redf(),g(),undefined,1 /* Red */(0, ns.f)()under--minify-syntax,(true ? ns.f : 0)()f()(0, f)()const f = ns.f; f()under--minify-syntaxf()let f2 = f; f2(){ k: ns.k }{ k }{ k: k }ns.a; void ns.a;a;a; a;if (ns.flag) {}under--minify-syntaxif (flag);ns.a;,void ns.a;,const unused = ns.a,class U extends ns.Base {},const { h } = ns.innerThe existing path for
X.nameon 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_slist, as before 1.4.1.import Request = ext.Requestoffimport ext = require("ext")with an externalextprintsvar Request = ext.Request;. Main removes it.ns.h(...)and keeps the namespace object. Main binds it because the dead statement leaves a count behind.import { E }enum with a negative member as the base of**now prints(-1) ** 2. Main prints-1 ** 2, which does not parse.m.Color.Red = 5) printsColor.Red = 5. Main prints1 /* 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)()forimport * as nsor a named import of a CommonJS module,(0, exports.f)()inside a CommonJS module, a wrapper used as a template tag,([] ? o.m : 0)(), andns[""]on a star import of an external module. #40829 is the candidate for thethisshapes. Under ESM output,require()of a package on the unwrap list (react,react-dom,schedulerand 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 anamespaceis kept and another is removed. This PR does not touch that.ns.f()written as a method call on a read that binds still printsf(), 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 smallerfold.rsdiff, 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_splittingandesbuild/defaulthave 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