Skip to content

[JSC] new.target in a class field initializer or a class static block throws a ReferenceError when the class is in an arrow function - #647

Open
robobun wants to merge 2 commits into
mainfrom
robobun/f92a6839/new-target-in-class-element
Open

robobun wants to merge 2 commits into
mainfrom
robobun/f92a6839/new-target-in-class-element

Conversation

@robobun

@robobun robobun commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • (() => { class A { x = new.target } })() in global or module code throws ReferenceError: Can't find private variable: PrivateSymbol.newTargetLocal on entry to the arrow function. The same for a static field, a static block, an async arrow function and indirect eval. The value must be undefined.
  • parseClass() parses a field initializer and a static block in place, with the tree builder of the code around the class (parser/Parser.cpp:3373, :3390). new.target there sets NewTargetFeature on that code (:5490). An arrow function or eval code with the feature loads @newTargetLocal on entry, and no function stored it. Upstream has the same code.

Fix

  • Parser::isDirectlyInClassElementParsedInPlace() walks up to the first function boundary. It is true for a class scope with the new isParsingFieldInitializerInPlace flag, and for a static block scope that is not the outermost scope.
  • Then parseMemberExpression() skips setInnerArrowFunctionUsesNewTarget() and createNewTargetExpr() does not record the feature.
  • Correct because each class element is parsed again, as its own function, when it is compiled. That parse records the feature on the right function.
  • Verified: the new JSTests/stress/new-target-in-class-field-initializer-and-static-block.js fails before and passes after. All 35 stress tests that contain new.target pass.

Background

  • A class field initializer and a class static block are functions of their own. They run as a method call, so new.target is undefined in them.
  • An arrow function has no new.target. It loads @newTargetLocal from the scope of the closest function, which stores it when an inner arrow function needs it.
  • NewTargetFeature comes from the ASTBuilder of the code that is compiled.
Notes

How each case fails before this change:

  • Field initializer, class in an arrow function: the class scope inherits isArrowFunction, so parseMemberExpression() calls setInnerArrowFunctionUsesNewTarget() on it, and createNewTargetExpr() sets NewTargetFeature on the arrow function. With a function around the arrow function the inner arrow function feature reaches that function, it stores its own new.target, and the arrow function loads a value that it never uses. Without a function, the load throws (emitLoadNewTargetFromArrowFunctionLexicalEnvironment() resolves with ThrowIfNotFound).
  • Static block: parseBlockStatement(context, BlockType::StaticBlock) pushes a scope with setSourceParseMode(ClassStaticBlockMode). That scope is a function boundary, so it is not an arrow function scope and it does not pass inner arrow function features up. The arrow function still gets NewTargetFeature, and no function stores @newTargetLocal. function f() { (() => { class A { static { new.target } } })() } throws.
  • Async arrow function: the load is in the prologue of the wrapper, so the call throws and no promise is returned.
  • Eval code: BytecodeGenerator(EvalNode*) loads @newTargetLocal when evalNode->needsNewTargetRegisterForThisScope(). Indirect eval, and direct eval in global code, have no function that stored it.
  • ProgramNode and ModuleProgramNode ignore the feature, so a class at the top level works. That is why JSTests/stress/class-fields-harmony.js (class C { c = new.target }) passes.

An arrow function in a class element (x = () => new.target) is not the in-place case: the walk stops at the arrow function scope. It keeps the inner arrow function feature in its SourceProviderCacheItem, and the function of the class element stores its new.target for it, as before.

The new Scope flag is the 31st one-bit member, so the layout that Scope::verifyLayout() checks does not change.

Test runs (linux x64, -O1, ENABLE_ASSERTS=ON, this change on cf1b36ec8703): the new test also passes with --useJIT=0, --useDFGJIT=0, low tier-up thresholds with --useConcurrentJIT=0, --validateBytecode=1, --forceDebuggerBytecodeGeneration=1 and --useBytecodeOptimizer=1. 224 stress tests with class, field, static-block, arrow or new-target in the name give the same result on the cf1b36ec8703 release jsc and on this build, except the new test. The expectations of the new test also hold on V8 (node 26.3.0, with drainMicrotasks replaced).

bun also substitutes undefined for these new.target in its transpiler, because a bundle has to run on a JavaScriptCore without this change too. The PR is linked in a comment.

… does not make the code around the class a user of new.target

A class field initializer and a class static block are functions of their own
at run time (ClassFieldInitializerMode, ClassStaticBlockMode). They run as a
method call, so new.target is undefined in them.

parseClass() parses the source of both in place, with the scopes and the tree
builder of the code around the class: parseAssignmentExpression(context) for a
field initializer and parseBlockStatement(context, BlockType::StaticBlock) for
a static block. That parse checks the syntax and finds the end. Each function
is parsed again, on its own, when it is compiled.

For new.target, parseMemberExpression() calls context.createNewTargetExpr(),
which sets NewTargetFeature on the code that the tree builder builds. In the
in-place parse that is the code around the class. It also calls
setInnerArrowFunctionUsesNewTarget() on the current scope when the scope
inherited isArrowFunction, which is true for a class scope in an arrow function.

BytecodeGenerator emits emitLoadNewTargetFromArrowFunctionLexicalEnvironment()
on entry to an arrow function, and to eval code, that
needsNewTargetRegisterForThisScope(). It resolves @newTargetLocal with
ThrowIfNotFound. So this throws "ReferenceError: Can't find private variable:
PrivateSymbol.newTargetLocal" when the arrow function is called:

    (() => { class A { x = new.target; } })();

No function is around the arrow function, so nothing stored @newTargetLocal.
The same happens for a static field, for a static block, for an async arrow
function (the call throws, the promise does not reject), and for
(0, eval)("class A { x = new.target }"). With a function around the arrow
function, a field works by accident: the class scope passes the inner arrow
function feature up to the function, and the function stores its new.target
for the arrow function to load. A static block scope is a function boundary
and does not pass the feature up, so this throws too:

    function f() { (() => { class A { static { new.target; } } })(); }

Add Parser::isDirectlyInClassElementParsedInPlace(). It walks up from the
current scope to the first function boundary. It returns true for a class
scope with the new isParsingFieldInitializerInPlace flag, which parseClass()
sets while it parses a field initializer, and for a static block scope that is
not the outermost scope of the parser. When it returns true,
parseMemberExpression() does not call setInnerArrowFunctionUsesNewTarget() and
createNewTargetExpr() does not record NewTargetFeature.

The parse of the class element as a function of its own is not in place, so it
records the feature on that function as before. new.target in an arrow
function in a class element is not changed either: the arrow function records
the inner arrow function feature, and the function of the class element
stores its (undefined) new.target for it.
@coderabbitai

coderabbitai Bot commented Sep 13, 2026 •

Copy link
Copy Markdown

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 6b40a7aa-e851-4064-bd57-bb2e05c1376d

📥 Commits

Reviewing files that changed from the base of the PR and between 161c962 and e283f89.

📒 Files selected for processing (1)
  • JSTests/stress/new-target-in-class-field-initializer-and-static-block.js

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


Walkthrough

The parser now distinguishes class elements parsed in place from enclosing code when handling new.target. Expression construction records whether an enclosing target is used. Stress tests cover synchronous, asynchronous, nested, eval, inheritance, and constructor cases.

Changes

Class element new.target handling

Layer / File(s) Summary
Class element parsing context
Source/JavaScriptCore/parser/Parser.h, Source/JavaScriptCore/parser/Parser.cpp
Scope records in-place class element parsing. Parser logic identifies class fields and static blocks while excluding nested function boundaries and arrow functions.
Conditional new.target usage tracking
Source/JavaScriptCore/parser/SyntaxChecker.h, Source/JavaScriptCore/parser/ASTBuilder.h, Source/JavaScriptCore/parser/Parser.cpp
createNewTargetExpr accepts a capture flag. Parser handling uses the flag to distinguish class-element new.target from inherited targets.
Behavioral stress coverage
JSTests/stress/new-target-in-class-field-initializer-and-static-block.js
The stress test covers class fields, static blocks, nested constructs, eval, async arrows, constructors, inheritance, computed names, private fields, and function constructors.

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to e283f

No merge-blocking issue has been identified from the available evidence.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the main bug: new.target throws a ReferenceError in class field initializers or static blocks inside arrow functions. It is specific and related to the changes.
Description check ✅ Passed The description is detailed and on topic. It explains the problem, root cause, fix, expected behavior, affected cases, implementation details, and test results. It does not include the Bugzilla URL, r…
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.

Warning

Git: CodeRabbit could not clone the repository, so clone-backed analysis was skipped and this review may be incomplete. Verify repository clone access, such as SSH credentials, before requesting another full review. If clone access is intentionally unavailable, use path_filters to narrow the review scope.


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

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline finding, I checked that the new m_isParsingFieldInitializerInPlace bit is the 31st one-bit Scope member, so the packed group stays within 4 bytes and Scope::verifyLayout() offsets do not shift. I also confirmed the flag is cleared before failIfFalse(initializer, ...), so a parse error in the initializer does not leave it set on the class scope, and that isDirectlyInClassElementParsedInPlace() stops at an arrow-function boundary and only treats a static-block boundary as in-place when it has a containing scope — so the reparse-as-own-function pass and arrows nested inside a field initializer keep their existing attribution.

Extended reasoning...

The posted inline finding is a test-convention nit (hard-coded loop count vs testLoopCount), not a correctness concern with the parser change itself. While reading the diff I separately checked three things a JSC reviewer would likely want confirmed: (1) the new one-bit Scope member does not overflow the packed bit-field group and shift m_lexicalVariables past the cache-line assertion in Scope::verifyLayout(); (2) the manual set/reset of the flag around parseAssignmentExpression is safe on the error path because the reset precedes failIfFalse; (3) the scope walk in isDirectlyInClassElementParsedInPlace() returns false for an arrow function nested inside a field initializer (function boundary that is not a static-block boundary) and for a static block that is the outermost scope of its own reparse (containingScope() is null), so only the genuinely in-place cases suppress the NewTargetFeature. None of these turned up a problem, so I noted them as ruled out rather than staying silent, since the inline nit alone would not convey that the parser logic was examined.

Comment thread JSTests/stress/new-target-in-class-field-initializer-and-static-block.js Outdated
@github-actions

github-actions Bot commented Sep 13, 2026 •

Copy link
Copy Markdown

Preview Builds

Commit Release Date
e283f89c autobuild-preview-pr-647-e283f89c 2026-09-13 19:09:57 UTC
161c9622 autobuild-preview-pr-647-161c9622 2026-09-13 12:48:22 UTC

The change is in the parser, so a case passes or fails on its first run.
No loop count is needed.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Thanks for dropping the hard-coded iteration loops in the stress test — that resolves the earlier nit (this bug reproduces at parse/first-execution, so single-shot assertions are fine). I re-reviewed after the new push and didn't find any bugs; because the change alters new.target feature-bit propagation in the parser scope chain, a JSC reviewer should still give it a look.

What was reviewed:

  • isDirectlyInClassElementParsedInPlace() scope walk — stops at arrow-function boundaries (isFunctionBoundary() is true for arrows), so x = () => new.target still marks the class element correctly on re-parse.
  • Static-block detection via containingScope() — outermost re-parse scope has no parent, in-place scope does; matches the intent.
  • SetForScope-free toggle of m_isParsingFieldInitializerInPlace — reset happens before any failIfFalse early return, so no stale state on parse failure.
  • Computed keys / heritage still use the surrounding code's new.target (flag is only set around the initializer expression, not the whole class element).
Extended reasoning...

Overview

The PR fixes a JSC parser bug where new.target inside a class field initializer or static block incorrectly marks the surrounding code (arrow function / eval) as a new.target user, causing a ReferenceError on @ newTargetLocal load. It adds a per-Scope bit (m_isParsingFieldInitializerInPlace), a scope-walk helper isDirectlyInClassElementParsedInPlace(), and threads a usesNewTargetOfThisCode flag into ASTBuilder::createNewTargetExpr / SyntaxChecker::createNewTargetExpr. A comprehensive stress test covers instance/static/private/computed fields, static blocks, nested arrows/classes, eval, async arrows, non-simple parameters, and computed keys/heritage. Since the prior review, only the test file changed: the three for (let i = 0; i < 100; ++i) loops were removed in favor of single-shot assertions, which addresses the earlier testLoopCount nit and is appropriate given the bug is parse-time, not tier-dependent.

Security risks

None identified. This is a correctness fix in the parser's feature-bit bookkeeping; it does not touch auth, crypto, permissions, or memory management. The new Scope bit is the 31st single-bit member and does not perturb the verifyLayout() invariant per the PR notes. No untrusted input handling changes — the parser already handled these tokens; only the propagation of a boolean feature flag changes.

Level of scrutiny

Moderate-to-high. Parser scope-chain semantics in JSC are subtle: the fix relies on the invariant that field initializers and static blocks are re-parsed as standalone functions at compile time (so suppressing the feature bit during the in-place parse is safe), and on isFunctionBoundary() returning true for arrow functions so the walk stops before reaching the class scope when inside a nested arrow. I traced these and they hold, but this is exactly the kind of change where a JSC-domain reviewer's confirmation is valuable. The changed paths (Source/JavaScriptCore/**, JSTests/**) are also listed under @ WebKit/jsc-reviewers in .github/CODEOWNERS.

Other factors

The stress test is thorough and, per the author, verified to fail before the fix and pass after (including under --useJIT=0, --validateBytecode=1, etc.) and cross-checked against V8. The setIsParsingFieldInitializerInPlace(false) reset occurs before the failIfFalse(initializer, ...) bailout, so a failed initializer parse does not leave the class scope in a stale state. The SyntaxChecker stub was updated in lockstep with ASTBuilder. No outstanding third-party CHANGES_REQUESTED reviews are on the timeline.

@robobun

robobun commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author

The bun side is oven-sh/bun#42653. It substitutes undefined for these new.target in the transpiler. It does not depend on this PR: a bundle has to run on a JavaScriptCore without this change too, and two lowerings in bun move a field initializer into the constructor. This PR is still needed for code that the transpiler does not see (eval, new Function, node:vm).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant