Skip to content

js_parser: fix four folds that change valid programs (switch const, [x][0](), new.target, assignment targets) - #30936

Open
robobun wants to merge 5 commits into
mainfrom
farm/05559398/switch-case-const-tdz
Open

robobun wants to merge 5 commits into
mainfrom
farm/05559398/switch-case-const-tdz

Conversation

@robobun

@robobun robobun commented May 17, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #18477, fixes #30932, fixes #21210

Problem

  • The parser rewrites valid programs. switch (1) { case 0: const a = 1; case 1: return a } returns 1 where node throws ReferenceError. "abc".length = 1 makes the file SyntaxError: Left side of assignment is not a reference.
  • [function () { return this }][0]() loses the array as this. class A { x = new.target } in an arrow function throws ReferenceError.
  • The causes are folds in src/js_parser (visit/*.rs, fold.rs) that ignore where the expression stands.

Fix

  • A switch body adds no const to the inlining table, and its case bodies skip the single-use substitution.
  • The [x][0] fold skips call targets and delete targets. new.target in a class field initializer or a static block is printed as undefined.
  • "str".length, import.meta.*, module.id and a defined identifier stay references as assignment or delete targets. One version bump covers all four.
  • Verified: test/regression/issue/30932.test.ts, assign-target-substitution.test.ts, transpiler.test.js and runtime-transpiler.test.ts in test/bundler/transpiler/.

Background

Downsides

  • Less folding: no const inlining in a switch case, and [x][0]() keeps the array literal. The size change is not measured.
  • The version bump makes every cached transpiler entry miss one time.
Notes

Four parser fixes in one PR

The branch is on main (bb35d1b) and has five commits:

  1. parser: preserve TDZ for const declared in switch case (this PR as it was).
  2. js_parser: don't fold [x][0] in call position, this must be the array (was js_parser: don't fold [x][0] in call position so this stays the array #37085).
  3. js_parser: substitute undefined for new.target in class field initializers and static blocks (was js_parser: substitute undefined for new.target in class field initializers and static blocks #42653).
  4. js_parser: keep assignment and delete targets as references (was js_parser: keep assignment and delete targets as references (string .length, import.meta, module.id, --define) #40804, which already held js_parser: set p.delete_target before visiting the delete operand #36734 and js_parser: keep the identifier when assigning to a define with a constant value #38563).
  5. Bump the runtime transpiler cache version (34 to 35, one time for the four changes).

The four changes merged with main and with each other without a conflict, except for the cache version lines. The full description of parts 2 to 4 is in #37085, #42653 and #40804. The comments of part 1 no longer name line numbers.

Exact error of part 3

ReferenceError: Can't find private variable: PrivateSymbol.newTargetLocal

Open review question on part 1

The question in review was if the bug is limited to switch. The answer in the thread: switch is the only statement where statement lists that run on different conditions share one lexical scope, so it is the only place where a const that does not dominate a reference is in the same scope as that reference. #40791 is a different design for the single-use substitution half.

Verification (linux x64, debug build with ASAN)

Related PRs named in the earlier descriptions

#36735 (tagged template receiver), #40857 (the same e_identifier change), #40829 (call targets at the same fold sites), #38200 (import.meta in cjs and iife output).


Description of this PR before the merge (the switch part)

Repro (from #30932)

'use strict';
const CONSTANT = 1;
const ACTION = 'a';

+function test() {
  switch (ACTION) {
    case '*':
      const CONSTANT = 2;
      break;
    case 'a':
      console.log('CONSTANT=' + CONSTANT);
  }
}();
Before After Node
output CONSTANT=2 ReferenceError: Cannot access 'CONSTANT' before initialization ReferenceError: Cannot access 'CONSTANT' before initialization

Root cause

Every case in a switch shares one lexical scope but each case is entered conditionally, so a const declared in one case does not dominate references to the same name in sibling cases. Two inlining passes in the visit pass were ignoring this and firing across case boundaries:

  1. const-local-prefix inlining (const_values): a leading run of const X = literal in a scope gets recorded and every later reference to X in the scope is replaced with the literal. Because all cases share one block scope, const CONSTANT = 2 in case '*' landed in the prefix for the whole switch body and CONSTANT in case 'a' was rewritten to 2.
  2. single-use substitution: visit_stmts walks a block's output and inlines the initializer of a const X = expr into its lone use, deleting the decl. It runs once per case body, but use_count_estimate is global: while visiting case '*', the counter has only seen case '*''s references, so const X = Math.random() followed by return X reads as single-use and the decl disappears: leaving case 'a''s reference dangling (throws X is not defined instead of a TDZ error).

Fix

  • In s_switch, set is_after_const_local_prefix = true on the switch body scope so no non-macro const inside any case is added to const_values.
  • Gate the single-use-substitution pass in visit_stmts on !p.fn_or_arrow_data_visit.is_inside_switch. is_inside_switch is already set by s_switch around each case-body visit and resets across function boundaries (it lives on FnOrArrowDataVisit), so nested functions inside a case body still get the optimization. Using this flag rather than StmtsKind::SwitchStmt keeps the using-lowering path at mod.rs:1488 unaffected.
  • Bump RuntimeTranspilerCache::EXPECTED_VERSION and RUNTIME_TRANSPILER_CACHE_VERSION from 20 → 21 so pre-fix cached entries (which contain the incorrectly-inlined output) are invalidated on upgrade. Both compiled mirrors are bumped; the reference-only .zig mirror is left alone per the port convention.

Declare + use within the same case is unaffected. Nested block scopes inside a case (case 'a': { const X = 1; ... }) get their own scope and still get const-local-prefix inlining; single-use substitution stays disabled one block deep (correctness-safe pessimization: the optimization only fires under minify_syntax + DCE).

Tests

test/regression/issue/30932.test.ts adds five cases:

  • literal-initialized const (prefix-inlining path)
  • non-foldable const (single-use-substitution path)
  • outer-shadowed variant
  • same-case declare+use (must still work)
  • fall-through from declaring case (must still work)

Three of the five fail without the fix; all five pass with it.

Supersedes #27189 (same fix, stale branch targeting a renamed file).


[review] gate passed · iteration 19 · 5 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/regression/issue/30932.test.ts"
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (fa9f305e5)

test/regression/issue/30932.test.ts:
25 |           return "matched " + CONSTANT;
26 |         case "a":
27 |           return "a=" + CONSTANT;
28 |       }
29 |     }
30 |     expect(() => test("a")).toThrow(ReferenceError);
                                 ^
error: expect(received).toThrow(expected)

Expected constructor: ReferenceError

Received function did not throw
Received value: "a=2"

      at <anonymous> (/workspace/bun/test/regression/issue/30932.test.ts:30:29)
(fail) switch-case const does not leak across cases > literal-initialized const is not inlined into sibling case [24.24ms]
44 |     // Post-fix JSC raises "Cannot access 'X' before initialization" (TDZ).
45 |     // Pre-fix, Bun deleted the `const X
... (truncated)

release without fix: 3 FAILED
bun test v1.4.0-canary.1 (1498d7b77)

test/regression/issue/30932.test.ts:
25 |           return "matched " + CONSTANT;
26 |         case "a":
27 |           return "a=" + CONSTANT;
28 |       }
29 |     }
30 |     expect(() => test("a")).toThrow(ReferenceError);
                                 ^
error: expect(received).toThrow(expected)

Expected constructor: ReferenceError

Received function did not throw
Received value: "a=2"

      at <anonymous> (/workspace/bun/test/regression/issue/30932.test.ts:30:29)
(fail) switch-case const does not leak across cases > literal-initialized const is not inlined into sibling case [0.23ms]
44 |     // Post-fix JSC raises "Cannot access 'X' before initialization" (TDZ).
45 |     // Pre-fix, Bun deleted the `const X` decl entirely as a single-use
46 |     // substitution while visiting case "*", turning the case "a" reference
47 |     // into an unbound "X is not defined" — also a ReferenceError, but with
48 |     // different semantics — so match on name + message shape.
49 |     expect(() => test("a")).toThrow(
                                 ^
error: expect(received).toThrow(expected)

Expected value: ObjectContaining {

... (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/regression/issue/30932.test.ts"
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (fa9f305e5)

test/regression/issue/30932.test.ts:
(pass) switch-case const does not leak across cases > literal-initialized const is not inlined into sibling case [21.32ms]
(pass) switch-case const does not leak across cases > non-foldable const is not substituted out of sibling case [5.89ms]
(pass) switch-case const does not leak across cases > outer const is shadowed by inner const, not leaked through [5.22ms]
(pass) switch-case const does not leak across cases > const in a nested block inside a case still inlines [2.26ms]
(pass) switch-case const does not leak across cases > fall-through from declaring case still initializes binding [2.41ms]

 5 pass
 0 fail
 8 expect() calls
Ran 5 tests across 1 file. [2.13s]
__F:0:S:0

release with fix: all passed
$ bun scripts/build.ts --profile=release
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     fa9f305e54
  features     (none)

22 deps, 105 codegen, 1167 objects in 1636ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1230] fetch zlib
[zlib] up to date
[2/1230] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[3/1230] gen ErrorCode+*.h
[4/1230] gen bindgenv2
[5/1230] fetch tinycc
[tinycc] up to date
[6/1229] fetch picohttpparser
[picohttpparser] up to date
[7/1229] subst deps/zlib/zconf.h
[8/1229] subst deps/zlib/zlib.h
[9/1180] gen .bind.ts → GeneratedBindings.cpp
[10/1180] gen JSBuffer.lut.h
Generating /workspace/bun/build/release/codegen/JSBuffer.lut.h from /workspace/bun/src/jsc/bindings/JSBuffer.cpp
[11/1180] gen JSSink.{cpp,h,lut.h,rs}
generated_jssink.rs: 6 sinks, 72 exported symbols
Generating /workspa
... (truncated)
diff hotspot
src/bundler/cache.rs                |   2 +-
 src/js_parser/visit/mod.rs          |  16 +++++-
 src/js_parser/visit/visit_stmt.rs   |  13 +++++
 src/jsc/RuntimeTranspilerCache.rs   |   3 +-
 test/regression/issue/30932.test.ts | 105 ++++++++++++++++++++++++++++++++++++
 5 files changed, 136 insertions(+), 3 deletions(-)

gate history · 1 passed · 0 rejected · iteration 19

evidence per changed file
file                                 reads  edits  tests
src/bundler/cache.rs                     6      6     14
src/js_parser/visit/mod.rs              13      3     15
src/js_parser/visit/visit_stmt.rs       11      5     15
src/jsc/RuntimeTranspilerCache.rs        6      3     14
test/regression/issue/30932.test.ts      1      2     14

root cause · written by the author bot

The parser's single-use substitution and const-local-prefix optimizations were inlining const and let declarations from one switch case into sibling cases, which collapsed the shared lexical scope and bypassed the temporal dead zone that should apply across cases. The fix disables both optimizations within switch blocks by gating single-use substitution on a new is_inside_switch check and setting is_after_const_local_prefix before visiting any case body, so bindings are no longer folded across case boundaries. The runtime transpiler cache version was bumped to invalidate stale cached output…

@coderabbitai

coderabbitai Bot commented May 17, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

This PR fixes Bun's parser to prevent const/let declarations in one switch case from being inlined into sibling cases, which violated JavaScript's TDZ (Temporal Dead Zone) semantics. The fix disables the single-use inlining and const-local-prefix optimizations within switch blocks, adds regression tests covering TDZ and shadowing behavior, and invalidates the runtime transpiler cache.

Changes

Switch-case const scoping and TDZ fix

Layer / File(s) Summary
Inlining guard in visit_stmts
src/js_parser/visit/mod.rs
Extend the single-use let/const substitution guard to check !p.fn_or_arrow_data_visit.is_inside_switch, preventing inlining from occurring when visiting switch-case bodies. Add comments explaining that switch cases share one lexical scope but are visited individually, making use_count_estimate temporarily incorrect.
Switch visitor: disable const-local-prefix inlining
src/js_parser/visit/visit_stmt.rs
Set cur_scope().is_after_const_local_prefix = true before visiting any case bodies to prevent const-local-prefix inlining from spanning case boundaries and bypassing TDZ. Clarify that each case body is visited with StmtsKind::None (using-lowering remains enabled per-case) while substitution behavior is gated by is_inside_switch.
Switch-case const binding tests
test/regression/issue/30932.test.ts
Add five regression tests covering literal-initialized const leakage, non-foldable const with Math.random(), inner-const shadowing with TDZ, same-case declaration and use, and fall-through initialization across cases. All test cases verify correct ReferenceError behavior when accessing uninitialized or undeclared bindings.
RuntimeTranspilerCache version bump
src/jsc/RuntimeTranspilerCache.rs
Bump EXPECTED_VERSION from 20 to 21 and update the version-history comment to document the parser change. Out-of-date caches are invalidated with StaleCache on version mismatch.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes directly address both linked issues #30932 and #18477 by preventing const inlining across switch case boundaries and ensuring TDZ behavior matches Node.js specifications.
Out of Scope Changes check ✅ Passed All changes are focused on fixing const inlining behavior in switch statements; the cache version bump is a necessary side effect to invalidate stale compiled code.
Title check ✅ Passed The title clearly summarizes the parser fixes for four incorrect folds, including the switch const issue. It is specific and related to the changes described.
Description check ✅ Passed The description explains the problems, fixes, and verification. Although it does not use the template’s exact headings, it provides the required information, including how the changes were tested.

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

@coderabbitai coderabbitai 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.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/js_parser/visit/visit_stmt.rs`:
- Around line 2174-2175: The call p.visit_stmts(&mut _stmts,
StmtsKind::SwitchStmt) currently disables LowerUsingDeclarationsContext because
visit_stmts treats StmtsKind::SwitchStmt as a blanket "disable using lowering"
signal; change this so the TDZ/single-use-substitution behavior and the
using/await-using lowering behavior are independent: either add a new StmtsKind
variant (e.g., SwitchForTDZ) or add a boolean flag (e.g., allow_lower_using:
bool) to visit_stmts and its callers, update the guard in visit/mod.rs to check
the specific TDZ-only signal (or the new flag) instead of StmtsKind::SwitchStmt,
and update the single caller (the p.visit_stmts(&mut _stmts,
StmtsKind::SwitchStmt) site) to pass the new variant/flag so switch-case TDZ
handling remains but using-lowering still runs inside case bodies.

In `@test/bundler/transpiler/runtime-transpiler.test.ts`:
- Line 225: The test contains intentional const declarations inside sibling
switch cases (e.g. the declaration "const CONSTANT = 2;") which trigger
lint/correctness/noSwitchDeclarations; add a targeted suppression comment "//
biome-ignore lint/correctness/noSwitchDeclarations" immediately above each such
intentional declaration (including the other similar declarations in the same
test file) so the TDZ behavior tests stay intact and the linter is satisfied.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 615ec262-a300-433d-839f-fd44e2f6180a

📥 Commits

Reviewing files that changed from the base of the PR and between 172afa5 and 6f771fd.

📒 Files selected for processing (3)
  • src/js_parser/visit/mod.rs
  • src/js_parser/visit/visit_stmt.rs
  • test/bundler/transpiler/runtime-transpiler.test.ts

Comment thread src/js_parser/visit/visit_stmt.rs
Comment thread test/bundler/transpiler/runtime-transpiler.test.ts Outdated
Comment thread src/js_parser/visit/visit_stmt.rs
Comment thread src/js_parser/visit/visit_stmt.rs
@robobun

robobun commented May 17, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 7:31 PM PT - Jul 8th, 2026

❌ @alii, your commit fa9f305 has 3 failures in Build #70713 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 30936

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

bun-30936 --bun

Comment thread src/js_parser/visit/mod.rs
@robobun
robobun force-pushed the farm/05559398/switch-case-const-tdz branch 2 times, most recently from 4dbea61 to 36f2461 Compare May 17, 2026 20:13

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/regression/issue/30932.test.ts`:
- Around line 3-18: The header comment block in
test/regression/issue/30932.test.ts contains an extended multi-line rationale;
trim it to the standard two-line regression header: first line the issue URL
(https://github.com/oven-sh/bun/issues/30932) and second line a single concise
bug-description, and move the rest of the long explanatory prose out of the file
(e.g., into the PR description or a separate markdown note). Locate the top
block comment that begins with the issue URL and replace the multi-paragraph
explanation with one short summary line so the file follows the established
two-line pattern.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 59efcea3-84d4-4faf-bf3f-10ef37f12253

📥 Commits

Reviewing files that changed from the base of the PR and between 4dbea61 and 36f2461.

📒 Files selected for processing (4)
  • src/js_parser/visit/mod.rs
  • src/js_parser/visit/visit_stmt.rs
  • src/jsc/RuntimeTranspilerCache.rs
  • test/regression/issue/30932.test.ts

Comment thread test/regression/issue/30932.test.ts
Comment thread src/jsc/RuntimeTranspilerCache.rs Outdated
@robobun
robobun force-pushed the farm/05559398/switch-case-const-tdz branch from 36f2461 to f6d558b Compare May 17, 2026 20:43

@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.

All my earlier concerns are resolved (using-lowering decoupled via is_inside_switch, both compiled cache-version constants now at 21). Deferring to a human for final sign-off since this touches the parser's visit pass and trades a minor minify-time pessimization for correctness.

Extended reasoning...

Overview

This PR fixes #30932/#18477 by disabling two inlining optimizations inside switch case bodies so that const declarations in one case are not inlined into sibling cases (which silently bypassed the spec-required TDZ ReferenceError). The change touches:

  • src/js_parser/visit/visit_stmt.rs — sets is_after_const_local_prefix = true on the switch body scope and keeps StmtsKind::None for case bodies (so using-lowering is unaffected).
  • src/js_parser/visit/mod.rs — adds !p.fn_or_arrow_data_visit.is_inside_switch to the single-use-substitution guard.
  • src/jsc/RuntimeTranspilerCache.rs and src/bundler/cache.rs — bump EXPECTED_VERSION / RUNTIME_TRANSPILER_CACHE_VERSION from 20 → 21.
  • test/regression/issue/30932.test.ts — five regression tests covering literal-inlining, single-use-substitution, outer-shadowing, same-case use, and fall-through.

Security risks

None. This is a transpiler-output correctness fix; no auth, crypto, network, or user-input handling is involved.

Level of scrutiny

High. The JS parser's visit pass runs on every transpiled file in both the runtime and bundler, so any change here has very broad blast radius. That said, the change itself is conservative — it only disables optimizations in a narrow context, which is always correctness-safe — and is well-covered by the new tests.

Other factors

This PR went through four rounds of review feedback from me, all of which the author addressed: (1) the original StmtsKind::SwitchStmt approach inadvertently disabled using-lowering for case bodies — fixed by switching to is_inside_switch; (2) the cache version bump was initially missing, then incomplete — now both compiled constants agree at 21; (3) a minor perf pessimization for nested {} blocks inside case bodies under minify_syntax was acknowledged and accepted as a deliberate tradeoff (the in-code comment matches actual behavior). The reference-only .zig mirror was intentionally left unsynced per the port convention. All review threads are resolved and the bug-hunting pass found nothing new. Given the parser is production-critical and the implementation approach changed mid-review, a human should give the final sign-off rather than a bot auto-approval.

@Jarred-Sumner Jarred-Sumner left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Wouldn't this be a bug at any scope and not just switch case statements?

@robobun

robobun commented May 18, 2026

Copy link
Copy Markdown
Collaborator Author

Switch is the only JS construct where multiple conditionally-entered branches share a single lexical scope — every other construct (if/else, for/while, try/catch/finally, labeled blocks) wraps each branch in its own {} block scope, so a const/let declared in one branch is not visible to a sibling branch at all:

function test(cond) {
  if (cond) {
    const X = 42;
    return X;
  }
  return X;  // ReferenceError: X is not defined (unbound, not TDZ)
}

Both passes in this PR (the const-local-prefix inliner and the single-use substituter) only touch declarations and references within one scope, so the branch-entry condition is what matters. Two independent checks confirm switch is the only path where one scope spans multiple visit_stmts calls: s_try (visit_stmt.rs:2094) pushes separate scopes for the try body, catch body, and finally body; s_for/s_for_in/s_for_of each push one scope for the header and visit the body through s_block (which pushes its own); s_if just calls visit_single_stmt per branch. Only s_switch pushes one scope and loops visit_stmts(case_body) inside it, which is exactly what makes use_count_estimate stale mid-switch and the const-local-prefix heuristic dominance-unsafe across case boundaries.

Happy to rename the guard (is_inside_switch → something more general) and scoped comments if you think a future construct could hit the same pattern, but I could not construct a repro for if, for, while, try, or labeled blocks either locally or on Node — their per-branch {} makes the cross-branch use unreachable at parse time.

@robobun
robobun force-pushed the farm/05559398/switch-case-const-tdz branch from 6eb46c0 to 4ea2064 Compare May 19, 2026 13:44
Comment thread src/js_parser/visit/visit_stmt.rs
@robobun
robobun force-pushed the farm/05559398/switch-case-const-tdz branch 2 times, most recently from d1cd48f to 8be25c7 Compare May 23, 2026 08:35
Comment thread test/regression/issue/30932.test.ts
@robobun
robobun force-pushed the farm/05559398/switch-case-const-tdz branch 2 times, most recently from 40fb2cb to 8f13560 Compare May 23, 2026 09:24
@robobun
robobun force-pushed the farm/05559398/switch-case-const-tdz branch from 8f13560 to 2b13227 Compare June 5, 2026 20:34
Comment thread src/bundler/cache.rs Outdated
@robobun

robobun commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator Author

#40791 takes a different route for the single-use substitution half of this: visit_stmts skips the mangle pass for case bodies and s_switch runs it once every case is visited, so the use counts are final and the inliner stays active inside switch bodies. It does not touch the const-local-prefix folding, so the TDZ part (#18477) is still only addressed here.

Every case in a switch statement shares one lexical scope but each case
is entered conditionally, so a `const` declared in one case does not
dominate references to that name in sibling cases. The visit pass was
performing two inlining passes that both ignored this and silently
bypassed the TDZ `ReferenceError` the spec requires.

1. const-local-prefix inlining (`const_values`): a leading run of
   `const X = literal` in a scope is recorded and every reference to
   `X` later in the scope is replaced with the literal. Because all
   cases share one block scope, `const CONSTANT = 2` in `case '*'`
   was treated as part of the prefix for the whole switch body and
   `CONSTANT` in `case 'a'` was rewritten to `2`.

2. single-use substitution: `visit_stmts` walks a block's output and
   inlines `const X = expr` into its lone use, deleting the decl. It
   runs once per case body, but `use_count_estimate` is global: while
   visiting `case '*'`, the counter has only seen the references of
   `case '*'`, so a single-use pattern reads as 1 and the decl is
   deleted out from under a sibling case's reference.

Fix: mark the switch body scope `is_after_const_local_prefix = true`
so no `const` inside any case is added to `const_values`, and gate
the single-use substitution on `fn_or_arrow_data_visit.is_inside_switch`
so case-body visits skip it. `is_inside_switch` resets at function
boundaries, so nested functions inside a case body are unaffected.
Calling through [x][0]() evaluates the callee as a property Reference,
so this inside the callee is the array literal itself. The fold emitted
(0, x)() for member-expression items and a bare x() for everything else,
binding this to undefined instead. Bail out of the fold for call targets.
…izers and static blocks

A class field initializer and a class static block run as a method call,
so new.target is always undefined in them. An arrow function in them takes
the same value.

JavaScriptCore throws "ReferenceError: Can't find private variable:
PrivateSymbol.newTargetLocal" on entry to an arrow function that has no
function around it when a class inside it has new.target in a field
initializer or a static block. The bundler's __esm wrapper is such an
arrow function, so a module that runs unbundled throws once bundled.
With a function around the arrow function, a static block throws too.

Lowering also moves the initializer of an auto-accessor into the
constructor, where new.target is the class.
Several folds replaced an expression with a value even when the expression
was the target of an assignment or of `delete`:

- `"str".length` under minify-syntax: `"abc".length = 1` became `3 = 1`,
  a SyntaxError for the whole file.
- `import.meta.main`, `import.meta.hot` and, under --format=cjs or a
  framework, `import.meta.dir`, `.file`, `.path` and `.url`.
- `import.meta.hot.<name>` when HMR is disabled.
- `module.id`, `module.filename` and `module.path` as delete targets in
  a bundle.
- `[x][0]` as a delete target.
- An identifier with a `--define` constant value as an assignment
  target: `X = 5` with `--define X='"abc"'` printed `"abc" = 5`. The
  identifier is kept, as esbuild does. Reads are still replaced.
The four parser changes before this commit change the cached transpiler
output, so entries written before them must not be read back.
@robobun
robobun force-pushed the farm/05559398/switch-case-const-tdz branch from fa9f305 to 7641434 Compare October 3, 2026 07:41
Comment on lines +1910 to +1920
//
// Ignore declarations inside a switch case body: every case in a switch
// shares one lexical scope but we visit one case at a time, so
// `use_count_estimate` for a decl in case 0 has not yet seen references
// from later cases and may spuriously read as 1 — inlining then would
// delete the decl out from under those later references (issue #30932).
// `is_inside_switch` resets at function boundaries (it lives on
// `FnOrArrowDataVisit`), so nested functions inside a case body still
// get the optimization. Unlike `StmtsKind::SwitchStmt`, using this
// flag keeps the using-lowering path in this function independent
// from the case-body guard here.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +1077 to +1080
// `[x][0]()` calls `x` with `this` bound to the array
// literal itself (GetThisValue of the Reference), which
// neither `x()` nor `(0, x)()` reproduces, so the fold
// must not fire for call targets.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +2142 to +2147
// Every case body shares one lexical scope but is entered conditionally,
// so a `const` declared in one case does not dominate references to that
// name in other cases. Disable the const-local-prefix inlining for the
// entire switch body — otherwise `case 'a': console.log(X)` could be
// rewritten to use the value from `case '*': const X = 2;` above it and
// silently bypass the TDZ error the spec requires (issue #30932).

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +2160 to +2165
// `SwitchStmt` suppresses per-case using-lowering in `visit_stmts`:
// the switch-level lowering below handles it across all cases
// with one try/finally. The single-use-substitution pass (which
// is also broken across case boundaries, see #30932) is gated
// separately on `fn_or_arrow_data_visit.is_inside_switch` in
// `visit_stmts` so the TDZ fix kicks in regardless of `StmtsKind`.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +67 to +69
/// Version 35: The parser keeps a `const` of one `switch` case out of the other cases,
/// keeps `[x][0]()` as a call on the array, prints `new.target` in a class field initializer
/// or a static block as `undefined`, and keeps assignment and delete targets as references.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

@robobun robobun changed the title parser: preserve TDZ for const declared in switch case js_parser: fix four folds that change valid programs (switch const, [x][0](), new.target, assignment targets) Oct 3, 2026

@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 findings, one more point worth a maintainer's look: the is_new_target_undefined = true set at src/js_parser/visit/mod.rs:1201 applies to every TypeScript field initializer, including the useDefineForClassFields: false path where the initializer is later moved into the constructor as this.x = init (mod.rs:1250). tsc evaluates new.target there as the class, and so did the base branch; the new test at test/bundler/transpiler/ts-use-define-for-class-fields.test.ts:101 asserts "undefined" for that case, so it certifies a divergence from tsc rather than guarding against one.

Extended reasoning...

The PR has grown from the titled switch-case TDZ fix into four separate parser behavior changes plus a cache version bump, six verified findings are posted inline, and a maintainer's CHANGES_REQUESTED review predates the latest commits. The new.target substitution is a parse-time workaround for a JSC bug that also changes output on the useDefineForClassFields: false lowering path, which was not among the posted findings.

Findings marked 🟡 are optional suggestions and need no follow-up push.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🟣 src/js_parser/visit/visit_expr.rs — Bundling users who write delete process.env.X (or delete any dot-defined name) get delete "value": the expression evaluates to true and nothing is deleted, with no diagnostic. The dot-define substitution at src/js_parser/visit/visit_expr.rs:1342 checks only in_.assign_target; is_delete_target is computed at :1332 but never consulted before value_for_define. This PR adds is_delete_target guards to every hard-coded inline in fold.rs (module., "str".length, import.meta.) but leaves this sibling site, the one most user code reaches. Fix: skip substitution when is_delete_target is set here, and in the same way at the identifier-define site, so delete targets stay references everywhere.

    Why this was flagged

    Trigger: bun build (default process.env.NODE_ENV define injected) or any user --define a.b=..., with source containing delete process.env.NODE_ENV or delete a.b; test setup code commonly does this. visit_expr.rs:1332 computes is_delete_target for the EDot, but the condition at :1342 is only in_.assign_target == AssignTarget::None, so value_for_define at :1345 replaces the dot with the literal. The printed output is delete "development", which is legal JS that returns true and leaves the property in place. The base branch behaves the same, but the PR's stated scope is keeping assignment and delete targets as references and it adds !identifier_opts.is_delete_target() guards at fold.rs:451, :459, :468, :552, :604 while skipping this site. The identifier-define path at visit_expr.rs:261 already excludes delete targets, so the dot path is the inconsistent one. Remedy: add !is_delete_target to the condition at :1342.

    Verification: Pre-existing: e_dot is untouched by the diff, so base produces the same output. bun build on source containing delete process.env.NODE_ENV triggers it. src/js_parser/visit/visit_expr.rs:1331 computes is_delete_target, but the gate at :1342 is only if in_.assign_target == js_ast::AssignTarget::None, so output is delete "development", which evaluates to true and deletes nothing.

  • 🟣 src/js_parser/fold.rs — Bake dev users who assign to or delete require.main get false = x or delete false in the emitted module, a SyntaxError or TypeError at load. The ERequireCallTarget arm at src/js_parser/fold.rs:666 rewrites require.main to ERequireMain without checking identifier_opts.assign_target() or is_delete_target(), and the printer at src/js_printer/lib.rs:3545 prints ERequireMain as false in InternalBakeDev format. Every neighboring arm in this function gained those guards in this PR. Fix: apply the same !is_delete_target && assign_target == None condition to the require.main arm so it stays a reference like the other inlines.

    Why this was flagged

    Trigger: a module processed in Bake dev format (InternalBakeDev) that contains require.main = module or delete require.main; the first form is a known CommonJS idiom in some packages. fold.rs:666-671 returns ERequireMain for any require.main access, and js_printer/lib.rs:3545 prints that node as the literal false in Bake dev. The output false = module is an early SyntaxError for the whole module (invalid assignment target), so the user's page fails to load instead of the harmless no-op the base runtime would perform. The base branch has the same arm, but this PR's purpose is adding assign/delete guards to the sibling inlines in this very function (fold.rs:451, :459, :468, :552, :604, :764) and it skips this one, so the class is left partially fixed. Remedy: guard this arm with the same can_inline condition.

    Verification: Pre-existing: the ERequireCallTarget arm is untouched and the base emits the same output, but it is a missed sibling site of the bug class this PR fixes. In Bake dev, src/js_parser/fold.rs:665-672 rewrites require.main with no assign_target() == None check, and src/js_printer/lib.rs:3545 prints ERequireMain as false, so the output is false = module, an early SyntaxError.

Comment thread src/js_parser/fold.rs
Comment on lines +764 to +769
if !enabled
&& (identifier_opts.is_delete_target()
|| identifier_opts.assign_target() != js_ast::AssignTarget::None)
{
return None;
}

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.

🔴 With HMR disabled, writing or deleting an unknown import.meta.hot.<name> no longer fails the build; it now compiles to a runtime undefined.<name> access. The early return None at fold.rs:764-769 runs before the name check that logs "import.meta.hot.{} does not exist" at fold.rs:828-835, so import.meta.hot.foo = 1 or delete import.meta.hot.foo skips the parser-side API validation that reads still get. Fix: validate the property name (the data/accept/lookup-table set) before bailing out for assign and delete targets, so unknown names are rejected in every position and HMR mode; only the known-name rewrites need the assign/delete bail-out.

Why this was flagged

Input: a module containing import.meta.hot.foo = 1 (or delete import.meta.hot.foo) transpiled with hot_module_reloading disabled, e.g. a plain bun build or the runtime transpiler. In e_dot the inner import.meta.hot becomes ESpecial(HotDisabled) via fold.rs:611-622; the outer access reaches the HotEnabled|HotDisabled arm at fold.rs:761 with assign_target != None or is_delete_target set. The new check at fold.rs:764-769 returns None immediately, before the name validation at fold.rs:793-835, so no "import.meta.hot.foo does not exist" error is logged and the expression is printed as undefined.foo = 1 (HotDisabled prints as undefined, js_printer/lib.rs:3403-3415). On the base branch the same input logged the "import.meta.hot.foo does not exist" build error. Reads of import.meta.hot.foo and all positions with HMR enabled still log the error, so only the disabled assign/delete path lost the validation.

Verification: nit. Triggering condition: a module containing import.meta.hot.<unknown> = v (or delete import.meta.hot.<unknown>) transpiled with hot_module_reloading disabled. The new guard at src/js_parser/fold.rs:764-769 runs before the lookup-table/name check at fold.rs:793-835, so the output is undefined.foo = 1 with no build error. On the base branch the same input reached the name check, logged the error and failed the build.

Comment on lines +1910 to +1920
//
// Ignore declarations inside a switch case body: every case in a switch
// shares one lexical scope but we visit one case at a time, so
// `use_count_estimate` for a decl in case 0 has not yet seen references
// from later cases and may spuriously read as 1 — inlining then would
// delete the decl out from under those later references (issue #30932).
// `is_inside_switch` resets at function boundaries (it lives on
// `FnOrArrowDataVisit`), so nested functions inside a case body still
// get the optimization. Unlike `StmtsKind::SwitchStmt`, using this
// flag keeps the using-lowering path in this function independent
// from the case-body guard here.

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.

🟡 nit (optional): Paragraph-long comments narrating the #30932 fix and cross-referencing sibling guards; sweep:#30932 in src/js_parser. Maintainers get three multi-paragraph comments (mod.rs:1910-1920, visit_stmt.rs:2142-2147, visit_stmt.rs:2160-2165) that restate the PR description and explain why the workaround is OK, which CLAUDE.md flags as a sign the code is wrong and REVIEW.md limits to one line. Fix: cut each to one line stating only what the code cannot say, e.g. "cases share one scope but are visited one at a time, so use counts are incomplete (#30932)", and drop the visit_stmt.rs:2160-2165 block that only points at the other guard.

Why this was flagged

The diff adds an 11-line comment block at src/js_parser/visit/mod.rs:1910-1920 and two more blocks at src/js_parser/visit/visit_stmt.rs:2142-2147 and 2160-2165. Each explains the switch-case TDZ reasoning at length, names the flag used instead of StmtsKind::SwitchStmt, and cross-references the other site. The root CLAUDE.md rule says a paragraph-long comment justifying a workaround means the code is wrong and that comments should be deleted unless the next reader would spend multiple tool calls to understand; REVIEW.md says comment only what the code cannot say, in one line, and never narrate the change. The base branch has only the two-line eval comment at mod.rs:1907-1909. No runtime behavior is affected; this is a convention nit only.

Verification: nit. Triggering condition: any maintainer reading these parser sites. The diff adds exactly three comment blocks: src/js_parser/visit/mod.rs:1910-1920, src/js_parser/visit/visit_stmt.rs:2142-2147, and visit_stmt.rs:2160-2165. Repo REVIEW.md: "Only comment what the code cannot say. One line. Never narrate the change." The #30932 sweep in src/js_parser hits only these three lines.

@@ -0,0 +1,105 @@
import { describe, expect, test } from "bun:test";

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.

🟡 nit (optional): This file lives under test/regression/issue/, which CLAUDE.md reserves for true regressions (worked in a release, then broke); cross-case const inlining in a switch has been wrong since the inlining passes existed and the PR does not claim it ever worked. Fix: move these five cases into the existing transpiler suite (e.g. test/bundler/transpiler/transpiler.test.js next to the other const-inlining tests) and keep the issue link in a comment.

Why this was flagged

The root CLAUDE.md Test Organization section states that test/regression/issue/.test.ts is only for bugs that are true regressions, and that behavior which was never correct belongs in the existing file for the module. The PR description describes the const-local-prefix and single-use substitution passes as long-standing and cites the older issue #18477 as the same bug, with no claim of a release where this worked. The cases at test/regression/issue/30932.test.ts:20-104 are in-process transpiler behavior checks that fit test/bundler/transpiler/transpiler.test.js, which this PR already edits. Nothing fails at runtime; the consequence for maintainers is a test placed where the repo's own convention says it will not be discovered alongside related coverage.

Verification: The PR adds a new file under test/regression/issue/ for a bug the PR itself does not establish as a true regression. The rule is explicit at /home/claude/bun/CLAUDE.md:60. /home/claude/bun/test/regression/issue/30932.test.ts:3-18 describes the cause as two inlining passes that "used to ignore" switch-case scoping; neither the file header nor the PR description names a release in which this worked and then broke.

}
}
} else if let Some(array) = target.data.as_e_array() {
} else if !is_call_target && let Some(array) = target.data.as_e_array() {

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.

🟣 pre-existing, not blocking: pre-existing: a tagged template on a folded [x][0] or {f: x}.f still gets the wrong this under --minify-syntax, the same defect this PR fixes for calls. The new !is_call_target guard at visit_expr.rs:1076 does not also check is_template_tag (computed at visit_expr.rs:856), so [obj.m][0]`a` still folds to obj.m`a` and this becomes obj instead of the array. Fix: bail out of the fold whenever the index/dot is the receiver of a tagged template as well as a call, which covers the 2 sites (visit_expr.rs:1076, fold.rs:581).
A small fix can ride a push you are already making; otherwise a short reply is enough.

Why this was flagged

Input: [obj.m][0]`a` or ({f: obj.m}).f`a` transpiled with minify_syntax (bun build --minify-syntax, or the runtime transpiler path that enables it). In e_index, is_template_tag is computed at src/js_parser/visit/visit_expr.rs:856 from p.template_tag, but the array fold at visit_expr.rs:1076 only tests !is_call_target, so the fold still fires and emits obj.m`a` . A tagged template call passes the Reference's base as this exactly like a call does, so the user's tag function sees this === obj (or undefined for a bare identifier) instead of the array literal; in the base branch the same wrong fold happened (base only special-cased call targets with a (0, x) rewrite), so this is pre-existing, but the PR's own commit message states the fold 'must not fire' when this must be the array and leaves this sibling in place. The object-property fold in src/js_parser/fold.rs:581 has the identical !is_call_target() guard without is_template_tag(). No other safeguard intervenes: can_be_inlined_from_property_access does not consider the template-tag position.

Verification: Pre-existing: base folds the same way by the same route. Trigger: any --minify-syntax transpile of a tagged template whose tag is [expr][n] or {f: expr}.f. The array fold at visit_expr.rs:1076 is gated only on !is_call_target; nothing tests is_template_tag. Same gap at the sibling {f: x}.f fold in src/js_parser/fold.rs:578-582.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants