Basic implementation of IntersectionObserver (#37853)

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

This adds a basic implementation of `IntersectionObserver`. This will not be available yet and is only compatible with the new React Native architecture. This shouldn't show up in the changelog until we're ready to enable this in some form.

Changelog: [Internal]

## Context

This implements a basic version of the `IntersectionObserver` API (as defined on the Web) for React Native.

The motivation for this is supporting several use cases that are not possible in React Native at the moment, most importantly:
* Tracking paint times for elements in the screen.
* Tracking precise visibility of elements in the screen outside the context of a `VirtualizedList` (with an even better precision and control).

## Implementation details

This API is implemented as a native module that registers a mount hook in Fabric. Whenever there's a mount (an update to the UI of the host platform) we check for intersections in the shadow tree. The shadow tree contains information about the representation of the UI in a given time (including scroll position), which we use as source of truth for this in a cross-platform fashion. We rely on the fact that scroll position is updated regularly in the shadow tree to provide an up-to-date view into the UI.

**This implementation is completely cross-platform.** The only platform-specific part is the report of mounts in mount hooks from the host platform to Fabric.

This API uses a centralized entity in JS and native to handle registration of observers and dispatch of notifications. The dispatch the notifications for all observers in the same callback so we can easily change the sequencing of events easily (for example, we can change this to use microtasks when they're available in RN).

## Known limitations

* Timestamps are generally accurate for paint (as we report mounts right after they happen in the host platform), but **state updates (like scroll) are reported with a slight delay**.
  * In regular rendering, we first update the shadow tree and then mount it (paint), which is generally precise. In state updates, the UI is updated first and then the shadow tree is updated. In this case, we're not correctly reporting the timestamp of the scroll event (which we should be using) but the timestamp of when the update is processed. We'll fix this in a following diff.
* The IntersectionObserver API has a concept of initial notification. This is a mechanism to report the initial state of an observed target. If we start observing a target when it's added to the tree but before it's painted, this initial notification is supposed to provide initial paint time (which is important for performance measurements). This implements some logic to handle that correctly (we check if there is a pending transaction) but it's currently unreliable:
  * React Native does not currently block paint on microtasks or layout effects, so setting up an observer in these stages could have race conditions with actual mount. If mount happens before the observation is started, the initial notification doesn't report initial time but observation time. If mount happens after, the initial notification should be fine (except in some cases on Android, see the next point).
  * On Android, we have a push model to send mutations to the host platform, we means we consume transactions after commit, not immediately before mount. This breaks this logic and we need to figure out a solution in a following diff.

----

Reviewed By: sammy-SC, rshest

Differential Revision: D45278720

fbshipit-source-id: de350388c6325128f1cf73328779a9d3577a258a
This commit is contained in:
Rubén Norte
2023-06-23 02:56:04 -07:00
committed by Facebook GitHub Bot
parent c54092fe3b
commit 387bd70e49
19 changed files with 1758 additions and 2 deletions
@@ -0,0 +1,118 @@
/*
* 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 "NativeIntersectionObserver.h"
#include <react/renderer/core/ShadowNode.h>
#include <react/renderer/uimanager/UIManagerBinding.h>
#include <react/renderer/uimanager/primitives.h>
#include "Plugins.h"
std::shared_ptr<facebook::react::TurboModule>
NativeIntersectionObserverModuleProvider(
std::shared_ptr<facebook::react::CallInvoker> jsInvoker) {
return std::make_shared<facebook::react::NativeIntersectionObserver>(
std::move(jsInvoker));
}
namespace facebook::react {
NativeIntersectionObserver::NativeIntersectionObserver(
std::shared_ptr<CallInvoker> jsInvoker)
: NativeIntersectionObserverCxxSpec(std::move(jsInvoker)) {}
void NativeIntersectionObserver::observe(
jsi::Runtime &runtime,
NativeIntersectionObserverObserveOptions options) {
auto intersectionObserverId = options.intersectionObserverId;
auto shadowNode =
shadowNodeFromValue(runtime, std::move(options.targetShadowNode));
auto thresholds = options.thresholds;
auto &uiManager = getUIManagerFromRuntime(runtime);
intersectionObserverManager_.observe(
intersectionObserverId, shadowNode, thresholds, uiManager);
}
void NativeIntersectionObserver::unobserve(
jsi::Runtime &runtime,
IntersectionObserverObserverId intersectionObserverId,
jsi::Object targetShadowNode) {
auto shadowNode = shadowNodeFromValue(runtime, std::move(targetShadowNode));
intersectionObserverManager_.unobserve(intersectionObserverId, *shadowNode);
}
void NativeIntersectionObserver::connect(
jsi::Runtime &runtime,
AsyncCallback<> notifyIntersectionObserversCallback) {
auto &uiManager = getUIManagerFromRuntime(runtime);
intersectionObserverManager_.connect(
uiManager, notifyIntersectionObserversCallback);
}
void NativeIntersectionObserver::disconnect(jsi::Runtime &runtime) {
auto &uiManager = getUIManagerFromRuntime(runtime);
intersectionObserverManager_.disconnect(uiManager);
}
std::vector<NativeIntersectionObserverEntry>
NativeIntersectionObserver::takeRecords(jsi::Runtime &runtime) {
auto entries = intersectionObserverManager_.takeRecords();
std::vector<NativeIntersectionObserverEntry> nativeModuleEntries;
nativeModuleEntries.reserve(entries.size());
for (auto const &entry : entries) {
nativeModuleEntries.emplace_back(
convertToNativeModuleEntry(entry, runtime));
}
return nativeModuleEntries;
}
NativeIntersectionObserverEntry
NativeIntersectionObserver::convertToNativeModuleEntry(
IntersectionObserverEntry entry,
jsi::Runtime &runtime) {
RectAsTuple targetRect = {
entry.targetRect.origin.x,
entry.targetRect.origin.y,
entry.targetRect.size.width,
entry.targetRect.size.height};
RectAsTuple rootRect = {
entry.rootRect.origin.x,
entry.rootRect.origin.y,
entry.rootRect.size.width,
entry.rootRect.size.height};
std::optional<RectAsTuple> intersectionRect;
if (entry.intersectionRect) {
intersectionRect = {
entry.intersectionRect.value().origin.x,
entry.intersectionRect.value().origin.y,
entry.intersectionRect.value().size.width,
entry.intersectionRect.value().size.height};
}
NativeIntersectionObserverEntry nativeModuleEntry = {
entry.intersectionObserverId,
(*entry.shadowNode).getInstanceHandle(runtime),
targetRect,
rootRect,
intersectionRect,
entry.isIntersectingAboveThresholds,
entry.time,
};
return nativeModuleEntry;
}
UIManager &NativeIntersectionObserver::getUIManagerFromRuntime(
jsi::Runtime &runtime) {
return UIManagerBinding::getBinding(runtime)->getUIManager();
}
} // namespace facebook::react