Skip to content

js_parser: keep the inferred name of lowered anonymous decorated class expressions - #38757

Open
robobun wants to merge 9 commits into
farm/80aecbe5/inspect-own-namefrom
farm/80aecbe5/decorator-inferred-class-name
Open

js_parser: keep the inferred name of lowered anonymous decorated class expressions#38757
robobun wants to merge 9 commits into
farm/80aecbe5/inspect-own-namefrom
farm/80aecbe5/decorator-inferred-class-name

Conversation

@robobun

@robobun robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #38922 (this PR's base branch): the formatter change there is what keeps console.log of these classes printing [class Bar] once the binding is gone (see Fix). Merge #38922 first; GitHub retargets this one to main.

Problem

  • With standard (TC39) decorators, an anonymous class expression whose name comes from its position gets the wrong .name once the module is bundled:
    function dec() {}
    function f() {
      const Bar = class { @dec m() {} };
      console.log(Bar.name);
    }
    f();
    bun repro.js prints Bar; bun build repro.js prints Bar2 (the output contains _class = class Bar2 {), and --minify prints a one-letter name. The same happens at the top level of a CommonJS-wrapped module, and for accessor members, which use the same lowering. It does not happen with a class decorator only because __decorateElement(_init, 0, "Bar", ...) re-applies the name at runtime.
  • Cause: lower_impl in src/js_parser/lower/lower_decorators.rs rewrites the expression to _class = class {}, which would infer the name "_class", and compensated by creating a symbol named after the context (Bar) and making it the class's own binding name. That symbol lands in the scope that also declares the user's const Bar, and the bundler's renamer (assign_names_in_scope in src/js_printer/renamer.rs) renames the second Bar it meets in a scope; declaring it in the class's own scope would not help, since the renamer also renames bindings that shadow an enclosing name (bun build turns an explicit const Bar = class Bar {} into class Bar2 today).
  • The same mechanism has two more visible effects in unbundled code:
    • names that cannot be a binding fall back to "_class": { "foo-bar": class { @dec m() {} } }, { default: ... }, { "": ... }, export default (class { @dec m() {} }) (node: "foo-bar", "default", "", "default"), and a class with no naming context at all is named "_class" instead of "";
    • the synthesized binding shadows the outer variable inside the class body: in let Bar = class { m() { return Bar } ... }; Bar = 1, m() returns the class instead of 1.

Fix

  • The lowering no longer gives the class a binding. Instead the class body starts with static { __name(this, "Bar") } ("Bar" being the context name, or "" when there is none); the class decorator path still passes the same name to __decorateElement as before.
  • Correct because the name is now a string literal rather than an identifier, so neither the bundler's renamer nor the minifier can alter it, any string works, and the class body keeps resolving Bar to the outer variable exactly as the source did. A static block runs during class definition, before the static field initializers the lowering leaves in the body (static x = this.name) and before the generated __privateAdd blocks, so everything that could observe the name still sees it; putting the __name call after the class expression would not. This is the shape esbuild emits for the same situation under --keep-names.
  • No block is emitted when the class declares a static method, getter, setter or undecorated accessor keyed name (defines_static_name_method, checked on the source properties before decorated computed keys are replaced by temporaries, so @dec static ["name"]() {} counts, and looking through the wrapper same-file enum inlining leaves on a key, so static [Key.Name]() {} with Name = "name" counts too): those are installed from the class body before static blocks run, so the block would overwrite them, which a class binding never did. Members the lowering installs from the suffix (a static name field, decorated or not, and a decorated accessor) do not suppress the block: they replace the name after it, and static initializers before them still have to read the inferred name. Known limitation: a computed key that only evaluates to "name" at runtime (static [k]() {}) is not detected and the block overwrites that member, as esbuild's --keep-names also does; suppressing the block for every computed static key would instead drop the name for classes with static [Symbol.iterator]() and the like.
  • Display: console.log, Bun.inspect and bun:test name a class from its executable, which for the lowered class is the _class temporary, so with the binding gone they printed [class _class] / _class {} where 1.4.0 printed [class Bar] / Bar {} (.name itself was right). inspect: display a class or function name set with Object.defineProperty #38922, the base of this PR, makes them use a name set with Object.defineProperty, which is also what tsc-compiled decorated classes need today; the new console.log and Bun.inspect show the inferred name test pins [class Item] Item { ... }, [class Child extends Item] and, for a class with no naming context, [class (anonymous)] for this PR's output.
  • __name is an existing runtime.js helper (__decorateElement already calls it for class decorators), so bundled and unbundled output both have it; can_be_class_binding_name (the identifier filter for the old binding) is removed with the binding.
  • Verification:
    • test/bundler/bundler_edgecase.test.ts: DecoratedAnonymousClassExprKeepsInferredName (function scope: const, accessor-only class, assignment, non-identifier key), the same input with minifyIdentifiers/minifySyntax/minifyWhitespace, and a CommonJS-wrapped module. On the unfixed build they print ["Bar2","Baz2","Qux2","_class4"], ["e","x","q","y"] and Bar2.
    • test/bundler/transpiler/es-decorators.test.ts, new inferred names of lowered anonymous class expressions block: non-identifier names, every naming context (const, assignment, destructuring defaults, object key, static and instance class field, class decorator), no context gives "", the class body still sees the outer binding after reassignment, and the name is visible to inline static fields, a static private field initializer and a static block, with and without a class decorator. The export default (class { @dec foo() {} }) test now also checks .name === "default". A further test covers classes declaring their own static name getter, setter, method, decorated method, decorated computed ["name"] method, a method keyed by an inlined TypeScript enum member (.ts file), accessor, and a field or decorated accessor preceded by an initializer that reads this.name. Expectations were cross-checked against node on the same code with the decorators removed. 4 of these fail on the unfixed build; the naming-context, static-initializer and static-name tests are regression guards for dropping the binding.
    • Existing suites pass: es-decorators-esbuild.test.ts (esbuild's 147 conformance tests), es-decorators.test.ts, decorators.test.ts, decorator-metadata.test.ts, bundler_decorator_metadata.test.ts, ts-use-define-for-class-fields.test.ts, regression/issue/27575.test.ts, transpiler.test.js, esbuild/ts.test.ts, esbuild/default.test.ts, bundler_edgecase.test.ts, bundler_minify.test.ts, bundler_naming.test.ts; cargo clippy -p bun_js_parser is clean.

Background

  • Name inference: an anonymous class or function expression takes its .name from where it appears (const Bar = class {}, { Bar: class {} }, Bar = class {}, a default value in a destructuring pattern, export default). It only applies when the expression is directly in that position; once the lowering wraps it in _class = class {}, ... the inferred name becomes _class. The parser records the position's name in decorator_class_name and passes it to the lowering as name_from_context.
  • Standard decorator lowering prints the class mostly as written, assigned to a hoisted _class temporary, followed by a comma list of __decorateElement(...) calls. Decorated and relocated static elements run in that suffix, but undecorated static fields stay in the class body and are evaluated while the class is being defined.
  • Class static blocks (static { ... }) run in source order with the static field initializers during class definition, with this bound to the class, so a block placed first runs before any other static initializer.
  • The bundler renames symbols with NumberRenamer: it walks each scope, keeps the first symbol with a given name, and renames later ones and ones that shadow a name from an enclosing scope by appending a number. It never sees string literals. __name(target, name) in src/runtime.js defines the name property on a function, the same helper the class decorator path uses.
  • Related open work on this file: js_parser: give standard decorator lowering temporaries unique names #38734 (unique temporary names; deliberately leaves this symbol alone), Let a decorated class body observe the class its decorators return #38731 (a class statement's body observing the decorated class, which injects a static block of its own for statements only; this change is expression-only, so the two do not interact).
  • js_parser: name an anonymous decorated export default class "default" #38758, opened a minute after this PR, removes the same binding but emits __name(_class, ...) after the class expression, and additionally fixes the export default class { @dec m() {} } statement form. Built at 50085c0, const Bar = class { static x = this.name; @dec m() {} } gives Bar.x === "_class" there ("Bar" on 1.4.0, node and this PR), because the class body has already run by then; that is what the static-block placement here is for. The statement-form fix is independent of this PR and can land on top of it.
Emitted code for the repro, before and after

Before (bun build):

function f() {
  var _class, _init, _dec, _dec;
  const Bar = (_dec = [dec], _init = __decoratorStart(undefined), _class = class Bar2 {
    constructor() { __runInitializers(_init, 5, this); }
    m() {}
  }, __decorateElement(_init, 1, "m", _dec, _class), __decoratorMetadata(_init, _class), _class);
  console.log(Bar.name);
}

After:

function f() {
  var _class, _init, _dec, _dec;
  const Bar = (_dec = [dec], _init = __decoratorStart(undefined), _class = class {
    static {
      __name(this, "Bar");
    }
    constructor() { __runInitializers(_init, 5, this); }
    m() {}
  }, __decorateElement(_init, 1, "m", _dec, _class), __decoratorMetadata(_init, _class), _class);
  console.log(Bar.name);
}

no test proof · iteration 4 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/bundler/bundler_edgecase.test.ts

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 10:05 AM PT - Aug 15th, 2026

@robobun, your commit 676ad01 is building: #98188

@coderabbitai

coderabbitai Bot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 2 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: cdc9fec0-189e-4e0d-88f9-b11d5bfb2e1f

📥 Commits

Reviewing files that changed from the base of the PR and between c418051 and 4997c18.

📒 Files selected for processing (3)
  • src/js_parser/lower/lower_decorators.rs
  • test/bundler/bundler_edgecase.test.ts
  • test/bundler/transpiler/es-decorators.test.ts

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

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Reproduced on bun 1.4.0 and a debug build of main: bun build of an anonymous class expression with a member decorator (or accessor) assigned inside a function prints .name === "Bar2" (_class = class Bar2 { in the output), a one-letter name with --minify, and Bar2 for a CommonJS-wrapped module at top level. Unbundled, the same lowering gives "_class" for non-identifier contexts ({ "foo-bar": class { @dec m() {} } }, export default (class { ... })) and makes the class body shadow the outer variable.

Fix in this PR: the lowering no longer names the class binding after the context; it opens the class body with static { __name(this, "Bar") } instead, skipped for classes that install their own static name method or accessor from the body. Tests: test/bundler/bundler_edgecase.test.ts (DecoratedAnonymousClassExpr*) and the inferred names of lowered anonymous class expressions block in test/bundler/transpiler/es-decorators.test.ts.

Stack: this PR is based on #38922 (the formatter has to display a redefined name, otherwise these classes would print as [class _class] once the binding is gone); #38758 (the export default class {} statement form) is based on this one. Merge order: #38922, #38757, #38758.

CI: the parser change alone was green on main (build 97393) and on the stack (97570); the current head 676ad01 (build 98188) and the current base 5727b0f (#38922, build 98123) each have only retried flakes, plus, on 98188, the Windows test/bake/deinitialization.test.ts segfault at process exit that also hit #38922 earlier and is unrelated to either diff (reported to triage separately).

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. js_parser: name an anonymous decorated export default class "default" #38758 - Same fix for the same bug: both remove can_be_class_binding_name and the synthesized class binding in lower_impl, then restore the inferred .name with a __name(target, "…") string-literal call so it survives renaming/minification (js_parser: name an anonymous decorated export default class "default" #38758 emits it after class creation instead of in a leading static block, and also covers the export default class statement form).

🤖 Generated with Claude Code

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/lower/lower_decorators.rs Outdated
@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

On the overlap with #38758: both remove the synthesized binding and set the name with __name, but the placement differs. #38758 emits __name(_class, ...) after the class expression, which runs after the static initializers the lowering leaves in the body, so (built at 50085c0) const Bar = class { static x = this.name; @dec m() {} } gives Bar.x === "_class"; bun 1.4.0, node and this PR give "Bar". This PR opens the body with static { __name(this, "Bar") } instead, which runs first, and as of 337ccf8 skips classes that define their own static name (the one case the block would otherwise get wrong). The part of #38758 that is not covered here, export default class { @dec m() {} } declarations being named mod_default, is a separate change in the statement path and can land on top of this one; I have left a note over there.

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

Additional findings (outside current diff — PR may have been updated during review):

  • 🔴 src/js_parser/lower/lower_decorators.rs:2423-2435defines_static_name scans new_properties after Phase 3 has already replaced every decorated computed key with an EIdentifier temp, so @dec static ["name"]() {} no longer matches the EString("name") check — the __name block is injected, runs after the method is installed, and clobbers it back to a data property (the decorator then receives the string "Foo" instead of the function). Pre-PR handled this correctly via the binding name. Compute the has-static-name flag from the original class.properties before Phase 3 rewrites the keys (the dynamic static [k]() case stays inherently undetectable, matching esbuild's --keep-names limitation).

    Extended reasoning...

    What the bug is

    The defines_static_name guard added in commit 337ccf8 only matches a static property whose key is ExprData::EString(s) with s == "name" (src/js_parser/lower/lower_decorators.rs:128-137). It is called at line 2423 against new_properties, which is built in Phase 4 from class.properties — but Phase 3 (lines 1259-1281) has already iterated class.properties.slice_mut() and, for every property with IsComputed set and at least one decorator, replaced prop.key in place with p.use_ref(key_ref, ...), an EIdentifier pointing at a _computedKey temporary. So a decorated static method written as @dec static ["name"]() {} reaches defines_static_name with key.data == EIdentifier(_computedKey), the guard returns false, and the static { __name(this, "Foo") } block is injected.

    Step-by-step proof

    Given:

    function dec(fn, ctx) { console.log(typeof fn); return fn; }
    const Foo = class { @dec static ["name"]() { return 1; } };
    console.log(typeof Foo.name);
    1. Phase 3 (line 1259): the property has IsComputed and ts_decorators.len_u32() > 0, so prop.key is rewritten from EString("name") to EIdentifier(_computedKey) (line 1280), and _computedKey = "name" is emitted as a pre-eval statement.
    2. Phase 4: the decorated public static method is copied into new_properties via prop_copy(prop), which shallow-copies key: p.key — now the EIdentifier.
    3. Line 2423: defines_static_name(&new_properties) sees only an EIdentifier key, returns false, and static { __name(this, "Foo") } is inserted at index 0.
    4. Emitted body (in order): static { __name(this, "Foo") }, then static [_computedKey]() { return 1; }.
    5. At runtime, per ClassDefinitionEvaluation: SetFunctionName(F, "_class") runs at step 12; static methods/accessors are installed on the constructor at step 25 (so F.name becomes the user's method); then static blocks execute at step 31 — the injected __name(this, "Foo") runs Object.defineProperty(F, "name", {value: "Foo", configurable: true}), reconfiguring the accessor/method back to a plain data property. The user's method is discarded.
    6. In the suffix, __decorateElement(_init, 9, _computedKey, [dec], _class) reads Object.getOwnPropertyDescriptor(_class, "name"), which is now {value: "Foo", …}, and passes "Foo" (a string) to the decorator instead of the original method.

    Native and pre-PR output: function / function. Post-PR output: string / string.

    Why this is a regression

    Before this PR, the removed block at lines 1145-1152 gave the class a syntactic binding name: _class = class Foo { static [_computedKey]() {} }. In that shape SetFunctionName(F, "Foo") runs at step 12, before step 25 installs the static method — so the method's DefinePropertyOrThrow runs last and survives. There is no injected static block to re-clobber it. So the string-literal computed form worked pre-PR and is broken post-PR.

    Why the existing guard doesn't catch it

    defines_static_name matches only EString. There is no parser/visitor pass that clears IsComputed from constant string-literal computed keys in class members, so ["name"] keeps its IsComputed flag, Phase 3 hoists it, and by the time the guard runs the EString is gone. The undecorated computed form (static ["name"]() {} without @dec) is unaffected — Phase 3 only hoists decorated computed keys, so its EString reaches new_properties intact and the guard fires. The PR's own DecoratedMethod test (@dec static name() {}, non-computed) also passes for the same reason.

    Impact

    Any anonymous decorated class expression with @dec static ["name"]() {} (or get/set variants) has that member silently replaced with a data property, and the decorator is handed the string class name instead of the function. This is not just a naming glitch — it destroys user code and hands the decorator garbage. The dynamic form const k = "name"; @dec static [k]() {} has the same failure and is fundamentally undetectable at compile time; that is the known trade-off of the __name-block approach vs. the removed binding-name approach and matches esbuild's --keep-names limitation. But the string-literal form is statically knowable and pre-PR handled it correctly.

    How to fix

    Compute the has-static-name flag from the original class.properties before Phase 3 mutates the keys (e.g. right after expr_class_is_anonymous is set), still restricting to static elements with an EString("name") key. Then use that captured flag at line 2423 instead of scanning new_properties. Alternatively, skip the Phase-3 key hoist when the computed key is already a side-effect-free constant literal — that also lets the existing EString match fire and avoids emitting a useless _computedKey = "name" temp — but the first option is the smaller change.

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

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

On the computed-key finding from the review (@dec static ["name"]() {}): confirmed on 5b17684 (typeof Foo.name came out "string"), fixed in 4309cd8 by deciding the guard from the source properties before Phase 2 swaps decorated computed keys for _computedKey temporaries. The static-name test now includes that case along with the static seenBefore = this.name; static name = ... field case from the same review; both match node and bun 1.4.0.

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

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

#38758 had converged on the same expression-path change as this PR (static block plus the source-property guard), so it has been reduced to the statement form that this PR leaves alone (export default class { @dec m() {} } and @dec export default class {} still report mod_default here) and is now stacked on this branch. It will be rebased onto main once this lands; no changes to this PR are needed.

Comment thread src/js_parser/lower/lower_decorators.rs Outdated
@robobun
robobun force-pushed the farm/80aecbe5/decorator-inferred-class-name branch from 820428e to 4997c18 Compare August 15, 2026 05:13

@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 re-reviewed the current revision and found no new issues — the earlier findings (static name getter/method clobbering, plain static name fields over-matching the guard, decorated static accessor name suppressing the block) are all addressed, and the remaining dynamic-computed-key edge is documented as a known limitation matching esbuild. Given the ClassDefinitionEvaluation-ordering subtlety this went through and the coordination with #38758's stacked statement-form fix, a maintainer sign-off on the final shape would still be worthwhile.

What was reviewed:

  • defines_static_name_method guard on source properties: static method/getter/setter, undecorated accessor, decorated ["name"] — each maps to whether the emitted body keeps a name element; matches the tests.
  • restore_inferred_name computed pre-Phase-2 so decorated computed keys still have their literal key; block inserted at index 0 via existing make_static_block/call_rt.
  • can_be_class_binding_name and its imports removed with no remaining callers; is_eval_or_arguments still used elsewhere in parser.
Extended reasoning...

Overview

The PR changes src/js_parser/lower/lower_decorators.rs (~40 lines net) to fix the inferred .name of anonymous decorated class expressions after standard-decorator lowering. Instead of synthesizing a class binding named after the context (which the bundler's renamer then mangles to Bar2, and which cannot hold non-identifier names, and which shadows the outer variable inside the class body), it now injects static { __name(this, "<name>") } at the top of the class body. A new defines_static_name_method guard skips the block when the source class already installs a body-resident static name method/getter/setter/undecorated-accessor. Ten new tests span bundler_edgecase.test.ts (bundled + minified + CJS-wrapped) and es-decorators.test.ts (naming contexts, no-context, outer-binding visibility, static-initializer ordering, static name matrix, decorated static accessor).

Security risks

None. This is transpiler output-shape only; no untrusted input parsing, no I/O, no allocation of user-controlled sizes.

Level of scrutiny

Medium-high. It changes emitted code for every anonymous decorated class expression, and correctness depends on the exact two-pass evaluation order in ClassDefinitionEvaluation (methods installed before static blocks/fields run in source order). Getting the guard right took three rounds of iteration on this PR — each round found a real observable regression in an edge of the static-name matrix. The final guard now correctly distinguishes body-resident members (methods, undecorated accessors) from suffix-installed ones (fields, decorated accessors), and reads source properties before Phase 2's computed-key hoisting. The test coverage is thorough and cross-checked against Node.

Other factors

  • All prior inline findings from this reviewer are resolved; the last one (dynamic [k] keys evaluating to "name") is a compile-time-undecidable case explicitly accepted as a known limitation in the PR description, matching esbuild's --keep-names.
  • CI is green on all decorator/bundler suites; the sole red test (test-http-chunk-problem.js) fails identically on main.
  • #38758 has been rescoped to the export default class { … } statement form and is stacked on this branch; a maintainer should confirm that landing order.
  • Not approving because parser lowering changes with this much semantic subtlety, plus a related stacked PR to coordinate, warrant a human look even though the automated review is now clean.

Comment thread src/jsc/bindings/bindings.cpp Outdated
Comment thread src/jsc/bindings/bindings.cpp Outdated
@robobun
robobun force-pushed the farm/80aecbe5/decorator-inferred-class-name branch from e32d6e9 to 68a8a87 Compare August 15, 2026 07:09
@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Review on #38787 (stacked on this branch) turned up a small gap in this PR's defines_static_name_method (lower_decorators.rs): it matches key.data against EString directly, so a computed key that enum inlining has rewritten to EInlinedEnum is not seen. With enum K { N = "name" } in the same file, const Foo = class { static [K.N]() {} @dec m() {} } is not detected as defining a static name, the static { __name(this, "Foo") } block is still inserted, and it overwrites the user's method (typeof Foo.name becomes "string"); the literal static ["name"]() {} spelling is handled. Matching on key.unwrap_inlined().data instead should cover it. Very narrow, noting it here since the code lives in this PR rather than in #38787.

…ns with __name

Standard-decorator lowering rewrites `const Bar = class { @dec m() {} }`
into `_class = class ... {}`, which no longer infers the name "Bar". The
lowering restored it by giving the class a binding named after the
context. That binding collides with the `Bar` declared in the same scope,
so the bundler renamed it (`class Bar2`, or a minified name), it could
only carry identifier names (object keys such as "foo-bar" or "default"
fell back to "_class"), and it shadowed the outer `Bar` inside the body.

Leave the class anonymous and open its body with
`static { __name(this, "Bar") }` instead, so the name is a string literal
the renamer never touches and is in place before any static initializer
left in the body runs. Classes with no naming context get "" like an
undecorated anonymous class.
Static methods and accessors are installed on the constructor before any
static block runs, so the injected __name block would overwrite a
`static get name()` the class declares itself. Skip the block when the
emitted body has a static member keyed `name`, the same rule esbuild
applies for --keep-names.
Only static methods and accessors keyed `name` need to suppress the
__name block: they are installed before static blocks run. A `static
name` field runs after the block and replaces the name itself, and
skipping the block for it left earlier static initializers reading
"_class". The check now also runs before decorated computed keys are
replaced with temporaries, so `@dec static ["name"]() {}` is seen.
A decorated `static accessor name` is installed from the suffix, so the
body has nothing the __name block could overwrite; skipping the block
left earlier static initializers reading "_class".
@robobun
robobun force-pushed the farm/80aecbe5/decorator-inferred-class-name branch from 68a8a87 to 1ed1f67 Compare August 15, 2026 13:20
Comment thread src/js_parser/lower/lower_decorators.rs Outdated
A computed key that same-file enum inlining turned into
EInlinedEnum(EString("name")) is a static `name` member too.

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

The EInlinedEnum case from the last review is now addressed (commit 676ad01: key.unwrap_inlined().data plus a dedicated TS-enum test), and this pass found no further issues. Given the number of spec-interaction edge cases this went through (static name method/field/accessor ordering, decorated vs undecorated accessors, computed keys, enum inlining) and the accepted limitation on runtime-computed name keys, a maintainer look at the final shape would still be worthwhile before merging.

What was reviewed:

  • defines_static_name_method: IsMethod / undecorated AutoAccessor gating, unwrap_inlined() on the key, run against source properties before Phase 2 rewrites decorated computed keys.
  • Static block placement at index 0 so it precedes body-resident static field initializers and __privateAdd blocks; suffix-installed members (decorated accessors, class decorator) still overwrite afterward.
  • Removal of the synthesized class binding no longer shadows the outer variable inside the class body — covered by the "class body still refers to the outer binding" test.
  • Bundler/minifier tests confirm the string-literal name survives NumberRenamer and identifier minification.
Extended reasoning...

Overview

This PR changes how the standard-decorator lowering (src/js_parser/lower/lower_decorators.rs) preserves the inferred .name of an anonymous decorated class expression. Previously it synthesized a class binding symbol named after the context, which the bundler's renamer would then rename (BarBar2) and which could not hold non-identifier names. The new approach drops the binding entirely and instead injects static { __name(this, "<name>") } as the first class body element, guarded by defines_static_name_method so a user-declared static name method/getter/setter/undecorated-accessor is not clobbered. can_be_class_binding_name and its imports are removed. Three bundler tests and eight transpiler tests are added covering naming contexts, non-identifier names, static-initializer ordering, the static-name guard variants, decorated static accessors, inlined TS enum keys, and console.log/Bun.inspect display (the latter depends on base PR #38922).

Security risks

None. This is compile-time AST rewriting with no untrusted-input parsing beyond what the JS parser already does; the injected helper (__name) is an existing runtime.js export that __decorateElement already calls.

Level of scrutiny

High — this is spec-sensitive transpiler output. ClassDefinitionEvaluation ordering (methods installed before static blocks/fields run) is exactly what four earlier review rounds turned on: the initial unconditional block clobbered a user static get name(); narrowing to any static name over-matched fields; the auto-accessor arm over-matched decorated accessors that are lifted to the suffix; and the key match missed EInlinedEnum-wrapped strings. Each was fixed with a corresponding test, and this pass found nothing new. The remaining known gap — a runtime-computed key evaluating to "name" — is compile-time undecidable, matches esbuild's --keep-names behavior, and is documented in the PR description.

Other factors

  • Stacked on #38922 (formatter change so console.log shows the __name-set name); one of the new tests pins that output, so merge order matters.
  • Design choice (leading static block vs. trailing __name(_class, ...) as in #38758) is deliberate and justified: only the block runs before body-resident static field initializers, so static x = this.name sees the right value. #38758 has been re-scoped to the statement-form export default class case this PR does not touch.
  • Test coverage is thorough and each earlier finding has a pinning test; the existing esbuild decorator conformance suite is stated to pass.

Given the iteration history and the subtlety of the ordering semantics, deferring for a maintainer to confirm the final guard shape and the accepted computed-key limitation rather than auto-approving.

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