js_parser: don't leak the import.meta.hot drop flag into the next call - #36875
js_parser: don't leak the import.meta.hot drop flag into the next call#36875robobun wants to merge 3 commits into
Conversation
When HMR is disabled, reading import.meta.hot.accept (or dispose/prune/on/off/send/decline/invalidate) without calling it would set p.method_call_must_be_replaced_with_undefined unconditionally. That flag is parser-wide and only consumed by e_call after visiting its target, so the write leaked into the next unrelated call expression and silently dropped it from the output. Gate the flag write on identifier_opts.is_call_target() so it is only set when an enclosing e_call will actually consume it. The --drop path already gates on the equivalent condition.
WalkthroughThe parser now treats disabled ChangesHMR property folding
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
|
Updated 4:12 AM PT - Aug 4th, 2026
✅ @robobun, your commit 3964047a36df5afa2da7994af58a4c68ac0edea9 passed in 🧪 To try this PR locally: bunx bun-pr 36875That installs a local version of the PR into your bun-36875 --bun |
|
Reproduced with: After the change: The diff is green locally (
Ready for review. |
There was a problem hiding this comment.
LGTM — surgical gate on the parser-wide drop flag; the leak into unrelated calls is fixed and the direct-call stripping path is unchanged.
What was reviewed
- Traced
is_call_targetat bothmaybe_rewrite_property_accesscall sites (e_dot/e_index) back top.call_target, whiche_callsets before visiting and whose consumer clears the flag on return — the gate matches the flag's lifetime exactly. - Confirmed the
--dropwriters of the same flag already gate onin_.property_access_for_method_call_maybe_should_replace_with_undefined, so this brings theimport.meta.hotpath in line. - Checked the third caller in
p.rs(define-chain builder) passes default opts, so it can't reach theHotDisabledarm withis_call_targettrue. - New tests cover every affected method name, both bundled and
--no-bundle, plus a re-assertion that direct calls are still stripped.
Extended reasoning...
Overview
Two-line fix in src/js_parser/fold.rs (maybe_rewrite_property_access, the ESpecial::HotEnabled|HotDisabled arm): the writes to p.method_call_must_be_replaced_with_undefined in the accept and decline|dispose|prune|invalidate|on|off|send branches are now gated on identifier_opts.is_call_target(). Two new itBundled cases in test/bundler/bundler_minify.test.ts sit next to the existing ImportMetaHotTreeShaking test.
Mechanism check
method_call_must_be_replaced_with_undefined is a parser-wide bool. Its only consumer is e_call (visit_expr.rs:1965-1968), which reads and clears it immediately after visiting its own target. is_call_target at the two visit_expr.rs call sites (887, 1343) is computed by pointer-comparing the current node against p.call_target, which e_call sets before that visit. So identifier_opts.is_call_target() is true exactly when an enclosing e_call will consume the flag on return — the gate is precise. The other two writers of this flag (visit_expr.rs:302, 1368, for --drop) already gate on the analogous in_.property_access_for_method_call_maybe_should_replace_with_undefined, so the hot path was the outlier.
The change is a strict narrowing: when is_call_target() is true, behavior is byte-identical to before (flag set, EUndefined returned, enclosing call dropped). When false, the property still folds to EUndefined but the flag stays clear, so the next unrelated call in the file survives. The one behavioral corner case I noted — import.meta.hot.accept.call(...) — now emits undefined.call(...) instead of being silently dropped, which is arguably more correct given import.meta.hot.accept is undefined when HMR is off.
Security risks
None. Bundler-output correctness only; no untrusted-input parsing, no allocation, no FFI.
Level of scrutiny
Medium — js_parser is production-critical, but the diff is a two-token guard on a niche branch (HMR-disabled import.meta.hot property access) with a well-understood consumer. The bug it fixes (silently dropping user code) is far more serious than any edge the narrowing could introduce.
Other factors
Tests are placed alongside existing coverage per repo convention, exercise every method name in the lookup table, cover both bundling modes, run the output, and negative-assert that direct calls are still stripped and import.meta.hot is gone from output. PR description reports bundler_minify, bundler_drop, and bake/dev-and-prod suites passing; robobun confirmed the repro before/after.
There was a problem hiding this comment.
LGTM — targeted fix that gates the parser-wide drop flag on is_call_target(), matching how the --drop path already guards it.
What was reviewed:
- Traced
p.call_targetset ine_call(visit_expr.rs:1845) →is_call_targetcomputed on the EDot node → passed through tomaybe_rewrite_property_access; the flag is now only set when the enclosinge_callwill read-and-clear it at visit_expr.rs:1965-1968. - Confirmed the non-call path still returns
EUndefined(bare reads fold correctly), and the existingImportMetaHotTreeShakingtest plus the newFAIL-*-callassertions guard against regressing the call-stripping behavior. - Both callers in visit_expr.rs (EDot line 1429, EIndex line 1037) thread
is_call_targetthrough, so the gate applies toimport.meta.hot["accept"]too.
Extended reasoning...
Overview
Two-line fix in src/js_parser/fold.rs wrapping p.method_call_must_be_replaced_with_undefined = true in an if identifier_opts.is_call_target() guard for both the accept branch and the decline|dispose|prune|invalidate|on|off|send branch of the HotEnabled | HotDisabled arm. Two new itBundled tests in test/bundler/bundler_minify.test.ts covering bundled+run and --no-bundle variants.
Security risks
None. This is bundler/transpiler constant folding for import.meta.hot — no auth, crypto, filesystem, or untrusted-input parsing surface.
Level of scrutiny
Low-medium. The mechanism is well-explained and I verified it against the source: e_call sets p.call_target = target.data (visit_expr.rs:1845) before visiting, the EDot/EIndex visitors compute is_call_target by pointer equality against that, and e_call consumes and clears the flag immediately after the target visit (visit_expr.rs:1965-1968). Gating on is_call_target() therefore guarantees an enclosing consumer exists. The change is strictly a narrowing — the flag is set in a subset of the cases it was before — so the only risk is that call-stripping stops working, which both the pre-existing ImportMetaHotTreeShaking test and the new not.toContain("FAIL") / not.toContain("import.meta.hot") assertions cover.
Other factors
The comment-cop inline about a paragraph-long explanatory comment was addressed in 3964047 (comment removed; thread resolved). Tests are placed next to existing import.meta.hot coverage per repo convention, exercise all eight affected method names, assert both output contents and runtime stdout, and re-verify the positive case (direct calls still stripped). PR description reports bundler_minify, bundler_drop, and bake/dev-and-prod suites green.
Problem
With HMR disabled (the default for
bun build), reading animport.meta.hotmethod without calling it silently drops the next unrelated call expression in the file:foo()is gone. The same happens fordispose/decline/prune/invalidate/on/off/send, and for both--no-bundleand the default bundling mode.Cause
maybe_rewrite_property_access(src/js_parser/fold.rs) handles theHotEnabled | HotDisabledtarget. When HMR is off, theacceptbranch and thedecline|dispose|prune|invalidate|on|off|sendbranch both do:method_call_must_be_replaced_with_undefinedis a parser-wide flag thate_callreads (and then clears) after visiting its own target. The--droppath that uses the same flag (visit_expr.rse_identifier/e_dot) gates its write onin_.property_access_for_method_call_maybe_should_replace_with_undefined, whiche_callsets only while visiting its target chain. Theimport.meta.hotpath has no such gate, so a bare property read sets the flag with no enclosinge_callto consume it, and the next call anywhere in the module picks it up and is replaced withundefined.Fix
Gate both writes on
identifier_opts.is_call_target(). That bit is true exactly when the property access node isp.call_target, i.e. when an enclosinge_callset it and will consume the flag on return. A bare read still folds toundefined; it just no longer poisons an unrelated call.Tests
Two new cases in
test/bundler/bundler_minify.test.tsnext to the existingImportMetaHotTreeShakingcoverage:minify/ImportMetaHotReadWithoutCall(bundled, run): reads every affected method name without calling, asserts each followingconsole.logis present in the output and runs, and re-asserts that direct calls are still stripped.minify/ImportMetaHotReadWithoutCallNoBundle:--no-bundlevariant of the repro.Both fail on
main(theafter-*lines are missing from the bundle) and pass with this change.bundler_minify.test.ts(44 pass),bundler_drop.test.ts(14 pass) andbake/dev-and-prod.test.ts(12 pass) are green.[review] gate passed · iteration 0 · 2 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