Skip to content

feat(mock): Animated as a live node graph with subscribing wrappers#76

Open
danfry1 wants to merge 2 commits into
mainfrom
feat/animated-node-graph
Open

feat(mock): Animated as a live node graph with subscribing wrappers#76
danfry1 wants to merge 2 commits into
mainfrom
feat/animated-node-graph

Conversation

@danfry1

@danfry1 danfry1 commented Jul 5, 2026

Copy link
Copy Markdown
Owner

Problem

The mock engine's Animated was a snapshot system — the largest fidelity gap the code audit found, and the class real-app bake-offs (react-native-paper) had to monkeypatch around:

  • Numeric interpolate() returned a dead value computed at call time (string interpolation was live via closure — the two paths disagreed with each other).
  • add/subtract/multiply/divide/modulo snapshotted, and coerced node operands (e.g. interpolations) to 0.
  • setOffset/flattenOffset/extractOffset were no-ops — the canonical PanResponder drag pattern silently did nothing.
  • Plain values lacked __getValue() (RN's own tests call it).
  • The Animated.* wrappers resolved style once at render and never re-rendered: a setValue() or timing().start() after render kept the stale style, so toHaveStyle assertions silently tested nothing. The crosscheck corpus explicitly excluded this class — the trust mechanism was blind exactly where the mock was weakest.

Change

A live node graph mirroring real RN's architecture:

  • AnimatedNode base with live __getValue(); derived nodes (AnimatedInterpolation, AnimatedOp, AnimatedDiffClamp) recompute from their parents on every read. Numeric interpolations chain; derived nodes are valid operands; string-interpolation chaining still throws like RN.
  • User listeners and graph children are separate populations (RN's __attach/__detach model): removeAllListeners() removes only JS listeners — mounted components and derived nodes keep updating, exactly like on-device. Derived nodes attach to parents lazily (only while they have consumers), so the common pattern of constructing an interpolation inside a render function cannot accumulate subscriptions on the source across re-renders.
  • Wrappers subscribe and re-render: Animated.View/Text/… and createAnimatedComponent register as graph children of every node in their style; post-render setValue/timing().start() updates the rendered style. Subscriptions are re-established per render and removed on unmount.
  • Offsets implement RN semantics on Value and ValueXY; ValueXY.addListener reports the joint {x, y}.
  • useAnimatedValue/XY/Color are real (lazily-initialized, useRef-memoized) hooks — the value survives re-renders instead of silently resetting; like on-device, they now require a component context (the RN-0.86 export tests exercised them bare and are updated).

Trust wiring

Three new crosscheck probes gate the fixed class against real React Native: post-render setValue reflow, live interpolation, and live transform — returning the observed style values so the engines are compared on exactly what a test would read. Corpus 75 → 78, all matching, verified live against RN 0.86. (The probes were also validated the honest way: before the mock fix they diverged — native live, mock stale.)

Review

Independently adversarially reviewed pre-PR (no blockers). Both should-fixes — the render-loop listener leak and the removeAllListeners divergence, sharing the single-listener-map root cause — are fixed via the children/listeners separation, with regression tests (leak bound across re-renders, post-unmount consumer emptiness, removeAllListeners keeps views updating). The reviewer verified the offset-inclusive notify and tracking semantics against real RN's source, and confirmed no re-entrancy in the wrapper effect.

Tests

20 new unit tests (graph liveness, chaining, ops with node operands, offsets/drag pattern, joint listeners, wrapper subscribe/unmount/leak-bound, hook stability, preserved shapes incl. divide-by-zero bug-compat) + 3 crosscheck probes; the 139 pre-existing Animated tests pass unchanged.

Full gate: mock 1311, native suites green, crosscheck 78/78, fidelity artifacts regenerated, lint/format/typecheck clean.

danfry1 added 2 commits July 5, 2026 21:41
The mock's Animated was a snapshot system: numeric interpolate() returned a
dead value computed at call time (string interpolation was live via closure —
the two paths disagreed), add/multiply/&c coerced node operands to 0 and
snapshotted, setOffset/flattenOffset/extractOffset were no-ops, plain values
lacked __getValue(), and the Animated.* wrappers resolved style once at
render and never re-rendered — so a setValue()/timing().start() after render
silently kept the stale style. This is the class real-app bake-offs had to
monkeypatch around, and the crosscheck corpus explicitly excluded it.

Now:
- AnimatedNode base with live __getValue() and listener fan-out; derived
  nodes (AnimatedInterpolation, AnimatedOp, AnimatedDiffClamp) subscribe to
  their parents at construction and recompute on every read. Numeric
  interpolations chain; string-interpolation chaining still throws like RN.
- Wrappers collect every node in their style and re-render on change (the
  mock equivalent of real RN's createAnimatedComponent update path);
  createAnimatedComponent gets the same treatment. Subscriptions are
  re-established per render and removed on unmount.
- Offsets implement RN semantics on Value and ValueXY (the PanResponder drag
  pattern); ValueXY.addListener reports the joint {x,y}.
- useAnimatedValue/XY/Color are useRef-memoized hooks (previously each
  render minted a fresh node — resetting animation state — and rebuilt the
  whole Animated namespace); they now require a component context, exactly
  like on-device. The RN-0.86 export tests were exercising them bare and
  are updated to render through a probe component.

Trust wiring: three new crosscheck probes gate the fixed class against real
React Native — post-render setValue reflow, live interpolation, live
transform — returning the OBSERVED style values so the engines are compared
on what a test would read. Corpus 75 → 78, all matching; verified live
against real RN 0.86. 17 new unit tests cover graph semantics, offsets,
listener propagation, wrapper subscription/unmount, and hook stability;
the 139 pre-existing Animated tests pass unchanged.
… parity)

Pre-PR review findings, both rooted in one design gap — user listeners and
graph propagation shared a single listener map with no detach lifecycle:

- Render-constructed derived nodes leaked: style={{opacity: v.interpolate(…)}}
  builds a new interpolation each re-render, and each subscribed permanently
  to v (O(N²) work across N updates). Derived nodes now attach to their
  parents LAZILY — only while they have a consumer of their own — mirroring
  RN's __attach/__detach; when the wrapper unsubscribes from a stale
  interpolation it detaches from the source and can be collected.
- removeAllListeners() severed view updates and derived-node propagation,
  where real RN removes only JS listeners. Graph edges (attached wrappers,
  derived nodes, timing-tracking, color channels) now live in a separate
  children set that removeAllListeners never touches.

diffClamp stays a PERMANENT child of its parent (it accumulates history and
must observe every move); tracking and color channels are children too, so
they survive the source's removeAllListeners like their RN counterparts.
The offset comment now states the real reason none of the three offset ops
notify; the registry hooks lazily initialize their useRef.

New tests: render-loop leak bound (children ≤ 1 after five re-renders),
post-unmount consumer emptiness, removeAllListeners keeps the mounted
component updating while user listeners stop. 158 Animated tests + 78/78
crosscheck probes green.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant