Repository navigation
Conversation
|
Warning Review limit reached
Next review available in: 43 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the 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 configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: ⛔ Files ignored due to path filters (1)
📒 Files selected for processing (4)
Comment |
|
Reproduced on bun 1.4.0 and main with This PR is stacked on #38284 (its branch is the base here) and should land after it; CI on 612112d: every test job passed; the build is marked failed only because the two |
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs. Since it rewrites how the parser lowers top-level class declarations (with knock-on interactions across decorators, TS namespaces, HMR, and finalize), a human look would still be worthwhile.
Checked: data and the SClass in lowered share the same arena StoreRef, so mem::take(&mut data.class) moves the (already-visited/lowered) class body; the later was_export_inside_namespace read of data.class.class_name is unreachable because a namespace body is never the module scope; and the removed SClass arm in finalize is dead now that module-level classes arrive as SLocal.
Extended reasoning...
Overview
The PR fixes miscompiled output when a module containing top-level using/await using is built for a target that lowers using (browser/node). Previously, exported class declarations were hoisted above the generated try/catch (evaluating before the using value was assigned) and non-exported ones stayed block-scoped inside it (invisible to hoisted function declarations). The fix rewrites each module-scope class statement into var Foo = class Foo {} during the visit pass — the class analogue of what select_local_kind already does for top-level let/const in this mode — and removes the now-dead SClass arm from LowerUsingDeclarationsContext::finalize. Five itBundled tests (exported/non-exported, subclass, await using, both decorator flavors, internal_bake_dev) and one transpiler snapshot cover the change.
Security risks
None. This is a bundler/transpiler output-correctness fix; no untrusted-input parsing surface changes.
Level of scrutiny
Medium-high. The change is small (~50 lines of Rust plus tests) and well-localized, but it sits in the class-lowering path and interacts with several subsystems: lower_class (both decorator flavors emit extra statements around the class), TS namespace merging (was_export_inside_namespace), the export-clause construction in finalize's SLocal arm, and HMR export scanning. The PR description explains each interaction and the tests exercise them at runtime, but AST rewrites that change emitted semantics warrant a maintainer glance.
Other factors
- The gate
p.current_scope().parent.is_none()matches the existing gates atvisit_stmt.rs:512andp.rs:5858, so nested-scope classes are untouched (verified byclass G {}insidef()in the transpiler snapshot). data: &mut S::Classand theSClassslot found inloweredderef the same arenaStoreRef(bothlower_classpaths keep the originalstmt's handle), somem::take(&mut data.class)moves the correct, post-lower_classbody;data.is_exportis read after the take but lives on the outerS::Class, not the takenG::Class.- The
was_export_inside_namespaceblock that readsdata.class.class_nameafter the rewrite requiresenclosing_namespace_arg_ref.is_some(), which never holds at module scope, so the post-takeNoneis never observed. - The removed
SClassarm infinalizehandled onlyis_exportclasses; since all module-scope classes now reachfinalizeasSLocal(routed through the existing export-clause path), and non-module-scope class statements are never exported, the arm is dead. - Test coverage is thorough (runtime assertions on
.name, self-reference, prototype chain, decorator return values, plus output-shape assertions that don't over-constrain the linker), and robobun confirmed all six fail on the release build.
|
Updated 6:05 AM PT - Aug 14th, 2026
❌ @robobun, your commit 612112d has some failures in 🧪 To try this PR locally: bunx bun-pr 38292That installs a local version of the PR into your bun-38292 --bun |
bd48c94 to
b21ddde
Compare
b21ddde to
7dcc2b9
Compare
…el using
When a module's top-level `using` is lowered, the module body moves into a
try/catch. Class declarations were left as declarations: exported ones were
hoisted above the try (running before the `using` values were initialized)
and the rest became block-scoped to it, unreachable from the hoisted
function declarations. Rewrite top-level class statements into
`var Foo = class Foo {}` while visiting, so they stay in source order inside
the try as module-scoped bindings and exported ones go through the existing
`export var` path into the export clause, matching esbuild.
7dcc2b9 to
612112d
Compare
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs. Because it changes bundler/transpiler output semantics for top-level class lowering, is stacked on #38284, and interacts with adjacent open PRs (#38287, #38279, #32655), a human look would still be worthwhile to confirm the sequencing and merge order.
What was reviewed:
- Traced
convert_class_stmt_to_varagainstlower_class's output: the SClass inloweredshares arena storage withdata(both plain andlower_standard_decorators_stmtpushoriginal_stmt), somem::take(&mut data.class)moves the right body. - Confirmed the module-scope guard (
current_scope().parent.is_none()) matchesselect_local_kind's condition and is mutually exclusive withwas_export_inside_namespace(namespace bodies are nested scopes), so the laterdata.class.class_name.expect(...)at line 1095 is not reached aftermem::take. - Checked the removed
SClassarm infinalizehas nothing left to handle: module-level classes now arrive asSLocal, and nested-block classes are never exported.
Extended reasoning...
Overview
The PR fixes top-level class declaration scoping when a module contains a top-level using/await using and is built for a target that lowers using (browser/node). The source change is ~35 lines: a new convert_class_stmt_to_var helper in src/js_parser/visit/visit_stmt.rs that rewrites the SClass returned by lower_class into var Foo = class Foo {} when at module scope in this mode, plus removal of the now-dead SClass arm in LowerUsingDeclarationsContext::finalize. Seven new tests cover bundled output, __esm-wrapped modules, await using, both decorator flavors, the bake dev format, and a Bun.Transpiler snapshot.
Security risks
None. This is an AST transformation in the parser's visit pass; no untrusted input handling, no I/O, no auth/crypto/permissions surface.
Level of scrutiny
Medium-high. The change is small and well-tested, matches the documented intent above will_wrap_module_in_try_catch_for_using in parse_entry.rs, and mirrors esbuild's output for the same input. But it changes emitted code for a real (if narrow) input class across every non-bun target, is stacked on an unlanded PR whose var-relocation is load-bearing for the __esm-wrapper case, and sits next to three other open PRs touching the same function. The sequencing constraint (UsingTopLevelClassDeclarationsInEsmWrapper fails if applied to main without #38284) is real and worth a human confirming the base has landed before this merges.
Other factors
- Test coverage is thorough: runtime assertions on
.name, static field values, prototype chains, and hoisted-function visibility, plus output-shape checks and a snapshot; the PR states all seven fail on the base without the src/ change. - The
.expect("infallible: class statements are always named")matches the two identical assertions already ins_class(anonymous classes are only expressions orexport default, neverSClass). - The comment-cop bot's four flags were addressed in 612112d (condensed to a one-line pointer to
select_local_kind); no other reviewer feedback is outstanding. - No prior review from me on this PR.
|
This bug came up again on main at f04caca. The repro reads an earlier // u.mjs
const log = [];
using r = { v: 1, [Symbol.dispose]() {} };
function base() { log.push("extends"); return Object; }
export class A extends base() {}
export class C { static x = r.v; }
console.log(log.join(), C.x);On main, #40833 (open) also changes the |
Stacked on #38284 (this PR's base is its branch); see the sequencing bullet under Fix. Only the last commit is this PR.
Problem
using/await using, built for any target that lowersusing(browser,node;--target=bunis unaffected), top-level class declarations end up in the wrong scope. Affectsbun build,bun build --no-bundle,Bun.Transpiler, the bake client bundle and the REPL. Reproduces on 1.4.0 and main; found by inspection, no user report for this exact shape.usingvalues it comes after are initialized:static v = foo.vthrowsTypeError: Cannot read properties of undefined (reading 'v'), and a field likestatic v = foo?.vis silentlyundefined.export class Sub extends Base {}with a non-exportedBasethrowsReferenceError: Base is not defined, because onlySubis hoisted.ReferenceError: Bar is not definedwhen called. Same for the export getters of--format=cjs/iifeoutput.SClassarm ofLowerUsingDeclarationsContext::finalize(src/js_parser/p.rs), added in hoisting of exports when there is top level using #14313 forexportandusingkeywords cannot be used in the same file #13734, movedexport classout of the try block because anexportis not allowed inside a block, and let the others fall into it unchanged. The comment abovewill_wrap_module_in_try_catch_for_usinginsrc/js_parser/parse/parse_entry.rsdescribes the class being turned into avar, but nothing implemented that.Fix
s_class(src/js_parser/visit/visit_stmt.rs): when the module is going to be wrapped forusingand the class is at module scope, the class statement returned bylower_classis replaced withvar Foo = class Foo {}, keeping itsexportflag (convert_class_stmt_to_var). It then takes the pathexport const/export letalready take in this mode:finalizemoves the binding into the export clause it emits after the try/catch, the export scan (which runs after visiting) picks it up from there, and when bundling js_parser: hoist the declarations of a lowered top-levelusingout of the ESM wrapper and the REPL IIFE #38284 relocates thevarlike every other one in the block.SClassarm infinalizeis removed. Module-level classes now arrive there as locals, and a class statement in a nested block is never exported, so the arm had nothing left to do. Theexportandusingkeywords cannot be used in the same file #13734 shape it was added for (export classdeclared before theusingthat instantiates it) is still covered by the existingedgecase/UsingExportClass, now through the export clause.varis initialized at the same point in evaluation order as the declaration was; it is module-scoped, so the hoisted function declarations and the importing modules can reach it; and later assignments to the binding (decorator lowering emitsFoo = __decorateElement(..., Foo)after the class) still reach the export through the clause. The class expression keeps its name, so.nameand references toFoofrom inside the class body behave as before. esbuild emits the same thing for this input (var Bar = class {...}andvar Baz = class {...}inside the try,export { Baz }after it).lower_classemits around a class (both decorator flavors) refer to the class by symbol, so they keep working against thevar. Also checked by hand: TS class/namespace merging,--minify,--format=cjs/iife, and the REPL.import()is evaluated inside an__esm(...)wrapper, and the linker only hoists top-level statements out of it. Thevars inside the try block are hoisted by js_parser: hoist the declarations of a lowered top-levelusingout of the ESM wrapper and the REPL IIFE #38284, so this PR is based on it and has to land after it. On main alone, this change would turn an exported class that does not touch theusingvalues in such a module from working (it was hoisted above the try) into aReferenceErrorat the export getter, the stateexport constis in today.edgecase/UsingTopLevelClassDeclarationsInEsmWrapperpins this down: it passes on this stack, fails on js_parser: hoist the declarations of a lowered top-levelusingout of the ESM wrapper and the REPL IIFE #38284's branch without this change (TypeError, class evaluated too early) and fails on main plus this change alone (var Exportedleft inside the wrapper), so CI enforces the order.test/bundler/bundler_edgecase.test.ts(edgecase/UsingTopLevelClassDeclarations,UsingTopLevelClassDeclarationsInEsmWrapper,AwaitUsingTopLevelClassDeclarations,UsingTopLevelClassStandardDecorators,UsingTopLevelClassExperimentalDecorators,UsingTopLevelClassBakeDev) andtest/bundler/transpiler/transpiler.test.js(using top level turns class declarations into vars, a snapshot of the non-bundledBun.Transpileroutput, which js_parser: hoist the declarations of a lowered top-levelusingout of the ESM wrapper and the REPL IIFE #38284 does not affect). All seven fail on the base branch without thesrc/change and pass with it. The bundler tests only assertFoo = classfor the rewritten classes, since thevaris relocated when bundling and the expression's name is not what they are about.bundler_edgecase(including js_parser: hoist the declarations of a lowered top-levelusingout of the ESM wrapper and the REPL IIFE #38284's tests andUsingExportClass),bundler_browser,bundler_minify,esbuild/{lower,ts,default},transpiler/{transpiler,decorators,es-decorators,es-decorators-esbuild,decorator-metadata},bundler_decorator_metadata,lower-using-bun-target,explicit-resource-management,bake/dev-and-prod(using runtime import).s_classlines for all bundled output (size motivation; its condition already includes theusingmode but it has nousingcoverage and has been open since June), so the two conflict textually; if it is picked up it can widen the condition here and add its name dropping insideconvert_class_stmt_to_var. js_parser: keep export default function/class when lowering top-level using #38287 handlesexport default function/classin this mode and removes thefinalizearm adjacent to the one removed here, so whichever of the two lands second needs a trivial rebase. js_parser: keep destructured exports when lowering top-level using #38279 (destructured exports) touches a different part of the same function.Background
using: for targets without nativeusing, the parser rewritesusing x = vintovar x = __using(stack, v)and wraps the statements that follow intry { ... } catch { ... } finally { __callDispose(stack, ...) }. At module level the whole module body ends up inside that try block.will_wrap_module_in_try_catch_for_usingis set before the visit pass so statements can be adjusted while they are visited;finalizebuilds the try/catch afterwards.let/constintovar(select_local_kind); a class declaration is block-scoped likelet, so it needs the same treatment.exportdeclarations are only valid directly at module level, so an exported binding inside the try block is exported through anexport { ... }clause placed after it. Bun's export list (named_exports) is computed from the visited statements, so the clause is what makes the export exist.__esmwrapper: when bundling, a module that must be evaluated lazily (for example because it isimport()ed) has its body wrapped in a closure, and the linker turns the module's top-level declarations into declarations outside the closure plus assignments inside it, so the export getters can reach them. Declarations nested in the try block are invisible to that pass unless the parser relocates them first, which is what js_parser: hoist the declarations of a lowered top-levelusingout of the ESM wrapper and the REPL IIFE #38284 adds.lower_classreturns the class statement plus any statements decorator lowering adds around it; the replacement swaps out the class statement within that list.Repro and output
cls.mjssection of the bundle before:After (on top of #38284, which is what turns the block's
vars into assignments plus separate top-level declarations; in an__esmwrapper the linker merges those into onevar __stack, foo, Bar, Baz;outside the wrapper):bun build --no-bundle/Bun.Transpileroutput (no relocation outside the bundler):