Skip to content

js_parser: substitute undefined for new.target in class field initializers and static blocks - #42653

Closed
robobun wants to merge 5 commits into
mainfrom
robobun/f92a6839/new-target-in-class-field-initializer
Closed

robobun wants to merge 5 commits into
mainfrom
robobun/f92a6839/new-target-in-class-field-initializer

Conversation

@robobun

@robobun robobun commented Sep 13, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • new.target in a class field initializer or a class static block throws ReferenceError: Can't find private variable: PrivateSymbol.newTargetLocal when the class is inside an arrow function that has no function around it. Node prints undefined.
  • The cause is JavaScriptCore's parser. It charges that new.target to the code around the class, and an arrow function then loads a value that no function stored. [JSC] new.target in a class field initializer or a class static block throws a ReferenceError when the class is in an arrow function WebKit#647 fixes the engine.
  • The bundler makes this shape: __esm(() => { ... }). A module that runs unbundled throws once bundled. Lowering also moves the initializer of accessor x = new.target into the constructor, where it is the class.

Fix

  • The visit pass substitutes undefined for new.target in a class field initializer and a class static block (e_new_target in src/js_parser/visit/visit_expr.rs). The flag is in FnOnlyDataVisit, the visit-pass state that an arrow function inherits and visit_func resets.
  • Correct because both run as a method call, so the value is always undefined. A key, an extends clause and a decorator belong to the code around the class and keep new.target.
  • A bundle then also runs on a JavaScriptCore without the engine fix. The transpiler cache EXPECTED_VERSION goes to 33.
  • Verified: runtime-transpiler.test.ts, transpiler.test.js and ts-use-define-for-class-fields.test.ts in test/bundler/transpiler/, and edgecase/EsmWrapNewTargetInClassFieldInitializer in test/bundler/bundler_edgecase.test.ts. A debug build of main fails all four.

Background

  • new.target is the constructor that new ran. An arrow function takes it from the function around it.
  • The spec runs a field initializer and a static block as a method call.
  • __esm is the bundler's lazy wrapper for a module that require() or import() loads.
Notes

Nobody reported this. Another change to decorator lowering found it in a test, and no code in the wild is known to hit it. tsc rejects new.target in a field initializer with TS17013. It is a conformance fix, and it keeps bun build from turning a module that runs into one that throws.

Repro (bun repro.mjs, compare with node repro.mjs):

const run = f => { try { return f(); } catch (e) { return String(e); } };
console.log(run(() => { class A { x = typeof new.target; } return new A().x; }));
console.log(run(() => { class A { static x = typeof new.target; } return A.x; }));
console.log(run(() => { let r; class A { static { r = typeof new.target; } } return r; }));
// With a function around the arrow function, a field works and a static block still throws.
console.log(run(function () { return (() => { let r; class A { static { r = typeof new.target; } } return r; })(); }));

bun 1.4.3-canary.1+b99371011 prints the ReferenceError four times. Node prints undefined four times. An async arrow function throws from the call, it does not return a rejected promise.

Why the transpiler and not only the engine:

  • bun build output runs on other JavaScriptCore versions. The bundle of export class A { x = new.target } behind await import() throws on the bun that built it and runs on node.
  • Two lowerings move a field initializer into the constructor, where new.target is the class: an auto-accessor (class Foo { accessor x = new.target }, new Foo().x is [class Foo] on canary) and TypeScript with useDefineForClassFields: false. tsc emits this.x = new.target for the second one, next to the TS17013 error. The value that ECMAScript gives is undefined. The engine fix cannot repair these.

What the substitution does not reach: code that the transpiler does not see (eval, new Function, node:vm). (0, eval)("class A { x = new.target }; new A().x") still throws until the engine fix lands.

Positions, as the printed-output test pins them:

  • Substituted: instance, static and private field initializers, static blocks, arrow functions inside them (body and parameter defaults), and the heritage, keys and members of a class that is inside one of them.
  • Kept: a function, method, getter, setter or constructor (each has its own new.target), and the key, extends clause and decorators of the class itself.
  • A parameter decorator is visited with the state of its function, so it always keeps new.target. That is right unless the class is itself inside a class element, where the value stays correct and only the substitution is missed.

E::Undefined prints as undefined, or void 0 with --minify-syntax, like every other place that produces it. delete new.target becomes delete (0, undefined).

#42595 adds a line to the same e_new_target body. The two changes do not depend on each other. The second one to land needs a rebase there. #42585 and #42588 also take cache version 33. The later ones take the next free number.

