react-native
Version:
A framework for building native apps using React
328 lines (292 loc) • 9.15 kB
JavaScript
/**
* 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 '../../ReactNative/FabricUIManager';
import type {RootTag} from '../../ReactNative/RootTag';
import type {
InternalInstanceHandle,
Node,
} from '../../Renderer/shims/ReactNativeTypes';
import type {
MutationObserverId,
NativeMutationObserverObserveOptions,
NativeMutationRecord,
Spec,
} from '../NativeMutationObserver';
import ReadOnlyNode from '../../../src/private/webapis/dom/nodes/ReadOnlyNode';
import {
type NodeMock,
type UIManagerCommitHook,
fromNode,
getFabricUIManager,
getNodeInChildSet,
} from '../../ReactNative/__mocks__/FabricUIManager';
import invariant from 'invariant';
import nullthrows from 'nullthrows';
let pendingRecords: Array<NativeMutationRecord> = [];
let callback: ?() => void;
let getPublicInstance: ?(instanceHandle: InternalInstanceHandle) => mixed;
let observersByRootTag: Map<
RootTag,
Map<MutationObserverId, {deep: Set<Node>, shallow: Set<Node>}>,
> = 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<NativeMutationRecord> => {
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<NativeMutationRecord> = [];
for (const [mutationObserverId, observations] of observers) {
const processedNodes: Set<Node> = 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<NativeMutationRecord>,
processedNodes: Set<Node>,
}): 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<NativeMutationRecord>,
processedNodes: Set<Node>,
}): 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);
}
}