diff --git a/jest.config.js b/jest.config.js index 3fb2cd09000..5a36fd90a5e 100644 --- a/jest.config.js +++ b/jest.config.js @@ -37,6 +37,7 @@ module.exports = { '/node_modules/', '/packages/react-native/sdks', '/packages/react-native/Libraries/Renderer', + '/packages/react-native-test-renderer/src', '/packages/react-native/sdks/hermes/', ...PODS_LOCATIONS, ], diff --git a/packages/react-native-test-renderer/babel.config.js b/packages/react-native-test-renderer/babel.config.js new file mode 100644 index 00000000000..7cd251d7825 --- /dev/null +++ b/packages/react-native-test-renderer/babel.config.js @@ -0,0 +1,17 @@ +/** + * 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 + */ + +module.exports = { + presets: [ + ['@babel/preset-env', {targets: {node: 'current'}}], + '@babel/preset-flow', + ], + plugins: ['@babel/plugin-transform-react-jsx'], +}; diff --git a/packages/react-native-test-renderer/jest.config.js b/packages/react-native-test-renderer/jest.config.js new file mode 100644 index 00000000000..d1c17f8cc89 --- /dev/null +++ b/packages/react-native-test-renderer/jest.config.js @@ -0,0 +1,25 @@ +/** + * 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. + * + * @format + */ + +'use strict'; + +module.exports = { + haste: { + defaultPlatform: 'ios', + platforms: ['android', 'ios', 'native'], + }, + transform: { + '^.+\\.(js|ts|tsx)$': 'babel-jest', + }, + transformIgnorePatterns: [ + 'node_modules/(?!((jest-)?react-native|@react-native(-community)?)/)', + ], + setupFilesAfterEnv: ['./src/jest/setup-files-after-env'], + testEnvironment: './src/jest/environment', +}; diff --git a/packages/react-native-test-renderer/package.json b/packages/react-native-test-renderer/package.json new file mode 100644 index 00000000000..5610c999a83 --- /dev/null +++ b/packages/react-native-test-renderer/package.json @@ -0,0 +1,18 @@ +{ + "name": "@react-native/test-renderer", + "private": true, + "version": "0.77.0-main", + "description": "A Test rendering library for React Native", + "license": "MIT", + "devDependencies": { + "@babel/core": "^7.25.2", + "@babel/plugin-transform-react-jsx": "^7.25.2", + "@babel/preset-env": "^7.25.3", + "@babel/preset-flow": "^7.20.0" + }, + "dependencies": {}, + "main": "src/index.js", + "peerDependencies": { + "jest": "^29.7.0" + } +} diff --git a/packages/react-native-test-renderer/src/index.js b/packages/react-native-test-renderer/src/index.js new file mode 100644 index 00000000000..4317334b562 --- /dev/null +++ b/packages/react-native-test-renderer/src/index.js @@ -0,0 +1,14 @@ +/** + * 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 + * @format + * @oncall react_native + */ + +export {render} from './renderer/index.js'; + +export {ReactNativeEnvironment} from './jest/environment.js'; diff --git a/packages/react-native-test-renderer/src/jest/environment.js b/packages/react-native-test-renderer/src/jest/environment.js new file mode 100644 index 00000000000..9f70431cb74 --- /dev/null +++ b/packages/react-native-test-renderer/src/jest/environment.js @@ -0,0 +1,74 @@ +/** + * 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. + * + * @format + */ + +'use strict'; + +const NodeEnv = require('jest-environment-node').TestEnvironment; + +module.exports = class ReactNativeEnvironment extends NodeEnv { + customExportConditions = ['require', 'react-native']; + + constructor(config, context) { + super(config, context); + } + + async setup() { + await super.setup(); + this.assignGlobals(); + this.initializeTurboModuleRegistry(); + } + + assignGlobals() { + Object.defineProperties(this.global, { + __DEV__: { + configurable: true, + enumerable: true, + value: true, + writable: true, + }, + }); + this.global.IS_REACT_ACT_ENVIRONMENT = true; + } + + initializeTurboModuleRegistry() { + const dims = {width: 100, height: 100, scale: 1, fontScale: 1}; + const DIMS = { + screen: { + ...dims, + }, + window: { + ...dims, + }, + }; + this.global.nativeModuleProxy = name => ({})[name]; + this.global.__turboModuleProxy = name => + ({ + SourceCode: {getConstants: () => ({scriptURL: ''})}, + WebSocketModule: {connect: () => {}}, + FileReaderModule: {}, + AppState: {getConstants: () => ({}), getCurrentAppState: () => ({})}, + DeviceInfo: {getConstants: () => ({Dimensions: DIMS})}, + UIManager: {getConstants: () => ({})}, + Timing: {}, + DevSettings: {}, + PlatformConstants: { + getConstants: () => ({reactNativeVersion: '1000.0.0'}), + }, + Networking: {}, + ImageLoader: {}, + NativePerformanceCxx: {}, + LogBox: {}, + SettingsManager: { + getConstants: () => ({settings: {}}), + }, + LinkingManager: {}, + I18n: {getConstants: () => ({})}, + })[name]; + } +}; diff --git a/packages/react-native-test-renderer/src/jest/setup-files-after-env.js b/packages/react-native-test-renderer/src/jest/setup-files-after-env.js new file mode 100644 index 00000000000..6dc49eedab9 --- /dev/null +++ b/packages/react-native-test-renderer/src/jest/setup-files-after-env.js @@ -0,0 +1,222 @@ +/** + * 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. + * + * @format + */ + +'use strict'; + +jest.requireActual('@react-native/js-polyfills/error-guard'); + +jest + .mock('react-native/Libraries/ReactNative/UIManager', () => ({ + AndroidViewPager: { + Commands: { + setPage: jest.fn(), + setPageWithoutAnimation: jest.fn(), + }, + }, + blur: jest.fn(), + createView: jest.fn(), + customBubblingEventTypes: {}, + customDirectEventTypes: {}, + getConstants: () => ({ + ViewManagerNames: [], + }), + getDefaultEventTypes: jest.fn(), + dispatchViewManagerCommand: jest.fn(), + focus: jest.fn(), + getViewManagerConfig: jest.fn(name => { + if (name === 'AndroidDrawerLayout') { + return { + Constants: { + DrawerPosition: { + Left: 10, + }, + }, + }; + } + + return {NativeProps: {}}; + }), + hasViewManagerConfig: jest.fn(name => { + return name === 'AndroidDrawerLayout'; + }), + measure: jest.fn(), + manageChildren: jest.fn(), + removeSubviewsFromContainerWithID: jest.fn(), + replaceExistingNonRootView: jest.fn(), + setChildren: jest.fn(), + updateView: jest.fn(), + AndroidDrawerLayout: { + Constants: { + DrawerPosition: { + Left: 10, + }, + }, + }, + AndroidTextInput: { + Commands: {}, + }, + ScrollView: { + Constants: {}, + }, + View: { + Constants: {}, + }, + })) + // Mock modules defined by the native layer (ex: Objective-C, Java) + .mock('react-native/Libraries/BatchedBridge/NativeModules', () => ({ + AlertManager: { + alertWithArgs: jest.fn(), + }, + AsyncLocalStorage: { + multiGet: jest.fn((keys, callback) => + process.nextTick(() => callback(null, [])), + ), + multiSet: jest.fn((entries, callback) => + process.nextTick(() => callback(null)), + ), + multiRemove: jest.fn((keys, callback) => + process.nextTick(() => callback(null)), + ), + multiMerge: jest.fn((entries, callback) => + process.nextTick(() => callback(null)), + ), + clear: jest.fn(callback => process.nextTick(() => callback(null))), + getAllKeys: jest.fn(callback => + process.nextTick(() => callback(null, [])), + ), + }, + DeviceInfo: { + getConstants() { + return { + Dimensions: { + window: { + fontScale: 2, + height: 1334, + scale: 2, + width: 750, + }, + screen: { + fontScale: 2, + height: 1334, + scale: 2, + width: 750, + }, + }, + }; + }, + }, + DevSettings: { + addMenuItem: jest.fn(), + reload: jest.fn(), + }, + ImageLoader: { + getSize: jest.fn(url => Promise.resolve([320, 240])), + prefetchImage: jest.fn(), + }, + ImageViewManager: { + getSize: jest.fn((uri, success) => + process.nextTick(() => success(320, 240)), + ), + prefetchImage: jest.fn(), + }, + KeyboardObserver: { + addListener: jest.fn(), + removeListeners: jest.fn(), + }, + Networking: { + sendRequest: jest.fn(), + abortRequest: jest.fn(), + addListener: jest.fn(), + removeListeners: jest.fn(), + }, + PlatformConstants: { + getConstants() { + return { + reactNativeVersion: { + major: 1000, + minor: 0, + patch: 0, + }, + }; + }, + }, + PushNotificationManager: { + presentLocalNotification: jest.fn(), + scheduleLocalNotification: jest.fn(), + cancelAllLocalNotifications: jest.fn(), + removeAllDeliveredNotifications: jest.fn(), + getDeliveredNotifications: jest.fn(callback => + process.nextTick(() => []), + ), + removeDeliveredNotifications: jest.fn(), + setApplicationIconBadgeNumber: jest.fn(), + getApplicationIconBadgeNumber: jest.fn(callback => + process.nextTick(() => callback(0)), + ), + cancelLocalNotifications: jest.fn(), + getScheduledLocalNotifications: jest.fn(callback => + process.nextTick(() => callback()), + ), + requestPermissions: jest.fn(() => + Promise.resolve({alert: true, badge: true, sound: true}), + ), + abandonPermissions: jest.fn(), + checkPermissions: jest.fn(callback => + process.nextTick(() => + callback({alert: true, badge: true, sound: true}), + ), + ), + getInitialNotification: jest.fn(() => Promise.resolve(null)), + addListener: jest.fn(), + removeListeners: jest.fn(), + }, + StatusBarManager: { + setColor: jest.fn(), + setStyle: jest.fn(), + setHidden: jest.fn(), + setNetworkActivityIndicatorVisible: jest.fn(), + setBackgroundColor: jest.fn(), + setTranslucent: jest.fn(), + getConstants: () => ({ + HEIGHT: 42, + }), + }, + Timing: { + createTimer: jest.fn(), + deleteTimer: jest.fn(), + }, + UIManager: {}, + BlobModule: { + getConstants: () => ({BLOB_URI_SCHEME: 'content', BLOB_URI_HOST: null}), + addNetworkingHandler: jest.fn(), + enableBlobSupport: jest.fn(), + disableBlobSupport: jest.fn(), + createFromParts: jest.fn(), + sendBlob: jest.fn(), + release: jest.fn(), + }, + WebSocketModule: { + connect: jest.fn(), + send: jest.fn(), + sendBinary: jest.fn(), + ping: jest.fn(), + close: jest.fn(), + addListener: jest.fn(), + removeListeners: jest.fn(), + }, + I18nManager: { + allowRTL: jest.fn(), + forceRTL: jest.fn(), + swapLeftAndRightInRTL: jest.fn(), + getConstants: () => ({ + isRTL: false, + doLeftAndRightSwapInRTL: true, + }), + }, + })); diff --git a/packages/react-native-test-renderer/src/renderer/__tests__/__snapshots__/render-test.js.snap b/packages/react-native-test-renderer/src/renderer/__tests__/__snapshots__/render-test.js.snap new file mode 100644 index 00000000000..edd8a0c422d --- /dev/null +++ b/packages/react-native-test-renderer/src/renderer/__tests__/__snapshots__/render-test.js.snap @@ -0,0 +1,39 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`render toJSON renders View props 1`] = ` + + + Hello + + + +`; + +exports[`render toJSON returns expected JSON output based on renderer component 1`] = ` + + + Hello + + + +`; diff --git a/packages/react-native-test-renderer/src/renderer/__tests__/render-test.js b/packages/react-native-test-renderer/src/renderer/__tests__/render-test.js new file mode 100644 index 00000000000..f1776daee9c --- /dev/null +++ b/packages/react-native-test-renderer/src/renderer/__tests__/render-test.js @@ -0,0 +1,63 @@ +/** + * 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. + * + * @format + * @flow + */ + +'use strict'; + +import * as ReactNativeTestRenderer from '../index'; +import * as React from 'react'; +import {Text, View} from 'react-native'; +import 'react-native/Libraries/Components/View/ViewNativeComponent'; + +function TestComponent() { + return ( + + Hello + + + ); +} + +function TestComponentWithProps() { + return ( + + Hello + + + ); +} + +describe('render', () => { + describe('toJSON', () => { + it('returns expected JSON output based on renderer component', () => { + const result = ReactNativeTestRenderer.render(); + expect(result.toJSON()).toMatchSnapshot(); + }); + + it('renders View props', () => { + const result = ReactNativeTestRenderer.render(); + expect(result.toJSON()).toMatchSnapshot(); + }); + }); + + describe('findAll', () => { + it('returns all nodes matching the predicate', () => { + const result = ReactNativeTestRenderer.render(); + const textNode = result.findAll(node => { + return node.props?.text === 'Hello'; + })[0]; + expect(textNode).not.toBeUndefined(); + + const viewNodes = result.findAll(node => { + return node.viewName === 'RCTView'; + }); + expect(viewNodes.length).toBe(2); + }); + }); +}); diff --git a/packages/react-native-test-renderer/src/renderer/index.js b/packages/react-native-test-renderer/src/renderer/index.js new file mode 100644 index 00000000000..a99ff9f45bb --- /dev/null +++ b/packages/react-native-test-renderer/src/renderer/index.js @@ -0,0 +1,114 @@ +/** + * 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. + * + * @format + * @flow + */ + +'use strict'; + +import * as FabricUIManager from 'react-native/Libraries/ReactNative/__mocks__/FabricUIManager'; +import ReactFabric from 'react-native/Libraries/Renderer/shims/ReactFabric'; +import {act} from 'react-test-renderer'; + +type FiberPartial = { + pendingProps: { + children: $ReadOnlyArray, + ... + }, + ... +}; + +type ReactNode = { + children: ?Array, + props: {text?: string | null, ...}, + viewName: string, + instanceHandle: FiberPartial, +}; + +type RenderedNodeJSON = { + type: string, + props: {[propName: string]: any, ...}, + children: null | Array, + $$typeof?: symbol, // Optional because we add it with defineProperty(). +}; +type RenderedJSON = RenderedNodeJSON | string; + +type RenderResult = { + toJSON: () => Array | RenderedJSON | null, + findAll: (predicate: (ReactNode) => boolean) => Array, +}; + +function buildRenderResult(rootNode: ReactNode): RenderResult { + return { + toJSON: () => toJSON(rootNode), + findAll: (predicate: ReactNode => boolean) => findAll(rootNode, predicate), + }; +} + +export function render(element: React.MixedElement): RenderResult { + const manager = FabricUIManager.getFabricUIManager(); + if (!manager) { + throw new Error('No FabricUIManager found'); + } + const containerTag = Math.round(Math.random() * 1000000); + act(() => { + ReactFabric.render(element, containerTag, () => {}, true); + }); + + // $FlowFixMe + const root: [ReactNode] = manager.getRoot(containerTag); + + if (root == null) { + throw new Error('No root found for containerTag ' + containerTag); + } + + return buildRenderResult(root[0]); +} + +function toJSON(node: ReactNode): RenderedJSON { + let renderedChildren = null; + if (node.children != null && node.children.length > 0) { + renderedChildren = node.children.map(c => toJSON(c)); + } + + if (node.viewName === 'RCTRawText') { + return node.props.text ?? ''; + } + + const {children: _children, ...props} = + node.instanceHandle?.pendingProps ?? {}; + const json: RenderedNodeJSON = { + type: node.viewName, + props, + children: renderedChildren, + }; + + Object.defineProperty(json, '$$typeof', { + value: Symbol.for('react.test.json'), + }); + + return json; +} + +function findAll( + node: ReactNode, + predicate: ReactNode => boolean, +): Array { + const results = []; + + if (predicate(node)) { + results.push(node); + } + + if (node.children != null && node.children.length > 0) { + for (const child of node.children) { + results.push(...findAll(child, predicate)); + } + } + + return results; +} diff --git a/packages/react-native/Libraries/ReactNative/ReactFabricPublicInstance/__tests__/ReactFabricPublicInstance-test.js b/packages/react-native/Libraries/ReactNative/ReactFabricPublicInstance/__tests__/ReactFabricPublicInstance-test.js new file mode 100644 index 00000000000..8041ccccb6e --- /dev/null +++ b/packages/react-native/Libraries/ReactNative/ReactFabricPublicInstance/__tests__/ReactFabricPublicInstance-test.js @@ -0,0 +1,307 @@ +/** + * 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 + */ + +// TODO(legacy-fake-timers): Fix these tests to work with modern timers. +jest.useFakeTimers({legacyFakeTimers: true}); + +import type {HostInstance} from '../../../Renderer/shims/ReactNativeTypes'; + +import * as React from 'react'; +import {act} from 'react-test-renderer'; + +const TextInputState = require('../../../Components/TextInput/TextInputState'); +const ReactFabric = require('../../../Renderer/shims/ReactFabric').default; +const ReactNativeViewConfigRegistry = require('../../../Renderer/shims/ReactNativeViewConfigRegistry'); +const FabricUIManager = require('../../FabricUIManager'); +const nullthrows = require('nullthrows'); + +const isWindows = process.platform === 'win32'; +const itif = (condition: boolean) => { + return condition ? it : it.skip; +}; + +jest.mock('../../FabricUIManager', () => + require('../../__mocks__/FabricUIManager'), +); + +jest.mock('../../../../src/private/webapis/dom/nodes/specs/NativeDOM', () => + require('../../../../src/private/webapis/dom/nodes/specs/__mocks__/NativeDOMMock'), +); + +/** + * Given a mocked function, get a correctly typed mock function that preserves + * the original function's type. + */ +function mockOf, TReturn>( + fn: (...args: TArguments) => TReturn, +): JestMockFn { + if (!jest.isMockFunction(fn)) { + throw new Error(`Function ${fn.name} is not a mock function`); + } + return (fn: $FlowFixMe); +} + +/** + * Renders a sequence of mock views as dictated by `keyLists`. The `keyLists` + * argument is an array of arrays which determines the number of render passes, + * how many views will be rendered in each pass, and what the keys are for each + * of the views. + * + * If an element in `keyLists` is null, the entire root will be unmounted. + * + * The return value is an array of arrays with the resulting refs from rendering + * each corresponding array of keys. + * + * If the corresponding array of keys is null, the returned element at that + * index will also be null. + */ +async function mockRenderKeys( + keyLists: Array>, +): Promise>> { + const mockContainerTag = 11; + const MockView = ReactNativeViewConfigRegistry.register( + 'RCTMockView', + () => ({ + validAttributes: {foo: true, style: {}}, + uiViewClassName: 'RCTMockView', + }), + ); + + const result: Array> = []; + for (let i = 0; i < keyLists.length; i++) { + const keyList = keyLists[i]; + if (Array.isArray(keyList)) { + const refs: Array = keyList.map(key => undefined); + await act(() => { + ReactFabric.render( + + {keyList.map((key, index) => ( + { + refs[index] = ((ref: $FlowFixMe): ?HostInstance); + }} + /> + ))} + , + mockContainerTag, + ); + }); + // Clone `refs` to ignore future passes. + result.push([...refs]); + continue; + } + if (keyList == null) { + await act(() => { + // $FlowFixMe[prop-missing] This actually exists in ReactFabric + ReactFabric.stopSurface(mockContainerTag); + }); + result.push(null); + continue; + } + throw new TypeError( + `Invalid 'keyLists' element of type ${typeof keyList}.`, + ); + } + + return result; +} + +[ + {enableAccessToHostTreeInFabric: false}, + {enableAccessToHostTreeInFabric: true}, +].forEach(flags => { + describe(`ReactFabricPublicInstance (ReactNativeFeatureFlags.enableAccessToHostTreeInFabric = ${String( + flags.enableAccessToHostTreeInFabric, + )})'`, () => { + beforeEach(() => { + jest.resetModules(); + // Installs the global `nativeFabricUIManager` pointing to the mock. + require('../../../ReactNative/__mocks__/FabricUIManager'); + jest.spyOn(TextInputState, 'blurTextInput'); + jest.spyOn(TextInputState, 'focusTextInput'); + + require('../../../../src/private/featureflags/ReactNativeFeatureFlags').override( + { + enableAccessToHostTreeInFabric: () => + flags.enableAccessToHostTreeInFabric, + }, + ); + }); + + describe('blur', () => { + test('blur() invokes TextInputState', async () => { + const result = await mockRenderKeys([['foo']]); + const fooRef = nullthrows(result?.[0]?.[0]); + + fooRef.blur(); + + expect(mockOf(TextInputState.blurTextInput).mock.calls).toEqual([ + [fooRef], + ]); + }); + }); + + describe('focus', () => { + test('focus() invokes TextInputState', async () => { + const result = await mockRenderKeys([['foo']]); + const fooRef = nullthrows(result?.[0]?.[0]); + + fooRef.focus(); + + expect(mockOf(TextInputState.focusTextInput).mock.calls).toEqual([ + [fooRef], + ]); + }); + }); + + describe('measure', () => { + itif(!isWindows)('component.measure(...) invokes callback', async () => { + const result = await mockRenderKeys([['foo']]); + const fooRef = nullthrows(result?.[0]?.[0]); + + const callback = jest.fn(); + fooRef.measure(callback); + + expect( + nullthrows(FabricUIManager.getFabricUIManager()).measure, + ).toHaveBeenCalledTimes(1); + expect(callback.mock.calls).toEqual([[10, 10, 100, 100, 0, 0]]); + }); + + itif(!isWindows)('unmounted.measure(...) does nothing', async () => { + const result = await mockRenderKeys([['foo'], null]); + const fooRef = nullthrows(result?.[0]?.[0]); + const callback = jest.fn(); + fooRef.measure(callback); + + expect( + nullthrows(FabricUIManager.getFabricUIManager()).measure, + ).not.toHaveBeenCalled(); + expect(callback).not.toHaveBeenCalled(); + }); + }); + + describe('measureInWindow', () => { + itif(!isWindows)( + 'component.measureInWindow(...) invokes callback', + async () => { + const result = await mockRenderKeys([['foo']]); + const fooRef = nullthrows(result?.[0]?.[0]); + + const callback = jest.fn(); + fooRef.measureInWindow(callback); + + expect( + nullthrows(FabricUIManager.getFabricUIManager()).measureInWindow, + ).toHaveBeenCalledTimes(1); + expect(callback.mock.calls).toEqual([[10, 10, 100, 100]]); + }, + ); + + itif(!isWindows)( + 'unmounted.measureInWindow(...) does nothing', + async () => { + const result = await mockRenderKeys([['foo'], null]); + const fooRef = nullthrows(result?.[0]?.[0]); + + const callback = jest.fn(); + fooRef.measureInWindow(callback); + + expect( + nullthrows(FabricUIManager.getFabricUIManager()).measureInWindow, + ).not.toHaveBeenCalled(); + expect(callback).not.toHaveBeenCalled(); + }, + ); + }); + + describe('measureLayout', () => { + itif(!isWindows)( + 'component.measureLayout(component, ...) invokes callback', + async () => { + const result = await mockRenderKeys([['foo', 'bar']]); + const fooRef = nullthrows(result?.[0]?.[0]); + const barRef = nullthrows(result?.[0]?.[1]); + + const successCallback = jest.fn(); + const failureCallback = jest.fn(); + fooRef.measureLayout(barRef, successCallback, failureCallback); + + expect( + nullthrows(FabricUIManager.getFabricUIManager()).measureLayout, + ).toHaveBeenCalledTimes(1); + expect(successCallback.mock.calls).toEqual([[1, 1, 100, 100]]); + }, + ); + + itif(!isWindows)( + 'unmounted.measureLayout(component, ...) does nothing', + async () => { + const result = await mockRenderKeys([ + ['foo', 'bar'], + ['foo', null], + ]); + const fooRef = nullthrows(result?.[0]?.[0]); + const barRef = nullthrows(result?.[0]?.[1]); + + const successCallback = jest.fn(); + const failureCallback = jest.fn(); + fooRef.measureLayout(barRef, successCallback, failureCallback); + + expect( + nullthrows(FabricUIManager.getFabricUIManager()).measureLayout, + ).not.toHaveBeenCalled(); + expect(successCallback).not.toHaveBeenCalled(); + }, + ); + + itif(!isWindows)( + 'component.measureLayout(unmounted, ...) does nothing', + async () => { + const result = await mockRenderKeys([ + ['foo', 'bar'], + [null, 'bar'], + ]); + const fooRef = nullthrows(result?.[0]?.[0]); + const barRef = nullthrows(result?.[0]?.[1]); + + const successCallback = jest.fn(); + const failureCallback = jest.fn(); + fooRef.measureLayout(barRef, successCallback, failureCallback); + + expect( + nullthrows(FabricUIManager.getFabricUIManager()).measureLayout, + ).not.toHaveBeenCalled(); + expect(successCallback).not.toHaveBeenCalled(); + }, + ); + + itif(!isWindows)( + 'unmounted.measureLayout(unmounted, ...) does nothing', + async () => { + const result = await mockRenderKeys([['foo', 'bar'], null]); + const fooRef = nullthrows(result?.[0]?.[0]); + const barRef = nullthrows(result?.[0]?.[1]); + + const successCallback = jest.fn(); + const failureCallback = jest.fn(); + fooRef.measureLayout(barRef, successCallback, failureCallback); + + expect( + nullthrows(FabricUIManager.getFabricUIManager()).measureLayout, + ).not.toHaveBeenCalled(); + expect(successCallback).not.toHaveBeenCalled(); + }, + ); + }); + }); +}); diff --git a/packages/react-native/Libraries/ReactNative/__mocks__/FabricUIManager.js b/packages/react-native/Libraries/ReactNative/__mocks__/FabricUIManager.js new file mode 100644 index 00000000000..1ab03a083c5 --- /dev/null +++ b/packages/react-native/Libraries/ReactNative/__mocks__/FabricUIManager.js @@ -0,0 +1,334 @@ +/** + * 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 { + InternalInstanceHandle, + LayoutAnimationConfig, + MeasureInWindowOnSuccessCallback, + MeasureLayoutOnSuccessCallback, + MeasureOnSuccessCallback, + Node, +} from '../../Renderer/shims/ReactNativeTypes'; +import type {RootTag} from '../../Types/RootTagTypes'; +import type { + NodeProps, + NodeSet, + Spec as FabricUIManager, +} from '../FabricUIManager'; + +import {createRootTag} from '../RootTag.js'; + +export type NodeMock = { + children: NodeSet, + instanceHandle: InternalInstanceHandle, + props: NodeProps, + reactTag: number, + rootTag: RootTag, + viewName: string, +}; + +export function fromNode(node: Node): NodeMock { + // $FlowExpectedError[incompatible-return] + return node; +} + +export function toNode(node: NodeMock): Node { + // $FlowExpectedError[incompatible-return] + return node; +} + +// Mock of the Native Hooks + +const roots: Map = new Map(); +const allocatedTags: Set = new Set(); + +export function ensureHostNode(node: Node): void { + if (node == null || typeof node !== 'object') { + throw new Error( + `Expected node to be an object. Got ${ + node === null ? 'null' : typeof node + } value`, + ); + } + + if (typeof node.viewName !== 'string') { + throw new Error( + `Expected node to be a host node. Got object with ${ + node.viewName === null ? 'null' : typeof node.viewName + } viewName`, + ); + } +} + +function getAncestorsInChildSet( + node: Node, + childSet: NodeSet, +): ?$ReadOnlyArray<[Node, number]> { + const rootNode = toNode({ + reactTag: 0, + rootTag: fromNode(node).rootTag, + viewName: 'RootNode', + // $FlowExpectedError + instanceHandle: null, + props: {}, + children: childSet, + }); + + let position = 0; + for (const child of childSet) { + const ancestors = getAncestors(child, node); + if (ancestors) { + return [[rootNode, position]].concat(ancestors); + } + position++; + } + + return null; +} + +export function getAncestorsInCurrentTree( + node: Node, +): ?$ReadOnlyArray<[Node, number]> { + const childSet = roots.get(fromNode(node).rootTag); + if (childSet == null) { + return null; + } + + return getAncestorsInChildSet(node, childSet); +} + +function getAncestors(root: Node, node: Node): ?$ReadOnlyArray<[Node, number]> { + if (fromNode(root).reactTag === fromNode(node).reactTag) { + return []; + } + + let position = 0; + for (const child of fromNode(root).children) { + const ancestors = getAncestors(child, node); + if (ancestors != null) { + return [[root, position]].concat(ancestors); + } + position++; + } + + return null; +} + +export function getNodeInChildSet(node: Node, childSet: NodeSet): ?Node { + const ancestors = getAncestorsInChildSet(node, childSet); + if (ancestors == null) { + return null; + } + + const [parent, position] = ancestors[ancestors.length - 1]; + const nodeInCurrentTree = fromNode(parent).children[position]; + return nodeInCurrentTree; +} + +export function getNodeInCurrentTree(node: Node): ?Node { + const childSet = roots.get(fromNode(node).rootTag); + if (childSet == null) { + return null; + } + + return getNodeInChildSet(node, childSet); +} + +interface IFabricUIManagerMock extends FabricUIManager { + getRoot(rootTag: RootTag | number): NodeSet; + __getInstanceHandleFromNode(node: Node): InternalInstanceHandle; + __addCommitHook(commitHook: UIManagerCommitHook): void; + __removeCommitHook(commitHook: UIManagerCommitHook): void; +} + +export interface UIManagerCommitHook { + shadowTreeWillCommit: ( + rootTag: RootTag, + oldChildSet: ?NodeSet, + newChildSet: NodeSet, + ) => void; +} + +const commitHooks: Set = new Set(); + +const FabricUIManagerMock: IFabricUIManagerMock = { + createNode: jest.fn( + ( + reactTag: number, + viewName: string, + rootTag: RootTag, + props: NodeProps, + instanceHandle: InternalInstanceHandle, + ): Node => { + if (allocatedTags.has(reactTag)) { + throw new Error(`Created two native views with tag ${reactTag}`); + } + + allocatedTags.add(reactTag); + return toNode({ + reactTag, + rootTag, + viewName, + instanceHandle, + props: props, + children: [], + }); + }, + ), + + cloneNode: jest.fn((node: Node): Node => { + return toNode({...fromNode(node)}); + }), + + cloneNodeWithNewChildren: jest.fn((node: Node): Node => { + return toNode({...fromNode(node), children: []}); + }), + + cloneNodeWithNewProps: jest.fn((node: Node, newProps: NodeProps): Node => { + return toNode({ + ...fromNode(node), + props: { + ...fromNode(node).props, + ...newProps, + }, + }); + }), + + cloneNodeWithNewChildrenAndProps: jest.fn( + (node: Node, newProps: NodeProps): Node => { + return toNode({ + ...fromNode(node), + children: [], + props: { + ...fromNode(node).props, + ...newProps, + }, + }); + }, + ), + + createChildSet: jest.fn((rootTag: RootTag): NodeSet => { + return []; + }), + + appendChild: jest.fn((parentNode: Node, child: Node): Node => { + // Although the signature returns a Node, React expects this to be mutating. + fromNode(parentNode).children.push(child); + return parentNode; + }), + + appendChildToSet: jest.fn((childSet: NodeSet, child: Node): void => { + childSet.push(child); + }), + + completeRoot: jest.fn((rootTag: RootTag, childSet: NodeSet): void => { + commitHooks.forEach(hook => + hook.shadowTreeWillCommit(rootTag, roots.get(rootTag), childSet), + ); + roots.set(rootTag, childSet); + }), + + measure: jest.fn((node: Node, callback: MeasureOnSuccessCallback): void => { + ensureHostNode(node); + + callback(10, 10, 100, 100, 0, 0); + }), + + measureInWindow: jest.fn( + (node: Node, callback: MeasureInWindowOnSuccessCallback): void => { + ensureHostNode(node); + + callback(10, 10, 100, 100); + }, + ), + + measureLayout: jest.fn( + ( + node: Node, + relativeNode: Node, + onFail: () => void, + onSuccess: MeasureLayoutOnSuccessCallback, + ): void => { + ensureHostNode(node); + ensureHostNode(relativeNode); + + onSuccess(1, 1, 100, 100); + }, + ), + + configureNextLayoutAnimation: jest.fn( + ( + config: LayoutAnimationConfig, + callback: () => void, // check what is returned here + errorCallback: () => void, + ): void => {}, + ), + + sendAccessibilityEvent: jest.fn((node: Node, eventType: string): void => {}), + + findShadowNodeByTag_DEPRECATED: jest.fn((reactTag: number): ?Node => {}), + + findNodeAtPoint: jest.fn( + ( + node: Node, + locationX: number, + locationY: number, + callback: (instanceHandle: ?InternalInstanceHandle) => void, + ): void => {}, + ), + + getBoundingClientRect: jest.fn( + ( + node: Node, + includeTransform: boolean, + ): ?[ + /* x:*/ number, + /* y:*/ number, + /* width:*/ number, + /* height:*/ number, + ] => {}, + ), + + setNativeProps: jest.fn((node: Node, newProps: NodeProps): void => {}), + + dispatchCommand: jest.fn( + (node: Node, commandName: string, args: Array): void => {}, + ), + + compareDocumentPosition: jest.fn((node: Node, otherNode: Node): number => 0), + + getRoot(containerTag: RootTag | number): NodeSet { + const tag = createRootTag(containerTag); + const root = roots.get(tag); + if (!root) { + throw new Error('No root found for containerTag ' + Number(tag)); + } + return root; + }, + + __getInstanceHandleFromNode(node: Node): InternalInstanceHandle { + return fromNode(node).instanceHandle; + }, + + __addCommitHook(commitHook: UIManagerCommitHook): void { + commitHooks.add(commitHook); + }, + + __removeCommitHook(commitHook: UIManagerCommitHook): void { + commitHooks.delete(commitHook); + }, +}; + +global.nativeFabricUIManager = FabricUIManagerMock; + +export function getFabricUIManager(): ?IFabricUIManagerMock { + return FabricUIManagerMock; +} diff --git a/packages/react-native/src/private/webapis/dom/nodes/specs/__mocks__/NativeDOMMock.js b/packages/react-native/src/private/webapis/dom/nodes/specs/__mocks__/NativeDOMMock.js new file mode 100644 index 00000000000..ab8eddc021f --- /dev/null +++ b/packages/react-native/src/private/webapis/dom/nodes/specs/__mocks__/NativeDOMMock.js @@ -0,0 +1,413 @@ +/** + * 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 { + InternalInstanceHandle, + Node, +} from '../../../../../../../Libraries/Renderer/shims/ReactNativeTypes'; +import type { + MeasureInWindowOnSuccessCallback, + MeasureLayoutOnSuccessCallback, + MeasureOnSuccessCallback, +} from '../NativeDOM'; +import typeof NativeDOM from '../NativeDOM'; + +import { + ensureHostNode, + fromNode, + getAncestorsInCurrentTree, + getNodeInCurrentTree, +} from '../../../../../../../Libraries/ReactNative/__mocks__/FabricUIManager'; + +function* dfs(node: ?Node): Iterator { + if (node == null) { + return; + } + + yield node; + + for (const child of fromNode(node).children) { + yield* dfs(child); + } +} + +function hasDisplayNone(node: Node): boolean { + const props = fromNode(node).props; + // Style is flattened when passed to native, so there's no style object. + // $FlowFixMe[prop-missing] + return props != null && props.display === 'none'; +} + +const NativeDOMMock: NativeDOM = { + getBoundingClientRect: jest.fn( + ( + node: Node, + includeTransform: boolean, + ): [ + /* x:*/ number, + /* y:*/ number, + /* width:*/ number, + /* height:*/ number, + ] => { + ensureHostNode(node); + + const nodeInCurrentTree = getNodeInCurrentTree(node); + const currentProps = + nodeInCurrentTree != null ? fromNode(nodeInCurrentTree).props : null; + if (currentProps == null) { + return [0, 0, 0, 0]; + } + + const boundingClientRectForTests: ?{ + x: number, + y: number, + width: number, + height: number, + } = + // $FlowExpectedError[prop-missing] + currentProps.__boundingClientRectForTests; + + if (boundingClientRectForTests == null) { + return [0, 0, 0, 0]; + } + + const {x, y, width, height} = boundingClientRectForTests; + return [x, y, width, height]; + }, + ), + + hasPointerCapture: jest.fn((node: Node, pointerId: number): boolean => false), + + setPointerCapture: jest.fn((node: Node, pointerId: number): void => {}), + + releasePointerCapture: jest.fn((node: Node, pointerId: number): void => {}), + + getParentNode: jest.fn((node: Node): ?InternalInstanceHandle => { + const ancestors = getAncestorsInCurrentTree(node); + if (ancestors == null || ancestors.length - 2 < 0) { + return null; + } + + const [parentOfParent, position] = ancestors[ancestors.length - 2]; + const parentInCurrentTree = fromNode(parentOfParent).children[position]; + return fromNode(parentInCurrentTree).instanceHandle; + }), + + getChildNodes: jest.fn( + (node: Node): $ReadOnlyArray => { + const nodeInCurrentTree = getNodeInCurrentTree(node); + + if (nodeInCurrentTree == null) { + return []; + } + + return fromNode(nodeInCurrentTree).children.map( + child => fromNode(child).instanceHandle, + ); + }, + ), + + isConnected: jest.fn((node: Node): boolean => { + return getNodeInCurrentTree(node) != null; + }), + + getTextContent: jest.fn((node: Node): string => { + const nodeInCurrentTree = getNodeInCurrentTree(node); + + let result = ''; + + if (nodeInCurrentTree == null) { + return result; + } + + for (const childNode of dfs(nodeInCurrentTree)) { + if (fromNode(childNode).viewName === 'RCTRawText') { + const props = fromNode(childNode).props; + // $FlowExpectedError[prop-missing] + const maybeString: ?string = props.text; + if (typeof maybeString === 'string') { + result += maybeString; + } + } + } + return result; + }), + + compareDocumentPosition: jest.fn((node: Node, otherNode: Node): number => { + /* eslint-disable no-bitwise */ + const ReadOnlyNode = require('../../ReadOnlyNode').default; + + // Quick check for node vs. itself + if (fromNode(node).reactTag === fromNode(otherNode).reactTag) { + return 0; + } + + if (fromNode(node).rootTag !== fromNode(otherNode).rootTag) { + return ReadOnlyNode.DOCUMENT_POSITION_DISCONNECTED; + } + + const ancestors = getAncestorsInCurrentTree(node); + if (ancestors == null) { + return ReadOnlyNode.DOCUMENT_POSITION_DISCONNECTED; + } + + const otherAncestors = getAncestorsInCurrentTree(otherNode); + if (otherAncestors == null) { + return ReadOnlyNode.DOCUMENT_POSITION_DISCONNECTED; + } + + // Consume all common ancestors + let i = 0; + while ( + i < ancestors.length && + i < otherAncestors.length && + ancestors[i][1] === otherAncestors[i][1] + ) { + i++; + } + + if (i === ancestors.length) { + return ( + ReadOnlyNode.DOCUMENT_POSITION_CONTAINED_BY | + ReadOnlyNode.DOCUMENT_POSITION_FOLLOWING + ); + } + + if (i === otherAncestors.length) { + return ( + ReadOnlyNode.DOCUMENT_POSITION_CONTAINS | + ReadOnlyNode.DOCUMENT_POSITION_PRECEDING + ); + } + + if (ancestors[i][1] > otherAncestors[i][1]) { + return ReadOnlyNode.DOCUMENT_POSITION_PRECEDING; + } + + return ReadOnlyNode.DOCUMENT_POSITION_FOLLOWING; + }), + + getOffset: jest.fn( + ( + node: Node, + ): [ + /* offsetParent: */ ?InternalInstanceHandle, + /* offsetTop: */ number, + /* offsetLeft: */ number, + ] => { + const ancestors = getAncestorsInCurrentTree(node); + if (ancestors == null) { + return [null, 0, 0]; + } + + const [parent, position] = ancestors[ancestors.length - 1]; + const nodeInCurrentTree = fromNode(parent).children[position]; + + const currentProps = + nodeInCurrentTree != null ? fromNode(nodeInCurrentTree).props : null; + if (currentProps == null || hasDisplayNone(nodeInCurrentTree)) { + return [null, 0, 0]; + } + + const offsetForTests: ?{ + top: number, + left: number, + } = + // $FlowExpectedError[prop-missing] + currentProps.__offsetForTests; + + if (offsetForTests == null) { + return [null, 0, 0]; + } + + let currentIndex = ancestors.length - 1; + while (currentIndex >= 0 && !hasDisplayNone(ancestors[currentIndex][0])) { + currentIndex--; + } + + if (currentIndex >= 0) { + // The node or one of its ancestors have display: none + return [null, 0, 0]; + } + + return [ + fromNode(parent).instanceHandle, + offsetForTests.top, + offsetForTests.left, + ]; + }, + ), + + getScrollPosition: jest.fn( + (node: Node): [/* scrollLeft: */ number, /* scrollTop: */ number] => { + ensureHostNode(node); + + const nodeInCurrentTree = getNodeInCurrentTree(node); + const currentProps = + nodeInCurrentTree != null ? fromNode(nodeInCurrentTree).props : null; + if (currentProps == null) { + return [0, 0]; + } + + const scrollForTests: ?{ + scrollLeft: number, + scrollTop: number, + ... + } = + // $FlowExpectedError[prop-missing] + currentProps.__scrollForTests; + + if (scrollForTests == null) { + return [0, 0]; + } + + const {scrollLeft, scrollTop} = scrollForTests; + return [scrollLeft, scrollTop]; + }, + ), + + getScrollSize: jest.fn( + (node: Node): [/* scrollLeft: */ number, /* scrollTop: */ number] => { + ensureHostNode(node); + + const nodeInCurrentTree = getNodeInCurrentTree(node); + const currentProps = + nodeInCurrentTree != null ? fromNode(nodeInCurrentTree).props : null; + if (currentProps == null) { + return [0, 0]; + } + + const scrollForTests: ?{ + scrollWidth: number, + scrollHeight: number, + ... + } = + // $FlowExpectedError[prop-missing] + currentProps.__scrollForTests; + + if (scrollForTests == null) { + return [0, 0]; + } + + const {scrollWidth, scrollHeight} = scrollForTests; + return [scrollWidth, scrollHeight]; + }, + ), + + getInnerSize: jest.fn( + (node: Node): [/* width: */ number, /* height: */ number] => { + ensureHostNode(node); + + const nodeInCurrentTree = getNodeInCurrentTree(node); + const currentProps = + nodeInCurrentTree != null ? fromNode(nodeInCurrentTree).props : null; + if (currentProps == null) { + return [0, 0]; + } + + const innerSizeForTests: ?{ + width: number, + height: number, + ... + } = + // $FlowExpectedError[prop-missing] + currentProps.__innerSizeForTests; + + if (innerSizeForTests == null) { + return [0, 0]; + } + + const {width, height} = innerSizeForTests; + return [width, height]; + }, + ), + + getBorderWidth: jest.fn( + ( + node: Node, + ): [ + /* topWidth: */ number, + /* rightWidth: */ number, + /* bottomWidth: */ number, + /* leftWidth: */ number, + ] => { + ensureHostNode(node); + + const nodeInCurrentTree = getNodeInCurrentTree(node); + const currentProps = + nodeInCurrentTree != null ? fromNode(nodeInCurrentTree).props : null; + if (currentProps == null) { + return [0, 0, 0, 0]; + } + + const borderSizeForTests: ?{ + topWidth?: number, + rightWidth?: number, + bottomWidth?: number, + leftWidth?: number, + ... + } = + // $FlowExpectedError[prop-missing] + currentProps.__borderSizeForTests; + + if (borderSizeForTests == null) { + return [0, 0, 0, 0]; + } + + const { + topWidth = 0, + rightWidth = 0, + bottomWidth = 0, + leftWidth = 0, + } = borderSizeForTests; + return [topWidth, rightWidth, bottomWidth, leftWidth]; + }, + ), + + getTagName: jest.fn((node: Node): string => { + ensureHostNode(node); + return 'RN:' + fromNode(node).viewName; + }), + + /** + * Legacy layout APIs + */ + + measure: jest.fn((node: Node, callback: MeasureOnSuccessCallback): void => { + ensureHostNode(node); + + callback(10, 10, 100, 100, 0, 0); + }), + + measureInWindow: jest.fn( + (node: Node, callback: MeasureInWindowOnSuccessCallback): void => { + ensureHostNode(node); + + callback(10, 10, 100, 100); + }, + ), + + measureLayout: jest.fn( + ( + node: Node, + relativeNode: Node, + onFail: () => void, + onSuccess: MeasureLayoutOnSuccessCallback, + ): void => { + ensureHostNode(node); + ensureHostNode(relativeNode); + + onSuccess(1, 1, 100, 100); + }, + ), +}; + +export default NativeDOMMock; diff --git a/packages/react-native/src/private/webapis/intersectionobserver/specs/__mocks__/NativeIntersectionObserver.js b/packages/react-native/src/private/webapis/intersectionobserver/specs/__mocks__/NativeIntersectionObserver.js new file mode 100644 index 00000000000..1c5e6abdf1f --- /dev/null +++ b/packages/react-native/src/private/webapis/intersectionobserver/specs/__mocks__/NativeIntersectionObserver.js @@ -0,0 +1,181 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import type ReactNativeElement from '../../../dom/nodes/ReactNativeElement'; +import type IntersectionObserver from '../../IntersectionObserver'; +import type { + NativeIntersectionObserverEntry, + NativeIntersectionObserverObserveOptions, + Spec, +} from '../NativeIntersectionObserver'; + +import {getFabricUIManager} from '../../../../../../Libraries/ReactNative/__mocks__/FabricUIManager'; +import {getShadowNode} from '../../../dom/nodes/ReadOnlyNode'; +import invariant from 'invariant'; +import nullthrows from 'nullthrows'; + +type ObserverState = { + thresholds: $ReadOnlyArray, + rootThresholds?: ?$ReadOnlyArray, + intersecting: boolean, + currentThreshold: ?number, + currentRootThreshold: ?number, +}; + +type Observation = { + ...NativeIntersectionObserverObserveOptions, + state: ObserverState, +}; + +let pendingRecords: Array = []; +let callback: ?() => void; +let observations: Array = []; + +const FabricUIManagerMock = nullthrows(getFabricUIManager()); + +function createRecordFromObservation( + observation: Observation, +): NativeIntersectionObserverEntry { + return { + intersectionObserverId: observation.intersectionObserverId, + targetInstanceHandle: FabricUIManagerMock.__getInstanceHandleFromNode( + // $FlowExpectedError[incompatible-call] + observation.targetShadowNode, + ), + targetRect: observation.state.intersecting ? [0, 0, 1, 1] : [20, 20, 1, 1], + rootRect: [0, 0, 10, 10], + intersectionRect: observation.state.intersecting ? [0, 0, 1, 1] : null, + isIntersectingAboveThresholds: observation.state.intersecting, + time: performance.now(), + }; +} + +function notifyIntersectionObservers(): void { + callback?.(); +} + +const NativeIntersectionObserverMock = { + observe: (options: NativeIntersectionObserverObserveOptions): void => { + invariant( + observations.find( + observation => + observation.intersectionObserverId === + options.intersectionObserverId && + observation.targetShadowNode === options.targetShadowNode, + ) == null, + 'unexpected duplicate call to observe', + ); + const observation = { + ...options, + state: { + thresholds: options.thresholds, + rootThresholds: options.rootThresholds, + intersecting: false, + currentThreshold: null, + currentRootThreshold: null, + }, + }; + observations.push(observation); + pendingRecords.push(createRecordFromObservation(observation)); + setImmediate(notifyIntersectionObservers); + }, + unobserve: ( + intersectionObserverId: number, + targetShadowNode: mixed, + ): void => { + const observationIndex = observations.findIndex( + observation => + observation.intersectionObserverId === intersectionObserverId && + observation.targetShadowNode === targetShadowNode, + ); + invariant( + observationIndex !== -1, + 'unexpected duplicate call to unobserve', + ); + observations.splice(observationIndex, 1); + + pendingRecords = pendingRecords.filter( + record => + record.intersectionObserverId !== intersectionObserverId || + record.targetInstanceHandle !== + FabricUIManagerMock.__getInstanceHandleFromNode( + // $FlowExpectedError[incompatible-call] + targetShadowNode, + ), + ); + }, + connect: (notifyIntersectionObserversCallback: () => void): void => { + invariant(callback == null, 'unexpected call to connect'); + invariant( + notifyIntersectionObserversCallback != null, + 'unexpected null notify intersection observers callback', + ); + callback = notifyIntersectionObserversCallback; + }, + disconnect: (): void => { + invariant(callback != null, 'unexpected call to disconnect'); + callback = null; + }, + takeRecords: (): $ReadOnlyArray => { + const currentRecords = pendingRecords; + pendingRecords = []; + return currentRecords; + }, + __forceTransitionForTests: ( + observer: IntersectionObserver, + target: ReactNativeElement, + ) => { + const targetShadowNode = getShadowNode(target); + const observation = observations.find( + obs => + obs.intersectionObserverId === observer.__getObserverID() && + obs.targetShadowNode === targetShadowNode, + ); + invariant( + observation != null, + 'cannot force transition on an unobserved target', + ); + if (observation.state.intersecting) { + observation.state.intersecting = false; + observation.state.currentThreshold = null; + observation.state.currentRootThreshold = null; + } else { + observation.state.intersecting = true; + observation.state.currentThreshold = observation.thresholds[0]; + observation.state.currentRootThreshold = + observation.rootThresholds != null + ? observation.rootThresholds[0] + : null; + } + pendingRecords.push(createRecordFromObservation(observation)); + setImmediate(notifyIntersectionObservers); + }, + __getObservationsForTests: ( + observer: IntersectionObserver, + ): Array<{targetShadowNode: mixed, thresholds: $ReadOnlyArray}> => { + const intersectionObserverId = observer.__getObserverID(); + return observations + .filter( + observation => + observation.intersectionObserverId === intersectionObserverId, + ) + .map(observation => ({ + targetShadowNode: observation.targetShadowNode, + thresholds: observation.thresholds, + })); + }, + __isConnected: (): boolean => { + return callback != null; + }, +}; + +(NativeIntersectionObserverMock: Spec); + +export default NativeIntersectionObserverMock; diff --git a/packages/react-native/src/private/webapis/mutationobserver/specs/__mocks__/NativeMutationObserver.js b/packages/react-native/src/private/webapis/mutationobserver/specs/__mocks__/NativeMutationObserver.js new file mode 100644 index 00000000000..0c6a93ae6c1 --- /dev/null +++ b/packages/react-native/src/private/webapis/mutationobserver/specs/__mocks__/NativeMutationObserver.js @@ -0,0 +1,327 @@ +/** + * 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 + */ + +/** + * This is a mock of `NativeMutationObserver` implementing the same logic as the + * native module and integrating with the existing mock for `FabricUIManager`. + * This allows us to test all the JavaScript code for IntersectionObserver in + * JavaScript as an integration test using only public APIs. + */ + +import type {NodeSet} from '../../../../../../Libraries/ReactNative/FabricUIManager'; +import type {RootTag} from '../../../../../../Libraries/ReactNative/RootTag'; +import type { + InternalInstanceHandle, + Node, +} from '../../../../../../Libraries/Renderer/shims/ReactNativeTypes'; +import type { + MutationObserverId, + NativeMutationObserverObserveOptions, + NativeMutationRecord, + Spec, +} from '../NativeMutationObserver'; + +import { + type NodeMock, + type UIManagerCommitHook, + fromNode, + getFabricUIManager, + getNodeInChildSet, +} from '../../../../../../Libraries/ReactNative/__mocks__/FabricUIManager'; +import ReadOnlyNode from '../../../dom/nodes/ReadOnlyNode'; +import invariant from 'invariant'; +import nullthrows from 'nullthrows'; + +let pendingRecords: Array = []; +let callback: ?() => void; +let getPublicInstance: ?(instanceHandle: InternalInstanceHandle) => mixed; +let observersByRootTag: Map< + RootTag, + Map, shallow: Set}>, +> = new Map(); + +const FabricUIManagerMock = nullthrows(getFabricUIManager()); + +function getMockDataFromShadowNode(node: mixed): NodeMock { + // $FlowExpectedError[incompatible-call] + return fromNode(node); +} + +function castToNode(node: mixed): Node { + // $FlowExpectedError[incompatible-return] + return node; +} + +const NativeMutationMock = { + observe: (options: NativeMutationObserverObserveOptions): void => { + const targetShadowNode = castToNode(options.targetShadowNode); + const rootTag = getMockDataFromShadowNode(options.targetShadowNode).rootTag; + + let observers = observersByRootTag.get(rootTag); + if (observers == null) { + observers = new Map(); + observersByRootTag.set(rootTag, observers); + } + let observations = observers.get(options.mutationObserverId); + if (observations == null) { + observations = {deep: new Set(), shallow: new Set()}; + observers.set(options.mutationObserverId, observations); + } + + const isTargetBeingObserved = + observations.deep.has(targetShadowNode) || + observations.shallow.has(targetShadowNode); + invariant(!isTargetBeingObserved, 'unexpected duplicate call to observe'); + + if (options.subtree) { + observations.deep.add(targetShadowNode); + } else { + observations.shallow.add(targetShadowNode); + } + }, + unobserve: (mutationObserverId: number, target: mixed): void => { + const targetShadowNode = castToNode(target); + + const observers = observersByRootTag.get( + getMockDataFromShadowNode(targetShadowNode).rootTag, + ); + const observations = observers?.get(mutationObserverId); + invariant(observations != null, 'unexpected call to unobserve'); + + const isTargetBeingObserved = + observations.deep.has(targetShadowNode) || + observations.shallow.has(targetShadowNode); + invariant(isTargetBeingObserved, 'unexpected call to unobserve'); + + observations.deep.delete(targetShadowNode); + observations.shallow.delete(targetShadowNode); + }, + connect: ( + notifyMutationObserversCallback: () => void, + getPublicInstanceFromInstanceHandle: ( + instanceHandle: InternalInstanceHandle, + ) => mixed, + ): void => { + invariant(callback == null, 'unexpected call to connect'); + callback = notifyMutationObserversCallback; + getPublicInstance = getPublicInstanceFromInstanceHandle; + FabricUIManagerMock.__addCommitHook(NativeMutationObserverCommitHook); + }, + disconnect: (): void => { + invariant(callback != null, 'unexpected call to disconnect'); + callback = null; + FabricUIManagerMock.__removeCommitHook(NativeMutationObserverCommitHook); + }, + takeRecords: (): $ReadOnlyArray => { + const currentRecords = pendingRecords; + pendingRecords = []; + return currentRecords; + }, +}; + +(NativeMutationMock: Spec); + +export default NativeMutationMock; + +const NativeMutationObserverCommitHook: UIManagerCommitHook = { + shadowTreeWillCommit: (rootTag, oldChildSet, newChildSet) => { + runMutationObservations(rootTag, oldChildSet, newChildSet); + }, +}; + +function runMutationObservations( + rootTag: RootTag, + oldChildSet: ?NodeSet, + newChildSet: NodeSet, +): void { + const observers = observersByRootTag.get(rootTag); + if (!observers) { + return; + } + + const newRecords: Array = []; + + for (const [mutationObserverId, observations] of observers) { + const processedNodes: Set = new Set(); + for (const targetShadowNode of observations.deep) { + runMutationObservation({ + mutationObserverId, + targetShadowNode, + subtree: true, + oldChildSet, + newChildSet, + newRecords, + processedNodes, + }); + } + for (const targetShadowNode of observations.shallow) { + runMutationObservation({ + mutationObserverId, + targetShadowNode, + subtree: false, + oldChildSet, + newChildSet, + newRecords, + processedNodes, + }); + } + } + + for (const record of newRecords) { + pendingRecords.push(record); + } + + notifyObserversIfNecessary(); +} + +function findNodeOfSameFamily(list: NodeSet, node: Node): ?Node { + for (const current of list) { + if (fromNode(current).reactTag === fromNode(node).reactTag) { + return current; + } + } + return; +} + +function recordMutations({ + mutationObserverId, + targetShadowNode, + subtree, + oldNode, + newNode, + newRecords, + processedNodes, +}: { + mutationObserverId: MutationObserverId, + targetShadowNode: Node, + subtree: boolean, + oldNode: Node, + newNode: Node, + newRecords: Array, + processedNodes: Set, +}): void { + // If the nodes are referentially equal, their children are also the same. + if (oldNode === newNode || processedNodes.has(newNode)) { + return; + } + + processedNodes.add(newNode); + + const oldChildren = fromNode(oldNode).children; + const newChildren = fromNode(newNode).children; + + const addedNodes = []; + const removedNodes = []; + + // Check for removed nodes (and equal nodes for further inspection later) + for (const oldChild of oldChildren) { + const newChild = findNodeOfSameFamily(newChildren, oldChild); + if (newChild == null) { + removedNodes.push(oldChild); + } else if (subtree) { + recordMutations({ + mutationObserverId, + targetShadowNode, + subtree, + oldNode: oldChild, + newNode: newChild, + newRecords, + processedNodes, + }); + } + } + + // Check for added nodes + for (const newChild of newChildren) { + const oldChild = findNodeOfSameFamily(oldChildren, newChild); + if (oldChild == null) { + addedNodes.push(newChild); + } + } + + if (addedNodes.length > 0 || removedNodes.length > 0) { + newRecords.push({ + mutationObserverId: mutationObserverId, + target: nullthrows(getPublicInstance)( + getMockDataFromShadowNode(targetShadowNode).instanceHandle, + ), + addedNodes: addedNodes.map(node => { + const readOnlyNode = nullthrows(getPublicInstance)( + fromNode(node).instanceHandle, + ); + invariant( + readOnlyNode instanceof ReadOnlyNode, + 'expected instance of ReadOnlyNode', + ); + return readOnlyNode; + }), + removedNodes: removedNodes.map(node => { + const readOnlyNode = nullthrows(getPublicInstance)( + fromNode(node).instanceHandle, + ); + invariant( + readOnlyNode instanceof ReadOnlyNode, + 'expected instance of ReadOnlyNode', + ); + return readOnlyNode; + }), + }); + } +} + +function runMutationObservation({ + mutationObserverId, + targetShadowNode, + subtree, + oldChildSet, + newChildSet, + newRecords, + processedNodes, +}: { + mutationObserverId: MutationObserverId, + targetShadowNode: Node, + subtree: boolean, + oldChildSet: ?NodeSet, + newChildSet: NodeSet, + newRecords: Array, + processedNodes: Set, +}): void { + if (!oldChildSet) { + return; + } + + const oldTargetShadowNode = getNodeInChildSet(targetShadowNode, oldChildSet); + if (oldTargetShadowNode == null) { + return; + } + + const newTargetShadowNode = getNodeInChildSet(targetShadowNode, newChildSet); + if (newTargetShadowNode == null) { + return; + } + + recordMutations({ + mutationObserverId, + targetShadowNode, + subtree, + oldNode: oldTargetShadowNode, + newNode: newTargetShadowNode, + newRecords, + processedNodes, + }); +} + +function notifyObserversIfNecessary(): void { + if (pendingRecords.length > 0) { + // We schedule these using regular tasks in native because microtasks are + // still not properly supported. + setTimeout(() => callback?.(), 0); + } +} diff --git a/yarn.lock b/yarn.lock index 7dc33cae17a..e424b377d27 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1051,7 +1051,7 @@ core-js-compat "^3.37.1" semver "^6.3.1" -"@babel/preset-flow@^7.13.13", "@babel/preset-flow@^7.24.7": +"@babel/preset-flow@^7.13.13", "@babel/preset-flow@^7.20.0", "@babel/preset-flow@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/preset-flow/-/preset-flow-7.24.7.tgz#eef5cb8e05e97a448fc50c16826f5612fe512c06" integrity sha512-NL3Lo0NorCU607zU3NwRyJbpaB6E3t0xtd3LfAQKDfkeX4/ggcDXvkmkW42QWT5owUeW/jAe4hn+2qvkV1IbfQ==