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..d6815d0 100644 --- a/packages/vitest-native/src/mocks/apis/Animated.ts +++ b/packages/vitest-native/src/mocks/apis/Animated.ts @@ -252,40 +252,174 @@ function checkValidRanges(inputRange: number[], outputRange: unknown[]): void { } } -class AnimatedValue { - private _value: number; - private _listeners: Map = new Map(); +// ─── 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 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; - _tracking: { source: AnimatedValue; listenerId: string } | null = null; - constructor(value: number = 0) { - this._value = value; - } + /** Current value of this node, computed live from its inputs. */ + abstract __getValue(): any; - setValue(value: number) { - this._value = value; - this._listeners.forEach((fn) => fn({ value })); + getValue() { + return this.__getValue(); } - getValue() { - return this._value; + // 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); + 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(); + } + + __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() { + if (this._listeners.size > 0) { + const value = this.__getValue(); + this._listeners.forEach((fn) => fn({ value })); + } + this._children.forEach((fn) => fn()); + } + + 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; child: () => void } | 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). 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; + } + + 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); + } + } + + private _propagate = () => this._notify(); - interpolate(config: any) { + protected override _attachToParents() { + this._parent.__addChild(this._propagate); + } + + protected override _detachFromParents() { + this._parent.__removeChild(this._propagate); + } + + __getValue(): number | string { const { inputRange, outputRange, @@ -293,49 +427,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 +449,57 @@ 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; + private _parents: AnimatedNode[]; + private _propagate = () => this._notify(); + + constructor(inputs: any[], compute: () => number) { + super(); + this._compute = compute; + this._parents = inputs.filter((i): i is AnimatedNode => i instanceof AnimatedNode); } - toJSON() { - return this._value; + protected override _attachToParents() { + for (const parent of this._parents) parent.__addChild(this._propagate); } - setOffset(_offset: number) {} - flattenOffset() {} - extractOffset() {} + protected override _detachFromParents() { + for (const parent of this._parents) parent.__removeChild(this._propagate); + } + + __getValue(): number { + return this._compute(); + } +} + +/** 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 +511,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 +558,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 +613,42 @@ 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). 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.__addChild(() => this._notify()); + } } setValue(value: any) { @@ -503,8 +664,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 +674,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 +696,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 +715,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 +728,49 @@ 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; + // 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 of nodes) node.__removeChild(force); + }; + }); +} + 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 }); @@ -605,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; } } @@ -615,17 +793,44 @@ 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); } } +// 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) { + // 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); + this._notify(); + }); + } + } + + __getValue(): number { + return this._current; + } +} + export function createAnimatedMock() { return { Value: AnimatedValue, @@ -817,46 +1022,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 +1088,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 +1110,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..718aac0 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,30 @@ 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); + 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 }) => { - const AnimatedMod = createAnimatedMock(); - return new AnimatedMod.ValueXY(initialValue); + 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) => { - const AnimatedMod = createAnimatedMock(); - return new AnimatedMod.Color(initialValue); + 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 new file mode 100644 index 0000000..58e1c73 --- /dev/null +++ b/packages/vitest-native/tests/animated-graph.test.tsx @@ -0,0 +1,227 @@ +/** + * 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 consumers left on the value)", async () => { + const v = new Animated.Value(0); + const { unmount } = await render(); + await act(async () => unmount()); + 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 }); + }); +}); + +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 |