From 5344e6687ce4bfe4082ca273f6c4b63e98bd23ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 12 Jun 2026 09:59:20 +0200 Subject: [PATCH] fix(runtime): register fn-prototype expandos in own-key metadata (#5024) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expando writes to a function's auto-created .prototype object went only to the CLASS_PROTOTYPE_METHODS side table, so the materialized prototype object's keys_array never saw them: Object.keys / getOwnPropertyNames / in / hasOwnProperty / for-in / Object.assign all missed the keys while direct GET and instance dispatch worked. React 19 copies Component.prototype onto PureComponent.prototype via Object.assign, so isReactComponent vanished, react-reconciler's shouldConstruct classified every `extends PureComponent` class as a function component, and its children were dropped — ink's ErrorBoundary blanked the whole app (#348). Three changes in class_registry.rs: - class_prototype_method_root_store mirrors each registration onto the materialized prototype object as an enumerable own property - ensure_function_prototype_object backfills already-registered methods when the object materializes - js_get_function_prototype_method falls back to the real prototype value when the side table misses, so the recognised .prototype. read shape agrees with generic property-get (covers Object.assign'd and replaced prototypes) --- .../src/object/class_registry.rs | 84 ++++++++++++++++--- .../test_gap_fn_prototype_own_keys_5024.ts | 78 +++++++++++++++++ 2 files changed, 152 insertions(+), 10 deletions(-) create mode 100644 test-files/test_gap_fn_prototype_own_keys_5024.ts diff --git a/crates/perry-runtime/src/object/class_registry.rs b/crates/perry-runtime/src/object/class_registry.rs index 4e1e34f856..d5ddb0a216 100644 --- a/crates/perry-runtime/src/object/class_registry.rs +++ b/crates/perry-runtime/src/object/class_registry.rs @@ -458,6 +458,23 @@ pub(crate) fn ensure_function_prototype_object( class_prototype_object_root_store(class_id, proto); + // #5024: methods registered before the prototype object materialized + // (`F.prototype.m = v` typically runs long before any reflective + // `F.prototype` read) live only in CLASS_PROTOTYPE_METHODS. Backfill + // them as ordinary own properties so enumeration sees them; later + // registrations write through via class_prototype_method_root_store. + let registered: Vec<(String, u64)> = { + let guard = CLASS_PROTOTYPE_METHODS.read().unwrap(); + guard + .as_ref() + .and_then(|map| map.get(&class_id)) + .map(|per_class| per_class.iter().map(|(k, &v)| (k.clone(), v)).collect()) + .unwrap_or_default() + }; + for (name, value_bits) in registered { + unsafe { mirror_prototype_method_on_object(proto, &name, value_bits) }; + } + let func_bits = func_value.to_bits(); if (func_bits >> 48) == 0x7FFD { let func_ptr = (func_bits & crate::value::POINTER_MASK) as usize; @@ -1251,18 +1268,45 @@ fn invalidate_class_prototype_fast_guards() { } pub(crate) fn class_prototype_method_root_store(class_id: u32, name: String, value_bits: u64) { - let mut guard = CLASS_PROTOTYPE_METHODS.write().unwrap(); - if guard.is_none() { - *guard = Some(HashMap::new()); + { + let mut guard = CLASS_PROTOTYPE_METHODS.write().unwrap(); + if guard.is_none() { + *guard = Some(HashMap::new()); + } + guard + .as_mut() + .unwrap() + .entry(class_id) + .or_insert_with(HashMap::new) + .insert(name.clone(), value_bits); } - guard - .as_mut() - .unwrap() - .entry(class_id) - .or_insert_with(HashMap::new) - .insert(name, value_bits); invalidate_class_prototype_fast_guards(); crate::gc::runtime_write_barrier_root_nanbox(value_bits); + // #5024: the side table makes the method dispatchable, but own-key + // enumeration on the prototype OBJECT (Object.keys / getOwnPropertyNames / + // `in` / hasOwnProperty / for-in / Object.assign) consults the object's + // keys_array, which the side table never touched — React's + // `Object.assign(PureComponent.prototype, Component.prototype)` copied + // nothing, so `isReactComponent` vanished and every `extends PureComponent` + // class rendered as a function component. Mirror the write onto the + // materialized prototype object as an ordinary enumerable own property. + let proto = class_prototype_object(class_id); + if !proto.is_null() { + unsafe { mirror_prototype_method_on_object(proto, &name, value_bits) }; + } +} + +/// #5024: write a side-table-registered prototype method onto the +/// materialized prototype object so the key lands in its `keys_array` +/// (assignment semantics: enumerable data property). Values keep their +/// full NaN-boxed bits; dispatch paths that find the property on the +/// object see the same value the side table holds. +unsafe fn mirror_prototype_method_on_object(proto: *mut ObjectHeader, name: &str, value_bits: u64) { + if proto.is_null() || name.is_empty() { + return; + } + let key = crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32); + js_object_set_field_by_name(proto, key, f64::from_bits(value_bits)); } /// Register a JS-classic prototype-method assignment on a class. @@ -1405,7 +1449,27 @@ pub unsafe extern "C" fn js_get_function_prototype_method( let method = js_class_method_bind(receiver, name_ptr, name_len); f64::from_bits(method.to_bits()) } - None => undef, + None => { + // #5024: properties can land on the prototype OBJECT without a + // side-table registration — `Object.assign(F.prototype, src)` + // (React's PureComponent setup), a replaced `F.prototype = obj`, + // or any generic dynamic write. Read the real prototype value + // (replaced object, or the materialized auto-created one) so + // the recognised `.prototype.` read shape agrees + // with the generic property-get path. + let proto_val = js_function_prototype_value_for_read(func_value); + let jv = crate::value::JSValue::from_bits(proto_val.to_bits()); + if !jv.is_pointer() { + return undef; + } + let pptr = jv.as_pointer::(); + if pptr.is_null() { + return undef; + } + let key = crate::string::js_string_from_bytes(name_ptr, name_len as u32); + let v = js_object_get_field_by_name(pptr, key); + f64::from_bits(v.bits()) + } } } diff --git a/test-files/test_gap_fn_prototype_own_keys_5024.ts b/test-files/test_gap_fn_prototype_own_keys_5024.ts new file mode 100644 index 0000000000..0b0f88e55e --- /dev/null +++ b/test-files/test_gap_fn_prototype_own_keys_5024.ts @@ -0,0 +1,78 @@ +// Issue #5024: expando properties written to a function's auto-created +// `.prototype` object must be registered in the object's own-property key +// metadata, so Object.keys / getOwnPropertyNames / `in` / hasOwnProperty / +// for-in / Object.assign all see them (React PureComponent setup relies on +// Object.assign(PureComponent.prototype, Component.prototype)). + +// --- Part 1: minimal own-key tracking on the auto-created prototype --- +function F(this: any) {} +(F.prototype as any).mark = { tag: 1 }; + +console.log("direct get:", typeof (F.prototype as any).mark); +console.log("keys:", JSON.stringify(Object.keys(F.prototype))); +console.log("ownNames:", JSON.stringify(Object.getOwnPropertyNames(F.prototype).sort())); +console.log("in:", "mark" in F.prototype); +console.log("hasOwn:", Object.prototype.hasOwnProperty.call(F.prototype, "mark")); + +const forInKeys: string[] = []; +for (const k in F.prototype) forInKeys.push(k); +console.log("forIn:", JSON.stringify(forInKeys)); + +const t: any = {}; +Object.assign(t, F.prototype); +console.log("assign keys:", JSON.stringify(Object.keys(t))); +console.log("assign get:", typeof t.mark); + +// Read through a plain variable (generic property-get path, not the +// recognised `.prototype.` shape). +const FP: any = F.prototype; +console.log("var get:", typeof FP.mark); + +// --- Part 2: replaced prototype (control — always worked) --- +function G(this: any) {} +G.prototype = { gmark: 1 }; +console.log("replaced keys:", JSON.stringify(Object.keys(G.prototype))); + +// --- Part 3: the React PureComponent shape (2-level chain) --- +function Component(this: any, props: any) { + this.props = props; +} +(Component.prototype as any).isReactComponent = {}; +(Component.prototype as any).setState = function (this: any, s: any) { + return "setState"; +}; + +function ComponentDummy(this: any) {} +ComponentDummy.prototype = Component.prototype; + +function PureComponent(this: any, props: any) { + this.props = props; +} +const pureProto: any = new (ComponentDummy as any)(); +PureComponent.prototype = pureProto; +pureProto.constructor = PureComponent; +Object.assign(pureProto, Component.prototype); +pureProto.isPureReactComponent = true; + +const PC: any = PureComponent; +console.log("PC proto isReactComponent:", typeof PC.prototype.isReactComponent); + +class Y extends (PureComponent as any) { + render() { + return null; + } +} +console.log("Y proto isReactComponent:", typeof (Y.prototype as any).isReactComponent); + +// shouldConstruct, exactly as react-reconciler does it +function shouldConstruct(type: any) { + const prototype = type.prototype; + return !!(prototype && prototype.isReactComponent); +} +console.log("shouldConstruct(Y):", shouldConstruct(Y)); + +// --- Part 4: dispatch through instances still works --- +const f: any = new (F as any)(); +console.log("inst mark:", typeof f.mark); +const y: any = new (Y as any)(); +console.log("y setState:", y.setState ? y.setState() : "MISSING");