Skip to content

js_parser: keep function, base class and static name names through standard decorator lowering - #42588

Open
robobun wants to merge 4 commits into
mainfrom
robobun/92cf56e6/decorator-lowering-names
Open

robobun wants to merge 4 commits into
mainfrom
robobun/92cf56e6/decorator-lowering-names

Conversation

@robobun

@robobun robobun commented Sep 13, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • @dec class C { static name() {} }; C.name() throws TypeError: C.name is not a function. A class decorator replaces a static method or getter called name with the class name. All releases with standard decorators do this.
  • Two more .name values differ from the undecorated class. class A { @dec onClick = () => {} } gives "", so a stack frame reads at <anonymous>, not at onClick. class B extends class {} { @dec m() {} } names the base class "_base$2".
  • Causes (src/js_parser/lower/lower_decorators.rs, src/runtime.js): __decorateElement runs __name(target, name) after the static methods exist. A decorated initializer becomes a call argument. The extends clause becomes _base = class {}.

Fix

Background

  • x = () => {} and class { x = () => {} } name an anonymous function or class after x. A call argument does not.
  • Every class starts with an own name string. A static method or accessor called name replaces it.
  • __decoratorStart creates the Symbol.metadata object of a class. It inherits from the metadata of the extended class.
Notes

Where this comes from

  • No user reported these. A comparison of each decorated class with its undecorated twin found them. esbuild 0.21.5 gives the same three values ("", "_a", "string"), and tsc 5.9 gives the same empty initializer names. Babel keeps the initializer names.
  • js_parser: lower standard decorators without moving class members #40833 set the rule that lowering does not change .name (its tests pin h = (effects, { h: () => {} }.h)). This PR applies the rule to the sites where the value is a call argument.

Emitted code, before and after

// before
class D extends (_base = class {}) {
  static { _init = __decoratorStart(_base); __decorateElement(_init, 5, "f", _dec, this); … }
  [(_dec = [dec], "f")] = __runInitializers(_init, 8, this, () => {});
}
// after
class D extends class {} {
  static { _init = __decoratorStart(undefined, this); __decorateElement(_init, 5, "f", _dec, this); … }
  [(_dec = [dec], "f")] = __runInitializers(_init, 8, this, { f: () => {} }["f"]);
}

Which initializers are wrapped

  • A decorated public field, static or not: FieldName::Key for a string or number key, FieldName::Computed with the _computedKey temporary for any other computed key (a symbol key gives [description]).
  • A decorated #private field or accessor: the key is the string "#p", so the name is #p as it is natively.
  • A public accessor, decorated or not. Its value goes through __privateAdd(this, _storage, value), so it lost the name without any decorator in the class. An undecorated accessor #x is a plain #x field and was already right.
  • __proto__ keys are emitted as computed ({ ["__proto__"]: fn }), as hosted_initializer already did.
  • A value that has its own name (function keep() {}, class { static name() {} }) keeps it, because this is real NamedEvaluation and not a defineProperty on name.
  • Not covered: a class that has class decorators itself, as the value of a #private, computed, numeric or symbol-keyed field (#p = @dec class {}). It is lowered to a comma expression before the field sees it, and its name comes from decorator_class_name, which knows identifier keys only. It is "" before and after this PR, with or without a decorator on the field. js_parser: name lowered anonymous classes after numeric, non-ASCII and private property keys #38787 is the open PR for that.

__decoratorStart

  • __decoratorStart(base) becomes __decoratorStart(base, target). With target, base is Object.getPrototypeOf(target). For extends null that is Function.prototype, which has no Symbol.metadata, so the metadata parent is null as before. A class with no extends clause still gets __decoratorStart(undefined).
  • I first tried _base = (0, class {}). It is correct in bun run and in a bundle that node runs. It is not correct in a bundle that Bun runs, because the syntax minifier of the runtime transpiler folds (0, class {}) back to class {}. Without a temporary there is nothing to fold, and bun build and bun build --minify output give "" too.
  • Output of an older Bun (bun build --no-bundle, which imports the helpers from bun:wrap) still passes _base as the only argument. I ran such a file from 1.4.3-canary on this build: the metadata chain is the same.