Test runs, linux x64, debug build with ASAN: the four files above fail on a debug build of main (09bb546) and pass on this branch. Also es-decorators.test.ts (418), es-decorators-esbuild.test.ts (147), decorators.test.ts (24), decorator-metadata.test.ts (5), ts-use-define-for-class-fields.test.ts (12), all of transpiler.test.js (215 pass, 21 todo) and all of bundler_edgecase.test.ts (181 pass, 11 todo).

Self-reviewed: 4 concerns raised, 3 addressed. The cache version bump, the engine PR link in the code comment and a test for the useDefineForClassFields: false path are in. The parameter decorator case above is left as it is, because it needs TypeScript experimental parameter decorators with new.target in a class inside a class element.


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

fails on main (without fix)
ASAN without fix: 4 failed, 33 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/bundler/bundler_edgecase.test.ts test/bundler/transpiler/runtime-transpiler.test.ts test/bundler/transpiler/transpiler.test.js test/bundler/transpiler/ts-use-define-for-class-fields.test.ts
bun test v1.4.3 (b99371011)

test/bundler/bundler_edgecase.test.ts:
(pass) bundler > edgecase/EmptyFile [567.81ms]
(pass) bundler > edgecase/EmptyCommonJSModule [459.10ms]
(pass) bundler > edgecase/NestedRedirectToABuiltin [445.16ms]
(pass) bundler > edgecase/ImportStarFunction [477.62ms]
(pass) bundler > edgecase/ImportStarSyntaxErrorBug [406.44ms]
(todo) bundler > edgecase/BunPluginTreeShakeImport
(pass) bundler > edgecase/TemplateStringIssue622 [133.48ms]
(pass) bundler > edgecase/ImportNamedFromExportStarCJS [399.35ms]
(pass) bundler > edgecase/NodeEnvDefaultUnset [278.89ms]
(pass) bundler > edgecase/NodeEnvDefaultDevelopment [333.88ms]
(pass) bundler > edgecase/NodeEnvDefaultProduction [232.46ms]
(todo) bundler > edgecase/NodeEnvOptionalChaining
(pass) bundler > edgecase/StarExternal [144.88ms]
(pass) bundler > edgecase/ImportNamespaceAndDe
... (truncated)

release without fix: 10 failed, 33 skipped
bun test v1.4.3-canary.1 (b99371011)

test/bundler/bundler_edgecase.test.ts:
(pass) bundler > edgecase/EmptyFile [14.09ms]
(pass) bundler > edgecase/EmptyCommonJSModule [17.89ms]
(pass) bundler > edgecase/NestedRedirectToABuiltin [14.69ms]
(pass) bundler > edgecase/ImportStarFunction [12.66ms]
(pass) bundler > edgecase/ImportStarSyntaxErrorBug [11.78ms]
(todo) bundler > edgecase/BunPluginTreeShakeImport
(pass) bundler > edgecase/TemplateStringIssue622 [5.96ms]
(pass) bundler > edgecase/ImportNamedFromExportStarCJS [11.25ms]
(pass) bundler > edgecase/NodeEnvDefaultUnset [6.01ms]
(pass) bundler > edgecase/NodeEnvDefaultDevelopment [6.78ms]
(pass) bundler > edgecase/NodeEnvDefaultProduction [5.79ms]
(todo) bundler > edgecase/NodeEnvOptionalChaining
(pass) bundler > edgecase/StarExternal [3.69ms]
(pass) bundler > edgecase/ImportNamespaceAndDefault [12.90ms]
(todo) bundler > edgecase/ExternalES6ConvertedToCommonJSSimplified
(pass) bundler > edgecase/ImportTrailingSlash [10.97ms]
(pass) bundler > edgecase/ValidLoaderSeenAsInvalid [4.30ms]
(pass) bundler > edgecase/InvalidLoaderSegfault [3.18ms]
(todo) bundler > edgecase/ScriptTagEscape
(pass) bundler > edgecase/JSONDefaul
... (truncated)
passes on PR (with fix)
ASAN with fix: 33 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/bundler/bundler_edgecase.test.ts test/bundler/transpiler/runtime-transpiler.test.ts test/bundler/transpiler/transpiler.test.js test/bundler/transpiler/ts-use-define-for-class-fields.test.ts
bun test v1.4.3 (b99371011)

test/bundler/bundler_edgecase.test.ts:
(pass) bundler > edgecase/EmptyFile [529.45ms]
(pass) bundler > edgecase/EmptyCommonJSModule [538.92ms]
(pass) bundler > edgecase/NestedRedirectToABuiltin [527.65ms]
(pass) bundler > edgecase/ImportStarFunction [376.31ms]
(pass) bundler > edgecase/ImportStarSyntaxErrorBug [440.60ms]
(todo) bundler > edgecase/BunPluginTreeShakeImport
(pass) bundler > edgecase/TemplateStringIssue622 [113.89ms]
(pass) bundler > edgecase/ImportNamedFromExportStarCJS [496.01ms]
(pass) bundler > edgecase/NodeEnvDefaultUnset [370.02ms]
(pass) bundler > edgecase/NodeEnvDefaultDevelopment [265.33ms]
(pass) bundler > edgecase/NodeEnvDefaultProduction [241.15ms]
(todo) bundler > edgecase/NodeEnvOptionalChaining
(pass) bundler > edgecase/StarExternal [111.34ms]
(pass) bundler > edgecase/ImportNamespaceAndDe
... (truncated)

