diff --git a/changelog.d/11525-subclass-species-inherited.md b/changelog.d/11525-subclass-species-inherited.md new file mode 100644 index 0000000000..37531a759b --- /dev/null +++ b/changelog.d/11525-subclass-species-inherited.md @@ -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`. diff --git a/crates/perry-codegen/src/codegen/method.rs b/crates/perry-codegen/src/codegen/method.rs index 28dbe23a16..2fe16f21ed 100644 --- a/crates/perry-codegen/src/codegen/method.rs +++ b/crates/perry-codegen/src/codegen/method.rs @@ -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 = 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 diff --git a/crates/perry-codegen/src/lower_call/mod.rs b/crates/perry-codegen/src/lower_call/mod.rs index ca6f37134b..07b0bea1ca 100644 --- a/crates/perry-codegen/src/lower_call/mod.rs +++ b/crates/perry-codegen/src/lower_call/mod.rs @@ -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. diff --git a/crates/perry-codegen/src/lower_call/new_helpers.rs b/crates/perry-codegen/src/lower_call/new_helpers.rs index c099a51636..68e515bed6 100644 --- a/crates/perry-codegen/src/lower_call/new_helpers.rs +++ b/crates/perry-codegen/src/lower_call/new_helpers.rs @@ -133,6 +133,44 @@ pub(crate) fn native_instance_base_in_chain( ctx: &FnCtx<'_>, class: &Class, ) -> Option { + 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?; @@ -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 diff --git a/crates/perry-runtime/src/object/class_registry.rs b/crates/perry-runtime/src/object/class_registry.rs index 1f141cb770..ed4cd0e3e5 100644 --- a/crates/perry-runtime/src/object/class_registry.rs +++ b/crates/perry-runtime/src/object/class_registry.rs @@ -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, diff --git a/crates/perry-runtime/src/object/class_registry/state.rs b/crates/perry-runtime/src/object/class_registry/state.rs index d0f66bf011..c9a7a07b54 100644 --- a/crates/perry-runtime/src/object/class_registry/state.rs +++ b/crates/perry-runtime/src/object/class_registry/state.rs @@ -865,6 +865,32 @@ pub(crate) fn parent_closure_in_chain(class_id: u32) -> Option { 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 { + 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 diff --git a/crates/perry-runtime/src/symbol/get.rs b/crates/perry-runtime/src/symbol/get.rs index a87149b788..19bc6e9eac 100644 --- a/crates/perry-runtime/src/symbol/get.rs +++ b/crates/perry-runtime/src/symbol/get.rs @@ -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. diff --git a/test-files/test_gap_11193_subclass_species_inherited.ts b/test-files/test_gap_11193_subclass_species_inherited.ts new file mode 100644 index 0000000000..c03dc04f8b --- /dev/null +++ b/test-files/test_gap_11193_subclass_species_inherited.ts @@ -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 {} +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));