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 18 commits into
Draft
bundler: keep a read off a require() / import() local a property access until the linker binds it#45296robobun wants to merge 18 commits into
robobun wants to merge 18 commits into
Conversation
Collaborator
Author
|
Updated 4:01 PM PT - Oct 21st, 2026
✅ @robobun, your commit 0b9cc62bd32016d01ba689aeb1a7e4310b6e37a3 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
robobun
force-pushed
the
robobun/b7bdb581/namespace-local-read-stays-property-access
branch
from
October 19, 2026 15:07
b01e9ad to
ab5079a
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.
…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.
robobun
force-pushed
the
robobun/b7bdb581/namespace-local-read-stays-property-access
branch
from
October 21, 2026 16:18
ab5079a to
0b9cc62
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.ns.a;is removed.maybe_rewrite_property_access(src/js_parser/fold.rs). It madens.fan import item before the linker knew if it binds.Fix
is_import_property_use).test/bundler/bundler_dynamic_import_dce.test.tsandreact-compiler.test.ts(42 new tests fail on main), all oftest/bundler. 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. 28 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 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, andtry_to_get_imported_enum_valueis as on main. 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.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):
m.z.object()with--minifyis 78,253 B on both. 28 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 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).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. 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).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_parttakes 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.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.print_expris 456 to 488 bytes (main 504 to 536, by instantiation), and 12,288 bytes in a debug build (main 12,512).visit_stmt::s_exprands_localare 16 bytes larger,s_ifis 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),if3,899 (main 3,839).qemu-x86_64 -one-insn-per-tb -d execand mapped to functions withnm. A frame is thesub rspof 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):Part200 bytes,E::DotandE::Index36,Expr16,Symbol48,symbol::Use4. All are as on main.export const a = ns.a, b = ns.b, c = ns.cwith one export used: byte-identical to main, 49 B with--minify, one export kept.--minifyit 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..text81,913,957 B (main 81,904,741 B),.rodata22,623,180 B (main 22,619,084 B), fromsize -A.bundler_bytecode_portable.test.tssnapshot move (undici and the libraries corpus). The undici bundle differs from main in one local name (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.The differential run found two more wrong outputs. Both reproduce on 1.4.2 and this PR does not touch them.
--minify-syntaxfolds[f][0]()tof()for anyf, sothisisundefinedand not the array.--format=cjsdrops the"use strict"directive of a bundled CommonJS module when the entry is not strict, so a plain call in it hasthis === 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 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.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_exprwas 160 bytes larger in a debug build. The last commit removes that.Suites run. All of
test/bundlerwith a release build of the branch: 7,825 pass. The one failure is the libraries entry of the bytecode snapshot. Thetest/node_modulesof 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) andtranspiler/react-compiler(123 pass). In one of five runs, nesting tests ofreact-compiler.test.tshit 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