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
84 changes: 74 additions & 10 deletions crates/perry-runtime/src/object/class_registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 `<func>.prototype.<name>` 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::<ObjectHeader>();
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())
}
}
}

Expand Down
78 changes: 78 additions & 0 deletions test-files/test_gap_fn_prototype_own_keys_5024.ts
Original file line number Diff line number Diff line change
@@ -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 `<funcName>.prototype.<name>` 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");