Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions changelog.d/11525-subclass-species-inherited.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
A user subclass of a built-in now inherits the built-in's
`get [Symbol.species]` accessor: `class X extends Array {}` (and `Map`, `Set`,
`Promise`, `RegExp`, `ArrayBuffer`, `Uint8Array`, …, including indirect
subclasses and class expressions) answers `X[Symbol.species] === X`. #11338
installed the accessor on the built-in constructors but left this case
`undefined`: a class ref's symbol lookup walked user ancestors, class-expression
parents and function parents, but never the built-in constructor its chain
ends in, because a built-in parent is recorded only as a reserved class id.
The lookup now takes one more step, `builtin_parent_ctor_in_chain`
(`object/class_registry/state.rs`). It reads the parent value
`js_register_class_parent_dynamic` already stashes at definition time and reads
the symbol off that constructor with the original receiver, so the inherited
getter answers the subclass. Prototype refs are excluded. This is the last open
part of #11193.

With the species found, RegExp `split` and `matchAll` construct their matcher
through the subclass. That exposed an older gap. A no-own-constructor class
whose chain ends at an exotic built-in (`RegExp`, `ArrayBuffer`, the typed
arrays) has a synthesized standalone constructor, and that constructor skipped
the built-in as an uncallable base. So every dynamic construct of such a class
(`new (R as any)(…)`, `Reflect.construct`, a species `Construct`) got a plain
object with no `[[RegExpMatcher]]` or buffer, and `"a,b".split(new R(","))`
would have thrown "RegExp builtin exec requires a RegExp receiver". The
synthesized constructor now emits the built-in's own Construct with the class as
newTarget (`js_builtin_subclass_construct`, `codegen/method.rs`), matching the
inline `new R()` lowering and an explicit `super()`. The walk is
`exotic_builtin_base_in_chain` (`lower_call/new_helpers.rs`), which shares the
ctor-less walk with `native_instance_base_in_chain`. Class fields still
initialize on the constructed instance.

Still open, and unchanged by this: the Array and typed-array species consumers
(`map`/`filter`/`slice`, …) still return plain results for subclass instances,
because their default fast paths never consult `constructor`. A dynamic
`new (A as any)(3)` of an `Array` subclass still has length 0.

Covered by `test-files/test_gap_11193_subclass_species_inherited.ts`.
69 changes: 69 additions & 0 deletions crates/perry-codegen/src/codegen/method.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1292,6 +1292,75 @@ pub(super) fn compile_method(
}
}

// #11193: the implicit `super(...args)` of a no-own-ctor class whose
// ctor-less chain ends at an exotic built-in (`class R extends
// RegExp {}`) is the built-in's own Construct with this class as
// newTarget — exactly what the inline `new R()` lowering and an
// explicit `super()` emit (`js_builtin_subclass_construct`). This
// standalone symbol is the body every dynamic construct replays
// (`new (R as any)(…)`, `Reflect.construct`, and a species
// `Construct` such as RegExp `split`), and it skipped the base as an
// uncallable builtin, so those got a plain object with no
// `[[RegExpMatcher]]` / buffer / typed-array slots.
if builtin_parent_runtime.is_none() && local_parent_ctor.is_none() {
if let Some(base) = crate::lower_call::exotic_builtin_base_in_chain(&ctx, class) {
let base = base.to_string();
let undef_lit =
crate::nanbox::double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED));
let mut forwarded: Vec<String> = Vec::with_capacity(method.params.len());
for fp in &method.params {
match ctx.locals.get(&fp.id).cloned() {
Some(slot) => {
let loaded = ctx.block().load(DOUBLE, &slot);
forwarded.push(loaded);
}
None => forwarded.push(undef_lit.clone()),
}
}
let (args_ptr, args_len) = if forwarded.is_empty() {
("null".to_string(), "0".to_string())
} else {
let buf = ctx.func.alloca_entry_array(DOUBLE, forwarded.len());
for (index, value) in forwarded.iter().enumerate() {
let slot = ctx.block().gep(DOUBLE, &buf, &[(I64, &index.to_string())]);
ctx.block().store(DOUBLE, value, &slot);
}
let ptr = ctx.block().next_reg();
ctx.block().emit_raw(format!(
"{} = getelementptr [{} x double], ptr {}, i64 0, i64 0",
ptr,
forwarded.len(),
buf
));
(ptr, forwarded.len().to_string())
};
let class_id = ctx
.class_ids
.get(&class.name)
.copied()
.unwrap_or(0)
.to_string();
let name_idx = ctx.strings.intern(&base);
let entry = ctx.strings.entry(name_idx);
let name_bytes = format!("@{}", entry.bytes_global);
let name_len = entry.byte_len.to_string();
let constructed = ctx.block().call(
DOUBLE,
"js_builtin_subclass_construct",
&[
(crate::types::I32, &class_id),
(crate::types::PTR, &name_bytes),
(I64, &name_len),
(crate::types::PTR, &args_ptr),
(I64, &args_len),
],
);
if let Some(this_slot) = ctx.this_stack.last().cloned() {
ctx.block().store(DOUBLE, &constructed, &this_slot);
}
}
}

// #10300: a no-own-ctor class whose chain reaches one of the native
// bases perry stamps onto the INSTANCE (`EventEmitter`, `Map`/`Set`,
// `Event`/`CustomEvent`, `AsyncLocalStorage`, ...) gets that surface
Expand Down
3 changes: 2 additions & 1 deletion crates/perry-codegen/src/lower_call/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,8 @@ pub(crate) use new_helpers::{
// `expr/this_super_call.rs`, which are the two places a derived constructor can
// reach the base.
pub(crate) use new_helpers::{
emit_native_instance_base_init, native_instance_base_in_chain, NativeInstanceBase,
emit_native_instance_base_init, exotic_builtin_base_in_chain, native_instance_base_in_chain,
NativeInstanceBase,
};
// `extract_options_fields` is consumed by `expr.rs` as
// `crate::lower_call::extract_options_fields` — keep that path stable.
Expand Down
40 changes: 39 additions & 1 deletion crates/perry-codegen/src/lower_call/new_helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,44 @@ pub(crate) fn native_instance_base_in_chain(
ctx: &FnCtx<'_>,
class: &Class,
) -> Option<NativeInstanceBase> {
ctorless_builtin_base(ctx, class).and_then(native_instance_base)
}

/// The exotic built-in base (`RegExp`, `ArrayBuffer`, the typed arrays) a
/// no-own-ctor class constructs through its implicit `super(...args)`, found by
/// the same ctor-less walk as [`native_instance_base_in_chain`]. Its instances
/// carry internal slots that cannot be stamped onto Perry's provisional object,
/// so the base's own `Construct` must produce `this` (#11193).
/// `SharedArrayBuffer` is absent: the synthesized constructor already reaches it
/// through the dynamic-parent super dispatch.
pub(crate) fn exotic_builtin_base_in_chain<'c>(
ctx: &FnCtx<'c>,
class: &'c Class,
) -> Option<&'c str> {
ctorless_builtin_base(ctx, class).filter(|name| {
matches!(
*name,
"RegExp"
| "ArrayBuffer"
| "Int8Array"
| "Uint8Array"
| "Uint8ClampedArray"
| "Int16Array"
| "Uint16Array"
| "Int32Array"
| "Uint32Array"
| "Float32Array"
| "Float64Array"
| "BigInt64Array"
| "BigUint64Array"
)
})
}

/// The non-class name the ctor-less `extends_name` walk from `class` ends at,
/// or `None` when an ancestor owns construction (see
/// [`native_instance_base_in_chain`]).
fn ctorless_builtin_base<'c>(ctx: &FnCtx<'c>, class: &'c Class) -> Option<&'c str> {
let mut cur = class.extends_name.as_deref();
for _ in 0..32 {
let name = cur?;
Expand Down Expand Up @@ -160,7 +198,7 @@ pub(crate) fn native_instance_base_in_chain(
cur = parent.extends_name.as_deref();
}
// Not a class in this module — the chain has reached a builtin.
None => return native_instance_base(name),
None => return Some(name),
}
}
None
Expand Down
14 changes: 7 additions & 7 deletions crates/perry-runtime/src/object/class_registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,13 +84,13 @@ pub(crate) use state::async_resource_prototype_value;
#[cfg(test)]
pub(crate) use state::class_decl_prototype_object_root_store;
pub(crate) use state::{
class_decl_prototype_method_names, class_decl_prototype_object, class_decl_prototype_value,
class_decl_prototype_value_for_instance_class, class_delete_own_dynamic_prop,
class_dynamic_prop_root_store, class_has_own_dynamic_prop, class_id_for_decl_prototype_object,
class_is_key_deleted, class_mark_key_deleted, class_object_value_for_cid,
class_object_value_root_store, class_own_dynamic_prop_names, class_own_enumerable_field_names,
class_own_static_field_value, class_own_string_member_names, class_parent_closure,
class_parent_closure_root_store, class_prototype_member_names,
builtin_parent_ctor_in_chain, class_decl_prototype_method_names, class_decl_prototype_object,
class_decl_prototype_value, class_decl_prototype_value_for_instance_class,
class_delete_own_dynamic_prop, class_dynamic_prop_root_store, class_has_own_dynamic_prop,
class_id_for_decl_prototype_object, class_is_key_deleted, class_mark_key_deleted,
class_object_value_for_cid, class_object_value_root_store, class_own_dynamic_prop_names,
class_own_enumerable_field_names, class_own_static_field_value, class_own_string_member_names,
class_parent_closure, class_parent_closure_root_store, class_prototype_member_names,
class_prototype_method_is_enumerable, class_prototype_method_set_enumerable,
class_prototype_method_value_cache_root_store, class_prototype_object_addr_index_contains,
class_prototype_object_addr_index_rekey, class_prototype_object_root_store,
Expand Down
26 changes: 26 additions & 0 deletions crates/perry-runtime/src/object/class_registry/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -865,6 +865,32 @@ pub(crate) fn parent_closure_in_chain(class_id: u32) -> Option<usize> {
None
}

/// Walk the class parent chain for the nearest ancestor that `extends` a
/// global built-in constructor (`class X extends Array`, or `class Y extends X`
/// above it) and return that built-in's constructor value. A built-in parent
/// registers only its reserved class id as the chain edge, never a
/// parent-closure edge, so its static surface (`Array[Symbol.species]`) is
/// reached through the parent value `js_register_class_parent_dynamic` stashed
/// at definition time (#11193).
pub(crate) fn builtin_parent_ctor_in_chain(class_id: u32) -> Option<f64> {
let mut cid = class_id;
let mut depth = 0u32;
while depth < 32 && cid != 0 {
let parent = super::parent_static::template_dynamic_parent_value(cid);
if identify_global_builtin_constructor(parent).is_some() {
return Some(parent);
}
match get_parent_class_id(cid) {
Some(p) if p != 0 && p != cid => {
cid = p;
depth += 1;
}
_ => break,
}
}
None
}

/// Reverse lookup: which declared class's `.prototype` is this heap object?
/// Used by `Object.getOwnPropertyDescriptor(C.prototype, name)` to surface
/// vtable accessors as own properties of the prototype object, and by
Expand Down
16 changes: 16 additions & 0 deletions crates/perry-runtime/src/symbol/get.rs
Original file line number Diff line number Diff line change
Expand Up @@ -815,6 +815,22 @@ pub(crate) unsafe fn js_object_get_symbol_property_with_receiver(
return v;
}
}
// #11193: the subclass (or an ancestor) extends a built-in constructor
// (`class X extends Array`). The built-in's own symbol statics — the
// `get [Symbol.species]` accessor — live on its constructor closure,
// which the chain edge (a reserved class id) does not reach. Read it
// there with the original receiver, so the inherited species getter
// answers `X`, not `Array`. Statics only: a prototype ref shares this
// tag, and `X.prototype` must not see the constructor's symbols.
if !is_proto_ref_receiver {
if let Some(parent_ctor) = crate::object::builtin_parent_ctor_in_chain(class_id) {
return js_object_get_symbol_property_with_receiver(
parent_ctor,
sym_f64,
receiver_f64,
);
}
}
return f64::from_bits(TAG_UNDEFINED);
}
// #1545: Web Stream handles are normal finite numbers, not heap objects.
Expand Down
105 changes: 105 additions & 0 deletions test-files/test_gap_11193_subclass_species_inherited.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
// #11193: a user subclass of a built-in inherits the built-in's
// `get [Symbol.species]` accessor, and the getter answers the subclass itself.
// #11338 installed the accessor on `Array`, `Map`, … but `class X extends
// Array {}` still read `X[Symbol.species]` as `undefined`: the class ref's
// symbol lookup never reached the built-in constructor its chain ends in.
//
// Once the species is found, RegExp `split`/`matchAll` construct the matcher
// through it, i.e. through a dynamic construct of the subclass. For a subclass
// with no constructor of its own that construct produced a plain object with
// no `[[RegExpMatcher]]` (same for typed-array and ArrayBuffer subclasses), so
// the synthesized default constructor now runs the built-in's own Construct.

const S = Symbol.species;

class MyArray extends Array {}
class MyMap extends Map {}
class MySet extends Set {}
class MyPromise extends Promise<any> {}
class MyRegExp extends RegExp {}
class MyArrayBuffer extends ArrayBuffer {}
class MyU8 extends Uint8Array {}
class Deep extends MyArray {}
const Expr = class extends Array {};

const rows: [string, any][] = [
["MyArray", MyArray],
["MyMap", MyMap],
["MySet", MySet],
["MyPromise", MyPromise],
["MyRegExp", MyRegExp],
["MyArrayBuffer", MyArrayBuffer],
["MyU8", MyU8],
["Deep", Deep],
["Expr", Expr],
];
for (const [n, C] of rows) {
console.log(
n,
"own:", Object.getOwnPropertyDescriptor(C, S) === undefined ? "none" : "own",
"species is ctor:", C[S] === C,
"typeof:", typeof C[S],
);
}

// Through a dynamic receiver and Reflect.get's receiver argument.
const dyn: any = MyArray;
console.log("dynamic:", dyn[S] === MyArray, Reflect.get(Array, S, MyArray) === MyArray);

// The prototype does not see the constructor's statics.
console.log("prototype:", (MyArray.prototype as any)[S] === undefined);

// A subclass's own species wins over the inherited accessor.
class Override extends Array {
static get [Symbol.species]() {
return Array;
}
}
class BelowOverride extends Override {}
console.log("override:", (Override as any)[S] === Array, (BelowOverride as any)[S] === Array);

// Inherited user statics are unchanged.
class Base {
static get [Symbol.species]() {
return Base;
}
}
class Kid extends Base {}
console.log("user base:", (Kid as any)[S] === Base);

// The base constructors still answer themselves.
console.log("intrinsics:", (Array as any)[S] === Array, (Map as any)[S] === Map, (Uint8Array as any)[S] === Uint8Array);

// RegExp split / matchAll construct the matcher through the subclass species.
console.log("split:", "a,b,c".split(new MyRegExp(",")), "x1y2".split(new MyRegExp("\\d"), 1));
console.log("matchAll:", [..."a1b22".matchAll(new MyRegExp("\\d+", "g"))].map((m) => m[0]));

// Constructing an implicit-constructor subclass through a VALUE builds the
// exotic built-in, like the literal `new MyRegExp(…)` does.
class Tagged extends RegExp {
tag = 7;
}
class DeepRegExp extends MyRegExp {}
const R: any = MyRegExp;
const r = new R(",", "g");
console.log("dyn RegExp:", r instanceof MyRegExp, r.flags, r.source, "a,b,c".replace(r, "+"));
const rr = Reflect.construct(MyRegExp, [r, "y"]);
console.log("reflect RegExp:", rr instanceof MyRegExp, rr.flags, rr.exec(",") !== null);
const T: any = Tagged;
const t = new T("b", "i");
console.log("fields:", t instanceof Tagged, t.flags, t.tag, t.test("ABC"));
const D: any = DeepRegExp;
const d = new D("z");
console.log("deep:", d instanceof DeepRegExp, d instanceof MyRegExp, d.test("xyz"));
const U: any = MyU8;
const u = new U(3);
console.log("dyn Uint8Array:", u instanceof MyU8, u.length, u.byteLength);
const B: any = MyArrayBuffer;
const b = new B(5);
console.log("dyn ArrayBuffer:", b instanceof MyArrayBuffer, b.byteLength);

// Promise then keeps building the subclass.
const p = MyPromise.resolve(1);
const next = p.then((v: number) => v + 1);
console.log("then is MyPromise:", next instanceof MyPromise);
next.then((v: number) => console.log("then value:", v));
Loading