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 (