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 |