Skip to content

js_parser: declare temporaries hoisted out of enum initializers inside the enum closure - #38761

Open
robobun wants to merge 4 commits into
mainfrom
farm/a5ff6a15/enum-initializer-hoisted-decls
Open

robobun wants to merge 4 commits into
mainfrom
farm/a5ff6a15/enum-initializer-hoisted-decls

Conversation

@robobun

@robobun robobun commented Aug 15, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • A class expression that needs the standard decorator lowering (it has decorators, or an accessor field) inside a member initializer of a top-level TypeScript enum fails at runtime: ReferenceError: _dec is not defined (or _x is not defined for an accessor named x). The same class expression outside the enum works.
    function dec(v: any, ctx: any) {}
    enum E {
      A = (globalThis.K = class { accessor x = 1 }, 0),
      B = (globalThis.L = @dec class {}, 1),
    }
  • The lowering emits a var declaring the temporaries it assigns in the comma expression (_dec, _init, _class, the accessor's WeakMap) and appends it to p.nearest_stmt_list (src/js_parser/lower/lower_decorators.rs:2531), silently doing nothing when that is None. React refresh appends its var _s = $RefreshSig$() the same way (src/js_parser/visit/visit_expr.rs), and for a hook function inside a top-level enum it emitted the _s() call in the body without ever declaring _s.
  • visit_stmts visits the enum statements of a list before the list's other statements, ahead of installing its own list in nearest_stmt_list (src/js_parser/visit/mod.rs, the preprocessed_enums loop runs before the assignment a few lines below it). So while enum members are visited the pointer still refers to the list around the enclosing visit_stmts:
    • at the top level that is None, and the declaration is dropped;
    • inside a function it is the list the function statement lives in, so the var is emitted at module level and every call of the function shares one set of temporaries. Calling such a function twice and then constructing an instance of the first class throws TypeError: Cannot read from private field: the constructor reads the second call's WeakMap while the accessor installed by __decorateElement still uses the first.

Fix

  • s_enum (src/js_parser/visit/visit_stmt.rs) points nearest_stmt_list at a local list while the member initializers are visited and emits that list at the top of the closure the enum body compiles to, ahead of the member assignments.
  • Why this is the right place: the enum body is already its own hoisting scope in the parser (Kind::Entry, the scope the temporaries are registered in) and compiles to a closure that runs once per evaluation of the enum declaration, so declaring the temporaries there gives every evaluation of every enum its own bindings, the same way a namespace body (visited with its own statement list) already behaves. Declaring them in the enclosing list instead would keep the var outside the scope the symbols belong to, and two top-level enums would share one _x even under the renamer (bun build), since sibling scopes legitimately receive the same names.
  • With that, every visit runs with a list installed, so nearest_stmt_list_mut() (src/js_parser/p.rs) now debug_assert!s that; the two react refresh sites go through the accessor as well. The release fallbacks are unchanged. This is what turns the next stale-pointer producer into a test failure instead of a silently dropped declaration, which is how this one went unnoticed.
  • The pointer handling follows the existing pattern in visit_stmts: a stack local, save/restore around the visit, addr_of_mut! so the pointer is not derived from a temporary &mut; nothing touches the local while the pointer is installed and there is no early return in between.
  • This fixes the enum producer of this bug shape. Two more producers exist and are tracked separately: a class expression in a default parameter (function f(K = class { @dec accessor x = 0 }) {}) or in a class field initializer (class Outer { inner = class { ... } }) still gets its var hoisted to the enclosing statement list, so calling the function / constructing Outer twice shares the temporaries. Neither has a per-evaluation statement list, so that fix belongs in the lowering's expression-mode emission, not here.
  • Independent of js_parser: give standard decorator lowering temporaries unique names #38734 (unique names for the temporaries): that PR does not change where the var is emitted and this one does not change how the temporaries are named; js_parser: give standard decorator lowering temporaries unique names #38734 registers them in the Kind::Entry scope, i.e. the closure this PR declares them in. Two lowered classes inside one enum still share names until it lands, exactly like two classes in one function today. The tests use decorated accessors, so they keep exercising the hoisting path if Lower accessor-only classes in place instead of relocating static elements #31926 / js_parser: lower undecorated auto-accessors to a native #-private storage field #35708 (undecorated accessors lowered in place) land first.
  • Verified:
    • test/bundler/transpiler/es-decorators.test.ts, describe "declarations hoisted out of enum initializers" (5 tests: top-level enum with a decorated class, a decorated accessor and a plain accessor; enum in a function called twice; two sibling top-level enums; Bun.build({ reactFastRefresh: true }) declaring _s inside the closure; Bun.Transpiler output declaring the temporaries inside the closure and not at module level, top-level and nested). All 5 fail on the released build with the errors above, pass with the fix.
    • The debug assertion did not fire across test/bundler/transpiler/, test/bundler/esbuild/, bundler_edgecase, bundler_jsx, bundler_decorator_metadata, bundler_cjs2esm, bundler_naming, bundler_minify, bundler_regressions, bun-build-api, regression/issue/25716 (react refresh), and the bake dev suites react-spa, hot, esm, bundle (all of which are also transpiled by the debug build themselves); all pass. cargo clippy -p bun_js_parser clean.
    • bun build of the sibling-enum case runs correctly with the fix; the released build's output throws.

Background

  • The standard decorator lowering turns class { ... } in expression position into (_init = ..., _class = class { ... }, ..., _class) and hoists a var _init, _class, ...; for the temporaries into the nearest statement list, because an expression cannot declare variables. The class body keeps referring to those temporaries at runtime (constructors run __runInitializers(_init, ...), accessors look their value up in a hoisted WeakMap), so where the var lands decides which code shares them.
  • nearest_stmt_list is a raw pointer to the statement list currently being built; visit_stmts points it at the before list that is emitted ahead of the visited statements and restores it when done. Its two consumers are the decorator lowering above and react refresh, which declares one _s = $RefreshSig$() per hook-calling function next to that function.
  • visit_stmts visits every enum in a list before the list's other statements so that enum members can be inlined into code that precedes the declaration (TypeScript itself relies on this). That is why the enum's members are visited while the enclosing list is not installed yet.
  • A TypeScript enum compiles to var E; ((E) => { E[E["A"] = ...] = "A"; })(E ||= {});; the parser gives the body a Kind::Entry scope, the same kind a namespace body gets, and generate_closure_for_type_script_namespace_or_enum wraps whatever statements it is handed into that closure.

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

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

test/bundler/transpiler/es-decorators.test.ts:
(pass) ES Decorators > class decorators > basic class decorator [371.49ms]
(pass) ES Decorators > class decorators > class decorator receives correct context [344.48ms]
(pass) ES Decorators > class decorators > class decorator can replace class [390.63ms]
(pass) ES Decorators > class decorators > multiple class decorators apply in reverse order [329.93ms]
(pass) ES Decorators > method decorators > instance method decorator [342.51ms]
(pass) ES Decorators > method decorators > static method decorator [341.91ms]
(pass) ES Decorators > method decorators > method decorator context has correct access [338.45ms]
(pass) ES Decorators > getter decorators > getter decorator [341.51ms]
(pass) ES Decorators > setter decorators > setter decorator [336.44ms]
(pass) ES Decorators > field decorators > field decorator receives undefined value [361.25ms]
(pass) ES Decorators > field decorators > multiple field decorators [347.03ms]

... (truncated)

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

test/bundler/transpiler/es-decorators.test.ts:
(pass) ES Decorators > class decorators > basic class decorator [12.98ms]
(pass) ES Decorators > class decorators > class decorator receives correct context [10.08ms]
(pass) ES Decorators > class decorators > class decorator can replace class [9.30ms]
(pass) ES Decorators > class decorators > multiple class decorators apply in reverse order [9.71ms]
(pass) ES Decorators > method decorators > instance method decorator [9.53ms]
(pass) ES Decorators > method decorators > static method decorator [8.73ms]
(pass) ES Decorators > method decorators > method decorator context has correct access [9.14ms]
(pass) ES Decorators > getter decorators > getter decorator [10.54ms]
(pass) ES Decorators > setter decorators > setter decorator [9.60ms]
(pass) ES Decorators > field decorators > field decorator receives undefined value [9.59ms]
(pass) ES Decorators > field decorators > multiple field decorators [9.61ms]
(pass) ES Decorators > field decorators > static field decorator [10.27ms]
(pass) ES Decorators > non-ASCII string-literal keys > Bun.Transpiler output preserves the key [0.73ms]
(pass) ES 
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/bundler/transpiler/es-decorators.test.ts
bun test v1.4.0 (ccae66185)

test/bundler/transpiler/es-decorators.test.ts:
(pass) ES Decorators > class decorators > basic class decorator [438.94ms]
(pass) ES Decorators > class decorators > class decorator receives correct context [373.37ms]
(pass) ES Decorators > class decorators > class decorator can replace class [380.08ms]
(pass) ES Decorators > class decorators > multiple class decorators apply in reverse order [387.65ms]
(pass) ES Decorators > method decorators > instance method decorator [394.33ms]
(pass) ES Decorators > method decorators > static method decorator [374.26ms]
(pass) ES Decorators > method decorators > method decorator context has correct access [357.57ms]
(pass) ES Decorators > getter decorators > getter decorator [430.52ms]
(pass) ES Decorators > setter decorators > setter decorator [389.56ms]
(pass) ES Decorators > field decorators > field decorator receives undefined value [366.76ms]
(pass) ES Decorators > field decorators > multiple field decorators [398.27ms]

... (truncated)

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

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

�[1m�[92m   Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92m   Compiling�[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
�[1m�[92m   Compiling�[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr)
�[1m�[92m   Compiling�[0m bun_boringssl_sys v0.0.0 (/workspace/bun/src/boringssl_sys)
�[1m�[92m   Compiling�[0m bun_safety v0.0.0 (/workspace/bun/src/safety)
�[1m�[92m   Compiling�[0m bun_zlib_sys v0.0.0 (/workspace/bun/src/zlib_sys)
�[1m�[92m   Compiling�[0m bun_cares_sys v0.0.0 (/workspace/bun/src/cares_sys)
�[1m�[92m   Compiling�[0m bun_zstd v0.0.0 (/workspace/bun/src/zstd)
�[1m�[92m   Compiling�[0m bun_picohttp v0.0.0 (/workspace/bun/src/picohttp)
�[1m�[92m   Compiling�[0m bun_brotli v
... (truncated)
diff hotspot
src/js_parser/p.rs                            |  12 +-
 src/js_parser/visit/visit_expr.rs             |   4 +-
 src/js_parser/visit/visit_stmt.rs             |   9 +-
 test/bundler/transpiler/es-decorators.test.ts | 152 +++++++++++++++++++++++---
 4 files changed, 152 insertions(+), 25 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                           reads  edits  tests
src/js_parser/p.rs                                12      9      0
src/js_parser/visit/visit_expr.rs                  4      2      0
src/js_parser/visit/visit_stmt.rs                  4      5      0
test/bundler/transpiler/es-decorators.test.ts      4      9      0

@robobun

robobun commented Aug 15, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 10:52 PM PT - Aug 14th, 2026

⏳ @robobun, your commit ccae661 is still building in Build #97432, but has 1 failures so far (All Failures):

@robobun

robobun commented Aug 15, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: reproduced on the released build (bun t.ts with a decorated or accessor class expression inside a top-level enum initializer throws ReferenceError: _dec is not defined; the same enum inside a function called twice throws TypeError: Cannot read from private field). Fix is in this PR; the 5 tests under "declarations hoisted out of enum initializers" in test/bundler/transpiler/es-decorators.test.ts fail on the released build and pass with the fix.

CI for ccae661 (build 97432, finished): 177 jobs passed, including every test lane that ran and the debug/ASAN lanes that exercise the new assertion; no test failed outside of known flaky ones that passed on retry. The two non-green jobs are infrastructure: linux aarch64-musl - verify-baseline failed while rustup was downloading rust-std-aarch64-unknown-linux-musl on the agent (before the verify script ran), and one darwin 14 aarch64 test job expired waiting for an agent. Both should clear on a retry. All review threads are resolved. Ready for review.

@coderabbitai

coderabbitai Bot commented Aug 15, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: d580f91f-7884-45c1-85df-72af50887280

📥 Commits

Reviewing files that changed from the base of the PR and between c2fa121 and 0b0a02e.

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

Walkthrough

TypeScript enum visitation now captures declarations hoisted from enum initializers, restores the previous parser context, and prepends the declarations to generated enum statements. Decorator transpiler tests cover class expressions, isolation, runtime behavior, and temporary placement.

Changes

Enum initializer lowering

Layer / File(s) Summary
Enum hoisting context
src/js_parser/p.rs, src/js_parser/visit/visit_stmt.rs
The parser uses a temporary nearest statement list during enum initializer visitation. It restores the previous pointer and prepends hoisted statements to generated enum values. Documentation describes the updated pointer targets.
Enum decorator coverage
test/bundler/transpiler/es-decorators.test.ts
The shared runDecoratorTS helper runs TypeScript fixtures. Tests cover decorated class expressions, nested and sibling enums, repeated calls, runtime behavior, and temporary placement.

Possibly related PRs

  • oven-sh/bun#38126: Both PRs update the runDecoratorTS helper in es-decorators.test.ts.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly identifies the parser change and the enum-closure placement of hoisted temporaries.
Description check ✅ Passed The description explains the problem, fix, design rationale, limitations, and verification results in detail.

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

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

I reviewed this PR and didn't find any bugs. The fix is small and follows the existing visit_stmts pointer pattern exactly, but since it touches the parser's raw-pointer nearest_stmt_list machinery and changes where lowered temporaries are scoped, a human look would still be worthwhile.

Checked: no ?/early return between installing the pointer and restoring it (the member-initializer loop is infallible); hoisted_stmts is never touched directly while the pointer is live, so the addr_of_mut! tag stays valid; the pointer is restored before hoisted_stmts is moved into value_stmts. Confirmed StmtList<'a> and ListManaged<'a, Stmt> are both BumpVec<'a, Stmt>, so the NonNull type lines up. The other nearest_stmt_list consumer (react refresh) would now emit into the enum closure too, which is the correct scope for anything inside an enum body.

Extended reasoning...

Overview

Fixes a codegen bug where the standard-decorator lowering's hoisted var (for _dec, _init, _class, accessor WeakMaps) is dropped or emitted in the wrong scope when the class expression appears inside a TypeScript enum member initializer. The fix has s_enum install a local BumpVec as p.nearest_stmt_list while visiting member initializers, then prepends that list to the closure the enum body compiles to. Touches src/js_parser/visit/visit_stmt.rs (~15 lines), doc comments in src/js_parser/p.rs, and adds 4 tests to test/bundler/transpiler/es-decorators.test.ts (plus moves runDecoratorTS to module scope so the new describe block can reuse it).

Security risks

None. This is a transpiler codegen fix; no untrusted-input parsing changes, no allocation sizing driven by user data, no FFI surface changes.

Level of scrutiny

Medium-high. The change is small and follows the exact save/install/restore pattern already used in visit_stmts (src/js_parser/visit/mod.rs:1343-1540), but it lives in the JS parser — a hot, correctness-critical path — and manipulates a raw NonNull back-pointer whose safety depends on there being no early return or direct reborrow of the local while the pointer is installed. I verified those invariants hold (the member loop between install and restore contains no ? and never names hoisted_stmts), and that the restore happens before the local is moved. The scoping decision (emit into the enum closure rather than the enclosing list) is well-argued in the PR description and matches how namespace bodies already behave, but it is a semantic choice a maintainer should confirm.

Other factors

The PR description is unusually thorough — it names the mechanism (preprocessed_enums runs before nearest_stmt_list is installed at visit/mod.rs:1348), explains why the enum closure is the right scope (matches the Kind::Entry scope the temporaries are registered in, and how namespaces already work), and lists the regression suites run. Tests cover the top-level case (dropped var), the nested-function case (shared temporaries across calls), sibling enums, and a Bun.Transpiler shape assertion that pins the var inside the closure and asserts it is absent at module level. Tests follow harness conventions (tempDir, drained pipes, stderr-before-exitCode, test.concurrent for the subprocess cases). No prior reviews or outstanding comments on the PR.

…e the enum closure

Lowering a class expression (standard decorators or an accessor field)
appends a var declaring its temporaries to p.nearest_stmt_list. While an
enum's members are visited that pointer still refers to the list around
the enclosing visit_stmts, because enums are visited before their
sibling statements: at the top level it is None and the declaration was
dropped (ReferenceError: _dec is not defined at runtime), and inside a
function it pointed at the list enclosing the function, so every call
shared one set of temporaries.

s_enum now collects those declarations itself and emits them at the top
of the closure the enum body compiles to, which gives each evaluation of
each enum its own bindings.
@robobun
robobun force-pushed the farm/a5ff6a15/enum-initializer-hoisted-decls branch from 0b0a02e to aea29e9 Compare August 15, 2026 02:40
Comment thread src/js_parser/p.rs Resolved
Comment thread src/js_parser/p.rs Resolved
Comment thread src/js_parser/visit/visit_stmt.rs Resolved
Comment thread src/js_parser/p.rs Resolved
Comment thread src/js_parser/visit/visit_stmt.rs Resolved
Comment thread src/js_parser/visit/visit_stmt.rs Resolved

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM — the earlier comment nit is addressed (single-line, no past-tense narration).

What was reviewed:

  • s_enum save/restore of nearest_stmt_list matches the existing visit_stmts pattern (addr_of_mut! on a stack local, restored before the local is moved); no early return / ? between install and restore.
  • Confirmed the preprocessed_enums loop in visit/mod.rs runs before nearest_stmt_list is installed, which is why the enclosing list can't be reused here.
  • p.rs changes are comment-only; runDecoratorTS is a mechanical hoist to file scope; new tests cover top-level / nested-in-function / sibling enums plus a transpiler output-shape check.
Extended reasoning...

Overview

The PR fixes a runtime ReferenceError when a class expression that requires standard-decorator lowering (a decorated class or one with an accessor field) appears inside a TypeScript enum member initializer. The lowering hoists a var for its temporaries into p.nearest_stmt_list, but enums are pre-visited in visit_stmts before that pointer is installed, so at the top level the pointer is None and the declaration is silently dropped, and inside a function it targets the wrong (outer) scope so all evaluations share one set of temporaries.

The Rust change in s_enum (src/js_parser/visit/visit_stmt.rs:2194-2327) is ~10 lines: allocate a local hoisted_stmts list, point nearest_stmt_list at it via addr_of_mut! while enum member initializers are visited, restore the previous pointer, then reuse hoisted_stmts as the head of value_stmts so the declarations land at the top of the enum closure passed to generate_closure_for_type_script_namespace_or_enum. The p.rs change is a one-line doc-comment update on nearest_stmt_list (adds s_enum alongside visit_stmts) plus removal of a redundant doc paragraph on nearest_stmt_list_mut (the SAFETY block already carries the invariant). The test file hoists runDecoratorTS to file scope (mechanical move, unchanged body) and adds 4 tests.

Security risks

None. This is transpiler visit-order logic; no user-controlled input reaches new unsafe code, no network/filesystem/auth surface. The one unsafe this touches is the pre-existing nearest_stmt_list_mut accessor, and the new install site follows the exact same pattern already used in visit/mod.rs:1343-1348 — stack-local BumpVec, addr_of_mut! (no intermediate &mut so the pointer's provenance survives), saved/restored around the loop. I traced lines 2199-2319 for early exits: no ?, no return, so the restore always runs before hoisted_stmts is moved into value_stmts. The local is not reborrowed directly while the pointer is installed.

Level of scrutiny

Medium. The parser is a hot/critical path, but the change is small, targeted (only the enum-member-initializer code path), and copies an established idiom line-for-line from visit_stmts. The PR description's argument for placing the var inside the enum closure (matching the enum body's Kind::Entry scope, the same behavior a namespace body already has) is sound and confirmed by reading visit/mod.rs — the preprocessed_enums loop indeed runs before nearest_stmt_list is set. No CODEOWNERS entry gates src/js_parser.

Other factors

  • All prior review threads (comment-cop bot × 5, my earlier past-tense-narration nit) are resolved; the current diff has the one-line comment without the "used to" clause.
  • The 4 new tests cover the variant matrix REVIEW.md asks for: top-level enum (both a decorated class and an accessor class in one enum), enum inside a function called twice (asserts fresh temporaries per call — the WeakMap-sharing failure mode), two sibling top-level enums, and a Bun.Transpiler output-shape test asserting the var is inside the closure and not at module level for both top-level and nested cases. Tests use tempDir, drain stdout/stderr/exited concurrently, assert stderr before exitCode, and the three subprocess tests are test.concurrent.
  • The PR description reports the tests fail on the released build and pass with the fix, and lists the existing suites that were run (es-decorators, esbuild/ts, react-compiler-fixtures — the other nearest_stmt_list consumer, etc.).

…ct refresh in enums

With s_enum installing its own list there is no longer a visit in which
nearest_stmt_list is None, so reading it through nearest_stmt_list_mut()
now debug-asserts instead of letting a future caller silently drop the
declaration again; the react refresh sites go through the accessor too
(their release fallback is unchanged).

Tests: the react refresh signature of a hook function inside a top-level
enum is declared inside the enum closure (it used to be referenced but
never declared), and the enum tests use decorated accessors so they keep
exercising the hoisting path if undecorated accessors stop hoisting.

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

LGTM — the save/install/restore of nearest_stmt_list in s_enum mirrors the existing pattern in visit_stmts exactly, and the hoisted declarations land in the closure that owns the scope the temporaries were registered in.

What was reviewed:

  • Traced the pointer lifetime in s_enum: no ?/early return between install (line 2197) and restore (line 2321); hoisted_stmts is only moved after the pointer is restored, so no dangling.
  • Confirmed the visit_expr.rs change (routing .is_none() through nearest_stmt_list_mut()) is intentional so the new debug_assert! covers the react refresh reader too; the guard remains in release builds.
  • Checked that the preprocessed-enum loop in visit/mod.rs:1319-1336 runs before nearest_stmt_list is installed, matching the PR's root-cause analysis.
  • Tests cover the full class: top-level, function-nested (per-call temporaries), sibling enums, react refresh, and negative Bun.Transpiler assertions on where the var is NOT emitted.
Extended reasoning...

Overview

The PR fixes a bug where the standard-decorator lowering's hoisted var declarations (_dec, _init, _class, accessor WeakMaps) were silently dropped when a class expression appeared inside a top-level TypeScript enum initializer, causing ReferenceError at runtime. The mechanism: visit_stmts visits enum statements in a preprocessing loop (mod.rs:1319-1336) before it installs its before list into p.nearest_stmt_list (mod.rs:1348), so lower_decorators.rs:2531 found None (top level) or the wrong enclosing list (nested).

The fix has s_enum install a local hoisted_stmts list as nearest_stmt_list around the member-initializer visit and prepend that list to the enum closure's body statements. Plus: a debug_assert! in nearest_stmt_list_mut() so any future path that reads it while unset panics in debug builds, and the two react-refresh readers in visit_expr.rs are routed through the accessor so they're covered by the assert.

Security risks

None. This is a compile-time transform of user source; the change only affects where a generated var statement is placed in emitted output. No new inputs are parsed and no untrusted data flows into new code paths.

Level of scrutiny

Medium-high — parser/visitor changes affect every transpiled file, and the mechanism uses a raw NonNull back-pointer to a stack local. However, the pattern is a byte-for-byte copy of the existing one in visit_stmts (addr_of_mut!, save/restore, no direct use of the local while the pointer is installed, no early return in between). I traced the loop body (lines 2200-2319) — no ? operators, and hoisted_stmts is not touched directly until after the pointer is restored at line 2321 and then moved into value_stmts at line 2326. The pointer targets the stack BumpVec struct, not its buffer, so pushes during visit_expr don't invalidate it.

Other factors

  • All prior review threads (comment-cop bot on comment length, and my own earlier nit about past-tense narration in the visit_stmt.rs comment) are resolved; the current diff has the one-line comment.
  • Tests are thorough per REVIEW.md's "cover the variant matrix": top-level enum (the repro), enum-in-function called twice (proves per-evaluation temporaries), two sibling enums (proves no cross-enum sharing under the renamer), react refresh inside an enum (the other nearest_stmt_list consumer), and a Bun.Transpiler test with both positive (var declared inside ((E) => {) and negative (^var not at module scope) assertions. runDecoratorTS was hoisted to file scope unchanged.
  • The debug_assert! is a good hardening move — it converts the previously-silent-drop into a debug-build panic, and REVIEW.md's "new invariant checks on existing code default to debug-only" is satisfied.
  • The fix is at the layer that owns the invariant (the enum body is its own Kind::Entry hoisting scope, so declarations hoisted out of its initializers belong in its closure), matching how namespace bodies already behave.
  • No CODEOWNERS entry for js_parser.

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

Development

Successfully merging this pull request may close these issues.

1 participant