Skip to content

js_parser: fold property access on multi-property object literals - #36599

Closed
robobun wants to merge 7 commits into
mainfrom
farm/fd834d72/minify-fold-multi-prop-object-access
Closed

robobun wants to merge 7 commits into
mainfrom
farm/fd834d72/minify-fold-multi-prop-object-access

Conversation

@robobun

@robobun robobun commented Aug 1, 2026 •

Copy link
Copy Markdown
Collaborator

Related to #3463. This PR does not change the output for the input in that issue (see the note below). It makes {a:1, b:2, c:3}.c fold to 3 under --minify-syntax, as esbuild does. Before, only the single-property form ({c:3}.c) folded. The code had an explicit properties.len() == 1 guard.

Repro

// in.js
console.log({a: 1, b: 1, c: 1}["c"])
$ bun build --minify in.js
# before: console.log({a:1,b:1,c:1}.c);
# after:  console.log(1);

Fix

maybe_rewrite_property_access in src/js_parser/fold.rs now scans every property (esbuild-style): each one must be kind == Normal, have no computed/method/spread flag, carry an EString key, and have a value that passes expr_can_be_removed_if_unused. The last matching key wins. A colon-form __proto__: null lets a miss fold to undefined; a shorthand {__proto__} is treated as an ordinary own property per Annex B.3.1. The existing single-property fast path is kept so {f: sideEffect()}.f still folds without a side-effect gate.

The UTF-16 branch of the file-local e_string_eql_bytes shim now routes through bun_core::strings::utf16_eql_string so a key like "\u00e9" compares correctly against a UTF-8 .é name instead of producing a false miss.

Cases that stay untouched (covered by the Unsafe test): spreads, computed keys, getters/setters/methods, numeric keys, unbound identifier values, assignment targets, and tagged-template tags.

Tagged-template tags

Threading the fold through multi-property objects exposed that Bun never tracked tagged-template tag position when rewriting property accesses (esbuild gates on !isCallTarget && !isTemplateTag; Bun only had the first half). Added p.template_tag alongside p.call_target/p.delete_target, set in e_template before visiting the tag, and an is_template_tag bit on IdentifierOpts. The EObject fold and the six has_value_for_this_in_call() sites in e_binary/e_if (comma, ??, ||, &&, ?: both arms) now consult it, so e.g. (null ?? obj.fn)`x` keeps the (0, obj.fn) wrapper instead of collapsing to obj.fn`x` and changing the tag's this.

RuntimeTranspilerCache version bumped to 25 since the runtime transpiler enables minify_syntax for bun-target files.

Note on the original issue's repro

#3463 uses const obj = {a:1,b:1,c:1}; console.log(obj["c"]) and expects console.log(1). esbuild does not actually produce that (verified 0.17 through current): it emits const obj={a:1,b:1,c:1};console.log(obj.c);. Getting all the way to console.log(1) at module scope would require inlining single-use const object bindings, which neither tool does today. Only the direct {a:1,b:1,c:1}["c"] form reaches the fold. A binding does not. function f(){const obj={a:1,b:1,c:1};return obj["c"]} still prints return{a:1,b:1,c:1}.c, because single-use substitution runs after the property access is visited (measured on bfedf0a, the first commit of this PR. The later commits do not change the substitution). An earlier version of this description said that this form folds. That was wrong.

Verification

bun bd test test/bundler/bundler_minify.test.ts -t InlineObjectLiteralPropertyAccess

3 new tests (fold results, unsafe cases that must not fold, runtime correctness covering __proto__ shorthand / UTF-16 keys / tag this). Also ran the full bundler_minify, bundler_edgecase, esbuild/dce, esbuild/default, and transpiler suites: all pass.


[review] gate passed · iteration 0 · 7 files touched

fails on main (without fix)
ASAN without fix: 3 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/bundler/bundler_minify.test.ts
bun test v1.4.0 (aad4c6756)

