diff --git a/packages/react-native/Libraries/Animated/AnimationTimingUtils.js b/packages/react-native/Libraries/Animated/AnimationTimingUtils.js
new file mode 100644
index 000000000000..d153a622dee7
--- /dev/null
+++ b/packages/react-native/Libraries/Animated/AnimationTimingUtils.js
@@ -0,0 +1,39 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ * @flow strict-local
+ * @format
+ */
+
+'use strict';
+
+export type AnimationTimeProvider = () => number;
+
+const defaultAnimationTimeProvider: AnimationTimeProvider = () => Date.now();
+
+let animationTimeProvider: AnimationTimeProvider = defaultAnimationTimeProvider;
+
+/**
+ * Returns the current time, in milliseconds, used to drive JavaScript-based
+ * animations (timing, spring and decay).
+ *
+ * Defaults to `Date.now()`. The value can be overridden with
+ * `setAnimationTimeProvider`, e.g. to drive animations from a controlled clock
+ * in tests.
+ */
+export function getCurrentAnimationTime(): number {
+ return animationTimeProvider();
+}
+
+/**
+ * Overrides the time source used by `getCurrentAnimationTime`. Pass `null` to restore
+ * the default `Date.now()`-based provider.
+ */
+export function setAnimationTimeProvider(
+ provider: ?AnimationTimeProvider,
+): void {
+ animationTimeProvider = provider ?? defaultAnimationTimeProvider;
+}
diff --git a/packages/react-native/Libraries/Animated/__tests__/AnimatedColor-itest.js b/packages/react-native/Libraries/Animated/__tests__/AnimatedColor-itest.js
new file mode 100644
index 000000000000..651811587b5b
--- /dev/null
+++ b/packages/react-native/Libraries/Animated/__tests__/AnimatedColor-itest.js
@@ -0,0 +1,195 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ * @flow strict-local
+ * @format
+ */
+
+import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment';
+
+import {DRIVERS} from './AnimatedFantomTestUtils';
+import * as Fantom from '@react-native/fantom';
+import * as React from 'react';
+import {Animated} from 'react-native';
+
+// Renders `color` as a View's backgroundColor so it can be observed through the
+// public rendered output rather than private state.
+function renderColor(color: Animated.Color): Fantom.Root {
+ const root = Fantom.createRoot();
+ Fantom.runTask(() => {
+ root.render(
+ ,
+ );
+ });
+ return root;
+}
+
+function mountedBackgroundColor(color: Animated.Color): string {
+ return renderColor(color)
+ .getRenderedOutput({props: ['backgroundColor']})
+ .toJSONObject().props.backgroundColor;
+}
+
+describe('Animated.Color', () => {
+ it('defaults to opaque black', () => {
+ expect(mountedBackgroundColor(new Animated.Color())).toBe(
+ 'rgba(0, 0, 0, 1)',
+ );
+ });
+
+ it('parses an rgba() string', () => {
+ expect(
+ mountedBackgroundColor(new Animated.Color('rgba(255, 128, 0, 1)')),
+ ).toBe('rgba(255, 128, 0, 1)');
+ });
+
+ it('parses a hex string', () => {
+ expect(mountedBackgroundColor(new Animated.Color('#ff0000'))).toBe(
+ 'rgba(255, 0, 0, 1)',
+ );
+ });
+
+ it('accepts an rgba object', () => {
+ // The rendered color quantizes alpha to 8 bits (0.5 -> 128/255).
+ expect(
+ mountedBackgroundColor(new Animated.Color({r: 10, g: 20, b: 30, a: 0.5})),
+ ).toBe('rgba(10, 20, 30, 0.501961)');
+ });
+
+ it('accepts individual AnimatedValues for each channel', () => {
+ expect(
+ mountedBackgroundColor(
+ new Animated.Color({
+ r: new Animated.Value(1),
+ g: new Animated.Value(2),
+ b: new Animated.Value(3),
+ a: new Animated.Value(1),
+ }),
+ ),
+ ).toBe('rgba(1, 2, 3, 1)');
+ });
+
+ it('updates all channels via setValue', () => {
+ const color = new Animated.Color('rgba(0, 0, 0, 1)');
+ const root = renderColor(color);
+
+ Fantom.runTask(() => {
+ color.setValue({r: 5, g: 6, b: 7, a: 1});
+ });
+
+ expect(
+ root.getRenderedOutput({props: ['backgroundColor']}).toJSX(),
+ ).toEqual();
+ });
+
+ it('applies an offset on top of the base value', () => {
+ const color = new Animated.Color({r: 10, g: 10, b: 10, a: 1});
+ const root = renderColor(color);
+
+ // `setOffset` on its own does not flush to a connected view (unlike the
+ // native driver, which does); the following value update flushes the
+ // composed color, which includes the offset (base 20 + offset 5).
+ Fantom.runTask(() => {
+ color.setOffset({r: 5, g: 5, b: 5, a: 0});
+ color.setValue({r: 20, g: 20, b: 20, a: 1});
+ });
+
+ expect(
+ root.getRenderedOutput({props: ['backgroundColor']}).toJSX(),
+ ).toEqual();
+ });
+
+ it('flattenOffset and extractOffset preserve the composed value', () => {
+ const color = new Animated.Color({r: 10, g: 10, b: 10, a: 1});
+ renderColor(color);
+
+ let afterFlatten: string = '';
+ let afterExtract: string = '';
+ Fantom.runTask(() => {
+ color.setOffset({r: 5, g: 5, b: 5, a: 0});
+ // Merges the offset (5) into the base (10) -> base 15, offset 0.
+ color.flattenOffset();
+ color.stopAnimation(value => {
+ afterFlatten = String(value);
+ });
+ // Moves the base (15) into the offset -> base 0, offset 15.
+ color.extractOffset();
+ color.stopAnimation(value => {
+ afterExtract = String(value);
+ });
+ });
+
+ expect(afterFlatten).toBe('rgba(15, 15, 15, 1)');
+ expect(afterExtract).toBe('rgba(15, 15, 15, 1)');
+ });
+
+ it('resetAnimation restores the value and reports it to the callback', () => {
+ const color = new Animated.Color('rgba(1, 2, 3, 1)');
+ renderColor(color);
+
+ let reported: string = '';
+ Fantom.runTask(() => {
+ color.resetAnimation(value => {
+ reported = String(value);
+ });
+ });
+
+ expect(reported).toBe('rgba(1, 2, 3, 1)');
+ });
+
+ it('updates a native-driven color via setValue', () => {
+ const color = new Animated.Color('rgba(0, 0, 0, 1)', {
+ useNativeDriver: true,
+ });
+ renderColor(color);
+
+ // A native-driven color applies via direct manipulation rather than the
+ // committed tree, so observe the value through the animation callback.
+ let reported: string = '';
+ Fantom.runTask(() => {
+ color.setValue({r: 5, g: 6, b: 7, a: 1});
+ color.stopAnimation(value => {
+ reported = String(value);
+ });
+ });
+ Fantom.unstable_produceFramesForDuration(16);
+ Fantom.runWorkLoop();
+
+ expect(reported).toBe('rgba(5, 6, 7, 1)');
+ });
+
+ for (const {name, useNativeDriver} of DRIVERS) {
+ it(`animates to a target color (${name})`, () => {
+ const color = new Animated.Color('rgba(255, 0, 0, 1)');
+ const root = renderColor(color);
+
+ expect(
+ root.getRenderedOutput({props: ['backgroundColor']}).toJSX(),
+ ).toEqual();
+
+ let finished = false;
+ Fantom.runTask(() => {
+ Animated.timing(color, {
+ toValue: {r: 0, g: 0, b: 255, a: 1},
+ duration: 100,
+ useNativeDriver,
+ }).start(result => {
+ finished = result.finished;
+ });
+ });
+
+ Fantom.unstable_produceFramesForDuration(200);
+ Fantom.runWorkLoop();
+
+ // The final driven color is flushed to the committed tree and observed
+ // through the public rendered output.
+ expect(finished).toBe(true);
+ expect(
+ root.getRenderedOutput({props: ['backgroundColor']}).toJSX(),
+ ).toEqual();
+ });
+ }
+});
diff --git a/packages/react-native/Libraries/Animated/__tests__/AnimatedComposition-itest.js b/packages/react-native/Libraries/Animated/__tests__/AnimatedComposition-itest.js
new file mode 100644
index 000000000000..ba97eacec7f9
--- /dev/null
+++ b/packages/react-native/Libraries/Animated/__tests__/AnimatedComposition-itest.js
@@ -0,0 +1,381 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ * @flow strict-local
+ * @format
+ */
+
+import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment';
+
+import type {HostInstance} from 'react-native';
+
+import * as Fantom from '@react-native/fantom';
+import nullthrows from 'nullthrows';
+import * as React from 'react';
+import {createRef} from 'react';
+import {Animated, useAnimatedValue} from 'react-native';
+
+// Renders an whose translateX is driven by `makeNode(base)` and
+// returns the base AnimatedValue plus the mounted element so tests can mutate the
+// base and observe the derived value on the shadow tree.
+function renderWithDerivedTranslateX(
+ makeNode: (base: Animated.Value) => Animated.WithAnimatedValue,
+): {
+ base: Animated.Value,
+ element: HostInstance,
+ root: Fantom.Root,
+} {
+ let base: ?Animated.Value;
+ const viewRef = createRef();
+
+ function MyApp() {
+ const value = useAnimatedValue(0);
+ base = value;
+ return (
+
+ );
+ }
+
+ const root = Fantom.createRoot();
+ Fantom.runTask(() => {
+ root.render();
+ });
+
+ const element = nullthrows(viewRef.current);
+ return {base: nullthrows(base), element, root};
+}
+
+function getTranslateX(root: Fantom.Root): number {
+ const output = root.getRenderedOutput({props: ['transform']}).toJSONObject();
+ const transform = JSON.parse(output.props.transform);
+ return transform[0].translateX;
+}
+
+describe('Animated.subtract', () => {
+ it('computes the difference of two values and reacts to updates', () => {
+ const {base, root} = renderWithDerivedTranslateX(value =>
+ Animated.subtract(100, value),
+ );
+
+ expect(getTranslateX(root)).toBe(100);
+
+ Fantom.runTask(() => {
+ base.setValue(30);
+ });
+
+ expect(getTranslateX(root)).toBe(70);
+ });
+});
+
+describe('Animated.divide', () => {
+ it('computes the quotient of two values and reacts to updates', () => {
+ const {base, root} = renderWithDerivedTranslateX(value =>
+ Animated.divide(value, 2),
+ );
+
+ Fantom.runTask(() => {
+ base.setValue(20);
+ });
+ expect(getTranslateX(root)).toBe(10);
+
+ Fantom.runTask(() => {
+ base.setValue(80);
+ });
+
+ expect(getTranslateX(root)).toBe(40);
+ });
+
+ it('returns 0 instead of Infinity when dividing by zero', () => {
+ const root = Fantom.createRoot();
+ Fantom.runTask(() => {
+ root.render(
+ ,
+ );
+ });
+ expect(
+ Number(
+ root.getRenderedOutput({props: ['width']}).toJSONObject().props.width,
+ ),
+ ).toBe(0);
+ });
+});
+
+describe('Animated.modulo', () => {
+ it('wraps values into the [0, modulus) range for positive and negative inputs', () => {
+ const {base, root} = renderWithDerivedTranslateX(value =>
+ Animated.modulo(value, 10),
+ );
+
+ Fantom.runTask(() => {
+ base.setValue(25);
+ });
+ expect(getTranslateX(root)).toBe(5);
+
+ Fantom.runTask(() => {
+ base.setValue(-3);
+ });
+ // ((-3 % 10) + 10) % 10 === 7
+ expect(getTranslateX(root)).toBe(7);
+ });
+});
+
+describe('Animated.diffClamp', () => {
+ it('clamps the accumulated delta between min and max', () => {
+ const base = new Animated.Value(0);
+ const root = Fantom.createRoot();
+ Fantom.runTask(() => {
+ root.render(
+ ,
+ );
+ });
+
+ // `width` (unlike a zero `translateX`) is preserved in the rendered output,
+ // so the accumulated, clamped value is observed publicly.
+ const getWidth = () =>
+ Number(
+ root.getRenderedOutput({props: ['width']}).toJSONObject().props.width,
+ );
+
+ expect(getWidth()).toBe(0);
+
+ // Increase beyond max: 0 + 100 clamped to 20.
+ Fantom.runTask(() => base.setValue(100));
+ expect(getWidth()).toBe(20);
+
+ // Decrease by 30: 20 + (70 - 100) = -10, clamped to 0.
+ Fantom.runTask(() => base.setValue(70));
+ expect(getWidth()).toBe(0);
+
+ // Increase by 5: 0 + (75 - 70) = 5, within range.
+ Fantom.runTask(() => base.setValue(75));
+ expect(getWidth()).toBe(5);
+ });
+});
+
+describe('Animated.add and Animated.multiply', () => {
+ it('compose additions and multiplications', () => {
+ const {base, root} = renderWithDerivedTranslateX(value =>
+ Animated.add(Animated.multiply(value, 2), 10),
+ );
+
+ Fantom.runTask(() => {
+ base.setValue(20);
+ });
+
+ // 20 * 2 + 10 = 50
+ expect(getTranslateX(root)).toBe(50);
+ });
+});
+
+describe('Animated tracking (toValue is an AnimatedNode)', () => {
+ it('follows the target value when animating toward another AnimatedValue', () => {
+ let follower: ?Animated.Value;
+ let leader: ?Animated.Value;
+ const viewRef = createRef();
+
+ function MyApp() {
+ const followerValue = useAnimatedValue(0);
+ const leaderValue = useAnimatedValue(0);
+ follower = followerValue;
+ leader = leaderValue;
+ return (
+
+ );
+ }
+
+ const root = Fantom.createRoot();
+ Fantom.runTask(() => {
+ root.render();
+ });
+
+ Fantom.runTask(() => {
+ Animated.timing(nullthrows(follower), {
+ toValue: nullthrows(leader),
+ duration: 100,
+ useNativeDriver: false,
+ }).start();
+ });
+
+ Fantom.runTask(() => {
+ nullthrows(leader).setValue(50);
+ });
+
+ Fantom.unstable_produceFramesForDuration(200);
+ Fantom.runWorkLoop();
+
+ expect(getTranslateX(root)).toBeCloseTo(50, 1);
+ });
+
+ it('follows the target value on the native driver', () => {
+ let follower: ?Animated.Value;
+ let leader: ?Animated.Value;
+ let animation: ?Animated.CompositeAnimation;
+ const viewRef = createRef();
+
+ function MyApp() {
+ const followerValue = useAnimatedValue(0);
+ const leaderValue = useAnimatedValue(0);
+ follower = followerValue;
+ leader = leaderValue;
+ return (
+
+ );
+ }
+
+ const root = Fantom.createRoot();
+ Fantom.runTask(() => {
+ root.render();
+ });
+ const element = nullthrows(viewRef.current);
+
+ Fantom.runTask(() => {
+ animation = Animated.timing(nullthrows(follower), {
+ toValue: nullthrows(leader),
+ duration: 100,
+ useNativeDriver: true,
+ });
+ animation.start();
+ });
+
+ Fantom.runTask(() => {
+ nullthrows(leader).setValue(50);
+ });
+
+ Fantom.unstable_produceFramesForDuration(200);
+ Fantom.runWorkLoop();
+
+ expect(element.getBoundingClientRect().x).toBeCloseTo(50, 0);
+
+ Fantom.runTask(() => {
+ nullthrows(animation).stop();
+ });
+ Fantom.runTask(() => {
+ root.render();
+ });
+ Fantom.unstable_produceFramesForDuration(16);
+ Fantom.runWorkLoop();
+ });
+});
+
+// Exercises the native-config, interpolation and detach paths of the
+// composition nodes (the JS `__getValue` path is covered by the tests above).
+describe('composition nodes: native driver, interpolation and detach', () => {
+ const factories = [
+ {
+ name: 'add',
+ make: (base: Animated.Value) => Animated.add(base, 10),
+ expected: 60,
+ },
+ {
+ name: 'subtract',
+ make: (base: Animated.Value) => Animated.subtract(base, 10),
+ expected: 40,
+ },
+ {
+ name: 'multiply',
+ make: (base: Animated.Value) => Animated.multiply(base, 2),
+ expected: 100,
+ },
+ {
+ name: 'divide',
+ make: (base: Animated.Value) => Animated.divide(base, 2),
+ expected: 25,
+ },
+ {
+ name: 'modulo',
+ make: (base: Animated.Value) => Animated.modulo(base, 7),
+ expected: 50 % 7,
+ },
+ {
+ name: 'diffClamp',
+ make: (base: Animated.Value) => Animated.diffClamp(base, 0, 100),
+ expected: 50,
+ },
+ ];
+
+ for (const {name, make, expected} of factories) {
+ it(`${name} runs on the native driver, interpolates, and detaches`, () => {
+ let base: ?Animated.Value;
+ const viewRef = createRef();
+
+ function MyApp() {
+ const value = useAnimatedValue(0);
+ base = value;
+ const node = make(value);
+ return (
+
+ );
+ }
+
+ const root = Fantom.createRoot();
+ Fantom.runTask(() => {
+ root.render();
+ });
+ const element = nullthrows(viewRef.current);
+
+ let animation: ?Animated.CompositeAnimation;
+ Fantom.runTask(() => {
+ animation = Animated.timing(nullthrows(base), {
+ toValue: 50,
+ duration: 100,
+ useNativeDriver: true,
+ });
+ animation.start();
+ });
+ Fantom.unstable_produceFramesForDuration(200);
+ Fantom.runWorkLoop();
+
+ expect(element.getBoundingClientRect().x).toBeCloseTo(expected, 0);
+
+ // Stop the animation and re-render without the node to detach the
+ // composition graph and drain pending work.
+ Fantom.runTask(() => {
+ nullthrows(animation).stop();
+ });
+ Fantom.runTask(() => {
+ root.render();
+ });
+ Fantom.unstable_produceFramesForDuration(16);
+ Fantom.runWorkLoop();
+ });
+ }
+});
diff --git a/packages/react-native/Libraries/Animated/__tests__/AnimatedFantomTestUtils.js b/packages/react-native/Libraries/Animated/__tests__/AnimatedFantomTestUtils.js
new file mode 100644
index 000000000000..0a94e454275f
--- /dev/null
+++ b/packages/react-native/Libraries/Animated/__tests__/AnimatedFantomTestUtils.js
@@ -0,0 +1,100 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ * @flow strict-local
+ * @format
+ * @oncall react_native
+ */
+
+import * as Fantom from '@react-native/fantom';
+import {Animated} from 'react-native';
+import {setAnimationTimeProvider} from 'react-native/Libraries/Animated/AnimationTimingUtils';
+
+export type Driver = {readonly name: string, readonly useNativeDriver: boolean};
+
+// Every animation is exercised with both drivers. The JS driver runs the
+// closed-form integration in JavaScript; the native driver hands the config to
+// the C++ backend, which drives the view on each frame.
+export const DRIVERS: ReadonlyArray = [
+ {name: 'JS driver', useNativeDriver: false},
+ {name: 'native driver', useNativeDriver: true},
+];
+
+// Fantom's `produceFramesForDuration` advances its frame clock in fixed
+// ~16.333ms (60fps) steps. We advance the JS animation clock by the same step
+// so both drivers step at the same cadence.
+export const FRAME_STEP_MS = 16333 / 1000;
+
+/**
+ * Runs `createAnimation(useNativeDriver)` on `value` to completion and returns
+ * the per-frame trajectory recorded from `value`'s listener.
+ *
+ * Both drivers step at the same frame cadence, driven by two clocks advanced
+ * together:
+ * - frame timing: `unstable_produceFramesForDuration` advances Fantom's frame
+ * clock (drives the native driver and flushes each frame's work), and
+ * - animation timing: `getCurrentAnimationTime` is overridden with a clock
+ * that advances one frame step per read, driving the JS driver
+ * deterministically (the native driver ignores it and reads the frame clock
+ * in C++).
+ *
+ * This makes the JS and native drivers produce the same trajectory, so tests
+ * can assert one curve for both.
+ */
+export function collectAnimationTrajectory(
+ value: Animated.Value,
+ createAnimation: (useNativeDriver: boolean) => Animated.CompositeAnimation,
+ useNativeDriver: boolean,
+ durationMs: number,
+): {samples: Array, finished: boolean} {
+ const samples: Array = [];
+ const listenerId = value.addListener(state => {
+ samples.push(state.value);
+ });
+
+ let animationTime = 0;
+ setAnimationTimeProvider(() => {
+ const current = animationTime;
+ animationTime += FRAME_STEP_MS;
+ return current;
+ });
+
+ let finished = false;
+ try {
+ Fantom.runTask(() => {
+ createAnimation(useNativeDriver).start(result => {
+ finished = result.finished;
+ });
+ });
+ Fantom.unstable_produceFramesForDuration(durationMs);
+ Fantom.runWorkLoop();
+ } finally {
+ setAnimationTimeProvider(null);
+ value.removeListener(listenerId);
+ }
+
+ return {samples, finished};
+}
+
+/** Diffs between consecutive samples. */
+export function deltas(samples: ReadonlyArray): Array {
+ const result: Array = [];
+ for (let i = 1; i < samples.length; i++) {
+ result.push(samples[i] - samples[i - 1]);
+ }
+ return result;
+}
+
+/**
+ * Asserts the samples rise monotonically to their peak (allowing tiny
+ * floating-point noise), i.e. no dips on the way up.
+ */
+export function expectMonotonicToPeak(samples: ReadonlyArray): void {
+ const peakIndex = samples.indexOf(Math.max(...samples));
+ for (let i = 1; i <= peakIndex; i++) {
+ expect(samples[i]).toBeGreaterThanOrEqual(samples[i - 1] - 1e-6);
+ }
+}
diff --git a/packages/react-native/Libraries/Animated/__tests__/DecayAnimation-itest.js b/packages/react-native/Libraries/Animated/__tests__/DecayAnimation-itest.js
new file mode 100644
index 000000000000..6863a40b66c3
--- /dev/null
+++ b/packages/react-native/Libraries/Animated/__tests__/DecayAnimation-itest.js
@@ -0,0 +1,106 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ * @flow strict-local
+ * @format
+ * @oncall react_native
+ */
+
+import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment';
+
+import type {HostInstance} from 'react-native';
+
+import {
+ DRIVERS,
+ collectAnimationTrajectory,
+ deltas,
+ expectMonotonicToPeak,
+} from './AnimatedFantomTestUtils';
+import * as Fantom from '@react-native/fantom';
+import nullthrows from 'nullthrows';
+import * as React from 'react';
+import {createRef} from 'react';
+import {Animated, useAnimatedValue} from 'react-native';
+
+function renderTranslateX(): Animated.Value {
+ let value: ?Animated.Value;
+ const viewRef = createRef();
+
+ function MyApp() {
+ const translateX = useAnimatedValue(0);
+ value = translateX;
+ return (
+
+ );
+ }
+
+ const root = Fantom.createRoot();
+ Fantom.runTask(() => {
+ root.render();
+ });
+
+ return nullthrows(value);
+}
+
+const VELOCITY = 2;
+const DECELERATION = 0.99;
+// value(t) = v0 / (1 - deceleration) * (1 - deceleration^t): the asymptote is
+// v0 / (1 - deceleration).
+const ASYMPTOTE = VELOCITY / (1 - DECELERATION);
+
+for (const {name, useNativeDriver} of DRIVERS) {
+ describe(`Animated.decay (${name})`, () => {
+ it('decelerates exponentially to a resting position', () => {
+ const value = renderTranslateX();
+
+ const {samples, finished} = collectAnimationTrajectory(
+ value,
+ driver =>
+ Animated.decay(value, {
+ velocity: VELOCITY,
+ deceleration: DECELERATION,
+ useNativeDriver: driver,
+ }),
+ useNativeDriver,
+ 3000,
+ );
+
+ expect(finished).toBe(true);
+ expect(samples.length).toBeGreaterThan(10);
+
+ // Comes to rest just below the analytical asymptote.
+ const rest = samples[samples.length - 1];
+ expect(rest).toBeGreaterThan(ASYMPTOTE * 0.97);
+ expect(rest).toBeLessThanOrEqual(ASYMPTOTE + 1e-6);
+
+ // Monotonically increasing to its resting position (no dips), which for
+ // decay is also the peak.
+ expectMonotonicToPeak(samples);
+
+ // Exponential deceleration has two signatures over the early frames
+ // (before it converges into floating-point noise):
+ // 1. each per-frame step is strictly smaller than the previous one, and
+ // 2. the ratio between consecutive steps is (nearly) constant — the
+ // defining property of geometric/exponential decay, which a linear
+ // ramp (constant deltas, ratio 1) or an accelerating curve (ratio
+ // > 1) would fail.
+ const frameDeltas = deltas(samples);
+ const ratios: Array = [];
+ for (let i = 1; i < 9; i++) {
+ expect(frameDeltas[i]).toBeLessThan(frameDeltas[i - 1]);
+ ratios.push(frameDeltas[i] / frameDeltas[i - 1]);
+ }
+ for (const ratio of ratios) {
+ expect(ratio).toBeGreaterThan(0.75);
+ expect(ratio).toBeLessThan(0.95);
+ }
+ expect(Math.max(...ratios) - Math.min(...ratios)).toBeLessThan(0.1);
+ });
+ });
+}
diff --git a/packages/react-native/Libraries/Animated/__tests__/SpringAnimation-itest.js b/packages/react-native/Libraries/Animated/__tests__/SpringAnimation-itest.js
new file mode 100644
index 000000000000..5d26452130de
--- /dev/null
+++ b/packages/react-native/Libraries/Animated/__tests__/SpringAnimation-itest.js
@@ -0,0 +1,214 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ * @flow strict-local
+ * @format
+ * @oncall react_native
+ */
+
+import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment';
+
+import type {HostInstance} from 'react-native';
+
+import {
+ DRIVERS,
+ collectAnimationTrajectory,
+ expectMonotonicToPeak,
+} from './AnimatedFantomTestUtils';
+import * as Fantom from '@react-native/fantom';
+import nullthrows from 'nullthrows';
+import * as React from 'react';
+import {createRef} from 'react';
+import {Animated, useAnimatedValue} from 'react-native';
+
+// Springs are observed through an 's opacity. The animation is
+// bound to a mounted view (required for the native driver) and its per-frame
+// trajectory is recorded from the value's listener.
+function renderOpacity(): Animated.Value {
+ let value: ?Animated.Value;
+ const viewRef = createRef();
+
+ function MyApp() {
+ const opacity = useAnimatedValue(0);
+ value = opacity;
+ return (
+
+ );
+ }
+
+ const root = Fantom.createRoot();
+ Fantom.runTask(() => {
+ root.render();
+ });
+
+ return nullthrows(value);
+}
+
+function expectSettledAtTarget(sample: number, target: number): void {
+ expect(Math.abs(sample - target)).toBeLessThan(0.02);
+}
+
+for (const {name, useNativeDriver} of DRIVERS) {
+ describe(`Animated.spring (${name})`, () => {
+ it('follows an underdamped curve that overshoots then settles', () => {
+ const value = renderOpacity();
+
+ // zeta = c / (2 * sqrt(k * m)) = 20 / (2 * sqrt(200)) ~= 0.707, which
+ // overshoots the target by exp(-zeta * pi / sqrt(1 - zeta^2)) ~= 4.3%.
+ const {samples, finished} = collectAnimationTrajectory(
+ value,
+ driver =>
+ Animated.spring(value, {
+ toValue: 1,
+ stiffness: 200,
+ damping: 20,
+ mass: 1,
+ useNativeDriver: driver,
+ }),
+ useNativeDriver,
+ 3000,
+ );
+
+ expect(finished).toBe(true);
+ expect(samples.length).toBeGreaterThan(3);
+ // Rises smoothly through the full range (not a snap to the end).
+ expect(samples.some(v => v > 0.2 && v < 0.4)).toBe(true);
+ expect(samples.some(v => v > 0.6 && v < 0.8)).toBe(true);
+ expectMonotonicToPeak(samples);
+
+ // Overshoots the target by the amount the damping ratio predicts.
+ const peak = Math.max(...samples);
+ expect(peak).toBeGreaterThan(1.02);
+ expect(peak).toBeLessThan(1.07);
+
+ // Oscillates back down from the peak and settles at the target.
+ const last = samples[samples.length - 1];
+ expect(last).toBeLessThan(peak);
+ expectSettledAtTarget(last, 1);
+ });
+
+ it('follows an overdamped curve that approaches the target without overshoot', () => {
+ const value = renderOpacity();
+
+ // zeta = 30 / (2 * sqrt(100)) = 1.5 > 1 (overdamped): a smooth sigmoid to
+ // the target with no overshoot.
+ const {samples, finished} = collectAnimationTrajectory(
+ value,
+ driver =>
+ Animated.spring(value, {
+ toValue: 1,
+ stiffness: 100,
+ damping: 30,
+ mass: 1,
+ useNativeDriver: driver,
+ }),
+ useNativeDriver,
+ 3000,
+ );
+
+ expect(finished).toBe(true);
+ expect(samples.some(v => v > 0.2 && v < 0.4)).toBe(true);
+ expect(samples.some(v => v > 0.6 && v < 0.8)).toBe(true);
+ expectMonotonicToPeak(samples);
+
+ // No meaningful overshoot for an overdamped spring.
+ expect(Math.max(...samples)).toBeLessThan(1.005);
+ expectSettledAtTarget(samples[samples.length - 1], 1);
+ });
+
+ it('does not overshoot past toValue when overshootClamping is enabled', () => {
+ const value = renderOpacity();
+
+ // Same underdamped config as the overshoot test, but clamping must
+ // suppress the overshoot entirely.
+ const {samples} = collectAnimationTrajectory(
+ value,
+ driver =>
+ Animated.spring(value, {
+ toValue: 1,
+ stiffness: 200,
+ damping: 20,
+ mass: 1,
+ overshootClamping: true,
+ useNativeDriver: driver,
+ }),
+ useNativeDriver,
+ 3000,
+ );
+
+ expectMonotonicToPeak(samples);
+ // Clamped: overshoot is suppressed to well under the ~4.3% the same
+ // unclamped config produces (peak ~1.043).
+ expect(Math.max(...samples)).toBeLessThan(1.02);
+ expectSettledAtTarget(samples[samples.length - 1], 1);
+ });
+
+ it('settles at the target when configured via tension/friction', () => {
+ const value = renderOpacity();
+
+ // Exercises the Origami tension/friction -> stiffness/damping conversion
+ // in SpringConfig (`fromOrigamiTensionAndFriction`).
+ const {samples, finished} = collectAnimationTrajectory(
+ value,
+ driver =>
+ Animated.spring(value, {
+ toValue: 1,
+ tension: 40,
+ friction: 7,
+ useNativeDriver: driver,
+ }),
+ useNativeDriver,
+ 3000,
+ );
+
+ expect(finished).toBe(true);
+ expectMonotonicToPeak(samples);
+ expectSettledAtTarget(samples[samples.length - 1], 1);
+ });
+
+ // `speed` values chosen so the bounciness/speed -> stiffness/damping
+ // conversion exercises all three friction regimes in SpringConfig
+ // (bouncyTension <= 18, 18 < bouncyTension <= 44, and > 44).
+ for (const speed of [2, 5, 12]) {
+ it(`settles at the target when configured via bounciness/speed (speed ${speed})`, () => {
+ const value = renderOpacity();
+
+ const {samples, finished} = collectAnimationTrajectory(
+ value,
+ driver =>
+ Animated.spring(value, {
+ toValue: 1,
+ bounciness: 12,
+ speed,
+ useNativeDriver: driver,
+ }),
+ useNativeDriver,
+ 3000,
+ );
+
+ expect(finished).toBe(true);
+ expectSettledAtTarget(samples[samples.length - 1], 1);
+ });
+ }
+ });
+}
+
+describe('Animated.spring config validation', () => {
+ it('throws when combining mutually exclusive config groups', () => {
+ const value = renderOpacity();
+
+ expect(() => {
+ Fantom.runTask(() => {
+ Animated.spring(value, {
+ toValue: 1,
+ stiffness: 100,
+ bounciness: 10,
+ useNativeDriver: true,
+ }).start();
+ });
+ }).toThrow();
+ });
+});
diff --git a/packages/react-native/Libraries/Animated/animations/DecayAnimation.js b/packages/react-native/Libraries/Animated/animations/DecayAnimation.js
index d6b834b032ee..07dc60372058 100644
--- a/packages/react-native/Libraries/Animated/animations/DecayAnimation.js
+++ b/packages/react-native/Libraries/Animated/animations/DecayAnimation.js
@@ -12,6 +12,7 @@ import type {PlatformConfig} from '../AnimatedPlatformConfig';
import type AnimatedValue from '../nodes/AnimatedValue';
import type {AnimationConfig, EndCallback} from './Animation';
+import {getCurrentAnimationTime} from '../AnimationTimingUtils';
import Animation from './Animation';
export type DecayAnimationConfig = Readonly<{
@@ -82,7 +83,7 @@ export default class DecayAnimation extends Animation {
this._lastValue = fromValue;
this._fromValue = fromValue;
this._onUpdate = onUpdate;
- this._startTime = Date.now();
+ this._startTime = getCurrentAnimationTime();
const useNativeDriver = this.__startAnimationIfNative(animatedValue);
// TODO: T274006331 - Remove js-only animation once shared backend is fully rolled out
@@ -92,7 +93,7 @@ export default class DecayAnimation extends Animation {
}
onUpdate(): void {
- const now = Date.now();
+ const now = getCurrentAnimationTime();
const value =
this._fromValue +
diff --git a/packages/react-native/Libraries/Animated/animations/SpringAnimation.js b/packages/react-native/Libraries/Animated/animations/SpringAnimation.js
index f04a527469b3..62ede656adce 100644
--- a/packages/react-native/Libraries/Animated/animations/SpringAnimation.js
+++ b/packages/react-native/Libraries/Animated/animations/SpringAnimation.js
@@ -14,6 +14,7 @@ import type AnimatedValue from '../nodes/AnimatedValue';
import type AnimatedValueXY from '../nodes/AnimatedValueXY';
import type {AnimationConfig, EndCallback} from './Animation';
+import {getCurrentAnimationTime} from '../AnimationTimingUtils';
import AnimatedColor from '../nodes/AnimatedColor';
import * as SpringConfig from '../SpringConfig';
import Animation from './Animation';
@@ -211,7 +212,7 @@ export default class SpringAnimation extends Animation {
this._lastPosition = this._startPosition;
this._onUpdate = onUpdate;
- this._lastTime = Date.now();
+ this._lastTime = getCurrentAnimationTime();
this._frameTime = 0.0;
if (previousAnimation instanceof SpringAnimation) {
@@ -274,7 +275,7 @@ export default class SpringAnimation extends Animation {
// computation and will continue on the next frame. It's better to have it
// running at faster speed than jumping to the end.
const MAX_STEPS = 64;
- let now = Date.now();
+ let now = getCurrentAnimationTime();
if (now > this._lastTime + MAX_STEPS) {
now = this._lastTime + MAX_STEPS;
}
diff --git a/packages/react-native/Libraries/Animated/animations/TimingAnimation.js b/packages/react-native/Libraries/Animated/animations/TimingAnimation.js
index dffb737a9882..e83d1a449a05 100644
--- a/packages/react-native/Libraries/Animated/animations/TimingAnimation.js
+++ b/packages/react-native/Libraries/Animated/animations/TimingAnimation.js
@@ -16,6 +16,7 @@ import type AnimatedValueXY from '../nodes/AnimatedValueXY';
import type {AnimationConfig, EndCallback} from './Animation';
import * as ReactNativeFeatureFlags from '../../../src/private/featureflags/ReactNativeFeatureFlags';
+import {getCurrentAnimationTime} from '../AnimationTimingUtils';
import AnimatedColor from '../nodes/AnimatedColor';
import Animation from './Animation';
@@ -126,7 +127,7 @@ export default class TimingAnimation extends Animation {
}
const start = () => {
- this._startTime = Date.now();
+ this._startTime = getCurrentAnimationTime();
const useNativeDriver = this.__startAnimationIfNative(animatedValue);
// TODO: T274006331 - Remove js-only animation once shared backend is fully rolled out
@@ -150,7 +151,7 @@ export default class TimingAnimation extends Animation {
}
onUpdate(): void {
- const now = Date.now();
+ const now = getCurrentAnimationTime();
if (now >= this._startTime + this._duration) {
if (this._duration === 0) {
this._onUpdate(this._toValue);
diff --git a/packages/react-native/Libraries/Components/TextInput/__tests__/TextInput-itest.js b/packages/react-native/Libraries/Components/TextInput/__tests__/TextInput-itest.js
index 7a9e2a47bc3c..d7f1636c2cdb 100644
--- a/packages/react-native/Libraries/Components/TextInput/__tests__/TextInput-itest.js
+++ b/packages/react-native/Libraries/Components/TextInput/__tests__/TextInput-itest.js
@@ -10,12 +10,13 @@
import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment';
+import type {HostInstance} from 'react-native';
+
import * as Fantom from '@react-native/fantom';
import nullthrows from 'nullthrows';
import * as React from 'react';
import {createRef, useEffect, useLayoutEffect, useRef} from 'react';
import {TextInput} from 'react-native';
-import ReactNativeElement from 'react-native/src/private/webapis/dom/nodes/ReactNativeElement';
describe('', () => {
describe('props', () => {
@@ -43,7 +44,7 @@ describe('', () => {
describe('onChange', () => {
it('is called when the change native event is dispatched', () => {
const root = Fantom.createRoot();
- const nodeRef = createRef>();
+ const nodeRef = createRef();
const onChange = jest.fn();
Fantom.runTask(() => {
@@ -74,7 +75,7 @@ describe('', () => {
describe('onChangeText', () => {
it('is called when the change native event is dispatched', () => {
const root = Fantom.createRoot();
- const nodeRef = createRef>();
+ const nodeRef = createRef();
const onChangeText = jest.fn();
Fantom.runTask(() => {
@@ -98,7 +99,7 @@ describe('', () => {
describe('onFocus', () => {
it('is called when the focus native event is dispatched', () => {
const root = Fantom.createRoot();
- const nodeRef = createRef>();
+ const nodeRef = createRef();
let focusEvent = jest.fn();
@@ -124,7 +125,7 @@ describe('', () => {
describe('onBlur', () => {
it('is called when the blur native event is dispatched', () => {
const root = Fantom.createRoot();
- const nodeRef = createRef>();
+ const nodeRef = createRef();
let blurEvent = jest.fn();
@@ -147,6 +148,113 @@ describe('', () => {
});
});
+ describe('onSelectionChange', () => {
+ it('is called with the updated selection', () => {
+ const root = Fantom.createRoot();
+ const nodeRef = createRef();
+ const onSelectionChange = jest.fn();
+
+ Fantom.runTask(() => {
+ root.render(
+ {
+ onSelectionChange(event.nativeEvent);
+ }}
+ ref={nodeRef}>
+ hello world
+ ,
+ );
+ });
+
+ Fantom.dispatchNativeEvent(nodeRef, 'selectionChange', {
+ selection: {start: 2, end: 5},
+ });
+
+ expect(onSelectionChange).toHaveBeenCalledTimes(1);
+ const [entry] = onSelectionChange.mock.lastCall;
+ expect(entry.selection).toEqual({start: 2, end: 5});
+ });
+ });
+
+ describe('onSubmitEditing', () => {
+ it('is called when the submit native event is dispatched', () => {
+ const root = Fantom.createRoot();
+ const nodeRef = createRef();
+ const onSubmitEditing = jest.fn();
+
+ Fantom.runTask(() => {
+ root.render(
+ {
+ onSubmitEditing(event.nativeEvent);
+ }}
+ ref={nodeRef}
+ />,
+ );
+ });
+
+ Fantom.dispatchNativeEvent(nodeRef, 'submitEditing', {
+ text: 'submitted text',
+ });
+
+ expect(onSubmitEditing).toHaveBeenCalledTimes(1);
+ const [entry] = onSubmitEditing.mock.lastCall;
+ expect(entry.text).toEqual('submitted text');
+ });
+ });
+
+ describe('onKeyPress', () => {
+ it('is called with the pressed key', () => {
+ const root = Fantom.createRoot();
+ const nodeRef = createRef();
+ const onKeyPress = jest.fn();
+
+ Fantom.runTask(() => {
+ root.render(
+ {
+ onKeyPress(event.nativeEvent);
+ }}
+ ref={nodeRef}
+ />,
+ );
+ });
+
+ Fantom.dispatchNativeEvent(nodeRef, 'keyPress', {key: 'a'});
+
+ expect(onKeyPress).toHaveBeenCalledTimes(1);
+ const [entry] = onKeyPress.mock.lastCall;
+ expect(entry.key).toEqual('a');
+ });
+ });
+
+ describe('onEndEditing', () => {
+ it('is called when the end editing native event is dispatched', () => {
+ const root = Fantom.createRoot();
+ const nodeRef = createRef();
+ const onEndEditing = jest.fn();
+
+ Fantom.runTask(() => {
+ root.render(
+ {
+ onEndEditing(event.nativeEvent);
+ }}
+ ref={nodeRef}
+ />,
+ );
+ });
+
+ Fantom.dispatchNativeEvent(nodeRef, 'endEditing', {
+ text: 'final text',
+ });
+
+ expect(onEndEditing).toHaveBeenCalledTimes(1);
+ const [entry] = onEndEditing.mock.lastCall;
+ expect(entry.text).toEqual('final text');
+ });
+ });
+
describe('id and nativeID', () => {
it(`has 'id' propagated as 'nativeID' to the mounting layer`, () => {
const root = Fantom.createRoot();
@@ -282,7 +390,7 @@ describe('', () => {
describe('ref', () => {
it('is an element node', () => {
- const ref = createRef>();
+ const ref = createRef();
const root = Fantom.createRoot();
@@ -290,7 +398,7 @@ describe('', () => {
root.render();
});
- expect(ref.current).toBeInstanceOf(ReactNativeElement);
+ expect(ref.current).toBeInstanceOf(HTMLElement);
});
it('provides additional methods: clear, isFocused, getNativeRef, setSelection', () => {
@@ -311,7 +419,7 @@ describe('', () => {
describe('focus()', () => {
it('dispatches the focus command', () => {
const root = Fantom.createRoot();
- const ref = createRef>();
+ const ref = createRef();
Fantom.runTask(() => {
root.render();
@@ -448,7 +556,7 @@ describe('', () => {
describe('blur()', () => {
it('does NOT dispatch any commands if the input is NOT focused', () => {
const root = Fantom.createRoot();
- const ref = createRef>();
+ const ref = createRef();
Fantom.runTask(() => {
root.render();
@@ -467,7 +575,7 @@ describe('', () => {
it('does dispatches the blur command if the input is focused', () => {
const root = Fantom.createRoot();
- const ref = createRef>();
+ const ref = createRef();
Fantom.runTask(() => {
root.render();
diff --git a/packages/react-native/Libraries/Components/View/__tests__/View-nativeCSSParsing-itest.js b/packages/react-native/Libraries/Components/View/__tests__/View-nativeCSSParsing-itest.js
new file mode 100644
index 000000000000..8aefac7b4248
--- /dev/null
+++ b/packages/react-native/Libraries/Components/View/__tests__/View-nativeCSSParsing-itest.js
@@ -0,0 +1,75 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ * @flow strict-local
+ * @fantom_flags enableNativeCSSParsing:true
+ * @format
+ */
+
+import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment';
+
+import type {ViewStyleProp} from 'react-native/Libraries/StyleSheet/StyleSheet';
+
+import * as Fantom from '@react-native/fantom';
+import * as React from 'react';
+import {View} from 'react-native';
+
+// These tests render with string-valued CSS properties. With
+// `enableNativeCSSParsing` forced on, the strings are parsed by the C++ CSS
+// parsers (color functions, transforms, filters, box shadows, gradients), and
+// we assert the resulting mounted props. `collapsable={false}` keeps views that
+// carry only a non-layout prop present in the mounting layer.
+function mountedProp(style: ViewStyleProp, prop: string): string {
+ const root = Fantom.createRoot();
+ Fantom.runTask(() => {
+ root.render();
+ });
+ return root.getRenderedOutput({props: [prop]}).toJSONObject().props[prop];
+}
+
+describe(' native CSS parsing', () => {
+ describe('color functions', () => {
+ it('parses hwb() into an rgba color', () => {
+ // hwb(0 0% 0%) is pure red.
+ expect(
+ mountedProp({backgroundColor: 'hwb(0 0% 0%)'}, 'backgroundColor'),
+ ).toBe('rgba(255, 0, 0, 1)');
+ });
+
+ it('parses hsl() into an rgba color', () => {
+ // hsl(120, 100%, 50%) is pure green.
+ expect(
+ mountedProp(
+ {backgroundColor: 'hsl(120, 100%, 50%)'},
+ 'backgroundColor',
+ ),
+ ).toBe('rgba(0, 255, 0, 1)');
+ });
+ });
+
+ describe('transform', () => {
+ it('parses string transform syntax', () => {
+ const root = Fantom.createRoot();
+ Fantom.runTask(() => {
+ root.render();
+ });
+ expect(root.getRenderedOutput({props: ['transform']}).toJSX()).toEqual(
+ ,
+ );
+ });
+ });
+
+ describe('backgroundImage', () => {
+ it('parses a linear-gradient()', () => {
+ const backgroundImage = mountedProp(
+ {backgroundImage: 'linear-gradient(#e66465, #9198e5)'},
+ 'backgroundImage',
+ );
+ expect(backgroundImage).toContain('linear-gradient');
+ expect(backgroundImage).toContain('rgba(230, 100, 101, 1)');
+ });
+ });
+});
diff --git a/packages/react-native/Libraries/Interaction/__tests__/PanResponder-itest.js b/packages/react-native/Libraries/Interaction/__tests__/PanResponder-itest.js
new file mode 100644
index 000000000000..f5846d4be360
--- /dev/null
+++ b/packages/react-native/Libraries/Interaction/__tests__/PanResponder-itest.js
@@ -0,0 +1,143 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ * @flow strict-local
+ * @format
+ * @oncall react_native
+ */
+
+import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment';
+
+import type {PanResponderCallbacks} from '../PanResponder';
+import type {HostInstance} from 'react-native';
+
+import * as Fantom from '@react-native/fantom';
+import nullthrows from 'nullthrows';
+import * as React from 'react';
+import {createRef} from 'react';
+import {PanResponder, View} from 'react-native';
+
+function touchAt(
+ pageX: number,
+ pageY: number,
+ timestamp: number,
+): {touches: Array<{...}>, changedTouches: Array<{...}>} {
+ return {
+ touches: [{identifier: 0, pageX, pageY, timestamp}],
+ changedTouches: [{identifier: 0, pageX, pageY, timestamp}],
+ };
+}
+
+function endAt(
+ pageX: number,
+ pageY: number,
+ timestamp: number,
+): {touches: Array<{...}>, changedTouches: Array<{...}>} {
+ return {
+ touches: [],
+ changedTouches: [{identifier: 0, pageX, pageY, timestamp}],
+ };
+}
+
+describe('PanResponder', () => {
+ function renderWithHandlers(config: PanResponderCallbacks): HostInstance {
+ const panResponder = PanResponder.create({
+ onStartShouldSetPanResponder: () => true,
+ onMoveShouldSetPanResponder: () => true,
+ ...config,
+ });
+ const ref = createRef();
+ const root = Fantom.createRoot();
+ Fantom.runTask(() => {
+ root.render(
+ ,
+ );
+ });
+ return nullthrows(ref.current);
+ }
+
+ function dispatch(
+ element: HostInstance,
+ type: string,
+ payload: Readonly<{[key: string]: unknown}>,
+ ): void {
+ Fantom.dispatchNativeEvent(element, type, payload, {
+ category: Fantom.NativeEventCategory.Discrete,
+ });
+ }
+
+ it('grants the responder on touch start', () => {
+ const onPanResponderGrant = jest.fn();
+ const element = renderWithHandlers({onPanResponderGrant});
+
+ dispatch(element, 'onTouchStart', touchAt(5, 5, 0));
+ expect(onPanResponderGrant).toHaveBeenCalledTimes(1);
+
+ dispatch(element, 'onTouchEnd', endAt(5, 5, 100));
+ });
+
+ it('reports gesture displacement on move', () => {
+ let capturedDx = null;
+ let capturedDy = null;
+ const onPanResponderMove = jest.fn((event, gestureState) => {
+ capturedDx = gestureState.dx;
+ capturedDy = gestureState.dy;
+ });
+ const element = renderWithHandlers({onPanResponderMove});
+
+ dispatch(element, 'onTouchStart', touchAt(0, 0, 0));
+ dispatch(element, 'onTouchMove', touchAt(10, 20, 100));
+
+ expect(onPanResponderMove).toHaveBeenCalled();
+ expect(capturedDx).toBe(10);
+ expect(capturedDy).toBe(20);
+
+ dispatch(element, 'onTouchEnd', endAt(10, 20, 200));
+ });
+
+ it('releases the responder on touch end', () => {
+ const onPanResponderRelease = jest.fn();
+ const element = renderWithHandlers({onPanResponderRelease});
+
+ dispatch(element, 'onTouchStart', touchAt(5, 5, 0));
+ dispatch(element, 'onTouchEnd', endAt(5, 5, 100));
+
+ expect(onPanResponderRelease).toHaveBeenCalledTimes(1);
+ });
+
+ it('terminates the responder on touch cancel', () => {
+ const onPanResponderTerminate = jest.fn();
+ const element = renderWithHandlers({onPanResponderTerminate});
+
+ dispatch(element, 'onTouchStart', touchAt(5, 5, 0));
+ dispatch(element, 'onTouchCancel', endAt(5, 5, 100));
+
+ expect(onPanResponderTerminate).toHaveBeenCalledTimes(1);
+ });
+
+ it('tracks the number of active touches across a gesture', () => {
+ let grantTouches = null;
+ let releaseTouches = null;
+ const element = renderWithHandlers({
+ onPanResponderGrant: (event, gestureState) => {
+ grantTouches = gestureState.numberActiveTouches;
+ },
+ onPanResponderRelease: (event, gestureState) => {
+ releaseTouches = gestureState.numberActiveTouches;
+ },
+ });
+
+ dispatch(element, 'onTouchStart', touchAt(5, 5, 0));
+ expect(grantTouches).toBe(1);
+
+ dispatch(element, 'onTouchEnd', endAt(5, 5, 100));
+ expect(releaseTouches).toBe(0);
+ });
+});
diff --git a/packages/react-native/Libraries/LayoutAnimation/__tests__/LayoutAnimation-itest.js b/packages/react-native/Libraries/LayoutAnimation/__tests__/LayoutAnimation-itest.js
new file mode 100644
index 000000000000..8c5f9b76febd
--- /dev/null
+++ b/packages/react-native/Libraries/LayoutAnimation/__tests__/LayoutAnimation-itest.js
@@ -0,0 +1,109 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ * @flow strict-local
+ * @format
+ * @oncall react_native
+ */
+
+import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment';
+
+import type {HostInstance} from 'react-native';
+
+import * as Fantom from '@react-native/fantom';
+import nullthrows from 'nullthrows';
+import * as React from 'react';
+import {createRef} from 'react';
+import {LayoutAnimation, View} from 'react-native';
+
+// Note: Fantom's mock mounting layer commits the post-animation layout in a
+// single mounting instruction — it does not replay the C++
+// LayoutAnimationKeyFrameManager's per-frame interpolation, so stepping the
+// virtual clock exposes only the final layout (verified: the box jumps straight
+// to its end size and `takeMountingManagerLogs` shows a single Update, with no
+// intermediate frames). These tests therefore exercise the JS LayoutAnimation
+// config/creation paths and assert that the configured layout change is
+// applied; intermediate-frame verification would require frame-by-frame
+// interpolation support in Fantom's mounting layer.
+function renderBox(
+ viewRef: {current: HostInstance | null},
+ width: number,
+): React.MixedElement {
+ return (
+
+ );
+}
+
+describe('LayoutAnimation', () => {
+ it('applies the layout change scheduled with a preset', () => {
+ const viewRef = createRef();
+ const root = Fantom.createRoot();
+
+ Fantom.runTask(() => {
+ root.render(renderBox(viewRef, 100));
+ });
+
+ const element = nullthrows(viewRef.current);
+ expect(element.getBoundingClientRect().width).toBe(100);
+
+ Fantom.runTask(() => {
+ LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
+ root.render(renderBox(viewRef, 300));
+ });
+
+ Fantom.unstable_produceFramesForDuration(500);
+ Fantom.runWorkLoop();
+
+ expect(element.getBoundingClientRect().width).toBe(300);
+ });
+
+ it('supports LayoutAnimation.create() as the config', () => {
+ const viewRef = createRef();
+ const root = Fantom.createRoot();
+
+ Fantom.runTask(() => {
+ root.render(renderBox(viewRef, 100));
+ });
+ const element = nullthrows(viewRef.current);
+
+ Fantom.runTask(() => {
+ LayoutAnimation.configureNext(
+ LayoutAnimation.create(200, 'linear', 'scaleXY'),
+ );
+ root.render(renderBox(viewRef, 250));
+ });
+
+ Fantom.unstable_produceFramesForDuration(200);
+ Fantom.runWorkLoop();
+
+ expect(element.getBoundingClientRect().width).toBe(250);
+ });
+
+ it('exposes the easeInEaseOut() shortcut', () => {
+ const viewRef = createRef();
+ const root = Fantom.createRoot();
+
+ Fantom.runTask(() => {
+ root.render(renderBox(viewRef, 100));
+ });
+ const element = nullthrows(viewRef.current);
+
+ Fantom.runTask(() => {
+ LayoutAnimation.easeInEaseOut();
+ root.render(renderBox(viewRef, 150));
+ });
+
+ Fantom.unstable_produceFramesForDuration(500);
+ Fantom.runWorkLoop();
+
+ expect(element.getBoundingClientRect().width).toBe(150);
+ });
+});
diff --git a/packages/react-native/Libraries/Lists/__tests__/FlatList-itest.js b/packages/react-native/Libraries/Lists/__tests__/FlatList-itest.js
index f45861320c83..15afb533d503 100644
--- a/packages/react-native/Libraries/Lists/__tests__/FlatList-itest.js
+++ b/packages/react-native/Libraries/Lists/__tests__/FlatList-itest.js
@@ -11,13 +11,11 @@
import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment';
import type {FlatListProps} from 'react-native/Libraries/Lists/FlatList';
-import ensureInstance from '../../../src/private/__tests__/utilities/ensureInstance';
import * as Fantom from '@react-native/fantom';
import nullthrows from 'nullthrows';
import * as React from 'react';
import {createRef} from 'react';
import {FlatList, Text, View} from 'react-native';
-import ReactNativeElement from 'react-native/src/private/webapis/dom/nodes/ReactNativeElement';
function testPropPropagatedToMountingLayer({
propName,
@@ -482,9 +480,8 @@ describe('', () => {
);
// Scroll down to trigger rendering of additional items.
- const scrollView = ensureInstance(
+ const scrollView = nullthrows(
nullthrows(flatListRef.current).getNativeScrollRef(),
- ReactNativeElement,
);
Fantom.scrollTo(scrollView, {x: 0, y: 100});
@@ -527,9 +524,8 @@ describe('', () => {
);
});
- const scrollView = ensureInstance(
+ const scrollView = nullthrows(
nullthrows(flatListRef.current).getNativeScrollRef(),
- ReactNativeElement,
);
Fantom.scrollTo(scrollView, {x: 0, y: 800});
@@ -538,6 +534,211 @@ describe('', () => {
});
});
+ describe('onViewableItemsChanged', () => {
+ it('reports viewable items after scrolling', () => {
+ const root = Fantom.createRoot({
+ viewportWidth: 400,
+ viewportHeight: 200,
+ });
+ const onViewableItemsChanged = jest.fn();
+ const flatListRef = createRef>();
+ Fantom.runTask(() => {
+ root.render(
+ ({key: String(i)}))}
+ renderItem={() => (
+
+ )}
+ getItemLayout={(
+ _data: ?Readonly<$ArrayLike<{key: string}>>,
+ index: number,
+ ) => ({
+ length: 100,
+ offset: 100 * index,
+ index,
+ })}
+ viewabilityConfig={{itemVisiblePercentThreshold: 50}}
+ onViewableItemsChanged={onViewableItemsChanged}
+ initialNumToRender={4}
+ windowSize={5}
+ />,
+ );
+ });
+
+ const scrollView = nullthrows(
+ nullthrows(flatListRef.current).getNativeScrollRef(),
+ );
+
+ // Scroll so items around index 3 become the viewable window.
+ Fantom.scrollTo(scrollView, {x: 0, y: 300});
+
+ expect(onViewableItemsChanged).toHaveBeenCalled();
+ const lastCall =
+ onViewableItemsChanged.mock.calls[
+ onViewableItemsChanged.mock.calls.length - 1
+ ][0];
+ const viewableKeys = lastCall.viewableItems.map(
+ (item: {key: string, ...}) => item.key,
+ );
+ expect(viewableKeys).toContain('3');
+ });
+
+ it('supports viewabilityConfigCallbackPairs', () => {
+ const root = Fantom.createRoot({
+ viewportWidth: 400,
+ viewportHeight: 200,
+ });
+ const onViewableItemsChanged = jest.fn();
+ const flatListRef = createRef>();
+ Fantom.runTask(() => {
+ root.render(
+ ({key: String(i)}))}
+ renderItem={() => (
+
+ )}
+ getItemLayout={(
+ _data: ?Readonly<$ArrayLike<{key: string}>>,
+ index: number,
+ ) => ({
+ length: 100,
+ offset: 100 * index,
+ index,
+ })}
+ viewabilityConfigCallbackPairs={[
+ {
+ viewabilityConfig: {viewAreaCoveragePercentThreshold: 50},
+ onViewableItemsChanged,
+ },
+ ]}
+ initialNumToRender={4}
+ windowSize={5}
+ />,
+ );
+ });
+
+ const scrollView = nullthrows(
+ nullthrows(flatListRef.current).getNativeScrollRef(),
+ );
+
+ Fantom.scrollTo(scrollView, {x: 0, y: 300});
+
+ expect(onViewableItemsChanged).toHaveBeenCalled();
+ const lastCall =
+ onViewableItemsChanged.mock.calls[
+ onViewableItemsChanged.mock.calls.length - 1
+ ][0];
+ expect(lastCall.viewableItems.length).toBeGreaterThan(0);
+ });
+
+ it('respects minimumViewTime before reporting items', () => {
+ const root = Fantom.createRoot({
+ viewportWidth: 400,
+ viewportHeight: 200,
+ });
+ const onViewableItemsChanged = jest.fn();
+ const flatListRef = createRef>();
+ const timers = Fantom.installTimerMock();
+ try {
+ Fantom.runTask(() => {
+ root.render(
+ ({key: String(i)}))}
+ renderItem={() => (
+
+ )}
+ getItemLayout={(
+ _data: ?Readonly<$ArrayLike<{key: string}>>,
+ index: number,
+ ) => ({
+ length: 100,
+ offset: 100 * index,
+ index,
+ })}
+ viewabilityConfig={{
+ itemVisiblePercentThreshold: 50,
+ minimumViewTime: 200,
+ }}
+ onViewableItemsChanged={onViewableItemsChanged}
+ initialNumToRender={4}
+ windowSize={5}
+ />,
+ );
+ });
+
+ const scrollView = nullthrows(
+ nullthrows(flatListRef.current).getNativeScrollRef(),
+ );
+ Fantom.scrollTo(scrollView, {x: 0, y: 300});
+
+ // Items must remain viewable for `minimumViewTime` before being
+ // reported, so nothing has fired yet.
+ expect(onViewableItemsChanged).not.toHaveBeenCalled();
+
+ timers.advanceTimersByTime(200);
+
+ expect(onViewableItemsChanged).toHaveBeenCalled();
+ } finally {
+ timers.uninstall();
+ }
+ });
+
+ it('waits for interaction before reporting items when waitForInteraction is set', () => {
+ const root = Fantom.createRoot({
+ viewportWidth: 400,
+ viewportHeight: 200,
+ });
+ const onViewableItemsChanged = jest.fn();
+ const flatListRef = createRef>();
+ Fantom.runTask(() => {
+ root.render(
+ ({key: String(i)}))}
+ renderItem={() => (
+
+ )}
+ getItemLayout={(
+ _data: ?Readonly<$ArrayLike<{key: string}>>,
+ index: number,
+ ) => ({
+ length: 100,
+ offset: 100 * index,
+ index,
+ })}
+ viewabilityConfig={{
+ itemVisiblePercentThreshold: 50,
+ waitForInteraction: true,
+ }}
+ onViewableItemsChanged={onViewableItemsChanged}
+ initialNumToRender={4}
+ windowSize={5}
+ />,
+ );
+ });
+
+ // Initially visible items are withheld until the user interacts.
+ expect(onViewableItemsChanged).not.toHaveBeenCalled();
+
+ // Signal an interaction (scrolling records this via onScrollBeginDrag; the
+ // public method is the deterministic equivalent), then a viewability
+ // re-evaluation reports the visible items.
+ Fantom.runTask(() => {
+ nullthrows(flatListRef.current).recordInteraction();
+ });
+
+ const scrollView = nullthrows(
+ nullthrows(flatListRef.current).getNativeScrollRef(),
+ );
+ Fantom.scrollTo(scrollView, {x: 0, y: 100});
+
+ expect(onViewableItemsChanged).toHaveBeenCalled();
+ });
+ });
+
describe('extraData', () => {
it('triggers re-render when extraData changes', () => {
const root = Fantom.createRoot();
diff --git a/packages/react-native/Libraries/Network/__tests__/Network-itest.js b/packages/react-native/Libraries/Network/__tests__/Network-itest.js
new file mode 100644
index 000000000000..5e9d748d799b
--- /dev/null
+++ b/packages/react-native/Libraries/Network/__tests__/Network-itest.js
@@ -0,0 +1,90 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ * @flow strict-local
+ * @format
+ * @oncall react_native
+ */
+
+import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment';
+
+// `XMLHttpRequest` is set up as a global by the default environment (via
+// `setUpXHR`), so these tests use the public global rather than importing the
+// internal module. Fantom's HTTP client stub does not deliver responses, so
+// they cover the synchronous XMLHttpRequest lifecycle and validation logic
+// rather than response/progress/load/error events.
+describe('XMLHttpRequest', () => {
+ it('transitions to OPENED after open()', () => {
+ const xhr = new XMLHttpRequest();
+ expect(xhr.readyState).toBe(XMLHttpRequest.UNSENT);
+
+ xhr.open('GET', 'https://example.com/data');
+ expect(xhr.readyState).toBe(XMLHttpRequest.OPENED);
+ });
+
+ it('throws for synchronous requests', () => {
+ const xhr = new XMLHttpRequest();
+ expect(() => {
+ xhr.open('GET', 'https://example.com', false);
+ }).toThrow('Synchronous http requests are not supported');
+ });
+
+ it('throws when opening an empty url', () => {
+ const xhr = new XMLHttpRequest();
+ expect(() => {
+ xhr.open('GET', '');
+ }).toThrow('Cannot load an empty url');
+ });
+
+ it('throws when setting a request header before open()', () => {
+ const xhr = new XMLHttpRequest();
+ expect(() => {
+ xhr.setRequestHeader('Content-Type', 'application/json');
+ }).toThrow('Request has not been opened');
+ });
+
+ it('accepts request headers after open()', () => {
+ const xhr = new XMLHttpRequest();
+ xhr.open('POST', 'https://example.com');
+ expect(() => {
+ xhr.setRequestHeader('Content-Type', 'application/json');
+ }).not.toThrow();
+ });
+
+ it('throws when sending before open()', () => {
+ const xhr = new XMLHttpRequest();
+ expect(() => {
+ xhr.send();
+ }).toThrow('Request has not been opened');
+ });
+
+ it('exposes an empty responseText before loading', () => {
+ const xhr = new XMLHttpRequest();
+ xhr.open('GET', 'https://example.com');
+ expect(xhr.responseText).toBe('');
+ expect(xhr.status).toBe(0);
+ expect(xhr.getAllResponseHeaders()).toBe(null);
+ });
+
+ it('supports setting a valid responseType before send', () => {
+ const xhr = new XMLHttpRequest();
+ xhr.open('GET', 'https://example.com');
+ xhr.responseType = 'json';
+ expect(xhr.responseType).toBe('json');
+ });
+
+ it('exposes the upload object and fires onreadystatechange when opened', () => {
+ const xhr = new XMLHttpRequest();
+ const onReadyStateChange = jest.fn();
+ xhr.onreadystatechange = onReadyStateChange;
+
+ xhr.open('GET', 'https://example.com');
+
+ // Opening advances readyState to OPENED, firing readystatechange.
+ expect(onReadyStateChange).toHaveBeenCalled();
+ expect(xhr.upload).toBeDefined();
+ });
+});
diff --git a/packages/react-native/Libraries/Pressability/__tests__/Pressability-touch-itest.js b/packages/react-native/Libraries/Pressability/__tests__/Pressability-touch-itest.js
new file mode 100644
index 000000000000..7b31bc57a0c6
--- /dev/null
+++ b/packages/react-native/Libraries/Pressability/__tests__/Pressability-touch-itest.js
@@ -0,0 +1,167 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ * @flow strict-local
+ * @format
+ * @oncall react_native
+ */
+
+import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment';
+
+import type {PressabilityConfig} from '../Pressability';
+import type {HostInstance} from 'react-native';
+
+import usePressability from '../usePressability';
+import * as Fantom from '@react-native/fantom';
+import nullthrows from 'nullthrows';
+import * as React from 'react';
+import {createRef} from 'react';
+import {View} from 'react-native';
+
+function touchPayload(
+ identifier: number = 0,
+ pageX: number = 5,
+ pageY: number = 5,
+): {touches: Array<{...}>, changedTouches: Array<{...}>} {
+ return {
+ touches: [{identifier, pageX, pageY, timestamp: 0}],
+ changedTouches: [{identifier, pageX, pageY, timestamp: 0}],
+ };
+}
+
+function touchEndPayload(identifier: number = 0): {
+ touches: Array<{...}>,
+ changedTouches: Array<{...}>,
+} {
+ return {
+ touches: [],
+ changedTouches: [{identifier, pageX: 5, pageY: 5, timestamp: 200}],
+ };
+}
+
+function PressabilityTestView({
+ config,
+ ...viewProps
+}: {
+ config: PressabilityConfig,
+ ref?: React.RefSetter,
+ style?: {height: number, width: number},
+}) {
+ const eventHandlers = usePressability(config);
+ return ;
+}
+
+function renderPressability(config: PressabilityConfig): HostInstance {
+ const ref = createRef();
+ const root = Fantom.createRoot();
+ Fantom.runTask(() => {
+ root.render(
+ ,
+ );
+ });
+ return nullthrows(ref.current);
+}
+
+function touchStart(element: HostInstance): void {
+ Fantom.dispatchNativeEvent(element, 'onTouchStart', touchPayload(), {
+ category: Fantom.NativeEventCategory.Discrete,
+ });
+}
+
+function touchEnd(element: HostInstance): void {
+ Fantom.dispatchNativeEvent(element, 'onTouchEnd', touchEndPayload(), {
+ category: Fantom.NativeEventCategory.Discrete,
+ });
+}
+
+describe('Pressability (touch)', () => {
+ let timers: Fantom.TimerMock;
+
+ beforeEach(() => {
+ timers = Fantom.installTimerMock();
+ });
+
+ afterEach(() => {
+ timers.uninstall();
+ });
+
+ it('fires onPressIn on touch start and onPressOut/onPress on touch end', () => {
+ const onPressIn = jest.fn();
+ const onPressOut = jest.fn();
+ const onPress = jest.fn();
+
+ const element = renderPressability({onPressIn, onPressOut, onPress});
+
+ touchStart(element);
+ expect(onPressIn).toHaveBeenCalledTimes(1);
+ expect(onPressOut).toHaveBeenCalledTimes(0);
+
+ touchEnd(element);
+ // Flush the minimum-press-duration timer that gates onPressOut.
+ timers.runAllTimers();
+
+ expect(onPress).toHaveBeenCalledTimes(1);
+ expect(onPressOut).toHaveBeenCalledTimes(1);
+ });
+
+ it('fires onLongPress after the long press delay and suppresses onPress', () => {
+ const onLongPress = jest.fn();
+ const onPress = jest.fn();
+
+ const element = renderPressability({onLongPress, onPress});
+
+ touchStart(element);
+ // Default long press delay is 500ms.
+ timers.advanceTimersByTime(500);
+ expect(onLongPress).toHaveBeenCalledTimes(1);
+
+ touchEnd(element);
+ timers.runAllTimers();
+
+ // A long press does not additionally fire onPress.
+ expect(onPress).toHaveBeenCalledTimes(0);
+ });
+
+ it('delays onPressIn when delayPressIn is set', () => {
+ const onPressIn = jest.fn();
+
+ const element = renderPressability({
+ onPressIn,
+ delayPressIn: 200,
+ });
+
+ touchStart(element);
+ expect(onPressIn).toHaveBeenCalledTimes(0);
+
+ timers.advanceTimersByTime(200);
+ expect(onPressIn).toHaveBeenCalledTimes(1);
+
+ // Release the responder to clean up global responder state.
+ touchEnd(element);
+ timers.runAllTimers();
+ });
+
+ it('does not fire onPress when the responder is terminated', () => {
+ const onPress = jest.fn();
+ const onPressIn = jest.fn();
+
+ const element = renderPressability({onPress, onPressIn});
+
+ touchStart(element);
+ expect(onPressIn).toHaveBeenCalledTimes(1);
+
+ Fantom.dispatchNativeEvent(element, 'onTouchCancel', touchEndPayload(), {
+ category: Fantom.NativeEventCategory.Discrete,
+ });
+ timers.runAllTimers();
+
+ expect(onPress).toHaveBeenCalledTimes(0);
+ });
+});
diff --git a/packages/react-native/Libraries/Text/__tests__/Text-itest.js b/packages/react-native/Libraries/Text/__tests__/Text-itest.js
index a902d3d528a0..a7a50140cd5a 100644
--- a/packages/react-native/Libraries/Text/__tests__/Text-itest.js
+++ b/packages/react-native/Libraries/Text/__tests__/Text-itest.js
@@ -10,10 +10,12 @@
import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment';
-import type {AccessibilityProps, HostInstance} from 'react-native';
+import type {HostInstance} from 'react-native';
+import type {AccessibilityProps} from 'react-native';
import ensureInstance from '../../../src/private/__tests__/utilities/ensureInstance';
import * as Fantom from '@react-native/fantom';
+import nullthrows from 'nullthrows';
import * as React from 'react';
import {createRef} from 'react';
import {Text} from 'react-native';
@@ -21,7 +23,7 @@ import accessibilityPropsSuite, {
rolePropSuite,
} from 'react-native/src/private/__tests__/utilities/accessibilityPropsSuite';
import {testIDPropSuite} from 'react-native/src/private/__tests__/utilities/commonPropsSuite';
-import ReactNativeElement from 'react-native/src/private/webapis/dom/nodes/ReactNativeElement';
+import ReadOnlyElement from 'react-native/src/private/webapis/dom/nodes/ReadOnlyElement';
import ReadOnlyText from 'react-native/src/private/webapis/dom/nodes/ReadOnlyText';
const TEST_TEXT = 'the text';
@@ -579,7 +581,7 @@ describe('', () => {
root.render({TEST_TEXT});
});
- const element = ensureInstance(elementRef.current, ReactNativeElement);
+ const element = nullthrows(elementRef.current);
expect(element.tagName).toBe('RN:Paragraph');
});
@@ -592,7 +594,7 @@ describe('', () => {
root.render({TEST_TEXT});
});
- const element = ensureInstance(elementRef.current, ReactNativeElement);
+ const element = nullthrows(elementRef.current);
expect(element.childNodes.length).toBe(1);
const textChild = ensureInstance(element.childNodes[0], ReadOnlyText);
@@ -612,7 +614,7 @@ describe('', () => {
);
});
- const element = ensureInstance(elementRef.current, ReactNativeElement);
+ const element = nullthrows(elementRef.current);
expect(element.childNodes.length).toBe(2);
const firstChild = ensureInstance(element.childNodes[0], ReadOnlyText);
@@ -620,7 +622,7 @@ describe('', () => {
const secondChild = ensureInstance(
element.childNodes[1],
- ReactNativeElement,
+ ReadOnlyElement,
);
expect(secondChild.tagName).toBe('RN:Text');
expect(secondChild.childNodes.length).toBe(1);
@@ -816,6 +818,41 @@ describe('', () => {
});
});
+ describe('text style props', () => {
+ it('propagates letterSpacing to the mounting layer', () => {
+ const root = Fantom.createRoot();
+ Fantom.runTask(() => {
+ root.render({TEST_TEXT});
+ });
+ expect(
+ root.getRenderedOutput({props: ['letterSpacing']}).toJSX(),
+ ).toEqual({TEST_TEXT});
+ });
+
+ it('propagates lineHeight to the mounting layer', () => {
+ const root = Fantom.createRoot();
+ Fantom.runTask(() => {
+ root.render({TEST_TEXT});
+ });
+ expect(root.getRenderedOutput({props: ['lineHeight']}).toJSX()).toEqual(
+ {TEST_TEXT},
+ );
+ });
+
+ it('propagates fontVariant to the mounting layer', () => {
+ const root = Fantom.createRoot();
+ Fantom.runTask(() => {
+ root.render(
+ {TEST_TEXT},
+ );
+ });
+ const fontVariant = root
+ .getRenderedOutput({props: ['fontVariant']})
+ .toJSONObject().props.fontVariant;
+ expect(fontVariant).toContain('small-caps');
+ });
+ });
+
component TestComponent(testID?: ?string, ...props: AccessibilityProps) {
return (