Skip to content
56 changes: 56 additions & 0 deletions changelog.d/8630-class-semantics-tail.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
Completed the class-semantics tail from #5893 (167/167 on the issue's Test262
worklist): derived construction and `super` for dynamic functions and native
built-in subclasses (return overrides, `new.target`, prototype identity),
private instance/static element branding and dispatch across fresh class
evaluations, proxies, accessors and extracted methods, and the remaining
computed/static class element, property-key and constructor/prototype corners.
Concentrated class runtime logic was split into focused source fragments to stay
under the 2000-line file cap.

Two spec-ordering fixes are worth calling out because they change observable
behaviour:

- `Object.defineProperty` no longer runs `[[Set]]`. `define_property_force_store_value`
used to funnel through `js_object_set_field_by_name`, which performs inherited
setter lookup; DefineProperty must write the receiver's own slot. It now
ensures the shape entry exists and stores by slot index (or through overflow
storage).
- `[[Set]]` honours OrdinarySet step 1: an own data property on the receiver
shadows an inherited class accessor, so the class-vtable setter walk in
`set_field_by_name_object_tail` only applies when the receiver has no own
property of that name. Node 26.5.1, on the exact production path
(`Object.assign` funnels into `js_object_set_field_by_name`):
`class A { x = 1; set x(v){} }` + `Object.assign(a, {x: 7})` stores 7 and
never calls the setter, while the same assignment on a class that only
declares the accessor still dispatches it (the #486 hono `set res(_res)`
shape). `typed_feedback_class_field_set_guard_falls_back_for_class_setter`
asserted the pre-spec behaviour and now covers both halves. The own-key probe
is evaluated last and only on the slow path, so a store-plan hit does not pay
for the keys-array scan.

Follow-up fixes on top of the original branch:

- `normalize_swc_class_syntax`'s tokenizer walked `masked.as_bytes()` and
advanced its cursor by a raw byte, then sliced the `&str`. Ordinary source
such as `const re = /a€b/;` panicked with "byte index N is not a char
boundary". It now advances by whole characters; a regression test covers a
non-ASCII regex literal, identifier and array literal, plus the
source/masked boundary map with non-ASCII text ahead of a rewritten token.
- `native_module.rs` had grown to 2018 lines, over the repository cap. The
class constructor/prototype ref values and their prototype-method lookups
moved to `native_module/class_ref_values.rs` (textually `include!`d, like
`class_method_values.rs`, so module paths and visibility are unchanged).
- #7341 shapes restored: `define_property_force_store_value` re-reads the
receiver and key through nested `across_mut`/`across_const` around
`ensure_key_in_keys_array` instead of two bare handle reads, and
`js_weak_collection_subclass_init` passes the entries array through
`with_mut_ptr` as a scoped argument. Raw-handle debt back to 925.
- The two new open-coded `StringHeader` payload offsets use the existing
readers (`string_key_eq`, `crate::string::string_data`) rather than
re-deriving the offset.
- Three new `perry_thread_local!` holders are pinned on the GC root-holder
frontier ratchet with their research: `PRIVATE_METHOD_OWNER_HINT` and
`PRIVATE_MEMBER_ACCESS_HINTS` hold only `u32`/`String`/`bool` owned data, and
`DERIVED_SUPER_BINDING_STACK` holds the derived constructor's own i1 ALLOCA
address — a native stack address, which does not move under GC — bounded by
its push/pop and savepoint/restore pairs.
29 changes: 29 additions & 0 deletions crates/perry-codegen/src/codegen/closure.rs
Original file line number Diff line number Diff line change
Expand Up @@ -758,6 +758,23 @@ pub(super) fn compile_closure(
);
let v = blk.bitcast_i64_to_double(&bits);
blk.store(DOUBLE, &v, &slot);
} else if let Some(class_id) = enclosing_class
.as_ref()
.and_then(|class_name| class_ids.get(class_name))
.copied()
.filter(|class_id| *class_id != 0)
{
// Static field initialization substitutes lexical `this` with the
// class constructor and then drops the ordinary this-capture slot.
// `super.x` encodes its receiver implicitly, though, so there is no
// Expr::This node for that substitution to rewrite. Seed the
// closure's synthetic this slot with the enclosing ClassRef rather
// than the old 0.0 sentinel so arrows in static fields retain the
// class constructor as their SuperProperty receiver.
let class_ref = crate::nanbox::double_literal(f64::from_bits(
crate::nanbox::INT32_TAG | class_id as u64,
));
blk.store(DOUBLE, &class_ref, &slot);
} else {
blk.store(DOUBLE, "0.0", &slot);
}
Expand Down Expand Up @@ -934,6 +951,18 @@ pub(super) fn compile_closure(
this_stack,
new_target_stack,
class_stack,
super_called_stack: Vec::new(),
shared_super_scope_active: false,
lexical_this_uses_derived_binding: captures_this
&& enclosing_class
.as_ref()
.and_then(|name| classes.get(name).copied())
.is_some_and(|class| {
class.extends.is_some()
|| class.extends_name.is_some()
|| class.native_extends.is_some()
|| class.extends_expr.is_some()
}),
inline_ctor_return: Vec::new(),
methods,
module_globals,
Expand Down
6 changes: 6 additions & 0 deletions crates/perry-codegen/src/codegen/entry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -783,6 +783,9 @@ pub(super) fn compile_module_entry(
pending_labels: Vec::new(),
classes,
this_stack: Vec::new(),
super_called_stack: Vec::new(),
shared_super_scope_active: false,
lexical_this_uses_derived_binding: false,
inline_ctor_return: Vec::new(),
new_target_stack: Vec::new(),
class_stack: Vec::new(),
Expand Down Expand Up @@ -1473,6 +1476,9 @@ pub(super) fn compile_module_entry(
pending_labels: Vec::new(),
classes,
this_stack: Vec::new(),
super_called_stack: Vec::new(),
shared_super_scope_active: false,
lexical_this_uses_derived_binding: false,
inline_ctor_return: Vec::new(),
new_target_stack: Vec::new(),
class_stack: Vec::new(),
Expand Down
3 changes: 3 additions & 0 deletions crates/perry-codegen/src/codegen/function.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1032,6 +1032,9 @@ pub(super) fn compile_function(
pending_labels: Vec::new(),
classes,
this_stack: Vec::new(),
super_called_stack: Vec::new(),
shared_super_scope_active: false,
lexical_this_uses_derived_binding: false,
inline_ctor_return: Vec::new(),
new_target_stack: Vec::new(),
class_stack: Vec::new(),
Expand Down
110 changes: 106 additions & 4 deletions crates/perry-codegen/src/codegen/method.rs
Original file line number Diff line number Diff line change
Expand Up @@ -485,6 +485,9 @@ pub(super) fn compile_method(
pending_labels: Vec::new(),
classes,
this_stack: vec![this_slot],
super_called_stack: Vec::new(),
shared_super_scope_active: false,
lexical_this_uses_derived_binding: false,
inline_ctor_return: Vec::new(),
new_target_stack: Vec::new(),
class_stack: vec![class.name.clone()],
Expand Down Expand Up @@ -728,6 +731,14 @@ pub(super) fn compile_method(
// as uninitialized register values (read as NaN-boxed undefined).
let is_constructor_method = method.name == format!("{}_constructor", class.name);
if is_constructor_method {
if class.extends.is_some()
|| class.extends_name.is_some()
|| class.native_extends.is_some()
|| class.extends_expr.is_some()
{
crate::expr::this_super_call::push_shared_super_called_slot(&mut ctx);
ctx.shared_super_scope_active = true;
}
// Stage field initializers around the parent body chain so leaf
// fields can read state set by parent body (Refs #420):
// - has extends: apply only ancestors here; self-fields apply
Expand Down Expand Up @@ -953,7 +964,7 @@ pub(super) fn compile_method(
}
// Load `this` from the this_stack.
let this_slot = ctx.this_stack.last().cloned();
let this_box = if let Some(slot) = this_slot {
let this_box = if let Some(ref slot) = this_slot {
ctx.block().load(DOUBLE, &slot)
} else {
undef_lit.clone()
Expand All @@ -973,7 +984,20 @@ pub(super) fn compile_method(
// real signature (see codegen/mod.rs).
ctx.pending_declares
.push((ctor_sym.clone(), DOUBLE, ctor_param_types));
let _ = ctx.block().call(DOUBLE, &ctor_sym, &ctor_args);
let parent_result = ctx.block().call(DOUBLE, &ctor_sym, &ctor_args);
if let Some(this_slot) = this_slot {
let current_this = ctx.block().load(DOUBLE, &this_slot);
let bound_this = ctx.block().call(
DOUBLE,
"js_ctor_return_override",
&[
(DOUBLE, &current_this),
(DOUBLE, &parent_result),
(crate::types::I32, "0"),
],
);
ctx.block().store(DOUBLE, &bound_this, &this_slot);
}
}
}
}
Expand Down Expand Up @@ -1049,7 +1073,7 @@ pub(super) fn compile_method(
Some(slot) => ctx.block().load(DOUBLE, &slot),
None => undef_lit.clone(),
};
let _ = ctx.block().call(
let parent_result = ctx.block().call(
DOUBLE,
"js_fetch_or_value_super",
&[
Expand All @@ -1059,9 +1083,28 @@ pub(super) fn compile_method(
(I64, &args_len),
],
);
if let Some(this_slot) = ctx.this_stack.last().cloned() {
let current_this = ctx.block().load(DOUBLE, &this_slot);
let bound_this = ctx.block().call(
DOUBLE,
"js_ctor_return_override",
&[
(DOUBLE, &current_this),
(DOUBLE, &parent_result),
(crate::types::I32, "0"),
],
);
ctx.block().store(DOUBLE, &bound_this, &this_slot);
}
}
}

// The synthesized default derived constructor has now completed
// its implicit `super(...arguments)` path. Publish that fact to
// both this standalone function and any arrow closures before
// evaluating the class's own instance fields.
crate::expr::this_super_call::bind_derived_this_after_super(&mut ctx);

// Apply self field initializers AFTER the parent body chain has
// run, so they can read state set by the parent body (e.g. drizzle's
// PgText.enumValues = this.config.enumValues — this.config is set
Expand Down Expand Up @@ -1102,6 +1145,31 @@ pub(super) fn compile_method(
&& !crate::lower_call::ctor_body_uses_this(&ctor.body))
&& !crate::lower_call::ctor_body_has_value_return(&ctor.body)
});
// Standalone constructor symbols use the same internal completion slot as
// an inlined `new`: every explicit/bare return funnels to one block, where
// constructor return-override semantics are applied against the CURRENT
// `this` binding. This matters for a derived `super()` whose base returns
// a replacement object — an implicit/bare return must publish that object
// to the caller, not `undefined` (which would make the caller retain its
// original pre-super allocation).
let standalone_ctor_return = if is_constructor_method && !ctor_no_super_throw {
let result_slot = ctx.func.alloca_entry(DOUBLE);
let undef = crate::nanbox::double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED));
ctx.block().store(DOUBLE, &undef, &result_slot);
let after_idx = ctx.new_block("standalone.ctor.return.after");
let target = crate::expr::InlineCtorReturn {
result_slot,
after_label: ctx.block_label(after_idx),
is_derived: class.extends.is_some()
|| class.extends_name.is_some()
|| class.native_extends.is_some()
|| class.extends_expr.is_some(),
};
ctx.inline_ctor_return.push(target.clone());
Some((target, after_idx))
} else {
None
};
if ctor_no_super_throw {
ctx.block()
.call(DOUBLE, "js_throw_reference_error_this_before_super", &[]);
Expand All @@ -1119,16 +1187,47 @@ pub(super) fn compile_method(
})?;
}

if let Some((target, after_idx)) = standalone_ctor_return.as_ref() {
let _ = ctx
.inline_ctor_return
.pop()
.expect("standalone constructor return target");
if !ctx.block().is_terminated() {
ctx.block().br(&target.after_label);
}
ctx.current_block = *after_idx;
}

if !ctx.block().is_terminated() {
let undef = crate::nanbox::double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED));
let return_value = if let Some((target, _)) = standalone_ctor_return.as_ref() {
let raw = ctx.block().load(DOUBLE, &target.result_slot);
let this_value = ctx
.this_stack
.last()
.cloned()
.map(|slot| ctx.block().load(DOUBLE, &slot))
.unwrap_or_else(|| undef.clone());
crate::lower_call::emit_ctor_return_override(
&mut ctx,
&this_value,
&raw,
target.is_derived,
)
} else {
undef.clone()
};
if ctx.shared_super_scope_active {
ctx.block().call_void("js_derived_super_scope_pop", &[]);
}
if method.is_async {
let handle = ctx
.block()
.call(I64, "js_promise_resolved", &[(DOUBLE, &undef)]);
let boxed = crate::expr::nanbox_pointer_inline_pub(ctx.block(), &handle);
ctx.block().ret(DOUBLE, &boxed);
} else {
ctx.block().ret(DOUBLE, &undef);
ctx.block().ret(DOUBLE, &return_value);
}
}
let ic_globals = std::mem::take(&mut ctx.ic_globals);
Expand Down Expand Up @@ -1600,6 +1699,9 @@ pub(super) fn compile_static_method(
pending_labels: Vec::new(),
classes,
this_stack: vec![this_slot],
super_called_stack: Vec::new(),
shared_super_scope_active: false,
lexical_this_uses_derived_binding: false,
inline_ctor_return: Vec::new(),
new_target_stack: Vec::new(),
// A static method's `this` is the class constructor (bound above to
Expand Down
35 changes: 35 additions & 0 deletions crates/perry-codegen/src/codegen/string_pool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1055,6 +1055,41 @@ pub(super) fn emit_string_pool(
],
);
}
// Class refs are immediate values, not heap Function objects. Register
// each constructor's visible arity so the field-get path can reify the
// Function-compatible own `length` property.
let mut class_lengths: Vec<(u32, u32)> = classes
.iter()
.filter(|(class_name, class)| *class_name == &class.name && class.id != 0)
.filter_map(|(class_name, class)| {
let cid = class_ids.get(class_name).copied()?;
let length = class
.constructor
.as_ref()
.map(|ctor| {
ctor.params
.iter()
.take_while(|p| {
!p.is_rest && p.default.is_none() && !p.name.starts_with("__perry_cap_")
})
.count() as u32
})
.unwrap_or(0);
Some((cid, length))
})
.collect();
class_lengths.sort_unstable_by_key(|(cid, _)| *cid);
class_lengths.dedup_by_key(|(cid, _)| *cid);
for (cid, length) in class_lengths {
chunker.roll_if_full();
chunker.current_block().call_void(
"js_register_class_length",
&[
(crate::types::I32, &cid.to_string()),
(crate::types::I32, &length.to_string()),
],
);
}

// Refs #486 (hono logger middleware): also register every class
// getter in the runtime VTABLE_REGISTRY. Without this, cross-module
Expand Down
9 changes: 6 additions & 3 deletions crates/perry-codegen/src/collectors/refs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -906,15 +906,18 @@ pub fn collect_ref_ids_in_expr(e: &perry_hir::Expr, out: &mut HashSet<u32>) {
}
Expr::ClassExprFresh {
named_statics,
symbol_statics,
computed_keys,
computed_statics,
captured_args,
..
} => {
for (_, v) in named_statics {
walk(v, out);
}
for (k, v) in symbol_statics {
walk(k, out);
for (_, key) in computed_keys {
walk(key, out);
}
for (_, v) in computed_statics {
walk(v, out);
}
for a in captured_args {
Expand Down
2 changes: 2 additions & 0 deletions crates/perry-codegen/src/eh_mode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ pub(crate) fn callee_is_nothrow(name: &str) -> bool {
| "js_get_exception"
| "js_clear_exception"
| "js_has_exception"
| "js_derived_super_scope_push"
| "js_derived_super_scope_pop"
)
|| !crate::module::helper_decl_attrs(name).is_empty()
}
Expand Down
Loading
Loading