Repository navigation
Conversation
|
No actionable comments were generated in the recent review.
|
| Layer / File(s) | Summary |
|---|---|
Const-value contract and declaration admission src/ast/ast_result.rs, src/ast/expr.rs, src/ast/lib.rs, src/js_parser/parser.rs, src/js_parser/visit/mod.rs |
ConstValuesMap now stores ConstValue. Declaration visiting passes the local kind and admits values through ConstValue::new, which checks the binding kind, default status, and supported expression types. |
Macro destructuring and replacement access src/js_parser/visit/mod.rs, src/js_parser/p.rs |
Macro-produced objects and arrays are recursively processed for destructuring. Eligible identifier leaves can record const values, and identifier replacement reads the expression through expr(). |
Macro binding and inlining tests test/bundler/transpiler/macro-test.test.ts |
Tests compare macro-bound modules with plain imports and cover declaration kinds, reads, writes, destructuring, inlining, and macro arguments. Debug and ASAN builds sample generated cases every 16th case. |
Suggested reviewers: jarred-sumner, alii
Priority:
Severity of issue fixed: Medium
Merge Risk: 
Minimal · up to 661da
-
The change limits macro-result inlining to const bindings and rebuilds destructured results without mutating the macro's value. No concrete merge-blocking risk remains after review.
Pre-merge checks: 
4
-
Full details: Linked Issues check
-
Explanation
[
#12067] requires a macro result assigned toletto remain a valid assignment reference.visit_declsnow passesLocalKindto declaration handling, andConstValue::newadmits values only forKConst. Macro results forletandvartherefore remain runtime bindings. The reported regression tests cover reassignment and pass with the change.
Full details: Out of Scope Changes check
-
Explanation
The changes stay within the macro inlining and destructuring path used by [
#12067]. The newConstValueadmission check, non-mutating destructuring prune, and related tests support safe handling of macro results. The separate behavior that copies macro-returned objects and arrays at eachconstread remains unchanged and is explicitly excluded.
Full details: Description check
-
Explanation
The pull request description clearly explains the problem, implementation, scope, trade-offs, and verification. It does not use the template headings exactly, but it provides equivalent and substantially more detailed information for both required sections.
-
- Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.
Comment @coderabbitai help to get the list of available commands.
|
Found 2 issues this PR may fix:
🤖 Generated with Claude Code |
There was a problem hiding this comment.
The follow-up commit addresses the default_value gap I flagged — maybe_record_macro_const_value now skips EUndefined when the pattern has a default, and has_default is threaded through both the object and array recursion. All three changes are strictly conservative (they only remove entries from const_values, never add), so the worst case is a missed inline rather than a new miscompile. Deferring to a maintainer to confirm the optimization tradeoff of dropping the EArray/EObject arms from the shared can_be_const_value() predicate is acceptable.
What was reviewed:
can_be_const_value()has only two callers (visit_declBIdentifier and the new helper);can_be_moved()retains itswas_originally_macroarms so single-use substitution is unaffected.was_constgate oncould_be_macronow makeslet s = macro()setis_after_const_local_prefixlike any otherlet— consistent, not a regression.has_default+EUndefinedcheck: tracednull(does not trigger default → correctly still inlined) and short-array / missing-property cases (loop doesn't reach them → correctly left as bindings).- #9613 (in-place property compaction) confirmed pre-existing and orthogonal; scoped out per author's reply.
Extended reasoning...
Overview
Three files: src/ast/expr.rs drops the EArray/EObject arms from can_be_const_value(); src/js_parser/visit/mod.rs gates could_be_macro on was_const, threads a has_default flag through visit_binding_and_expr_for_macro, and factors the leaf insert into maybe_record_macro_const_value which additionally skips when has_default && EUndefined; a new test file exercises bun run, bun build, and bun build --minify across direct object/array bindings, nested destructuring, defaults over defined and undefined slots, and let reassignment.
Delta since my last review
My prior review flagged two pre-existing issues. The author pushed 3dad298 which fully addresses the default_value one (new helper + has_default param + holes/holesArr test cases) and explicitly scoped out the property-compaction reorder bug as #9613 — a separate defect in the lookup-after-overwrite logic, not in the "should this go into const_values" decision this PR tightens. Both threads are resolved.
Security risks
None. Build-time transpiler const-folding; no untrusted-input parsing surface changes, no allocation logic touched.
Level of scrutiny
Moderate-high — the visit pass is a hot, load-bearing code path and can_be_const_value() is a shared predicate. That said, every change here is a strict tightening: fewer expressions qualify for const_values, and could_be_macro requires more to be true. There is no path by which this can introduce a new wrong-value substitution; the only possible downside is that const o = macro(); o.x no longer folds through the identifier (direct macro().x and destructured leaves still do, per the tests). A maintainer should sign off on that tradeoff.
Other factors
- Callers of
can_be_const_value(): only two, both in this diff's blast radius.can_be_moved()(single-use-var substitution) still returnstruefor macro objects/arrays at expr.rs:2406-2407, so the separate move optimization is unchanged. - The
is_after_const_local_prefixside effect of thewas_constgate now fires forlet x = macro()where it previously didn't — this matches ordinaryletbehavior and is more consistent. - Test file is new rather than appended to
macro-test.test.ts; the PR body states that file currently aborts the debug build on an unrelated assertion, which is a reasonable justification. - Test structure follows harness conventions:
tempDir,bunEnv/bunExe, concurrent draining of stdout/stderr/exited,test.concurrent, combined-object assertions.
There was a problem hiding this comment.
No issues found, but this is transpiler-correctness logic (macro const-inlining) rather than a mechanical change, so it's worth a maintainer glance before merge. The robobun status is also still red on 3dad298a (build-bun step) — the two later commits look like they'd fix whatever broke there, but CI hasn't reported on HEAD yet.
What was reviewed:
can_be_const_value()callers — only the two sites invisit/mod.rs;can_be_moved()still keeps thewas_originally_macroarms, so tree-shaking/movability of macro objects is unchanged.is_after_const_local_prefix—let s = macro()now falls into theelseand sets it (previously didn't); this matches the non-macroletpath and is strictly more correct.- The
has_default && EUndefinedgate correctly leavesnullalone (JS defaults trigger only onundefined); nested EObject/EArray leaves reached via BIdentifier now bail oncan_be_const_value()soinner === innerholds.
Extended reasoning...
Overview
The PR removes the EArray/EObject arms from Data::can_be_const_value() and threads two new gates through the macro-destructuring path in visit/mod.rs: was_const (the declaration keyword) and has_default (the binding pattern has a default initializer). A new maybe_record_macro_const_value helper centralizes the decision to insert into const_values. visit_decl gains a was_const parameter and now requires was_const && could_be_macro for the macro branch. A new test file exercises bun run, bun build, and bun build --minify across identity, destructured leaves with defaults, and let reassignment.
Security risks
None. This is AST-shape logic in the transpiler; no I/O, no untrusted input parsing beyond what the parser already handles.
Level of scrutiny
Medium-high. Const-inlining decides whether an expression is duplicated at every use site, so a wrong true here silently miscompiles user code (as #7196 and #12067 demonstrate). The fix is a strict tightening — every path that previously inserted into const_values now has an additional gate — so the risk profile is "macro output slightly less inlined" rather than "new miscompile", but a maintainer who owns the macro/inlining path should confirm the is_after_const_local_prefix side-effect change for let x = macro() (now sets the flag; previously didn't) is intended.
Other factors
- The two concerns I raised on the earlier revision are resolved: the
default_value-over-undefinedcase is now handled inmaybe_record_macro_const_valueand covered by theholes/holesArrtests; the reordered-destructure compaction bug is explicitly scoped out as #9613. can_be_const_value()has exactly two call sites, both in this file, andcan_be_moved()retains thewas_originally_macroarms — so no other consumer loses the old behavior.- CI (robobun) shows
build-bunfailing on 4 platforms at3dad298a; two commits landed after (8b45d92frefactor,62732cb4autofix) and the current file reads as valid Rust, but the status hasn't updated for HEAD. - The test file is placed separately from
macro-test.test.tsbecause that file currently aborts the debug build; that's a reasonable justification but worth a maintainer ack.
|
Rebased onto main at 1d85431. Verified locally on the rebased commit:
The previous red checks were unrelated to the diff: the three darwin test lanes expired without running, and the clippy job failed at |
62732cb to
a3f66e1
Compare
There was a problem hiding this comment.
No issues found — the change strictly narrows what enters const_values, so it can only reduce inlining, not introduce new incorrect substitutions. My earlier note on default_value + EUndefined is now handled by maybe_record_macro_const_value, and #9613 is reasonably left out of scope. Deferring to a human only because this is the transpiler hot path that runs on every file.
What was reviewed
can_be_const_value()has just two callers, both invisit/mod.rs; dropping theEArray/EObjectarms only affects the macro const-inlining paths this PR is fixing.- Macro deserializer emits only primitives,
EObject, andEArray— the_arm inBObjectstill folds every primitive it did before after the newcan_be_const_value()gate. let x = macro()now falls into theelseand setsis_after_const_local_prefix = true; that matches ordinaryletbehavior and is at worst a de-opt for laterconsts in the same prefix.
Extended reasoning...
Overview
Two-line removal from Data::can_be_const_value() in src/ast/expr.rs (drops the EArray/EObject arms whose "no observable difference if duplicated" contract they violated), plus a focused refactor in src/js_parser/visit/mod.rs: visit_decl gains a was_const parameter, the macro-branch condition becomes was_const && could_be_macro, and the destructuring leaf insert is factored into maybe_record_macro_const_value which also checks has_default && EUndefined. has_default is threaded through the BObject/BArray recursion. A new 200-line test file exercises bun run, bun build, and bun build --minify across direct bindings, nested destructuring, defaults over undefined vs. defined, and let reassignment.
Security risks
None. This is const-folding logic in the AST visitor; no untrusted-input parsing, no allocation sizing, no I/O.
Level of scrutiny
High — js_parser/visit runs on every transpiled file, and can_be_const_value() is a shared predicate. I verified it has exactly two call sites (both in visit/mod.rs, both the paths under repair), and the macro deserializer's output set (ENull/EUndefined/EBoolean/ENumber/EString/EObject/EArray) means the new gate on the BObject _ arm rejects nothing that was previously (correctly) admitted. Every edit narrows the set of refs entering const_values, so the failure mode is a missed optimization, not a new miscompile.
Other factors
My earlier inline comment about default_value being ignored was addressed in a follow-up commit (now the has_default/EUndefined guard, covered by the holes/holesArr cases). The other pre-existing note (#9613, in-place property compaction) is a distinct algorithm bug in the same function and the author's decision to keep it out of scope is sound. Tests follow harness conventions (tempDir, bunEnv, concurrent draining of stdout/stderr/exited, test.concurrent). CI on the completed lanes is green; remaining red is build-infra unrelated to the parser. Deferring solely because a human should sign off on any change to the transpiler's const-inlining decision path.
a3f66e1 to
1d85431
Compare
There was a problem hiding this comment.
LGTM — the follow-up commit addressed the default_value/EUndefined gap I flagged, and #9613 is reasonably scoped out.
What was reviewed:
can_be_const_value()has only the two call sites invisit/mod.rs; dropping theEArray/EObjectarms only affectswas_originally_macrovalues, so non-macro const-inlining is unchanged.- Checked that not-inlining is always safe: the decl-removal loop only skips a decl when its ref is in
const_values, soconst o = macroObj()now stays emitted;is_after_const_local_prefixforletmacro decls now matches non-macrolet. - Traced
has_defaultthrough nestedBObject/BArrayrecursion including theBIdentifier-binding-with-EObject-value case and theEUndefined-with-nested-pattern case — all fall through to "skip insert". - Property/item truncation still runs for
letdestructures (only theconst_valuesinsert is gated), which the last test asserts.
Extended reasoning...
Overview
Three-file change fixing #7196 and #12067: src/ast/expr.rs drops the EArray/EObject arms from can_be_const_value(), src/js_parser/visit/mod.rs threads was_const into visit_decl and has_default through visit_binding_and_expr_for_macro, factoring the leaf insert into maybe_record_macro_const_value, and a new 6-case test file covers bun run / bun build / bun build --minify for object/array identity, nested destructuring, destructure defaults over undefined, and let reassignment.
Security risks
None. Transpiler const-inlining correctness only; no untrusted-input parsing changes, no I/O, no allocation-size arithmetic touched.
Level of scrutiny
Medium — parser/visit-pass code is core, but the change is strictly narrowing: it only removes refs from const_values that were previously (incorrectly) inlined. Not-inlining is always the safe fallback because handle_identifier leaves the ref alone when it is not in the map, and the decl-removal loop only drops a decl whose ref is in const_values with zero uses. I confirmed can_be_const_value() has exactly two callers (both in this file) and visit_decl has exactly two callers (both updated), so there is no missed call site. The was_originally_macro guard on the removed arms means non-macro const x = [...] was already excluded, so ordinary const-inlining is untouched.
Other factors
- My earlier inline comment about
default_value+EUndefinedwas addressed in 3dad298 with thehas_defaultthreading and covered by theda/dxassertions in the new test. The other inline comment (in-place property compaction, #9613) is a separate lookup-after-overwrite defect that predates this PR; the author scoped it out with a stated reason and the PR description calls it out explicitly. - Test quality is good per REVIEW.md:
test.concurrent,tempDir/bunEnv/bunExe, concurrent pipe drain, combined{out, stderr, exitCode}assertion, and a negative-contract check thatlet { a } = big()still truncates unused properties. The PR body evidence block shows 5 failing on the released bun and all 6 passing on both debug-ASAN and release with the fix. - No CODEOWNERS on
src/js_parser/orsrc/ast/.
### Problem - `visit_decl` in `src/js_parser/visit/mod.rs` took three adjacent `bool` parameters (`was_anonymous_named_expr`, `could_be_const_value`, `could_be_macro`), and the second of its two callers passed them positionally: `visit_decl(decl, false, was_const && !is_after, false)`. Nothing at that call says which flag is which. - This is the one `bare_bool_args` finding recorded for this file in `mordant-baseline.toml`. - The shape has already bitten once: the Zig version of this call site passed `was_const and !is_after` in the `was_anonymous_named_expr` slot (`src/js_parser/visit/visit.zig` before d451445); the Rust port put it in the right slot. Two open PRs (#34089, #35548) each add a fourth positional bool to the same call. ### Fix - Adds `VisitDeclOpts` to `src/js_parser/parser.rs`, next to `VisitArgsOpts`, with one documented field per former parameter. Same shape as the existing `VisitArgsOpts` / `ParenExprOpts` option structs; an options struct rather than three enums because `was_anonymous_named_expr` is handed straight on to `maybe_keep_expr_symbol_name` as a `bool`, and `dylint.toml` already treats several-bool structs as this repo's option-bag style. - `visit_decl` now takes `opts: VisitDeclOpts` and destructures it on entry; the rest of its body is unchanged. Both callers build the struct with every field named, using the same expressions as before, in the same order. - Removes the `"bare_bool_args:src/js_parser/visit/mod.rs" = 1` line from `mordant-baseline.toml`. Running the baseline writer scoped to `bun_js_parser` (`MORDANT_BASELINE_WRITE=1 cargo dylint --all -p bun_js_parser`) rewrites the file byte for byte identical to what is committed. - No behavior change is intended. Verified with a debug build: - `bun bd test test/bundler/transpiler/`: 3870 pass, 0 fail. The only red was `jsx-production.test.ts`, whose 32 concurrent parent+child debug-build spawns exceed the 5s per-test timeout on an 8 core box; all 32 pass with `--timeout 120000`. - `bun bd test test/bundler/bundler_minify.test.ts test/bundler/esbuild/dce.test.ts test/regression/issue/26360.test.ts test/regression/issue/22656.test.ts` (keepNames, const inlining, macros): 128 pass, 0 fail. - `bun bd test test/bundler/bundler_edgecase.test.ts`: 134 pass, 0 fail. - `cargo dylint --all -p bun_js_parser -- --keep-going` with the baseline line removed: on the previous code it reports the finding quoted below as over the baseline; on this branch it reports nothing and `target/mordant/over-baseline.txt` is not written. - No test is added: a signature refactor has no observable behavior to pin, so there is nothing a new test could fail on before this change. The existing suites above are the coverage; the lint run is what distinguishes before from after. ### Background - `visit_decls` visits every `decl` of a `let`/`const`/`var` statement. For each one it visits the initializer and then calls `visit_decl`, which does the bookkeeping that depends on facts only the caller observed: whether the initializer was an anonymous function or class before visiting (so the binding's name can be attached to it via `maybe_keep_expr_symbol_name`), whether this is a `const` still inside the scope's leading run of `const`s (so the value may be recorded in `const_values` for inlining), and whether visiting the initializer ran a macro (so the macro result may be recorded or destructured into `const_values`). The three fields of `VisitDeclOpts` are those three facts. - The second caller runs on the `exports.replace` / `exports.eliminate` path of `Bun.Transpiler` for a declaration with no initializer. Its replace/delete branches are inverted today (`eliminate` on `export let x;` hits an `unwrap` on `None`); that is pre-existing and already addressed by #33378, so it is left alone here. - `mordant-baseline.toml` is a ratchet: it records the per (lint, file) counts of findings that predate the lint job, and the job fails only on findings above those counts. Fixing a finding means deleting its line so it cannot come back. <details> <summary>Lint output on the previous code with the baseline line removed</summary> ``` warning: `visit_decl` takes bools `was_anonymous_named_expr`, `could_be_const_value` and `could_be_macro`, and 1 of its 2 calls passes bare `true`/`false` for at least two of them. At `visit_decl(.., false, .., false)` nothing says which is which --> src/js_parser/visit/mod.rs:517:19 | 517 | pub(crate) fn visit_decl( | ^^^^^^^^^^ | note: one of those calls --> src/js_parser/visit/mod.rs:417:29 | 417 | ... self.visit_decl(decl, false, was_const && !is_after, false); | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ = help: give `was_anonymous_named_expr`, `could_be_const_value` and `could_be_macro` a two-variant enum each, or an options struct, so every call names what it sets = note: `bare_bool_args` over the mordant baseline (0 recorded for src/js_parser/visit/mod.rs) warning: mordant: 1 finding(s) over the baseline in bun_js_parser ``` On this branch the same command finishes with no warnings. </details>
…or and one writer `ConstValue::new` admits a value only for a `const` binding. It admits a primitive, and an object or an array that a macro returned, as before. It does not admit an `undefined` destructuring leaf that has a default. `admit_const_value` is the only writer of the table, so the two destructuring leaf writes ask the same question as the identifier write. A `let` or a `var` that holds a macro result is no longer printed in place of each read. `let s = hello(); s += " World"` printed `"Hello" += " World"`.
1d85431 to
bb9f6f2
Compare
const, and narrow a destructured result into a new node
|
Reworked on main How it was reproduced.
Scope. This PR no longer removes the fold for a |
`visit_binding_and_expr_for_macro` removed the properties that a pattern
does not bind from the macro's own object. That object can have another
reader: a pattern can bind one key two times, and a macro can return one
object at two places. `const { og: { title }, og } = meta()` left
`og.image` undefined.
The function now returns a new object or array, and keeps the whole
value of a key that the pattern binds again. It also keeps each property
when a pattern key is not one flat string: a computed key, a joined
string key or a number key read a property that the prune had removed.
bb9f6f2 to
b683c9b
Compare
There was a problem hiding this comment.
Still open from earlier reviews (3):
- 🔴
src/ast/ast_result.rs:274—Users who bind a macro-returned path with let or var and pass it to import() get a --minify build that exits 0 but thro… - 🔴
src/js_parser/visit/mod.rs:762—Debug and ASAN builds panic when Bun.Transpiler injects a replacement export into a const declaration, where the base b… - Also unresolved: 1 minor or pre-existing.
If you have decided not to act on one of these findings, resolve its thread (a reply alone leaves it open) and the next review stops counting it. To review this commit again now, use Re-run on its "Claude Code Review" check.
Each door built its own directory and ran the plain imports again. A debug build under load needed more than the default 5 seconds for one door. The doors now share one directory and one run of the plain imports, and have a limit of 60 seconds. A debug build runs each sixteenth generated module.
…n assertion on the symbol kind
A pattern with a computed key keeps each property of the macro result.
Its other leaves are still `const` primitives, so the inliner takes
them, as it did before: `const { a, [k]: v } = cfg(); other(a)` gives the
macro `other` the literal.
`admit_const_value` asserted that the binding is a constant symbol. An
export that `Bun.Transpiler` injects into an `export const` is declared
with another kind, so a debug build panicked on
`exports: { replace: { a: ["N", true] } }`.
The four doors ran at the same time and had a limit of 60 seconds. Each door starts a build and a run, so they slowed each other down on a debug build. One at a time, a door takes about one second there. Two more written modules bind the whole key before the nested pattern. Only that order fails when the prune does not keep the whole value of a key that the pattern binds again.
There was a problem hiding this comment.
I re-reviewed the latest push and found no bugs; the two nits from the previous round (per-test timeouts, and twice-bound keys only tested with the nested pattern first) are addressed in 22b20d8. A maintainer should still weigh the intentional trade-off this PR makes: let/var macro results are no longer inlined, which the description documents as costing import(home) bundling and the var DEV = isDev(); if (DEV) require(...) dead-code fold.
What was reviewed:
visit_declprefix logic old vs new: the four (kind, is_macro, is_after) cases produce the sameis_after_const_local_prefixwrites; onlylet/varmacro admission is dropped.visit_binding_and_expr_for_macrorewrite: no in-place mutation of the macro node remains;!narrowstill admits flat-key leaves; spread returns early as before; array holes and defaults route throughhas_default.ConstValueis the only type inConstValuesMap; the one reader in p.rs and theusingskip at mod.rs:1899 were checked against the new constructor (ausingref can no longer be admitted, and thecontinuethere is still live).
Extended reasoning...
The change touches the JS parser's const-inlining table (src/ast/ast_result.rs, src/js_parser/visit/mod.rs, p.rs, parser.rs) and adds a ~320-line cross-product test in test/bundler/transpiler/macro-test.test.ts; it touches no auth, crypto, or injection surface. The latest commit addressed both of this reviewer's prior nits and no new defects were found in the diff. Approval is withheld because the PR deliberately changes user-visible output for let/var macro bindings (documented downsides: unresolved import() of a let path under --minify, and a dev-only require no longer folded away), and the earlier inline objection on that point was resolved only by the author, so a maintainer decision on that trade-off is the remaining open item.
Fixes #12067
Problem
hello,let s = hello(); s += " World"fails underbun runand--minify-syntax:SyntaxError: Left side of assignment is not a reference.visit_decl(src/js_parser/visit/mod.rs:740) put a macro result into the inliner table forletandvartoo, and for anundefinedpattern leaf that has a default.mod.rs:655) edited the macro's own object.const { og: { title }, og } = meta()leftog.imageundefined in every build mode.Fix
const_valuesholds aConstValue. Its one constructor admits only aconst, and noundefinedleaf that has a default.admit_const_valueis the only writer.test/bundler/transpiler/macro-test.test.ts(7 new tests, all fail on main).constmacro object or array as one value, and foldmacro().proponly for a read #46924 (Notes).Background
const_valuesis the inliner table.handle_identifierprints the stored value in place of each read.constmacro object is still copied at each read ([macro] minify breaks object equality #7196). That removal changes documented output. It is js_parser: keep aconstmacro object or array as one value, and foldmacro().proponly for a read #46924.Downsides
letorvarmacro result is read at run time.var DEV = isDev(); if (DEV) require("./dev.ts")is 1,576 B under--minify-syntax(main: 8 B).import(home)of aletis not bundled.let h = hello(); id(h)is a build error underbun runtoo.Notes
All numbers come from release builds of one base: main
179664ea89and this branch. Linux x64.What each mechanism broke, and where.
letorvarmacro result is printed at each readlet s = hello(); s += " World"bun run,--minify-syntax,--minify(the module does not load)undefinedleafconst [x = 5] = macro()with[undefined]givesundefinedconst { [key()]: v } = macro()givesundefinedbun buildincludedconst { og: { title }, og } = meta():og.imageisundefinedconst { p: { a }, q } = twice():qis{ a: 1 }Rows. 118 one-line programs ran with and without
with { type: "macro" }at three doors:bun run,bun build --target=bun,bun build --target=bun --minify-syntax. A cell is right when stdout and the exit code equal the plain import on the same binary.bun runbun build--minify-syntaxThe rows that still differ are the
constobject and array rows of #7196, 3 rows that read aconstin its temporal dead zone, and the rows that pass a name to a macro. 4 cells are right on main and wrong here:let h = hello(); id(h)and the same withvar, underbun runand--minify-syntax.What changes for a program that had no problem.
let n = num(); console.log(n + n, n * 2),--minifylet t = table(); console.log(t[0], t[1])with a 1,000-row table,--minifyvar DEV = isDev(); if (DEV) console.log(require("./devonly.ts")),--minify-syntaxconst DEVlet home = homePath(); await import(home),--minifyCannot find moduleat run time from another directoryconst homelet h = hello(); id(h): exit atbun run/bun build/--minify-syntaxconst h--reject-unresolvedmakes theimport(home)case a build error. Aconstkeeps each fold.Cost for a file with no macro.
handle_identifier(2 instantiations): the disassembly is equal on both builds. 1,506 B and 2,188 B on both.s_localplusvisit_decl, executed instructions underbun build --no-bundle --target=bun --minify-syntax. Counted with qemu-user 10.0.13 (-one-insn-per-tb -d exec,nochain):Bun.Transpiler#transformSyncover the 220 files ofsrc/jswith default options,inline: trueandminify: { syntax: true }gives the same SHA-256 for 660 of 660 outputs.sizeofbun-profile): 107,582,646 B to 107,583,926 B.visit_decl242 B to 396 B,visit_binding_and_expr_for_macro942 B to 2,114 B,s_local7,689 B to 7,614 B.src/js_parser/parse/parse_entry.rs:2479).valgrind,perfandbloatyare not in the container.Which lines are the fix.
ConstValue::newinsrc/ast/ast_result.rs:kind == LocalKind::KConst, andEUndefined => !has_default.visit_decl:kind == LocalKind::KConst && (is_macro || !is_after_const_local_prefix). A declaration with a macro call stays exempt from the const-local prefix, as on main. Solet i = macro(); const n = 1still inlinesn.visit_binding_and_expr_for_macro: it buildskeptand returns a newE::ObjectorE::Array.visit_declstores the result indecl.value. With a computed key (narrowis false) it returns the object as it is, and still admits the other leaves.can_be_const_valuemoved into the constructor, and threeconst_values.putcalls became one.What stays as on main.
constthat holds a macro object or array is copied at each read ([macro] minify breaks object equality #7196).ConstValue::newkeeps the twowas_originally_macroarms. js_parser: keep aconstmacro object or array as one value, and foldmacro().proponly for a read #46924 removes them, adds a guard formacro().prop = v, and rewrites the example indocs/bundler/macros.mdx. It needs a maintainer's decision, because 660eb46 added the fold on purpose and the docs teach it.console.log(getV()); const v = num(); function getV() { return v }prints 21 where the plain import throws a ReferenceError.constonly underbun runand--minify-syntax(Macros cannot use constant under certain circumstances #16969, open PR js_parser: resolve a literal or macro-result const in a macro argument at any position, in every build mode #44620).Tests. 16 written modules have a stored value. 208 more come from cross products of the declaration kind, the value and the use, and the plain import is their oracle. Each module runs at
bun run,bun build,bun build --no-bundleandbun build --minify, one door at a time and with no time limit. A debug build runs each sixteenth generated module. The build rows pass--entry-naming, because 9 or more entry points change the output layout (#38732). Four mutations of the fix were built and each fails a test: no exemption for a macroconstbelow the prefix, no number and string arm in the constructor, a macroletthat ends the prefix, and no whole value for a key that the pattern binds again.Self-review. 21 concerns.
ConstValue, theletthat ended the const-local prefix (let counter = num(); const name = "world"; greet(name)was a build error in an earlier head), the losses ofletandvarlisted and pinned, tests that pin an admission, the generated table, rows for each recursive call that forwards the kind, a--no-bundledoor, and the comment for--entry-naming.visit_decland is not a parameter of the writer, becausevisit_declalso writes the prefix flag.Bun.Transpiler({ inline: true })has no door of its own. This PR adds no docs sentence forimport()of alet.constmacro object or array as one value, and foldmacro().proponly for a read #46924: the numbers for the lost object fold, the docs scope, the properties that ship with a bound object, thedeleteand compound-operator rows of the write guard, the JSX-spread test, the define table and an opt-back flag.Review after the first push. 3 findings.
new Bun.Transpiler({ exports: { replace: { a: ["N", true] } } })forexport const a:admit_const_valueasserted a constant symbol, and the injected binding has another kind. The assertion is removed.import()of aletis not bundled. Not changed: the program now builds like the same program with a plain import, and aconstkeeps the fold.Suites run on a debug build.
macro-test.test.ts(41 pass),transpiler.test.js(258),runtime-transpiler.test.ts(15),scope-mismatch-panic.test.ts(21),bundler_minify(67),esbuild/dce(87),esbuild/ts(59),bundler_barrel(70),bundler_jsx(65),bundler_string(59),plugins.test.ts(111),isolation.test.ts(50),coverage.test.ts(43),transpiler-cache.test.ts(24),build-error.test.ts(9), and the regression tests 03830, 22656, 26360 and 39900.Related. #26362 reports the lost write under
bun runand was closed as a duplicate of #7196. #26363 and #34089 were earlier attempts that the stale sweep closed. Open PR #44620 gives macro arguments a table of their own and edits the same lines: it rebases on this and routes its inliner write throughadmit_const_value. Open PR #45581 edits the hit arm ofhandle_identifier. Open PR #45658 keeps assignment and delete targets as references at 12 other fold sites.The earlier description of this PR. It removed the object and array arms too, with a guard at each write site.
Repro
Cause
visit_declputs the macro result intoconst_valueswhencould_be_macrois set, andhandle_identifierthen substitutes the stored expression at every use of the binding. Three checks that gate ordinary const-inlining were missing on that path:can_be_const_value()returnedtrueforEObject/EArraywheneverwas_originally_macrowas set. Object/array literals allocate a fresh value on each evaluation, so duplicating one at every use turnsa === ainto({}) === ({})([macro] minify breaks object equality #7196). TheBIdentifierleaf ofvisit_binding_and_expr_for_macrohad the same hole for a nested object reached through destructuring.could_be_macrowas computed from the macro-call count alone, withoutwas_const, so alet/varbound to a macro result was still substituted, turnings += xinto"Hello" += x(Bug with--minify-syntaxand macro #12067).default_value, soconst [x = 5] = macroReturning([undefined])inlinedxasundefinedinstead of leaving the destructuring to apply the default at runtime.Fix
EArray/EObjectarms fromcan_be_const_value().was_constwhen computingcould_be_macroinvisit_decls.maybe_record_macro_const_value, which also skips when the pattern has a default and the value isEUndefined. Threadhas_defaultthrough the destructuring recursion.Primitive leaves from
const { x } = macro()and directmacro().propstill fold; only whole-object substitution,let/varbindings, and undefined-with-default leaves are kept as bindings.Verification
test/bundler/transpiler/macro-identity.test.tscoversbun run,bun build, andbun build --minifyfor:o === o,a === a)inner,list)undefinedand over a defined valueletstring binding that is reassignedletdestructured binding that is reassignedand asserts primitive-leaf / direct-property folding are kept. Five of the six cases fail on the released bun and all pass with this change.
macro-test.test.tsandbundler_minify.test.tsremain green.#9613 (destructuring with reordered property names) has a different root cause (the in-place property compaction overwrites entries before later lookups) and is not addressed here.
[human-review] gate passed · iteration 0 · 7 files touched
fails on main (without fix)
passes on PR (with fix)
diff hotspot
gate history · 1 passed · 0 rejected · iteration 0
evidence per changed file