Skip to content

js_parser: keep this for a tagged template through a decorated #private member - #42677

Open
robobun wants to merge 1 commit into
mainfrom
robobun/e0088d53/decorated-private-template-tag
Open

robobun wants to merge 1 commit into
mainfrom
robobun/e0088d53/decorated-private-template-tag

Conversation

@robobun

@robobun robobun commented Sep 14, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • Under standard decorators, a tagged template whose tag is a decorated #private member runs the function with this === undefined. this.#tag`a${1}b` gives undefined:a|b1. Node gives 7:a|b1 without the decorator.
  • The cause is the ETemplate arm of rewrite_private_accesses_in_expr (src/js_parser/lower/lower_decorators.rs:423). It only recursed into the tag, so the tag became the value __privateMethod(this, _tag, _tag_fn). That is not a member access, so it gets no receiver.
  • Found by audit, no user report. js_parser: lower standard decorators without moving class members #40833 lists this form under "Not in this PR".

Fix

  • The arm rewrites such a tag to __privateMethod(recv, _tag, _tag_fn).bind(recv), the shape esbuild emits. .call(recv, strings) is not possible: the engine keeps one strings array per call site.
  • The ECall and ETemplate arms share a new capture_receiver helper. A receiver other than this or an identifier goes through an _obj temporary, so make().#tag`x` evaluates make() once.
  • The lowered output changes, so the runtime transpiler cache version goes to 33.
  • Verified: test/bundler/transpiler/es-decorators.test.ts, new privateTag section (.js, .ts, bun build). The 3 new tests fail without the fix. Other suites: see Notes.

Background

  • Decorator lowering replaces a #private name that has a decorated member with a WeakMap or WeakSet, so the runtime helpers can reach it. Each x.#name becomes a helper call such as __privateGet(x, _name).
  • A tagged template a.b`x` calls a.b with this = a only when the tag is a member access. Its first argument is a frozen strings array, created once per call site.
  • The ECall arm already lowers x.#m(...) to __privateMethod(x, _m, _m_fn).call(x, ...).
Notes

Repro (bun tag.js). Before: ["undefined:a|b1",false]. After, and node without the decorators: ["7:a|b1",true].