__name on a class

  • Natively the name is set before the class elements are defined, so a static method, getter, setter or accessor called name replaces it. A static field called name was already right, because static fields run after the leading static block.
  • The check is typeof Object.getOwnPropertyDescriptor(target, "name")?.value == "string". It does not read target.name, so a static getter is not called.
  • Also covered: a computed key that evaluates to "name", an anonymous class expression (const B = @dec class { static name() {} }), and the decorator seeing typeof value.name === "function".

Related open PRs

Self-review

  • Concern 1: js_parser: do not fold { f: () => {} }.f to the bare function #42585 bumps the same cache version and adds tests at the same place in es-decorators.test.ts, and this body did not link it. Addressed: linked above, with what depends on it and who takes version 34.
  • Concern 2: the body led with the cosmetic names and did not say who asked, what esbuild and tsc do, or what stays unnamed. Addressed: the body now leads with the TypeError, and "Where this comes from" and "Not covered" say the rest.
  • Concern 3 (optional): carve the static name guard into its own PR without a cache bump. Not done: the three fixes share tests and one rule, and the same review recommends one PR.

Other

  • RuntimeTranspilerCache version 32 → 33, because the emitted code changes.
  • Suites run with the debug build: es-decorators.test.ts (424), es-decorators-esbuild.test.ts (147), decorators.test.ts (24), decorator-metadata.test.ts (5), ts-use-define-for-class-fields.test.ts (11), regression/issue/27526, regression/issue/27575.
  • With git checkout origin/main -- src/, the 6 failures are the 3 new tests in "members stay where they are written" and the new names fixture section in .js, .ts and bundled mode. The other 418 pass.

[human-review] gate passed · iteration 0 · 5 files touched

