From 921de0936296909708e1fab0b3be1c8b08b04e74 Mon Sep 17 00:00:00 2001 From: Daniel Fry Date: Sun, 5 Jul 2026 21:41:12 +0100 Subject: [PATCH 1/2] feat(mock): Animated as a live node graph with subscribing wrappers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .changeset/animated-node-graph.md | 11 + .../crosscheck/crosscheck.test.tsx | 55 ++- .../crosscheck/fidelity-badge.json | 2 +- .../vitest-native/src/mocks/apis/Animated.ts | 466 ++++++++++++------ packages/vitest-native/src/mocks/registry.ts | 19 +- .../tests/animated-graph.test.tsx | 195 ++++++++ .../tests/rn-086-exports.test.ts | 27 +- website/guide/fidelity.md | 5 +- 8 files changed, 602 insertions(+), 178 deletions(-) create mode 100644 .changeset/animated-node-graph.md create mode 100644 packages/vitest-native/tests/animated-graph.test.tsx diff --git a/.changeset/animated-node-graph.md b/.changeset/animated-node-graph.md new file mode 100644 index 0000000..d1c8f63 --- /dev/null +++ b/.changeset/animated-node-graph.md @@ -0,0 +1,11 @@ +--- +"vitest-native": minor +--- + +The mock engine's Animated is now a live node graph, matching real React Native's semantics (previously it was a snapshot system — the largest known fidelity gap, and the class real-app bake-offs had to monkeypatch around). + +- **Derived nodes are live.** `interpolate()` (numeric AND string), `add`/`subtract`/`multiply`/`divide`/`modulo`, and `diffClamp` recompute from their sources on every read and re-notify listeners when any source moves. Numeric interpolations chain; derived nodes are valid operands (previously coerced to 0); chaining off a string interpolation still throws like RN. +- **Animated components re-render.** `Animated.View`/`Text`/`Image`/`ScrollView`/`FlatList`/`SectionList` and `createAnimatedComponent` wrappers subscribe to every node in their style — a `setValue()` or `timing().start()` after render updates the rendered style, so `toHaveStyle` assertions see current values. Gated against real React Native by three new crosscheck probes (post-render `setValue`, live interpolation, live transform) — the corpus is now 78/78. +- **Offsets are real.** `setOffset`/`flattenOffset`/`extractOffset` implement RN's semantics (the canonical PanResponder drag pattern) on `Value` and `ValueXY`; `ValueXY.addListener` now reports the joint `{x, y}` value. +- **`__getValue()` exists on plain values** (RN's own tests call it), and `AnimatedValueXY`/`AnimatedColor` gained `__getValue`/`getValue` parity. +- **`useAnimatedValue`/`useAnimatedValueXY`/`useAnimatedColor` are real hooks**: the value is `useRef`-memoized and survives re-renders (previously every render minted a fresh node, silently resetting animation state — and rebuilt the entire Animated namespace to do it). Consequently they must now be called inside a component, exactly like on-device. diff --git a/packages/vitest-native/crosscheck/crosscheck.test.tsx b/packages/vitest-native/crosscheck/crosscheck.test.tsx index d93940c..8eb09ac 100644 --- a/packages/vitest-native/crosscheck/crosscheck.test.tsx +++ b/packages/vitest-native/crosscheck/crosscheck.test.tsx @@ -42,7 +42,15 @@ import { TouchableWithoutFeedback, View, } from "react-native"; -import { cleanup, fireEvent, render, screen, userEvent, within } from "@testing-library/react-native"; +import { + act, + cleanup, + fireEvent, + render, + screen, + userEvent, + within, +} from "@testing-library/react-native"; import fs from "node:fs"; afterEach(cleanup); @@ -287,6 +295,51 @@ probe("animated-value-initial-style", async () => { return { hit: passes(() => expect(screen.getByTestId("av")).toHaveStyle({ opacity: 0.3 })) }; }); +probe("animated-setvalue-updates-rendered-style", async () => { + // The class the node-graph refactor fixed: a setValue() AFTER render must be + // visible in the rendered style (real RN's animated components re-render). + // The observed value is returned, so the engines are compared on exactly + // what a test would read. + const opacity = new Animated.Value(0.3); + await render(); + await act(async () => opacity.setValue(0.8)); + const style = StyleSheet.flatten(screen.getByTestId("av-live").props.style) as { + opacity?: number; + }; + return { + observed: style?.opacity, + hit: passes(() => expect(screen.getByTestId("av-live")).toHaveStyle({ opacity: 0.8 })), + }; +}); + +probe("animated-interpolation-live-style", async () => { + // Interpolations are live nodes: moving the source value after render must + // recompute the derived style (previously the mock froze the value computed + // at interpolate() call time). + const progress = new Animated.Value(0); + const opacity = progress.interpolate({ inputRange: [0, 1], outputRange: [0.25, 0.75] }); + await render(); + await act(async () => progress.setValue(1)); + const style = StyleSheet.flatten(screen.getByTestId("av-interp").props.style) as { + opacity?: number; + }; + return { + observed: style?.opacity, + hit: passes(() => expect(screen.getByTestId("av-interp")).toHaveStyle({ opacity: 0.75 })), + }; +}); + +probe("animated-transform-live-style", async () => { + // The same liveness through a transform array (translateX driven by a value). + const x = new Animated.Value(0); + await render(); + await act(async () => x.setValue(42)); + const style = StyleSheet.flatten(screen.getByTestId("av-tx").props.style) as { + transform?: Array>; + }; + return { transform: style?.transform }; +}); + // --- accessibility props (what RNTL byRole / toBeDisabled depend on) --- probe("a11y-role", async () => { await render(); diff --git a/packages/vitest-native/crosscheck/fidelity-badge.json b/packages/vitest-native/crosscheck/fidelity-badge.json index 291fe60..e78adb2 100644 --- a/packages/vitest-native/crosscheck/fidelity-badge.json +++ b/packages/vitest-native/crosscheck/fidelity-badge.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, "label": "RN fidelity", - "message": "75/75 probes", + "message": "78/78 probes", "color": "brightgreen" } diff --git a/packages/vitest-native/src/mocks/apis/Animated.ts b/packages/vitest-native/src/mocks/apis/Animated.ts index 4b47f18..3ee5ec8 100644 --- a/packages/vitest-native/src/mocks/apis/Animated.ts +++ b/packages/vitest-native/src/mocks/apis/Animated.ts @@ -252,28 +252,32 @@ function checkValidRanges(inputRange: number[], outputRange: unknown[]): void { } } -class AnimatedValue { - private _value: number; - private _listeners: Map = new Map(); - private _listenerIdCounter = 0; - _tracking: { source: AnimatedValue; listenerId: string } | null = null; +// ─── Live node graph ───────────────────────────────────────────────────────── +// +// Real RN's Animated is a dependency graph: derived nodes (interpolations, +// arithmetic ops) recompute from their parents on every read, and attached +// components re-render when any node in their style changes. The mock mirrors +// that shape: every node has a live __getValue(), derived nodes subscribe to +// their parents at construction, and the Animated.* wrappers subscribe to the +// nodes in their style — so a setValue()/timing().start() after render updates +// the rendered style exactly as it does on-device. - constructor(value: number = 0) { - this._value = value; - } +type ListenerFn = (state: { value: any }) => void; - setValue(value: number) { - this._value = value; - this._listeners.forEach((fn) => fn({ value })); - } +abstract class AnimatedNode { + protected _listeners: Map = new Map(); + private _listenerIdCounter = 0; + + /** Current value of this node, computed live from its inputs. */ + abstract __getValue(): any; getValue() { - return this._value; + return this.__getValue(); } addListener(callback: Function): string { const id = String(++this._listenerIdCounter); - this._listeners.set(id, callback); + this._listeners.set(id, callback as ListenerFn); return id; } @@ -285,7 +289,94 @@ class AnimatedValue { this._listeners.clear(); } - interpolate(config: any) { + /** Notify listeners with the node's CURRENT (offset-inclusive) value. */ + protected _notify() { + const value = this.__getValue(); + this._listeners.forEach((fn) => fn({ value })); + } + + interpolate(config: any): AnimatedNode { + const { inputRange, outputRange } = config || {}; + if (!inputRange || !outputRange || inputRange.length < 2 || outputRange.length < 2) { + // Lenient path for malformed configs (RN throws; tests historically + // relied on getting a value-shaped object back). + return new AnimatedValue(Number(this.__getValue()) || 0); + } + return new AnimatedInterpolation(this, config); + } + + stopAnimation(callback?: Function) { + callback?.(this.__getValue()); + } + + resetAnimation(callback?: Function) { + callback?.(this.__getValue()); + } + + toJSON() { + return this.__getValue(); + } +} + +class AnimatedValue extends AnimatedNode { + private _value: number; + private _offset: number = 0; + _tracking: { source: AnimatedValue; listenerId: string } | null = null; + + constructor(value: number = 0) { + super(); + this._value = value; + } + + setValue(value: number) { + this._value = value; + this._notify(); + } + + __getValue(): number { + return this._value + this._offset; + } + + // Offsets follow RN's semantics: the offset is added on read; flatten folds + // it into the value; extract moves the value into the offset (the canonical + // PanResponder drag pattern). Neither changes the observable value, so + // neither notifies. + setOffset(offset: number) { + this._offset = offset; + } + + flattenOffset() { + this._value += this._offset; + this._offset = 0; + } + + extractOffset() { + this._offset += this._value; + this._value = 0; + } +} + +class AnimatedInterpolation extends AnimatedNode { + private _parent: AnimatedNode; + private _config: any; + private _preparedString: PreparedStringInterp | null = null; + + constructor(parent: AnimatedNode, config: any) { + super(); + this._parent = parent; + this._config = config; + const { inputRange, outputRange } = config; + // Validate ranges eagerly, like RN's createInterpolation (monotonic input, + // matched lengths, no [-Infinity, Infinity]). + checkValidRanges(inputRange, outputRange); + if (typeof outputRange[0] === "string") { + this._preparedString = prepareStringInterpolation(outputRange); + } + // Live propagation: recompute + re-notify whenever the parent moves. + parent.addListener(() => this._notify()); + } + + __getValue(): number | string { const { inputRange, outputRange, @@ -293,49 +384,21 @@ class AnimatedValue { extrapolateLeft, extrapolateRight, easing = IDENTITY_EASING, - } = config || {}; - if (!inputRange || !outputRange || inputRange.length < 2 || outputRange.length < 2) { - return new AnimatedValue(this._value); - } - // Validate ranges eagerly, like RN's createInterpolation (monotonic input, - // matched lengths, no [-Infinity, Infinity]). - checkValidRanges(inputRange, outputRange); - - // String output ranges ("0deg" -> "360deg", rgba(...), arbitrary suffixes). - // Parse + validate the pattern eagerly, then return a live interpolation node - // whose getValue()/__getValue() reconstruct the string from the current source - // value, mirroring RN's AnimatedInterpolation surface. - if (typeof outputRange[0] === "string") { - const prepared = prepareStringInterpolation(outputRange); - // Arrow closes over `this`, so getValue() reads the live source value. - const compute = () => - interpolateString( - prepared, - this._value, - inputRange, - extrapolate, - extrapolateLeft, - extrapolateRight, - easing, - ); - return { - getValue: compute, - __getValue: compute, - toJSON: compute, - // Chaining off a string/color interpolation is invalid in RN (the parent - // value is no longer numeric). Match that by throwing. - interpolate: () => { - throw new Error("Cannot chain an interpolation off a string-valued interpolation"); - }, - addListener: () => "0", - removeListener: () => {}, - removeAllListeners: () => {}, - stopAnimation: (cb?: Function) => cb?.(compute()), - resetAnimation: (cb?: Function) => cb?.(compute()), - } as any; + } = this._config; + const input = Number(this._parent.__getValue()); + if (this._preparedString) { + return interpolateString( + this._preparedString, + input, + inputRange, + extrapolate, + extrapolateLeft, + extrapolateRight, + easing, + ); } - const result = interpolateNumeric( - this._value, + return interpolateNumeric( + input, inputRange, outputRange, extrapolate, @@ -343,29 +406,49 @@ class AnimatedValue { extrapolateRight, easing, ); - return new AnimatedValue(result); } - stopAnimation(callback?: Function) { - callback?.(this._value); + interpolate(config: any): AnimatedNode { + if (this._preparedString) { + // Chaining off a string/color interpolation is invalid in RN (the parent + // value is no longer numeric). Match that by throwing. + throw new Error("Cannot chain an interpolation off a string-valued interpolation"); + } + return super.interpolate(config); } +} - resetAnimation(callback?: Function) { - callback?.(this._value); +// A derived node computed from one or more inputs (add/multiply/…/diffClamp). +class AnimatedOp extends AnimatedNode { + private _compute: () => number; + + constructor(inputs: any[], compute: () => number) { + super(); + this._compute = compute; + for (const input of inputs) { + if (input instanceof AnimatedNode) input.addListener(() => this._notify()); + } } - toJSON() { - return this._value; + __getValue(): number { + return this._compute(); } +} - setOffset(_offset: number) {} - flattenOffset() {} - extractOffset() {} +/** Numeric value of an operand that may be a node, a number, or junk (→ 0). */ +function operandValue(v: any): number { + if (v instanceof AnimatedNode) { + const n = Number(v.__getValue()); + return Number.isNaN(n) ? 0 : n; + } + return typeof v === "number" ? v : 0; } class AnimatedValueXY { x: AnimatedValue; y: AnimatedValue; + private _listenerIdCounter = 0; + private _joint: Map = new Map(); constructor(value?: { x: number; y: number }) { this.x = new AnimatedValue(value?.x ?? 0); @@ -377,22 +460,46 @@ class AnimatedValueXY { this.y.setValue(value.y); } - setOffset(_offset: { x: number; y: number }) {} - flattenOffset() {} - extractOffset() {} + setOffset(offset: { x: number; y: number }) { + this.x.setOffset(offset.x); + this.y.setOffset(offset.y); + } + + flattenOffset() { + this.x.flattenOffset(); + this.y.flattenOffset(); + } + + extractOffset() { + this.x.extractOffset(); + this.y.extractOffset(); + } + + __getValue() { + return { x: this.x.__getValue(), y: this.y.__getValue() }; + } + + getValue() { + return this.__getValue(); + } stopAnimation(callback?: Function) { - callback?.({ x: this.x.getValue(), y: this.y.getValue() }); + callback?.(this.__getValue()); } resetAnimation(callback?: Function) { - callback?.({ x: this.x.getValue(), y: this.y.getValue() }); + callback?.(this.__getValue()); } - addListener(_callback: Function) { - const xId = this.x.addListener(() => {}); - const yId = this.y.addListener(() => {}); - return { x: xId, y: yId }; + // RN's ValueXY joint listener: the callback receives the {x, y} pair when + // EITHER component moves. + addListener(callback: Function) { + const emit = () => callback(this.__getValue()); + const id = String(++this._listenerIdCounter); + const pair = { x: this.x.addListener(emit), y: this.y.addListener(emit) }; + this._joint.set(id, pair); + // Historical return shape: the per-axis ids (also accepted by removeListener). + return pair; } removeListener(id: { x: string; y: string }) { @@ -400,6 +507,12 @@ class AnimatedValueXY { this.y.removeListener(id.y); } + removeAllListeners() { + this.x.removeAllListeners(); + this.y.removeAllListeners(); + this._joint.clear(); + } + getLayout() { return { left: this.x, top: this.y }; } @@ -449,45 +562,40 @@ function parseColorString(color: string): [number, number, number, number] { return [0, 0, 0, 1]; } -class AnimatedColor { +class AnimatedColor extends AnimatedNode { r: AnimatedValue; g: AnimatedValue; b: AnimatedValue; a: AnimatedValue; - private _listeners: Map = new Map(); - private _listenerIdCounter = 0; constructor(color?: any) { + super(); if (typeof color === "string") { const [r, g, b, a] = parseColorString(color); this.r = new AnimatedValue(r); this.g = new AnimatedValue(g); this.b = new AnimatedValue(b); this.a = new AnimatedValue(a); - } else if (color && typeof color === "object") { - const isAnimated = color.r instanceof AnimatedValue; - if (isAnimated) { - this.r = color.r; - this.g = color.g; - this.b = color.b; - this.a = color.a; - } else if (typeof color.r === "number") { - this.r = new AnimatedValue(color.r); - this.g = new AnimatedValue(color.g ?? 0); - this.b = new AnimatedValue(color.b ?? 0); - this.a = new AnimatedValue(color.a ?? 1); - } else { - this.r = new AnimatedValue(0); - this.g = new AnimatedValue(0); - this.b = new AnimatedValue(0); - this.a = new AnimatedValue(1); - } + } else if (color && typeof color === "object" && color.r instanceof AnimatedValue) { + this.r = color.r; + this.g = color.g; + this.b = color.b; + this.a = color.a; + } else if (color && typeof color === "object" && typeof color.r === "number") { + this.r = new AnimatedValue(color.r); + this.g = new AnimatedValue(color.g ?? 0); + this.b = new AnimatedValue(color.b ?? 0); + this.a = new AnimatedValue(color.a ?? 1); } else { this.r = new AnimatedValue(0); this.g = new AnimatedValue(0); this.b = new AnimatedValue(0); this.a = new AnimatedValue(1); } + // Channel moves re-notify the color (live wrappers + listeners). + for (const channel of [this.r, this.g, this.b, this.a]) { + channel.addListener(() => this._notify()); + } } setValue(value: any) { @@ -503,8 +611,6 @@ class AnimatedColor { this.b.setValue(value.b ?? 0); this.a.setValue(value.a ?? 1); } - const colorStr = this.__getValue(); - this._listeners.forEach((fn) => fn({ value: colorStr })); } __getValue(): string { @@ -515,28 +621,8 @@ class AnimatedColor { return `rgba(${r}, ${g}, ${b}, ${a})`; } - addListener(callback: Function): string { - const id = String(++this._listenerIdCounter); - this._listeners.set(id, callback); - return id; - } - - removeListener(id: string) { - this._listeners.delete(id); - } - - removeAllListeners() { - this._listeners.clear(); - } - setOffset(_offset: any) {} flattenOffset() {} - stopAnimation(callback?: Function) { - callback?.(this.__getValue()); - } - resetAnimation(callback?: Function) { - callback?.(this.__getValue()); - } } function createAnimation(onStart?: () => void) { @@ -557,9 +643,11 @@ function createAnimation(onStart?: () => void) { // so a test asserting `toHaveStyle({ opacity: 0.3 })` against an Animated.Value(0.3) // must see the number, not the node. function resolveAnimatedLeaf(v: any): any { - if (v instanceof AnimatedValue) return v.getValue(); + if (v instanceof AnimatedNode) return v.__getValue(); if (v && typeof v === "object" && typeof v.__getValue === "function") return v.__getValue(); - if (v && typeof v === "object" && typeof v.getValue === "function") return v.getValue(); + if (v && typeof v === "object" && !Array.isArray(v) && typeof v.getValue === "function") { + return v.getValue(); + } return v; } @@ -574,7 +662,7 @@ function resolveAnimatedStyle(style: any): any { const val = style[key]; if (key === "transform" && Array.isArray(val)) { out[key] = val.map((t: any) => - t && typeof t === "object" && !(t instanceof AnimatedValue) + t && typeof t === "object" && !(t instanceof AnimatedNode) ? Object.fromEntries(Object.keys(t).map((tk) => [tk, resolveAnimatedLeaf(t[tk])])) : resolveAnimatedLeaf(t), ); @@ -587,12 +675,47 @@ function resolveAnimatedStyle(style: any): any { return style; } +// Collect every animated node reachable from a style (same traversal shape as +// resolveAnimatedStyle) so the wrapper can subscribe to all of them. +function collectAnimatedNodes(style: any, out: Set) { + if (style instanceof AnimatedNode) { + out.add(style); + return; + } + if (Array.isArray(style)) { + for (const s of style) collectAnimatedNodes(s, out); + return; + } + if (style && typeof style === "object") { + for (const key of Object.keys(style)) collectAnimatedNodes(style[key], out); + } +} + +// Subscribe a component to every animated node in its style, re-rendering on +// any change — the mock equivalent of real RN's createAnimatedComponent update +// path (which forceUpdate's under the test renderer). Re-subscribes each render +// because the style (and the nodes inside it) may be new objects every render. +function useAnimatedStyleSubscription(style: any) { + const [, force] = React.useReducer((n: number) => n + 1, 0); + React.useEffect(() => { + const nodes = new Set(); + collectAnimatedNodes(style, nodes); + if (nodes.size === 0) return undefined; + const subs: Array<[AnimatedNode, string]> = []; + for (const node of nodes) subs.push([node, node.addListener(force)]); + return () => { + for (const [node, id] of subs) node.removeListener(id); + }; + }); +} + function createAnimatedWrapper(displayName: string) { // Render the *base* host (e.g. "Text", "View") — not "Animated.Text" — so RNTL's // host-component detection (queryByText only descends into Text hosts) and real RN // agree. The Animated.* component still carries its own displayName for identity. const hostName = displayName.replace(/^Animated\./, ""); const Component = React.forwardRef((props: any, ref: any) => { + useAnimatedStyleSubscription(props?.style); if (props && props.style !== undefined) { const { style, ...rest } = props; return React.createElement(hostName, { ...rest, style: resolveAnimatedStyle(style), ref }); @@ -626,6 +749,29 @@ function startTracking(value: AnimatedValue, toValue: any) { } } +// Stateful diffClamp: accumulates deltas of its input, clamped (RN semantics). +class AnimatedDiffClamp extends AnimatedNode { + private _current: number; + + constructor(a: any, min: number, max: number) { + super(); + let lastInput = operandValue(a); + this._current = Math.min(Math.max(lastInput, min), max); + if (a instanceof AnimatedNode) { + a.addListener(({ value }: { value: number }) => { + const diff = value - lastInput; + lastInput = value; + this._current = Math.min(Math.max(this._current + diff, min), max); + this._notify(); + }); + } + } + + __getValue(): number { + return this._current; + } +} + export function createAnimatedMock() { return { Value: AnimatedValue, @@ -817,46 +963,29 @@ export function createAnimatedMock() { // Synchronous in mock — no real delay needed for testing return createAnimation(); }), - add: vi.fn((a: any, b: any) => { - const aVal = a instanceof AnimatedValue ? a.getValue() : typeof a === "number" ? a : 0; - const bVal = b instanceof AnimatedValue ? b.getValue() : typeof b === "number" ? b : 0; - return new AnimatedValue(aVal + bVal); - }), - subtract: vi.fn((a: any, b: any) => { - const aVal = a instanceof AnimatedValue ? a.getValue() : typeof a === "number" ? a : 0; - const bVal = b instanceof AnimatedValue ? b.getValue() : typeof b === "number" ? b : 0; - return new AnimatedValue(aVal - bVal); - }), - multiply: vi.fn((a: any, b: any) => { - const aVal = a instanceof AnimatedValue ? a.getValue() : typeof a === "number" ? a : 0; - const bVal = b instanceof AnimatedValue ? b.getValue() : typeof b === "number" ? b : 0; - return new AnimatedValue(aVal * bVal); - }), - divide: vi.fn((a: any, b: any) => { - const aVal = a instanceof AnimatedValue ? a.getValue() : typeof a === "number" ? a : 0; - const bVal = b instanceof AnimatedValue ? b.getValue() : typeof b === "number" ? b : 0; - return new AnimatedValue(bVal === 0 ? 0 : aVal / bVal); - }), - modulo: vi.fn((a: any, modulus: number) => { - const aVal = a instanceof AnimatedValue ? a.getValue() : typeof a === "number" ? a : 0; - return new AnimatedValue(((aVal % modulus) + modulus) % modulus); - }), - diffClamp: vi.fn((a: any, min: number, max: number) => { - const result = new AnimatedValue( - Math.min(Math.max(a instanceof AnimatedValue ? a.getValue() : 0, min), max), - ); - if (a instanceof AnimatedValue) { - let lastInput = a.getValue(); - let current = result.getValue(); - a.addListener(({ value }: { value: number }) => { - const diff = value - lastInput; - lastInput = value; - current = Math.min(Math.max(current + diff, min), max); - result.setValue(current); - }); - } - return result; - }), + // Arithmetic combinators are LIVE derived nodes: they recompute from their + // operands on every read and re-notify when any operand moves (real RN + // semantics — previously these returned dead snapshots). + add: vi.fn((a: any, b: any) => new AnimatedOp([a, b], () => operandValue(a) + operandValue(b))), + subtract: vi.fn( + (a: any, b: any) => new AnimatedOp([a, b], () => operandValue(a) - operandValue(b)), + ), + multiply: vi.fn( + (a: any, b: any) => new AnimatedOp([a, b], () => operandValue(a) * operandValue(b)), + ), + divide: vi.fn( + (a: any, b: any) => + new AnimatedOp([a, b], () => { + const divisor = operandValue(b); + // Historical mock behavior (pinned by the conformance suite): 0, not Infinity. + return divisor === 0 ? 0 : operandValue(a) / divisor; + }), + ), + modulo: vi.fn( + (a: any, modulus: number) => + new AnimatedOp([a], () => ((operandValue(a) % modulus) + modulus) % modulus), + ), + diffClamp: vi.fn((a: any, min: number, max: number) => new AnimatedDiffClamp(a, min, max)), event: vi.fn((argMapping: any[], config?: any) => { const handler = vi.fn((...args: any[]) => { // Walk the arg mapping and extract values from the event args @@ -900,6 +1029,15 @@ export function createAnimatedMock() { unforkEvent: vi.fn(), createAnimatedComponent: vi.fn((component: any) => { const Wrapper = React.forwardRef((props: any, ref: any) => { + useAnimatedStyleSubscription(props?.style); + if (props && props.style !== undefined) { + const { style, ...rest } = props; + return React.createElement(component, { + ...rest, + style: resolveAnimatedStyle(style), + ref, + }); + } return React.createElement(component, { ...props, ref }); }); Wrapper.displayName = `Animated(${component.displayName || component.name || "Component"})`; @@ -913,3 +1051,7 @@ export function createAnimatedMock() { SectionList: createAnimatedWrapper("Animated.SectionList"), }; } + +// Node classes exported for the registry's useAnimatedValue/XY/Color hooks — +// they must construct values without rebuilding the whole Animated namespace. +export { AnimatedNode, AnimatedValue, AnimatedValueXY, AnimatedColor }; diff --git a/packages/vitest-native/src/mocks/registry.ts b/packages/vitest-native/src/mocks/registry.ts index 84c537a..c5063c8 100644 --- a/packages/vitest-native/src/mocks/registry.ts +++ b/packages/vitest-native/src/mocks/registry.ts @@ -28,7 +28,12 @@ import { createDrawerLayoutAndroidMock } from "./components/DrawerLayoutAndroid. import { createPlatformMock } from "./apis/Platform.js"; import { createDimensionsMock } from "./apis/Dimensions.js"; import { createStyleSheetMock } from "./apis/StyleSheet.js"; -import { createAnimatedMock } from "./apis/Animated.js"; +import { + createAnimatedMock, + AnimatedValue, + AnimatedValueXY, + AnimatedColor, +} from "./apis/Animated.js"; import { createAlertMock } from "./apis/Alert.js"; import { createLinkingMock } from "./apis/Linking.js"; import { createAppStateMock } from "./apis/AppState.js"; @@ -273,24 +278,24 @@ function createTouchableMock() { }; } +// These are HOOKS: real RN memoizes the value with useRef, so it must survive +// re-renders. Previously each render minted a fresh value (losing animation +// state mid-test) — and rebuilt the entire Animated namespace to do it. function createUseAnimatedValueMock() { return vi.fn((initialValue: number) => { - const AnimatedMod = createAnimatedMock(); - return new AnimatedMod.Value(initialValue); + return React.useRef(new AnimatedValue(initialValue)).current; }); } function createUseAnimatedValueXYMock() { return vi.fn((initialValue?: { x: number; y: number }) => { - const AnimatedMod = createAnimatedMock(); - return new AnimatedMod.ValueXY(initialValue); + return React.useRef(new AnimatedValueXY(initialValue)).current; }); } function createUseAnimatedColorMock() { return vi.fn((initialValue?: any) => { - const AnimatedMod = createAnimatedMock(); - return new AnimatedMod.Color(initialValue); + return React.useRef(new AnimatedColor(initialValue)).current; }); } diff --git a/packages/vitest-native/tests/animated-graph.test.tsx b/packages/vitest-native/tests/animated-graph.test.tsx new file mode 100644 index 0000000..42ebe7b --- /dev/null +++ b/packages/vitest-native/tests/animated-graph.test.tsx @@ -0,0 +1,195 @@ +/** + * The Animated node graph: live derived nodes, offsets, and subscribing + * wrappers — the behaviors real RN has and the old snapshot mock lacked. + * Rendering behavior is additionally gated against real RN by the crosscheck + * probes (animated-setvalue-updates-rendered-style and friends). + */ +import { describe, it, expect } from "vitest"; +import React from "react"; +import { act, render, screen } from "@testing-library/react-native"; +import { Animated, useAnimatedValue } from "react-native"; + +describe("live derived nodes", () => { + it("numeric interpolations recompute from the source on every read", () => { + const v = new Animated.Value(0); + const interp = v.interpolate({ inputRange: [0, 1], outputRange: [10, 20] }); + expect(interp.__getValue()).toBe(10); + v.setValue(0.5); + expect(interp.__getValue()).toBe(15); + v.setValue(1); + expect(interp.__getValue()).toBe(20); + }); + + it("string interpolations stay live too", () => { + const v = new Animated.Value(0); + const deg = v.interpolate({ inputRange: [0, 1], outputRange: ["0deg", "360deg"] }); + expect(deg.__getValue()).toBe("0deg"); + v.setValue(0.5); + expect(deg.__getValue()).toBe("180deg"); + }); + + it("numeric interpolations chain (RN allows interpolate().interpolate())", () => { + const v = new Animated.Value(0); + const chained = v + .interpolate({ inputRange: [0, 1], outputRange: [0, 10] }) + .interpolate({ inputRange: [0, 10], outputRange: [100, 200] }); + expect(chained.__getValue()).toBe(100); + v.setValue(1); + expect(chained.__getValue()).toBe(200); + }); + + it("chaining off a string interpolation still throws (RN parity)", () => { + const v = new Animated.Value(0); + const s = v.interpolate({ inputRange: [0, 1], outputRange: ["0deg", "90deg"] }); + expect(() => s.interpolate({ inputRange: [0, 1], outputRange: [0, 1] })).toThrow( + /string-valued interpolation/, + ); + }); + + it("arithmetic ops are live and accept derived nodes as operands", () => { + const a = new Animated.Value(2); + const b = new Animated.Value(3); + const sum = Animated.add(a, b); + expect(sum.__getValue()).toBe(5); + a.setValue(10); + expect(sum.__getValue()).toBe(13); + + // An interpolation as an operand (previously coerced to 0). + const scaled = Animated.multiply( + a.interpolate({ inputRange: [0, 10], outputRange: [0, 1] }), + b, + ); + expect(scaled.__getValue()).toBe(3); + b.setValue(4); + expect(scaled.__getValue()).toBe(4); + }); + + it("value listeners fire through derived nodes", () => { + const v = new Animated.Value(0); + const interp = v.interpolate({ inputRange: [0, 1], outputRange: [0, 100] }); + const seen: number[] = []; + interp.addListener(({ value }: { value: number }) => seen.push(value)); + v.setValue(0.25); + v.setValue(0.5); + expect(seen).toEqual([25, 50]); + }); +}); + +describe("offsets (the PanResponder drag pattern)", () => { + it("setOffset adds on read; flattenOffset folds; extractOffset moves", () => { + const v = new Animated.Value(10); + v.setOffset(5); + expect(v.__getValue()).toBe(15); + v.flattenOffset(); + expect(v.__getValue()).toBe(15); // observable value unchanged + v.setValue(0); + v.setOffset(3); + v.extractOffset(); + expect(v.__getValue()).toBe(3); // value moved into offset + v.setValue(4); // fresh gesture delta on top of the offset + expect(v.__getValue()).toBe(7); + }); + + it("ValueXY delegates offsets and reports joint listener values", () => { + const xy = new Animated.ValueXY({ x: 1, y: 2 }); + xy.setOffset({ x: 10, y: 20 }); + expect(xy.getValue()).toEqual({ x: 11, y: 22 }); + + const seen: Array<{ x: number; y: number }> = []; + xy.addListener((v: { x: number; y: number }) => seen.push(v)); + xy.setValue({ x: 5, y: 6 }); + // One notification per axis write, each carrying the full current pair. + expect(seen.at(-1)).toEqual({ x: 15, y: 26 }); + }); +}); + +describe("subscribing wrappers", () => { + it("re-renders the host style when a value moves after render", async () => { + const opacity = new Animated.Value(0.3); + await render(); + expect(screen.getByTestId("live")).toHaveStyle({ opacity: 0.3 }); + await act(async () => opacity.setValue(0.9)); + expect(screen.getByTestId("live")).toHaveStyle({ opacity: 0.9 }); + }); + + it("timing().start() drives the rendered style through tracking", async () => { + const opacity = new Animated.Value(0); + await render(); + await act(async () => { + Animated.timing(opacity, { toValue: 1, duration: 200, useNativeDriver: false }).start(); + }); + expect(screen.getByTestId("anim")).toHaveStyle({ opacity: 1 }); + }); + + it("unsubscribes on unmount (no listener leak, no post-unmount updates)", async () => { + const v = new Animated.Value(0); + const { unmount } = await render(); + await act(async () => unmount()); + // A post-unmount setValue must not throw or warn about updates on + // unmounted components — the subscription was removed with the effect. + expect(() => v.setValue(1)).not.toThrow(); + }); +}); + +describe("useAnimatedValue is a real hook", () => { + it("returns the SAME node across re-renders", async () => { + const seen: unknown[] = []; + function Probe({ tick }: { tick: number }) { + const v = useAnimatedValue(0.5); + seen.push(v); + return ; + } + const { rerender } = await render(); + await act(async () => rerender()); + expect(seen.length).toBeGreaterThanOrEqual(2); + expect(seen[0]).toBe(seen[1]); + }); + + it("preserves animation state across re-renders", async () => { + let captured: any; + function Probe({ tick }: { tick: number }) { + const v = useAnimatedValue(0); + captured = v; + return ; + } + const { rerender } = await render(); + await act(async () => captured.setValue(0.7)); + await act(async () => rerender()); + // The value survives the re-render (previously a fresh node reset it). + expect(captured.getValue()).toBe(0.7); + expect(screen.getByTestId("p-2")).toHaveStyle({ opacity: 0.7 }); + }); +}); + +describe("regression: existing shapes preserved", () => { + it("__getValue exists on plain values (RN's own tests call it)", () => { + const v = new Animated.Value(0.25); + expect(v.__getValue()).toBe(0.25); + expect(v.getValue()).toBe(0.25); + }); + + it("Animated.event still maps native events into values", () => { + const x = new Animated.Value(0); + const handler = Animated.event([{ nativeEvent: { contentOffset: { x } } }], { + useNativeDriver: false, + }); + handler({ nativeEvent: { contentOffset: { x: 55 } } }); + expect(x.getValue()).toBe(55); + }); + + it("diffClamp accumulates deltas within bounds and notifies", () => { + const v = new Animated.Value(0); + const clamped = Animated.diffClamp(v, 0, 20); + const seen: number[] = []; + clamped.addListener(({ value }: { value: number }) => seen.push(value)); + v.setValue(30); // +30 clamps to 20 + v.setValue(25); // -5 → 15 + expect(clamped.__getValue()).toBe(15); + expect(seen).toEqual([20, 15]); + }); + + it("divide by zero keeps the historical mock behavior (0, not Infinity)", () => { + const q = Animated.divide(new Animated.Value(4), new Animated.Value(0)); + expect(q.__getValue()).toBe(0); + }); +}); diff --git a/packages/vitest-native/tests/rn-086-exports.test.ts b/packages/vitest-native/tests/rn-086-exports.test.ts index c17cc99..d2c5bd9 100644 --- a/packages/vitest-native/tests/rn-086-exports.test.ts +++ b/packages/vitest-native/tests/rn-086-exports.test.ts @@ -7,26 +7,41 @@ */ import { describe, it, expect } from "vitest"; +import React from "react"; +import { render } from "@testing-library/react-native"; import { Animated, EventEmitter, useAnimatedColor, useAnimatedValueXY } from "react-native"; +// These are HOOKS — like real RN's, they memoize with useRef and therefore +// must run inside a component (calling them bare throws the null-dispatcher +// error on-device too). +async function renderHookValue(hook: () => T): Promise { + let captured: T | undefined; + function Probe() { + captured = hook(); + return null; + } + await render(React.createElement(Probe)); + return captured as T; +} + describe("useAnimatedValueXY (RN 0.86)", () => { - it("returns an Animated.ValueXY", () => { - const value = useAnimatedValueXY({ x: 10, y: 20 }); + it("returns an Animated.ValueXY", async () => { + const value = await renderHookValue(() => useAnimatedValueXY({ x: 10, y: 20 })); expect(value).toBeInstanceOf(Animated.ValueXY); expect(value.x.getValue()).toBe(10); expect(value.y.getValue()).toBe(20); }); - it("defaults to the origin", () => { - const value = useAnimatedValueXY(); + it("defaults to the origin", async () => { + const value = await renderHookValue(() => useAnimatedValueXY()); expect(value.x.getValue()).toBe(0); expect(value.y.getValue()).toBe(0); }); }); describe("useAnimatedColor (RN 0.86)", () => { - it("returns an Animated.Color parsed from a string", () => { - const color = useAnimatedColor("rgba(255, 0, 0, 1)"); + it("returns an Animated.Color parsed from a string", async () => { + const color = await renderHookValue(() => useAnimatedColor("rgba(255, 0, 0, 1)")); expect(color).toBeInstanceOf(Animated.Color); expect(color.r.getValue()).toBe(255); expect(color.g.getValue()).toBe(0); diff --git a/website/guide/fidelity.md b/website/guide/fidelity.md index 1b4c60e..fe498c6 100644 --- a/website/guide/fidelity.md +++ b/website/guide/fidelity.md @@ -13,7 +13,7 @@ generated from the corpus itself, so the numbers below are exactly what ships. ## Summary -- **75 / 75 probes** match between the mock engine and real React Native. +- **78 / 78 probes** match between the mock engine and real React Native. - CI runs the same corpus across **React Native 0.81–0.85** on every commit. - Reproduce it yourself: `bun run crosscheck`. @@ -33,8 +33,11 @@ across both engines. | `accessibility-value` | ✅ match | | `activityindicator-renders` | ✅ match | | `animated-image-renders` | ✅ match | +| `animated-interpolation-live-style` | ✅ match | | `animated-scrollview-renders` | ✅ match | +| `animated-setvalue-updates-rendered-style` | ✅ match | | `animated-text-renders` | ✅ match | +| `animated-transform-live-style` | ✅ match | | `animated-value-initial-style` | ✅ match | | `animated-view-renders` | ✅ match | | `button-renders-title` | ✅ match | From d5044e50dae6f787cfdc3c9268a4a4c8fa26bb72 Mon Sep 17 00:00:00 2001 From: Daniel Fry Date: Sun, 5 Jul 2026 21:48:17 +0100 Subject: [PATCH 2/2] review: separate graph children from user listeners (RN attach/detach parity) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../vitest-native/src/mocks/apis/Animated.ts | 115 +++++++++++++----- packages/vitest-native/src/mocks/registry.ts | 12 +- .../tests/animated-graph.test.tsx | 38 +++++- 3 files changed, 131 insertions(+), 34 deletions(-) diff --git a/packages/vitest-native/src/mocks/apis/Animated.ts b/packages/vitest-native/src/mocks/apis/Animated.ts index 3ee5ec8..d6815d0 100644 --- a/packages/vitest-native/src/mocks/apis/Animated.ts +++ b/packages/vitest-native/src/mocks/apis/Animated.ts @@ -257,15 +257,19 @@ function checkValidRanges(inputRange: number[], outputRange: unknown[]): void { // Real RN's Animated is a dependency graph: derived nodes (interpolations, // arithmetic ops) recompute from their parents on every read, and attached // components re-render when any node in their style changes. The mock mirrors -// that shape: every node has a live __getValue(), derived nodes subscribe to -// their parents at construction, and the Animated.* wrappers subscribe to the -// nodes in their style — so a setValue()/timing().start() after render updates -// the rendered style exactly as it does on-device. +// that shape: every node has a live __getValue(), derived nodes attach to +// their parents while they have consumers (mirroring RN's __attach/__detach), +// and the Animated.* wrappers register as graph children of the nodes in their +// style — so a setValue()/timing().start() after render updates the rendered +// style exactly as it does on-device. USER listeners (addListener) and GRAPH +// children are separate populations: removeAllListeners() removes only the +// former, exactly like real RN. type ListenerFn = (state: { value: any }) => void; abstract class AnimatedNode { protected _listeners: Map = new Map(); + private _children: Set<() => void> = new Set(); private _listenerIdCounter = 0; /** Current value of this node, computed live from its inputs. */ @@ -275,24 +279,54 @@ abstract class AnimatedNode { return this.__getValue(); } + // Derived nodes attach to their parents LAZILY — only while they have a + // consumer (listener or child) of their own, mirroring RN's __attach/__detach + // lifecycle. Without this, an interpolation constructed inside a render + // function would leak one permanent parent subscription per re-render. + // Leaf values override neither hook. + protected _attachToParents(): void {} + protected _detachFromParents(): void {} + + private _consumerCount(): number { + return this._listeners.size + this._children.size; + } + addListener(callback: Function): string { const id = String(++this._listenerIdCounter); this._listeners.set(id, callback as ListenerFn); + if (this._consumerCount() === 1) this._attachToParents(); return id; } removeListener(id: string) { - this._listeners.delete(id); + if (this._listeners.delete(id) && this._consumerCount() === 0) this._detachFromParents(); } removeAllListeners() { + // USER listeners only — graph children (views, derived nodes) stay + // attached, exactly like real RN's removeAllListeners. + const had = this._listeners.size > 0; this._listeners.clear(); + if (had && this._consumerCount() === 0) this._detachFromParents(); + } + + /** Graph edge: `fn` runs whenever this node's value may have changed. */ + __addChild(fn: () => void) { + this._children.add(fn); + if (this._consumerCount() === 1) this._attachToParents(); } - /** Notify listeners with the node's CURRENT (offset-inclusive) value. */ + __removeChild(fn: () => void) { + if (this._children.delete(fn) && this._consumerCount() === 0) this._detachFromParents(); + } + + /** Notify consumers with the node's CURRENT (offset-inclusive) value. */ protected _notify() { - const value = this.__getValue(); - this._listeners.forEach((fn) => fn({ value })); + if (this._listeners.size > 0) { + const value = this.__getValue(); + this._listeners.forEach((fn) => fn({ value })); + } + this._children.forEach((fn) => fn()); } interpolate(config: any): AnimatedNode { @@ -321,7 +355,7 @@ abstract class AnimatedNode { class AnimatedValue extends AnimatedNode { private _value: number; private _offset: number = 0; - _tracking: { source: AnimatedValue; listenerId: string } | null = null; + _tracking: { source: AnimatedValue; child: () => void } | null = null; constructor(value: number = 0) { super(); @@ -339,8 +373,9 @@ class AnimatedValue extends AnimatedNode { // Offsets follow RN's semantics: the offset is added on read; flatten folds // it into the value; extract moves the value into the offset (the canonical - // PanResponder drag pattern). Neither changes the observable value, so - // neither notifies. + // PanResponder drag pattern). None of the three notifies — matching RN's JS + // driver, where offset changes surface on the NEXT value set (flatten and + // extract additionally leave the observable value unchanged). setOffset(offset: number) { this._offset = offset; } @@ -372,8 +407,16 @@ class AnimatedInterpolation extends AnimatedNode { if (typeof outputRange[0] === "string") { this._preparedString = prepareStringInterpolation(outputRange); } - // Live propagation: recompute + re-notify whenever the parent moves. - parent.addListener(() => this._notify()); + } + + private _propagate = () => this._notify(); + + protected override _attachToParents() { + this._parent.__addChild(this._propagate); + } + + protected override _detachFromParents() { + this._parent.__removeChild(this._propagate); } __getValue(): number | string { @@ -421,13 +464,21 @@ class AnimatedInterpolation extends AnimatedNode { // A derived node computed from one or more inputs (add/multiply/…/diffClamp). class AnimatedOp extends AnimatedNode { private _compute: () => number; + private _parents: AnimatedNode[]; + private _propagate = () => this._notify(); constructor(inputs: any[], compute: () => number) { super(); this._compute = compute; - for (const input of inputs) { - if (input instanceof AnimatedNode) input.addListener(() => this._notify()); - } + this._parents = inputs.filter((i): i is AnimatedNode => i instanceof AnimatedNode); + } + + protected override _attachToParents() { + for (const parent of this._parents) parent.__addChild(this._propagate); + } + + protected override _detachFromParents() { + for (const parent of this._parents) parent.__removeChild(this._propagate); } __getValue(): number { @@ -592,9 +643,11 @@ class AnimatedColor extends AnimatedNode { this.b = new AnimatedValue(0); this.a = new AnimatedValue(1); } - // Channel moves re-notify the color (live wrappers + listeners). + // Channel moves re-notify the color (live wrappers + listeners). Graph + // children, not user listeners: a user's channel.removeAllListeners() + // must not sever the color from its own channels. for (const channel of [this.r, this.g, this.b, this.a]) { - channel.addListener(() => this._notify()); + channel.__addChild(() => this._notify()); } } @@ -701,10 +754,12 @@ function useAnimatedStyleSubscription(style: any) { const nodes = new Set(); collectAnimatedNodes(style, nodes); if (nodes.size === 0) return undefined; - const subs: Array<[AnimatedNode, string]> = []; - for (const node of nodes) subs.push([node, node.addListener(force)]); + // Graph children (like real RN's attached views), so the subscription + // survives a user's removeAllListeners() — and lazily attaches any + // render-constructed derived nodes to their sources. + for (const node of nodes) node.__addChild(force); return () => { - for (const [node, id] of subs) node.removeListener(id); + for (const node of nodes) node.__removeChild(force); }; }); } @@ -728,7 +783,7 @@ function createAnimatedWrapper(displayName: string) { function stopTracking(value: AnimatedValue) { if (value._tracking) { - value._tracking.source.removeListener(value._tracking.listenerId); + value._tracking.source.__removeChild(value._tracking.child); value._tracking = null; } } @@ -738,12 +793,12 @@ function startTracking(value: AnimatedValue, toValue: any) { stopTracking(value); if (toValue instanceof AnimatedValue) { - // Track the source value + // Track the source value — as a graph child (real RN's TrackingAnimatedNode + // is a child too, so it survives the source's removeAllListeners()). value.setValue(toValue.getValue()); - const listenerId = toValue.addListener(({ value: v }: { value: number }) => { - value.setValue(v); - }); - value._tracking = { source: toValue, listenerId }; + const child = () => value.setValue(toValue.__getValue()); + toValue.__addChild(child); + value._tracking = { source: toValue, child }; } else { value.setValue(toValue); } @@ -758,7 +813,11 @@ class AnimatedDiffClamp extends AnimatedNode { let lastInput = operandValue(a); this._current = Math.min(Math.max(lastInput, min), max); if (a instanceof AnimatedNode) { - a.addListener(({ value }: { value: number }) => { + // Permanent graph child (not a lazy attach): the clamp accumulates + // HISTORY, so it must observe every parent move even while unobserved + // itself — and must survive the parent's removeAllListeners(). + a.__addChild(() => { + const value = Number(a.__getValue()); const diff = value - lastInput; lastInput = value; this._current = Math.min(Math.max(this._current + diff, min), max); diff --git a/packages/vitest-native/src/mocks/registry.ts b/packages/vitest-native/src/mocks/registry.ts index c5063c8..718aac0 100644 --- a/packages/vitest-native/src/mocks/registry.ts +++ b/packages/vitest-native/src/mocks/registry.ts @@ -283,19 +283,25 @@ function createTouchableMock() { // state mid-test) — and rebuilt the entire Animated namespace to do it. function createUseAnimatedValueMock() { return vi.fn((initialValue: number) => { - return React.useRef(new AnimatedValue(initialValue)).current; + const ref = React.useRef | null>(null); + if (ref.current == null) ref.current = new AnimatedValue(initialValue); + return ref.current; }); } function createUseAnimatedValueXYMock() { return vi.fn((initialValue?: { x: number; y: number }) => { - return React.useRef(new AnimatedValueXY(initialValue)).current; + const ref = React.useRef | null>(null); + if (ref.current == null) ref.current = new AnimatedValueXY(initialValue); + return ref.current; }); } function createUseAnimatedColorMock() { return vi.fn((initialValue?: any) => { - return React.useRef(new AnimatedColor(initialValue)).current; + const ref = React.useRef | null>(null); + if (ref.current == null) ref.current = new AnimatedColor(initialValue); + return ref.current; }); } diff --git a/packages/vitest-native/tests/animated-graph.test.tsx b/packages/vitest-native/tests/animated-graph.test.tsx index 42ebe7b..58e1c73 100644 --- a/packages/vitest-native/tests/animated-graph.test.tsx +++ b/packages/vitest-native/tests/animated-graph.test.tsx @@ -121,13 +121,45 @@ describe("subscribing wrappers", () => { expect(screen.getByTestId("anim")).toHaveStyle({ opacity: 1 }); }); - it("unsubscribes on unmount (no listener leak, no post-unmount updates)", async () => { + it("unsubscribes on unmount (no consumers left on the value)", async () => { const v = new Animated.Value(0); const { unmount } = await render(); await act(async () => unmount()); - // A post-unmount setValue must not throw or warn about updates on - // unmounted components — the subscription was removed with the effect. expect(() => v.setValue(1)).not.toThrow(); + // The graph edge was actually removed, not just tolerated. + expect((v as any)._children.size).toBe(0); + expect((v as any)._listeners.size).toBe(0); + }); + + it("render-constructed interpolations do not accumulate on the source", async () => { + // The user pattern: interpolate() INSIDE render. Every re-render constructs + // a new interpolation; the previous one must detach from the source when + // the wrapper unsubscribes from it (RN's attach/detach lifecycle). + const v = new Animated.Value(0); + function Fading() { + const opacity = v.interpolate({ inputRange: [0, 1], outputRange: [0, 1] }); + return ; + } + await render(); + for (const step of [0.2, 0.4, 0.6, 0.8, 1]) { + await act(async () => v.setValue(step)); + } + expect(screen.getByTestId("fade")).toHaveStyle({ opacity: 1 }); + // One live interpolation child at most (the current render's), never five. + expect((v as any)._children.size).toBeLessThanOrEqual(1); + }); + + it("removeAllListeners removes user listeners but not view updates (RN parity)", async () => { + const v = new Animated.Value(0.1); + const seen: number[] = []; + v.addListener(({ value }: { value: number }) => seen.push(value)); + await render(); + v.removeAllListeners(); + await act(async () => v.setValue(0.6)); + // The user listener is gone… + expect(seen).toEqual([]); + // …but the mounted component still re-renders, like on-device. + expect(screen.getByTestId("rl")).toHaveStyle({ opacity: 0.6 }); }); });