diff --git a/packages/react-native/Libraries/Components/TextInput/__tests__/TextInput-itest.js b/packages/react-native/Libraries/Components/TextInput/__tests__/TextInput-itest.js new file mode 100644 index 00000000000..504d69fd766 --- /dev/null +++ b/packages/react-native/Libraries/Components/TextInput/__tests__/TextInput-itest.js @@ -0,0 +1,96 @@ +/** + * 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 '../../../Core/InitializeCore.js'; +import * as ReactNativeTester from '../../../../src/private/__tests__/ReactNativeTester'; +import TextInput from '../TextInput'; +import * as React from 'react'; +import {useEffect, useLayoutEffect, useRef} from 'react'; + +describe('TextInput', () => { + it('creates view before dispatching view command from ref function', () => { + const root = ReactNativeTester.createRoot(); + + ReactNativeTester.runTask(() => { + root.render( + { + if (node) { + node.focus(); + } + }} + />, + ); + }); + + const mountingLogs = root.getMountingLogs(); + + expect(mountingLogs.length).toBe(2); + expect(mountingLogs[0]).toBe('create view type: `AndroidTextInput`'); + expect(mountingLogs[1]).toBe( + 'dispatch command `focus` on component `AndroidTextInput`', + ); + }); + + it('creates view before dispatching view command from useLayoutEffect', () => { + const root = ReactNativeTester.createRoot(); + + function Component() { + const textInputRef = useRef>( + null, + ); + + useLayoutEffect(() => { + textInputRef.current?.focus(); + }); + + return ; + } + ReactNativeTester.runTask(() => { + root.render(); + }); + + const mountingLogs = root.getMountingLogs(); + + expect(mountingLogs.length).toBe(2); + expect(mountingLogs[0]).toBe('create view type: `AndroidTextInput`'); + expect(mountingLogs[1]).toBe( + 'dispatch command `focus` on component `AndroidTextInput`', + ); + }); + + it('creates view before dispatching view command from useEffect', () => { + const root = ReactNativeTester.createRoot(); + + function Component() { + const textInputRef = useRef>( + null, + ); + + useEffect(() => { + textInputRef.current?.focus(); + }); + + return ; + } + ReactNativeTester.runTask(() => { + root.render(); + }); + + const mountingLogs = root.getMountingLogs(); + + expect(mountingLogs.length).toBe(2); + expect(mountingLogs[0]).toBe('create view type: `AndroidTextInput`'); + expect(mountingLogs[1]).toBe( + 'dispatch command `focus` on component `AndroidTextInput`', + ); + }); +}); diff --git a/packages/react-native/Libraries/ReactNative/__tests__/ReactFabric-Suspense-itest.js b/packages/react-native/Libraries/ReactNative/__tests__/ReactFabric-Suspense-itest.js new file mode 100644 index 00000000000..e4c7ceee69a --- /dev/null +++ b/packages/react-native/Libraries/ReactNative/__tests__/ReactFabric-Suspense-itest.js @@ -0,0 +1,245 @@ +/** + * 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 '../../Core/InitializeCore.js'; +import * as ReactNativeTester from '../../../src/private/__tests__/ReactNativeTester'; +import View from '../../Components/View/View'; +import * as React from 'react'; +import {Suspense, startTransition} from 'react'; + +let resolveFunction: (() => void) | null = null; + +// This is a workaround for a bug to get the demo running. +// TODO: replace with real implementation when the bug is fixed. +// $FlowFixMe: [missing-local-annot] +function use(promise) { + if (promise.status === 'fulfilled') { + return promise.value; + } else if (promise.status === 'rejected') { + throw promise.reason; + } else if (promise.status === 'pending') { + throw promise; + } else { + promise.status = 'pending'; + promise.then( + result => { + promise.status = 'fulfilled'; + promise.value = result; + }, + reason => { + promise.status = 'rejected'; + promise.reason = reason; + }, + ); + throw promise; + } +} + +type SquareData = { + color: 'red' | 'green', +}; + +enum SquareId { + Green = 'green-square', + Red = 'red-square', +} + +async function getGreenSquareData(): Promise { + await new Promise(resolve => { + resolveFunction = resolve; + }); + return { + color: 'green', + }; +} + +async function getRedSquareData(): Promise { + await new Promise(resolve => { + resolveFunction = resolve; + }); + return { + color: 'red', + }; +} + +const cache = new Map(); + +async function getData(squareId: SquareId): Promise { + switch (squareId) { + case SquareId.Green: + return await getGreenSquareData(); + case SquareId.Red: + return await getRedSquareData(); + } +} + +async function fetchData(squareId: SquareId): Promise { + const data = await getData(squareId); + cache.set(squareId, data); + return data; +} + +function Square(props: {squareId: SquareId}) { + let data = cache.get(props.squareId); + if (data == null) { + data = use(fetchData(props.squareId)); + } + return ; +} + +function GreenSquare() { + return ; +} + +function RedSquare() { + return ; +} + +function Fallback() { + return ; +} + +describe('Suspense', () => { + it('shows fallback if data is not available', () => { + cache.clear(); + const root = ReactNativeTester.createRoot(); + + ReactNativeTester.runTask(() => { + root.render( + }> + + , + ); + }); + + let mountingLogs = root.getMountingLogs(); + + expect(mountingLogs.length).toBe(1); + expect(mountingLogs[0]).toBe( + 'create view type: `View` nativeId: `suspense fallback`', + ); + + expect(resolveFunction).not.toBeNull(); + ReactNativeTester.runTask(() => { + resolveFunction?.(); + resolveFunction = null; + }); + + mountingLogs = root.getMountingLogs(); + + expect(mountingLogs.length).toBe(1); + expect(mountingLogs[0]).toBe( + 'create view type: `View` nativeId: `square with data: green`', + ); + + ReactNativeTester.runTask(() => { + root.render( + }> + + , + ); + }); + + mountingLogs = root.getMountingLogs(); + + expect(mountingLogs.length).toBe(1); + expect(mountingLogs[0]).toBe( + 'create view type: `View` nativeId: `suspense fallback`', + ); + + expect(resolveFunction).not.toBeNull(); + ReactNativeTester.runTask(() => { + resolveFunction?.(); + resolveFunction = null; + }); + + mountingLogs = root.getMountingLogs(); + + expect(mountingLogs.length).toBe(1); + expect(mountingLogs[0]).toBe( + 'create view type: `View` nativeId: `square with data: red`', + ); + + ReactNativeTester.runTask(() => { + root.render( + }> + + , + ); + }); + + mountingLogs = root.getMountingLogs(); + + expect(mountingLogs.length).toBe(1); + expect(mountingLogs[0]).toBe( + 'create view type: `View` nativeId: `square with data: green`', + ); + + expect(resolveFunction).toBeNull(); + + root.destroy(); + }); + + // TODO(T207868872): this test only succeeds with enableFabricCompleteRootInCommitPhase enabled. + // enableFabricCompleteRootInCommitPhase is hardcoded to true in the testing environment. + it('shows stale data while transition is happening', () => { + cache.clear(); + cache.set(SquareId.Green, {color: 'green'}); + + const root = ReactNativeTester.createRoot(); + + function App(props: {color: 'red' | 'green'}) { + return ( + }> + {props.color === 'green' ? : } + + ); + } + + ReactNativeTester.runTask(() => { + root.render(); + }); + + let mountingLogs = root.getMountingLogs(); + + expect(mountingLogs.length).toBe(1); + expect(mountingLogs[0]).toBe( + 'create view type: `View` nativeId: `square with data: green`', + ); + + expect(resolveFunction).toBeNull(); + ReactNativeTester.runTask(() => { + startTransition(() => { + root.render(); + }); + }); + + mountingLogs = root.getMountingLogs(); + + // Green square is still mounted. Fallback is not shown to the user. + expect(mountingLogs.length).toBe(0); + + expect(resolveFunction).not.toBeNull(); + ReactNativeTester.runTask(() => { + resolveFunction?.(); + resolveFunction = null; + }); + + mountingLogs = root.getMountingLogs(); + + expect(mountingLogs.length).toBe(1); + expect(mountingLogs[0]).toBe( + 'create view type: `View` nativeId: `square with data: red`', + ); + + root.destroy(); + }); +}); diff --git a/packages/react-native/src/private/__tests__/expect-itest.js b/packages/react-native/src/private/__tests__/expect-itest.js new file mode 100644 index 00000000000..9db94a70e3d --- /dev/null +++ b/packages/react-native/src/private/__tests__/expect-itest.js @@ -0,0 +1,515 @@ +/** + * 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 + */ + +function ensureError(fn: () => void): void { + try { + fn(); + } catch (e) { + return; + } + + throw new Error(`Expected function to throw, but it didn't`); +} + +describe('expect', () => { + test('toThrow', () => { + expect(() => { + throw new Error(); + }).toThrow(); + + expect(() => { + throw new Error('error message'); + }).toThrow('error message'); + + expect(() => { + throw new Error('error message'); + }).not.toThrow('error message 2'); + + expect(() => {}).not.toThrow(); + + ensureError(() => { + expect(() => {}).toThrow(); + }); + + ensureError(() => { + expect(() => { + throw new Error(); + }).not.toThrow(); + }); + }); + + test('toBe', () => { + expect(1).toBe(1); + expect(1).not.toBe(2); + + const obj = {a: 1}; + const obj2 = {a: 1}; + + expect(obj).toBe(obj); + expect(obj).not.toBe(obj2); + + expect(() => { + expect(obj).not.toBe(obj); + }).toThrow(); + + expect(() => { + expect(1).not.toBe(1); + }).toThrow(); + }); + + test('toEqual', () => { + expect(1).toEqual(1); + expect(1).not.toEqual(2); + + const obj = {a: 1}; + const obj2 = {a: 1}; + const obj3 = {a: 2}; + + expect(obj).toEqual(obj); + expect(obj).toEqual(obj2); + expect(obj).not.toEqual(obj3); + + expect(null).toEqual(null); + expect(undefined).toEqual(undefined); + expect(null).not.toEqual(undefined); + + expect({a: null}).not.toEqual({a: undefined}); + expect({a: undefined}).not.toEqual({}); + + expect(() => { + expect(obj).not.toEqual(obj2); + }).toThrow(); + + expect(() => { + expect(obj).toEqual(obj3); + }).toThrow(); + + expect(() => { + expect(1).not.toEqual(1); + }).toThrow(); + + expect(() => { + expect(null).not.toEqual(null); + }).toThrow(); + + expect(() => { + expect(undefined).not.toEqual(undefined); + }).toThrow(); + + expect(() => { + expect({a: undefined}).toEqual({}); + }).toThrow(); + }); + + test('toBeInstanceOf', () => { + class Class {} + + expect(1).not.toBeInstanceOf(Number); + expect(1).not.toBeInstanceOf(Class); + + expect(new Class()).toBeInstanceOf(Class); + expect(new Class()).toBeInstanceOf(Object); + expect(new Class()).not.toBeInstanceOf(Number); + + expect(() => { + expect(1).toBeInstanceOf(Number); + }).toThrow(); + + expect(() => { + expect(new Class()).not.toBeInstanceOf(Class); + }).toThrow(); + }); + + test('toBeCloseTo', () => { + expect(1).toBeCloseTo(1.001); + expect(1).toBeCloseTo(1.01, 1); + expect(1).toBeCloseTo(1.1, 0); + + expect(() => { + expect(1).toBeCloseTo(1.01); + }).toThrow(); + + expect(() => { + expect(1).toBeCloseTo(1.1, 1); + }).toThrow(); + + expect(() => { + expect(1).toBeCloseTo(2, 0); + }).toThrow(); + }); + + test('toHaveBeenCalled', () => { + const fn = jest.fn(); + + expect(fn).not.toHaveBeenCalled(); + + expect(() => { + expect(fn).toHaveBeenCalled(); + }).toThrow(); + + fn(); + + expect(fn).toHaveBeenCalled(); + + expect(() => { + expect(fn).not.toHaveBeenCalled(); + }).toThrow(); + + // Passing functions that aren't mocks should always fail + expect(() => { + expect(() => {}).toHaveBeenCalled(); + }).toThrow(); + + expect(() => { + expect(() => {}).not.toHaveBeenCalled(); + }).toThrow(); + }); + + test('toHaveBeenCalledTimes', () => { + const fn = jest.fn(); + + expect(fn).toHaveBeenCalledTimes(0); + expect(fn).not.toHaveBeenCalledTimes(1); + + expect(() => { + expect(fn).not.toHaveBeenCalledTimes(0); + }).toThrow(); + + expect(() => { + expect(fn).toHaveBeenCalledTimes(1); + }).toThrow(); + + fn(); + + expect(fn).not.toHaveBeenCalledTimes(0); + expect(fn).toHaveBeenCalledTimes(1); + + expect(() => { + expect(fn).toHaveBeenCalledTimes(0); + }).toThrow(); + + expect(() => { + expect(fn).not.toHaveBeenCalledTimes(1); + }).toThrow(); + + // Passing functions that aren't mocks should always fail + expect(() => { + expect(() => {}).toHaveBeenCalledTimes(0); + }).toThrow(); + + expect(() => { + expect(() => {}).not.toHaveBeenCalledTimes(1); + }).toThrow(); + }); + + describe('jest.fn()', () => { + it('tracks execution of functions without implementations', () => { + const fn = jest.fn(); + + expect(fn).toBeInstanceOf(Function); + + expect(fn.mock.calls).toEqual([]); + expect(fn.mock.lastCall).toBe(undefined); + expect(fn.mock.instances).toEqual([]); + expect(fn.mock.contexts).toEqual([]); + expect(fn.mock.results).toEqual([]); + + expect(fn()).toBe(undefined); + + expect(fn.mock.calls).toEqual([[]]); + expect(fn.mock.lastCall).toEqual([]); + expect(fn.mock.instances).toEqual([undefined]); + expect(fn.mock.contexts).toEqual([global]); + expect(fn.mock.contexts[0]).toBe(global); + expect(fn.mock.results).toEqual([{value: undefined, isThrow: false}]); + }); + + it('tracks execution of methods without implementations', () => { + const fn = jest.fn(); + + expect(fn).toBeInstanceOf(Function); + + expect(fn.mock.calls).toEqual([]); + expect(fn.mock.lastCall).toBe(undefined); + expect(fn.mock.instances).toEqual([]); + expect(fn.mock.contexts).toEqual([]); + expect(fn.mock.results).toEqual([]); + + const obj = {fn}; + expect(obj.fn()).toBe(undefined); + + expect(fn.mock.calls).toEqual([[]]); + expect(fn.mock.lastCall).toEqual([]); + expect(fn.mock.instances).toEqual([undefined]); + expect(fn.mock.contexts).toEqual([obj]); + expect(fn.mock.contexts[0]).toBe(obj); + expect(fn.mock.results).toEqual([{value: undefined, isThrow: false}]); + }); + + it('tracks constructors without implementations', () => { + const fn = jest.fn(); + + expect(fn).toBeInstanceOf(Function); + + expect(fn.mock.calls).toEqual([]); + expect(fn.mock.lastCall).toBe(undefined); + expect(fn.mock.instances).toEqual([]); + expect(fn.mock.contexts).toEqual([]); + expect(fn.mock.results).toEqual([]); + + // $FlowExpectedError[invalid-constructor] + const instance = new fn(); + expect(instance).toBeInstanceOf(Object); + + expect(fn.mock.calls).toEqual([[]]); + expect(fn.mock.lastCall).toEqual([]); + expect(fn.mock.instances).toEqual([instance]); + expect(fn.mock.instances[0]).toBe(instance); + expect(fn.mock.contexts).toEqual([instance]); + expect(fn.mock.contexts[0]).toBe(instance); + expect(fn.mock.results).toEqual([{value: undefined, isThrow: false}]); + }); + + it('tracks execution of functions with an implementation', () => { + const fn = jest.fn((a, b) => { + return a + b; + }); + + expect(fn).toBeInstanceOf(Function); + + expect(fn.mock.calls).toEqual([]); + expect(fn.mock.lastCall).toBe(undefined); + expect(fn.mock.instances).toEqual([]); + expect(fn.mock.contexts).toEqual([]); + expect(fn.mock.results).toEqual([]); + + expect(fn(1, 2)).toBe(3); + + expect(fn.mock.calls).toEqual([[1, 2]]); + expect(fn.mock.lastCall).toEqual([1, 2]); + expect(fn.mock.instances).toEqual([undefined]); + expect(fn.mock.contexts).toEqual([global]); + expect(fn.mock.contexts[0]).toBe(global); + expect(fn.mock.results).toEqual([{value: 3, isThrow: false}]); + }); + + it('tracks execution of methods with an implementation', () => { + const fn = jest.fn(function (this: {prop: number}): number { + return this.prop; + }); + + expect(fn).toBeInstanceOf(Function); + + expect(fn.mock.calls).toEqual([]); + expect(fn.mock.lastCall).toBe(undefined); + expect(fn.mock.instances).toEqual([]); + expect(fn.mock.contexts).toEqual([]); + expect(fn.mock.results).toEqual([]); + + const obj = {fn, prop: 2}; + expect(obj.fn()).toBe(2); + + expect(fn.mock.calls).toEqual([[]]); + expect(fn.mock.lastCall).toEqual([]); + expect(fn.mock.instances).toEqual([undefined]); + expect(fn.mock.contexts).toEqual([obj]); + expect(fn.mock.contexts[0]).toBe(obj); + expect(fn.mock.results).toEqual([{value: 2, isThrow: false}]); + }); + + it('tracks constructors with an implementation', () => { + const fn = jest.fn(function (this: {prop: number}) { + this.prop = 3; + }); + + expect(fn).toBeInstanceOf(Function); + + expect(fn.mock.calls).toEqual([]); + expect(fn.mock.lastCall).toBe(undefined); + expect(fn.mock.instances).toEqual([]); + expect(fn.mock.contexts).toEqual([]); + expect(fn.mock.results).toEqual([]); + + // $FlowExpectedError[invalid-constructor] + const instance = new fn(); + expect(instance).toBeInstanceOf(Object); + + expect(instance.prop).toBe(3); + + expect(fn.mock.calls).toEqual([[]]); + expect(fn.mock.lastCall).toEqual([]); + expect(fn.mock.instances).toEqual([instance]); + expect(fn.mock.instances[0]).toBe(instance); + expect(fn.mock.contexts).toEqual([instance]); + expect(fn.mock.contexts[0]).toBe(instance); + expect(fn.mock.results).toEqual([{value: undefined, isThrow: false}]); + }); + }); + + test('toBeNull()', () => { + expect(null).toBeNull(); + expect('string value').not.toBeNull(); + + expect(() => { + expect(null).not.toBeNull(); + }).toThrow(); + + expect(() => { + expect('string value').toBeNull(); + }).toThrow(); + }); + + test('toBeLessThan', () => { + expect(1).toBeLessThan(2); + + expect(1).not.toBeLessThan(1); + + expect(1).not.toBeLessThan(0); + + expect(() => { + expect(1).toBeLessThan(0); + }).toThrow(); + + expect(() => { + expect(1).toBeLessThan(1); + }).toThrow(); + + expect(() => { + expect(1).not.toBeLessThan(2); + }).toThrow(); + + // Should always throw if the received value isn't a number + expect(() => { + expect('string value').toBeLessThan(1); + }).toThrow(); + + expect(() => { + expect('string value').not.toBeLessThan(1); + }).toThrow(); + + // Should always throw if the expected value isn't a number + expect(() => { + // $FlowExpectedError[incompatible-call] + expect(1).toBeLessThan('string value'); + }).toThrow(); + + expect(() => { + // $FlowExpectedError[incompatible-call] + expect(1).not.toBeLessThan('string value'); + }).toThrow(); + }); + + test('toBeLessThanOrEqual', () => { + expect(1).toBeLessThanOrEqual(1); + expect(1).toBeLessThanOrEqual(2); + + expect(1).not.toBeLessThanOrEqual(0.8); + + expect(() => { + expect(1).not.toBeLessThanOrEqual(1); + }).toThrow(); + + expect(() => { + expect(1).not.toBeLessThanOrEqual(2); + }).toThrow(); + + // Should always throw if the received value isn't a number + expect(() => { + expect('string value').toBeLessThanOrEqual(1); + }).toThrow(); + + expect(() => { + expect('string value').not.toBeLessThanOrEqual(1); + }).toThrow(); + + // Should always throw if the expected value isn't a number + expect(() => { + // $FlowExpectedError[incompatible-call] + expect(1).toBeLessThanOrEqual('string value'); + }).toThrow(); + + expect(() => { + // $FlowExpectedError[incompatible-call] + expect(1).not.toBeLessThanOrEqual('string value'); + }).toThrow(); + }); + + test('toBeGreaterThan', () => { + expect(1).toBeGreaterThan(0); + + expect(1).not.toBeGreaterThan(1); + + expect(1).not.toBeGreaterThan(2); + + expect(() => { + expect(1).toBeGreaterThan(2); + }).toThrow(); + + expect(() => { + expect(1).not.toBeGreaterThan(0); + }).toThrow(); + + // Should always throw if the received value isn't a number + expect(() => { + expect('string value').toBeGreaterThan(1); + }).toThrow(); + + expect(() => { + expect('string value').not.toBeGreaterThan(1); + }).toThrow(); + + // Should always throw if the expected value isn't a number + expect(() => { + // $FlowExpectedError[incompatible-call] + expect(1).toBeGreaterThan('string value'); + }).toThrow(); + + expect(() => { + // $FlowExpectedError[incompatible-call] + expect(1).not.toBeGreaterThan('string value'); + }).toThrow(); + }); + + test('toBeGreaterThanOrEqual', () => { + expect(1).toBeGreaterThanOrEqual(0); + expect(1).toBeGreaterThanOrEqual(1); + + expect(1).not.toBeGreaterThanOrEqual(2); + + expect(() => { + expect(1).not.toBeGreaterThanOrEqual(0); + }).toThrow(); + + expect(() => { + expect(1).not.toBeGreaterThanOrEqual(1); + }).toThrow(); + + // Should always throw if the received value isn't a number + expect(() => { + expect('string value').toBeGreaterThanOrEqual(1); + }).toThrow(); + + expect(() => { + expect('string value').not.toBeGreaterThanOrEqual(1); + }).toThrow(); + + // Should always throw if the expected value isn't a number + expect(() => { + // $FlowExpectedError[incompatible-call] + expect(1).toBeGreaterThanOrEqual('string value'); + }).toThrow(); + + expect(() => { + // $FlowExpectedError[incompatible-call] + expect(1).not.toBeGreaterThanOrEqual('string value'); + }).toThrow(); + }); +}); diff --git a/packages/react-native/src/private/webapis/dom/nodes/__tests__/ReactNativeElement-itest.js b/packages/react-native/src/private/webapis/dom/nodes/__tests__/ReactNativeElement-itest.js new file mode 100644 index 00000000000..ad8d0a1bccf --- /dev/null +++ b/packages/react-native/src/private/webapis/dom/nodes/__tests__/ReactNativeElement-itest.js @@ -0,0 +1,1199 @@ +/** + * 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 './setUpFeatureFlags'; +import '../../../../../../Libraries/Core/InitializeCore.js'; + +import * as ReactNativeTester from '../../../../__tests__/ReactNativeTester'; +import ScrollView from '../../../../../../Libraries/Components/ScrollView/ScrollView'; +import View from '../../../../../../Libraries/Components/View/View'; +import { + NativeText, + NativeVirtualText, +} from '../../../../../../Libraries/Text/TextNativeComponent'; +import HTMLCollection from '../../oldstylecollections/HTMLCollection'; +import NodeList from '../../oldstylecollections/NodeList'; +import ReactNativeElement from '../ReactNativeElement'; +import ReadOnlyNode from '../ReadOnlyNode'; +import * as React from 'react'; + +function ensureReactNativeElement(value: mixed): ReactNativeElement { + if (!(value instanceof ReactNativeElement)) { + throw new Error( + `Expected instance of ReactNativeElement but got ${String(value)}`, + ); + } + + return value; +} + +/* eslint-disable no-bitwise */ + +describe('ReactNativeElement', () => { + it('should be used to create public instances when the `enableAccessToHostTreeInFabric` feature flag is enabled', () => { + let node; + + const root = ReactNativeTester.createRoot(); + ReactNativeTester.runTask(() => { + root.render( + { + node = receivedNode; + }} + />, + ); + }); + + expect(node).toBeInstanceOf(ReactNativeElement); + }); + + describe('extends `ReadOnlyNode`', () => { + describe('nodeType', () => { + it('returns ReadOnlyNode.ELEMENT_NODE', () => { + let lastParentNode; + let lastChildNodeA; + let lastChildNodeB; + let lastChildNodeC; + + // Initial render with 3 children + const root = ReactNativeTester.createRoot(); + ReactNativeTester.runTask(() => { + root.render( + { + lastParentNode = node; + }}> + { + lastChildNodeA = node; + }} + /> + { + lastChildNodeB = node; + }} + /> + { + lastChildNodeC = node; + }} + /> + , + ); + }); + + const parentNode = ensureReactNativeElement(lastParentNode); + const childNodeA = ensureReactNativeElement(lastChildNodeA); + const childNodeB = ensureReactNativeElement(lastChildNodeB); + const childNodeC = ensureReactNativeElement(lastChildNodeC); + + expect(parentNode.nodeType).toBe(ReadOnlyNode.ELEMENT_NODE); + expect(childNodeA.nodeType).toBe(ReadOnlyNode.ELEMENT_NODE); + expect(childNodeB.nodeType).toBe(ReadOnlyNode.ELEMENT_NODE); + expect(childNodeC.nodeType).toBe(ReadOnlyNode.ELEMENT_NODE); + }); + }); + + describe('nodeValue', () => { + it('returns null', () => { + let lastParentNode; + let lastChildNodeA; + let lastChildNodeB; + let lastChildNodeC; + + // Initial render with 3 children + const root = ReactNativeTester.createRoot(); + ReactNativeTester.runTask(() => { + root.render( + { + lastParentNode = node; + }}> + { + lastChildNodeA = node; + }} + /> + { + lastChildNodeB = node; + }} + /> + { + lastChildNodeC = node; + }} + /> + , + ); + }); + + const parentNode = ensureReactNativeElement(lastParentNode); + const childNodeA = ensureReactNativeElement(lastChildNodeA); + const childNodeB = ensureReactNativeElement(lastChildNodeB); + const childNodeC = ensureReactNativeElement(lastChildNodeC); + + expect(parentNode.nodeValue).toBe(null); + expect(childNodeA.nodeValue).toBe(null); + expect(childNodeB.nodeValue).toBe(null); + expect(childNodeC.nodeValue).toBe(null); + }); + }); + + describe('childNodes / hasChildNodes()', () => { + it('returns updated child nodes information', () => { + let lastParentNode; + let lastChildNodeA; + let lastChildNodeB; + let lastChildNodeC; + + // Initial render with 3 children + const root = ReactNativeTester.createRoot(); + ReactNativeTester.runTask(() => { + root.render( + { + lastParentNode = node; + }}> + { + lastChildNodeA = node; + }} + /> + { + lastChildNodeB = node; + }} + /> + { + lastChildNodeC = node; + }} + /> + , + ); + }); + + const parentNode = ensureReactNativeElement(lastParentNode); + const childNodeA = ensureReactNativeElement(lastChildNodeA); + const childNodeB = ensureReactNativeElement(lastChildNodeB); + const childNodeC = ensureReactNativeElement(lastChildNodeC); + + const childNodes = parentNode.childNodes; + expect(childNodes).toBeInstanceOf(NodeList); + expect(childNodes.length).toBe(3); + expect(childNodes[0]).toBe(childNodeA); + expect(childNodes[1]).toBe(childNodeB); + expect(childNodes[2]).toBe(childNodeC); + + expect(parentNode.hasChildNodes()).toBe(true); + + // Remove one of the children + ReactNativeTester.runTask(() => { + root.render( + + + + , + ); + }); + + const childNodesAfterUpdate = parentNode.childNodes; + expect(childNodesAfterUpdate).toBeInstanceOf(NodeList); + expect(childNodesAfterUpdate.length).toBe(2); + expect(childNodesAfterUpdate[0]).toBe(childNodeA); + expect(childNodesAfterUpdate[1]).toBe(childNodeB); + + expect(parentNode.hasChildNodes()).toBe(true); + + // Unmount node + ReactNativeTester.runTask(() => { + root.render(<>); + }); + + const childNodesAfterUnmount = parentNode.childNodes; + expect(childNodesAfterUnmount).toBeInstanceOf(NodeList); + expect(childNodesAfterUnmount.length).toBe(0); + + expect(parentNode.hasChildNodes()).toBe(false); + }); + }); + + describe('firstChild / lastChild / previousSibling / nextSibling / parentNode / parentElement / getRootNode()', () => { + it('return updated relative nodes', () => { + let lastParentNode; + let lastChildNodeA; + let lastChildNodeB; + let lastChildNodeC; + + // Initial render with 3 children + const root = ReactNativeTester.createRoot(); + ReactNativeTester.runTask(() => { + root.render( + { + lastParentNode = node; + }}> + { + lastChildNodeA = node; + }} + /> + { + lastChildNodeB = node; + }} + /> + { + lastChildNodeC = node; + }} + /> + , + ); + }); + + const parentNode = ensureReactNativeElement(lastParentNode); + const childNodeA = ensureReactNativeElement(lastChildNodeA); + const childNodeB = ensureReactNativeElement(lastChildNodeB); + const childNodeC = ensureReactNativeElement(lastChildNodeC); + + expect(parentNode.isConnected).toBe(true); + expect(parentNode.firstChild).toBe(childNodeA); + expect(parentNode.lastChild).toBe(childNodeC); + expect(parentNode.previousSibling).toBe(null); + expect(parentNode.nextSibling).toBe(null); + // Document-level root nodes are not supported yet + expect(parentNode.parentNode).toBe(null); + expect(parentNode.parentElement).toBe(null); + expect(parentNode.getRootNode()).toBe(parentNode); + + expect(childNodeA.isConnected).toBe(true); + expect(childNodeA.firstChild).toBe(null); + expect(childNodeA.lastChild).toBe(null); + expect(childNodeA.previousSibling).toBe(null); + expect(childNodeA.nextSibling).toBe(childNodeB); + expect(childNodeA.parentNode).toBe(parentNode); + expect(childNodeA.parentElement).toBe(parentNode); + expect(childNodeA.getRootNode()).toBe(parentNode); + + expect(childNodeB.isConnected).toBe(true); + expect(childNodeB.firstChild).toBe(null); + expect(childNodeB.lastChild).toBe(null); + expect(childNodeB.previousSibling).toBe(childNodeA); + expect(childNodeB.nextSibling).toBe(childNodeC); + expect(childNodeB.parentNode).toBe(parentNode); + expect(childNodeB.parentElement).toBe(parentNode); + expect(childNodeB.getRootNode()).toBe(parentNode); + + expect(childNodeC.isConnected).toBe(true); + expect(childNodeC.firstChild).toBe(null); + expect(childNodeC.lastChild).toBe(null); + expect(childNodeC.previousSibling).toBe(childNodeB); + expect(childNodeC.nextSibling).toBe(null); + expect(childNodeC.parentNode).toBe(parentNode); + expect(childNodeC.parentElement).toBe(parentNode); + expect(childNodeC.getRootNode()).toBe(parentNode); + + // Remove one of the children + ReactNativeTester.runTask(() => { + root.render( + + + + , + ); + }); + + expect(parentNode.isConnected).toBe(true); + expect(parentNode.firstChild).toBe(childNodeA); + expect(parentNode.lastChild).toBe(childNodeB); + expect(parentNode.previousSibling).toBe(null); + expect(parentNode.nextSibling).toBe(null); + // Document-level root nodes are not supported yet + expect(parentNode.parentNode).toBe(null); + expect(parentNode.parentElement).toBe(null); + expect(parentNode.getRootNode()).toBe(parentNode); + + expect(childNodeA.isConnected).toBe(true); + expect(childNodeA.firstChild).toBe(null); + expect(childNodeA.lastChild).toBe(null); + expect(childNodeA.previousSibling).toBe(null); + expect(childNodeA.nextSibling).toBe(childNodeB); + expect(childNodeA.parentNode).toBe(parentNode); + expect(childNodeA.parentElement).toBe(parentNode); + expect(childNodeA.getRootNode()).toBe(parentNode); + + expect(childNodeB.isConnected).toBe(true); + expect(childNodeB.firstChild).toBe(null); + expect(childNodeB.lastChild).toBe(null); + expect(childNodeB.previousSibling).toBe(childNodeA); + expect(childNodeB.nextSibling).toBe(null); + expect(childNodeB.parentNode).toBe(parentNode); + expect(childNodeB.parentElement).toBe(parentNode); + expect(childNodeB.getRootNode()).toBe(parentNode); + + // Disconnected + expect(childNodeC.isConnected).toBe(false); + expect(childNodeC.firstChild).toBe(null); + expect(childNodeC.lastChild).toBe(null); + expect(childNodeC.previousSibling).toBe(null); + expect(childNodeC.nextSibling).toBe(null); + expect(childNodeC.parentNode).toBe(null); + expect(childNodeC.parentElement).toBe(null); + expect(childNodeC.getRootNode()).toBe(childNodeC); + + // Unmount node + ReactNativeTester.runTask(() => { + root.render(<>); + }); + + // Disconnected + expect(parentNode.isConnected).toBe(false); + expect(parentNode.firstChild).toBe(null); + expect(parentNode.lastChild).toBe(null); + expect(parentNode.previousSibling).toBe(null); + expect(parentNode.nextSibling).toBe(null); + expect(parentNode.parentNode).toBe(null); + expect(parentNode.parentElement).toBe(null); + expect(parentNode.getRootNode()).toBe(parentNode); + + // Disconnected + expect(childNodeA.isConnected).toBe(false); + expect(childNodeA.firstChild).toBe(null); + expect(childNodeA.lastChild).toBe(null); + expect(childNodeA.previousSibling).toBe(null); + expect(childNodeA.nextSibling).toBe(null); + expect(childNodeA.parentNode).toBe(null); + expect(childNodeA.parentElement).toBe(null); + expect(childNodeA.getRootNode()).toBe(childNodeA); + + // Disconnected + expect(childNodeB.isConnected).toBe(false); + expect(childNodeB.firstChild).toBe(null); + expect(childNodeB.lastChild).toBe(null); + expect(childNodeB.previousSibling).toBe(null); + expect(childNodeB.nextSibling).toBe(null); + expect(childNodeB.parentNode).toBe(null); + expect(childNodeB.parentElement).toBe(null); + expect(childNodeB.getRootNode()).toBe(childNodeB); + + // Disconnected + expect(childNodeC.isConnected).toBe(false); + expect(childNodeC.firstChild).toBe(null); + expect(childNodeC.lastChild).toBe(null); + expect(childNodeC.previousSibling).toBe(null); + expect(childNodeC.nextSibling).toBe(null); + expect(childNodeC.parentNode).toBe(null); + expect(childNodeC.parentElement).toBe(null); + expect(childNodeC.getRootNode()).toBe(childNodeC); + }); + }); + + describe('compareDocumentPosition / contains', () => { + it('handles containment, order and connection', () => { + let lastParentNode; + let lastChildNodeA; + let lastChildNodeAA; + let lastChildNodeB; + let lastChildNodeBB; + + // Initial render with 2 children + const root = ReactNativeTester.createRoot(); + ReactNativeTester.runTask(() => { + root.render( + { + lastParentNode = node; + }}> + { + lastChildNodeA = node; + }}> + { + lastChildNodeAA = node; + }} + /> + + { + lastChildNodeB = node; + }}> + { + lastChildNodeBB = node; + }} + /> + + , + ); + }); + + const parentNode = ensureReactNativeElement(lastParentNode); + const childNodeA = ensureReactNativeElement(lastChildNodeA); + const childNodeAA = ensureReactNativeElement(lastChildNodeAA); + const childNodeB = ensureReactNativeElement(lastChildNodeB); + const childNodeBB = ensureReactNativeElement(lastChildNodeBB); + + // Node/self + expect(parentNode.compareDocumentPosition(parentNode)).toBe(0); + expect(parentNode.contains(parentNode)).toBe(true); + // Parent/child + expect(parentNode.compareDocumentPosition(childNodeA)).toBe( + ReadOnlyNode.DOCUMENT_POSITION_CONTAINED_BY | + ReadOnlyNode.DOCUMENT_POSITION_FOLLOWING, + ); + expect(parentNode.contains(childNodeA)).toBe(true); + // Child/parent + expect(childNodeA.compareDocumentPosition(parentNode)).toBe( + ReadOnlyNode.DOCUMENT_POSITION_CONTAINS | + ReadOnlyNode.DOCUMENT_POSITION_PRECEDING, + ); + expect(childNodeA.contains(parentNode)).toBe(false); + // Grandparent/grandchild + expect(parentNode.compareDocumentPosition(childNodeAA)).toBe( + ReadOnlyNode.DOCUMENT_POSITION_CONTAINED_BY | + ReadOnlyNode.DOCUMENT_POSITION_FOLLOWING, + ); + expect(parentNode.contains(childNodeAA)).toBe(true); + // Grandchild/grandparent + expect(childNodeAA.compareDocumentPosition(parentNode)).toBe( + ReadOnlyNode.DOCUMENT_POSITION_CONTAINS | + ReadOnlyNode.DOCUMENT_POSITION_PRECEDING, + ); + expect(childNodeAA.contains(parentNode)).toBe(false); + // Sibling/sibling + expect(childNodeA.compareDocumentPosition(childNodeB)).toBe( + ReadOnlyNode.DOCUMENT_POSITION_FOLLOWING, + ); + expect(childNodeA.contains(childNodeB)).toBe(false); + // Sibling/sibling + expect(childNodeB.compareDocumentPosition(childNodeA)).toBe( + ReadOnlyNode.DOCUMENT_POSITION_PRECEDING, + ); + expect(childNodeB.contains(childNodeA)).toBe(false); + // Cousing/cousing + expect(childNodeAA.compareDocumentPosition(childNodeBB)).toBe( + ReadOnlyNode.DOCUMENT_POSITION_FOLLOWING, + ); + expect(childNodeAA.contains(childNodeBB)).toBe(false); + // Cousing/cousing + expect(childNodeBB.compareDocumentPosition(childNodeAA)).toBe( + ReadOnlyNode.DOCUMENT_POSITION_PRECEDING, + ); + expect(childNodeBB.contains(childNodeAA)).toBe(false); + + // Remove one of the children + ReactNativeTester.runTask(() => { + root.render( + + + + , + ); + }); + + // Node/disconnected + expect(parentNode.compareDocumentPosition(childNodeAA)).toBe( + ReadOnlyNode.DOCUMENT_POSITION_DISCONNECTED, + ); + expect(parentNode.contains(childNodeAA)).toBe(false); + // Disconnected/node + expect(childNodeAA.compareDocumentPosition(parentNode)).toBe( + ReadOnlyNode.DOCUMENT_POSITION_DISCONNECTED, + ); + expect(childNodeAA.contains(parentNode)).toBe(false); + // Disconnected/disconnected + expect(childNodeAA.compareDocumentPosition(childNodeBB)).toBe( + ReadOnlyNode.DOCUMENT_POSITION_DISCONNECTED, + ); + expect(childNodeAA.contains(childNodeBB)).toBe(false); + // Disconnected/disconnected + expect(childNodeBB.compareDocumentPosition(childNodeAA)).toBe( + ReadOnlyNode.DOCUMENT_POSITION_DISCONNECTED, + ); + expect(childNodeBB.contains(childNodeAA)).toBe(false); + // Disconnected/self + expect(childNodeBB.compareDocumentPosition(childNodeBB)).toBe(0); + expect(childNodeBB.contains(childNodeBB)).toBe(true); + + let lastAltParentNode; + + // Similar structure in a different tree + const root2 = ReactNativeTester.createRoot(); + ReactNativeTester.runTask(() => { + root2.render( + { + lastAltParentNode = node; + }}> + + + , + ); + }); + + const altParentNode = ensureReactNativeElement(lastAltParentNode); + + // Node/same position in different tree + expect(altParentNode.compareDocumentPosition(parentNode)).toBe( + ReadOnlyNode.DOCUMENT_POSITION_DISCONNECTED, + ); + expect(parentNode.compareDocumentPosition(altParentNode)).toBe( + ReadOnlyNode.DOCUMENT_POSITION_DISCONNECTED, + ); + expect(parentNode.contains(altParentNode)).toBe(false); + expect(altParentNode.contains(parentNode)).toBe(false); + + // Node/child position in different tree + expect(altParentNode.compareDocumentPosition(childNodeA)).toBe( + ReadOnlyNode.DOCUMENT_POSITION_DISCONNECTED, + ); + expect(childNodeA.compareDocumentPosition(altParentNode)).toBe( + ReadOnlyNode.DOCUMENT_POSITION_DISCONNECTED, + ); + expect(altParentNode.contains(childNodeA)).toBe(false); + expect(childNodeA.contains(altParentNode)).toBe(false); + }); + }); + }); + + describe('extends `ReadOnlyElement`', () => { + describe('children / childElementCount', () => { + it('return updated element children information', () => { + let lastParentElement; + let lastChildElementA; + let lastChildElementB; + let lastChildElementC; + + // Initial render with 3 children + const root = ReactNativeTester.createRoot(); + ReactNativeTester.runTask(() => { + root.render( + { + lastParentElement = element; + }}> + { + lastChildElementA = element; + }} + /> + { + lastChildElementB = element; + }} + /> + { + lastChildElementC = element; + }} + /> + , + ); + }); + + const parentElement = ensureReactNativeElement(lastParentElement); + const childElementA = ensureReactNativeElement(lastChildElementA); + const childElementB = ensureReactNativeElement(lastChildElementB); + const childElementC = ensureReactNativeElement(lastChildElementC); + + const children = parentElement.children; + expect(children).toBeInstanceOf(HTMLCollection); + expect(children.length).toBe(3); + expect(children[0]).toBe(childElementA); + expect(children[1]).toBe(childElementB); + expect(children[2]).toBe(childElementC); + + expect(parentElement.childElementCount).toBe(3); + + // Remove one of the children + ReactNativeTester.runTask(() => { + root.render( + + + + , + ); + }); + + const childrenAfterUpdate = parentElement.children; + expect(childrenAfterUpdate).toBeInstanceOf(HTMLCollection); + expect(childrenAfterUpdate.length).toBe(2); + expect(childrenAfterUpdate[0]).toBe(childElementA); + expect(childrenAfterUpdate[1]).toBe(childElementB); + + expect(parentElement.childElementCount).toBe(2); + + // Unmount node + ReactNativeTester.runTask(() => { + root.render(<>); + }); + + const childrenAfterUnmount = parentElement.children; + expect(childrenAfterUnmount).toBeInstanceOf(HTMLCollection); + expect(childrenAfterUnmount.length).toBe(0); + + expect(parentElement.childElementCount).toBe(0); + }); + }); + + describe('firstElementChild / lastElementChild / previousElementSibling / nextElementSibling', () => { + it('return updated relative elements', () => { + let lastParentElement; + let lastChildElementA; + let lastChildElementB; + let lastChildElementC; + + // Initial render with 3 children + const root = ReactNativeTester.createRoot(); + ReactNativeTester.runTask(() => { + root.render( + { + lastParentElement = element; + }}> + { + lastChildElementA = element; + }} + /> + { + lastChildElementB = element; + }} + /> + { + lastChildElementC = element; + }} + /> + , + ); + }); + + const parentElement = ensureReactNativeElement(lastParentElement); + const childElementA = ensureReactNativeElement(lastChildElementA); + const childElementB = ensureReactNativeElement(lastChildElementB); + const childElementC = ensureReactNativeElement(lastChildElementC); + + expect(parentElement.firstElementChild).toBe(childElementA); + expect(parentElement.lastElementChild).toBe(childElementC); + expect(parentElement.previousElementSibling).toBe(null); + expect(parentElement.nextElementSibling).toBe(null); + + expect(childElementA.firstElementChild).toBe(null); + expect(childElementA.lastElementChild).toBe(null); + expect(childElementA.previousElementSibling).toBe(null); + expect(childElementA.nextElementSibling).toBe(childElementB); + + expect(childElementB.firstElementChild).toBe(null); + expect(childElementB.lastElementChild).toBe(null); + expect(childElementB.previousElementSibling).toBe(childElementA); + expect(childElementB.nextElementSibling).toBe(childElementC); + + expect(childElementC.firstElementChild).toBe(null); + expect(childElementC.lastElementChild).toBe(null); + expect(childElementC.previousElementSibling).toBe(childElementB); + expect(childElementC.nextElementSibling).toBe(null); + + // Remove one of the children + ReactNativeTester.runTask(() => { + root.render( + + + + , + ); + }); + + expect(parentElement.firstElementChild).toBe(childElementA); + expect(parentElement.lastElementChild).toBe(childElementB); + expect(parentElement.previousElementSibling).toBe(null); + expect(parentElement.nextElementSibling).toBe(null); + + expect(childElementA.firstElementChild).toBe(null); + expect(childElementA.lastElementChild).toBe(null); + expect(childElementA.previousElementSibling).toBe(null); + expect(childElementA.nextElementSibling).toBe(childElementB); + + expect(childElementB.firstElementChild).toBe(null); + expect(childElementB.lastElementChild).toBe(null); + expect(childElementB.previousElementSibling).toBe(childElementA); + expect(childElementB.nextElementSibling).toBe(null); + + // Disconnected + expect(childElementC.firstElementChild).toBe(null); + expect(childElementC.lastElementChild).toBe(null); + expect(childElementC.previousElementSibling).toBe(null); + expect(childElementC.nextElementSibling).toBe(null); + + // Unmount node + ReactNativeTester.runTask(() => { + root.render(<>); + }); + + // Disconnected + expect(parentElement.firstElementChild).toBe(null); + expect(parentElement.lastElementChild).toBe(null); + expect(parentElement.previousElementSibling).toBe(null); + expect(parentElement.nextElementSibling).toBe(null); + + // Disconnected + expect(childElementA.firstElementChild).toBe(null); + expect(childElementA.lastElementChild).toBe(null); + expect(childElementA.previousElementSibling).toBe(null); + expect(childElementA.nextElementSibling).toBe(null); + + // Disconnected + expect(childElementB.firstElementChild).toBe(null); + expect(childElementB.lastElementChild).toBe(null); + expect(childElementB.previousElementSibling).toBe(null); + expect(childElementB.nextElementSibling).toBe(null); + + // Disconnected + expect(childElementC.firstElementChild).toBe(null); + expect(childElementC.lastElementChild).toBe(null); + expect(childElementC.previousElementSibling).toBe(null); + expect(childElementC.nextElementSibling).toBe(null); + }); + }); + + describe('textContent', () => { + it('should return the concatenated values of all its text node descendants (using DFS)', () => { + let lastParentNode; + let lastChildNodeA; + + const root = ReactNativeTester.createRoot(); + ReactNativeTester.runTask(() => { + root.render( + { + lastParentNode = node; + }}> + Hello + { + lastChildNodeA = node; + }}> + world! + + , + ); + }); + + const parentNode = ensureReactNativeElement(lastParentNode); + const childNodeA = ensureReactNativeElement(lastChildNodeA); + + expect(parentNode.textContent).toBe('Hello world!'); + expect(childNodeA.textContent).toBe('world!'); + + let lastChildNodeB; + ReactNativeTester.runTask(() => { + root.render( + + Hello + + world + + { + lastChildNodeB = node; + }}> + + + again + and again! + + + + , + ); + }); + + const childNodeB = ensureReactNativeElement(lastChildNodeB); + + expect(parentNode.textContent).toBe('Hello world again and again!'); + expect(childNodeA.textContent).toBe('world '); + expect(childNodeB.textContent).toBe('again and again!'); + }); + }); + + describe('getBoundingClientRect', () => { + it('returns a DOMRect with its size and position, or an empty DOMRect when disconnected', () => { + let lastElement; + + const root = ReactNativeTester.createRoot(); + + ReactNativeTester.runTask(() => { + root.render( + { + lastElement = element; + }} + />, + ); + }); + + const element = ensureReactNativeElement(lastElement); + + const boundingClientRect = element.getBoundingClientRect(); + expect(boundingClientRect).toBeInstanceOf(DOMRect); + expect(boundingClientRect.x).toBe(5); + expect(boundingClientRect.y).toBe(10); + expect(boundingClientRect.width).toBe(50); + expect(boundingClientRect.height).toBe(101); + + ReactNativeTester.runTask(() => { + root.render(); + }); + + const boundingClientRectAfterUnmount = element.getBoundingClientRect(); + expect(boundingClientRectAfterUnmount).toBeInstanceOf(DOMRect); + expect(boundingClientRectAfterUnmount.x).toBe(0); + expect(boundingClientRectAfterUnmount.y).toBe(0); + expect(boundingClientRectAfterUnmount.width).toBe(0); + expect(boundingClientRectAfterUnmount.height).toBe(0); + }); + }); + + describe('scrollLeft / scrollTop', () => { + it('return the scroll position on each axis', () => { + let lastElement; + + const root = ReactNativeTester.createRoot(); + ReactNativeTester.runTask(() => { + root.render( + { + lastElement = element; + }} + />, + ); + }); + + const element = ensureReactNativeElement(lastElement); + + expect(element.scrollLeft).toBeCloseTo(5.1); + expect(element.scrollTop).toBeCloseTo(10.2); + + ReactNativeTester.runTask(() => { + root.render(); + }); + + expect(element.scrollLeft).toBe(0); + expect(element.scrollTop).toBe(0); + }); + }); + + describe('scrollWidth / scrollHeight', () => { + it('return the scroll size on each axis', () => { + let lastElement; + + const root = ReactNativeTester.createRoot(); + ReactNativeTester.runTask(() => { + root.render( + { + lastElement = element; + }}> + + , + ); + }); + + const element = ensureReactNativeElement(lastElement); + + expect(element.scrollWidth).toBe(200); + expect(element.scrollHeight).toBe(1500); + + ReactNativeTester.runTask(() => { + root.render(); + }); + + expect(element.scrollWidth).toBe(0); + expect(element.scrollHeight).toBe(0); + }); + }); + + describe('clientWidth / clientHeight', () => { + it('return the inner size of the node', () => { + let lastElement; + + const root = ReactNativeTester.createRoot(); + ReactNativeTester.runTask(() => { + root.render( + { + lastElement = element; + }} + />, + ); + }); + + const element = ensureReactNativeElement(lastElement); + + expect(element.clientWidth).toBe(200); + expect(element.clientHeight).toBe(250); + + ReactNativeTester.runTask(() => { + root.render(); + }); + + expect(element.clientWidth).toBe(0); + expect(element.clientHeight).toBe(0); + }); + }); + + describe('clientLeft / clientTop', () => { + it('return the border size of the node', () => { + let lastElement; + + const root = ReactNativeTester.createRoot(); + ReactNativeTester.runTask(() => { + root.render( + { + lastElement = element; + }} + />, + ); + }); + + const element = ensureReactNativeElement(lastElement); + + expect(element.clientLeft).toBe(200); + expect(element.clientTop).toBe(250); + + ReactNativeTester.runTask(() => { + root.render(); + }); + + expect(element.clientLeft).toBe(0); + expect(element.clientTop).toBe(0); + }); + }); + + describe('id', () => { + it('returns the current `id` prop from the node', () => { + let lastElement; + + const root = ReactNativeTester.createRoot(); + ReactNativeTester.runTask(() => { + root.render( + { + lastElement = element; + }} + />, + ); + }); + + const element = ensureReactNativeElement(lastElement); + + expect(element.id).toBe(''); + }); + + it('returns the current `nativeID` prop from the node', () => { + let lastElement; + + const root = ReactNativeTester.createRoot(); + ReactNativeTester.runTask(() => { + root.render( + { + lastElement = element; + }} + />, + ); + }); + + const element = ensureReactNativeElement(lastElement); + + expect(element.id).toBe(''); + }); + }); + + describe('tagName', () => { + it('returns the normalized tag name for the node', () => { + let lastElement; + + const root = ReactNativeTester.createRoot(); + ReactNativeTester.runTask(() => { + root.render( + { + lastElement = element; + }} + />, + ); + }); + + const element = ensureReactNativeElement(lastElement); + + expect(element.tagName).toBe('RN:View'); + }); + }); + }); + + describe('implements specific `ReactNativeElement` methods', () => { + describe('offsetWidth / offsetHeight', () => { + it('return the rounded width and height, or 0 when disconnected', () => { + let lastElement; + + const root = ReactNativeTester.createRoot(); + ReactNativeTester.runTask(() => { + root.render( + { + lastElement = element; + }} + />, + ); + }); + + const element = ensureReactNativeElement(lastElement); + + expect(element.offsetWidth).toBe(50); + expect(element.offsetHeight).toBe(101); + + ReactNativeTester.runTask(() => { + root.render(); + }); + + expect(element.offsetWidth).toBe(0); + expect(element.offsetHeight).toBe(0); + }); + }); + + describe('offsetParent / offsetTop / offsetLeft', () => { + it('retun the rounded offset values and the parent, or null and zeros when disconnected or hidden', () => { + let lastParentElement; + let lastElement; + + const root = ReactNativeTester.createRoot(); + + ReactNativeTester.runTask(() => { + root.render( + { + lastParentElement = element; + }}> + { + lastElement = element; + }} + /> + , + ); + }); + + const parentElement = ensureReactNativeElement(lastParentElement); + const element = ensureReactNativeElement(lastElement); + + expect(element.offsetTop).toBe(11); + expect(element.offsetLeft).toBe(5); + expect(element.offsetParent).toBe(parentElement); + + ReactNativeTester.runTask(() => { + root.render( + + + , + ); + }); + + expect(element.offsetTop).toBe(0); + expect(element.offsetLeft).toBe(0); + expect(element.offsetParent).toBe(null); + + ReactNativeTester.runTask(() => { + root.render(); + }); + + expect(element.offsetTop).toBe(0); + expect(element.offsetLeft).toBe(0); + expect(element.offsetParent).toBe(null); + }); + }); + }); +}); diff --git a/packages/react-native/src/private/webapis/dom/nodes/__tests__/ReadOnlyText-itest.js b/packages/react-native/src/private/webapis/dom/nodes/__tests__/ReadOnlyText-itest.js new file mode 100644 index 00000000000..1d74bfb76c3 --- /dev/null +++ b/packages/react-native/src/private/webapis/dom/nodes/__tests__/ReadOnlyText-itest.js @@ -0,0 +1,412 @@ +/** + * 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 './setUpFeatureFlags'; +import '../../../../../../Libraries/Core/InitializeCore.js'; + +import {NativeText} from '../../../../../../Libraries/Text/TextNativeComponent'; +import * as ReactNativeTester from '../../../../../../src/private/__tests__/ReactNativeTester'; +import ReactNativeElement from '../ReactNativeElement'; +import ReadOnlyNode from '../ReadOnlyNode'; +import ReadOnlyText from '../ReadOnlyText'; +import invariant from 'invariant'; +import * as React from 'react'; + +function ensureReadOnlyText(value: mixed): ReadOnlyText { + if (!(value instanceof ReadOnlyText)) { + throw new Error( + `Expected instance of ReactOnlyNode but got ${String(value)}`, + ); + } + + return value; +} + +function ensureReadOnlyNode(value: mixed): ReadOnlyNode { + if (!(value instanceof ReadOnlyNode)) { + throw new Error( + `Expected instance of ReactOnlyNode but got ${String(value)}`, + ); + } + + return value; +} + +function ensureReactNativeElement(value: mixed): ReactNativeElement { + if (!(value instanceof ReactNativeElement)) { + throw new Error( + `Expected instance of ReactNativeElement but got ${String(value)}`, + ); + } + + return value; +} + +describe('ReadOnlyText', () => { + it('should be used to create public text instances when the `enableAccessToHostTreeInFabric` feature flag is enabled', () => { + let lastParentNode; + + const root = ReactNativeTester.createRoot(); + + ReactNativeTester.runTask(() => { + root.render( + { + lastParentNode = node; + }}> + Some text + , + ); + }); + + const parentNode = ensureReadOnlyNode(lastParentNode); + const textNode = parentNode.childNodes[0]; + + expect(textNode).toBeInstanceOf(ReadOnlyText); + }); + + describe('extends `ReadOnlyNode`', () => { + describe('nodeName', () => { + it('returns "#text"', () => { + let lastParentNode; + + const root = ReactNativeTester.createRoot(); + + ReactNativeTester.runTask(() => { + root.render( + { + lastParentNode = node; + }}> + Some text + , + ); + }); + + const parentNode = ensureReadOnlyNode(lastParentNode); + const textNode = parentNode.childNodes[0]; + + expect(textNode.nodeName).toBe('#text'); + }); + }); + + describe('nodeType', () => { + it('returns ReadOnlyNode.TEXT_NODE', () => { + let lastParentNode; + + const root = ReactNativeTester.createRoot(); + + ReactNativeTester.runTask(() => { + root.render( + { + lastParentNode = node; + }}> + Some text + , + ); + }); + + const parentNode = ensureReadOnlyNode(lastParentNode); + const textNode = parentNode.childNodes[0]; + + expect(textNode.nodeType).toBe(ReadOnlyNode.TEXT_NODE); + }); + }); + + describe('nodeValue / textContent', () => { + it('returns the string data contained in the node', () => { + let lastParentNode; + + const root = ReactNativeTester.createRoot(); + + ReactNativeTester.runTask(() => { + root.render( + { + lastParentNode = node; + }}> + Some text + , + ); + }); + + const parentNode = ensureReadOnlyNode(lastParentNode); + const textNode = parentNode.childNodes[0]; + + expect(textNode.nodeValue).toBe('Some text'); + expect(textNode.textContent).toBe('Some text'); + }); + }); + + describe('traversal', () => { + it('only preserves text nodes when their contents do not change', () => { + let lastParentElement; + let lastChildElementA; + + const root = ReactNativeTester.createRoot(); + + ReactNativeTester.runTask(() => { + root.render( + { + lastParentElement = element; + }}> + Text A + { + lastChildElementA = element; + }} + /> + Text B + , + ); + }); + + const parentElement: ReactNativeElement = + ensureReactNativeElement(lastParentElement); + const childElementA: ReactNativeElement = + ensureReactNativeElement(lastChildElementA); + + // Get text nodes and refine them as text nodes for Flow + const childTextA = parentElement.childNodes[0]; + invariant( + childTextA instanceof ReadOnlyText, + 'expected instance of ReadOnlyText', + ); + expect(childTextA.textContent).toBe('Text A'); + const childTextB = parentElement.childNodes[2]; + invariant( + childTextB instanceof ReadOnlyText, + 'expected instance of ReadOnlyText', + ); + expect(childTextB.textContent).toBe('Text B'); + + // Validate structure + expect(parentElement.childNodes.length).toBe(3); + expect(parentElement.childNodes[0]).toBe(childTextA); + expect(parentElement.childNodes[1]).toBe(childElementA); + expect(parentElement.childNodes[2]).toBe(childTextB); + + // Change contents of the second text only + ReactNativeTester.runTask(() => { + root.render( + { + lastParentElement = element; + }}> + Text A + { + lastChildElementA = element; + }} + /> + Text B modified + , + ); + }); + + expect(parentElement.childNodes.length).toBe(3); + expect(parentElement.childNodes[0]).toBe(childTextA); + expect(parentElement.childNodes[1]).toBe(childElementA); + expect(parentElement.childNodes[2]).not.toBe(childTextB); + expect(parentElement.childNodes[2]).toBeInstanceOf(ReadOnlyText); + expect(ensureReadOnlyText(parentElement.childNodes[2]).data).toBe( + 'Text B modified', + ); + expect(childTextB.isConnected).toBe(false); + }); + }); + }); + + describe('extends `ReadOnlyCharacterData`', () => { + describe('data / length', () => { + it('returns the string data and its length, respectively', () => { + let lastParentNode; + + const root = ReactNativeTester.createRoot(); + + ReactNativeTester.runTask(() => { + root.render( + { + lastParentNode = node; + }}> + Some text + , + ); + }); + + const parentNode: ReadOnlyNode = ensureReadOnlyNode(lastParentNode); + const textNode = ensureReadOnlyText(parentNode.childNodes[0]); + + expect(textNode.data).toBe('Some text'); + expect(textNode.length).toBe('Some text'.length); + }); + }); + + describe('previousElementSibling / nextElementSibling', () => { + it('return updated relative elements', () => { + let lastParentElement; + let lastChildElementA; + let lastChildElementB; + let lastChildElementC; + + const root = ReactNativeTester.createRoot(); + + ReactNativeTester.runTask(() => { + root.render( + { + lastParentElement = element; + }}> + Text A + { + lastChildElementA = element; + }} + /> + Text B + { + lastChildElementB = element; + }} + /> + Text C + { + lastChildElementC = element; + }} + /> + Text D + , + ); + }); + + const parentElement = ensureReactNativeElement(lastParentElement); + const childElementA = ensureReactNativeElement(lastChildElementA); + const childElementB = ensureReactNativeElement(lastChildElementB); + const childElementC = ensureReactNativeElement(lastChildElementC); + + // Get text nodes and refine them as text nodes for Flow + const childTextA = parentElement.childNodes[0]; + invariant( + childTextA instanceof ReadOnlyText, + 'expected instance of ReadOnlyText', + ); + const childTextB = parentElement.childNodes[2]; + invariant( + childTextB instanceof ReadOnlyText, + 'expected instance of ReadOnlyText', + ); + const childTextC = parentElement.childNodes[4]; + invariant( + childTextC instanceof ReadOnlyText, + 'expected instance of ReadOnlyText', + ); + const childTextD = parentElement.childNodes[6]; + invariant( + childTextD instanceof ReadOnlyText, + 'expected instance of ReadOnlyText', + ); + + // Validate structure + expect(parentElement.childNodes.length).toBe(7); + expect(parentElement.childNodes[0]).toBe(childTextA); + expect(parentElement.childNodes[1]).toBe(childElementA); + expect(parentElement.childNodes[2]).toBe(childTextB); + expect(parentElement.childNodes[3]).toBe(childElementB); + expect(parentElement.childNodes[4]).toBe(childTextC); + expect(parentElement.childNodes[5]).toBe(childElementC); + expect(parentElement.childNodes[6]).toBe(childTextD); + + expect(childTextA.previousElementSibling).toBe(null); + expect(childTextA.nextElementSibling).toBe(childElementA); + + expect(childTextB.previousElementSibling).toBe(childElementA); + expect(childTextB.nextElementSibling).toBe(childElementB); + + expect(childTextC.previousElementSibling).toBe(childElementB); + expect(childTextC.nextElementSibling).toBe(childElementC); + + expect(childTextD.previousElementSibling).toBe(childElementC); + expect(childTextD.nextElementSibling).toBe(null); + }); + }); + + describe('substringData', () => { + it('returns a slice of the text content', () => { + let lastParentElement; + + const root = ReactNativeTester.createRoot(); + + ReactNativeTester.runTask(() => { + root.render( + { + lastParentElement = element; + }}> + Text A + , + ); + }); + + const parentElement = ensureReactNativeElement(lastParentElement); + + // Get text nodes and refine them as text nodes for Flow + const childTextA = parentElement.childNodes[0]; + invariant( + childTextA instanceof ReadOnlyText, + 'expected instance of ReadOnlyText', + ); + + expect(childTextA.substringData(0, 1)).toBe('T'); + expect(childTextA.substringData(0, 5)).toBe('Text '); + // Count > length + expect(childTextA.substringData(0, 10)).toBe('Text A'); + // Count = length + expect(childTextA.substringData(0, 6)).toBe('Text A'); + expect(childTextA.substringData(0, 0)).toBe(''); + // Negative count + expect(childTextA.substringData(0, -1)).toBe('Text A'); + expect(childTextA.substringData(0, -10)).toBe('Text A'); + + expect(childTextA.substringData(1, 3)).toBe('ext'); + expect(childTextA.substringData(5, 1)).toBe('A'); + // Offset + count > length + expect(childTextA.substringData(5, 2)).toBe('A'); + // Offset = length + expect(childTextA.substringData(6, 1)).toBe(''); + // Offset = length & negative count + expect(childTextA.substringData(6, -1)).toBe(''); + // Negative count + expect(childTextA.substringData(5, -1)).toBe('A'); + + // Out of bounds offset + expect(() => { + childTextA.substringData(-1, 0); + }).toThrow(); + expect(() => { + childTextA.substringData(7, 0); + }).toThrow(); + }); + }); + }); +}); diff --git a/packages/react-native/src/private/webapis/dom/nodes/__tests__/setUpFeatureFlags.js b/packages/react-native/src/private/webapis/dom/nodes/__tests__/setUpFeatureFlags.js new file mode 100644 index 00000000000..6b2553b76b6 --- /dev/null +++ b/packages/react-native/src/private/webapis/dom/nodes/__tests__/setUpFeatureFlags.js @@ -0,0 +1,16 @@ +/** + * 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 ReactNativeFeatureFlags from '../../../../featureflags/ReactNativeFeatureFlags'; + +ReactNativeFeatureFlags.override({ + enableAccessToHostTreeInFabric: () => true, +}); diff --git a/packages/react-native/src/private/webapis/intersectionobserver/__tests__/IntersectionObserver-itest.js b/packages/react-native/src/private/webapis/intersectionobserver/__tests__/IntersectionObserver-itest.js new file mode 100644 index 00000000000..f034867e8fa --- /dev/null +++ b/packages/react-native/src/private/webapis/intersectionobserver/__tests__/IntersectionObserver-itest.js @@ -0,0 +1,1568 @@ +/** + * 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 + */ + +/* eslint-disable lint/sort-imports */ +import type IntersectionObserverType from '../IntersectionObserver'; + +import DOMRectReadOnly from '../../dom/geometry/DOMRectReadOnly'; +import * as ReactNativeTester from '../../../__tests__/ReactNativeTester'; +import setUpIntersectionObserver from '../../../setup/setUpIntersectionObserver'; +import ReactNativeElement from '../../dom/nodes/ReactNativeElement'; +import IntersectionObserverEntry from '../IntersectionObserverEntry'; +import * as React from 'react'; + +import './setUpFeatureFlags'; +import '../../../../../Libraries/Core/InitializeCore.js'; + +import ScrollView from '../../../../../Libraries/Components/ScrollView/ScrollView'; +import View from '../../../../../Libraries/Components/View/View'; + +declare const IntersectionObserver: Class; + +setUpIntersectionObserver(); + +function ensureInstance(value: mixed, Class: Class): T { + if (!(value instanceof Class)) { + // $FlowExpectedError[incompatible-use] + const className = Class.name; + throw new Error( + `Expected instance of ${className} but got ${String(value)}`, + ); + } + + return value; +} + +function ensureReactNativeElement(value: mixed): ReactNativeElement { + return ensureInstance(value, ReactNativeElement); +} + +export function expectRectEquals( + rect: DOMRectReadOnly, + expected: {x: number, y: number, width: number, height: number}, +): boolean { + const {x, y, width, height} = expected; + if ( + !( + rect.x === x && + rect.y === y && + rect.width === width && + rect.height === height + ) + ) { + const received = { + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + }; + throw new Error( + `Expected ${JSON.stringify(expected)} but received ${JSON.stringify(received)}`, + ); + } + return true; +} + +describe('IntersectionObserver', () => { + describe('constructor(callback, {root, rootMargin, threshold, rnRootThreshold})', () => { + it('should throw if `callback` is not provided', () => { + expect(() => { + // $FlowExpectedError[incompatible-call] + return new IntersectionObserver(); + }).toThrow( + "Failed to construct 'IntersectionObserver': 1 argument required, but only 0 present.", + ); + }); + + it('should throw if `callback` is not a function', () => { + expect(() => { + // $FlowExpectedError[incompatible-call] + return new IntersectionObserver('not a function!'); + }).toThrow( + "Failed to construct 'IntersectionObserver': parameter 1 is not of type 'Function'.", + ); + }); + + it('should throw if `root` is provided', () => { + let maybeNode; + + const root = ReactNativeTester.createRoot(); + ReactNativeTester.runTask(() => { + root.render( + { + maybeNode = receivedNode; + }} + />, + ); + }); + + const node = ensureReactNativeElement(maybeNode); + + expect(() => { + // $FlowExpectedError[prop-missing] root is not even defined in Flow. + return new IntersectionObserver(() => {}, {root: node}); + }).toThrow( + "Failed to construct 'IntersectionObserver': root is not supported", + ); + }); + + it('should throw if `rootMargin` is provided', () => { + expect(() => { + // $FlowExpectedError[prop-missing] rootMargin is not even defined in Flow. + return new IntersectionObserver(() => {}, {rootMargin: '10px'}); + }).toThrow( + "Failed to construct 'IntersectionObserver': rootMargin is not supported", + ); + }); + + it('should throw if `threshold` contains a value lower than 0 or greater than 1', () => { + expect(() => { + return new IntersectionObserver(() => {}, {threshold: 1.01}); + }).toThrow( + "Failed to construct 'IntersectionObserver': Threshold values must be numbers between 0 and 1", + ); + + expect(() => { + return new IntersectionObserver(() => {}, {threshold: -0.01}); + }).toThrow( + "Failed to construct 'IntersectionObserver': Threshold values must be numbers between 0 and 1", + ); + }); + + it('should throw if `threshold` contains a value that cannot be casted to a finite number', () => { + expect(() => { + // $FlowExpectedError[incompatible-call] + return new IntersectionObserver(() => {}, {threshold: ['test']}); + }).toThrow( + "Failed to read the 'threshold' property from 'IntersectionObserverInit': The provided double value is non-finite.", + ); + }); + + it('should provide access to `root`, `rootMargin` and `thresholds`', () => { + const observer = new IntersectionObserver(() => {}); + + expect(observer.root).toBe(null); + expect(observer.rootMargin).toBe('0px 0px 0px 0px'); + expect(observer.thresholds).toEqual([0]); + }); + + it('should normalize `threshold` values', () => { + // Sets default value + expect(new IntersectionObserver(() => {}).thresholds).toEqual([0]); + // Sets default value + expect( + new IntersectionObserver(() => {}, {threshold: []}).thresholds, + ).toEqual([0]); + + // Converts to array + expect( + new IntersectionObserver(() => {}, {threshold: 0.5}).thresholds, + ).toEqual([0.5]); + + // Sorts + expect( + new IntersectionObserver(() => {}, {threshold: [0.5, 0, 1]}).thresholds, + ).toEqual([0, 0.5, 1]); + + // Does NOT deduplicate (browsers don't do it - shrug) + expect( + new IntersectionObserver(() => {}, {threshold: [0.5, 0.5, 0.5]}) + .thresholds, + ).toEqual([0.5, 0.5, 0.5]); + + // Casts to number + expect( + // $FlowExpectedError[incompatible-call] + new IntersectionObserver(() => {}, {threshold: [true]}).thresholds, + ).toEqual([1]); + expect( + // $FlowExpectedError[incompatible-call] + new IntersectionObserver(() => {}, {threshold: [false]}).thresholds, + ).toEqual([0]); + expect( + // $FlowExpectedError[incompatible-call] + new IntersectionObserver(() => {}, {threshold: ['']}).thresholds, + ).toEqual([0]); + }); + + it('should not throw if rnRootThreshold is null or undefined', () => { + expect(() => { + // $FlowExpectedError[incompatible-call] + return new IntersectionObserver(() => {}, {rnRootThreshold: null}); + }).not.toThrow(); + + expect(() => { + return new IntersectionObserver(() => {}, { + rnRootThreshold: undefined, + }); + }).not.toThrow(); + }); + + it('should throw if rnRootThreshold is set to an invalid value', () => { + expect(() => { + return new IntersectionObserver(() => {}, { + // $FlowExpectedError[incompatible-call] + rnRootThreshold: 2, + }); + }).toThrow( + "Failed to construct 'IntersectionObserver': Threshold values must be numbers between 0 and 1", + ); + + expect(() => { + return new IntersectionObserver(() => {}, { + // $FlowExpectedError[incompatible-call] + rnRootThreshold: 'invalid', + }); + }).toThrow( + "Failed to read the 'rnRootThreshold' property from 'IntersectionObserverInit': The provided double value is non-finite.", + ); + }); + + it('should default to null if rnRootThreshold is not set or invalid', () => { + expect( + new IntersectionObserver(() => {}, {}).rnRootThresholds, + ).toBeNull(); + + expect( + new IntersectionObserver(() => {}, {threshold: 1}).rnRootThresholds, + ).toBeNull(); + expect( + // $FlowExpectedError[incompatible-call] + new IntersectionObserver(() => {}, {rnRootThreshold: null}) + .rnRootThresholds, + ).toBeNull(); + + expect( + new IntersectionObserver(() => {}, {rnRootThreshold: undefined}) + .rnRootThresholds, + ).toBeNull(); + + expect( + new IntersectionObserver(() => {}, {rnRootThreshold: []}) + .rnRootThresholds, + ).toBeNull(); + + expect( + // $FlowExpectedError[incompatible-call] + new IntersectionObserver(() => {}, {rnRootThreshold: [null]}) + .rnRootThresholds, + ).toBeNull(); + }); + + it('should normalize `rnRootThreshold` values', () => { + // Sets default value + expect(new IntersectionObserver(() => {}).rnRootThresholds).toBeNull(); + // Sets default value + expect( + new IntersectionObserver(() => {}, {rnRootThreshold: []}) + .rnRootThresholds, + ).toBeNull(); + + // Converts to array + expect( + new IntersectionObserver(() => {}, {rnRootThreshold: 0.5}) + .rnRootThresholds, + ).toEqual([0.5]); + + // Sorts + expect( + new IntersectionObserver(() => {}, {rnRootThreshold: [0.5, 0, 1]}) + .rnRootThresholds, + ).toEqual([0, 0.5, 1]); + + // Does NOT deduplicate (browsers don't do it - shrug) + expect( + new IntersectionObserver(() => {}, {rnRootThreshold: [0.5, 0.5, 0.5]}) + .rnRootThresholds, + ).toEqual([0.5, 0.5, 0.5]); + + // Casts to number + expect( + // $FlowExpectedError[incompatible-call] + new IntersectionObserver(() => {}, {rnRootThreshold: [true]}) + .rnRootThresholds, + ).toEqual([1]); + expect( + // $FlowExpectedError[incompatible-call] + new IntersectionObserver(() => {}, {rnRootThreshold: [false]}) + .rnRootThresholds, + ).toEqual([0]); + expect( + // $FlowExpectedError[incompatible-call] + new IntersectionObserver(() => {}, {rnRootThreshold: ['']}) + .rnRootThresholds, + ).toEqual([0]); + }); + + it('should default thresholds to empty if rnRootThreshold is validly', () => { + expect( + new IntersectionObserver(() => {}, {rnRootThreshold: 0.5}).thresholds, + ).toEqual([]); + + expect( + new IntersectionObserver(() => {}, {rnRootThreshold: [1]}).thresholds, + ).toEqual([]); + }); + + it('should default thresholds to [0] if rnRootThreshold is invalidly set', () => { + expect( + // $FlowExpectedError[incompatible-call] + new IntersectionObserver(() => {}, {rnRootThreshold: null}).thresholds, + ).toEqual([0]); + + expect( + new IntersectionObserver(() => {}, {rnRootThreshold: []}).thresholds, + ).toEqual([0]); + }); + }); + + describe('observe(target)', () => { + it('should throw if `target` is not a `ReactNativeElement`', () => { + const observer = new IntersectionObserver(() => {}); + expect(() => { + // $FlowExpectedError[incompatible-call] + observer.observe('something'); + }).toThrow( + "Failed to execute 'observe' on 'IntersectionObserver': parameter 1 is not of type 'ReactNativeElement'.", + ); + }); + + it('should start observing the target when called for the first time (using normalized thresholds)', () => { + let maybeNode; + let observer: IntersectionObserver; + + const root = ReactNativeTester.createRoot(); + ReactNativeTester.runTask(() => { + root.render( + { + maybeNode = receivedNode; + }} + />, + ); + }); + + const node = ensureReactNativeElement(maybeNode); + + const intersectionObserverCallback = jest.fn(); + + ReactNativeTester.runTask(() => { + observer = new IntersectionObserver(intersectionObserverCallback, { + threshold: [1, 0.5, 0], + }); + observer.observe(node); + }); + + expect(observer?.thresholds).toEqual([0, 0.5, 1]); + expect(intersectionObserverCallback).toHaveBeenCalledTimes(1); + const [entries, reportedObserver] = + intersectionObserverCallback.mock.lastCall; + expect(entries.length).toBe(1); + expect(entries[0].isIntersecting).toBe(true); + + expectRectEquals(entries[0].intersectionRect, { + x: 0, + y: 0, + width: 100, + height: 100, + }); + expect(entries[0].target).toBe(node); + expect(entries[0].intersectionRatio).toBe(1); + expectRectEquals(entries[0].boundingClientRect, { + x: 0, + y: 0, + width: 100, + height: 100, + }); + expectRectEquals(entries[0].rootBounds, { + x: 0, + y: 0, + width: 1000, + height: 1000, + }); + + expect(reportedObserver).toBe(observer); + }); + + it('should ignore subsequent calls to observe a target already being observed', () => { + let maybeNode; + let observer: IntersectionObserver; + + const root = ReactNativeTester.createRoot(); + ReactNativeTester.runTask(() => { + root.render( + { + maybeNode = receivedNode; + }} + />, + ); + }); + const node = ensureReactNativeElement(maybeNode); + + const intersectionObserverCallback = jest.fn(); + + // Let observer to run + ReactNativeTester.runTask(() => { + observer = new IntersectionObserver(intersectionObserverCallback, { + threshold: [0, 0.5, 1], + }); + + observer.observe(node); + }); + + expect(intersectionObserverCallback).toHaveBeenCalledTimes(1); + const [entries, reportedObserver] = + intersectionObserverCallback.mock.lastCall; + + expect(entries.length).toBe(1); + expect(entries[0]).toBeInstanceOf(IntersectionObserverEntry); + expect(entries[0].target).toBe(node); + expect(reportedObserver).toBe(observer); + + // Observe the same node and let observer run again + ReactNativeTester.runTask(() => { + observer.observe(node); + }); + + // Expect no additional calls + expect(intersectionObserverCallback).toHaveBeenCalledTimes(1); + }); + + it('should ignore disconnected targets', () => { + let maybeNode; + + const root = ReactNativeTester.createRoot(); + ReactNativeTester.runTask(() => { + root.render( + { + maybeNode = receivedNode; + }} + />, + ); + }); + + const node = ensureReactNativeElement(maybeNode); + + ReactNativeTester.runTask(() => { + root.render(<>); + }); + + expect(node.isConnected).toBe(false); + + const intersectionObserverCallback = jest.fn(); + + ReactNativeTester.runTask(() => { + const observer = new IntersectionObserver(intersectionObserverCallback); + observer.observe(node); + }); + + expect(intersectionObserverCallback).not.toHaveBeenCalled(); + }); + + it('should report completely non-intersecting initial state correctly', () => { + let maybeNode; + let observer: IntersectionObserver; + + const root = ReactNativeTester.createRoot(); + ReactNativeTester.runTask(() => { + root.render( + + { + maybeNode = receivedNode; + }} + /> + , + , + ); + }); + const node = ensureReactNativeElement(maybeNode); + + const intersectionObserverCallback = jest.fn(); + + ReactNativeTester.runTask(() => { + observer = new IntersectionObserver(intersectionObserverCallback, { + threshold: [0], + rnRootThreshold: [0.5], + }); + + observer.observe(node); + }); + + expect(intersectionObserverCallback).toHaveBeenCalledTimes(1); + const [entries, reportedObserver] = + intersectionObserverCallback.mock.lastCall; + + expect(reportedObserver).toBe(observer); + expect(entries.length).toBe(1); + expect(entries[0]).toBeInstanceOf(IntersectionObserverEntry); + expect(entries[0].intersectionRatio).toBe(0); + expect(entries[0].rnRootIntersectionRatio).toBe(0); + expect(entries[0].isIntersecting).toBe(false); + expect(entries[0].target).toBe(node); + expectRectEquals(entries[0].intersectionRect, { + x: 0, + y: 0, + width: 0, + height: 0, + }); + expectRectEquals(entries[0].boundingClientRect, { + x: 0, + y: -200, + width: 50, + height: 50, + }); + expectRectEquals(entries[0].rootBounds, { + x: 0, + y: 0, + width: 1000, + height: 1000, + }); + }); + + it('should report partial non-intersecting initial state correctly', () => { + let maybeNode; + let observer: IntersectionObserver; + + const root = ReactNativeTester.createRoot(); + ReactNativeTester.runTask(() => { + root.render( + + { + maybeNode = receivedNode; + }} + /> + , + , + ); + }); + const node = ensureReactNativeElement(maybeNode); + + const intersectionObserverCallback = jest.fn(); + + ReactNativeTester.runTask(() => { + observer = new IntersectionObserver(intersectionObserverCallback, { + threshold: [1], + }); + + observer.observe(node); + }); + + expect(intersectionObserverCallback).toHaveBeenCalledTimes(1); + const [entries, reportedObserver] = + intersectionObserverCallback.mock.lastCall; + + expect(reportedObserver).toBe(observer); + expect(entries.length).toBe(1); + expect(entries[0]).toBeInstanceOf(IntersectionObserverEntry); + expect(entries[0].intersectionRatio).toBe(0.5); + expect(entries[0].rnRootIntersectionRatio).toBe(0.00125); + expect(entries[0].isIntersecting).toBe(false); + expect(entries[0].target).toBe(node); + expectRectEquals(entries[0].intersectionRect, { + x: 0, + y: 0, + width: 50, + height: 25, + }); + expectRectEquals(entries[0].boundingClientRect, { + x: 0, + y: -25, + width: 50, + height: 50, + }); + expectRectEquals(entries[0].rootBounds, { + x: 0, + y: 0, + width: 1000, + height: 1000, + }); + }); + + it('should report partial intersecting initial state correctly', () => { + let maybeNode; + let observer: IntersectionObserver; + + const root = ReactNativeTester.createRoot(); + ReactNativeTester.runTask(() => { + root.render( + + { + maybeNode = receivedNode; + }} + /> + , + , + ); + }); + const node = ensureReactNativeElement(maybeNode); + + const intersectionObserverCallback = jest.fn(); + + ReactNativeTester.runTask(() => { + observer = new IntersectionObserver(intersectionObserverCallback, { + threshold: [], + }); + + observer.observe(node); + }); + + expect(intersectionObserverCallback).toHaveBeenCalledTimes(1); + const [entries, reportedObserver] = + intersectionObserverCallback.mock.lastCall; + + expect(reportedObserver).toBe(observer); + expect(entries.length).toBe(1); + expect(entries[0]).toBeInstanceOf(IntersectionObserverEntry); + expect(entries[0].intersectionRatio).toBe(0.5); + expect(entries[0].rnRootIntersectionRatio).toBe(0.00125); + expect(entries[0].isIntersecting).toBe(true); + expect(entries[0].target).toBe(node); + expectRectEquals(entries[0].intersectionRect, { + x: 0, + y: 0, + width: 50, + height: 25, + }); + expectRectEquals(entries[0].boundingClientRect, { + x: 0, + y: -25, + width: 50, + height: 50, + }); + expectRectEquals(entries[0].rootBounds, { + x: 0, + y: 0, + width: 1000, + height: 1000, + }); + }); + + it('should report subsequent updates correctly', () => { + let maybeNode; + let observer: IntersectionObserver; + + const root = ReactNativeTester.createRoot(); + ReactNativeTester.runTask(() => { + root.render( + { + maybeNode = receivedNode; + }} + />, + ); + }); + + const node = ensureReactNativeElement(maybeNode); + + expect(node.isConnected).toBe(true); + + const intersectionObserverCallback = jest.fn(); + + ReactNativeTester.runTask(() => { + observer = new IntersectionObserver(intersectionObserverCallback); + observer.observe(node); + }); + + expect(intersectionObserverCallback).toHaveBeenCalledTimes(1); + const [entries, reportedObserver] = + intersectionObserverCallback.mock.lastCall; + expect(entries.length).toBe(1); + expect(entries[0]).toBeInstanceOf(IntersectionObserverEntry); + expect(entries[0].intersectionRatio).toBe(1); + expect(entries[0].rnRootIntersectionRatio).toBe(0.01); + expect(entries[0].isIntersecting).toBe(true); + + expectRectEquals(entries[0].intersectionRect, { + x: 0, + y: 0, + width: 100, + height: 100, + }); + expectRectEquals(entries[0].boundingClientRect, { + x: 0, + y: 0, + width: 100, + height: 100, + }); + expectRectEquals(entries[0].rootBounds, { + x: 0, + y: 0, + width: 1000, + height: 1000, + }); + + expect(reportedObserver).toBe(observer); + + // Move the view out of the viewport + // Using position absolute as there isn't support ScrollView scrolls + ReactNativeTester.runTask(() => { + root.render( + { + maybeNode = receivedNode; + }} + />, + ); + }); + + expect(node.isConnected).toBe(true); + expect(intersectionObserverCallback).toHaveBeenCalledTimes(2); + const [entries2, reportedObserver2] = + intersectionObserverCallback.mock.lastCall; + expect(entries2.length).toBe(1); + expect(entries2[0].isIntersecting).toBe(false); + expect(entries2[0].target).toBe(node); + + expectRectEquals(entries2[0].intersectionRect, { + x: 0, + y: 0, + width: 0, + height: 0, + }); + expectRectEquals(entries2[0].boundingClientRect, { + x: 0, + y: 2000, + width: 100, + height: 100, + }); + expectRectEquals(entries2[0].rootBounds, { + x: 0, + y: 0, + width: 1000, + height: 1000, + }); + expect(reportedObserver2).toBe(observer); + }); + + it('should report updates to the right observers', () => { + let maybeNode1; + let maybeNode2; + let observer1: IntersectionObserver; + let observer2: IntersectionObserver; + + const root = ReactNativeTester.createRoot(); + ReactNativeTester.runTask(() => { + root.render( + + { + maybeNode1 = receivedNode; + }} + /> + { + maybeNode2 = receivedNode; + }} + /> + , + ); + }); + const node1 = ensureReactNativeElement(maybeNode1); + const node2 = ensureReactNativeElement(maybeNode2); + + const intersectionObserverCallback1 = jest.fn(); + const intersectionObserverCallback2 = jest.fn(); + + ReactNativeTester.runTask(() => { + observer1 = new IntersectionObserver(intersectionObserverCallback1, { + threshold: [0], + }); + + observer1.observe(node1); + observer1.observe(node2); + + observer2 = new IntersectionObserver(intersectionObserverCallback2, { + threshold: [1], + }); + observer2.observe(node2); + }); + + // Verify observer1 is reporting right thing + expect(intersectionObserverCallback1).toHaveBeenCalledTimes(1); + const [entries1, reportedObserver1] = + intersectionObserverCallback1.mock.lastCall; + + expect(reportedObserver1).toBe(observer1); + expect(entries1.length).toBe(2); + + expect(entries1[0].isIntersecting).toBe(false); + expect(entries1[0].intersectionRatio).toBe(0); + expect(entries1[0].target).toBe(node1); + + expectRectEquals(entries1[0].intersectionRect, { + x: 0, + y: 0, + width: 0, + height: 0, + }); + expectRectEquals(entries1[0].boundingClientRect, { + x: 0, + y: -100, + width: 50, + height: 50, + }); + expectRectEquals(entries1[0].rootBounds, { + x: 0, + y: 0, + width: 1000, + height: 1000, + }); + + expect(entries1[1].isIntersecting).toBe(true); + expect(entries1[1].intersectionRatio).toBe(0.75); + expect(entries1[1].target).toBe(node2); + + // Verify observer2 is reporting no intersection because the threshold is 1 + expect(intersectionObserverCallback2).toHaveBeenCalledTimes(1); + const [entries2, reportedObserver2] = + intersectionObserverCallback2.mock.lastCall; + + expect(reportedObserver2).toBe(observer2); + expect(entries2.length).toBe(1); + + expect(entries2[0].isIntersecting).toBe(false); + expect(entries2[0].intersectionRatio).toBe(0.75); + expect(entries2[0].target).toBe(node2); + + expectRectEquals(entries2[0].intersectionRect, { + x: 0, + y: 0, + width: 200, + height: 150, + }); + expectRectEquals(entries2[0].boundingClientRect, { + x: 0, + y: -50, + width: 200, + height: 200, + }); + expectRectEquals(entries2[0].rootBounds, { + x: 0, + y: 0, + width: 1000, + height: 1000, + }); + }); + describe('rootThreshold', () => { + it('should report partial intersecting initial state correctly', () => { + let maybeNode; + let observer: IntersectionObserver; + + const root = ReactNativeTester.createRoot(); + ReactNativeTester.runTask(() => { + root.render( + { + maybeNode = receivedNode; + }} + />, + ); + }); + + const node = ensureReactNativeElement(maybeNode); + + const intersectionObserverCallback = jest.fn(); + + ReactNativeTester.runTask(() => { + observer = new IntersectionObserver(intersectionObserverCallback, { + rnRootThreshold: [1, 0.5, 0], + }); + observer.observe(node); + }); + + expect(observer?.rnRootThresholds).toEqual([0, 0.5, 1]); + expect(intersectionObserverCallback).toHaveBeenCalledTimes(1); + + const [entries, reportedObserver] = + intersectionObserverCallback.mock.lastCall; + expect(entries.length).toBe(1); + expect(entries[0].isIntersecting).toBe(true); + expect(entries[0].target).toBe(node); + expect(entries[0].intersectionRatio).toBe(1); + expect(entries[0].rnRootIntersectionRatio).toBe(0.01); + + expectRectEquals(entries[0].intersectionRect, { + x: 0, + y: 0, + width: 100, + height: 100, + }); + expectRectEquals(entries[0].boundingClientRect, { + x: 0, + y: 0, + width: 100, + height: 100, + }); + expectRectEquals(entries[0].rootBounds, { + x: 0, + y: 0, + width: 1000, + height: 1000, + }); + + expect(reportedObserver).toBe(observer); + }); + + it('should report partial non-intersecting initial state correctly', () => { + let maybeNode; + let observer: IntersectionObserver; + + const root = ReactNativeTester.createRoot(); + ReactNativeTester.runTask(() => { + root.render( + + { + maybeNode = receivedNode; + }} + /> + , + , + ); + }); + const node = ensureReactNativeElement(maybeNode); + + const intersectionObserverCallback = jest.fn(); + + ReactNativeTester.runTask(() => { + observer = new IntersectionObserver(intersectionObserverCallback, { + rnRootThreshold: [0.5], + }); + + observer.observe(node); + }); + + expect(intersectionObserverCallback).toHaveBeenCalledTimes(1); + const [entries, reportedObserver] = + intersectionObserverCallback.mock.lastCall; + + expect(reportedObserver).toBe(observer); + expect(entries.length).toBe(1); + expect(entries[0]).toBeInstanceOf(IntersectionObserverEntry); + expect(entries[0].intersectionRatio).toBe(0.5); + expect(entries[0].rnRootIntersectionRatio).toBe(0.00125); + expect(entries[0].isIntersecting).toBe(false); + expect(entries[0].target).toBe(node); + expectRectEquals(entries[0].intersectionRect, { + x: 0, + y: 0, + width: 50, + height: 25, + }); + expectRectEquals(entries[0].boundingClientRect, { + x: 0, + y: -25, + width: 50, + height: 50, + }); + expectRectEquals(entries[0].rootBounds, { + x: 0, + y: 0, + width: 1000, + height: 1000, + }); + }); + + it('should report completely non-intersecting initial state correctly', () => { + let maybeNode; + let observer: IntersectionObserver; + + const root = ReactNativeTester.createRoot(); + ReactNativeTester.runTask(() => { + root.render( + + { + maybeNode = receivedNode; + }} + /> + , + , + ); + }); + const node = ensureReactNativeElement(maybeNode); + + const intersectionObserverCallback = jest.fn(); + + ReactNativeTester.runTask(() => { + observer = new IntersectionObserver(intersectionObserverCallback, { + rnRootThreshold: [0.5], + }); + + observer.observe(node); + }); + + expect(intersectionObserverCallback).toHaveBeenCalledTimes(1); + const [entries, reportedObserver] = + intersectionObserverCallback.mock.lastCall; + + expect(reportedObserver).toBe(observer); + expect(entries.length).toBe(1); + expect(entries[0]).toBeInstanceOf(IntersectionObserverEntry); + expect(entries[0].intersectionRatio).toBe(0); + expect(entries[0].rnRootIntersectionRatio).toBe(0); + expect(entries[0].isIntersecting).toBe(false); + expect(entries[0].target).toBe(node); + expectRectEquals(entries[0].intersectionRect, { + x: 0, + y: 0, + width: 0, + height: 0, + }); + expectRectEquals(entries[0].boundingClientRect, { + x: 0, + y: -200, + width: 50, + height: 50, + }); + expectRectEquals(entries[0].rootBounds, { + x: 0, + y: 0, + width: 1000, + height: 1000, + }); + }); + + it('should report subsequent updates correctly', () => { + let maybeNode; + let observer: IntersectionObserver; + + const root = ReactNativeTester.createRoot(); + ReactNativeTester.runTask(() => { + root.render( + { + maybeNode = receivedNode; + }} + />, + ); + }); + + const node = ensureReactNativeElement(maybeNode); + + expect(node.isConnected).toBe(true); + + const intersectionObserverCallback = jest.fn(); + + ReactNativeTester.runTask(() => { + observer = new IntersectionObserver(intersectionObserverCallback, { + rnRootThreshold: [1], + }); + observer.observe(node); + }); + + expect(intersectionObserverCallback).toHaveBeenCalledTimes(1); + const [entries, reportedObserver] = + intersectionObserverCallback.mock.lastCall; + expect(entries.length).toBe(1); + expect(entries[0]).toBeInstanceOf(IntersectionObserverEntry); + expect(entries[0].intersectionRatio).toBe(0); + expect(entries[0].rnRootIntersectionRatio).toBe(0); + expect(entries[0].isIntersecting).toBe(false); + expectRectEquals(entries[0].intersectionRect, { + x: 0, + y: 0, + width: 0, + height: 0, + }); + expectRectEquals(entries[0].boundingClientRect, { + x: 0, + y: 2000, + width: 1000, + height: 1000, + }); + expectRectEquals(entries[0].rootBounds, { + x: 0, + y: 0, + width: 1000, + height: 1000, + }); + + expect(reportedObserver).toBe(observer); + + // Move the view out of the viewport + // Using position absolute as we don't support changes in scroll position yet + ReactNativeTester.runTask(() => { + root.render( + { + maybeNode = receivedNode; + }} + />, + ); + }); + + expect(node.isConnected).toBe(true); + expect(intersectionObserverCallback).toHaveBeenCalledTimes(2); + const [entries2, reportedObserver2] = + intersectionObserverCallback.mock.lastCall; + expect(entries2.length).toBe(1); + expect(entries2[0].isIntersecting).toBe(true); + expect(entries2[0].intersectionRatio).toBe(1); + expect(entries2[0].rnRootIntersectionRatio).toBe(1); + expect(entries2[0].target).toBe(node); + + expectRectEquals(entries2[0].intersectionRect, { + x: 0, + y: 0, + width: 1000, + height: 1000, + }); + expectRectEquals(entries2[0].boundingClientRect, { + x: 0, + y: 0, + width: 1000, + height: 1000, + }); + expectRectEquals(entries2[0].rootBounds, { + x: 0, + y: 0, + width: 1000, + height: 1000, + }); + + expect(reportedObserver2).toBe(observer); + }); + }); + }); + + describe('unobserve(target)', () => { + it('should throw if `target` is not a `ReactNativeElement`', () => { + const observer = new IntersectionObserver(() => {}); + expect(() => { + // $FlowExpectedError[incompatible-call] + observer.unobserve('something'); + }).toThrow( + "Failed to execute 'unobserve' on 'IntersectionObserver': parameter 1 is not of type 'ReactNativeElement'.", + ); + }); + + it('should ignore the call if `target` was not observed (not fail)', () => { + let maybeNode; + + const root = ReactNativeTester.createRoot(); + ReactNativeTester.runTask(() => { + root.render( + { + maybeNode = receivedNode; + }} + />, + ); + }); + + const node = ensureReactNativeElement(maybeNode); + const callback = jest.fn(); + + ReactNativeTester.runTask(() => { + const observer = new IntersectionObserver(callback); + observer.unobserve(node); + }); + + expect(callback).not.toHaveBeenCalled(); + }); + + it('should stop observing the target if it was observed', () => { + let maybeNode; + let observer: IntersectionObserver; + + const root = ReactNativeTester.createRoot(); + // View is not intersecting with ScrollView + ReactNativeTester.runTask(() => { + root.render( + + { + maybeNode = receivedNode; + }} + /> + , + ); + }); + + const node = ensureReactNativeElement(maybeNode); + + const callback = jest.fn(); + + ReactNativeTester.runTask(() => { + observer = new IntersectionObserver(callback, {threshold: 1}); + observer.observe(node); + }); + + // Should get initial non-intersecting entry on observation + expect(callback).toHaveBeenCalledTimes(1); + const [entries] = callback.mock.lastCall; + expect(entries.length).toBe(1); + expect(entries[0].isIntersecting).toBe(false); + + ReactNativeTester.runTask(() => { + observer.unobserve(node); + }); + + // Update View such that it is intersecting + ReactNativeTester.runTask(() => { + root.render( + + { + maybeNode = receivedNode; + }} + /> + , + ); + }); + + // Expect no additional callback calls + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should stop observing the target if it was observed (detached target after observing)', () => { + let maybeNode; + let observer: IntersectionObserver; + + const root = ReactNativeTester.createRoot(); + ReactNativeTester.runTask(() => { + root.render( + { + maybeNode = receivedNode; + }} + />, + ); + }); + + const node = ensureReactNativeElement(maybeNode); + + const callback = jest.fn(); + + ReactNativeTester.runTask(() => { + observer = new IntersectionObserver(callback); + observer.observe(node); + }); + + expect(callback).toHaveBeenCalledTimes(1); + + ReactNativeTester.runTask(() => { + root.render(<>); + }); + + expect(node.isConnected).toBe(false); + + ReactNativeTester.runTask(() => { + observer.unobserve(node); + }); + + // expect no change in callback + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should stop observing the target if it was observed (detached target before observing)', () => { + let maybeNode; + let observer: IntersectionObserver; + + const root = ReactNativeTester.createRoot(); + ReactNativeTester.runTask(() => { + root.render( + { + maybeNode = receivedNode; + }} + />, + ); + }); + + const node = ensureReactNativeElement(maybeNode); + + ReactNativeTester.runTask(() => { + root.render(<>); + }); + expect(node.isConnected).toBe(false); + + ReactNativeTester.runTask(() => { + observer = new IntersectionObserver(() => {}); + observer.observe(node); + // TODO what happens if this throws an exception? + observer.unobserve(node); + throw new Error('unobserve should not throw'); + }); + }); + + it('should not report the initial state if the target is unobserved before it is delivered', () => { + let maybeNode; + let observer: IntersectionObserver; + + const root = ReactNativeTester.createRoot(); + ReactNativeTester.runTask(() => { + root.render( + { + maybeNode = receivedNode; + }} + />, + ); + }); + + const node = ensureReactNativeElement(maybeNode); + + const intersectionObserverCallback = jest.fn(); + + ReactNativeTester.runTask(() => { + observer = new IntersectionObserver(intersectionObserverCallback); + observer.observe(node); + observer.unobserve(node); + }); + + expect(intersectionObserverCallback).not.toHaveBeenCalled(); + }); + + it('should not report updates if the target is unobserved before they are delivered', () => { + let maybeNode; + let observer: IntersectionObserver; + + const root = ReactNativeTester.createRoot(); + // View is not intersecting with ScrollView + ReactNativeTester.runTask(() => { + root.render( + + { + maybeNode = receivedNode; + }} + /> + , + ); + }); + + const node = ensureReactNativeElement(maybeNode); + + const callback = jest.fn(); + + ReactNativeTester.runTask(() => { + observer = new IntersectionObserver(callback, {threshold: 1}); + observer.observe(node); + }); + + // Should get initial non-intersecting entry on observation + expect(callback).toHaveBeenCalledTimes(1); + const [entries] = callback.mock.lastCall; + expect(entries.length).toBe(1); + expect(entries[0].isIntersecting).toBe(false); + + ReactNativeTester.runTask(() => { + observer.unobserve(node); + }); + + // Update View such that it is intersecting + ReactNativeTester.runTask(() => { + root.render( + + { + maybeNode = receivedNode; + }} + /> + , + ); + }); + + // The callback should not be called again because the target was + // unobserved before the callback was called with the update. + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should not report updates if the target is unobserved before they are delivered (with other active targets)', () => { + let maybeNode1; + let maybeNode2; + let observer: IntersectionObserver; + + const root = ReactNativeTester.createRoot(); + ReactNativeTester.runTask(() => { + root.render( + <> + { + maybeNode1 = receivedNode; + }} + /> + { + maybeNode2 = receivedNode; + }} + /> + , + ); + }); + const node1 = ensureReactNativeElement(maybeNode1); + const node2 = ensureReactNativeElement(maybeNode2); + + const intersectionObserverCallback = jest.fn(); + + ReactNativeTester.runTask(() => { + observer = new IntersectionObserver(intersectionObserverCallback, { + threshold: 0, + }); + + observer.observe(node1); + observer.observe(node2); + }); + + expect(intersectionObserverCallback).toHaveBeenCalledTimes(1); + + ReactNativeTester.runTask(() => { + observer.unobserve(node1); + }); + + ReactNativeTester.runTask(() => { + root.render( + <> + { + maybeNode1 = receivedNode; + }} + /> + { + maybeNode2 = receivedNode; + }} + /> + , + ); + }); + + // There should be no additional callback becuase node1 was unobserved + // and node2 has no change in intersecting state (threshold 0) + expect(intersectionObserverCallback).toHaveBeenCalledTimes(1); + }); + + // This is a regression test for a bug where the registration data for the + // target was incorrectly cleaned up when a single observer stopped + // observing it. + it('should work with multiple intersection observer instances', () => { + let maybeNode; + let observer1: IntersectionObserver; + let observer2: IntersectionObserver; + + const root = ReactNativeTester.createRoot(); + ReactNativeTester.runTask(() => { + root.render( + { + maybeNode = receivedNode; + }} + />, + ); + }); + + const node = ensureReactNativeElement(maybeNode); + + ReactNativeTester.runTask(() => { + observer1 = new IntersectionObserver(() => {}); + observer2 = new IntersectionObserver(() => {}); + + observer1.observe(node); + observer2.observe(node); + + observer1.unobserve(node); + + // The second call shouldn't log errors (that would make the test fail). + observer2.unobserve(node); + throw new Error('unobserve should not throw'); + }); + }); + }); + + describe('disconnect', () => { + it('should do nothing if no targets are observed (not fail)', () => { + const callback = jest.fn(); + + ReactNativeTester.runTask(() => { + const observer = new IntersectionObserver(callback); + observer.disconnect(); + }); + + expect(callback).not.toHaveBeenCalled(); + }); + + it('should stop observing all observed targets', () => { + let maybeNode1; + let maybeNode2; + + const root = ReactNativeTester.createRoot(); + ReactNativeTester.runTask(() => { + root.render( + <> + { + maybeNode1 = receivedNode; + }} + /> + { + maybeNode2 = receivedNode; + }} + /> + , + ); + }); + const node1 = ensureReactNativeElement(maybeNode1); + const node2 = ensureReactNativeElement(maybeNode2); + + const callback = jest.fn(); + + ReactNativeTester.runTask(() => { + const observer = new IntersectionObserver(callback); + + observer.observe(node1); + observer.observe(node2); + + observer.disconnect(); + }); + + expect(callback).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/react-native/src/private/webapis/intersectionobserver/__tests__/setUpFeatureFlags.js b/packages/react-native/src/private/webapis/intersectionobserver/__tests__/setUpFeatureFlags.js new file mode 100644 index 00000000000..5af0f6f71dd --- /dev/null +++ b/packages/react-native/src/private/webapis/intersectionobserver/__tests__/setUpFeatureFlags.js @@ -0,0 +1,16 @@ +/** + * 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 ReactNativeFeatureFlags from '../../../featureflags/ReactNativeFeatureFlags'; + +ReactNativeFeatureFlags.override({ + enableAccessToHostTreeInFabric: () => true, +}); diff --git a/packages/react-native/src/private/webapis/mutationobserver/__tests__/MutationObserver-itest.js b/packages/react-native/src/private/webapis/mutationobserver/__tests__/MutationObserver-itest.js new file mode 100644 index 00000000000..d14ec74a2dc --- /dev/null +++ b/packages/react-native/src/private/webapis/mutationobserver/__tests__/MutationObserver-itest.js @@ -0,0 +1,1074 @@ +/** + * 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 type MutationObserverType from '../MutationObserver'; + +import * as ReactNativeTester from '../../../__tests__/ReactNativeTester'; +import View from '../../../../../Libraries/Components/View/View'; +import setUpMutationObserver from '../../../setup/setUpMutationObserver'; +import ReactNativeElement from '../../dom/nodes/ReactNativeElement'; +import nullthrows from 'nullthrows'; +import * as React from 'react'; + +import './setUpFeatureFlags'; +import '../../../../../Libraries/Core/InitializeCore.js'; + +declare const MutationObserver: Class; + +setUpMutationObserver(); + +function ensureInstance(value: mixed, Class: Class): T { + if (!(value instanceof Class)) { + // $FlowExpectedError[incompatible-use] + const className = Class.name; + throw new Error( + `Expected instance of ${className} but got ${String(value)}`, + ); + } + + return value; +} + +function ensureReactNativeElement(value: mixed): ReactNativeElement { + return ensureInstance(value, ReactNativeElement); +} + +function ensureMutationRecordArray( + value: mixed, +): $ReadOnlyArray { + return ensureInstance(value, Array).map((element: mixed) => + ensureInstance(element, MutationRecord), + ); +} + +describe('MutationObserver', () => { + describe('constructor(callback)', () => { + it('should throw if `callback` is not provided', () => { + expect(() => { + // $FlowExpectedError[incompatible-call] + return new MutationObserver(); + }).toThrow( + "Failed to construct 'MutationObserver': 1 argument required, but only 0 present.", + ); + }); + + it('should throw if `callback` is not a function', () => { + expect(() => { + // $FlowExpectedError[incompatible-call] + return new MutationObserver('not a function!'); + }).toThrow( + "Failed to construct 'MutationObserver': parameter 1 is not of type 'Function'.", + ); + }); + }); + + describe('observe(target, {childList: boolean, subtree: boolean})', () => { + it('should throw if `target` is not a `ReactNativeElement`', () => { + const observer = new MutationObserver(() => {}); + expect(() => { + // $FlowExpectedError[incompatible-call] + observer.observe('something'); + }).toThrow( + "Failed to execute 'observe' on 'MutationObserver': parameter 1 is not of type 'ReactNativeElement'.", + ); + }); + + it('should throw if the `childList` option is not provided', () => { + let maybeNode; + + const root = ReactNativeTester.createRoot(); + ReactNativeTester.runTask(() => { + root.render( + { + maybeNode = receivedNode; + }} + />, + ); + }); + + const node = ensureReactNativeElement(maybeNode); + + expect(() => { + const observer = new MutationObserver(() => {}); + observer.observe(node); + }).toThrow( + "Failed to execute 'observe' on 'MutationObserver': The options object must set 'childList' to true.", + ); + + expect(() => { + const observer = new MutationObserver(() => {}); + // $FlowExpectedError[incompatible-call] + observer.observe(node, {childList: false}); + }).toThrow( + "Failed to execute 'observe' on 'MutationObserver': The options object must set 'childList' to true.", + ); + + expect(() => { + const observer = new MutationObserver(() => {}); + observer.observe(node, {childList: true}); + }).not.toThrow(); + + expect(() => { + const observer = new MutationObserver(() => {}); + // $FlowExpectedError[incompatible-call] + observer.observe(node, {childList: 1}); + }).not.toThrow(); + }); + + it('should throw if the `attributes` option is provided', () => { + let maybeNode; + + const root = ReactNativeTester.createRoot(); + ReactNativeTester.runTask(() => { + root.render( + { + maybeNode = receivedNode; + }} + />, + ); + }); + + const node = ensureReactNativeElement(maybeNode); + + expect(() => { + const observer = new MutationObserver(() => {}); + observer.observe(node, {childList: true, attributes: true}); + }).toThrow( + "Failed to execute 'observe' on 'MutationObserver': attributes is not supported", + ); + }); + + it('should throw if the `attributeFilter` option is provided', () => { + let maybeNode; + + const root = ReactNativeTester.createRoot(); + ReactNativeTester.runTask(() => { + root.render( + { + maybeNode = receivedNode; + }} + />, + ); + }); + + const node = ensureReactNativeElement(maybeNode); + + expect(() => { + const observer = new MutationObserver(() => {}); + observer.observe(node, {childList: true, attributeFilter: []}); + }).toThrow( + "Failed to execute 'observe' on 'MutationObserver': attributeFilter is not supported", + ); + }); + + it('should throw if the `attributeOldValue` option is provided', () => { + let maybeNode; + + const root = ReactNativeTester.createRoot(); + ReactNativeTester.runTask(() => { + root.render( + { + maybeNode = receivedNode; + }} + />, + ); + }); + + const node = ensureReactNativeElement(maybeNode); + + expect(() => { + const observer = new MutationObserver(() => {}); + // $FlowExpected + observer.observe(node, {childList: true, attributeOldValue: true}); + }).toThrow( + "Failed to execute 'observe' on 'MutationObserver': attributeOldValue is not supported", + ); + }); + + it('should throw if the `characterData` option is provided', () => { + let maybeNode; + + const root = ReactNativeTester.createRoot(); + ReactNativeTester.runTask(() => { + root.render( + { + maybeNode = receivedNode; + }} + />, + ); + }); + + const node = ensureReactNativeElement(maybeNode); + + expect(() => { + const observer = new MutationObserver(() => {}); + observer.observe(node, {childList: true, characterData: true}); + }).toThrow( + "Failed to execute 'observe' on 'MutationObserver': characterData is not supported", + ); + }); + + it('should throw if the `characterDataOldValue` option is provided', () => { + let maybeNode; + + const root = ReactNativeTester.createRoot(); + ReactNativeTester.runTask(() => { + root.render( + { + maybeNode = receivedNode; + }} + />, + ); + }); + + const node = ensureReactNativeElement(maybeNode); + + expect(() => { + const observer = new MutationObserver(() => {}); + observer.observe(node, {childList: true, characterDataOldValue: true}); + }).toThrow( + "Failed to execute 'observe' on 'MutationObserver': characterDataOldValue is not supported", + ); + }); + + it('should ignore calls to observe disconnected targets', () => { + let maybeNode; + + const root = ReactNativeTester.createRoot(); + ReactNativeTester.runTask(() => { + root.render( + { + maybeNode = receivedNode; + }} + />, + ); + }); + + const node = ensureReactNativeElement(maybeNode); + + ReactNativeTester.runTask(() => { + root.render(<>); + }); + + expect(node.isConnected).toBe(false); + + const observerCallback = () => {}; + const observer = new MutationObserver(observerCallback); + + expect(() => { + observer.observe(node, {childList: true}); + }).not.toThrow(); + }); + + it('should report direct children added to and removed from an observed node (childList: true, subtree: false) ', () => { + let maybeNode; + + const root = ReactNativeTester.createRoot(); + ReactNativeTester.runTask(() => { + root.render( + { + maybeNode = receivedNode; + }} + />, + ); + }); + + const node = ensureReactNativeElement(maybeNode); + + const observerCallbackCallArgs = []; + const observerCallback = (...args: $ReadOnlyArray) => { + observerCallbackCallArgs.push(args); + }; + const observer = new MutationObserver(observerCallback); + observer.observe(node, {childList: true}); + + // Does not report anything initially + expect(observerCallbackCallArgs.length).toBe(0); + + let maybeChildNode1, maybeChildNode2; + + ReactNativeTester.runTask(() => { + root.render( + + { + maybeChildNode1 = receivedChildNode; + }} + /> + { + maybeChildNode2 = receivedChildNode; + }} + /> + , + ); + }); + + const childNode1 = ensureReactNativeElement(maybeChildNode1); + const childNode2 = ensureReactNativeElement(maybeChildNode2); + + expect(observerCallbackCallArgs.length).toBe(1); + const firstCall = nullthrows(observerCallbackCallArgs.at(-1)); + expect(firstCall.length).toBe(2); + + const firstRecords = ensureMutationRecordArray(firstCall[0]); + expect(firstRecords).toBeInstanceOf(Array); + expect(firstRecords.length).toBe(1); + expect(firstRecords[0]).toBeInstanceOf(MutationRecord); + + const firstRecord = firstRecords[0]; + expect(firstRecord.type).toBe('childList'); + expect(firstRecord.target).toBe(node); + expect(firstRecord.addedNodes).toBeInstanceOf(NodeList); + expect(firstRecord.addedNodes[0]).toBe(childNode1); + expect(firstRecord.addedNodes[1]).toBe(childNode2); + expect(firstRecord.removedNodes).toBeInstanceOf(NodeList); + expect(firstRecord.removedNodes.length).toBe(0); + + expect(firstCall[1]).toBe(observer); + + ReactNativeTester.runTask(() => { + root.render( + + + , + ); + }); + + expect(observerCallbackCallArgs.length).toBe(2); + const secondCall = nullthrows(observerCallbackCallArgs.at(-1)); + expect(secondCall.length).toBe(2); + + const secondRecords = ensureMutationRecordArray(secondCall[0]); + expect(secondRecords.length).toBe(1); + expect(secondRecords[0]).toBeInstanceOf(MutationRecord); + + const secondRecord = secondRecords[0]; + expect(secondRecord.type).toBe('childList'); + expect(secondRecord.target).toBe(node); + expect(secondRecord.addedNodes).toBeInstanceOf(NodeList); + expect([...secondRecord.addedNodes]).toEqual([]); + expect(secondRecord.removedNodes).toBeInstanceOf(NodeList); + expect([...secondRecord.removedNodes]).toEqual([childNode1]); + + expect(secondCall[1]).toBe(observer); + }); + + it('should NOT report changes in transitive children when `subtree` is not set to true', () => { + let maybeObservedNode; + + const root = ReactNativeTester.createRoot(); + ReactNativeTester.runTask(() => { + root.render( + { + maybeObservedNode = ensureReactNativeElement(receivedNode); + }}> + + , + ); + }); + + const observedNode = nullthrows(maybeObservedNode); + + const observerCallback = jest.fn(); + const observer = new MutationObserver(observerCallback); + observer.observe(observedNode, {childList: true}); + + // Does not report anything initially + expect(observerCallback).not.toHaveBeenCalled(); + + ReactNativeTester.runTask(() => { + root.render( + + + + + , + ); + }); + + expect(observerCallback).not.toHaveBeenCalled(); + + ReactNativeTester.runTask(() => { + root.render( + + + , + ); + }); + + expect(observerCallback).not.toHaveBeenCalled(); + }); + + it('should report changes in transitive children when `subtree` is set to true', () => { + let maybeNode; + + const root = ReactNativeTester.createRoot(); + ReactNativeTester.runTask(() => { + root.render( + { + maybeNode = receivedNode; + }}> + + , + ); + }); + + const node = ensureReactNativeElement(maybeNode); + + const observerCallback = jest.fn(); + const observer = new MutationObserver(observerCallback); + observer.observe(node, {childList: true, subtree: true}); + + // Does not report anything initially + expect(observerCallback).not.toHaveBeenCalled(); + + let maybeNode111; + + ReactNativeTester.runTask(() => { + root.render( + + + { + maybeNode111 = receivedGrandchildNode; + }} + /> + + , + ); + }); + + const node111 = ensureReactNativeElement(maybeNode111); + + expect(observerCallback).toHaveBeenCalledTimes(1); + const firstCall = observerCallback.mock.lastCall; + const firstRecords = ensureMutationRecordArray(firstCall[0]); + expect(firstRecords.length).toBe(1); + expect([...firstRecords[0].addedNodes]).toEqual([node111]); + expect([...firstRecords[0].removedNodes]).toEqual([]); + + ReactNativeTester.runTask(() => { + root.render( + + + , + ); + }); + + expect(observerCallback).toHaveBeenCalledTimes(2); + const secondRecords = ensureMutationRecordArray( + observerCallback.mock.lastCall[0], + ); + expect(secondRecords.length).toBe(1); + expect([...secondRecords[0].addedNodes]).toEqual([]); + expect([...secondRecords[0].removedNodes]).toEqual([node111]); + }); + + it('should report changes in different parts of the subtree as separate entries (subtree = true)', () => { + let maybeNode; + + const root = ReactNativeTester.createRoot(); + ReactNativeTester.runTask(() => { + root.render( + { + maybeNode = receivedNode; + }}> + + + , + ); + }); + + const node = ensureReactNativeElement(maybeNode); + + const observerCallback = jest.fn(); + const observer = new MutationObserver(observerCallback); + observer.observe(node, {childList: true, subtree: true}); + + // Does not report anything initially + expect(observerCallback).not.toHaveBeenCalled(); + + let maybeNode111, maybeNode121; + + ReactNativeTester.runTask(() => { + root.render( + + + { + maybeNode111 = receivedGrandchildNode; + }} + /> + + + { + maybeNode121 = receivedGrandchildNode; + }} + /> + + , + ); + }); + + const node111 = ensureReactNativeElement(maybeNode111); + const node121 = ensureReactNativeElement(maybeNode121); + + expect(observerCallback).toHaveBeenCalledTimes(1); + const firstCall = observerCallback.mock.lastCall; + const firstRecords = ensureMutationRecordArray(firstCall[0]); + expect(firstRecords.length).toBe(2); + expect([...firstRecords[0].addedNodes]).toEqual([node111]); + expect([...firstRecords[0].removedNodes]).toEqual([]); + expect([...firstRecords[1].addedNodes]).toEqual([node121]); + expect([...firstRecords[1].removedNodes]).toEqual([]); + + ReactNativeTester.runTask(() => { + root.render( + + + + , + ); + }); + + expect(observerCallback).toHaveBeenCalledTimes(2); + const secondCall = observerCallback.mock.lastCall; + const secondRecords = ensureMutationRecordArray(secondCall[0]); + expect(secondRecords.length).toBe(2); + expect([...secondRecords[0].addedNodes]).toEqual([]); + expect([...secondRecords[0].removedNodes]).toEqual([node111]); + expect([...secondRecords[1].addedNodes]).toEqual([]); + expect([...secondRecords[1].removedNodes]).toEqual([node121]); + + expect(secondCall[1]).toBe(observer); + }); + + describe('multiple observers', () => { + it('should report changes to multiple observers observing different subtrees', () => { + let maybeNode1; + let maybeNode2; + + const root = ReactNativeTester.createRoot(); + ReactNativeTester.runTask(() => { + root.render( + <> + { + maybeNode1 = receivedNode; + }} + /> + { + maybeNode2 = receivedNode; + }} + /> + , + ); + }); + + const node1 = ensureReactNativeElement(maybeNode1); + const node2 = ensureReactNativeElement(maybeNode2); + + const observerCallback1 = jest.fn(); + const observer1 = new MutationObserver(observerCallback1); + observer1.observe(node1, {childList: true, subtree: true}); + + const observerCallback2 = jest.fn(); + const observer2 = new MutationObserver(observerCallback2); + observer2.observe(node2, {childList: true, subtree: true}); + + // Does not report anything initially + expect(observerCallback1).not.toHaveBeenCalled(); + expect(observerCallback2).not.toHaveBeenCalled(); + + let maybeChildNode11; + let maybeChildNode21; + + ReactNativeTester.runTask(() => { + root.render( + <> + + { + maybeChildNode11 = receivedNode; + }} + /> + + + { + maybeChildNode21 = receivedNode; + }} + /> + + , + ); + }); + + const childNode11 = ensureReactNativeElement(maybeChildNode11); + const childNode21 = ensureReactNativeElement(maybeChildNode21); + + expect(observerCallback1).toHaveBeenCalledTimes(1); + const observer1Records1 = ensureMutationRecordArray( + observerCallback1.mock.lastCall[0], + ); + expect(observer1Records1.length).toBe(1); + expect([...observer1Records1[0].addedNodes]).toEqual([childNode11]); + expect([...observer1Records1[0].removedNodes]).toEqual([]); + + expect(observerCallback2).toHaveBeenCalledTimes(1); + const observer2Records1 = ensureMutationRecordArray( + observerCallback2.mock.lastCall[0], + ); + expect(observer2Records1.length).toBe(1); + expect([...observer2Records1[0].addedNodes]).toEqual([childNode21]); + expect([...observer2Records1[0].removedNodes]).toEqual([]); + + ReactNativeTester.runTask(() => { + root.render( + <> + + + , + ); + }); + + expect(observerCallback1).toHaveBeenCalledTimes(2); + const observer1Records2 = ensureMutationRecordArray( + observerCallback1.mock.lastCall[0], + ); + expect(observer1Records2.length).toBe(1); + expect([...observer1Records2[0].addedNodes]).toEqual([]); + expect([...observer1Records2[0].removedNodes]).toEqual([childNode11]); + + expect(observerCallback2).toHaveBeenCalledTimes(2); + const observer2Records2 = ensureMutationRecordArray( + observerCallback2.mock.lastCall[0], + ); + expect(observer2Records2.length).toBe(1); + expect([...observer2Records2[0].addedNodes]).toEqual([]); + expect([...observer2Records2[0].removedNodes]).toEqual([childNode21]); + }); + + it('should report changes to multiple observers observing the same subtree', () => { + let maybeNode1; + let maybeNode2; + + const root = ReactNativeTester.createRoot(); + ReactNativeTester.runTask(() => { + root.render( + { + maybeNode1 = receivedNode; + }}> + { + maybeNode2 = receivedNode; + }} + /> + , + ); + }); + + const node1 = ensureReactNativeElement(maybeNode1); + const node2 = ensureReactNativeElement(maybeNode2); + + const observerCallback1 = jest.fn(); + const observer1 = new MutationObserver(observerCallback1); + observer1.observe(node1, {childList: true, subtree: true}); + + const observerCallback2 = jest.fn(); + const observer2 = new MutationObserver(observerCallback2); + observer2.observe(node2, {childList: true, subtree: true}); + + // Does not report anything initially + expect(observerCallback1).not.toHaveBeenCalled(); + expect(observerCallback2).not.toHaveBeenCalled(); + + let maybeChildNode111; + + ReactNativeTester.runTask(() => { + root.render( + + + { + maybeChildNode111 = receivedNode; + }} + /> + + , + ); + }); + + const childNode111 = ensureReactNativeElement(maybeChildNode111); + + expect(observerCallback1).toHaveBeenCalledTimes(1); + const observer1Records1 = ensureMutationRecordArray( + observerCallback1.mock.lastCall[0], + ); + expect(observer1Records1.length).toBe(1); + expect([...observer1Records1[0].addedNodes]).toEqual([childNode111]); + expect([...observer1Records1[0].removedNodes]).toEqual([]); + expect(observerCallback2).toHaveBeenCalledTimes(1); + const observer2Records1 = ensureMutationRecordArray( + observerCallback2.mock.lastCall[0], + ); + expect(observer2Records1.length).toBe(1); + expect([...observer2Records1[0].addedNodes]).toEqual([childNode111]); + expect([...observer2Records1[0].removedNodes]).toEqual([]); + + ReactNativeTester.runTask(() => { + root.render( + <> + + + + , + ); + }); + + expect(observerCallback1).toHaveBeenCalledTimes(2); + const observer1Records2 = ensureMutationRecordArray( + observerCallback1.mock.lastCall[0], + ); + expect(observer1Records2.length).toBe(1); + expect([...observer1Records2[0].addedNodes]).toEqual([]); + expect([...observer1Records2[0].removedNodes]).toEqual([childNode111]); + + expect(observerCallback2).toHaveBeenCalledTimes(2); + const observer2Records2 = ensureMutationRecordArray( + observerCallback2.mock.lastCall[0], + ); + expect(observer2Records2.length).toBe(1); + expect([...observer2Records2[0].addedNodes]).toEqual([]); + expect([...observer2Records2[0].removedNodes]).toEqual([childNode111]); + }); + }); + + describe('multiple observed nodes in the same observer', () => { + it('should report changes in disjoint observations', () => { + let maybeNode1; + let maybeNode2; + + const root = ReactNativeTester.createRoot(); + ReactNativeTester.runTask(() => { + root.render( + <> + { + maybeNode1 = receivedNode; + }} + /> + { + maybeNode2 = receivedNode; + }} + /> + , + ); + }); + + const node1 = ensureReactNativeElement(maybeNode1); + const node2 = ensureReactNativeElement(maybeNode2); + + const observerCallback = jest.fn(); + const observer = new MutationObserver(observerCallback); + observer.observe(node1, {childList: true, subtree: true}); + observer.observe(node2, {childList: true, subtree: true}); + + // Does not report anything initially + expect(observerCallback).not.toHaveBeenCalled(); + + let maybeChildNode11; + let maybeChildNode21; + + ReactNativeTester.runTask(() => { + root.render( + <> + + { + maybeChildNode11 = receivedNode; + }} + /> + + + { + maybeChildNode21 = receivedNode; + }} + /> + + , + ); + }); + + const childNode11 = ensureReactNativeElement(maybeChildNode11); + const childNode21 = ensureReactNativeElement(maybeChildNode21); + + expect(observerCallback).toHaveBeenCalledTimes(1); + const records = ensureMutationRecordArray( + observerCallback.mock.lastCall[0], + ); + expect(records.length).toBe(2); + expect([...records[0].addedNodes]).toEqual([childNode11]); + expect([...records[0].removedNodes]).toEqual([]); + expect([...records[1].addedNodes]).toEqual([childNode21]); + expect([...records[1].removedNodes]).toEqual([]); + + ReactNativeTester.runTask(() => { + root.render( + <> + + + , + ); + }); + + expect(observerCallback).toHaveBeenCalledTimes(2); + const records2 = ensureMutationRecordArray( + observerCallback.mock.lastCall[0], + ); + expect(records2.length).toBe(2); + expect([...records2[0].addedNodes]).toEqual([]); + expect([...records2[0].removedNodes]).toEqual([childNode11]); + expect([...records2[1].addedNodes]).toEqual([]); + expect([...records2[1].removedNodes]).toEqual([childNode21]); + }); + + it('should report changes in joint observations', () => { + let maybeNode1; + let maybeNode11; + + const root = ReactNativeTester.createRoot(); + ReactNativeTester.runTask(() => { + root.render( + { + maybeNode1 = receivedNode; + }}> + { + maybeNode11 = receivedNode; + }} + /> + , + ); + }); + + const node1 = ensureReactNativeElement(maybeNode1); + const node11 = ensureReactNativeElement(maybeNode11); + + const observerCallback = jest.fn(); + const observer = new MutationObserver(observerCallback); + observer.observe(node1, {childList: true, subtree: true}); + observer.observe(node11, {childList: true, subtree: true}); + + // Does not report anything initially + expect(observerCallback).not.toHaveBeenCalled(); + + let maybeChildNode111; + + ReactNativeTester.runTask(() => { + root.render( + + + { + maybeChildNode111 = receivedNode; + }} + /> + + , + ); + }); + + const childNode111 = ensureReactNativeElement(maybeChildNode111); + + expect(observerCallback).toHaveBeenCalledTimes(1); + const records = ensureMutationRecordArray( + observerCallback.mock.lastCall[0], + ); + expect(records.length).toBe(1); + expect([...records[0].addedNodes]).toEqual([childNode111]); + expect([...records[0].removedNodes]).toEqual([]); + + ReactNativeTester.runTask(() => { + root.render( + + + , + ); + }); + + expect(observerCallback).toHaveBeenCalledTimes(2); + const records2 = ensureMutationRecordArray( + observerCallback.mock.lastCall[0], + ); + expect(records2.length).toBe(1); + expect([...records2[0].addedNodes]).toEqual([]); + expect([...records2[0].removedNodes]).toEqual([childNode111]); + }); + }); + }); + + describe('disconnect()', () => { + it('should stop observing targets', () => { + let maybeObservedNode; + + const root = ReactNativeTester.createRoot(); + ReactNativeTester.runTask(() => { + root.render( + { + maybeObservedNode = ensureReactNativeElement(receivedNode); + }} + />, + ); + }); + + const observedNode = nullthrows(maybeObservedNode); + + const observerCallback = jest.fn(); + const observer = new MutationObserver(observerCallback); + observer.observe(observedNode, {childList: true}); + + // Does not report anything initially + expect(observerCallback).not.toHaveBeenCalled(); + + ReactNativeTester.runTask(() => { + root.render( + + + + , + ); + }); + + expect(observerCallback).toHaveBeenCalledTimes(1); + + observer.disconnect(); + + ReactNativeTester.runTask(() => { + root.render( + + + , + ); + }); + + expect(observerCallback).toHaveBeenCalledTimes(1); + }); + + it('should correctly unobserve targets that are disconnected after observing', () => { + let maybeObservedNode; + + const root = ReactNativeTester.createRoot(); + ReactNativeTester.runTask(() => { + root.render( + { + maybeObservedNode = ensureReactNativeElement(receivedNode); + }} + />, + ); + }); + + const observedNode = nullthrows(maybeObservedNode); + + const observerCallback = jest.fn(); + const observer = new MutationObserver(observerCallback); + observer.observe(observedNode, {childList: true}); + + ReactNativeTester.runTask(() => { + root.render(<>); + }); + + expect(observedNode.isConnected).toBe(false); + + expect(() => { + observer.disconnect(); + }).not.toThrow(); + }); + + it('should correctly unobserve targets that are disconnected before observing', () => { + let maybeObservedNode; + + const root = ReactNativeTester.createRoot(); + ReactNativeTester.runTask(() => { + root.render( + { + maybeObservedNode = ensureReactNativeElement(receivedNode); + }} + />, + ); + }); + + const observedNode = nullthrows(maybeObservedNode); + + ReactNativeTester.runTask(() => { + root.render(<>); + }); + + expect(observedNode.isConnected).toBe(false); + + const observerCallback = jest.fn(); + const observer = new MutationObserver(observerCallback); + observer.observe(observedNode, {childList: true}); + + expect(() => { + observer.disconnect(); + }).not.toThrow(); + }); + }); +}); diff --git a/packages/react-native/src/private/webapis/mutationobserver/__tests__/setUpFeatureFlags.js b/packages/react-native/src/private/webapis/mutationobserver/__tests__/setUpFeatureFlags.js new file mode 100644 index 00000000000..5af0f6f71dd --- /dev/null +++ b/packages/react-native/src/private/webapis/mutationobserver/__tests__/setUpFeatureFlags.js @@ -0,0 +1,16 @@ +/** + * 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 ReactNativeFeatureFlags from '../../../featureflags/ReactNativeFeatureFlags'; + +ReactNativeFeatureFlags.override({ + enableAccessToHostTreeInFabric: () => true, +});