diff --git a/jest.config.js b/jest.config.js index 3a64c4abd9b440..b603f7fedbc6d4 100644 --- a/jest.config.js +++ b/jest.config.js @@ -28,7 +28,8 @@ module.exports = { testPathIgnorePatterns: [ '/node_modules/', '/packages/react-native/template', - '/packages/react-native/Libraries/Renderer', + '/packages/react-native/Libraries/Renderer/implementations', + '/packages/react-native/Libraries/Renderer/shims', '/packages/rn-tester/e2e', ], transformIgnorePatterns: ['node_modules/(?!@react-native/)'], diff --git a/packages/react-native/Libraries/ReactNative/FabricUIManager.js b/packages/react-native/Libraries/ReactNative/FabricUIManager.js index e37261a153bbd5..7bb264502150f0 100644 --- a/packages/react-native/Libraries/ReactNative/FabricUIManager.js +++ b/packages/react-native/Libraries/ReactNative/FabricUIManager.js @@ -15,14 +15,13 @@ import type { MeasureInWindowOnSuccessCallback, MeasureLayoutOnSuccessCallback, MeasureOnSuccessCallback, + Node, } from '../Renderer/shims/ReactNativeTypes'; import type {RootTag} from '../Types/RootTagTypes'; -// TODO: type these properly. -export opaque type Node = {...}; -type NodeSet = Array; -type NodeProps = {...}; -type InstanceHandle = {...}; +export type NodeSet = Array; +export type NodeProps = {...}; +export type InstanceHandle = {...}; export type Spec = {| +createNode: ( reactTag: number, 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 00000000000000..7f302694e233e4 --- /dev/null +++ b/packages/react-native/Libraries/ReactNative/__mocks__/FabricUIManager.js @@ -0,0 +1,139 @@ +/** + * 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 { + LayoutAnimationConfig, + MeasureInWindowOnSuccessCallback, + MeasureLayoutOnSuccessCallback, + MeasureOnSuccessCallback, + Node, +} from '../../Renderer/shims/ReactNativeTypes'; +import type {RootTag} from '../../Types/RootTagTypes'; +import type { + InstanceHandle, + NodeProps, + NodeSet, + Spec as FabricUIManager, +} from '../FabricUIManager'; + +type NodeMock = { + reactTag: number, + rootTag: RootTag, + props: NodeProps, + instanceHandle: InstanceHandle, + children: NodeSet, +}; + +function fromNode(node: Node): NodeMock { + // $FlowExpectedError[incompatible-return] + return node; +} + +function toNode(node: NodeMock): Node { + // $FlowExpectedError[incompatible-return] + return node; +} + +const FabricUIManagerMock: FabricUIManager = { + createNode: jest.fn( + ( + reactTag: number, + viewName: string, + rootTag: RootTag, + props: NodeProps, + instanceHandle: InstanceHandle, + ): Node => { + return toNode({ + reactTag, + rootTag, + props, + instanceHandle, + 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: newProps}); + }), + cloneNodeWithNewChildrenAndProps: jest.fn( + (node: Node, newProps: NodeProps): Node => { + return toNode({...fromNode(node), children: [], props: newProps}); + }, + ), + createChildSet: jest.fn((rootTag: RootTag): NodeSet => { + return []; + }), + appendChild: jest.fn((parentNode: Node, child: Node): Node => { + return toNode({ + ...fromNode(parentNode), + children: fromNode(parentNode).children.concat(child), + }); + }), + appendChildToSet: jest.fn((childSet: NodeSet, child: Node): void => { + childSet.push(child); + }), + completeRoot: jest.fn((rootTag: RootTag, childSet: NodeSet): void => {}), + measure: jest.fn((node: Node, callback: MeasureOnSuccessCallback): void => { + callback(10, 10, 100, 100, 0, 0); + }), + measureInWindow: jest.fn( + (node: Node, callback: MeasureInWindowOnSuccessCallback): void => { + callback(10, 10, 100, 100); + }, + ), + measureLayout: jest.fn( + ( + node: Node, + relativeNode: Node, + onFail: () => void, + onSuccess: MeasureLayoutOnSuccessCallback, + ): void => { + 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 => {}), + getBoundingClientRect: jest.fn( + ( + node: Node, + ): [ + /* x:*/ number, + /* y:*/ number, + /* width:*/ number, + /* height:*/ number, + ] => { + return [1, 1, 100, 100]; + }, + ), + setNativeProps: jest.fn((node: Node, newProps: NodeProps): void => {}), + dispatchCommand: jest.fn( + (node: Node, commandName: string, args: Array): void => {}, + ), +}; + +global.nativeFabricUIManager = FabricUIManagerMock; + +export function getFabricUIManager(): ?FabricUIManager { + return FabricUIManagerMock; +} diff --git a/packages/react-native/Libraries/ReactPrivate/ReactNativePrivateInterface.js b/packages/react-native/Libraries/ReactPrivate/ReactNativePrivateInterface.js index 2d705ea0b97cb9..92c707d7e3b816 100644 --- a/packages/react-native/Libraries/ReactPrivate/ReactNativePrivateInterface.js +++ b/packages/react-native/Libraries/ReactPrivate/ReactNativePrivateInterface.js @@ -17,14 +17,22 @@ import typeof ReactFiberErrorDialog from '../Core/ReactFiberErrorDialog'; import typeof RCTEventEmitter from '../EventEmitter/RCTEventEmitter'; import typeof CustomEvent from '../Events/CustomEvent'; import typeof UIManager from '../ReactNative/UIManager'; +import typeof { + createPublicInstance, + getNativeTagFromPublicInstance, + getNodeFromPublicInstance, +} from '../Renderer/public/ReactFabricPublicInstance'; +import typeof { + create as createAttributePayload, + diff as diffAttributePayloads, +} from '../Renderer/public/ReactNativeAttributePayload'; import typeof ReactNativeViewConfigRegistry from '../Renderer/shims/ReactNativeViewConfigRegistry'; import typeof flattenStyle from '../StyleSheet/flattenStyle'; +import type {DangerouslyImpreciseStyleProp} from '../StyleSheet/StyleSheet'; import typeof deepFreezeAndThrowOnMutationInDev from '../Utilities/deepFreezeAndThrowOnMutationInDev'; import typeof deepDiffer from '../Utilities/differ/deepDiffer'; import typeof Platform from '../Utilities/Platform'; -import {type DangerouslyImpreciseStyleProp} from '../StyleSheet/StyleSheet'; - // flowlint unsafe-getters-setters:off module.exports = { get BatchedBridge(): BatchedBridge { @@ -48,6 +56,7 @@ module.exports = { get UIManager(): UIManager { return require('../ReactNative/UIManager'); }, + // TODO: Remove when React has migrated to `createAttributePayload` and `diffAttributePayloads` get deepDiffer(): deepDiffer { return require('../Utilities/differ/deepDiffer'); }, @@ -56,6 +65,7 @@ module.exports = { > { return require('../Utilities/deepFreezeAndThrowOnMutationInDev'); }, + // TODO: Remove when React has migrated to `createAttributePayload` and `diffAttributePayloads` get flattenStyle(): flattenStyle { // $FlowFixMe[underconstrained-implicit-instantiation] return require('../StyleSheet/flattenStyle'); @@ -72,4 +82,22 @@ module.exports = { get CustomEvent(): CustomEvent { return require('../Events/CustomEvent').default; }, + get createAttributePayload(): createAttributePayload { + return require('../Renderer/public/ReactNativeAttributePayload').create; + }, + get diffAttributePayloads(): diffAttributePayloads { + return require('../Renderer/public/ReactNativeAttributePayload').diff; + }, + get createPublicInstance(): createPublicInstance { + return require('../Renderer/public/ReactFabricPublicInstance') + .createPublicInstance; + }, + get getNativeTagFromPublicInstance(): getNativeTagFromPublicInstance { + return require('../Renderer/public/ReactFabricPublicInstance') + .getNativeTagFromPublicInstance; + }, + get getNodeFromPublicInstance(): getNodeFromPublicInstance { + return require('../Renderer/public/ReactFabricPublicInstance') + .getNodeFromPublicInstance; + }, }; diff --git a/packages/react-native/Libraries/Renderer/public/ReactFabricPublicInstance.js b/packages/react-native/Libraries/Renderer/public/ReactFabricPublicInstance.js new file mode 100644 index 00000000000000..379778af4c88bb --- /dev/null +++ b/packages/react-native/Libraries/Renderer/public/ReactFabricPublicInstance.js @@ -0,0 +1,193 @@ +/** + * 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 strict-local + */ + +import type { + AttributeConfiguration, + HostComponent, + INativeMethods, + MeasureInWindowOnSuccessCallback, + MeasureLayoutOnSuccessCallback, + MeasureOnSuccessCallback, + ViewConfig, +} from '../shims/ReactNativeTypes'; +import type {ElementRef} from 'react'; + +import TextInputState from '../../Components/TextInput/TextInputState'; +import {getFabricUIManager} from '../../ReactNative/FabricUIManager'; +import {getNodeFromInternalInstanceHandle} from '../shims/ReactFabric'; +import {create} from './ReactNativeAttributePayload'; +import nullthrows from 'nullthrows'; + +const { + measure: fabricMeasure, + measureInWindow: fabricMeasureInWindow, + measureLayout: fabricMeasureLayout, + getBoundingClientRect: fabricGetBoundingClientRect, + setNativeProps, +} = nullthrows(getFabricUIManager()); + +const noop = () => {}; + +/** + * This is used for refs on host components. + */ +export class ReactFabricHostComponent implements INativeMethods { + // These need to be accessible from `ReactFabricPublicInstanceUtils`. + __nativeTag: number; + __internalInstanceHandle: mixed; + + _viewConfig: ViewConfig; + + constructor( + tag: number, + viewConfig: ViewConfig, + internalInstanceHandle: mixed, + ) { + this.__nativeTag = tag; + this._viewConfig = viewConfig; + this.__internalInstanceHandle = internalInstanceHandle; + } + + blur() { + // $FlowFixMe[incompatible-exact] Migrate all usages of `NativeMethods` to an interface to fix this. + TextInputState.blurTextInput(this); + } + + focus() { + // $FlowFixMe[incompatible-exact] Migrate all usages of `NativeMethods` to an interface to fix this. + TextInputState.focusTextInput(this); + } + + measure(callback: MeasureOnSuccessCallback) { + const node = getNodeFromInternalInstanceHandle( + this.__internalInstanceHandle, + ); + if (node != null) { + fabricMeasure(node, callback); + } + } + + measureInWindow(callback: MeasureInWindowOnSuccessCallback) { + const node = getNodeFromInternalInstanceHandle( + this.__internalInstanceHandle, + ); + if (node != null) { + fabricMeasureInWindow(node, callback); + } + } + + measureLayout( + relativeToNativeNode: number | ElementRef>, + onSuccess: MeasureLayoutOnSuccessCallback, + onFail?: () => void /* currently unused */, + ) { + if ( + typeof relativeToNativeNode === 'number' || + !(relativeToNativeNode instanceof ReactFabricHostComponent) + ) { + if (__DEV__) { + console.error( + 'Warning: ref.measureLayout must be called with a ref to a native component.', + ); + } + + return; + } + + const toStateNode = getNodeFromInternalInstanceHandle( + this.__internalInstanceHandle, + ); + const fromStateNode = getNodeFromInternalInstanceHandle( + relativeToNativeNode.__internalInstanceHandle, + ); + + if (toStateNode != null && fromStateNode != null) { + fabricMeasureLayout( + toStateNode, + fromStateNode, + onFail != null ? onFail : noop, + onSuccess != null ? onSuccess : noop, + ); + } + } + + unstable_getBoundingClientRect(): DOMRect { + const node = getNodeFromInternalInstanceHandle( + this.__internalInstanceHandle, + ); + if (node != null) { + const rect = fabricGetBoundingClientRect(node); + + if (rect) { + return new DOMRect(rect[0], rect[1], rect[2], rect[3]); + } + } + + // Empty rect if any of the above failed + return new DOMRect(0, 0, 0, 0); + } + + setNativeProps(nativeProps: {...}): void { + if (__DEV__) { + warnForStyleProps(nativeProps, this._viewConfig.validAttributes); + } + const updatePayload = create(nativeProps, this._viewConfig.validAttributes); + + const node = getNodeFromInternalInstanceHandle( + this.__internalInstanceHandle, + ); + if (node != null && updatePayload != null) { + setNativeProps(node, updatePayload); + } + } +} + +export function warnForStyleProps( + props: {...}, + validAttributes: AttributeConfiguration, +): void { + if (__DEV__) { + for (const key in validAttributes.style) { + if (!(validAttributes[key] || props[key] === undefined)) { + console.error( + 'You are setting the style `{ %s' + + ': ... }` as a prop. You ' + + 'should nest it in a style object. ' + + 'E.g. `{ style: { %s' + + ': ... } }`', + key, + key, + ); + } + } + } +} + +export function createPublicInstance( + tag: number, + viewConfig: ViewConfig, + internalInstanceHandle: mixed, +): ReactFabricHostComponent { + return new ReactFabricHostComponent(tag, viewConfig, internalInstanceHandle); +} + +export function getNativeTagFromPublicInstance( + publicInstance: ReactFabricHostComponent, +): number { + return publicInstance.__nativeTag; +} + +export function getNodeFromPublicInstance( + publicInstance: ReactFabricHostComponent, +): mixed { + return getNodeFromInternalInstanceHandle( + publicInstance.__internalInstanceHandle, + ); +} diff --git a/packages/react-native/Libraries/Renderer/public/ReactNativeAttributePayload.js b/packages/react-native/Libraries/Renderer/public/ReactNativeAttributePayload.js new file mode 100644 index 00000000000000..f65edd1fbab790 --- /dev/null +++ b/packages/react-native/Libraries/Renderer/public/ReactNativeAttributePayload.js @@ -0,0 +1,492 @@ +/** + * 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 + */ + +import type {AttributeConfiguration} from '../shims/ReactNativeTypes'; + +import flattenStyle from '../../StyleSheet/flattenStyle'; +import deepDiffer from '../../Utilities/differ/deepDiffer'; + +const emptyObject = {}; + +/** + * Create a payload that contains all the updates between two sets of props. + * + * These helpers are all encapsulated into a single module, because they use + * mutation as a performance optimization which leads to subtle shared + * dependencies between the code paths. To avoid this mutable state leaking + * across modules, I've kept them isolated to this module. + */ + +type NestedNode = Array | Object; + +// Tracks removed keys +let removedKeys: {[string]: boolean} | null = null; +let removedKeyCount = 0; + +const deepDifferOptions = { + unsafelyIgnoreFunctions: true, +}; + +function defaultDiffer(prevProp: mixed, nextProp: mixed): boolean { + if (typeof nextProp !== 'object' || nextProp === null) { + // Scalars have already been checked for equality + return true; + } else { + // For objects and arrays, the default diffing algorithm is a deep compare + return deepDiffer(prevProp, nextProp, deepDifferOptions); + } +} + +function restoreDeletedValuesInNestedArray( + updatePayload: Object, + node: NestedNode, + validAttributes: AttributeConfiguration, +) { + if (Array.isArray(node)) { + let i = node.length; + while (i-- && removedKeyCount > 0) { + restoreDeletedValuesInNestedArray( + updatePayload, + node[i], + validAttributes, + ); + } + } else if (node && removedKeyCount > 0) { + const obj = node; + for (const propKey in removedKeys) { + // $FlowFixMe[incompatible-use] found when upgrading Flow + if (!removedKeys[propKey]) { + continue; + } + let nextProp = obj[propKey]; + if (nextProp === undefined) { + continue; + } + + const attributeConfig = validAttributes[propKey]; + if (!attributeConfig) { + continue; // not a valid native prop + } + + if (typeof nextProp === 'function') { + // $FlowFixMe[incompatible-type] found when upgrading Flow + nextProp = true; + } + if (typeof nextProp === 'undefined') { + // $FlowFixMe[incompatible-type] found when upgrading Flow + nextProp = null; + } + + if (typeof attributeConfig !== 'object') { + // case: !Object is the default case + updatePayload[propKey] = nextProp; + } else if ( + typeof attributeConfig.diff === 'function' || + typeof attributeConfig.process === 'function' + ) { + // case: CustomAttributeConfiguration + const nextValue = + typeof attributeConfig.process === 'function' + ? attributeConfig.process(nextProp) + : nextProp; + updatePayload[propKey] = nextValue; + } + // $FlowFixMe[incompatible-use] found when upgrading Flow + removedKeys[propKey] = false; + removedKeyCount--; + } + } +} + +function diffNestedArrayProperty( + updatePayload: null | Object, + prevArray: Array, + nextArray: Array, + validAttributes: AttributeConfiguration, +): null | Object { + const minLength = + prevArray.length < nextArray.length ? prevArray.length : nextArray.length; + let i; + for (i = 0; i < minLength; i++) { + // Diff any items in the array in the forward direction. Repeated keys + // will be overwritten by later values. + updatePayload = diffNestedProperty( + updatePayload, + prevArray[i], + nextArray[i], + validAttributes, + ); + } + for (; i < prevArray.length; i++) { + // Clear out all remaining properties. + updatePayload = clearNestedProperty( + updatePayload, + prevArray[i], + validAttributes, + ); + } + for (; i < nextArray.length; i++) { + // Add all remaining properties. + updatePayload = addNestedProperty( + updatePayload, + nextArray[i], + validAttributes, + ); + } + return updatePayload; +} + +function diffNestedProperty( + updatePayload: null | Object, + prevProp: NestedNode, + nextProp: NestedNode, + validAttributes: AttributeConfiguration, +): null | Object { + if (!updatePayload && prevProp === nextProp) { + // If no properties have been added, then we can bail out quickly on object + // equality. + return updatePayload; + } + + if (!prevProp || !nextProp) { + if (nextProp) { + return addNestedProperty(updatePayload, nextProp, validAttributes); + } + if (prevProp) { + return clearNestedProperty(updatePayload, prevProp, validAttributes); + } + return updatePayload; + } + + if (!Array.isArray(prevProp) && !Array.isArray(nextProp)) { + // Both are leaves, we can diff the leaves. + return diffProperties(updatePayload, prevProp, nextProp, validAttributes); + } + + if (Array.isArray(prevProp) && Array.isArray(nextProp)) { + // Both are arrays, we can diff the arrays. + return diffNestedArrayProperty( + updatePayload, + prevProp, + nextProp, + validAttributes, + ); + } + + if (Array.isArray(prevProp)) { + return diffProperties( + updatePayload, + // $FlowFixMe - We know that this is always an object when the input is. + flattenStyle(prevProp), + // $FlowFixMe - We know that this isn't an array because of above flow. + nextProp, + validAttributes, + ); + } + + return diffProperties( + updatePayload, + prevProp, + // $FlowFixMe - We know that this is always an object when the input is. + flattenStyle(nextProp), + validAttributes, + ); +} + +/** + * addNestedProperty takes a single set of props and valid attribute + * attribute configurations. It processes each prop and adds it to the + * updatePayload. + */ +function addNestedProperty( + updatePayload: null | Object, + nextProp: NestedNode, + validAttributes: AttributeConfiguration, +): $FlowFixMe { + if (!nextProp) { + return updatePayload; + } + + if (!Array.isArray(nextProp)) { + // Add each property of the leaf. + return addProperties(updatePayload, nextProp, validAttributes); + } + + for (let i = 0; i < nextProp.length; i++) { + // Add all the properties of the array. + updatePayload = addNestedProperty( + updatePayload, + nextProp[i], + validAttributes, + ); + } + + return updatePayload; +} + +/** + * clearNestedProperty takes a single set of props and valid attributes. It + * adds a null sentinel to the updatePayload, for each prop key. + */ +function clearNestedProperty( + updatePayload: null | Object, + prevProp: NestedNode, + validAttributes: AttributeConfiguration, +): null | Object { + if (!prevProp) { + return updatePayload; + } + + if (!Array.isArray(prevProp)) { + // Add each property of the leaf. + return clearProperties(updatePayload, prevProp, validAttributes); + } + + for (let i = 0; i < prevProp.length; i++) { + // Add all the properties of the array. + updatePayload = clearNestedProperty( + updatePayload, + prevProp[i], + validAttributes, + ); + } + return updatePayload; +} + +/** + * diffProperties takes two sets of props and a set of valid attributes + * and write to updatePayload the values that changed or were deleted. + * If no updatePayload is provided, a new one is created and returned if + * anything changed. + */ +function diffProperties( + updatePayload: null | Object, + prevProps: Object, + nextProps: Object, + validAttributes: AttributeConfiguration, +): null | Object { + let attributeConfig; + let nextProp; + let prevProp; + + for (const propKey in nextProps) { + attributeConfig = validAttributes[propKey]; + if (!attributeConfig) { + continue; // not a valid native prop + } + + prevProp = prevProps[propKey]; + nextProp = nextProps[propKey]; + + // functions are converted to booleans as markers that the associated + // events should be sent from native. + if (typeof nextProp === 'function') { + nextProp = (true: any); + // If nextProp is not a function, then don't bother changing prevProp + // since nextProp will win and go into the updatePayload regardless. + if (typeof prevProp === 'function') { + prevProp = (true: any); + } + } + + // An explicit value of undefined is treated as a null because it overrides + // any other preceding value. + if (typeof nextProp === 'undefined') { + nextProp = (null: any); + if (typeof prevProp === 'undefined') { + prevProp = (null: any); + } + } + + if (removedKeys) { + removedKeys[propKey] = false; + } + + if (updatePayload && updatePayload[propKey] !== undefined) { + // Something else already triggered an update to this key because another + // value diffed. Since we're now later in the nested arrays our value is + // more important so we need to calculate it and override the existing + // value. It doesn't matter if nothing changed, we'll set it anyway. + + // Pattern match on: attributeConfig + if (typeof attributeConfig !== 'object') { + // case: !Object is the default case + updatePayload[propKey] = nextProp; + } else if ( + typeof attributeConfig.diff === 'function' || + typeof attributeConfig.process === 'function' + ) { + // case: CustomAttributeConfiguration + const nextValue = + typeof attributeConfig.process === 'function' + ? attributeConfig.process(nextProp) + : nextProp; + updatePayload[propKey] = nextValue; + } + continue; + } + + if (prevProp === nextProp) { + continue; // nothing changed + } + + // Pattern match on: attributeConfig + if (typeof attributeConfig !== 'object') { + // case: !Object is the default case + if (defaultDiffer(prevProp, nextProp)) { + // a normal leaf has changed + (updatePayload || (updatePayload = ({}: {[string]: $FlowFixMe})))[ + propKey + ] = nextProp; + } + } else if ( + typeof attributeConfig.diff === 'function' || + typeof attributeConfig.process === 'function' + ) { + // case: CustomAttributeConfiguration + const shouldUpdate = + prevProp === undefined || + (typeof attributeConfig.diff === 'function' + ? attributeConfig.diff(prevProp, nextProp) + : defaultDiffer(prevProp, nextProp)); + if (shouldUpdate) { + const nextValue = + typeof attributeConfig.process === 'function' + ? // $FlowFixMe[incompatible-use] found when upgrading Flow + attributeConfig.process(nextProp) + : nextProp; + (updatePayload || (updatePayload = ({}: {[string]: $FlowFixMe})))[ + propKey + ] = nextValue; + } + } else { + // default: fallthrough case when nested properties are defined + removedKeys = null; + removedKeyCount = 0; + // We think that attributeConfig is not CustomAttributeConfiguration at + // this point so we assume it must be AttributeConfiguration. + updatePayload = diffNestedProperty( + updatePayload, + prevProp, + nextProp, + ((attributeConfig: any): AttributeConfiguration), + ); + if (removedKeyCount > 0 && updatePayload) { + restoreDeletedValuesInNestedArray( + updatePayload, + nextProp, + ((attributeConfig: any): AttributeConfiguration), + ); + removedKeys = null; + } + } + } + + // Also iterate through all the previous props to catch any that have been + // removed and make sure native gets the signal so it can reset them to the + // default. + for (const propKey in prevProps) { + if (nextProps[propKey] !== undefined) { + continue; // we've already covered this key in the previous pass + } + attributeConfig = validAttributes[propKey]; + if (!attributeConfig) { + continue; // not a valid native prop + } + + if (updatePayload && updatePayload[propKey] !== undefined) { + // This was already updated to a diff result earlier. + continue; + } + + prevProp = prevProps[propKey]; + if (prevProp === undefined) { + continue; // was already empty anyway + } + // Pattern match on: attributeConfig + if ( + typeof attributeConfig !== 'object' || + typeof attributeConfig.diff === 'function' || + typeof attributeConfig.process === 'function' + ) { + // case: CustomAttributeConfiguration | !Object + // Flag the leaf property for removal by sending a sentinel. + (updatePayload || (updatePayload = ({}: {[string]: $FlowFixMe})))[ + propKey + ] = null; + if (!removedKeys) { + removedKeys = ({}: {[string]: boolean}); + } + if (!removedKeys[propKey]) { + removedKeys[propKey] = true; + removedKeyCount++; + } + } else { + // default: + // This is a nested attribute configuration where all the properties + // were removed so we need to go through and clear out all of them. + updatePayload = clearNestedProperty( + updatePayload, + prevProp, + ((attributeConfig: any): AttributeConfiguration), + ); + } + } + return updatePayload; +} + +/** + * addProperties adds all the valid props to the payload after being processed. + */ +function addProperties( + updatePayload: null | Object, + props: Object, + validAttributes: AttributeConfiguration, +): null | Object { + // TODO: Fast path + return diffProperties(updatePayload, emptyObject, props, validAttributes); +} + +/** + * clearProperties clears all the previous props by adding a null sentinel + * to the payload for each valid key. + */ +function clearProperties( + updatePayload: null | Object, + prevProps: Object, + validAttributes: AttributeConfiguration, +): null | Object { + // TODO: Fast path + return diffProperties(updatePayload, prevProps, emptyObject, validAttributes); +} + +export function create( + props: Object, + validAttributes: AttributeConfiguration, +): null | Object { + return addProperties( + null, // updatePayload + props, + validAttributes, + ); +} + +export function diff( + prevProps: Object, + nextProps: Object, + validAttributes: AttributeConfiguration, +): null | Object { + return diffProperties( + null, // updatePayload + prevProps, + nextProps, + validAttributes, + ); +} diff --git a/packages/react-native/Libraries/Renderer/public/__tests__/ReactFabricPublicInstance-test.js b/packages/react-native/Libraries/Renderer/public/__tests__/ReactFabricPublicInstance-test.js new file mode 100644 index 00000000000000..72bcd7741c3aa4 --- /dev/null +++ b/packages/react-native/Libraries/Renderer/public/__tests__/ReactFabricPublicInstance-test.js @@ -0,0 +1,267 @@ +/** + * 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 {HostComponent} from '../../shims/ReactNativeTypes'; + +import * as React from 'react'; +import {act} from 'react-test-renderer'; + +const TextInputState = require('../../../Components/TextInput/TextInputState'); +const FabricUIManager = require('../../../ReactNative/FabricUIManager'); +const ReactFabric = require('../../shims/ReactFabric'); +const ReactNativeViewConfigRegistry = require('../../shims/ReactNativeViewConfigRegistry'); +const nullthrows = require('nullthrows'); + +jest.mock('../../../ReactNative/FabricUIManager', () => + require('../../../ReactNative/__mocks__/FabricUIManager'), +); + +/** + * 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): ?React.ElementRef< + HostComponent, + >); + }} + /> + ))} + , + 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; +} + +describe('ReactFabricPublicInstance', () => { + beforeEach(() => { + jest.resetModules(); + // Installs the global `nativeFabricUIManager` pointing to the mock. + require('../../../ReactNative/__mocks__/FabricUIManager'); + jest.spyOn(TextInputState, 'blurTextInput'); + jest.spyOn(TextInputState, 'focusTextInput'); + }); + + 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', () => { + test('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]]); + }); + + test('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', () => { + test('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]]); + }); + + test('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', () => { + test('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]]); + }); + + test('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(); + }); + + test('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(); + }); + + test('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(); + }); + }); +});