test/bundler/bundler_minify.test.ts:
(pass) bundler > minify/TemplateStringFolding [924.75ms]
1551 |         }
1552 |       }
1553 | 
1554 |       if (capture) {
1555 |         const captures = api.captureFile(path.relative(root, outfile ?? outputPaths[0]));
1556 |         expect(captures).toEqual(capture);
                                ^
error: expect(received).toEqual(expected)

  [
-   "3",
-   "1",
-   "1",
-   "4",
-   "()=>1",
-   "1",
-   "void 0",
-   "void 0",
+   "{a:1,b:2,c:3}.c",
+   "{a:1,b:2,c:3}.a",
+   "{a:1,b:2,c:3}.a",
+   "{a:1,a:4}.a",
+   "{a:()=>1,b:()=>2}.a",
+   "{__proto__:null,a:1}.a",
+   "{__proto__:null,a:1}.missing",
+   "{__proto__:null}.__proto__",
    "{a:1,c:3}.missing",
    "{}.missing",
    "sideEffect()",
  ]

- Expected  - 8
+ Received  + 8

      at <anonymous> (/workspace/bun/test/bundler/expectBundled.ts:1556:26)
(fail) bundler > minify/InlineObjectLiteralPropertyAccess [626.51ms]
1551 |         }
1552 |       }
1553 | 
1554 |   
... (truncated)

release without fix: 1 FAILED
bun test v1.4.0-canary.1 (badf0e28d)

test/bundler/bundler_minify.test.ts:
(pass) bundler > minify/TemplateStringFolding [109.47ms]
(pass) bundler > minify/InlineObjectLiteralPropertyAccess [7.90ms]
(pass) bundler > minify/InlineObjectLiteralPropertyAccessUnsafe [9.64ms]
runtime failed file: /tmp/bun-build-tests/bun-PEbKml/minify/InlineObjectLiteralPropertyAccessRuntime/out.js
stdout output:
[3,1,4,1,null,null,3,1,42,"OBJ",1,1,null]
hits=1
[false,false,false,false,false,false]
---
expected stdout:
[3,1,4,1,null,null,3,1,42,"OBJ",1,1,null]
hits=1
[true,true,true,true,true,true]
---
1843 |               console.log(`---`);
1844 |               console.log(`expected ${name}:`);
1845 |               console.log(expected);
1846 |               console.log(`---`);
1847 |             }
1848 |             expect(result).toBe(expected);
                                  ^
error: expect(received).toBe(expected)

  "[3,1,4,1,null,null,3,1,42,"OBJ",1,1,null]
  hits=1
- [true,true,true,true,true,true]"
+ [false,false,false,false,false,false]"

- Expected  - 1
+ Received  + 1

      at <anonymous> (/workspace/bun/test/bundler/expectBundled.ts:1848:28)
(fail) bundler > minify/Inli
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/bundler/bundler_minify.test.ts
bun test v1.4.0 (aad4c6756)

test/bundler/bundler_minify.test.ts:
(pass) bundler > minify/TemplateStringFolding [1659.99ms]
(pass) bundler > minify/InlineObjectLiteralPropertyAccess [277.55ms]
(pass) bundler > minify/InlineObjectLiteralPropertyAccessUnsafe [789.45ms]
(pass) bundler > minify/InlineObjectLiteralPropertyAccessRuntime [820.23ms]
(pass) bundler > minify/StringAdditionFolding [369.08ms]
(pass) bundler > minify/FunctionExpressionRemoveName [183.20ms]
(pass) bundler > minify/KeepNamesPreservesNames [241.07ms]
(pass) bundler > minify/KeepNamesWithMinifyIdentifiers [133.36ms]
(pass) bundler > minify/PrivateIdentifiersNameCollision [1925.24ms]
(pass) bundler > minify/MergeAdjacentVars [1123.82ms]
(pass) bundler > minify/UnusedCommaAndStrictEqChains [807.46ms]
(pass) bundler > minify/Infinity [482.47ms]
(pass) bundler > minify+whitespace/Infinity [153.68ms]
(pass) bundler > minify/NumericPropertyKeysPrintedAsComputed [705.32ms]
(pass) bundler > minify/InlineArraySpread [145.16ms]
(pass) bundler 
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 2568ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/6] gen generated_host_exports.rs
generated_host_exports.rs: 94 exports (host=3, lazy=10, generic=81, rust=0); 239 extern-C blocks audited
[1/6] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

^[[1m^[[92m   Compiling^[[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
^[[1m^[[92m   Compiling^[[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
^[[1m^[[92m   Compiling^[[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr)
^[[1m^[[92m   Compiling^[[0m bun_boringssl_sys v0.0.0 (/workspace/bun/src/boringssl_sys)
^[[1m^[[92m   Compiling^[[0m bun_safety v0.0.0 (/workspace/bun/src/safety)
^[[1m^[[92m   Compiling^[[0m bun_zlib_sys v0.0.0 (/workspace/bun/src/zlib_sys)
^[[1m^[[92m   Compiling^[[0m bun_cares_sys v0.0.0 (/workspace/bun/src/cares_sys)
^[[1m^[[92m   Compiling^[[0m bun_zstd v0.0.0 (/workspace/bun/src/zstd)
^[[1m^[[92m   Compiling^[[0m bun_picohttp v0.0.0 (/workspace/bun/src/picohttp)
^[[1m^[[92m   Compiling^[[0m bun_brotli 
... (truncated)
diff hotspot
src/js_parser/fold.rs               | 97 ++++++++++++++++++++++++++++++------
 src/js_parser/p.rs                  |  2 +
 src/js_parser/parser.rs             | 14 +++++-
 src/js_parser/visit/visit_binary.rs | 17 +++++--
 src/js_parser/visit/visit_expr.rs   | 18 +++++--
 src/jsc/RuntimeTranspilerCache.rs   |  6 ++-
 test/bundler/bundler_minify.test.ts | 99 +++++++++++++++++++++++++++++++++++++
 7 files changed, 226 insertions(+), 27 deletions(-)

gate history · 4 passed · 0 rejected · iteration 0

evidence per changed file
file                                 reads  edits  tests
src/js_parser/fold.rs                    7     11      0
src/js_parser/p.rs                       6      2      0
src/js_parser/parser.rs                  2      4      0
src/js_parser/visit/visit_binary.rs      2      2      0
src/js_parser/visit/visit_expr.rs        6      8      0
src/jsc/RuntimeTranspilerCache.rs        1      1      0
test/bundler/bundler_minify.test.ts      4      6      0

{a:1, b:2, c:3}.c now folds to 3 under --minify-syntax, matching
esbuild. Previously only single-property objects ({c:3}.c) were folded.

The fold bails on any property that makes static resolution unsound:
spreads, computed keys, getters/setters/methods, and numeric keys. Every
dropped property's value must be side-effect free (checked with
expr_can_be_removed_if_unused), so {a: fn(), c: 2}.c is left alone.

A __proto__: null entry lets a miss fold to undefined; any other
__proto__ keeps the access intact since it may resolve via the
prototype.

The existing single-property fast path is kept so {f: sideEffect()}.f
still folds to sideEffect() without a side-effect check (the value is
what's returned, so nothing is dropped).

Fixes #3463
@coderabbitai

coderabbitai Bot commented Aug 1, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 29 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 12f25e7d-8441-4b60-bbbc-9d42fdee65ee

📥 Commits

Reviewing files that changed from the base of the PR and between df49a6e and aad4c67.

📒 Files selected for processing (7)
  • src/js_parser/fold.rs
  • src/js_parser/p.rs
  • src/js_parser/parser.rs
  • src/js_parser/visit/visit_binary.rs
  • src/js_parser/visit/visit_expr.rs
  • src/jsc/RuntimeTranspilerCache.rs
  • test/bundler/bundler_minify.test.ts

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added the claude label Aug 1, 2026
Comment thread src/js_parser/fold.rs Resolved
Comment thread src/js_parser/fold.rs Resolved
Comment thread src/js_parser/fold.rs Resolved
Comment thread src/js_parser/fold.rs Resolved
Comment thread src/js_parser/fold.rs Resolved
Comment thread src/js_parser/fold.rs Resolved
Comment thread test/bundler/bundler_minify.test.ts Resolved
…horthand __proto__

Tagged templates bind `this` to the receiver just like calls, so
folding the tag away changes observable behavior. Add p.template_tag
(same pattern as p.call_target/delete_target), set it in e_template
before visiting the tag, thread an is_template_tag bit through
IdentifierOpts to maybe_rewrite_property_access, and gate the EObject
fold on it. This also closes the pre-existing single-property case:
{f: fn}.f`x` now keeps the object literal.

Shorthand {__proto__} defines an own data property (Annex B.3.1 only
gives colon-form __proto__: the [[Prototype]] setter semantics), so
treat a WasShorthand __proto__ key as an ordinary own property instead
of the prototype setter. Colon-form __proto__: is skipped for key
matching and only contributes has_proto_null.

Tests tightened to use matching keys so the assign/template-tag guards
are actually load-bearing, and the runtime test now covers both edge
cases.
@robobun

robobun commented Aug 1, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 3:23 AM PT - Aug 1st, 2026

✅ @autofix-ci[bot], your commit aad4c6756acee1f144d7e9541b761bb123b1f4e0 passed in Build #86710! 🎉


🧪   To try this PR locally:

bunx bun-pr 36599

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

bun-36599 --bun

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Beyond the inline finding, I re-checked the three fixes from 571480a: the template-tag guard is threaded through p.template_tag → IdentifierOpts.is_template_tag() and gates both the len==1 fast path and the general scan (tests cover .a and ["a"] tag forms plus the single-property case); shorthand __proto__ now falls through to normal key-match (runtime test covers {__proto__: null, __proto__}.__proto__ → 42); and the Unsafe assertions now use matching keys so they are load-bearing. Also checked that a non-null colon-form __proto__ correctly leaves has_proto_null = false so misses on {__proto__: obj, ...} do not fold to undefined.

Extended reasoning...

One new correctness finding (UTF-16 key vs UTF-8 name byte-widened comparison turning a false-negative match into void 0 via the new has_proto_null return) is posted inline; deferring on that. The prior-round fixes hold.

Comment thread src/js_parser/fold.rs Resolved
The file-local e_string_eql_bytes shim did a length-then-widened-byte
compare for UTF-16 keys, which only agrees with a UTF-8 name for pure
ASCII. With the new has_proto_null path, a false negative on a key like
{__proto__: null, "\u00e9": 1}.é folded to undefined instead of 1.
Route the UTF-16 branch through bun_core::strings::utf16_eql_string,
which decodes code points before comparing.
Comment thread src/js_parser/parser.rs Resolved
Comment thread src/js_parser/visit/visit_expr.rs Resolved
Comment thread src/js_parser/parser.rs Resolved
…transpiler cache version

The ??/||/&&/?:/, folds in visit_binary.rs and e_if check is_call_target
to keep a (0, obj.fn) wrapper when collapsing to a bare member access,
but tagged-template tags bind this the same way. Compute is_template_tag
at those sites from the new p.template_tag field and include it in the
guard so (null ?? obj.fn)`x` keeps the wrapper instead of dropping it.

Also bump RuntimeTranspilerCache EXPECTED_VERSION (24 -> 25) since the
runtime transpiler runs with minify_syntax for bun-target files, and
update the IdentifierOpts packed-layout comment for the new bit 5.
Comment thread src/js_parser/parser.rs Resolved
Comment thread src/jsc/RuntimeTranspilerCache.rs Resolved

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No new issues found — all prior findings (template-tag guard, shorthand __proto__, UTF-16 key compare, the six has_value_for_this_in_call() sibling sites, cache version bump) are addressed. Deferring to a maintainer given this changes runtime transpiler output and touches parser fold semantics with Annex B.3.1 subtleties.

Checked: colon-form vs shorthand __proto__ handling in the general path; .__proto__ access on {__proto__: null} and on objects with no proto key (both correctly not over-folded); non-null __proto__: values leave has_proto_null false so misses don't fold; p.template_tag follows the same set-before-visit / pointer-identity pattern as call_target; utf16_eql_string handles both the false-negative and false-positive encoding directions the runtime test covers.

Extended reasoning...

Overview

Extends maybe_rewrite_property_access in src/js_parser/fold.rs to fold property access on multi-property object literals under --minify-syntax (previously only single-property). Adds p.template_tag parser state (p.rs, parser.rs) and threads an is_template_tag bit through IdentifierOpts, e_dot/e_index/e_if in visit_expr.rs, and four e_binary arms in visit_binary.rs. Fixes the UTF-16 branch of the file-local e_string_eql_bytes shim to use bun_core::strings::utf16_eql_string. Bumps RuntimeTranspilerCache version 24→25. Three new bundler tests plus runtime coverage.

Security risks

None. Pure minifier/parser optimization; no untrusted input handling, auth, crypto, or FFI surface changes.

Level of scrutiny

High. The JS parser is production-critical — a miscompile silently changes runtime behavior of user code. This change:

  • Adds ~70 lines of new fold logic with Annex B.3.1 __proto__ semantics (colon-form vs shorthand vs computed distinction)
  • Fixes three pre-existing miscompiles (template-tag this binding, UTF-16 key false-match, six sibling this-preservation sites) — each of which changes emitted code
  • Runtime transpiler enables minify_syntax for bun-target files, so this affects bun run, not just bun build
  • The cache version bump means the correctness of the fold matters for every re-transpiled file

Other factors

  • Went through three prior review rounds; every finding I raised was fixed with a targeted commit and the threads are all resolved.
  • Test coverage is solid: capture assertions for fold results, an Unsafe test with load-bearing keys (post-fix), and a runtime test that would fail on any of the semantic mistakes flagged earlier (shorthand __proto__ → 42, tagged-template this → "OBJ", UTF-16 keys, all six ??/||/&&/?:/comma tag cases).
  • I traced the fold through the non-obvious inputs ({__proto__: {foo:1}, a:2}.foo correctly not folded since has_proto_null stays false; {__proto__: null}.__proto__ → undefined; {__proto__}.__proto__ with bound identifier → the identifier via the shorthand fall-through) and found nothing wrong.
  • The p.template_tag field follows the exact same set-before-visit / pointer-identity-compare / never-cleared pattern as the pre-existing call_target/delete_target, so the staleness question is answered by precedent.

Given the scope (parser hot path, runtime-behavior fixes across six sibling sites, spec-subtle __proto__ handling), a maintainer should sign off even though I found nothing to flag on this revision.

@robobun

robobun commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator Author

Heads up: this PR's template_tag tracking doesn't gate the [x][0] fold in e_index (visit_expr.rs around the as_e_number() / as_e_array() block), so [obj.m][0]`x``` still folds to obj.mx``` and rebinds this. #36735 is the standalone fix for the tag-position bug and adds && !is_template_tag` there; if this PR lands first it'll want the same one-line gate.

@github-actions

Copy link
Copy Markdown
Contributor

Closing this PR because it has been inactive for more than 90 days.

@github-actions github-actions Bot closed this Oct 31, 2026
@robobun

robobun commented Oct 31, 2026

Copy link
Copy Markdown
Collaborator Author

The inactivity sweep closed this PR. No maintainer reviewed it. CI was green on aad4c67 (build 86710).

Two corrections to the description. I edited it to match.

State of the branch:

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