Bring ReactFabricHostComponent back to react-native (#36570)

Summary:
Pull Request resolved: https://github.com/facebook/react-native/pull/36570

I'm doing some preparations to implement this proposal to bring some DOM APIs to React Native refs: https://github.com/react-native-community/discussions-and-proposals/pull/607

To make it easier to iterate on the proposal, and to improve the separation of concerns between React and React Native, I'm moving the definition of `ReactFabricHostComponent` (the public instance provided by React when using refs on host conmponents) to the `react-native` package.

I already did some steps in the React repository to simplify this:
* Removing unused imperative events that caused increased coupling: https://github.com/facebook/react/pull/26282
* Extracting the definition of the public instance to a separate module: https://github.com/facebook/react/pull/26291

In this case, in order to be able to move the definition from React to React Native, we need to:
1. Create the definition in React Native and export it through `ReactNativePrivateInterface`.
2. Update React to use that definition instead of the one in its own module.

This diff implements the first step.

`ReactNativeAttributePayload` is required by this definition and by the one for Paper that still exists in React. I moved it here so we only define it where we use it when we remove Paper. Paper will access it through `ReactNativePrivateInterface` as well. That will also allow us to remove a few other fields in that interface.

Changelog: [Internal]

bypass-github-export-checks

Reviewed By: yungsters

Differential Revision: D43772356

fbshipit-source-id: 78dac152f415f19316ec90887127bf9861fe3110
This commit is contained in:
Rubén Norte
2023-03-22 06:50:46 -07:00
committed by Facebook GitHub Bot
parent 64ea075141
commit ee76107d74
7 changed files with 1127 additions and 8 deletions
+2 -1
View File
@@ -28,7 +28,8 @@ module.exports = {
testPathIgnorePatterns: [
'/node_modules/',
'<rootDir>/packages/react-native/template',
'<rootDir>/packages/react-native/Libraries/Renderer',
'<rootDir>/packages/react-native/Libraries/Renderer/implementations',
'<rootDir>/packages/react-native/Libraries/Renderer/shims',
'<rootDir>/packages/rn-tester/e2e',
],
transformIgnorePatterns: ['node_modules/(?!@react-native/)'],
@@ -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<Node>;
type NodeProps = {...};
type InstanceHandle = {...};
export type NodeSet = Array<Node>;
export type NodeProps = {...};
export type InstanceHandle = {...};
export type Spec = {|
+createNode: (
reactTag: number,
@@ -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<mixed>): void => {},
),
};
global.nativeFabricUIManager = FabricUIManagerMock;
export function getFabricUIManager(): ?FabricUIManager {
return FabricUIManagerMock;
}
@@ -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<DangerouslyImpreciseStyleProp> {
// $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;
},
};
@@ -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<HostComponent<mixed>>,
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,
);
}
@@ -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<NestedNode> | 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<NestedNode>,
nextArray: Array<NestedNode>,
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,
);
}
@@ -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<TArguments: $ReadOnlyArray<mixed>, TReturn>(
fn: (...args: TArguments) => TReturn,
): JestMockFn<TArguments, TReturn> {
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<?Array<?string>>,
): Promise<Array<?Array<?React.ElementRef<HostComponent<mixed>>>>> {
const mockContainerTag = 11;
const MockView = ReactNativeViewConfigRegistry.register(
'RCTMockView',
() => ({
validAttributes: {foo: true, style: {}},
uiViewClassName: 'RCTMockView',
}),
);
const result: Array<?Array<?React.ElementRef<HostComponent<mixed>>>> = [];
for (let i = 0; i < keyLists.length; i++) {
const keyList = keyLists[i];
if (Array.isArray(keyList)) {
const refs: Array<?React.ElementRef<HostComponent<mixed>>> = keyList.map(
key => undefined,
);
await act(() => {
ReactFabric.render(
<MockView>
{keyList.map((key, index) => (
<MockView
key={key}
ref={ref => {
refs[index] = ((ref: $FlowFixMe): ?React.ElementRef<
HostComponent<mixed>,
>);
}}
/>
))}
</MockView>,
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();
});
});
});