Skip to content

fix(engine): run derived class field initializers after super() - #992

Draft
simonyang08 wants to merge 3 commits into
trynova:mainfrom
simonyang08:codex/nova-948-class-field-init-derived
Draft

simonyang08 wants to merge 3 commits into
trynova:mainfrom
simonyang08:codex/nova-948-class-field-init-derived

Conversation

@simonyang08

@simonyang08 simonyang08 commented Sep 6, 2026

Copy link
Copy Markdown

Fixes #948

A user-written derived class constructor that declared instance fields used to throw ReferenceError: Uninitialized this binding because the field-initializer prelude was emitted at the start of the constructor body, before super() had bound this.

class A {}
class B extends A {
  b = 2
  constructor() { super() }
}
new B() // previously: ReferenceError: Uninitialized this binding

Change

For derived classes, the field-initializer prelude is built as a separate executable and attached to the constructor body executable's existing class_initializer_bytecodes slot (no new fields on ECMAScriptFunctionHeapData or FunctionExpression), and is invoked from EvaluateSuper after super() has bound this, mirroring how default constructors already behave. Slot 0 of the body executable's vec is reserved for this entry before the body compiles, since nested class definitions push their own default-constructor entries into the same vec. Base-class constructors keep the existing prelude-inside-body path because OrdinaryCallBindThis runs before the user body.

The new helper initialize_ecmascript_function_class_field_initializers mirrors initialize_instance_elements (used by default constructors) so future field-init environment changes should apply to both paths.

Regression test

tests/class-field-init-in-derived.js covers the original issue repro, single/multi-field cases, grand-child fields, a base-class no-regression check, field visibility after super() inside the constructor, and two nested-class cases (implicit default constructor and nested derived constructor) that pin the slot-0 invariant.

Verification

  • cargo build --bin nova_cli --profile dev-fast — clean
  • cargo clippy --bin nova_cli --profile dev-fast — 0 warnings
  • cargo fmt --check — clean
  • Repro script: ReferenceError before the change → completes normally after
  • Workspace unit tests: all green (0 failed across every binary)
  • Full test262 sweep NOT run (submodule not initialized in this environment) — worth running in CI before merge

Scope note

This fix threads state through the bytecode compiler, VM, and executable structures (4 files changed, +252/−29), but it is a single semantic change and reuses the heap-tracked class_initializer_bytecodes storage that default constructors already use, so no new GC plumbing is introduced.

Signed-off-by: simonyang08 ppt5928@gmail.com

…ova#948)

A user-written derived class constructor that declared instance fields used
to throw ReferenceError: Uninitialized this binding because the field
initializer prelude was emitted at the start of the constructor body,
before super() had bound this.

For derived classes, the prelude is now built as a separate executable
and stored on the function. It is invoked from step 11 of EvaluateSuper
after super() has bound this, mirroring the behaviour of default
constructors. Base-class constructors keep the existing
prelude-inside-body path because OrdinaryCallBindThis runs before the
user body and so this is already initialized.

Includes a regression script under tests/ that covers the original
issue, single/multi-field cases, grand-child fields, and a base-class
no-regression check.

Signed-off-by: simonyang08 <ppt5928@gmail.com>

@aapoalas aapoalas left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Hello and welcome to the project! Thank you for the interest in this - this is a really hard bug to fix I must say, but also one of the most important ones and one that I've had in the back of my mind for a good long while :)

