Skip to content

js_parser: run lowered decorator effects after a super() that is not a statement - #42663

Open
robobun wants to merge 4 commits into
mainfrom
robobun/1f87cd24/tail-host-no-super-statement
Open

robobun wants to merge 4 commits into
mainfrom
robobun/1f87cd24/tail-host-no-super-statement

Conversation

@robobun

@robobun robobun commented Sep 13, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • A derived class with standard decorators or an accessor throws on construction when its constructor calls super() anywhere but in a statement of its own, for example const made = super();. The error: ReferenceError: 'super()' must be called in derived constructor before accessing |this| or returning non-object.
  • The lowering puts what follows the last instance field in the constructor. insert_after_super (src/js_parser/lower/lower_decorators.rs:106) looks for a top-level super(); statement. With none, it puts the code at the top, before this exists.

Fix

  • insert_after_fields reports a derived constructor with no super(); statement. The lowering then adds one #private field after the last member, with the code in its initializer: #_ = (effects, undefined). A field runs where super() returns. Every other shape keeps its output.
  • new_private_name picks a name that no enclosing class declares, in the class body scope that visit_class leaves in P::visited_class_body. The minifier renames it with the others.
  • Verified: test/bundler/transpiler/es-decorators.test.ts (422 pass, the 4 new tests fail on main), and the other decorator suites.
  • Self-reviewed: 1 concern raised, 1 addressed (this PR is split from a larger storage change).

Background

  • Code that the lowering runs between two instance fields rides in the initializer of the next field. After the last field there is none.
  • The instance fields of a derived class are defined when super() returns. Before that, this is in its temporal dead zone.
  • A #private name is a symbol of the class body scope. The minifier names symbols per scope.
Notes
  • js_parser: lower standard decorators without moving class members #40833 lists this under "Not in this PR".
  • Unchanged: a class with no constructor, a base class, and a constructor with a super(); statement still get the code in the constructor, and no extra field.
  • Other shapes that throw on main: if (x) { super(); } else { super(); }, (() => super())(), void super();, and a constructor that returns an object and never calls super().
  • Repro: class B {} class A extends B { accessor p = 1; constructor() { const made = super(); } } new A().p. Main a22b2aa throws the ReferenceError above. This branch prints 1. The same for @dec x = 1, @dec #x = 1, @dec accessor #x = 1, @dec m() {} and @dec #m() {} as the only member.
  • Output for class C extends B { @dec #m() {} constructor() { if (x) { super(); } else { super(); } } }: the constructor is untouched and the class ends with #_ = (__privateAdd(this, _m), __runInitializers(_init, 5, this), undefined);.
  • Cost: one more private slot per instance, only in a class of this shape. A base constructor that returns the same object for two constructions makes the second one throw TypeError when the field is added again. A class with an accessor or a lowered #private member already behaves that way.
  • The new name is #_, or #_2, #_3 when the class or an enclosing class declares the shorter ones. The test covers bun run and bun build --minify.
  • RuntimeTranspilerCache version 32 to 33.
  • Suites run: es-decorators, es-decorators-esbuild, decorators, decorator-metadata, ts-use-define-for-class-fields, bundler_edgecase, transpiler-cache, regression/issue/{27526,27575}.
  • The new fixture section runs as .js, as .ts and through bun build. It wraps each construction in try/catch, so on main only the 4 new tests fail and the other 418 pass.
  • Not in this PR: with a super(); statement, an initializer that lands in the constructor still sees the constructor's new.target (js_parser: substitute undefined for new.target in class field initializers and static blocks #42653 covers that) and its parameters. That part is on the branch robobun/1f87cd24/native-accessor-storage, which makes accessor storage a native #private field. This PR is the part of that branch that is needed whichever way the storage goes.

[human-review] gate passed · iteration 0 · 5 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/pr_gate.xml" test/bundler/transpiler/es-decorators.test.ts
bun test v1.4.3 (b99371011)

test/bundler/transpiler/es-decorators.test.ts:
(pass) ES Decorators > class decorators > basic class decorator [533.20ms]
(pass) ES Decorators > class decorators > class decorator receives correct context [503.08ms]
(pass) ES Decorators > class decorators > class decorator can replace class [395.50ms]
(pass) ES Decorators > class decorators > multiple class decorators apply in reverse order [515.04ms]
(pass) ES Decorators > method decorators > instance method decorator [342.61ms]
(pass) ES Decorators > method decorators > static method decorator [358.73ms]
(pass) ES Decorators > method decorators > method decorator context has correct access [377.07ms]
(pass) ES Decorators > getter decorators > getter decorator [386.48ms]
(pass) ES Decorators > setter decorators > setter decorator [384.96ms]
(pass) ES Decorators > field decorators > field decorator receives undefined value [321.08ms]
(pass) ES Decorators > field decorators > multiple field decorators [456.91ms]
(
... (truncated)

release without fix: all passed
bun test v1.4.3-canary.1 (aaa3425ea)

test/bundler/transpiler/es-decorators.test.ts:
(pass) ES Decorators > class decorators > basic class decorator [6.76ms]
(pass) ES Decorators > class decorators > class decorator receives correct context [7.07ms]
(pass) ES Decorators > class decorators > class decorator can replace class [5.41ms]
(pass) ES Decorators > class decorators > multiple class decorators apply in reverse order [5.20ms]
(pass) ES Decorators > method decorators > instance method decorator [6.26ms]
(pass) ES Decorators > method decorators > static method decorator [7.22ms]
(pass) ES Decorators > method decorators > method decorator context has correct access [5.24ms]
(pass) ES Decorators > getter decorators > getter decorator [5.26ms]
(pass) ES Decorators > setter decorators > setter decorator [5.38ms]
(pass) ES Decorators > field decorators > field decorator receives undefined value [4.85ms]
(pass) ES Decorators > field decorators > multiple field decorators [4.84ms]
(pass) ES Decorators > field decorators > static field decorator [5.68ms]
(pass) ES Decorators > non-ASCII string-literal keys > Bun.Transpiler output preserves the key [0.65ms]
(pass) ES Deco
... (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/pr_gate.xml" test/bundler/transpiler/es-decorators.test.ts
bun test v1.4.3 (b99371011)

test/bundler/transpiler/es-decorators.test.ts:
(pass) ES Decorators > class decorators > basic class decorator [455.78ms]
(pass) ES Decorators > class decorators > class decorator receives correct context [302.95ms]
(pass) ES Decorators > class decorators > class decorator can replace class [320.97ms]
(pass) ES Decorators > class decorators > multiple class decorators apply in reverse order [377.21ms]
(pass) ES Decorators > method decorators > instance method decorator [369.88ms]
(pass) ES Decorators > method decorators > static method decorator [306.66ms]
(pass) ES Decorators > method decorators > method decorator context has correct access [330.83ms]
(pass) ES Decorators > getter decorators > getter decorator [444.48ms]
(pass) ES Decorators > setter decorators > setter decorator [398.96ms]
(pass) ES Decorators > field decorators > field decorator receives undefined value [468.76ms]
(pass) ES Decorators > field decorators > multiple field decorators [450.18ms]
(
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 869ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/5] gen generated_host_exports.rs
generated_host_exports.rs: 122 exports (host=5, lazy=10, generic=107, rust=0); 243 extern-C blocks audited
[1/5] cargo bun_runtime → libbun_runtime.a
�[1m�[92m   Compiling�[0m bun_js_parser v0.0.0 (/workspace/bun/src/js_parser)
�[1m�[92m   Compiling�[0m bun_resolver v0.0.0 (/workspace/bun/src/resolver)
�[1m�[92m   Compiling�[0m bun_ini v0.0.0 (/workspace/bun/src/ini)
�[1m�[92m   Compiling�[0m bun_bundler v0.0.0 (/workspace/bun/src/bundler)
�[1m�[92m   Compiling�[0m bun_router v0.0.0 (/workspace/bun/src/router)
�[1m�[92m   Compiling�[0m bun_standalone_graph v0.0.0 (/workspace/bun/src/standalone_graph)
�[1m�[92m   Compiling�[0m bun_transpiler v0.0.0 (/workspace/bun/src/transpiler)
�[1m�[92m   Compiling�[0m bun_bunfig v0.0.0 (/workspace/bun/src/bunfig)
�[1m�[92m   Compiling�[0m bun_install v0.0.0 (/workspace/bun/src/install)
�[1m�[92m   Compiling�[0m bun_jsc v0.0.0 (/workspace/bun/src/jsc)
�[1m�[92m   Compiling�[0m bun_js_parser_jsc v0.0.0 (/workspace/bun/src/js_parser_js
... (truncated)
diff hotspot
src/js_parser/lower/lower_decorators.rs       | 85 +++++++++++++++++++++++----
 src/js_parser/p.rs                            |  3 +
 src/js_parser/visit/mod.rs                    |  2 +
 src/jsc/RuntimeTranspilerCache.rs             |  3 +-
 test/bundler/transpiler/es-decorators.test.ts | 83 ++++++++++++++++++++++++++
 5 files changed, 163 insertions(+), 13 deletions(-)

gate history · 2 passed · 0 rejected · iteration 0

evidence per changed file
file                                           reads  edits  tests
src/js_parser/lower/lower_decorators.rs           18     19     14
src/js_parser/p.rs                                 6      4     14
src/js_parser/visit/mod.rs                         3      1     14
src/jsc/RuntimeTranspilerCache.rs                  1      1     14
test/bundler/transpiler/es-decorators.test.ts      4     13     14

…t a statement

The standard decorator lowering puts what follows the last instance
field (accessor storage, extra initializers, brands) in the constructor,
after a top-level `super();` statement. A derived constructor with no
such statement (`const made = super();`, `if (x) super(); else super();`,
an arrow that calls it, or a constructor that returns an object) got the
code at the top, before `this` exists, and threw a ReferenceError on
every construction.

For that shape the lowering now adds one `#private` field after the last
member and puts the code in its initializer. A field runs where
`super()` returns. The name is one that no enclosing class declares, and
it is registered in the class body scope so the minifier renames it.
@robobun

robobun commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author

Status

Reproduced on main a22b2aa with a debug build:

const dec = (v, ctx) => {};
class B {}
class A extends B { accessor p = 1; constructor() { const made = super(); } }
class C extends B { @dec m() {} constructor(x) { if (x) { super(); } else { super(); } } }
new A().p; new C(1);

Both constructions throw ReferenceError: 'super()' must be called in derived constructor before accessing |this| or returning non-object. With this branch new A().p is 1 and new C(1) returns.

Fix: #42663

@coderabbitai

coderabbitai Bot commented Sep 14, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview 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: Essentials

Run ID: 8e54280a-bb9b-4ac1-a269-57b71dc068a2

📥 Commits

Reviewing files that changed from the base of the PR and between 74b3c1a and b90a7e0.

📒 Files selected for processing (1)
  • src/js_parser/lower/lower_decorators.rs

Included review availability: Your plan provides up to 10 included reviews per hour; 7 remain after this review.


Walkthrough

Changes

Decorator lowering now distinguishes base and derived constructors. Derived constructors without a usable top-level super() use a generated collision-free private field for post-field effects. Parser scope tracking, cache versioning, and end-to-end tests were updated.

Decorator lowering

Layer / File(s) Summary
Class-body scope tracking
src/js_parser/p.rs, src/js_parser/visit/mod.rs
The parser records the most recently visited class-body scope and initializes the new field to None.
Constructor effect lowering
src/js_parser/lower/lower_decorators.rs, src/jsc/RuntimeTranspilerCache.rs
Lowering inserts effects in base constructors or after top-level super() calls. If insertion fails, it creates a collision-free private field. The cache format version changes from 32 to 33.
Decorator lowering validation
test/bundler/transpiler/es-decorators.test.ts
Tests cover private-name collisions, initializer placement, super call forms, constructors without super(), bundling, and minification.

Suggested reviewers: jarred-sumner

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to b90a7

The changed lowering covers the reported derived-constructor cases, with cache invalidation and relevant JavaScript, TypeScript, bundling, and minification coverage.

🚥 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 summarizes the main change: decorator effects now run after a non-statement super() in derived constructors.
Description check ✅ Passed The description explains the problem, fix, scope, verification steps, test results, and known limitations. It does not use the exact template headings, but it provides the required information and is …

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. Because it changes transpiler output and adds per-P state that must stay in lockstep with visit_class, a human look from someone familiar with the lowering pipeline would still be worthwhile.

What was reviewed:

  • Traced all three lower_standard_decorators_impl entry points (SClass, SExportDefault→SClass, EClass expr) — each is preceded by visit_class, which unconditionally sets visited_class_body after visiting nested properties, so the .take().expect(...) invariant holds and nested classes cannot leave a stale scope for an outer class.
  • Checked insert_after_fields: base-class and derived-with-super();-statement paths keep their previous output; only the derived-no-statement case falls through to the new #private field.
  • new_private_name walks scope.parent and checks both members and generated at each ClassBody, and records the new symbol in class_body.generated for the minifier; the nested-class test exercises both collision sources. Cache version is bumped.
Extended reasoning...

Overview

This PR fixes standard-decorator lowering for derived classes whose constructor calls super() somewhere other than a top-level statement. insert_after_super is renamed insert_after_fields, now takes is_derived, and returns false when a derived constructor has no super(); statement; the caller then appends a synthetic #private field whose initializer carries the post-last-field effects, so they run when super() returns wherever it is written. A new P::new_private_name picks a #_/#_2/… name unused by any enclosing class-body scope and records it in generated. visit_class stashes the class-body scope into a new P::visited_class_body just before popping it, and lower_standard_decorators_impl .take()s it. RuntimeTranspilerCache::EXPECTED_VERSION is bumped 32→33. Tests add a nested-class name-collision case (run and --minify bundled) and an extraSections matrix covering accessor/#field/#accessor/field/method/#method with super() written as const made = super(), in both branches of an if, inside an arrow, void super(), never called, and next to an existing #_.

Security risks

None identified. This is transpiler output shaping for a language feature; there is no untrusted input parsing beyond what the JS parser already handles, no I/O, no auth/crypto, and no network. The change cannot be reached without the user opting into standard-decorator lowering on a derived class of this specific shape.

Level of scrutiny

Moderate-to-high. Transpiler output changes affect every user who compiles matching source, and a wrong emit here would surface as a runtime ReferenceError or a #private name collision. The new P::visited_class_body field is per-operation state on a reusable object, so I traced every caller: visit_class sets it unconditionally at line 1460 (after visiting all properties, so nested-class recursion cannot leave a stale inner scope for the outer class), and all three lower_standard_decorators_* entry points are immediately preceded by a visit_class call with no intervening class visits. visit_class has no early return between function entry and the assignment. Stale values from classes that do not lower decorators are harmless because the next visit_class overwrites before any .take().

Other factors

Test coverage is thorough: the fixture matrix covers six member kinds crossed with six super() placements, plus the negative case (constructor returns an object without calling super()), and the standalone test exercises name collision against both an enclosing class's members and the minifier path. The cache version bump satisfies the REVIEW.md rule for serialized-output changes. CODEOWNERS does not cover any changed path. No issues were found by the bug-hunting pass (dry_streak). I am deferring rather than approving because this is a non-trivial control-flow change in the lowering pipeline with new mutable state on P; a maintainer familiar with the parser should confirm the visited_class_body handoff and the scope-walk in new_private_name match their model of scope lifetimes.

Comment thread src/js_parser/lower/lower_decorators.rs Outdated
Comment thread src/js_parser/lower/lower_decorators.rs Outdated
Comment thread src/js_parser/lower/lower_decorators.rs Outdated
Comment thread src/js_parser/p.rs Outdated
Comment thread src/js_parser/lower/lower_decorators.rs Outdated
@robobun

robobun commented Sep 14, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 5:57 PM PT - Sep 13th, 2026

✅ @robobun, your commit b90a7e0fc90748ae9d4ebcd37327d8b38ecb49c1 passed in Build #115323! 🎉


🧪   To try this PR locally:

bunx bun-pr 42663

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

bun-42663 --bun

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

Code review found no issues

No high-confidence issues detected in this change.

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.

2 participants