Conversation
|
Note Reviews pausedIt 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 Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughThis 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. ChangesSwitch-case const scoping and TDZ fix
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
There was a problem hiding this comment.
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
📒 Files selected for processing (3)
src/js_parser/visit/mod.rssrc/js_parser/visit/visit_stmt.rstest/bundler/transpiler/runtime-transpiler.test.ts
|
Updated 7:31 PM PT - Jul 8th, 2026
❌ @alii, your commit fa9f305 has 3 failures in
🧪 To try this PR locally: bunx bun-pr 30936That installs a local version of the PR into your bun-30936 --bun |
4dbea61 to
36f2461
Compare
There was a problem hiding this comment.
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
📒 Files selected for processing (4)
src/js_parser/visit/mod.rssrc/js_parser/visit/visit_stmt.rssrc/jsc/RuntimeTranspilerCache.rstest/regression/issue/30932.test.ts
36f2461 to
f6d558b
Compare
There was a problem hiding this comment.
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— setsis_after_const_local_prefix = trueon the switch body scope and keepsStmtsKind::Nonefor case bodies (so using-lowering is unaffected).src/js_parser/visit/mod.rs— adds!p.fn_or_arrow_data_visit.is_inside_switchto the single-use-substitution guard.src/jsc/RuntimeTranspilerCache.rsandsrc/bundler/cache.rs— bumpEXPECTED_VERSION/RUNTIME_TRANSPILER_CACHE_VERSIONfrom 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
left a comment
There was a problem hiding this comment.
Wouldn't this be a bug at any scope and not just switch case statements?
|
Switch is the only JS construct where multiple conditionally-entered branches share a single lexical scope — every other construct ( 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 Happy to rename the guard ( |
6eb46c0 to
4ea2064
Compare
d1cd48f to
8be25c7
Compare
40fb2cb to
8f13560
Compare
8f13560 to
2b13227
Compare
|
#40791 takes a different route for the single-use substitution half of this: |
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.
fa9f305 to
7641434
Compare
| // | ||
| // 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. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| // `[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. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| // 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). |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| // `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`. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| /// 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. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
There was a problem hiding this comment.
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 writedelete process.env.X(or delete any dot-defined name) getdelete "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 onlyin_.assign_target;is_delete_targetis computed at :1332 but never consulted beforevalue_for_define. This PR addsis_delete_targetguards 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 whenis_delete_targetis 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(defaultprocess.env.NODE_ENVdefine injected) or any user--define a.b=..., with source containingdelete process.env.NODE_ENVordelete a.b; test setup code commonly does this. visit_expr.rs:1332 computesis_delete_targetfor theEDot, but the condition at :1342 is onlyin_.assign_target == AssignTarget::None, sovalue_for_defineat :1345 replaces the dot with the literal. The printed output isdelete "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_targetto the condition at :1342.Verification: Pre-existing: e_dot is untouched by the diff, so base produces the same output.
bun buildon source containingdelete process.env.NODE_ENVtriggers it. src/js_parser/visit/visit_expr.rs:1331 computesis_delete_target, but the gate at :1342 is onlyif in_.assign_target == js_ast::AssignTarget::None, so output isdelete "development", which evaluates totrueand deletes nothing. -
🟣
src/js_parser/fold.rs— Bake dev users who assign to or deleterequire.maingetfalse = xordelete falsein the emitted module, a SyntaxError or TypeError at load. TheERequireCallTargetarm at src/js_parser/fold.rs:666 rewritesrequire.maintoERequireMainwithout checkingidentifier_opts.assign_target()oris_delete_target(), and the printer at src/js_printer/lib.rs:3545 printsERequireMainasfalseinInternalBakeDevformat. Every neighboring arm in this function gained those guards in this PR. Fix: apply the same!is_delete_target && assign_target == Nonecondition to therequire.mainarm so it stays a reference like the other inlines.Why this was flagged
Trigger: a module processed in Bake dev format (
InternalBakeDev) that containsrequire.main = moduleordelete require.main; the first form is a known CommonJS idiom in some packages. fold.rs:666-671 returnsERequireMainfor anyrequire.mainaccess, and js_printer/lib.rs:3545 prints that node as the literalfalsein Bake dev. The outputfalse = moduleis 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 samecan_inlinecondition.Verification: Pre-existing: the
ERequireCallTargetarm 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 rewritesrequire.mainwith noassign_target() == Nonecheck, and src/js_printer/lib.rs:3545 printsERequireMainasfalse, so the output isfalse = module, an early SyntaxError.
| if !enabled | ||
| && (identifier_opts.is_delete_target() | ||
| || identifier_opts.assign_target() != js_ast::AssignTarget::None) | ||
| { | ||
| return None; | ||
| } |
There was a problem hiding this comment.
🔴 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.
| // | ||
| // 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. |
There was a problem hiding this comment.
🟡 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"; | |||
There was a problem hiding this comment.
🟡 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() { |
There was a problem hiding this comment.
🟣 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.
Fixes #18477, fixes #30932, fixes #21210
Problem
switch (1) { case 0: const a = 1; case 1: return a }returns 1 where node throwsReferenceError."abc".length = 1makes the fileSyntaxError: Left side of assignment is not a reference.[function () { return this }][0]()loses the array asthis.class A { x = new.target }in an arrow function throwsReferenceError.src/js_parser(visit/*.rs,fold.rs) that ignore where the expression stands.Fix
switchbody adds noconstto the inlining table, and its case bodies skip the single-use substitution.[x][0]fold skips call targets and delete targets.new.targetin a class field initializer or a static block is printed asundefined."str".length,import.meta.*,module.idand a defined identifier stay references as assignment or delete targets. One version bump covers all four.test/regression/issue/30932.test.ts,assign-target-substitution.test.ts,transpiler.test.jsandruntime-transpiler.test.tsintest/bundler/transpiler/.Background
EXPECTED_VERSION(src/jsc/RuntimeTranspilerCache.rs). New parser output needs a new version.switchshare one lexical scope, but aconstin one case does not dominate the other cases.thisstays the array #37085, js_parser: substitute undefined for new.target in class field initializers and static blocks #42653 and js_parser: keep assignment and delete targets as references (string .length, import.meta, module.id, --define) #40804 too, because each one bumped the same cache version.Downsides
constinlining in aswitchcase, and[x][0]()keeps the array literal. The size change is not measured.Notes
Four parser fixes in one PR
The branch is on main (bb35d1b) and has five commits:
parser: preserve TDZ for const declared in switch case(this PR as it was).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 sothisstays the array #37085).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).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).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.newTargetLocalOpen review question on part 1
The question in review was if the bug is limited to
switch. The answer in the thread:switchis the only statement where statement lists that run on different conditions share one lexical scope, so it is the only place where aconstthat 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)
src/change (a build of main 519963e, which also had the changes of bake: unregister Bake::SourceProvider from the source map table when it is destroyed #37444 and Bun.serve: reject upgrade(), timeout() and requestIP() for a Request that another server received #41811, and they do not touch the parser): 59 tests fail in the six files30932.test.ts,transpiler.test.js,runtime-transpiler.test.ts,ts-use-define-for-class-fields.test.ts,assign-target-substitution.test.ts,assign-to-import.test.ts. Each of the four parts has failing tests: 3switchtests,preserves runtime semantics when inlining from a literal index, 4new.targettests, and the assignment and delete target tests.default/DefineAssignWarningandedgecase/EsmWrapNewTargetInClassFieldInitializerfail.cjs2esm/DeleteExportsPropertyDeoptandcjs2esm/DeleteModuleExportsPropertyDeoptpass on main already.Related PRs named in the earlier descriptions
#36735 (tagged template receiver), #40857 (the same
e_identifierchange), #40829 (call targets at the same fold sites), #38200 (import.metain cjs and iife output).Description of this PR before the merge (the
switchpart)Repro (from #30932)
CONSTANT=2ReferenceError: Cannot access 'CONSTANT' before initializationReferenceError: Cannot access 'CONSTANT' before initializationRoot cause
Every case in a switch shares one lexical scope but each case is entered conditionally, so a
constdeclared 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:const_values): a leading run ofconst X = literalin a scope gets recorded and every later reference toXin the scope is replaced with the literal. Because all cases share one block scope,const CONSTANT = 2incase '*'landed in the prefix for the whole switch body andCONSTANTincase 'a'was rewritten to2.visit_stmtswalks a block's output and inlines the initializer of aconst X = exprinto its lone use, deleting the decl. It runs once per case body, butuse_count_estimateis global: while visitingcase '*', the counter has only seencase '*''s references, soconst X = Math.random()followed byreturn Xreads as single-use and the decl disappears: leavingcase 'a''s reference dangling (throwsX is not definedinstead of a TDZ error).Fix
s_switch, setis_after_const_local_prefix = trueon the switch body scope so no non-macroconstinside any case is added toconst_values.visit_stmtson!p.fn_or_arrow_data_visit.is_inside_switch.is_inside_switchis already set bys_switcharound each case-body visit and resets across function boundaries (it lives onFnOrArrowDataVisit), so nested functions inside a case body still get the optimization. Using this flag rather thanStmtsKind::SwitchStmtkeeps the using-lowering path atmod.rs:1488unaffected.RuntimeTranspilerCache::EXPECTED_VERSIONandRUNTIME_TRANSPILER_CACHE_VERSIONfrom 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.zigmirror 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 underminify_syntax+ DCE).Tests
test/regression/issue/30932.test.tsadds five cases: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)
passes on PR (with fix)
diff hotspot
gate history · 1 passed · 0 rejected · iteration 19
evidence per changed file
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…