Unfortunately, I don't think this direction of attack is acceptable. We cannot grow the ECMAScriptFunctionHeapData object for this, as this is a fairly uncommon feature to be used. I think the optimal solution would be for the initializers to "get swallowed up" by the constructor bytecode itself, but that is non-trivial. The ExecutableHeapData does already contain a class_initializer_bytecodes field which could possibly be reused here to avoid the large-ish refactoring needed for the (possibly) optimal solution, but it may not be an entirely trivial choice either.

) -> JsResult<'a, ()> {
// Read everything we need before mutating the agent.
let bytecode = f.get(agent).class_field_initializer_bytecode;
let bytecode = match bytecode {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

issue: b.unbind() is definitely wrong - all GC handles must be bound using .bind(gc.nogc()) and only unbound in special cases (eg. when having to unbind them temporarily for let gc = gc.into_nogc() reasons).

You can find some examples about this in https://trynova.dev/blog/guide-to-nova-gc

/// For a user-written derived class constructor with instance fields,
/// holds the compiled bytecode that initializes those fields. It is run
/// after `super()` has bound `this` (from `EvaluateSuper` step 11).
pub(crate) class_field_initializer_bytecode: Option<Executable<'a>>,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

issue: Under no circumstances can we add 4 bytes to every function for this. A different solution must be found.

// `OrdinaryCallBindThis` runs before the user body and so
// `this` is already initialized.
if has_constructor_parent {
let initializer_executable = constructor_ctx.finish();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

thought: I'd honestly prefer to build the initializers as part of the constructor bytecode directly. That would probably need one or two new VM bytecode instructions but that's kinda cheap still.

The harder part is the refactoring of moving the initialization bytecode compilation out of ClassDefinitionEvaluation and into the super() instruction.

…s for derived ctor field prelude

Per maintainer review of trynova#992: do not grow ECMAScriptFunctionHeapData
for the deferred class field initializer. The compiler now attaches
the deferred field initializer executable to the body executable's
existing class_initializer_bytecodes slot (already heap-tracked on
ExecutableHeapData). EvaluateSuper looks it up via the running
function's compiled_bytecode.class_initializer_bytecodes.

Removes the new ECMAScriptFunctionHeapData.class_field_initializer_bytecode
and FunctionExpression.class_field_initializer_bytecode fields plus their
accompanying setters, and trims the net diff vs main.

Signed-off-by: simonyang08 <ppt5928@gmail.com>
…ing the constructor body

Class definitions nested inside a derived constructor push their own
default-constructor initializer entries into the same
class_initializer_bytecodes vec while the body compiles, which could
displace the deferred field-initializer entry from index 0 and break
the EvaluateSuper lookup. Reserve slot 0 for the field-initializer
executable before compiling the body, document the invariant, and add
nested-class regression cases covering both the implicit-default-
constructor and nested-derived-constructor variants.

Signed-off-by: simonyang08 <ppt5928@gmail.com>
@simonyang08

Copy link
Copy Markdown
Author

Thank you for the review and for pointing at ExecutableHeapData.class_initializer_bytecodes — the rework is pushed.

What changed vs the previous head:

  • ECMAScriptFunctionHeapData is no longer grown: the new class_field_initializer_bytecode field (and its FunctionExpression counterpart, setters, and GC mark/sweep plumbing) is removed entirely.
  • For a user-written derived-class constructor, the deferred field-initializer executable is now attached to the constructor body executable's existing class_initializer_bytecodes slot via add_class_initializer_bytecode, which is already heap-tracked on ExecutableHeapData.
  • EvaluateSuper reads the entry at index 0 of the body executable's slot. The compiler reserves slot 0 for this entry before compiling the constructor body, because class definitions nested inside the body push their own default-constructor entries into the same vec — two regression cases covering both nested variants (implicit default constructor and nested derived constructor) were added to tests/class-field-init-in-derived.js.
  • The 'initializers swallowed by the constructor bytecode' direction seemed clearly better long-term but needs control-flow splicing in the bytecode stream, so we went with the slot-reuse interim you suggested.

Net diff vs main shrank from +233/−30 to +199/−29 (9 files changed locally for the rework). cargo build/clippy/fmt clean, the issue repro and the test file pass, and the workspace test suite is green. test262 was not run locally (submodule not initialized) — worth a CI sweep before merge.

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.

class field initializers are broken in subclasses

3 participants