Skip to content

fix(runtime): function-prototype expandos invisible to own-key enumeration broke Object.assign — PureComponent subclasses rendered as function components (#5024)#5035

Merged
proggeramlug merged 2 commits into
mainfrom
fix/fn-prototype-own-keys-5024
Jun 12, 2026

Conversation

@proggeramlug

Copy link
Copy Markdown
Contributor

Fixes #5024 — the last blocker for ink end-to-end rendering (#348).

Problem

Expando properties written to a function's auto-created .prototype object (F.prototype.mark = v) were dispatchable but invisible to own-property enumeration: Object.keys, Object.getOwnPropertyNames, in, hasOwnProperty, for-in, and — critically — Object.assign all missed them.

React 19 sets up PureComponent by copying Component.prototype with Object.assign(PureComponent.prototype, Component.prototype). Because the copy saw no keys, PureComponent.prototype.isReactComponent ended up undefined, react-reconciler's shouldConstruct classified every class X extends PureComponent as a function component, its class render() 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> = v to js_register_function_prototype_method, which stores the value only in the CLASS_PROTOTYPE_METHODS side 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 its keys_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, replaced F.prototype = obj) read back undefined even though the generic property-get path could see them.

Fix (all in crates/perry-runtime/src/object/class_registry.rs)

  1. Write-throughclass_prototype_method_root_store now mirrors each side-table registration onto the materialized prototype object as an ordinary enumerable own property (when the object exists).
  2. Backfillensure_function_prototype_object copies all already-registered side-table methods onto the freshly materialized object (covers the common F.prototype.m = v before any reflective F.prototype read ordering).
  3. Read fallbackjs_get_function_prototype_method falls 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

  • New gap test test-files/test_gap_fn_prototype_own_keys_5024.ts covers the issue's minimal repro (keys/ownNames/in/hasOwn/for-in/assign/var-read), the replaced-prototype control, the full React Component/ComponentDummy/PureComponent/Object.assign shape with a 2-level class Y extends PureComponent chain, shouldConstruct exactly as react-reconciler does it, and instance dispatch — byte-identical to node --experimental-strip-types.
  • Full parity suite run (759 tests): 48 of the 50 failures fail identically on a pristine origin/main build 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_lifecycle hangs intermittently on pristine (4 FAIL / 2 PASS over 6 runs; byte-identical output, exit-only divergence), and test_gap_fs_fd_2749 flips the same fchown closed cb line 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 pristine origin/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.

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)
@proggeramlug

Copy link
Copy Markdown
Contributor Author

CI note: the cargo-test failure is the pre-existing main breakage — the same 2 node_stream::tests_extra::readable_unshift_* tests with the identical 1023 passed; 2 failed result as the runs on #5026/#5027/#5028 (all merged with it). Verified locally that they fail on pristine origin/main (3559ed7) with --test-threads=1; this branch's failure set is byte-identical to pristine.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

CI final state: all checks done. compiler-output-regression red is also pre-existing — it fails on the exact workload tracked in #5030 (h1_buffer_alias_negative native-region-proof, hot-loop runtime-call check), and failed identically on #5026/#5027/#5028. Everything else green (lint, api-docs-drift, security-audit, harmonyos-smoke); the rest skipped (tag-gated). So both reds are the known main breakage (#5030 + the readable_unshift pair) — nothing from this branch.

@proggeramlug proggeramlug merged commit 963adc1 into main Jun 12, 2026
13 checks passed
@proggeramlug proggeramlug deleted the fix/fn-prototype-own-keys-5024 branch June 12, 2026 11:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant