Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/animated-node-graph.md
Original file line number Diff line number Diff line change
@@ -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.
55 changes: 54 additions & 1 deletion packages/vitest-native/crosscheck/crosscheck.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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(<Animated.View testID="av-live" style={{ opacity }} />);
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(<Animated.View testID="av-interp" style={{ opacity }} />);
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(<Animated.View testID="av-tx" style={{ transform: [{ translateX: x }] }} />);
await act(async () => x.setValue(42));
const style = StyleSheet.flatten(screen.getByTestId("av-tx").props.style) as {
transform?: Array<Record<string, unknown>>;
};
return { transform: style?.transform };
});

// --- accessibility props (what RNTL byRole / toBeDisabled depend on) ---
probe("a11y-role", async () => {
await render(<Pressable testID="p" accessibilityRole="button" />);
Expand Down
2 changes: 1 addition & 1 deletion packages/vitest-native/crosscheck/fidelity-badge.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"schemaVersion": 1,
"label": "RN fidelity",
"message": "75/75 probes",
"message": "78/78 probes",
"color": "brightgreen"
}
Loading
Loading