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..a5a06d9bb22d
--- /dev/null
+++ b/packages/react-native/Libraries/Animated/__tests__/AnimatedColor-itest.js
@@ -0,0 +1,125 @@
+/**
+ * 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} from 'react-native';
+
+// Renders `color` as a View's backgroundColor so the parsed/composed value can
+// be observed through the public rendered output rather than private state.
+function mountedBackgroundColor(color: Animated.Color): string {
+ const root = Fantom.createRoot();
+ Fantom.runTask(() => {
+ root.render(
+ ,
+ );
+ });
+ return root.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)');
+ });
+
+ // Runtime color updates (setValue/offsets/JS-driven animation) are not exposed
+ // through Fantom's rendered output or direct-manipulation props, so these
+ // assert the computed value via `__getValue()`.
+ it('updates all channels via setValue', () => {
+ const color = new Animated.Color('rgba(0, 0, 0, 1)');
+ color.setValue({r: 5, g: 6, b: 7, a: 1});
+ expect(color.__getValue()).toBe('rgba(5, 6, 7, 1)');
+ });
+
+ it('applies an offset on top of the base value', () => {
+ const color = new Animated.Color({r: 10, g: 10, b: 10, a: 1});
+ color.setOffset({r: 5, g: 5, b: 5, a: 0});
+ expect(color.__getValue()).toBe('rgba(15, 15, 15, 1)');
+ });
+
+ it('animates to a target color', () => {
+ const color = new Animated.Color('rgba(255, 0, 0, 1)');
+ const viewRef = createRef();
+ const root = Fantom.createRoot();
+ Fantom.runTask(() => {
+ root.render(
+ ,
+ );
+ });
+ nullthrows(viewRef.current);
+
+ // Initial value is observed publicly through the rendered output.
+ 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: false,
+ }).start(result => {
+ finished = result.finished;
+ });
+ });
+
+ Fantom.unstable_produceFramesForDuration(200);
+ Fantom.runWorkLoop();
+
+ expect(finished).toBe(true);
+ // The driven value isn't exposed publicly; assert the computed result.
+ expect(color.__getValue()).toBe('rgba(0, 0, 255, 1)');
+ });
+});
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..d58eee52e3e8
--- /dev/null
+++ b/packages/react-native/Libraries/Animated/__tests__/AnimatedComposition-itest.js
@@ -0,0 +1,202 @@
+/**
+ * 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 = new Animated.Value(0);
+ 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, 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(80);
+ });
+
+ expect(getTranslateX(root)).toBe(40);
+ });
+
+ it('returns 0 instead of Infinity when dividing by zero', () => {
+ // A zero transform value is dropped from the rendered output, so the result
+ // isn't observable publicly; assert the computed value directly.
+ const node = Animated.divide(10, 0);
+ expect(node.__getValue()).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', () => {
+ // `AnimatedDiffClamp.__getValue()` mutates internal accumulator state on every
+ // call, so it is driven directly here (rather than through the render/commit
+ // pipeline, which may read the node an unpredictable number of times).
+ it('clamps the accumulated delta between min and max', () => {
+ const base = new Animated.Value(0);
+ const clamped = Animated.diffClamp(base, 0, 20);
+
+ expect(clamped.__getValue()).toBe(0);
+
+ // Increase beyond max: 0 + 100 clamped to 20.
+ base.setValue(100);
+ expect(clamped.__getValue()).toBe(20);
+
+ // Decrease by 30: 20 + (70 - 100) = -10, clamped to 0.
+ base.setValue(70);
+ expect(clamped.__getValue()).toBe(0);
+
+ // Increase by 5: 0 + (75 - 70) = 5, within range.
+ base.setValue(75);
+ expect(clamped.__getValue()).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 = new Animated.Value(0);
+ let leader: Animated.Value = new Animated.Value(0);
+ 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(follower, {
+ toValue: leader,
+ duration: 100,
+ useNativeDriver: true,
+ }).start();
+ });
+
+ Fantom.runTask(() => {
+ leader.setValue(50);
+ });
+
+ Fantom.unstable_produceFramesForDuration(200);
+ Fantom.runWorkLoop();
+
+ expect(getTranslateX(root)).toBeCloseTo(50, 1);
+ });
+});
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..40aa96557194
--- /dev/null
+++ b/packages/react-native/Libraries/Animated/__tests__/DecayAnimation-itest.js
@@ -0,0 +1,38 @@
+/**
+ * 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 * as Fantom from '@react-native/fantom';
+import {Animated} from 'react-native';
+
+describe('Animated.decay', () => {
+ it('runs the JS decay animation to completion', () => {
+ const value = new Animated.Value(0);
+
+ let finished = false;
+ Fantom.runTask(() => {
+ Animated.decay(value, {
+ velocity: 1,
+ useNativeDriver: false,
+ }).start(result => {
+ finished = result.finished;
+ });
+ });
+
+ // Drive the `requestAnimationFrame`-based JS decay to rest. Fantom's virtual
+ // clock does not deterministically expose intermediate frame values for the
+ // JS driver, so we assert that the animation completes (which exercises
+ // `onUpdate`) rather than asserting per-frame movement.
+ Fantom.unstable_produceFramesForDuration(3000);
+
+ expect(finished).toBe(true);
+ });
+});
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..e53e0cc4dcee
--- /dev/null
+++ b/packages/react-native/Libraries/Animated/__tests__/SpringAnimation-itest.js
@@ -0,0 +1,181 @@
+/**
+ * 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 bound to an AnimatedValue and
+// returns the value plus the mounted element. Spring/decay configs are all
+// exercised through the public `Animated.spring`/`Animated.decay` API with the
+// native driver, which builds the JS animation instance (running its config
+// conversion and invariants) and hands the config to the C++ backend.
+function renderTranslateX(): {
+ value: Animated.Value,
+ element: HostInstance,
+ root: Fantom.Root,
+} {
+ let value: Animated.Value = new Animated.Value(0);
+ const viewRef = createRef();
+
+ function MyApp() {
+ const translateX = useAnimatedValue(0);
+ value = translateX;
+ return (
+
+ );
+ }
+
+ const root = Fantom.createRoot();
+ Fantom.runTask(() => {
+ root.render();
+ });
+
+ const element = nullthrows(viewRef.current);
+ return {value, element, root};
+}
+
+function getTranslateX(root: Fantom.Root): number {
+ const output = root.getRenderedOutput({props: ['transform']}).toJSONObject();
+ return JSON.parse(output.props.transform)[0].translateX;
+}
+
+describe('Animated.spring', () => {
+ // Runs first so it drives frames from a fresh virtual clock. Uses the JS
+ // driver so the closed-form spring integration (`onUpdate`) runs in JS; the
+ // settled value is observed through the rendered transform.
+ it('settles exactly at toValue with the JS driver', () => {
+ const {value, root} = renderTranslateX();
+
+ let finished = false;
+ Fantom.runTask(() => {
+ Animated.spring(value, {
+ toValue: 100,
+ useNativeDriver: false,
+ }).start(result => {
+ finished = result.finished;
+ });
+ });
+
+ Fantom.unstable_produceFramesForDuration(3000);
+
+ expect(finished).toBe(true);
+ expect(getTranslateX(root)).toBe(100);
+ });
+
+ it('settles at toValue with the default tension/friction config', () => {
+ const {value, element} = renderTranslateX();
+
+ let finished = false;
+ Fantom.runTask(() => {
+ Animated.spring(value, {
+ toValue: 100,
+ useNativeDriver: true,
+ }).start(result => {
+ finished = result.finished;
+ });
+ });
+
+ Fantom.unstable_produceFramesForDuration(3000);
+ Fantom.runWorkLoop();
+
+ expect(finished).toBe(true);
+ expect(element.getBoundingClientRect().x).toBe(100);
+ });
+
+ it('settles at toValue when configured via bounciness/speed', () => {
+ const {value, element} = renderTranslateX();
+
+ Fantom.runTask(() => {
+ Animated.spring(value, {
+ toValue: 80,
+ bounciness: 12,
+ speed: 12,
+ useNativeDriver: true,
+ }).start();
+ });
+
+ Fantom.unstable_produceFramesForDuration(3000);
+ Fantom.runWorkLoop();
+
+ expect(element.getBoundingClientRect().x).toBe(80);
+ });
+
+ it('settles at toValue when configured via stiffness/damping/mass', () => {
+ const {value, element} = renderTranslateX();
+
+ Fantom.runTask(() => {
+ Animated.spring(value, {
+ toValue: 60,
+ stiffness: 200,
+ damping: 20,
+ mass: 1,
+ useNativeDriver: true,
+ }).start();
+ });
+
+ Fantom.unstable_produceFramesForDuration(3000);
+ Fantom.runWorkLoop();
+
+ expect(element.getBoundingClientRect().x).toBe(60);
+ });
+
+ it('does not overshoot past toValue when overshootClamping is enabled', () => {
+ const {value, element} = renderTranslateX();
+
+ let maxObserved = 0;
+ Fantom.runTask(() => {
+ value.addListener(({value: v}) => {
+ maxObserved = Math.max(maxObserved, v);
+ });
+ });
+
+ Fantom.runTask(() => {
+ Animated.spring(value, {
+ toValue: 100,
+ overshootClamping: true,
+ // A low-damping spring would overshoot without clamping.
+ stiffness: 500,
+ damping: 5,
+ mass: 1,
+ useNativeDriver: true,
+ }).start();
+ });
+
+ Fantom.unstable_produceFramesForDuration(3000);
+ Fantom.runWorkLoop();
+
+ expect(element.getBoundingClientRect().x).toBe(100);
+ });
+
+ it('throws when combining mutually exclusive config groups', () => {
+ const {value} = renderTranslateX();
+
+ expect(() => {
+ Fantom.runTask(() => {
+ Animated.spring(value, {
+ toValue: 1,
+ stiffness: 100,
+ bounciness: 10,
+ useNativeDriver: true,
+ }).start();
+ });
+ }).toThrow();
+ });
+});
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..0209c2967828 100644
--- a/packages/react-native/Libraries/Components/TextInput/__tests__/TextInput-itest.js
+++ b/packages/react-native/Libraries/Components/TextInput/__tests__/TextInput-itest.js
@@ -15,7 +15,6 @@ 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', () => {
@@ -147,6 +146,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();
@@ -290,7 +396,7 @@ describe('', () => {
root.render();
});
- expect(ref.current).toBeInstanceOf(ReactNativeElement);
+ expect(ref.current).toBeInstanceOf(HTMLElement);
});
it('provides additional methods: clear, isFocused, getNativeRef, setSelection', () => {
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..27245766b251
--- /dev/null
+++ b/packages/react-native/Libraries/LayoutAnimation/__tests__/LayoutAnimation-itest.js
@@ -0,0 +1,104 @@
+/**
+ * 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 applies the post-animation layout without
+// exposing frame-by-frame interpolation, so these tests exercise the JS
+// LayoutAnimation config/creation paths and assert the resulting layout instead
+// of intermediate frames.
+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..817d4e4f4043 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,106 @@ 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);
+ });
+ });
+
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..1a1a6bfac2a0
--- /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('supports the upload object and event listeners', () => {
+ 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 (