diff --git a/packages/react-native/Libraries/Core/setUpMutationObserver.js b/packages/react-native/Libraries/Core/setUpMutationObserver.js new file mode 100644 index 00000000000..6e34a2e5882 --- /dev/null +++ b/packages/react-native/Libraries/Core/setUpMutationObserver.js @@ -0,0 +1,16 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import {polyfillGlobal} from '../Utilities/PolyfillFunctions'; + +polyfillGlobal( + 'MutationObserver', + () => require('../MutationObserver/MutationObserver').default, +); diff --git a/packages/react-native/Libraries/MutationObserver/MutationObserver.js b/packages/react-native/Libraries/MutationObserver/MutationObserver.js new file mode 100644 index 00000000000..4c6c4ef2294 --- /dev/null +++ b/packages/react-native/Libraries/MutationObserver/MutationObserver.js @@ -0,0 +1,184 @@ +/** + * 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 + */ + +// flowlint unsafe-getters-setters:off + +import type {MutationObserverId} from './MutationObserverManager'; +import type MutationRecord from './MutationRecord'; + +import ReactNativeElement from '../DOM/Nodes/ReactNativeElement'; +import * as MutationObserverManager from './MutationObserverManager'; + +export type MutationObserverCallback = ( + mutationRecords: $ReadOnlyArray, + observer: MutationObserver, +) => mixed; + +type MutationObserverInit = $ReadOnly<{ + subtree?: boolean, + // This is the only supported option so it's required to be `true`. + childList: true, + + // Unsupported: + attributes?: boolean, + attributeFilter?: $ReadOnlyArray, + attributeOldValue?: boolean, + characterData?: boolean, + characterDataOldValue?: boolean, +}>; + +/** + * This is a React Native implementation for the `MutationObserver` API + * (https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver). + * + * It only supports the `subtree` and `childList` options at the moment. + */ +export default class MutationObserver { + _callback: MutationObserverCallback; + _observationTargets: Set = new Set(); + _mutationObserverId: ?MutationObserverId; + + constructor(callback: MutationObserverCallback): void { + if (callback == null) { + throw new TypeError( + "Failed to construct 'MutationObserver': 1 argument required, but only 0 present.", + ); + } + + if (typeof callback !== 'function') { + throw new TypeError( + "Failed to construct 'MutationObserver': parameter 1 is not of type 'Function'.", + ); + } + + this._callback = callback; + } + + /** + * Configures the `MutationObserver` callback to begin receiving notifications + * of changes to the UI tree that match the given options. + * Depending on the configuration, the observer may watch a single node in the + * UI tree, or that node and some or all of its descendant nodes. + * To stop the `MutationObserver` (so that none of its callbacks will be + * triggered any longer), call `MutationObserver.disconnect()`. + */ + observe(target: ReactNativeElement, options?: MutationObserverInit): void { + if (!(target instanceof ReactNativeElement)) { + throw new TypeError( + "Failed to execute 'observe' on 'MutationObserver': parameter 1 is not of type 'ReactNativeElement'.", + ); + } + + // Browsers force a cast of this value to boolean + if (Boolean(options?.childList) !== true) { + throw new TypeError( + "Failed to execute 'observe' on 'MutationObserver': The options object must set 'childList' to true.", + ); + } + + if (options?.attributes != null) { + throw new Error( + "Failed to execute 'observe' on 'MutationObserver': attributes is not supported", + ); + } + + if (options?.attributeFilter != null) { + throw new Error( + "Failed to execute 'observe' on 'MutationObserver': attributeFilter is not supported", + ); + } + + if (options?.attributeOldValue != null) { + throw new Error( + "Failed to execute 'observe' on 'MutationObserver': attributeOldValue is not supported", + ); + } + + if (options?.characterData != null) { + throw new Error( + "Failed to execute 'observe' on 'MutationObserver': characterData is not supported", + ); + } + + if (options?.characterDataOldValue != null) { + throw new Error( + "Failed to execute 'observe' on 'MutationObserver': characterDataOldValue is not supported", + ); + } + + const mutationObserverId = this._getOrCreateMutationObserverId(); + + // As per the spec, if the target is already being observed, we "reset" + // the observation and only use the last options used. + if (this._observationTargets.has(target)) { + MutationObserverManager.unobserve(mutationObserverId, target); + } + + MutationObserverManager.observe({ + mutationObserverId, + target, + subtree: Boolean(options?.subtree), + }); + + this._observationTargets.add(target); + } + + _unobserve(target: ReactNativeElement): void { + if (!(target instanceof ReactNativeElement)) { + throw new TypeError( + "Failed to execute 'observe' on 'MutationObserver': parameter 1 is not of type 'ReactNativeElement'.", + ); + } + + if (!this._observationTargets.has(target)) { + return; + } + + const mutationObserverId = this._mutationObserverId; + if (mutationObserverId == null) { + return; + } + + MutationObserverManager.unobserve(mutationObserverId, target); + this._observationTargets.delete(target); + + if (this._observationTargets.size === 0) { + MutationObserverManager.unregisterObserver(mutationObserverId); + this._mutationObserverId = null; + } + } + + /** + * Tells the observer to stop watching for mutations. + * The observer can be reused by calling its `observe()` method again. + */ + disconnect(): void { + for (const target of this._observationTargets.keys()) { + this._unobserve(target); + } + } + + _getOrCreateMutationObserverId(): MutationObserverId { + let mutationObserverId = this._mutationObserverId; + if (mutationObserverId == null) { + mutationObserverId = MutationObserverManager.registerObserver( + this, + this._callback, + ); + this._mutationObserverId = mutationObserverId; + } + return mutationObserverId; + } + + // Only for tests + __getObserverID(): ?MutationObserverId { + return this._mutationObserverId; + } +} diff --git a/packages/react-native/Libraries/MutationObserver/MutationObserverManager.js b/packages/react-native/Libraries/MutationObserver/MutationObserverManager.js new file mode 100644 index 00000000000..9cdf56f0c5d --- /dev/null +++ b/packages/react-native/Libraries/MutationObserver/MutationObserverManager.js @@ -0,0 +1,218 @@ +/** + * 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 module handles the communication between the React Native renderer + * and all the mutation observers that are currently observing any targets. + * + * In order to reduce the communication between native and JavaScript, + * we register a single notication callback in native, and then we handle how + * to notify each entry to the right mutation observer when we receive all + * the notifications together. + */ + +import type ReactNativeElement from '../DOM/Nodes/ReactNativeElement'; +import type MutationObserver, { + MutationObserverCallback, +} from './MutationObserver'; +import type MutationRecord from './MutationRecord'; + +import { + getPublicInstanceFromInternalInstanceHandle, + getShadowNode, +} from '../DOM/Nodes/ReadOnlyNode'; +import * as Systrace from '../Performance/Systrace'; +import warnOnce from '../Utilities/warnOnce'; +import {createMutationRecord} from './MutationRecord'; +import NativeMutationObserver from './NativeMutationObserver'; + +export type MutationObserverId = number; + +let nextMutationObserverId: MutationObserverId = 1; +let isConnected: boolean = false; + +const registeredMutationObservers: Map< + MutationObserverId, + $ReadOnly<{observer: MutationObserver, callback: MutationObserverCallback}>, +> = new Map(); + +/** + * Registers the given mutation observer and returns a unique ID for it, + * which is required to start observing targets. + */ +export function registerObserver( + observer: MutationObserver, + callback: MutationObserverCallback, +): MutationObserverId { + const mutationObserverId = nextMutationObserverId; + nextMutationObserverId++; + registeredMutationObservers.set(mutationObserverId, { + observer, + callback, + }); + return mutationObserverId; +} + +/** + * Unregisters the given mutation observer. + * This should only be called when an observer is no longer observing any + * targets. + */ +export function unregisterObserver( + mutationObserverId: MutationObserverId, +): void { + const deleted = registeredMutationObservers.delete(mutationObserverId); + if (deleted && registeredMutationObservers.size === 0) { + // When there are no observers left, we can disconnect the native module + // so we don't need to check commits for mutations. + NativeMutationObserver?.disconnect(); + isConnected = false; + } +} + +export function observe({ + mutationObserverId, + target, + subtree, +}: { + mutationObserverId: MutationObserverId, + target: ReactNativeElement, + subtree: boolean, +}): void { + if (NativeMutationObserver == null) { + warnNoNativeMutationObserver(); + return; + } + + const registeredObserver = + registeredMutationObservers.get(mutationObserverId); + if (registeredObserver == null) { + console.error( + `MutationObserverManager: could not start observing target because MutationObserver with ID ${mutationObserverId} was not registered.`, + ); + return; + } + + const targetShadowNode = getShadowNode(target); + if (targetShadowNode == null) { + console.error( + 'MutationObserverManager: could not find reference to host node from target', + ); + return; + } + + if (!isConnected) { + NativeMutationObserver.connect( + notifyMutationObservers, + // We need to do this operation from native to make sure we're retaining + // the public instance immediately when mutations occur. Otherwise React + // could dereference it in the instance handle and we wouldn't be able to + // access it. + // $FlowExpectedError[incompatible-call] This is typed as (mixed) => mixed in the native module because the codegen doesn't support the actual types. + getPublicInstanceFromInternalInstanceHandle, + ); + isConnected = true; + } + + return NativeMutationObserver.observe({ + mutationObserverId, + targetShadowNode, + subtree, + }); +} + +export function unobserve( + mutationObserverId: number, + target: ReactNativeElement, +): void { + if (NativeMutationObserver == null) { + warnNoNativeMutationObserver(); + return; + } + + const registeredObserver = + registeredMutationObservers.get(mutationObserverId); + if (registeredObserver == null) { + console.error( + `MutationObserverManager: could not stop observing target because MutationObserver with ID ${mutationObserverId} was not registered.`, + ); + return; + } + + const targetShadowNode = getShadowNode(target); + if (targetShadowNode == null) { + console.error( + 'MutationObserverManager: could not find reference to host node from target', + ); + return; + } + + NativeMutationObserver.unobserve(mutationObserverId, targetShadowNode); +} + +/** + * This function is called from native when there are `MutationObserver` + * entries to dispatch. + */ +function notifyMutationObservers(): void { + Systrace.beginEvent('MutationObserverManager.notifyMutationObservers'); + try { + doNotifyMutationObservers(); + } finally { + Systrace.endEvent(); + } +} + +function doNotifyMutationObservers(): void { + if (NativeMutationObserver == null) { + warnNoNativeMutationObserver(); + return; + } + + const nativeRecords = NativeMutationObserver.takeRecords(); + + const entriesByObserver: Map< + MutationObserverId, + Array, + > = new Map(); + + for (const nativeRecord of nativeRecords) { + let list = entriesByObserver.get(nativeRecord.mutationObserverId); + if (list == null) { + list = []; + entriesByObserver.set(nativeRecord.mutationObserverId, list); + } + list.push(createMutationRecord(nativeRecord)); + } + + for (const [mutationObserverId, entriesForObserver] of entriesByObserver) { + const registeredObserver = + registeredMutationObservers.get(mutationObserverId); + if (!registeredObserver) { + // This could happen if the observer is disconnected between commit + // and mount. In this case, we can just ignore the entries. + return; + } + + const {observer, callback} = registeredObserver; + try { + callback.call(observer, entriesForObserver, observer); + } catch (error) { + console.error(error); + } + } +} + +function warnNoNativeMutationObserver() { + warnOnce( + 'missing-native-mutation-observer', + 'Missing native implementation of MutationObserver', + ); +} diff --git a/packages/react-native/Libraries/MutationObserver/MutationRecord.js b/packages/react-native/Libraries/MutationObserver/MutationRecord.js new file mode 100644 index 00000000000..18cce2d1fda --- /dev/null +++ b/packages/react-native/Libraries/MutationObserver/MutationRecord.js @@ -0,0 +1,82 @@ +/** + * 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 + */ + +// flowlint unsafe-getters-setters:off + +import type ReactNativeElement from '../DOM/Nodes/ReactNativeElement'; +import type ReadOnlyNode from '../DOM/Nodes/ReadOnlyNode'; +import type {NativeMutationRecord} from './NativeMutationObserver'; + +import NodeList, {createNodeList} from '../DOM/OldStyleCollections/NodeList'; + +export type MutationType = 'attributes' | 'characterData' | 'childList'; + +/** + * The `MutationRecord` is a read-only interface that represents an individual + * DOM mutation observed by a `MutationObserver`. + * + * It is the object inside the array passed to the callback of a `MutationObserver`. + */ +export default class MutationRecord { + _target: ReactNativeElement; + _addedNodes: NodeList; + _removedNodes: NodeList; + + constructor(nativeRecord: NativeMutationRecord) { + // $FlowExpectedError[incompatible-cast] the codegen doesn't support the actual type. + const target = (nativeRecord.target: ReactNativeElement); + this._target = target; + // $FlowExpectedError[incompatible-cast] the codegen doesn't support the actual type. + const addedNodes = (nativeRecord.addedNodes: $ReadOnlyArray); + this._addedNodes = createNodeList(addedNodes); + const removedNodes = + // $FlowExpectedError[incompatible-cast] the codegen doesn't support the actual type. + (nativeRecord.removedNodes: $ReadOnlyArray); + this._removedNodes = createNodeList(removedNodes); + } + + get addedNodes(): NodeList { + return this._addedNodes; + } + + get attributeName(): string | null { + return null; + } + + get nextSibling(): ReadOnlyNode | null { + return null; + } + + get oldValue(): mixed | null { + return null; + } + + get previousSibling(): ReadOnlyNode | null { + return null; + } + + get removedNodes(): NodeList { + return this._removedNodes; + } + + get target(): ReactNativeElement { + return this._target; + } + + get type(): MutationType { + return 'childList'; + } +} + +export function createMutationRecord( + entry: NativeMutationRecord, +): MutationRecord { + return new MutationRecord(entry); +} diff --git a/packages/react-native/Libraries/MutationObserver/NativeMutationObserver.cpp b/packages/react-native/Libraries/MutationObserver/NativeMutationObserver.cpp new file mode 100644 index 00000000000..eb6d5231d69 --- /dev/null +++ b/packages/react-native/Libraries/MutationObserver/NativeMutationObserver.cpp @@ -0,0 +1,165 @@ +/* + * 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. + */ + +#include "NativeMutationObserver.h" +#include +#include +#include +#include + +#include "Plugins.h" + +std::shared_ptr +NativeMutationObserverModuleProvider( + std::shared_ptr jsInvoker) { + return std::make_shared( + std::move(jsInvoker)); +} + +namespace facebook::react { + +static UIManager &getUIManagerFromRuntime(jsi::Runtime &runtime) { + return UIManagerBinding::getBinding(runtime)->getUIManager(); +} + +NativeMutationObserver::NativeMutationObserver( + std::shared_ptr jsInvoker) + : NativeMutationObserverCxxSpec(std::move(jsInvoker)) {} + +void NativeMutationObserver::observe( + jsi::Runtime &runtime, + NativeMutationObserverObserveOptions options) { + auto mutationObserverId = options.mutationObserverId; + auto subtree = options.subtree; + auto shadowNode = + shadowNodeFromValue(runtime, std::move(options).targetShadowNode); + auto &uiManager = getUIManagerFromRuntime(runtime); + + mutationObserverManager_.observe( + mutationObserverId, shadowNode, subtree, uiManager); +} + +void NativeMutationObserver::unobserve( + jsi::Runtime &runtime, + MutationObserverId mutationObserverId, + jsi::Object targetShadowNode) { + auto shadowNode = shadowNodeFromValue(runtime, std::move(targetShadowNode)); + mutationObserverManager_.unobserve(mutationObserverId, *shadowNode); +} + +void NativeMutationObserver::connect( + jsi::Runtime &runtime, + AsyncCallback<> notifyMutationObservers, + jsi::Function getPublicInstanceFromInstanceHandle) { + auto &uiManager = getUIManagerFromRuntime(runtime); + + // MutationObserver is not compatible with background executor. + // When using background executor, we commit trees outside the JS thread. + // In that case, we can't safely access the JS runtime in commit hooks to + // get references to mutated nodes (which we need to do at that point + // to ensure we are retaining removed nodes). + if (uiManager.hasBackgroundExecutor()) { + throw jsi::JSError( + runtime, + "MutationObserver: could not start observation because MutationObserver is incompatible with UIManager using background executor."); + } + + getPublicInstanceFromInstanceHandle_ = + jsi::Value(runtime, getPublicInstanceFromInstanceHandle); + + // This should always be called from the JS thread, as it's unsafe to call + // into JS otherwise (via `getPublicInstanceFromInstanceHandle`). + getPublicInstanceFromShadowNode_ = [&](ShadowNode const &shadowNode) { + auto instanceHandle = shadowNode.getInstanceHandle(runtime); + if (!instanceHandle.isObject() || + !getPublicInstanceFromInstanceHandle_.isObject() || + !getPublicInstanceFromInstanceHandle_.asObject(runtime).isFunction( + runtime)) { + return jsi::Value::null(); + } + return getPublicInstanceFromInstanceHandle_.asObject(runtime) + .asFunction(runtime) + .call(runtime, instanceHandle); + }; + + notifyMutationObservers_ = std::move(notifyMutationObservers); + + auto onMutationsCallback = [&](std::vector &records) { + return onMutations(records); + }; + + mutationObserverManager_.connect(uiManager, std::move(onMutationsCallback)); +} + +void NativeMutationObserver::disconnect(jsi::Runtime &runtime) { + auto &uiManager = getUIManagerFromRuntime(runtime); + mutationObserverManager_.disconnect(uiManager); + getPublicInstanceFromInstanceHandle_ = jsi::Value::undefined(); + getPublicInstanceFromShadowNode_ = nullptr; + notifyMutationObservers_ = nullptr; +} + +std::vector NativeMutationObserver::takeRecords( + jsi::Runtime & /*runtime*/) { + notifiedMutationObservers_ = false; + + std::vector records; + pendingRecords_.swap(records); + return records; +} + +std::vector +NativeMutationObserver::getPublicInstancesFromShadowNodes( + std::vector const &shadowNodes) const { + std::vector publicInstances; + publicInstances.reserve(shadowNodes.size()); + + for (auto const &shadowNode : shadowNodes) { + publicInstances.push_back(getPublicInstanceFromShadowNode_(*shadowNode)); + } + + return publicInstances; +} + +void NativeMutationObserver::onMutations( + std::vector &records) { + SystraceSection s("NativeMutationObserver::onMutations"); + + for (auto const &record : records) { + pendingRecords_.emplace_back(NativeMutationRecord{ + record.mutationObserverId, + // FIXME(T157129303) Instead of assuming we can call into JS from here, + // we should use an API that explicitly indicates it. + getPublicInstanceFromShadowNode_(*record.targetShadowNode), + getPublicInstancesFromShadowNodes(record.addedShadowNodes), + getPublicInstancesFromShadowNodes(record.removedShadowNodes)}); + } + + notifyMutationObserversIfNecessary(); +} + +/** + * This method allows us to avoid scheduling multiple calls to notify observers + * in the JS thread. We schedule one and skip subsequent ones (we just append + * the records to the pending list and wait for the scheduled task to consume + * all of them). + */ +void NativeMutationObserver::notifyMutationObserversIfNecessary() { + bool dispatchNotification = false; + + if (!pendingRecords_.empty() && !notifiedMutationObservers_) { + notifiedMutationObservers_ = true; + dispatchNotification = true; + } + + if (dispatchNotification) { + SystraceSection s("NativeMutationObserver::notifyObservers"); + notifyMutationObservers_(); + } +} + +} // namespace facebook::react diff --git a/packages/react-native/Libraries/MutationObserver/NativeMutationObserver.h b/packages/react-native/Libraries/MutationObserver/NativeMutationObserver.h new file mode 100644 index 00000000000..e5ad900267f --- /dev/null +++ b/packages/react-native/Libraries/MutationObserver/NativeMutationObserver.h @@ -0,0 +1,105 @@ +/* + * 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. + */ + +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace facebook::react { + +using NativeMutationObserverObserveOptions = + NativeMutationObserverCxxBaseNativeMutationObserverObserveOptions< + // mutationObserverId + MutationObserverId, + // targetShadowNode + jsi::Object, + // subtree + bool>; + +template <> +struct Bridging + : NativeMutationObserverCxxBaseNativeMutationObserverObserveOptionsBridging< + // mutationObserverId + MutationObserverId, + // targetShadowNode + jsi::Object, + // subtree + bool> {}; + +using NativeMutationRecord = NativeMutationObserverCxxBaseNativeMutationRecord< + // mutationObserverId + MutationObserverId, + // target + jsi::Value, + // addedNodes + std::vector, + // removedNodes + std::vector>; + +template <> +struct Bridging + : NativeMutationObserverCxxBaseNativeMutationRecordBridging< + // mutationObserverId + MutationObserverId, + // target + jsi::Value, + // addedNodes + std::vector, + // removedNodes + std::vector> {}; + +class NativeMutationObserver + : public NativeMutationObserverCxxSpec, + std::enable_shared_from_this { + public: + NativeMutationObserver(std::shared_ptr jsInvoker); + + void observe( + jsi::Runtime &runtime, + NativeMutationObserverObserveOptions options); + + void unobserve( + jsi::Runtime &runtime, + MutationObserverId mutationObserverId, + jsi::Object targetShadowNode); + + void connect( + jsi::Runtime &runtime, + AsyncCallback<> notifyMutationObservers, + jsi::Function getPublicInstanceFromInstanceHandle); + + void disconnect(jsi::Runtime &runtime); + + std::vector takeRecords(jsi::Runtime &runtime); + + private: + MutationObserverManager mutationObserverManager_{}; + + std::vector pendingRecords_; + + // This is passed to `connect` so we can retain references to public instances + // when mutation occur, before React cleans up unmounted instances. + jsi::Value getPublicInstanceFromInstanceHandle_ = jsi::Value::undefined(); + std::function + getPublicInstanceFromShadowNode_; + + bool notifiedMutationObservers_{}; + std::function notifyMutationObservers_; + + void onMutations(std::vector &records); + void notifyMutationObserversIfNecessary(); + + std::vector getPublicInstancesFromShadowNodes( + std::vector const &shadowNodes) const; +}; + +} // namespace facebook::react diff --git a/packages/react-native/Libraries/MutationObserver/NativeMutationObserver.js b/packages/react-native/Libraries/MutationObserver/NativeMutationObserver.js new file mode 100644 index 00000000000..024102c96c8 --- /dev/null +++ b/packages/react-native/Libraries/MutationObserver/NativeMutationObserver.js @@ -0,0 +1,58 @@ +/** + * 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 {TurboModule} from '../TurboModule/RCTExport'; + +import * as TurboModuleRegistry from '../TurboModule/TurboModuleRegistry'; + +export type MutationObserverId = number; + +// These types are not supported by the codegen. +type ShadowNode = mixed; +type InstanceHandle = mixed; +type ReactNativeElement = mixed; +type ReadOnlyNode = mixed; + +export type NativeMutationRecord = { + mutationObserverId: MutationObserverId, + target: ReactNativeElement, + addedNodes: $ReadOnlyArray, + removedNodes: $ReadOnlyArray, + ... +}; + +export type NativeMutationObserverObserveOptions = { + mutationObserverId: number, + targetShadowNode: ShadowNode, + subtree: boolean, +}; + +export interface Spec extends TurboModule { + +observe: (options: NativeMutationObserverObserveOptions) => void; + +unobserve: ( + mutationObserverId: number, + targetShadowNode: ShadowNode, + ) => void; + +connect: ( + notifyMutationObservers: () => void, + // We need this to retain the public instance before React removes the + // reference to it (which happen in mutations that remove nodes, or when + // nodes are removed between the change and the callback is executed in JS). + getPublicInstanceFromInstanceHandle: ( + instanceHandle: InstanceHandle, + ) => ReadOnlyNode, + ) => void; + +disconnect: () => void; + +takeRecords: () => $ReadOnlyArray; +} + +export default (TurboModuleRegistry.get( + 'NativeMutationObserverCxx', +): ?Spec); diff --git a/packages/react-native/Libraries/MutationObserver/__mocks__/NativeMutationObserver.js b/packages/react-native/Libraries/MutationObserver/__mocks__/NativeMutationObserver.js new file mode 100644 index 00000000000..d7ffb65520d --- /dev/null +++ b/packages/react-native/Libraries/MutationObserver/__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 '../../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 '../../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 = []; +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/packages/react-native/Libraries/ReactNative/__mocks__/FabricUIManager.js b/packages/react-native/Libraries/ReactNative/__mocks__/FabricUIManager.js index 36cbb700fdb..ddab78a2f14 100644 --- a/packages/react-native/Libraries/ReactNative/__mocks__/FabricUIManager.js +++ b/packages/react-native/Libraries/ReactNative/__mocks__/FabricUIManager.js @@ -24,7 +24,7 @@ import type { Spec as FabricUIManager, } from '../FabricUIManager'; -type NodeMock = { +export type NodeMock = { children: NodeSet, instanceHandle: InternalInstanceHandle, props: NodeProps, @@ -33,12 +33,12 @@ type NodeMock = { viewName: string, }; -function fromNode(node: Node): NodeMock { +export function fromNode(node: Node): NodeMock { // $FlowExpectedError[incompatible-return] return node; } -function toNode(node: NodeMock): Node { +export function toNode(node: NodeMock): Node { // $FlowExpectedError[incompatible-return] return node; } @@ -66,14 +66,10 @@ function ensureHostNode(node: Node): void { } } -function getAncestorsInCurrentTree( +function getAncestorsInChildSet( node: Node, + childSet: NodeSet, ): ?$ReadOnlyArray<[Node, number]> { - const childSet = roots.get(fromNode(node).rootTag); - if (childSet == null) { - return null; - } - const rootNode = toNode({ reactTag: 0, rootTag: fromNode(node).rootTag, @@ -96,6 +92,17 @@ function getAncestorsInCurrentTree( return null; } +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 []; @@ -113,8 +120,8 @@ function getAncestors(root: Node, node: Node): ?$ReadOnlyArray<[Node, number]> { return null; } -function getNodeInCurrentTree(node: Node): ?Node { - const ancestors = getAncestorsInCurrentTree(node); +export function getNodeInChildSet(node: Node, childSet: NodeSet): ?Node { + const ancestors = getAncestorsInChildSet(node, childSet); if (ancestors == null) { return null; } @@ -124,6 +131,15 @@ function getNodeInCurrentTree(node: Node): ?Node { return nodeInCurrentTree; } +function getNodeInCurrentTree(node: Node): ?Node { + const childSet = roots.get(fromNode(node).rootTag); + if (childSet == null) { + return null; + } + + return getNodeInChildSet(node, childSet); +} + function* dfs(node: ?Node): Iterator { if (node == null) { return; @@ -145,8 +161,20 @@ function hasDisplayNone(node: Node): boolean { interface IFabricUIManagerMock extends FabricUIManager { __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( ( @@ -210,6 +238,9 @@ const FabricUIManagerMock: IFabricUIManagerMock = { 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 => { @@ -466,6 +497,14 @@ const FabricUIManagerMock: IFabricUIManagerMock = { __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; diff --git a/packages/react-native/ReactCommon/react/renderer/observers/mutation/MutationObserver.cpp b/packages/react-native/ReactCommon/react/renderer/observers/mutation/MutationObserver.cpp new file mode 100644 index 00000000000..e59813a77d3 --- /dev/null +++ b/packages/react-native/ReactCommon/react/renderer/observers/mutation/MutationObserver.cpp @@ -0,0 +1,200 @@ +/* + * 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. + */ + +#include "MutationObserver.h" +#include +#include + +namespace facebook::react { + +MutationObserver::MutationObserver(MutationObserverId mutationObserverId) + : mutationObserverId_(mutationObserverId) {} + +void MutationObserver::observe( + ShadowNode::Shared targetShadowNode, + bool observeSubtree) { + if (observeSubtree) { + deeplyObservedShadowNodes_.push_back(targetShadowNode); + } else { + shallowlyObservedShadowNodes_.push_back(targetShadowNode); + } +} + +void MutationObserver::unobserve(ShadowNode const &targetShadowNode) { + // We don't know if it's being observed deeply or not, so we need to check + // both possibilities. + deeplyObservedShadowNodes_.erase( + std::remove_if( + deeplyObservedShadowNodes_.begin(), + deeplyObservedShadowNodes_.end(), + [&targetShadowNode](auto shadowNode) { + return ShadowNode::sameFamily(*shadowNode, targetShadowNode); + }), + deeplyObservedShadowNodes_.end()); + + shallowlyObservedShadowNodes_.erase( + std::remove_if( + shallowlyObservedShadowNodes_.begin(), + shallowlyObservedShadowNodes_.end(), + [&targetShadowNode](auto const shadowNode) { + return ShadowNode::sameFamily(*shadowNode, targetShadowNode); + }), + shallowlyObservedShadowNodes_.end()); +} + +bool MutationObserver::isObserving() const { + return !deeplyObservedShadowNodes_.empty() || + !shallowlyObservedShadowNodes_.empty(); +} + +static ShadowNode::Shared getShadowNodeInTree( + ShadowNode const &shadowNode, + ShadowNode const &newTree) { + auto ancestors = shadowNode.getFamily().getAncestors(newTree); + if (ancestors.empty()) { + return nullptr; + } + + auto pair = ancestors.rbegin(); + return pair->first.get().getChildren().at(pair->second); +} + +static ShadowNode::Shared findNodeOfSameFamily( + ShadowNode::ListOfShared const &list, + ShadowNode const &node) { + for (auto ¤t : list) { + if (ShadowNode::sameFamily(node, *current)) { + return current; + } + } + return nullptr; +} + +void MutationObserver::recordMutations( + RootShadowNode const &oldRootShadowNode, + RootShadowNode const &newRootShadowNode, + std::vector &recordedMutations) const { + // This tracks the nodes that have already been processed by this observer, + // so we avoid unnecessary work and duplicated entries. + SetOfShadowNodePointers processedNodes; + + // We go over the deeply observed nodes first to avoid skipping nodes that + // have only been checked shallowly. + for (auto targetShadowNode : deeplyObservedShadowNodes_) { + recordMutationsInTarget( + targetShadowNode, + oldRootShadowNode, + newRootShadowNode, + true, + recordedMutations, + processedNodes); + } + + for (auto targetShadowNode : shallowlyObservedShadowNodes_) { + recordMutationsInTarget( + targetShadowNode, + oldRootShadowNode, + newRootShadowNode, + false, + recordedMutations, + processedNodes); + } +} + +void MutationObserver::recordMutationsInTarget( + ShadowNode::Shared targetShadowNode, + RootShadowNode const &oldRootShadowNode, + RootShadowNode const &newRootShadowNode, + bool observeSubtree, + std::vector &recordedMutations, + SetOfShadowNodePointers &processedNodes) const { + // If the node isnt't present in the old tree, it's either: + // - A new node. In that case, the mutation happened in its parent, not in the + // node itself. + // - A non-existent node. In that case, there are no new mutations. + auto oldTargetShadowNode = + getShadowNodeInTree(*targetShadowNode, oldRootShadowNode); + if (!oldTargetShadowNode) { + return; + } + + // If the node isn't present in the new tree (and we didn't return in the + // previous check), it means the whole node was removed. In that case we don't + // record any mutations in the node itself (maybe in its parent if there are + // other observers set up). + auto newTargetShadowNode = + getShadowNodeInTree(*targetShadowNode, newRootShadowNode); + if (!newTargetShadowNode) { + return; + } + + recordMutationsInSubtrees( + std::move(targetShadowNode), + *oldTargetShadowNode, + *newTargetShadowNode, + observeSubtree, + recordedMutations, + processedNodes); +} + +void MutationObserver::recordMutationsInSubtrees( + ShadowNode::Shared targetShadowNode, + ShadowNode const &oldNode, + ShadowNode const &newNode, + bool observeSubtree, + std::vector &recordedMutations, + SetOfShadowNodePointers processedNodes) const { + bool isSameNode = &oldNode == &newNode; + // If the nodes are referentially equal, their children are also the same. + if (isSameNode || processedNodes.find(&newNode) != processedNodes.end()) { + return; + } + + processedNodes.insert(&newNode); + + auto oldChildren = oldNode.getChildren(); + auto newChildren = newNode.getChildren(); + + std::vector addedNodes; + std::vector removedNodes; + + // Check for removed nodes (and equal nodes for further inspection) + for (auto &oldChild : oldChildren) { + auto newChild = findNodeOfSameFamily(newChildren, *oldChild); + if (!newChild) { + removedNodes.push_back(oldChild); + } else if (observeSubtree) { + // Nodes are present in both tress. If `subtree` is set to true, + // we continue checking their children. + recordMutationsInSubtrees( + targetShadowNode, + *oldChild, + *newChild, + observeSubtree, + recordedMutations, + processedNodes); + } + } + + // Check for added nodes + for (auto &newChild : newChildren) { + auto oldChild = findNodeOfSameFamily(oldChildren, *newChild); + if (!oldChild) { + addedNodes.push_back(newChild); + } + } + + if (!addedNodes.empty() || !removedNodes.empty()) { + recordedMutations.emplace_back(MutationRecord{ + mutationObserverId_, + targetShadowNode, + std::move(addedNodes), + std::move(removedNodes)}); + } +} + +} // namespace facebook::react diff --git a/packages/react-native/ReactCommon/react/renderer/observers/mutation/MutationObserver.h b/packages/react-native/ReactCommon/react/renderer/observers/mutation/MutationObserver.h new file mode 100644 index 00000000000..b1677b93b64 --- /dev/null +++ b/packages/react-native/ReactCommon/react/renderer/observers/mutation/MutationObserver.h @@ -0,0 +1,64 @@ +/* + * 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. + */ + +#pragma once + +#include +#include +#include +#include + +namespace facebook::react { + +using MutationObserverId = int32_t; + +struct MutationRecord { + MutationObserverId mutationObserverId; + ShadowNode::Shared targetShadowNode; + std::vector addedShadowNodes; + std::vector removedShadowNodes; +}; + +class MutationObserver { + public: + MutationObserver(MutationObserverId intersectionObserverId); + + void observe(ShadowNode::Shared targetShadowNode, bool observeSubtree); + void unobserve(ShadowNode const &targetShadowNode); + + bool isObserving() const; + + void recordMutations( + RootShadowNode const &oldRootShadowNode, + RootShadowNode const &newRootShadowNode, + std::vector &recordedMutations) const; + + private: + MutationObserverId mutationObserverId_; + std::vector deeplyObservedShadowNodes_; + std::vector shallowlyObservedShadowNodes_; + + using SetOfShadowNodePointers = std::unordered_set; + + void recordMutationsInTarget( + ShadowNode::Shared targetShadowNode, + RootShadowNode const &oldRootShadowNode, + RootShadowNode const &newRootShadowNode, + bool observeSubtree, + std::vector &recordedMutations, + SetOfShadowNodePointers &processedNodes) const; + + void recordMutationsInSubtrees( + ShadowNode::Shared targetShadowNode, + ShadowNode const &oldNode, + ShadowNode const &newNode, + bool observeSubtree, + std::vector &recordedMutations, + SetOfShadowNodePointers processedNodes) const; +}; + +} // namespace facebook::react diff --git a/packages/react-native/ReactCommon/react/renderer/observers/mutation/MutationObserverManager.cpp b/packages/react-native/ReactCommon/react/renderer/observers/mutation/MutationObserverManager.cpp new file mode 100644 index 00000000000..75386a60710 --- /dev/null +++ b/packages/react-native/ReactCommon/react/renderer/observers/mutation/MutationObserverManager.cpp @@ -0,0 +1,142 @@ +/* + * 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. + */ + +#include "MutationObserverManager.h" +#include +#include +#include "MutationObserver.h" + +namespace facebook::react { + +MutationObserverManager::MutationObserverManager() = default; + +void MutationObserverManager::observe( + MutationObserverId mutationObserverId, + ShadowNode::Shared shadowNode, + bool observeSubtree, + UIManager const &uiManager) { + SystraceSection s("MutationObserverManager::observe"); + + auto surfaceId = shadowNode->getSurfaceId(); + + auto &observers = observersBySurfaceId_[surfaceId]; + + auto observerIt = observers.find(mutationObserverId); + if (observerIt == observers.end()) { + auto observer = MutationObserver{mutationObserverId}; + observer.observe(shadowNode, observeSubtree); + observers.insert({mutationObserverId, std::move(observer)}); + } else { + auto observer = observerIt->second; + observer.observe(shadowNode, observeSubtree); + } +} + +void MutationObserverManager::unobserve( + MutationObserverId mutationObserverId, + ShadowNode const &shadowNode) { + SystraceSection s("MutationObserverManager::unobserve"); + + auto surfaceId = shadowNode.getSurfaceId(); + + auto observersIt = observersBySurfaceId_.find(surfaceId); + if (observersIt == observersBySurfaceId_.end()) { + return; + } + + auto &observers = observersIt->second; + + auto observerIt = observers.find(mutationObserverId); + if (observerIt == observers.end()) { + return; + } + + auto &observer = observerIt->second; + + observer.unobserve(shadowNode); + + if (!observer.isObserving()) { + observers.erase(mutationObserverId); + } + + if (observers.empty()) { + observersBySurfaceId_.erase(surfaceId); + } +} + +void MutationObserverManager::connect( + UIManager &uiManager, + std::function &)> onMutations) { + SystraceSection s("MutationObserverManager::connect"); + + // Fail-safe in case the caller doesn't guarantee consistency. + if (commitHookRegistered_) { + return; + } + + onMutations_ = onMutations; + + uiManager.registerCommitHook(*this); + commitHookRegistered_ = true; +} + +void MutationObserverManager::disconnect(UIManager &uiManager) { + SystraceSection s("MutationObserverManager::disconnect"); + + // Fail-safe in case the caller doesn't guarantee consistency. + if (!commitHookRegistered_) { + return; + } + + uiManager.unregisterCommitHook(*this); + + onMutations_ = nullptr; + commitHookRegistered_ = false; +} + +void MutationObserverManager::commitHookWasRegistered( + UIManager const &uiManager) noexcept {} +void MutationObserverManager::commitHookWasUnregistered( + UIManager const &uiManager) noexcept {} + +RootShadowNode::Unshared MutationObserverManager::shadowTreeWillCommit( + ShadowTree const &shadowTree, + RootShadowNode::Shared const &oldRootShadowNode, + RootShadowNode::Unshared const &newRootShadowNode) noexcept { + runMutationObservations(shadowTree, *oldRootShadowNode, *newRootShadowNode); + return newRootShadowNode; +} + +void MutationObserverManager::runMutationObservations( + ShadowTree const &shadowTree, + RootShadowNode const &oldRootShadowNode, + RootShadowNode const &newRootShadowNode) { + SystraceSection s("MutationObserverManager::runMutationObservations"); + + auto surfaceId = shadowTree.getSurfaceId(); + + auto observersIt = observersBySurfaceId_.find(surfaceId); + if (observersIt == observersBySurfaceId_.end()) { + return; + } + + std::vector mutationRecords; + + auto &observers = observersIt->second; + for (const auto &[mutationObserverId, observer] : observers) { + observer.recordMutations( + oldRootShadowNode, newRootShadowNode, mutationRecords); + } + + if (!mutationRecords.empty()) { + onMutations_(mutationRecords); + } + + return; +} + +} // namespace facebook::react diff --git a/packages/react-native/ReactCommon/react/renderer/observers/mutation/MutationObserverManager.h b/packages/react-native/ReactCommon/react/renderer/observers/mutation/MutationObserverManager.h new file mode 100644 index 00000000000..c77e074d101 --- /dev/null +++ b/packages/react-native/ReactCommon/react/renderer/observers/mutation/MutationObserverManager.h @@ -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. + */ + +#pragma once + +#include +#include +#include +#include +#include "MutationObserver.h" + +namespace facebook::react { + +class MutationObserverManager final : public UIManagerCommitHook { + public: + MutationObserverManager(); + + void observe( + MutationObserverId mutationObserverId, + ShadowNode::Shared shadowNode, + bool observeSubtree, + UIManager const &uiManager); + + void unobserve( + MutationObserverId mutationObserverId, + ShadowNode const &shadowNode); + + void connect( + UIManager &uiManager, + std::function &)> onMutations); + + void disconnect(UIManager &uiManager); + +#pragma mark - UIManagerCommitHook + + void commitHookWasRegistered(UIManager const &uiManager) noexcept override; + void commitHookWasUnregistered(UIManager const &uiManager) noexcept override; + + RootShadowNode::Unshared shadowTreeWillCommit( + ShadowTree const &shadowTree, + RootShadowNode::Shared const &oldRootShadowNode, + RootShadowNode::Unshared const &newRootShadowNode) noexcept override; + + private: + std::unordered_map< + SurfaceId, + std::unordered_map> + observersBySurfaceId_; + + std::function &)> onMutations_; + bool commitHookRegistered_{}; + + void runMutationObservations( + ShadowTree const &shadowTree, + RootShadowNode const &oldRootShadowNode, + RootShadowNode const &newRootShadowNode); +}; + +} // namespace facebook::react diff --git a/packages/react-native/ReactCommon/react/renderer/uimanager/UIManager.h b/packages/react-native/ReactCommon/react/renderer/uimanager/UIManager.h index 31db46be2c0..6eb71dd549e 100644 --- a/packages/react-native/ReactCommon/react/renderer/uimanager/UIManager.h +++ b/packages/react-native/ReactCommon/react/renderer/uimanager/UIManager.h @@ -201,6 +201,10 @@ class UIManager final : public ShadowTreeDelegate { void reportMount(SurfaceId surfaceId) const; + bool hasBackgroundExecutor() const { + return backgroundExecutor_ != nullptr; + } + private: friend class UIManagerBinding; friend class Scheduler;