Skip to content

transpiler: preserve class TDZ by not hoisting declarations in the runtime path - #35648

Open
robobun wants to merge 6 commits into
mainfrom
farm/d6c0c432/transpiler-class-tdz
Open

transpiler: preserve class TDZ by not hoisting declarations in the runtime path#35648
robobun wants to merge 6 commits into
mainfrom
farm/d6c0c432/transpiler-class-tdz

Conversation

@robobun

@robobun robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

What

bun run (runtime transpiler, --target=bun without bundling) moves side-effect-free class declarations and movable export default statements to the top of the module. For class bindings that erases the TDZ: typeof Foo and new Foo() succeed before the declaration line instead of throwing ReferenceError, diverging from Node, browsers, and tsc output. Behaviour is also inconsistent within a file, because a class with a static block / extends / an outer-reading static initializer is not moved.

Repro

// bun run repro.mjs
const t = (f) => { try { return f(); } catch (e) { return "THROW:" + e.constructor.name; } };
console.log(t(() => typeof Pure));      // spec/node: THROW:ReferenceError, bun before: "function"
console.log(t(() => new Pure().m()));   // spec/node: THROW:ReferenceError, bun before: "constructed-before-declaration"
console.log(t(() => typeof WithBlock)); // THROW:ReferenceError in both (static block => not moved)
class Pure { m() { return "constructed-before-declaration"; } f = 1; get g() { return 2; } static s = 3; }
class WithBlock { static { void 0; } }

bun build --no-bundle --target=bun repro.mjs on main prints class Pure physically above the const t = ... line.

Cause

src/js_parser/parse/parse_entry.rs in the tree-shaking (per-statement part) branch pops SClass and SExportDefault parts into the before list when !bundle && can_be_moved(), and before is later prepended to parts. Added in eec1a07 / c3dc64d to work around early ESM cycle evaluation-order issues that broke kysely and luxon imports. The original change gated SClass on is_export; that guard was dropped in the follow-up and the reorder has applied to every movable top-level class since.

Fix

  • Drop the SClass match arm entirely so class / export class declarations fall through to the default per-statement handling and keep source order. Their bindings now stay in TDZ until the declaration runs.
  • Keep the SExportDefault hoist (still needed: removing it segfaults svelte.test.ts on linux-aarch64 release, build 80714, while adjacent PR builds 80740/80750 pass the same shard) but suppress it when the default is a named class whose name an earlier top-level statement already references, so export default class Named stays in TDZ when observed before its declaration. export default function / export default <const> are unaffected.

Verified import { DateTime } from "luxon" and import { Kysely } from "kysely" still succeed with the debug build.

Relationship to #34933

#34933 applies the same use_count_estimate guard to both SClass and SExportDefault. This PR removes the SClass hoist outright instead (luxon / kysely import fine without it) and keeps the guarded hoist only for SExportDefault where dropping it is observably load-bearing. Either is a valid direction; happy to close one once a maintainer picks.

Tests

Added to test/bundler/transpiler/runtime-transpiler.test.ts:

  • class / export class / export default class all throw ReferenceError when touched before their declaration
  • bun build --no-bundle --target=bun keeps class / export class in source order
  • two-file mutual export default class cycle (the luxon/kysely pattern) still evaluates

[review] gate passed · iteration 1 · 2 files touched

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