fails on main (without fix)
ASAN without fix: 6 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 [397.33ms]
(pass) ES Decorators > class decorators > class decorator receives correct context [433.34ms]
(pass) ES Decorators > class decorators > class decorator can replace class [423.80ms]
(pass) ES Decorators > class decorators > multiple class decorators apply in reverse order [489.46ms]
(pass) ES Decorators > method decorators > instance method decorator [664.95ms]
(pass) ES Decorators > method decorators > static method decorator [522.57ms]
(pass) ES Decorators > method decorators > method decorator context has correct access [403.03ms]
(pass) ES Decorators > getter decorators > getter decorator [408.03ms]
(pass) ES Decorators > setter decorators > setter decorator [292.96ms]
(pass) ES Decorators > field decorators > field decorator receives undefined value [378.08ms]
(pass) ES Decorators > field decorators > multiple field decorators [302.42ms]
(
... (truncated)

release without fix: 358 FAILED
bun test v1.4.3-canary.1 (b99371011)

test/bundler/transpiler/es-decorators.test.ts:
(pass) ES Decorators > class decorators > basic class decorator [15.52ms]
(pass) ES Decorators > class decorators > class decorator receives correct context [13.73ms]
(pass) ES Decorators > class decorators > class decorator can replace class [13.81ms]
(pass) ES Decorators > class decorators > multiple class decorators apply in reverse order [12.51ms]
(pass) ES Decorators > method decorators > instance method decorator [11.06ms]
(pass) ES Decorators > method decorators > static method decorator [10.08ms]
(pass) ES Decorators > method decorators > method decorator context has correct access [9.54ms]
(pass) ES Decorators > getter decorators > getter decorator [13.63ms]
(pass) ES Decorators > setter decorators > setter decorator [9.32ms]
(pass) ES Decorators > field decorators > field decorator receives undefined value [12.61ms]
(pass) ES Decorators > field decorators > multiple field decorators [9.75ms]
(pass) ES Decorators > field decorators > static field decorator [13.70ms]
(pass) ES Decorators > non-ASCII string-literal keys > Bun.Transpiler output preserves the key [0.71ms]
(pass
... (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 [548.89ms]
(pass) ES Decorators > class decorators > class decorator receives correct context [536.99ms]
(pass) ES Decorators > class decorators > class decorator can replace class [412.34ms]
(pass) ES Decorators > class decorators > multiple class decorators apply in reverse order [404.62ms]
(pass) ES Decorators > method decorators > instance method decorator [389.75ms]
(pass) ES Decorators > method decorators > static method decorator [412.22ms]
(pass) ES Decorators > method decorators > method decorator context has correct access [301.39ms]
(pass) ES Decorators > getter decorators > getter decorator [303.49ms]
(pass) ES Decorators > setter decorators > setter decorator [414.11ms]
(pass) ES Decorators > field decorators > field decorator receives undefined value [452.60ms]
(pass) ES Decorators > field decorators > multiple field decorators [369.59ms]
(
... (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     3d24661356
  features     baseline

23 deps, 131 codegen, 1176 objects in 686ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1248] install /workspace/bun
bun install v1.4.3-canary.1 (b99371011)

Checked 22 installs across 61 packages (no changes) [11.00ms]
[2/1248] gen ErrorCode+*.h
[3/1248] install /workspace/bun/packages/bun-error
bun install v1.4.3-canary.1 (b99371011)

Checked 1 install across 2 packages (no changes) [1.00ms]
[4/1248] install /workspace/bun/src/node-fallbacks
bun install v1.4.3-canary.1 (b99371011)

Checked 111 installs across 104 packages (no changes) [5.00ms]
[5/1248] gen bindgenv2
[6/1248] fetch tinycc
[tinycc] up to date
[7/1247] fetch zlib
[zlib] up to date
[8/1247] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[9/1247] gen node-fallbacks/react-refresh.js
Bundled 1 module in 5ms

  react-refresh.js  4.81 KB  (entry point)

[10/1247] gen bake.{client,server,error}.js
-> bake.client.js, bake.server.js, bake.error.js
[11/1247] esbuild bun-error

 
... (truncated)
diff hotspot
scripts/build/codegen.ts                      |   3 +-
 src/js_parser/lower/lower_decorators.rs       |  74 ++++++++++------
 src/jsc/RuntimeTranspilerCache.rs             |   3 +-
 src/runtime.js                                |  11 ++-
 test/bundler/transpiler/es-decorators.test.ts | 118 ++++++++++++++++++++++++++
 5 files changed, 177 insertions(+), 32 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                           reads  edits  tests
scripts/build/codegen.ts                           1      1     19
src/js_parser/lower/lower_decorators.rs            4      4     17
src/jsc/RuntimeTranspilerCache.rs                  1      2     17
src/runtime.js                                     2      3     18
test/bundler/transpiler/es-decorators.test.ts      1      5     17

…dard decorator lowering

A decorated field or an accessor passes its initializer to a runtime helper as
a call argument, where an anonymous function or class is no longer named by
the field. It is now wrapped as `{ key: value }[key]`, like the initializer of
a field that carries the effects of its neighbors.

The class a decorated class extends was kept in a `_base` temporary for
`__decoratorStart`, so an anonymous class or function in the extends clause was
named after the temporary. `__decoratorStart` now reads the prototype of the
class itself and the extends clause stays as written.

`__decorateElement` set the name of a class with class decorators after its
static methods and accessors were defined, which replaced a static member
called `name`. It now only replaces the string every class starts with.

scripts/build/codegen.ts: runtime.out.js is bundled from runtime.bun.js, which
re-exports runtime.js. Only the former was an input of the build edge, so an
edit to runtime.js did not rebuild it.
@coderabbitai

coderabbitai Bot commented Sep 13, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

  • Run on-demand review

On-demand reviews are free for the next 7 days. After that, they cost $0.25 per reviewed file.

Or wait 6 minutes for your next included review.

Check out review usage here.

View limit details

Limit details: You’ve used all 10 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 35c7f578-c009-4886-9f60-0bfabd485422

📥 Commits

Reviewing files that changed from the base of the PR and between 09bb546 and 3d24661.

📒 Files selected for processing (5)
  • scripts/build/codegen.ts
  • src/js_parser/lower/lower_decorators.rs
  • src/jsc/RuntimeTranspilerCache.rs
  • src/runtime.js
  • test/bundler/transpiler/es-decorators.test.ts

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

@robobun

robobun commented Sep 13, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 1:50 PM PT - Sep 13th, 2026

✅ @robobun, your commit 3d2466135600742927e3ee2771bad5ce6ee8d18c passed in Build #115285! 🎉


🧪   To try this PR locally:

bunx bun-pr 42588

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

bun-42588 --bun

@robobun

robobun commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author

Status

Reproduced on main a22b2aa (debug build) and on 1.4.3-canary b993710 with:

const dec = (v, c) => {};
class A { @dec f = () => {}; @dec static g = function () {}; @dec h = class {}; }
class B extends class {} { @dec m() {} }
@dec class C { static name() { return "static method" } }
console.log(new A().f.name, A.g.name, new A().h.name);   // "" "" ""      want f g h
console.log(Object.getPrototypeOf(B).name);               // "_base$2"     want ""
console.log(typeof C.name);                               // "string"      want "function"

With git checkout origin/main -- src/, bun bd test test/bundler/transpiler/es-decorators.test.ts fails 6 of 424 (the 3 new tests and the new names fixture section in 3 modes). With this branch all 424 pass.

@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 emitted decorator-lowering output and reworks the __decoratorStart/__decorateElement runtime helper contract (diverging from esbuild's _base approach), a human look at the design would still be worthwhile.

What was reviewed:

  • named_by_field key reuse — FieldName::Key is a constant and FieldName::Computed is a temp ref, so emitting the key twice (as property and as index) is side-effect-free.
  • __decoratorStart(base, target) backward compat — old one-arg output leaves target undefined so base is used as before; extends null gives Function.prototype with no Symbol.metadata, covered by the Nul test.
  • The __getOwnPropDesc(target, "name")?.value guard — static getters/accessors have no .value, static methods have a function .value, and static fields run after the leading block so G.name = 1 still wins.
  • base_ref removal leaves no other consumers; cache version bumped 32→33; runtime.js added as an explicit ninja input.
Extended reasoning...

Overview

This PR fixes three .name-related correctness bugs in the standard-decorator lowering pass: (1) decorated fields/accessors now name their anonymous initializer via a { key: value }[key] wrapper routed through a new named_by_field helper; (2) the _base = <extends> temporary is removed and __decoratorStart gains a second target argument so metadata inheritance is derived from Object.getPrototypeOf(this) in the leading static block; (3) __decorateElement skips __name(target, name) when the class already has a non-string own name descriptor. It touches src/js_parser/lower/lower_decorators.rs (~77 lines net), src/runtime.js (two helpers), bumps RuntimeTranspilerCache 32→33, adds src/runtime.js as an explicit ninja input in scripts/build/codegen.ts, and adds three test.concurrent cases plus a names fixture section in test/bundler/transpiler/es-decorators.test.ts.

Security risks

None. This is transpiler output shaping and runtime helper logic for decorator lowering; no auth, crypto, network, filesystem, or untrusted-input parsing is touched. The __getOwnPropDesc(target, "name") read avoids invoking a user getter (it inspects the descriptor, not target.name).

Level of scrutiny

Medium-high. The lowering pass is subtle — small changes here alter the code emitted for every user of standard decorators, and REVIEW.md calls out that the parser's reference is esbuild and control-flow divergence should be deliberate. Removing _base in favor of Object.getPrototypeOf(this) inside a static block is a deliberate divergence from esbuild's shape (well-justified in the PR body: (0, class {}) gets folded back by Bun's runtime minifier), and the two-arg __decoratorStart introduces a helper-contract change with a backward-compat branch. These are the kind of design choices a maintainer familiar with lower_decorators.rs should sign off on, even though I found nothing wrong with them.

Other factors

Test coverage is thorough and follows the repo's conventions: test.concurrent, stderr/stdout asserted before exitCode, and the variant matrix is wide (private/computed/symbol/numeric/__proto__ keys, static vs instance, decorated vs plain accessors, extends null, anonymous class expressions, static method/getter/accessor/field named name, and the value seen by decorator init functions). The cache-version bump satisfies the REVIEW.md rule for changed serialized output, the dead base_ref block is deleted rather than left behind, and no CODEOWNERS entry covers the changed paths. The bug hunt exited on dry_streak with no findings and no ruled-out candidates.

Comment thread src/js_parser/lower/lower_decorators.rs Resolved
Comment thread src/js_parser/lower/lower_decorators.rs Resolved
Comment thread src/js_parser/lower/lower_decorators.rs Resolved
Comment thread src/runtime.js Resolved

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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