release with fix: 33 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     5a92b150ba
  features     baseline

23 deps, 131 codegen, 1176 objects in 786ms

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) [16.00ms]
[2/1248] install /workspace/bun/packages/bun-error
bun install v1.4.3-canary.1 (b99371011)

Checked 1 install across 2 packages (no changes) [5.00ms]
[3/1248] gen ErrorCode+*.h
[4/1248] install /workspace/bun/src/node-fallbacks
bun install v1.4.3-canary.1 (b99371011)

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

  react-refresh.js  4.81 KB  (entry point)

[8/1248] gen .bind.ts → GeneratedBindings.cpp
[9/1248] gen ProcessBindingConstants.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingConstants.lut.h from /workspace/bun/src/jsc/bindings/ProcessBindingC
... (truncated)
diff hotspot
src/js_parser/parser.rs                            |  3 +
 src/js_parser/visit/mod.rs                         |  6 +-
 src/js_parser/visit/visit_expr.rs                  |  7 +-
 src/jsc/RuntimeTranspilerCache.rs                  |  3 +-
 test/bundler/bundler_edgecase.test.ts              | 26 +++++++
 test/bundler/transpiler/runtime-transpiler.test.ts | 80 ++++++++++++++++++++++
 test/bundler/transpiler/transpiler.test.js         | 45 ++++++++++++
 .../ts-use-define-for-class-fields.test.ts         | 16 +++++
 8 files changed, 183 insertions(+), 3 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                                      reads  edits  tests
src/js_parser/parser.rs                                       2      3     19
src/js_parser/visit/mod.rs                                    2      3     19
src/js_parser/visit/visit_expr.rs                             4      4     19
src/jsc/RuntimeTranspilerCache.rs                             1      1     19
test/bundler/bundler_edgecase.test.ts                         1      1      7
test/bundler/transpiler/runtime-transpiler.test.ts            2      4      8
test/bundler/transpiler/transpiler.test.js                    3      1      7
…ndler/transpiler/ts-use-define-for-class-fields.test.ts      1      2      4

…izers and static blocks

A class field initializer and a class static block run as a method call,
so new.target is always undefined in them. An arrow function in them takes
the same value.

JavaScriptCore throws "ReferenceError: Can't find private variable:
PrivateSymbol.newTargetLocal" on entry to an arrow function that has no
function around it when a class inside it has new.target in a field
initializer or a static block. The bundler's __esm wrapper is such an
arrow function, so a module that runs unbundled throws once bundled.
With a function around the arrow function, a static block throws too.

Lowering also moves the initializer of an auto-accessor into the
constructor, where new.target is the class.
A cached file that was transpiled before this change still has new.target
in a class field initializer or a class static block.
@coderabbitai

coderabbitai Bot commented Sep 13, 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: 6db25953-dd85-46e9-a5d5-8f6e8db44822

📥 Commits

Reviewing files that changed from the base of the PR and between 09ef9e9 and 5a92b15.

📒 Files selected for processing (2)
  • src/js_parser/parser.rs
  • src/js_parser/visit/visit_expr.rs

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


Walkthrough

The parser now tracks class field initializer and static block contexts where new.target is undefined. Expression visitation emits undefined in those contexts. The runtime transpiler cache version and regression tests were updated.

Changes

new.target context handling