function dec() { return (v, ctx) => {}; }
class A {
  @dec() #tag(s, ...v) { return this?.n + ":" + s.raw.join("|") + v.join(","); }
  @dec() static #stag(s) { return this === A; }
  n = 7;
  run() { return [this.#tag`a${1}b`, A.#stag`x`]; }
}
console.log(JSON.stringify(new A().run()));

Lowered run() after the change:

run() {
  return [__privateMethod(this, _tag, _tag_fn).bind(this)`a${1}b`, __privateMethod(A, _stag, _stag_fn).bind(A)`x`];
}

The same path covers a decorated private method, static method, getter, field and auto-accessor that holds a function. The new test section checks each of them. It also checks an identifier receiver, a receiver with side effects (evaluated once), a receiver that is itself a lowered private read (this.#me.#tag), a tagged template nested in a template part, an arrow function with an expression body, and a static block. It checks that one call site gets the same strings array on every evaluation. The expected values are what node prints for the same class without the decorators.

Other probes against node, all equal with this change: a decorated static field initializer in a class expression, a chained template (Foo.#curry`ab ``), an async method, a generator, a parameter default, an arrow function inside a method, a computed object key, and a parenthesized optional chain (``(a?.#tag)p`` withaset and witha` null).

capture_receiver is the receiver block of the ECall arm, moved into a function. The output of the ECall arm does not change. As before, the lowering reads an identifier receiver twice and does not capture it. A private getter that assigns to that identifier can see the difference. That is the same in both arms and this PR does not change it.

Order against other open PRs:

Not in this PR:

Suites run with the debug build: test/bundler/transpiler/es-decorators.test.ts (421 pass), es-decorators-esbuild.test.ts (147 pass), decorators.test.ts (24 pass), decorator-metadata.test.ts (5 pass), test/cli/run/transpiler-cache.test.ts (20 pass), test/regression/issue/27526.test.ts, test/regression/issue/27575.test.ts.

Self-reviewed: 3 changes requested, 3 made (the cache version, the shared helper, the notes above on order and on excluded forms).


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

fails on main (without fix)
ASAN without fix: 3 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 [338.82ms]
(pass) ES Decorators > class decorators > class decorator receives correct context [407.36ms]
(pass) ES Decorators > class decorators > class decorator can replace class [438.67ms]
(pass) ES Decorators > class decorators > multiple class decorators apply in reverse order [443.73ms]
(pass) ES Decorators > method decorators > instance method decorator [322.63ms]
(pass) ES Decorators > method decorators > static method decorator [383.38ms]
(pass) ES Decorators > method decorators > method decorator context has correct access [324.87ms]
(pass) ES Decorators > getter decorators > getter decorator [478.13ms]
(pass) ES Decorators > setter decorators > setter decorator [365.09ms]
(pass) ES Decorators > field decorators > field decorator receives undefined value [349.90ms]
(pass) ES Decorators > field decorators > multiple field decorators [398.23ms]
(
... (truncated)

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

test/bundler/transpiler/es-decorators.test.ts:
(pass) ES Decorators > class decorators > basic class decorator [11.42ms]
(pass) ES Decorators > class decorators > class decorator receives correct context [8.08ms]
(pass) ES Decorators > class decorators > class decorator can replace class [7.90ms]
(pass) ES Decorators > class decorators > multiple class decorators apply in reverse order [10.26ms]
(pass) ES Decorators > method decorators > instance method decorator [7.42ms]
(pass) ES Decorators > method decorators > static method decorator [7.00ms]
(pass) ES Decorators > method decorators > method decorator context has correct access [7.06ms]
(pass) ES Decorators > getter decorators > getter decorator [6.73ms]
(pass) ES Decorators > setter decorators > setter decorator [10.16ms]
(pass) ES Decorators > field decorators > field decorator receives undefined value [6.85ms]
(pass) ES Decorators > field decorators > multiple field decorators [7.31ms]
(pass) ES Decorators > field decorators > static field decorator [7.73ms]
(pass) ES Decorators > non-ASCII string-literal keys > Bun.Transpiler output preserves the key [0.74ms]
(pass) ES D
... (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 [303.06ms]
(pass) ES Decorators > class decorators > class decorator receives correct context [296.75ms]
(pass) ES Decorators > class decorators > class decorator can replace class [477.98ms]
(pass) ES Decorators > class decorators > multiple class decorators apply in reverse order [397.28ms]
(pass) ES Decorators > method decorators > instance method decorator [473.06ms]
(pass) ES Decorators > method decorators > static method decorator [588.36ms]
(pass) ES Decorators > method decorators > method decorator context has correct access [572.64ms]
(pass) ES Decorators > getter decorators > getter decorator [560.75ms]
(pass) ES Decorators > setter decorators > setter decorator [463.36ms]
(pass) ES Decorators > field decorators > field decorator receives undefined value [421.64ms]
(pass) ES Decorators > field decorators > multiple field decorators [467.87ms]
(
... (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     104dfcfc6b
  features     baseline

23 deps, 131 codegen, 1176 objects in 647ms

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) [8.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) [4.00ms]
[4/1248] gen bindgenv2
[5/1248] install /workspace/bun/src/node-fallbacks
bun install v1.4.3-canary.1 (b99371011)

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

  react-refresh.js  4.81 KB  (entry point)

[9/1247] fetch zlib
[zlib] up to date
[10/1247] gen .bind.ts → GeneratedBindings.cpp
[11/1247] gen ProcessBindingConstants.lut.h
Generating /workspace/bun/build
... (truncated)
diff hotspot
src/js_parser/lower/lower_decorators.rs       | 80 +++++++++++++++++++--------
 src/jsc/RuntimeTranspilerCache.rs             |  3 +-
 test/bundler/transpiler/es-decorators.test.ts | 71 ++++++++++++++++++++++++
 3 files changed, 130 insertions(+), 24 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

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

…ate` member

Standard decorator lowering turns a `#private` name that has a decorated
member into a WeakMap or WeakSet and rewrites each access. The template
arm of `rewrite_private_accesses_in_expr` only recursed into the tag, so
`this.#tag`x`` became `__privateMethod(this, _tag, _tag_fn)`x``. The tag
was no longer a member access and the function ran with `this` undefined.

The arm now rewrites such a tag to `__privateMethod(recv, ...).bind(recv)`.
`.call` cannot be used here because the strings array of a tagged template
is the one the engine keeps for the call site.

The call arm and the template arm share one `capture_receiver` helper:
`this` and an identifier are read twice, any other receiver goes through
an `_obj` temporary so it is evaluated once.

The lowered output changes, so the runtime transpiler cache version goes
to 33.
@robobun

robobun commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator Author

Status: fix pushed, waiting for CI.

How I reproduced it: the class below, run as bun tag.js on 1.4.3 and on a debug build of main (09bb546), prints ["undefined:a|b1",false]. Node prints ["7:a|b1",true] for the same class without the decorators. With this branch, bun prints ["7:a|b1",true].

function dec() { return (v, ctx) => {}; }
class A {
  @dec() #tag(s, ...v) { return this?.n + ":" + s.raw.join("|") + v.join(","); }
  @dec() static #stag(s) { return this === A; }
  n = 7;
  run() { return [this.#tag`a${1}b`, A.#stag`x`]; }
}
console.log(JSON.stringify(new A().run()));

Test: bun bd test test/bundler/transpiler/es-decorators.test.ts -t privateTag. The 3 privateTag tests fail on main and pass on this branch.

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

LGTM — focused lowering fix that mirrors the existing ECall arm, with the cache version bumped and thorough test coverage.

What was reviewed:

  • capture_receiver is a pure extraction of the previous inline block in the ECall arm; that arm's output is unchanged.
  • The new ETemplate path emits .bind(recv) (not .call), which preserves per-call-site template-strings identity — the test asserts this directly (sameSiteSameStrings / otherSiteOtherStrings).
  • Non-matching tags still fall through to the recursive rewrite, and template parts are still walked; receiverEvals: 9 checks the temporary-capture path evaluates each self() once per site.
  • Transpiler cache constant bumped to 33 as required for changed lowered output.
Extended reasoning...

Overview

The PR fixes ES-decorator lowering for tagged templates whose tag is a decorated #private member. Before, the ETemplate arm of rewrite_private_accesses_in_expr in src/js_parser/lower/lower_decorators.rs only recursed into the tag, producing a bare __privateGet(x, _m) value that lost its receiver, so the tag ran with this === undefined. The fix rewrites such tags to __privateGet(x, _m).bind(x) and factors the receiver-capture logic (identifier/this re-read; anything else through an _obj temporary) into a shared capture_receiver helper used by both the ECall and ETemplate arms. The runtime transpiler cache version in src/jsc/RuntimeTranspilerCache.rs is bumped 32→33, and test/bundler/transpiler/es-decorators.test.ts gains a privateTag section covering method/static/getter/field/accessor tags, chained and nested receivers, arrow bodies, a static {} block, single-evaluation of side-effecting receivers, and template-strings-array identity.

Security risks

None. This is AST-to-AST lowering of already-parsed source; no untrusted-length arithmetic, no filesystem/network I/O, no auth or crypto surface. The only user-observable effect is corrected this binding in emitted code.

Level of scrutiny

Moderate. Transpiler output correctness affects every user, so I read the surrounding ECall arm to confirm the extracted capture_receiver helper is byte-for-byte the previous inline logic (it is — the ECall arm's emitted code is unchanged). The new ETemplate path is a direct structural mirror of ECall with .bind in place of .call, which is the correct choice: .call(x, strings, ...) would destroy the engine's per-call-site frozen strings identity, whereas .bind keeps the tagged-template call form intact. The else branch preserves the prior recursive walk for non-private tags, and template parts are still visited afterward, so no existing rewrite is skipped. I hand-checked the test's expected receiverEvals: 9 (one static-block self() + four per run() × two runs) and the sites[] indexing for sameSiteSameStrings — both line up.

Other factors

REVIEW.md's specific requirements are all met: the duplicated receiver-capture block is extracted into a named helper and used at both sites; the transpiler cache format constant is bumped for changed cached output; the whole sibling class (all five private-member kinds, static and instance, plain/this/chained/nested receivers) is covered rather than just the repro; the test is added to the existing module file with a single structural toEqual on exact values derived from Node's undecorated output. No CODEOWNERS entry covers src/js_parser/. The PR description transparently documents the ordering interaction with two open PRs (cache version conflict, and the target_was_originally_property_access flag) and the forms deliberately left out — none of which affect the correctness of what's landed here. Exit reason was dry_streak with no findings.

@robobun

robobun commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator Author

The review found nothing to change, and no review threads are open. Buildkite build 115372 is still running.

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.

1 participant