fix(runtime): function-prototype expandos invisible to own-key enumeration broke Object.assign — PureComponent subclasses rendered as function components (#5024)#5035
Conversation
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 <func>.prototype.<name> read shape agrees with generic property-get (covers Object.assign'd and replaced prototypes)
|
CI note: the |
|
CI final state: all checks done. |
Fixes #5024 — the last blocker for ink end-to-end rendering (#348).
Problem
Expando properties written to a function's auto-created
.prototypeobject (F.prototype.mark = v) were dispatchable but invisible to own-property enumeration:Object.keys,Object.getOwnPropertyNames,in,hasOwnProperty,for-in, and — critically —Object.assignall missed them.React 19 sets up
PureComponentby copyingComponent.prototypewithObject.assign(PureComponent.prototype, Component.prototype). Because the copy saw no keys,PureComponent.prototype.isReactComponentended upundefined, react-reconciler'sshouldConstructclassified everyclass X extends PureComponentas a function component, its classrender()never ran, and its children were dropped — ink's<ErrorBoundary extends PureComponent>wraps the whole app, so the entire tree rendered empty.Root cause
The #838 recogniser lowers
F.prototype.<name> = vtojs_register_function_prototype_method, which stores the value only in theCLASS_PROTOTYPE_METHODSside table (keyed by synthetic class id). Dispatch paths consult that table, so direct GET and instance-method calls worked — but the materialized prototype object (ensure_function_prototype_object) never learned the key, so itskeys_array(the metadata every enumeration path consults) stayed empty.A second gap on the read side: the recognised
<func>.prototype.<name>READ shape (js_get_function_prototype_method) consulted only the side table, so properties that landed on the prototype object without a registration (Object.assign, generic dynamic writes, replacedF.prototype = obj) read backundefinedeven though the generic property-get path could see them.Fix (all in
crates/perry-runtime/src/object/class_registry.rs)class_prototype_method_root_storenow mirrors each side-table registration onto the materialized prototype object as an ordinary enumerable own property (when the object exists).ensure_function_prototype_objectcopies all already-registered side-table methods onto the freshly materialized object (covers the commonF.prototype.m = vbefore any reflectiveF.prototyperead ordering).js_get_function_prototype_methodfalls back to reading the real prototype value (replaced object or materialized auto-created one) when the side table misses, so the recognised read shape agrees with the generic property-get path.Validation
test-files/test_gap_fn_prototype_own_keys_5024.tscovers the issue's minimal repro (keys/ownNames/in/hasOwn/for-in/assign/var-read), the replaced-prototype control, the full ReactComponent/ComponentDummy/PureComponent/Object.assignshape with a 2-levelclass Y extends PureComponentchain,shouldConstructexactly as react-reconciler does it, and instance dispatch — byte-identical tonode --experimental-strip-types.origin/mainbuild of the same worktree. The remaining 2 were chased to pre-existing flakes, verified on a fully pristine tree (source stashed +target/perry-auto-*cleared):test_issue_1852_net_lifecyclehangs intermittently on pristine (4 FAIL / 2 PASS over 6 runs; byte-identical output, exit-only divergence), andtest_gap_fs_fd_2749flips the samefchown closed cbline ordering on pristine (async fs callback completion-order race). A 3-way bisect (each of the three changes disabled) confirmed neither symptom tracks this fix.cargo test --release -p perry-runtime --lib -- --test-threads=1: failure set identical to pristineorigin/main(3 known pre-existing:date::tests::test_full_year_setters_revive_invalid_date_only, 2×node_stream::tests_extra::readable_unshift_*).Per the workflow notes, no version bump / changelog entry — maintainer folds metadata at merge.