Layer / File(s) Summary
Context tracking and expression rewrite
src/js_parser/parser.rs, src/js_parser/visit/mod.rs, src/js_parser/visit/visit_expr.rs
The visitor tracks when new.target is undefined for class fields and static blocks. e_new_target replaces it with undefined and restores prior state after field initializers.
Cache versioning and regression coverage
src/jsc/RuntimeTranspilerCache.rs, test/bundler/bundler_edgecase.test.ts, test/bundler/transpiler/*
The runtime transpiler cache version increases to 33. Bundler and transpiler tests cover class fields, static blocks, nested expressions, and preserved new.target behavior in other contexts.

Suggested reviewers: jarred-sumner

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to 5a92b

The parser rewrite is scoped to the intended class-field and static-block cases, with cache versioning and regression coverage in place.

🚥 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 and concisely describes the main parser change: substituting undefined for new.target in class field initializers and static blocks.
Description check ✅ Passed The description explains the problem, fix, rationale, scope, cache update, and verification results. It uses different headings from the template, but it provides the required information and is compl…

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

@robobun

robobun commented Sep 13, 2026 •

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

✅ @robobun, your commit 5a92b150bacd989195e80bc1ad7455a87f4fb9bc passed in Build #115281! 🎉


🧪   To try this PR locally:

bunx bun-pr 42653

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

bun-42653 --bun

@robobun

robobun commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author

Status

Reproduced on bun 1.4.3-canary.1+b99371011 and on a debug build of main (09bb546):

// repro.mjs
const run = f => { try { return f(); } catch (e) { return String(e); } };
console.log(run(() => { class A { x = typeof new.target; } return new A().x; }));
console.log(run(() => { let r; class A { static { r = typeof new.target; } } return r; }));

bun repro.mjs prints ReferenceError: Can't find private variable: PrivateSymbol.newTargetLocal two times. node repro.mjs prints undefined two times. This branch prints undefined two times.

The engine fix is oven-sh/WebKit#647. This PR does not depend on it.

Comment thread src/js_parser/parser.rs Outdated
Comment thread src/js_parser/visit/visit_expr.rs Outdated

@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 — small, spec-correct rewrite with the cache version bumped and thorough positive/negative coverage.

What was reviewed:

  • FnOnlyDataVisit flag placement: inherited by arrows (e_arrow doesn't touch it), reset by visit_func via ..Default::default(), so functions/methods/constructors inside a field keep new.target.
  • Save/restore around field initializers matches the neighboring is_this_nested pattern; static-block struct literal is exhaustive (only two fields).
  • Negative cases (computed keys, extends, constructor, function-in-field) are pinned by printer snapshots and runtime assertions; nested class-in-field key correctly inherits the outer field's undefined.
  • Tests follow harness conventions (tempDir, concurrent pipe drain, stdout/stderr before exitCode, .toEqual on one object).
Extended reasoning...

Overview

This PR adds a single boolean is_new_target_undefined to FnOnlyDataVisit in the JS parser's visit pass, sets it while visiting class field initializers and class static blocks, and rewrites E::NewTarget → E::Undefined when the flag is set. It bumps RuntimeTranspilerCache::EXPECTED_VERSION 32→33 and adds tests to four existing files (bundler edgecase, runtime transpiler, printer snapshots, and useDefineForClassFields: false). The parser change is ~5 lines of logic plus a struct field.

Security risks

None. This is a compile-time AST rewrite of a meta-property whose value is spec-defined as undefined in these contexts. No untrusted input parsing, no allocation, no FFI, no auth/crypto surface.

Level of scrutiny

Low-to-moderate. The correctness hinges on flag scoping, which I traced: FnOnlyDataVisit is documented as saved/restored around nested functions but not arrows; visit_func at mod.rs:96 resets it via ..Default::default() (so is_new_target_undefined becomes false inside any real function/method/constructor), and e_arrow never touches it (so arrows inherit). The field-initializer path saves/restores the old value around visit_expr, mirroring the existing is_this_nested pattern at lines 1122/1223. The static-block path replaces ..Default::default() with an explicit two-field literal, which is exhaustive since the struct now has exactly two fields. Computed keys and extends are visited before the flag is set (mod.rs:1048 for extends; keys are visited outside the initializer block), so they correctly retain new.target — verified by the printer test at transpiler.test.js:3852.

Other factors

REVIEW.md's cache-version-bump rule is satisfied with a doc line matching the existing convention. The upstream WebKit PR is linked in the code comment as required. Test coverage hits the whole variant matrix (instance/static/private fields, static blocks, arrows-in-fields, class expressions, derived classes, auto-accessors) and the negative contract (constructor, function-in-field, function-in-static-block, computed keys, extends clause). Tests follow harness conventions: tempDir, bunExe()/bunEnv, Promise.all for pipe draining, stdout/stderr asserted before exitCode, .toEqual on a single result object, test.concurrent for the subprocess test. No CODEOWNERS cover the touched paths. Two earlier bot inline threads were followed by two subsequent commits, and the current diff is clean at those lines. Exit reason was dry_streak.

@robobun

robobun commented Oct 3, 2026

Copy link
Copy Markdown
Collaborator Author

Closing: this change is now a commit of #30936 (js_parser: substitute undefined for new.target in class field initializers and static blocks). The four parser PRs each bumped the transpiler cache version in src/jsc/RuntimeTranspilerCache.rs, so they are one PR now, with one bump. The code and the tests of this PR are in #30936 as they were.

@robobun robobun closed this Oct 3, 2026
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