test/bundler/transpiler/runtime-transpiler.test.ts:
(pass) use strict causes CommonJS [309.78ms]
(pass) non-ascii regexp literals [3.12ms]
(pass) ascii regex with escapes [1.98ms]
(pass) // @bun > async transpiler [46.97ms]
(pass) // @bun > require() [16.89ms]
(pass) // @bun > synchronous [294.20ms]
(pass) json imports > require(*.json) [18.80ms]
(pass) json imports > import(*.json) [10.14ms]
(pass) json imports > should support comments in tsconfig.json [14.78ms]
(pass) json imports > should handle non-boecjts in tsconfig.json [11.89ms]
(pass) json imports > should handle duplicate keys [7.21ms]
(pass) with statement > works [305.17ms]
(pass) math.pow [6.07ms]
(pass) unterminated string literals in large files > reports an unterminated string literal at the end of a large JavaScript file [478.69ms]
(pass) unterminated string literals in large files > reports an unterminated string literal at the end of a large JSON file [356.92ms]
325 | 
326 |     expect(s
... (truncated)

release without fix: 6 FAILED
bun test v1.3.14 (0d9b296a)

test/bundler/transpiler/runtime-transpiler.test.ts:
(pass) use strict causes CommonJS [15.44ms]
(pass) non-ascii regexp literals [0.14ms]
(pass) ascii regex with escapes [1.00ms]
(pass) // @bun > async transpiler [2.62ms]
(pass) // @bun > require() [0.48ms]
(pass) // @bun > synchronous [18.30ms]
(pass) json imports > require(*.json) [0.51ms]
(pass) json imports > import(*.json) [0.24ms]
(pass) json imports > should support comments in tsconfig.json [0.23ms]
(pass) json imports > should handle non-boecjts in tsconfig.json [0.11ms]
(pass) json imports > should handle duplicate keys [0.11ms]
(pass) with statement > works [14.73ms]
(pass) math.pow [0.18ms]
killed 1 dangling process
(fail) unterminated string literals in large files > reports an unterminated string literal at the end of a large JavaScript file [5000.13ms]
  ^ this test timed out after 5000ms.

# Unhandled error between tests
-------------------------------
225 |     });
226 | 
227 |     const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
228 | 
229 |     expect(stdout).toBe("");
230 |     expect(stderr).toContain("Unter
... (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/runtime-transpiler.test.ts
bun test v1.4.0 (277884fe9)

test/bundler/transpiler/runtime-transpiler.test.ts:
(pass) use strict causes CommonJS [489.33ms]
(pass) non-ascii regexp literals [5.58ms]
(pass) ascii regex with escapes [10.09ms]
(pass) // @bun > async transpiler [104.50ms]
(pass) // @bun > require() [64.38ms]
(pass) // @bun > synchronous [653.94ms]
(pass) json imports > require(*.json) [41.90ms]
(pass) json imports > import(*.json) [24.71ms]
(pass) json imports > should support comments in tsconfig.json [27.05ms]
(pass) json imports > should handle non-boecjts in tsconfig.json [19.54ms]
(pass) json imports > should handle duplicate keys [23.56ms]
(pass) with statement > works [555.48ms]
(pass) math.pow [9.81ms]
(pass) unterminated string literals in large files > reports an unterminated string literal at the end of a large JavaScript file [801.09ms]
(pass) unterminated string literals in large files > reports an unterminated string literal at the end of a large JSON file [830.53ms]
(pass) class declarati
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     277884fe96
  features     baseline

22 deps, 108 codegen, 1171 objects in 3027ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1238] mkdir codegen
[2/1238] mkdir stamps
[3/1238] mkdir pch
[4/1238] mkdir obj
[5/1238] gen bindgenv2
[6/1238] gen ErrorCode+*.h
[7/1238] gen .bind.ts → GeneratedBindings.cpp
[8/1238] fetch zlib
[zlib] up to date
[9/1238] fetch picohttpparser
[picohttpparser] up to date
[10/1238] subst deps/zlib/zlib.h
[11/1238] fetch nodejs (prebuilt)
[nodejs] up to date
[12/1238] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[13/1238] gen ProcessBindingConstants.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingConstants.lut.h from /workspace/bun/src/jsc/bindings/ProcessBindingConstants.cpp
[14/1238] fetch tinycc
[tinycc] up to date
[15/1238] fetch zstd
[zstd] up to date
[16/1238] fetch libarchive
[libarchive] up to date
[17/1238] fetch libdeflate
[libdeflate] up to date
[18/1238] subst deps/libjpeg-turbo/jconfig.h
[19/1238] gen JSBuffer.lu
... (truncated)
diff hotspot
src/js_parser/parse/parse_entry.rs                 |  29 +++---
 test/bundler/transpiler/runtime-transpiler.test.ts | 104 +++++++++++++++++++++
 2 files changed, 115 insertions(+), 18 deletions(-)

gate history · 1 passed · 0 rejected · iteration 1

evidence per changed file
file                                                reads  edits  tests
src/js_parser/parse/parse_entry.rs                      3      5      0
test/bundler/transpiler/runtime-transpiler.test.ts      3      4      0

… path

The runtime transpiler (target=bun, non-bundle) moved side-effect-free
class declarations and export default statements to the top of the
module via the 'before' parts list. This erased the TDZ for class
bindings: 'typeof Foo' and 'new Foo()' would succeed before the
declaration line instead of throwing ReferenceError, diverging from
Node and browsers.

The hoist was originally added (eec1a07, c3dc64d) to paper over
early ESM cycle evaluation-order differences that hit kysely and luxon.
Both packages now import cleanly without it, so drop the reordering and
let SClass / SExportDefault fall through to the default per-statement
part path, preserving source order and class TDZ semantics.
@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

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

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

Next review available in: 41 minutes

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

How can I continue?

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

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

How do review limits work?

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

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

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 1c78e246-a79d-4b7c-80c6-779e0495efda

📥 Commits

Reviewing files that changed from the base of the PR and between df6c7ee and 277884f.

📒 Files selected for processing (2)
  • src/js_parser/parse/parse_entry.rs
  • test/bundler/transpiler/runtime-transpiler.test.ts

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

@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

Reproduced with bun run repro.mjs printing function / constructed-before-declaration for a class referenced before its declaration. Fix in src/js_parser/parse/parse_entry.rs: drop the SClass hoist so class / export class keep source order and TDZ; keep the SExportDefault hoist for #1961 cycle compat except when the default is a named class (which has a module-scope TDZ binding), so export default class Named also stays in place.

CI (build 80920): 135 test shards pass including svelte.test.ts on all five linux aarch64/x64 lanes (the regression from the earlier full-removal variant in build 80714 is gone) and the new runtime-transpiler.test.ts cases. Remaining red is unrelated: node-tls-getpeercert-leak.test.ts RSS threshold on debian-13-x64 (34.1MB vs 32MB, with a File Watcher EAGAIN panic in the same run; reported for main-break triage), an aarch64-musl build-bun step failure outside the runner marked pre-existing, and a handful of [flaky] retries. 56 lanes never scheduled (build-cpp agents expiring, same infra issue as builds 80493/80714). Ready for maintainer review.

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. transpiler: preserve TDZ for top-level class declarations referenced earlier in the file #34933 - Also modifies the same SClass/SExportDefault hoisting logic in parse_entry.rs to preserve class TDZ semantics, using a use-count guard instead of removing the hoist entirely

🤖 Generated with Claude Code

@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

#34933 targets the same hoist but takes a different approach, so noting the tradeoff here rather than treating either as a pure duplicate:

The hoist was a 2023 workaround for cyclic-import evaluation order in kysely and luxon (eec1a07 / c3dc64d). With the hoist removed, import { DateTime } from "luxon" and import { Kysely } from "kysely" both succeed under the debug build, so the evaluation-order bug it papered over is no longer present and the workaround itself is what is breaking TDZ. Removing it also closes the secondary inconsistency where class Pure {} is hoisted but class WithBlock { static {} } is not, so --no-bundle --target=bun output now matches source order for all class declarations.

Happy to close either one in favour of the other once a maintainer picks a direction.

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

This deletes the SClass/SExportDefault hoist that was added specifically to fix kysely-org/kysely#412 and #1961. The TDZ fix is spec-correct, but the claim that those cyclical-import cases no longer need the workaround is only verified manually — there's no automated test for luxon/kysely in this PR or in test/, so a regression there wouldn't be caught. Worth a maintainer look at whether the underlying ESM cycle-ordering fix that supersedes this is actually in place, and whether an integration test for the original repros should ship alongside.

Extended reasoning...

Overview

Two files: src/js_parser/parse/parse_entry.rs drops the two match arms that popped SClass and SExportDefault parts into before when !bundle && can_be_moved(); they now fall through to the default arm (append_part(&mut parts, ...)), which is exactly what the removed arms did minus the hoist, so the fallthrough is mechanically correct. test/bundler/transpiler/runtime-transpiler.test.ts adds three tests covering class, export class, export default class TDZ behavior at runtime plus a --no-bundle output-order check.

Security risks

None. Parser statement-ordering only; no untrusted input handling, no allocation changes.

Level of scrutiny

High. This is the runtime transpiler path that every bun run goes through, and it deliberately reverses commits eec1a07 / c3dc64d which were added to fix real-world package breakage (kysely, luxon). REVIEW.md is explicit: "Before deleting odd-looking code, git-blame why it was written — it is usually load-bearing." The author did the blame and states the original cycle-ordering problem "is no longer present," but that's a manual check — I found no automated test in test/ covering the kysely/luxon cyclical-import scenario, so nothing in CI would catch a regression. Also note SExportDefault::can_be_moved() covers more than classes (functions, arrows, literal exprs), so this stops hoisting export default function/export default <literal> too — semantically fine (function decls hoist at runtime anyway; literal defaults have no binding), but broader than the PR title suggests.

Other factors

The new tests follow harness conventions well (tempDir, bunEnv, concurrent pipe drain, test.concurrent). The one inline nit (vacuous expect.any(Number)) is cosmetic and wouldn't block on its own. I checked that the bundler-side hoisting in needs_wrapper_ref (p.rs:8518) and generateCodeForFileInChunkJS.rs is gated on options.bundle and unaffected. Deferring because reversing a documented workaround for ecosystem packages with only manual verification is a maintainer call, not something to auto-approve.

Comment thread test/bundler/transpiler/runtime-transpiler.test.ts Outdated
…import coverage

Address review on #35648:
- Drop the expect.any(Number) object check in the --no-bundle order test
  (indexOf always returns a number) and assert the sorted token order
  directly so a regression shows the actual emitted order in the diff.
- Add a two-file mutual 'export default class' cycle (the luxon/kysely
  pattern the removed hoist was originally added for) to guard against
  regressing those imports.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No issues found — the removed SClass/SExportDefault arms fall through to the default arm which does the identical append_part(&mut parts, ...) work minus the hoist, and the tests cover all three declaration forms plus the cyclic-import case the hoist originally papered over. Deferring only because this and #34933 are two competing fixes for the same code and the author has explicitly asked a maintainer to pick a direction.

What was reviewed:

  • Fallthrough to the _ arm in parse_entry.rs is behaviorally identical to the deleted arms sans the before.push — no lost side effects.
  • can_be_moved() still has live callers in p.rs, lower_esm_exports_hmr.rs, and the linker — not dead code.
  • No existing tests in test/ assert the old hoist-to-top behavior, so nothing should regress in CI on that axis.
  • The new { missing, order } assertion (2040c75) replaces the earlier vacuous check and now fails with a useful diff.
Extended reasoning...

Overview

Deletes two match arms in the tree-shaking per-statement loop of src/js_parser/parse/parse_entry.rs that popped SClass and SExportDefault parts into the before list when !bundle && can_be_moved(). With the arms gone both statement kinds hit the _ catch-all, which performs the same arena.alloc_slice_copy + append_part(&mut parts, ...) without the reorder. Adds four subprocess tests to runtime-transpiler.test.ts covering class / export class / export default class TDZ, printed source order under --no-bundle --target=bun, and a two-file mutual export default class cycle.

Security risks

None. This is a statement-ordering change in the transpiler; no untrusted-input parsing, allocation sizing, or privilege boundaries are touched.

Level of scrutiny

Medium-high. The diff is tiny and mechanically obvious, but it removes a deliberate 2023 workaround (eec1a07 / c3dc64d) that runs on every ESM file the runtime transpiler sees. The author verified luxon and kysely still import cleanly and added a reduced cycle test, but the blast radius is "every top-level class in every module executed by bun run", so ecosystem regressions that the two spot-checks miss are possible. More importantly, #34933 proposes a narrower guard-based fix for the same lines and the author has explicitly deferred the choice to a maintainer — that is a design decision a human should make, not something to auto-approve past.

Other factors

  • The earlier vacuous-assertion nit was addressed in 2040c75; the replacement sorted-token { missing, order } check can fail and surfaces the actual emitted order on regression.
  • Confirmed can_be_moved() remains referenced in src/js_parser/p.rs, src/js_parser/lower/lower_esm_exports_hmr.rs, src/bundler/linker_context/generateCodeForFileInChunkJS.rs, and src/ast/{s,g,expr}.rs, so no dead-code cleanup is owed.
  • Grepped test/ for 1961, kysely, luxon — no existing test asserts the hoist-to-top output, so CI shouldn't flip on that axis.
  • Tests follow harness conventions (tempDir, bunEnv, test.concurrent, concurrent pipe drain, stderr asserted before exitCode).

@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 7:44 AM PT - Jul 25th, 2026

@autofix-ci[bot], your commit 277884f is still building in Build #80920, but has 1 failures so far (All Failures):

…d default classes

svelte.test.ts segfaults on linux-aarch64 release when the SExportDefault
hoist is dropped entirely (build 80714, both distros, identical trace;
adjacent PR builds 80740/80750 pass the same shard). Restore the hoist so
'export default function' / 'export default <const>' keep the existing
cycle-friendly ordering, but suppress it when the default is a named class
whose name an earlier top-level statement already references, so
'export default class Named' still stays in TDZ when observed before its
declaration. Plain 'class'/'export class' declarations remain in source
order (SClass arm still removed).

Test updates:
- --no-bundle order test now covers 'class'/'export class' (both SClass)
- cyclic default-class test still exercises the hoisted path
Comment thread src/js_parser/parse/parse_entry.rs Outdated
Comment thread src/js_parser/parse/parse_entry.rs Outdated
Comment thread src/js_parser/parse/parse_entry.rs Outdated
The use_count_estimate guard only sees references in textually-earlier
statements, so a function declared after the default class (but
engine-hoisted and callable before it) could still observe the class
out of TDZ. Replace the heuristic with a simple is_named_default_class
check: a named default class always stays in source order; anonymous
'export default class {}', 'export default function', and constant
defaults have no module-scope TDZ binding and keep the #1961 hoist.

Extends the export-default TDZ test with a trailing 'function probe()'
that reads the class name, covering the forward-reference case.
Comment thread src/js_parser/parse/parse_entry.rs

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

My earlier feedback (the vacuous assertion and the use-count guard missing hoisted-function references) is addressed — the is_named_default_class check is the right shape and the tests cover the variant matrix. I'm not approving because this needs a maintainer call on two points the PR itself flags: (1) it overlaps with #34933 and asks someone to pick a direction, and (2) the SExportDefault hoist is kept solely because removing it segfaults svelte.test.ts on linux-aarch64 release — that's an un-root-caused crash driving a design choice.

What was reviewed:

  • SClass arm removal falls through to the default _ => branch, so class/export class now go straight to append_part(&mut parts, ...) with no reordering.
  • is_named_default_class: anonymous export default class {} has no module-scope TDZ binding, so continuing to hoist it is spec-safe; export default function bindings are var-like, also fine.
  • Cyclic test uses named default classes (A/B), so it exercises the no-hoist path and still evaluates — confirms the named-class hoist itself was never load-bearing for the luxon/kysely shape.
Extended reasoning...

Overview

The PR touches two files: src/js_parser/parse/parse_entry.rs (removes the SClass match arm from the per-statement tree-shaking loop and gates the SExportDefault hoist on the default not being a named class) and test/bundler/transpiler/runtime-transpiler.test.ts (four new tests: TDZ for class/export class, TDZ for export default class Named including the forward-reference-via-hoisted-function case I raised, --no-bundle source-order preservation, and a two-file mutual default-class import cycle).

Security risks

None. This is statement-ordering in the runtime transpiler; no input parsing, auth, or resource handling is touched.

Level of scrutiny

High. The tree-shaking loop in parse_entry.rs runs for every module Bun executes at runtime, so a regression here has a very wide blast radius. The change narrows an existing reorder (strictly less transformation than before), which limits the failure surface to code that was silently depending on the old hoist — exactly the cyclic-import case the hoist was originally added for. The new cycle test covers the named-class flavor of that pattern.

Other factors

  • The PR description and the duplicate-PR bot both note #34933 takes a different approach (guard both arms on use_count_estimate) and the author explicitly defers the choice to a maintainer. That is a design decision I shouldn't make.
  • The stated reason for retaining the SExportDefault hoist at all is that dropping it segfaults svelte.test.ts on linux-aarch64 release only (build 80714). Per REVIEW.md, "'the crash goes away' is not a root cause" — a maintainer should decide whether that's acceptable to land as-is or whether the segfault needs to be understood first. Notably, the PR's own cycle test uses named default classes, which are now excluded from the hoist, and it passes — so what the remaining hoist actually protects is not covered by a test in this PR.
  • Both of my earlier inline comments were addressed: the vacuous expect.any(Number) block was replaced with a { missing, order } assertion, and the use_count_estimate heuristic was replaced with a structural class_name.is_some() check plus a trailing-function-declaration probe in the test.
  • All prior inline threads on the PR are resolved; the last comment-cop flag was a false positive on a two-line comment (shorter than what it replaces on main).

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants