From d804f43924387f6d83e95a5447c97b675ff94ae6 Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Tue, 15 Sep 2026 11:11:50 -0400 Subject: [PATCH 1/3] Process synchronous event beats in the frame that requested them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EventEmitter::experimental_flushSync only requests a beat, processed at the next EventBeat::induce. On iOS the run loop observer that induces the beat runs before Core Animation's commit observer, so a request made from layoutSubviews — inside CA's commit cycle — is only processed one frame later. AppleEventBeat now additionally schedules an induce in the display phase of the current commit cycle. Core Animation runs a commit as layout → display → commit, so a zero-sized layer marked as needing display during layout has its display called after the whole layout pass and before the transaction is committed. The layer is attached to the window of the requesting view: experimental_flushSync carries the tag of the emitting view — cached from its ShadowNodeFamily when the family is attached at creation, before the emitter is published, so reading it takes no lock — through EventDispatcher and EventQueue to EventBeat::requestSynchronous, with kNoTag meaning no view attribution; a no-argument overload keeps unattributed requesters unchanged. AppleEventBeat resolves the tag to the view's window layer through a resolver injected by RCTSurfacePresenter (findComponentViewWithTag: on the mounting registry, a nullable, non-creating, main-thread lookup). The requesting view's window is by definition the root of the layer tree whose layout emitted the request, so the flusher is guaranteed a display phase in the current commit cycle, including for content UIKit mounts in a window of its own, like a full screen modal or LogBox. Requests from several windows in one cycle each dirty their own layer; the first display to fire drains the queue and the rest no-op on the request flag. VirtualView's synchronous flushes get the same targeting through their own emitter. A related fix in EventBeat itself: a synchronous request is no longer stranded behind an already-scheduled asynchronous beat (it would silently lose its this-frame guarantee, and the leftover flag would make an unrelated later beat blocking). AppleEventBeat.cpp becomes .mm for the Objective-C. Covered by new unit tests in EventBeatTest.cpp, which drive the protected induce through a subclass standing in for the platform. The C++ API snapshots are regenerated; the deltas are the requestSynchronous overload pair, the resolver type, and the AppleEventBeat constructor and destructor. --- .../React/Fabric/AppleEventBeat.cpp | 31 --- .../React/Fabric/AppleEventBeat.h | 34 +++- .../React/Fabric/AppleEventBeat.mm | 124 ++++++++++++ .../React/Fabric/RCTSurfacePresenter.mm | 9 +- .../react/renderer/core/EventBeat.cpp | 13 +- .../react/renderer/core/EventBeat.h | 12 +- .../react/renderer/core/EventDispatcher.cpp | 4 +- .../react/renderer/core/EventDispatcher.h | 3 +- .../react/renderer/core/EventEmitter.cpp | 5 + .../react/renderer/core/EventEmitter.h | 3 +- .../react/renderer/core/EventQueue.cpp | 4 +- .../react/renderer/core/EventQueue.h | 3 +- .../runtimescheduler/tests/EventBeatTest.cpp | 185 ++++++++++++++++++ .../api-snapshots/ReactAndroidDebugCxx.api | 3 +- .../api-snapshots/ReactAndroidNewarchCxx.api | 3 +- .../api-snapshots/ReactAndroidReleaseCxx.api | 3 +- .../api-snapshots/ReactAppleDebugCxx.api | 9 +- .../api-snapshots/ReactAppleNewarchCxx.api | 9 +- .../api-snapshots/ReactAppleReleaseCxx.api | 9 +- .../api-snapshots/ReactCommonDebugCxx.api | 3 +- .../api-snapshots/ReactCommonNewarchCxx.api | 3 +- .../api-snapshots/ReactCommonReleaseCxx.api | 3 +- 22 files changed, 420 insertions(+), 55 deletions(-) delete mode 100644 packages/react-native/React/Fabric/AppleEventBeat.cpp create mode 100644 packages/react-native/React/Fabric/AppleEventBeat.mm create mode 100644 packages/react-native/ReactCommon/react/renderer/runtimescheduler/tests/EventBeatTest.cpp diff --git a/packages/react-native/React/Fabric/AppleEventBeat.cpp b/packages/react-native/React/Fabric/AppleEventBeat.cpp deleted file mode 100644 index 4a3d533a0cd9..000000000000 --- a/packages/react-native/React/Fabric/AppleEventBeat.cpp +++ /dev/null @@ -1,31 +0,0 @@ -/* - * 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 "AppleEventBeat.h" - -#include - -namespace facebook::react { - -AppleEventBeat::AppleEventBeat( - std::shared_ptr ownerBox, - std::unique_ptr uiRunLoopObserver, - RuntimeScheduler& runtimeScheduler) - : EventBeat(std::move(ownerBox), runtimeScheduler), - uiRunLoopObserver_(std::move(uiRunLoopObserver)) { - uiRunLoopObserver_->setDelegate(this); - uiRunLoopObserver_->enable(); -} - -void AppleEventBeat::activityDidChange( - const RunLoopObserver::Delegate* delegate, - RunLoopObserver::Activity /*activity*/) const noexcept { - react_native_assert(delegate == this); - induce(); -} - -} // namespace facebook::react diff --git a/packages/react-native/React/Fabric/AppleEventBeat.h b/packages/react-native/React/Fabric/AppleEventBeat.h index 256e0f0983ad..4124fc7860aa 100644 --- a/packages/react-native/React/Fabric/AppleEventBeat.h +++ b/packages/react-native/React/Fabric/AppleEventBeat.h @@ -7,10 +7,18 @@ #pragma once +#include +#include +#include + +#import + #include #include #include +@class RCTEventBeatFlusherLayer; + namespace facebook::react { class RuntimeScheduler; @@ -19,13 +27,34 @@ class RuntimeScheduler; * Event beat associated with JavaScript runtime. * The beat is called on `RuntimeExecutor`'s thread induced by the UI thread * event loop. + * + * A synchronous request made while Core Animation is laying out the current + * frame (the run loop observer that induces the beat has already run at that + * point) is additionally induced from the display phase of the same commit + * cycle, so that its effects are mounted before the frame is presented. The + * induce is scheduled on the layer of the requesting view's window — the root + * of the tree Core Animation is laying out when the request is made from + * layout. */ class AppleEventBeat : public EventBeat, public RunLoopObserver::Delegate { public: + /* + * Resolves the layer of the window containing the view with the given tag. + * Called on the main thread; returns nil when the view is not mounted or + * not attached to a window. + */ + using WindowLayerResolver = std::function; + AppleEventBeat( std::shared_ptr ownerBox, std::unique_ptr uiRunLoopObserver, - RuntimeScheduler &RuntimeScheduler); + RuntimeScheduler &RuntimeScheduler, + WindowLayerResolver windowLayerResolver); + + ~AppleEventBeat() override; + + using EventBeat::requestSynchronous; + void requestSynchronous(Tag tag) const override; #pragma mark - RunLoopObserver::Delegate @@ -34,6 +63,9 @@ class AppleEventBeat : public EventBeat, public RunLoopObserver::Delegate { private: std::unique_ptr uiRunLoopObserver_; + WindowLayerResolver windowLayerResolver_; + NSMapTable *layers_; + void (^onDisplay_)(void); }; } // namespace facebook::react diff --git a/packages/react-native/React/Fabric/AppleEventBeat.mm b/packages/react-native/React/Fabric/AppleEventBeat.mm new file mode 100644 index 000000000000..d4ca6a9d5986 --- /dev/null +++ b/packages/react-native/React/Fabric/AppleEventBeat.mm @@ -0,0 +1,124 @@ +/* + * 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 "AppleEventBeat.h" + +#import +#import + +#include + +/* + * A zero-sized layer whose only purpose is to run a callback during the + * display phase of a Core Animation commit. Core Animation processes a commit + * as layout → display → (repeat until stable) → commit, so a layer marked as + * needing display during the layout phase has its `display` called after the + * whole layout pass but before the transaction is committed. + */ +@interface RCTEventBeatFlusherLayer : CALayer +- (instancetype)initWithOnDisplay:(void (^)(void))onDisplay; +@end + +@implementation RCTEventBeatFlusherLayer { + void (^_onDisplay)(void); +} + +- (instancetype)initWithOnDisplay:(void (^)(void))onDisplay +{ + if (self = [super init]) { + _onDisplay = [onDisplay copy]; + self.frame = CGRectZero; + } + return self; +} + +- (void)display +{ + _onDisplay(); +} + +// The layer is not a visual element; never participate in animations. +- (id)actionForKey:(NSString *)event +{ + return nil; +} + +@end + +namespace facebook::react { + +AppleEventBeat::AppleEventBeat( + std::shared_ptr ownerBox, + std::unique_ptr uiRunLoopObserver, + RuntimeScheduler &runtimeScheduler, + WindowLayerResolver windowLayerResolver) + : EventBeat(std::move(ownerBox), runtimeScheduler), + uiRunLoopObserver_(std::move(uiRunLoopObserver)), + windowLayerResolver_(std::move(windowLayerResolver)), + layers_([NSMapTable weakToStrongObjectsMapTable]) +{ + std::weak_ptr weakOwner = ownerBox_->owner; + onDisplay_ = ^{ + // The owner (indirectly) retains the event beat; if it is gone, so is + // the beat this induces. + auto owner = weakOwner.lock(); + if (!owner) { + return; + } + this->induce(); + }; + + uiRunLoopObserver_->setDelegate(this); + uiRunLoopObserver_->enable(); +} + +AppleEventBeat::~AppleEventBeat() +{ + // The beat can be destroyed on any thread; layer mutations belong on the + // main thread. The block only retains the layers, and a display happening + // before it executes is made safe by the owner check above. + NSMapTable *layers = layers_; + RCTExecuteOnMainQueue(^{ + for (RCTEventBeatFlusherLayer *layer in layers.objectEnumerator) { + [layer removeFromSuperlayer]; + } + [layers removeAllObjects]; + }); +} + +void AppleEventBeat::requestSynchronous(Tag tag) const +{ + EventBeat::requestSynchronous(tag); + + if (tag == kNoTag || !RCTIsMainQueue()) { + return; + } + CALayer *hostLayer = windowLayerResolver_ ? windowLayerResolver_(tag) : nil; + if (hostLayer == nil) { + return; + } + RCTEventBeatFlusherLayer *layer = [layers_ objectForKey:hostLayer]; + if (layer == nil) { + layer = [[RCTEventBeatFlusherLayer alloc] initWithOnDisplay:onDisplay_]; + [layers_ setObject:layer forKey:hostLayer]; + } + if (layer.superlayer != hostLayer) { + [layer removeFromSuperlayer]; + [hostLayer addSublayer:layer]; + } + [layer setNeedsDisplay]; +} + +void AppleEventBeat::activityDidChange( + const RunLoopObserver::Delegate *delegate, + RunLoopObserver::Activity /*activity*/) const noexcept +{ + react_native_assert(delegate == this); + induce(); +} + +} // namespace facebook::react diff --git a/packages/react-native/React/Fabric/RCTSurfacePresenter.mm b/packages/react-native/React/Fabric/RCTSurfacePresenter.mm index 0e4bbe376463..303523b983a6 100644 --- a/packages/react-native/React/Fabric/RCTSurfacePresenter.mm +++ b/packages/react-native/React/Fabric/RCTSurfacePresenter.mm @@ -292,11 +292,16 @@ - (RCTScheduler *)_createScheduler toolbox.runtimeExecutor = runtimeExecutor; toolbox.bridgelessBindingsExecutor = _bridgelessBindingsExecutor; + RCTMountingManager *mountingManager = _mountingManager; toolbox.eventBeatFactory = - [runtimeScheduler](std::shared_ptr ownerBox) -> std::unique_ptr { + [runtimeScheduler, mountingManager](std::shared_ptr ownerBox) -> std::unique_ptr { auto runLoopObserver = std::make_unique(RunLoopObserver::Activity::BeforeWaiting, ownerBox->owner); - return std::make_unique(std::move(ownerBox), std::move(runLoopObserver), *runtimeScheduler); + auto windowLayerResolver = [mountingManager](Tag tag) -> CALayer * { + return [mountingManager.componentViewRegistry findComponentViewWithTag:tag].window.layer; + }; + return std::make_unique( + std::move(ownerBox), std::move(runLoopObserver), *runtimeScheduler, std::move(windowLayerResolver)); }; RCTScheduler *scheduler = [[RCTScheduler alloc] initWithToolbox:toolbox]; diff --git a/packages/react-native/ReactCommon/react/renderer/core/EventBeat.cpp b/packages/react-native/ReactCommon/react/renderer/core/EventBeat.cpp index cdb05f4719fd..e8cf9ffdda29 100644 --- a/packages/react-native/ReactCommon/react/renderer/core/EventBeat.cpp +++ b/packages/react-native/ReactCommon/react/renderer/core/EventBeat.cpp @@ -26,6 +26,10 @@ void EventBeat::request() const { } void EventBeat::requestSynchronous() const { + requestSynchronous(kNoTag); +} + +void EventBeat::requestSynchronous(Tag /*tag*/) const { react_native_assert( beatCallback_ && "Unexpected state: EventBeat::setBeatCallback was not called before EventBeat::requestSynchronous."); @@ -53,7 +57,14 @@ void EventBeat::induce() const { isEventBeatRequested_ = false; if (isBeatCallbackScheduled_) { - return; + // An asynchronous beat is already scheduled but has not run yet. A + // synchronous request must not be stranded behind it (it would silently + // lose its this-frame guarantee, and the leftover flag would make an + // unrelated later beat blocking), so it proceeds and processes the queue + // now; the already scheduled beat will simply find an empty queue. + if (!isSynchronousRequested_) { + return; + } } isBeatCallbackScheduled_ = true; diff --git a/packages/react-native/ReactCommon/react/renderer/core/EventBeat.h b/packages/react-native/ReactCommon/react/renderer/core/EventBeat.h index 6f25d5f45c69..561a72472cdb 100644 --- a/packages/react-native/ReactCommon/react/renderer/core/EventBeat.h +++ b/packages/react-native/ReactCommon/react/renderer/core/EventBeat.h @@ -9,6 +9,7 @@ #include +#include #include #include #include @@ -110,8 +111,17 @@ class EventBeat { * thread────────────────────┴─────────────────────────┴▶ * Both JS and UI thread are * blocked. + * + * `tag` is the view the request originates from, or `kNoTag` when unknown. + * Platform implementations use it to schedule an induce where that view + * renders, and fall back to their ordinary beat timing without it. + */ + virtual void requestSynchronous(Tag tag) const; + + /* + * Convenience for requesters with no view attribution. */ - virtual void requestSynchronous() const; + void requestSynchronous() const; /* * The callback will be executed once a consumer (for example EventQueue) diff --git a/packages/react-native/ReactCommon/react/renderer/core/EventDispatcher.cpp b/packages/react-native/ReactCommon/react/renderer/core/EventDispatcher.cpp index 5fa5e6821a51..70f9c63ee77e 100644 --- a/packages/react-native/ReactCommon/react/renderer/core/EventDispatcher.cpp +++ b/packages/react-native/ReactCommon/react/renderer/core/EventDispatcher.cpp @@ -37,8 +37,8 @@ void EventDispatcher::dispatchEvent(RawEvent&& rawEvent) const { eventQueue_.enqueueEvent(std::move(rawEvent)); } -void EventDispatcher::experimental_flushSync() const { - eventQueue_.experimental_flushSync(); +void EventDispatcher::experimental_flushSync(Tag tag) const { + eventQueue_.experimental_flushSync(tag); } void EventDispatcher::dispatchStateUpdate( diff --git a/packages/react-native/ReactCommon/react/renderer/core/EventDispatcher.h b/packages/react-native/ReactCommon/react/renderer/core/EventDispatcher.h index 5aaa48364f9e..12d651c42392 100644 --- a/packages/react-native/ReactCommon/react/renderer/core/EventDispatcher.h +++ b/packages/react-native/ReactCommon/react/renderer/core/EventDispatcher.h @@ -14,6 +14,7 @@ #include #include #include +#include #include #include #include @@ -46,7 +47,7 @@ class EventDispatcher { /* * Experimental API exposed to support EventEmitter::experimental_flushSync. */ - void experimental_flushSync() const; + void experimental_flushSync(Tag tag) const; /* * Dispatches a raw event with asynchronous batched priority. Before the diff --git a/packages/react-native/ReactCommon/react/renderer/core/EventEmitter.cpp b/packages/react-native/ReactCommon/react/renderer/core/EventEmitter.cpp index 2d6ac50e3730..be00eccfa140 100644 --- a/packages/react-native/ReactCommon/react/renderer/core/EventEmitter.cpp +++ b/packages/react-native/ReactCommon/react/renderer/core/EventEmitter.cpp @@ -7,6 +7,8 @@ #include "EventEmitter.h" +#include + #include #include #include @@ -231,6 +233,9 @@ void EventEmitter::setEnabled(bool enabled) { void EventEmitter::setShadowNodeFamily( std::weak_ptr shadowNodeFamily) { + if (auto family = shadowNodeFamily.lock()) { + tag_ = family->getTag(); + } shadowNodeFamily_ = std::move(shadowNodeFamily); } diff --git a/packages/react-native/ReactCommon/react/renderer/core/EventEmitter.h b/packages/react-native/ReactCommon/react/renderer/core/EventEmitter.h index 8863d9a6f4fe..4e00fb1347a9 100644 --- a/packages/react-native/ReactCommon/react/renderer/core/EventEmitter.h +++ b/packages/react-native/ReactCommon/react/renderer/core/EventEmitter.h @@ -77,7 +77,7 @@ class EventEmitter { } syncFunc(); - eventDispatcher->experimental_flushSync(); + eventDispatcher->experimental_flushSync(tag_); } /* @@ -134,6 +134,7 @@ class EventEmitter { friend class UIManagerBinding; SharedEventTarget eventTarget_; + Tag tag_{kNoTag}; std::weak_ptr shadowNodeFamily_; EventDispatcher::Weak eventDispatcher_; diff --git a/packages/react-native/ReactCommon/react/renderer/core/EventQueue.cpp b/packages/react-native/ReactCommon/react/renderer/core/EventQueue.cpp index 6e99fd71bb7a..27b4ee16df91 100644 --- a/packages/react-native/ReactCommon/react/renderer/core/EventQueue.cpp +++ b/packages/react-native/ReactCommon/react/renderer/core/EventQueue.cpp @@ -86,8 +86,8 @@ void EventQueue::onEnqueue() const { eventBeat_->request(); } -void EventQueue::experimental_flushSync() const { - eventBeat_->requestSynchronous(); +void EventQueue::experimental_flushSync(Tag tag) const { + eventBeat_->requestSynchronous(tag); } void EventQueue::onBeat(jsi::Runtime& runtime) const { diff --git a/packages/react-native/ReactCommon/react/renderer/core/EventQueue.h b/packages/react-native/ReactCommon/react/renderer/core/EventQueue.h index 93fc8119e72d..d67875ac5fd1 100644 --- a/packages/react-native/ReactCommon/react/renderer/core/EventQueue.h +++ b/packages/react-native/ReactCommon/react/renderer/core/EventQueue.h @@ -9,6 +9,7 @@ #include +#include #include #include #include @@ -63,7 +64,7 @@ class EventQueue { /* * Experimental API exposed to support EventEmitter::experimental_flushSync. */ - void experimental_flushSync() const; + void experimental_flushSync(Tag tag) const; protected: /* diff --git a/packages/react-native/ReactCommon/react/renderer/runtimescheduler/tests/EventBeatTest.cpp b/packages/react-native/ReactCommon/react/renderer/runtimescheduler/tests/EventBeatTest.cpp new file mode 100644 index 000000000000..d64d41f1328c --- /dev/null +++ b/packages/react-native/ReactCommon/react/renderer/runtimescheduler/tests/EventBeatTest.cpp @@ -0,0 +1,185 @@ +/* + * 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 +#include +#include +#include +#include +#include +#include +#include + +#include "StubQueue.h" + +namespace facebook::react { + +class EventBeatTestFeatureFlags : public ReactNativeFeatureFlagsDefaults { + public: + bool enableBridgelessArchitecture() override { + return true; + } +}; + +/* + * `induce` is protected: production code induces from platform beat + * subclasses. The tests drive it directly, standing in for the platform. + */ +class TestEventBeat : public EventBeat { + public: + using EventBeat::EventBeat; + using EventBeat::induce; +}; + +class EventBeatTest : public testing::Test { + protected: + void SetUp() override { + ReactNativeFeatureFlags::dangerouslyReset(); + ReactNativeFeatureFlags::override( + std::make_unique()); + + runtime_ = facebook::hermes::makeHermesRuntime( + ::hermes::vm::RuntimeConfig::Builder().build()); + stubQueue_ = std::make_unique(); + + RuntimeExecutor runtimeExecutor = + [this]( + std::function&& callback) { + stubQueue_->runOnQueue([this, callback = std::move(callback)]() { + callback(*runtime_); + }); + }; + + runtimeScheduler_ = std::make_unique(runtimeExecutor); + + ownerBox_ = std::make_shared(); + owner_ = std::make_shared(0); + ownerBox_->owner = owner_; + eventBeat_ = std::make_unique(ownerBox_, *runtimeScheduler_); + } + + void TearDown() override { + ReactNativeFeatureFlags::dangerouslyReset(); + } + + std::unique_ptr runtime_; + std::unique_ptr stubQueue_; + std::unique_ptr runtimeScheduler_; + std::shared_ptr ownerBox_; + std::shared_ptr owner_; + std::unique_ptr eventBeat_; +}; + +TEST_F(EventBeatTest, induceWithoutRequestIsNoop) { + int beatCount = 0; + eventBeat_->setBeatCallback( + [&beatCount](jsi::Runtime& /*runtime*/) { beatCount++; }); + + eventBeat_->induce(); + + EXPECT_EQ(beatCount, 0); + EXPECT_EQ(stubQueue_->size(), 0); +} + +TEST_F(EventBeatTest, synchronousRequestIsProcessedAtInduce) { + int beatCount = 0; + eventBeat_->setBeatCallback( + [&beatCount](jsi::Runtime& /*runtime*/) { beatCount++; }); + + eventBeat_->requestSynchronous(); + EXPECT_EQ(beatCount, 0); + + // Platform implementations induce the beat at a point where the effects of + // synchronous events can still make the current frame (the display phase on + // Apple, before the draw on Android). The beat callback runs synchronously + // before `induce` returns, with both threads blocked. + std::thread driver([this]() { + stubQueue_->waitForTask(); + stubQueue_->tick(); + }); + eventBeat_->induce(); + driver.join(); + + EXPECT_EQ(beatCount, 1); + + // The request was consumed: another induce does nothing. + eventBeat_->induce(); + EXPECT_EQ(beatCount, 1); + EXPECT_EQ(stubQueue_->size(), 0); +} + +TEST_F(EventBeatTest, requestMadeDuringBeatIsProcessedByASubsequentInduce) { + int beatCount = 0; + eventBeat_->setBeatCallback([&](jsi::Runtime& /*runtime*/) { + beatCount++; + if (beatCount == 1) { + // A synchronous request made from within the beat (e.g. an event whose + // handler causes another synchronous event). Platform implementations + // defer the induce for it (the display phase flusher on Apple, the next + // pre-draw on Android) rather than inducing from within the beat. + eventBeat_->requestSynchronous(); + } + }); + + eventBeat_->requestSynchronous(); + std::thread driver([this]() { + stubQueue_->waitForTask(); + stubQueue_->tick(); + }); + eventBeat_->induce(); + driver.join(); + + EXPECT_EQ(beatCount, 1); + + // The request made during the beat is not lost: the next induce processes + // it. + std::thread driver2([this]() { + stubQueue_->waitForTask(); + stubQueue_->tick(); + }); + eventBeat_->induce(); + driver2.join(); + + EXPECT_EQ(beatCount, 2); +} + +TEST_F(EventBeatTest, synchronousRequestIsNotStrandedBehindScheduledBeat) { + int beatCount = 0; + eventBeat_->setBeatCallback( + [&beatCount](jsi::Runtime& /*runtime*/) { beatCount++; }); + + // An asynchronous beat is scheduled but has not run yet. + eventBeat_->request(); + eventBeat_->induce(); + EXPECT_EQ(beatCount, 0); + + // A synchronous request arriving now must still be processed by its induce + // instead of being silently deferred behind the scheduled beat. + eventBeat_->requestSynchronous(); + std::thread inducer([this]() { eventBeat_->induce(); }); + + // Wait until the synchronous access request joins the already-queued work + // item, so that the tick order below is deterministic. + stubQueue_->waitForTasks(2); + + // The scheduled beat's work item yields to the pending synchronous access + // without executing. + stubQueue_->tick(); + EXPECT_EQ(beatCount, 0); + + // The synchronous access processes the beat within its induce. + stubQueue_->tick(); + inducer.join(); + EXPECT_EQ(beatCount, 1); + + // The beat that yielded resumes afterwards; it was not lost. + stubQueue_->tick(); + EXPECT_EQ(beatCount, 2); + EXPECT_EQ(stubQueue_->size(), 0); +} + +} // namespace facebook::react diff --git a/scripts/cxx-api/api-snapshots/ReactAndroidDebugCxx.api b/scripts/cxx-api/api-snapshots/ReactAndroidDebugCxx.api index eaa69cfd1d93..711727372d10 100644 --- a/scripts/cxx-api/api-snapshots/ReactAndroidDebugCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAndroidDebugCxx.api @@ -2235,8 +2235,9 @@ class facebook::react::EventBeat { public using BeatCallback = std::function; public using Factory = std::function(std::shared_ptr ownerBox)>; public virtual void request() const; - public virtual void requestSynchronous() const; + public virtual void requestSynchronous(facebook::react::Tag tag) const; public virtual ~EventBeat() = default; + public void requestSynchronous() const; public void setBeatCallback(facebook::react::EventBeat::BeatCallback beatCallback); } diff --git a/scripts/cxx-api/api-snapshots/ReactAndroidNewarchCxx.api b/scripts/cxx-api/api-snapshots/ReactAndroidNewarchCxx.api index 6ef175742788..a0c64b5e2027 100644 --- a/scripts/cxx-api/api-snapshots/ReactAndroidNewarchCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAndroidNewarchCxx.api @@ -2218,8 +2218,9 @@ class facebook::react::EventBeat { public using BeatCallback = std::function; public using Factory = std::function(std::shared_ptr ownerBox)>; public virtual void request() const; - public virtual void requestSynchronous() const; + public virtual void requestSynchronous(facebook::react::Tag tag) const; public virtual ~EventBeat() = default; + public void requestSynchronous() const; public void setBeatCallback(facebook::react::EventBeat::BeatCallback beatCallback); } diff --git a/scripts/cxx-api/api-snapshots/ReactAndroidReleaseCxx.api b/scripts/cxx-api/api-snapshots/ReactAndroidReleaseCxx.api index 0bfdc66232f0..cf939dd1883d 100644 --- a/scripts/cxx-api/api-snapshots/ReactAndroidReleaseCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAndroidReleaseCxx.api @@ -2233,8 +2233,9 @@ class facebook::react::EventBeat { public using BeatCallback = std::function; public using Factory = std::function(std::shared_ptr ownerBox)>; public virtual void request() const; - public virtual void requestSynchronous() const; + public virtual void requestSynchronous(facebook::react::Tag tag) const; public virtual ~EventBeat() = default; + public void requestSynchronous() const; public void setBeatCallback(facebook::react::EventBeat::BeatCallback beatCallback); } diff --git a/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api index 5c09093c0667..42352381c992 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api @@ -4235,8 +4235,12 @@ class facebook::react::AppRegistryBinding { } class facebook::react::AppleEventBeat : public facebook::react::EventBeat, public facebook::react::RunLoopObserver::Delegate { - public AppleEventBeat(std::shared_ptr ownerBox, std::unique_ptr uiRunLoopObserver, facebook::react::RuntimeScheduler& RuntimeScheduler); + public AppleEventBeat(std::shared_ptr ownerBox, std::unique_ptr uiRunLoopObserver, facebook::react::RuntimeScheduler& RuntimeScheduler, facebook::react::AppleEventBeat::WindowLayerResolver windowLayerResolver); + public using WindowLayerResolver = std::function; public virtual void activityDidChange(const facebook::react::RunLoopObserver::Delegate* delegate, facebook::react::RunLoopObserver::Activity activity) const noexcept override; + public virtual void requestSynchronous(facebook::react::Tag tag) const override; + public void requestSynchronous() const; + public ~AppleEventBeat() override; } class facebook::react::AsyncArrayBuffer { @@ -4749,8 +4753,9 @@ class facebook::react::EventBeat { public using BeatCallback = std::function; public using Factory = std::function(std::shared_ptr ownerBox)>; public virtual void request() const; - public virtual void requestSynchronous() const; + public virtual void requestSynchronous(facebook::react::Tag tag) const; public virtual ~EventBeat() = default; + public void requestSynchronous() const; public void setBeatCallback(facebook::react::EventBeat::BeatCallback beatCallback); } diff --git a/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api index 957c81af10ac..71cae2c0b9dd 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api @@ -4222,8 +4222,12 @@ class facebook::react::AppRegistryBinding { } class facebook::react::AppleEventBeat : public facebook::react::EventBeat, public facebook::react::RunLoopObserver::Delegate { - public AppleEventBeat(std::shared_ptr ownerBox, std::unique_ptr uiRunLoopObserver, facebook::react::RuntimeScheduler& RuntimeScheduler); + public AppleEventBeat(std::shared_ptr ownerBox, std::unique_ptr uiRunLoopObserver, facebook::react::RuntimeScheduler& RuntimeScheduler, facebook::react::AppleEventBeat::WindowLayerResolver windowLayerResolver); + public using WindowLayerResolver = std::function; public virtual void activityDidChange(const facebook::react::RunLoopObserver::Delegate* delegate, facebook::react::RunLoopObserver::Activity activity) const noexcept override; + public virtual void requestSynchronous(facebook::react::Tag tag) const override; + public void requestSynchronous() const; + public ~AppleEventBeat() override; } class facebook::react::AsyncArrayBuffer { @@ -4725,8 +4729,9 @@ class facebook::react::EventBeat { public using BeatCallback = std::function; public using Factory = std::function(std::shared_ptr ownerBox)>; public virtual void request() const; - public virtual void requestSynchronous() const; + public virtual void requestSynchronous(facebook::react::Tag tag) const; public virtual ~EventBeat() = default; + public void requestSynchronous() const; public void setBeatCallback(facebook::react::EventBeat::BeatCallback beatCallback); } diff --git a/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api index 11b224c80b7a..3f521dd7bc00 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api @@ -4233,8 +4233,12 @@ class facebook::react::AppRegistryBinding { } class facebook::react::AppleEventBeat : public facebook::react::EventBeat, public facebook::react::RunLoopObserver::Delegate { - public AppleEventBeat(std::shared_ptr ownerBox, std::unique_ptr uiRunLoopObserver, facebook::react::RuntimeScheduler& RuntimeScheduler); + public AppleEventBeat(std::shared_ptr ownerBox, std::unique_ptr uiRunLoopObserver, facebook::react::RuntimeScheduler& RuntimeScheduler, facebook::react::AppleEventBeat::WindowLayerResolver windowLayerResolver); + public using WindowLayerResolver = std::function; public virtual void activityDidChange(const facebook::react::RunLoopObserver::Delegate* delegate, facebook::react::RunLoopObserver::Activity activity) const noexcept override; + public virtual void requestSynchronous(facebook::react::Tag tag) const override; + public void requestSynchronous() const; + public ~AppleEventBeat() override; } class facebook::react::AsyncArrayBuffer { @@ -4747,8 +4751,9 @@ class facebook::react::EventBeat { public using BeatCallback = std::function; public using Factory = std::function(std::shared_ptr ownerBox)>; public virtual void request() const; - public virtual void requestSynchronous() const; + public virtual void requestSynchronous(facebook::react::Tag tag) const; public virtual ~EventBeat() = default; + public void requestSynchronous() const; public void setBeatCallback(facebook::react::EventBeat::BeatCallback beatCallback); } diff --git a/scripts/cxx-api/api-snapshots/ReactCommonDebugCxx.api b/scripts/cxx-api/api-snapshots/ReactCommonDebugCxx.api index f7482dddbc3c..ca122453190b 100644 --- a/scripts/cxx-api/api-snapshots/ReactCommonDebugCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactCommonDebugCxx.api @@ -1477,8 +1477,9 @@ class facebook::react::EventBeat { public using BeatCallback = std::function; public using Factory = std::function(std::shared_ptr ownerBox)>; public virtual void request() const; - public virtual void requestSynchronous() const; + public virtual void requestSynchronous(facebook::react::Tag tag) const; public virtual ~EventBeat() = default; + public void requestSynchronous() const; public void setBeatCallback(facebook::react::EventBeat::BeatCallback beatCallback); } diff --git a/scripts/cxx-api/api-snapshots/ReactCommonNewarchCxx.api b/scripts/cxx-api/api-snapshots/ReactCommonNewarchCxx.api index c1a206b292de..cafcc5c43887 100644 --- a/scripts/cxx-api/api-snapshots/ReactCommonNewarchCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactCommonNewarchCxx.api @@ -1461,8 +1461,9 @@ class facebook::react::EventBeat { public using BeatCallback = std::function; public using Factory = std::function(std::shared_ptr ownerBox)>; public virtual void request() const; - public virtual void requestSynchronous() const; + public virtual void requestSynchronous(facebook::react::Tag tag) const; public virtual ~EventBeat() = default; + public void requestSynchronous() const; public void setBeatCallback(facebook::react::EventBeat::BeatCallback beatCallback); } diff --git a/scripts/cxx-api/api-snapshots/ReactCommonReleaseCxx.api b/scripts/cxx-api/api-snapshots/ReactCommonReleaseCxx.api index d39ff62ad642..dcdffd130c12 100644 --- a/scripts/cxx-api/api-snapshots/ReactCommonReleaseCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactCommonReleaseCxx.api @@ -1475,8 +1475,9 @@ class facebook::react::EventBeat { public using BeatCallback = std::function; public using Factory = std::function(std::shared_ptr ownerBox)>; public virtual void request() const; - public virtual void requestSynchronous() const; + public virtual void requestSynchronous(facebook::react::Tag tag) const; public virtual ~EventBeat() = default; + public void requestSynchronous() const; public void setBeatCallback(facebook::react::EventBeat::BeatCallback beatCallback); } From 74f286c07e5a39cff9178cf20353e57dd0a7861d Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Wed, 16 Sep 2026 12:02:27 -0400 Subject: [PATCH 2/3] Add an experimental_onSafeAreaInsetsChange view prop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reports the part of a view that is covered by the system UI, as a view prop: ```jsx { // insets: {top, right, bottom, left}, frame: {x, y, width, height} }} /> ``` `SafeAreaView` is deprecated in favour of `react-native-safe-area-context`, but core surfaces like LogBox and the element inspector cannot depend on the library, so core keeps a private copy of the deprecated component alive. The smallest primitive that lets both sides go away is native code reporting inset values to JavaScript — today the library's own `RNCSafeAreaProvider` component. This adds that primitive, with the payload the library already uses, so `SafeAreaProvider` can swap its native component for a plain `View`. Insets are relative to the view: one laid out inside the safe area reports zeros. That is what makes the prop composable and stops nested providers from double-padding. **Cost when unused.** The prop is a `bool` in `BaseViewProps`, like `onLayout`; native only observes the safe area when it is set. On iOS the flag is read from the props the view already holds and the last-sent insets live behind a single pointer ivar that stays nil unless the view observes; the only unconditional cost is a branch in `layoutSubviews`, `didMoveToWindow` and `safeAreaInsetsDidChange`. **Cost when used.** Events fire only when the *insets* change — the frame is in the payload but not in the trigger — so a view moving inside a scroll view emits nothing, and 50 observing rows scroll at the same frame times as zero. An observing view allocates nothing per frame on Android in the steady state. Benchmarked with the "Scroll benchmark" section of the new RNTester example. **Synchronous dispatch.** The event goes out through `EventEmitter::experimental_flushSync` as a `Discrete` event, so inset-driven layout is mounted in the frame the insets changed in — first mount included, and on rotation the padding animates with the transition instead of jumping after it. Edge cases covered: view flattening (the prop forms a stacking context so the host view cannot be optimized away), view recycling on both platforms, Android views fully clipped by an ancestor, and multi-window iPad. Folded in from review: the prop is forwarded through BaseViewManagerDelegate for components with generated delegates, and the event is exported from the native view config so it maps to the handler when native view configs are in use. Development warning for a view that reports its insets in a loop: The system UI does not move many times a second, so a sustained stream of inset events means the layout is feeding the insets back into the position of the observed view: it is offset by the insets it reports, which moves it out from under the system UI, which changes its insets. Every one of those events renders synchronously, so the loop is paid for in frames. `View` wraps the handler in development builds and warns once per view above ten events in a second. The check lives in the handler `View` passes down rather than in either platform's observer, so it covers iOS and Android with one implementation and surfaces in LogBox with a JavaScript stack. The production branch is the identity function, so the module stays out of the bundle, and the native prop is unaffected either way — function props are normalized to `true` before props are diffed, so wrapping does not produce an update. Counts are kept per view in a `WeakMap` keyed by the event target, so views that do not loop are never charged for it. RNTester grows the mistake it warns about, and a Fantom test with a mocked clock covers the rate, the once-per-view behaviour, per-view counting, and that the handler still receives its event. --- .../Libraries/Components/View/View.js | 22 ++ .../Components/View/ViewPropTypes.js | 27 ++ .../__tests__/ViewSafeAreaInsets-itest.js | 111 ++++++ .../ViewSafeAreaInsetsWarning-itest.js | 132 +++++++ .../NativeComponent/BaseViewConfig.android.js | 4 + .../NativeComponent/BaseViewConfig.ios.js | 4 + .../Libraries/Types/CoreEventTypes.js | 23 ++ .../View/RCTViewComponentView.mm | 115 ++++++ .../ReactAndroid/api/ReactAndroid.api | 2 + .../react/uimanager/BaseViewManager.java | 18 + .../uimanager/BaseViewManagerDelegate.kt | 2 + .../com/facebook/react/uimanager/ViewProps.kt | 1 + .../events/SafeAreaInsetsChangeEvent.kt | 65 +++ .../internal/SafeAreaInsetsObserver.kt | 212 ++++++++++ .../main/res/views/uimanager/values/ids.xml | 3 + .../components/view/BaseViewEventEmitter.cpp | 36 ++ .../components/view/BaseViewEventEmitter.h | 14 + .../components/view/BaseViewProps.cpp | 12 + .../renderer/components/view/BaseViewProps.h | 1 + .../components/view/ViewShadowNode.cpp | 3 +- .../components/view/HostPlatformViewProps.cpp | 4 + .../warnOnRepeatedSafeAreaInsetsChanges.js | 78 ++++ .../SafeAreaInsets/SafeAreaInsetsExample.js | 372 ++++++++++++++++++ .../js/utils/RNTesterList.android.js | 4 + .../rn-tester/js/utils/RNTesterList.ios.js | 4 + .../api-snapshots/ReactAndroidDebugCxx.api | 2 + .../api-snapshots/ReactAndroidNewarchCxx.api | 2 + .../api-snapshots/ReactAndroidReleaseCxx.api | 2 + .../api-snapshots/ReactAppleDebugCxx.api | 2 + .../api-snapshots/ReactAppleNewarchCxx.api | 2 + .../api-snapshots/ReactAppleReleaseCxx.api | 2 + .../api-snapshots/ReactCommonDebugCxx.api | 2 + .../api-snapshots/ReactCommonNewarchCxx.api | 2 + .../api-snapshots/ReactCommonReleaseCxx.api | 2 + 34 files changed, 1286 insertions(+), 1 deletion(-) create mode 100644 packages/react-native/Libraries/Components/View/__tests__/ViewSafeAreaInsets-itest.js create mode 100644 packages/react-native/Libraries/Components/View/__tests__/ViewSafeAreaInsetsWarning-itest.js create mode 100644 packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/events/SafeAreaInsetsChangeEvent.kt create mode 100644 packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/internal/SafeAreaInsetsObserver.kt create mode 100644 packages/react-native/src/private/components/view/warnOnRepeatedSafeAreaInsetsChanges.js create mode 100644 packages/rn-tester/js/examples/SafeAreaInsets/SafeAreaInsetsExample.js diff --git a/packages/react-native/Libraries/Components/View/View.js b/packages/react-native/Libraries/Components/View/View.js index 461f7707c7fa..dcf8dacd0d88 100644 --- a/packages/react-native/Libraries/Components/View/View.js +++ b/packages/react-native/Libraries/Components/View/View.js @@ -9,6 +9,7 @@ */ import type {HostInstance} from '../../../src/private/types/HostInstance'; +import type {SafeAreaInsetsChangeEvent} from '../../Types/CoreEventTypes'; import type {ViewProps} from './ViewPropTypes'; import TextAncestorContext from '../../Text/TextAncestorContext'; @@ -16,6 +17,15 @@ import ViewNativeComponent from './ViewNativeComponent'; import * as React from 'react'; import {use} from 'react'; +// Only development builds check for a view reporting its insets in a loop; the +// production branch keeps the handler as it is, and the module out of the bundle. +const warnOnRepeatedSafeAreaInsetsChanges: ( + onSafeAreaInsetsChange: (event: SafeAreaInsetsChangeEvent) => unknown, +) => (event: SafeAreaInsetsChangeEvent) => unknown = __DEV__ + ? require('../../../src/private/components/view/warnOnRepeatedSafeAreaInsetsChanges') + .default + : onSafeAreaInsetsChange => onSafeAreaInsetsChange; + export type ViewInstance = HostInstance; /** @@ -115,6 +125,18 @@ component View(ref?: React.RefSetter, ...props: ViewProps) { }; } + if (__DEV__) { + // Views are the only place the prop is used in practice, so the check for a + // view reporting its insets in a loop lives here rather than on every host + // component that inherits the prop. + const onSafeAreaInsetsChange = + resolvedProps.experimental_onSafeAreaInsetsChange; + if (onSafeAreaInsetsChange != null) { + resolvedProps.experimental_onSafeAreaInsetsChange = + warnOnRepeatedSafeAreaInsetsChanges(onSafeAreaInsetsChange); + } + } + const actualView = ref == null ? ( diff --git a/packages/react-native/Libraries/Components/View/ViewPropTypes.js b/packages/react-native/Libraries/Components/View/ViewPropTypes.js index 3d5fdd8db373..fb7aac2447f5 100644 --- a/packages/react-native/Libraries/Components/View/ViewPropTypes.js +++ b/packages/react-native/Libraries/Components/View/ViewPropTypes.js @@ -23,6 +23,7 @@ import type { LayoutRectangle, MouseEvent, PointerEvent, + SafeAreaInsetsChangeEvent, } from '../../Types/CoreEventTypes'; import type { AccessibilityActionEvent, @@ -63,6 +64,32 @@ type DirectEventProps = Readonly<{ */ onLayout?: ?(event: LayoutChangeEvent) => unknown, + /** + * Invoked when the part of this view that is covered by the system UI + * (status bar, navigation bar, home indicator, display cutouts, ...) + * changes, with: + * + * `{nativeEvent: {insets: {top, right, bottom, left}, frame: {x, y, width, height}}}` + * + * `insets` are relative to this view: an inset is only non-zero for the part + * of the view that actually overlaps the system UI. `frame` is the position + * of the view at the time of the event, relative to its enclosing view + * controller on iOS and to the window on Android; it does not trigger the + * event on its own, so it can be stale while the view moves without its + * insets changing. + * + * The event is dispatched synchronously, so the rendering it schedules is + * applied in the same frame the insets changed in. + * + * Setting this prop makes the view observe safe area changes; views without + * it are unaffected. + * + * @experimental + */ + experimental_onSafeAreaInsetsChange?: ?( + event: SafeAreaInsetsChangeEvent, + ) => unknown, + /** * When `accessible` is `true`, the system will invoke this function when the * user performs the magic tap gesture. diff --git a/packages/react-native/Libraries/Components/View/__tests__/ViewSafeAreaInsets-itest.js b/packages/react-native/Libraries/Components/View/__tests__/ViewSafeAreaInsets-itest.js new file mode 100644 index 000000000000..e361d265330e --- /dev/null +++ b/packages/react-native/Libraries/Components/View/__tests__/ViewSafeAreaInsets-itest.js @@ -0,0 +1,111 @@ +/** + * 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 '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; + +import type {HostInstance} from 'react-native/src/private/types/HostInstance'; + +import * as Fantom from '@react-native/fantom'; +import * as React from 'react'; +import {createRef} from 'react'; +import {View} from 'react-native'; + +const INSETS = {top: 44, right: 0, bottom: 34, left: 0}; +const FRAME = {x: 0, y: 0, width: 390, height: 844}; + +describe('experimental_onSafeAreaInsetsChange', () => { + it('delivers the insets and the frame of the view', () => { + const root = Fantom.createRoot(); + const nodeRef = createRef(); + const onSafeAreaInsetsChange = jest.fn(); + + Fantom.runTask(() => { + root.render( + { + onSafeAreaInsetsChange(event.nativeEvent); + }} + />, + ); + }); + + Fantom.dispatchNativeEvent(nodeRef, 'safeAreaInsetsChange', { + insets: INSETS, + frame: FRAME, + }); + + expect(onSafeAreaInsetsChange).toHaveBeenCalledTimes(1); + const [event] = onSafeAreaInsetsChange.mock.lastCall; + expect(event.insets).toEqual(INSETS); + expect(event.frame).toEqual(FRAME); + }); + + it('is not delivered to views that did not opt in', () => { + const root = Fantom.createRoot(); + const nodeRef = createRef(); + + Fantom.runTask(() => { + root.render(); + }); + + // The prop is what makes the view observe the safe area, so a view without + // it is never the target of the event. + expect( + root + .getRenderedOutput({props: ['experimental_onSafeAreaInsetsChange']}) + .toJSX(), + ).toEqual(); + }); + + it('prevents the view from being flattened', () => { + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render( + // A layout-only view would ordinarily be flattened away; observing the + // safe area requires a host view to observe with. + {}}> + + , + ); + }); + + expect( + root + .getRenderedOutput({props: ['experimental_onSafeAreaInsetsChange']}) + .toJSX(), + ).toEqual( + + + , + ); + }); + + it('is reflected in the props of the view when set', () => { + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render( + {}} + />, + ); + }); + + expect( + root + .getRenderedOutput({props: ['experimental_onSafeAreaInsetsChange']}) + .toJSX(), + ).toEqual(); + }); +}); diff --git a/packages/react-native/Libraries/Components/View/__tests__/ViewSafeAreaInsetsWarning-itest.js b/packages/react-native/Libraries/Components/View/__tests__/ViewSafeAreaInsetsWarning-itest.js new file mode 100644 index 000000000000..0735e7473717 --- /dev/null +++ b/packages/react-native/Libraries/Components/View/__tests__/ViewSafeAreaInsetsWarning-itest.js @@ -0,0 +1,132 @@ +/** + * 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 '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; + +import type {HighResTimeStampMock} from '@react-native/fantom/src/HighResTimeStampMock'; +import type {HostInstance} from 'react-native/src/private/types/HostInstance'; + +import * as Fantom from '@react-native/fantom'; +import * as React from 'react'; +import {createRef} from 'react'; +import {View} from 'react-native'; + +const INSETS = {top: 44, right: 0, bottom: 34, left: 0}; +const FRAME = {x: 0, y: 0, width: 390, height: 844}; + +function renderObservingView(): {current: HostInstance | null} { + const nodeRef = createRef(); + const root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render( + {}} />, + ); + }); + return nodeRef; +} + +function dispatchInsetsChange(nodeRef: {current: HostInstance | null}) { + Fantom.dispatchNativeEvent(nodeRef, 'safeAreaInsetsChange', { + insets: INSETS, + frame: FRAME, + }); +} + +describe('experimental_onSafeAreaInsetsChange warning', () => { + const originalConsoleWarn = console.warn; + let mockConsoleWarn: JestMockFn, void>; + let mockClock: ?HighResTimeStampMock; + + beforeEach(() => { + mockConsoleWarn = jest.fn(); + // $FlowFixMe[cannot-write] + console.warn = mockConsoleWarn; + mockClock = Fantom.installHighResTimeStampMock(); + }); + + afterEach(() => { + // $FlowFixMe[cannot-write] + console.warn = originalConsoleWarn; + mockClock?.uninstall(); + mockClock = null; + }); + + it('stays silent while the insets change at a plausible rate', () => { + const nodeRef = renderObservingView(); + + // A rotation, a keyboard, a split view: a handful of changes, spread out. + for (let i = 0; i < 20; i++) { + dispatchInsetsChange(nodeRef); + mockClock?.advanceTimeBy(200); + } + + expect(mockConsoleWarn).not.toHaveBeenCalled(); + }); + + it('warns once when a single view loops within the window', () => { + const nodeRef = renderObservingView(); + + for (let i = 0; i < 11; i++) { + dispatchInsetsChange(nodeRef); + mockClock?.advanceTimeBy(16); + } + + expect(mockConsoleWarn).toHaveBeenCalledTimes(1); + expect(mockConsoleWarn.mock.lastCall[0]).toContain( + '`experimental_onSafeAreaInsetsChange` fired more than 10 times in 1000ms', + ); + + // The loop keeps running; the warning does not. + for (let i = 0; i < 50; i++) { + dispatchInsetsChange(nodeRef); + mockClock?.advanceTimeBy(16); + } + + expect(mockConsoleWarn).toHaveBeenCalledTimes(1); + }); + + it('counts each view separately', () => { + const nodeRefA = renderObservingView(); + const nodeRefB = renderObservingView(); + + for (let i = 0; i < 10; i++) { + dispatchInsetsChange(nodeRefA); + dispatchInsetsChange(nodeRefB); + mockClock?.advanceTimeBy(16); + } + + expect(mockConsoleWarn).not.toHaveBeenCalled(); + + dispatchInsetsChange(nodeRefA); + + expect(mockConsoleWarn).toHaveBeenCalledTimes(1); + }); + + it('still delivers the event to the handler', () => { + const nodeRef = createRef(); + const onSafeAreaInsetsChange = jest.fn(); + const root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render( + { + onSafeAreaInsetsChange(event.nativeEvent); + }} + />, + ); + }); + + dispatchInsetsChange(nodeRef); + + expect(onSafeAreaInsetsChange).toHaveBeenCalledTimes(1); + expect(onSafeAreaInsetsChange.mock.lastCall[0].insets).toEqual(INSETS); + }); +}); diff --git a/packages/react-native/Libraries/NativeComponent/BaseViewConfig.android.js b/packages/react-native/Libraries/NativeComponent/BaseViewConfig.android.js index 6e3ee698720d..c37f44b61888 100644 --- a/packages/react-native/Libraries/NativeComponent/BaseViewConfig.android.js +++ b/packages/react-native/Libraries/NativeComponent/BaseViewConfig.android.js @@ -204,6 +204,9 @@ const directEventTypes = { topLayout: { registrationName: 'onLayout', }, + topSafeAreaInsetsChange: { + registrationName: 'experimental_onSafeAreaInsetsChange', + }, }; const validAttributesForNonEventProps = { @@ -405,6 +408,7 @@ const validAttributesForNonEventProps = { // Props for bubbling and direct events const validAttributesForEventProps = { onLayout: true, + experimental_onSafeAreaInsetsChange: true, // PanResponder handlers onMoveShouldSetResponder: true, diff --git a/packages/react-native/Libraries/NativeComponent/BaseViewConfig.ios.js b/packages/react-native/Libraries/NativeComponent/BaseViewConfig.ios.js index d22a68642194..80c413a7c1d0 100644 --- a/packages/react-native/Libraries/NativeComponent/BaseViewConfig.ios.js +++ b/packages/react-native/Libraries/NativeComponent/BaseViewConfig.ios.js @@ -179,6 +179,9 @@ const directEventTypes = { topLayout: { registrationName: 'onLayout', }, + topSafeAreaInsetsChange: { + registrationName: 'experimental_onSafeAreaInsetsChange', + }, onGestureHandlerEvent: DynamicallyInjectedByGestureHandler({ registrationName: 'onGestureHandlerEvent', }), @@ -380,6 +383,7 @@ const validAttributesForNonEventProps = { // Props for bubbling and direct events const validAttributesForEventProps = ConditionallyIgnoredEventHandlers({ onLayout: true, + experimental_onSafeAreaInsetsChange: true, onMagicTap: true, // Accessibility diff --git a/packages/react-native/Libraries/Types/CoreEventTypes.js b/packages/react-native/Libraries/Types/CoreEventTypes.js index dff10cb27609..15aff5e610c3 100644 --- a/packages/react-native/Libraries/Types/CoreEventTypes.js +++ b/packages/react-native/Libraries/Types/CoreEventTypes.js @@ -76,6 +76,29 @@ export type LayoutChangeEvent = NativeSyntheticEvent< }>, >; +export type SafeAreaInsets = Readonly<{ + top: number, + right: number, + bottom: number, + left: number, +}>; + +export type SafeAreaInsetsChangeEvent = NativeSyntheticEvent< + Readonly<{ + /** + * The part of the view that is covered by the system UI, in the view's own + * coordinate space. + */ + insets: SafeAreaInsets, + /** + * The frame of the view at the time of the event. Relative to the + * enclosing view controller on iOS and to the window on Android; only + * updated when the insets change. + */ + frame: LayoutRectangle, + }>, +>; + /** * @deprecated Use `TextLayoutEvent` instead. */ diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm index 37db047e8b25..eecfbb48214e 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm @@ -25,6 +25,7 @@ #import #import #import +#import #import #import #import @@ -122,6 +123,11 @@ @implementation RCTViewComponentView { NSMutableSet *_accessibilityOrderNativeIDs; RCTSwiftUIContainerViewWrapper *_swiftUIWrapper; BOOL _focusable; + // The insets sent with the last `onSafeAreaInsetsChange` event, or nil if + // none was sent yet. A pointer because almost no view observes the safe + // area: the views that do pay for a small box, every other view only for + // the pointer. + NSValue *_lastSentSafeAreaInsets; } #ifdef RCT_DYNAMIC_FRAMEWORKS @@ -438,6 +444,15 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & -newViewProps.hitSlop.right}; } + // `onSafeAreaInsetsChange`. Scheduled whenever the prop is set, not only on + // its transitions: recycled views keep their last props, so `oldViewProps` + // of a freshly reused view is not a reliable baseline. + if (newViewProps.onSafeAreaInsetsChange) { + [self setNeedsLayout]; + } else if (oldViewProps.onSafeAreaInsetsChange) { + _lastSentSafeAreaInsets = nil; + } + // `overflow` if (oldViewProps.getClipsContentToBounds() != newViewProps.getClipsContentToBounds()) { self.currentContainerView.clipsToBounds = newViewProps.getClipsContentToBounds(); @@ -720,6 +735,105 @@ - (void)updateLayoutMetrics:(const LayoutMetrics &)layoutMetrics } } +#pragma mark - Safe area insets + +// The view controller the view is hosted in, which is the coordinate space +// `frame` is reported in. Modals and other view controllers are positioned +// independently of the window, so the window is not a usable reference. +static UIViewController *RCTParentViewControllerOfView(UIView *view) +{ + UIResponder *responder = view.nextResponder; + while (responder != nil) { + if ([responder isKindOfClass:[UIViewController class]]) { + return (UIViewController *)responder; + } + responder = responder.nextResponder; + } + return nil; +} + +static BOOL RCTEdgeInsetsEqualWithThreshold(UIEdgeInsets lhs, UIEdgeInsets rhs, CGFloat threshold) +{ + return ABS(lhs.left - rhs.left) <= threshold && ABS(lhs.top - rhs.top) <= threshold && + ABS(lhs.right - rhs.right) <= threshold && ABS(lhs.bottom - rhs.bottom) <= threshold; +} + +// The event is only ever emitted from `layoutSubviews`; everything that might +// have changed the insets merely marks the view as needing layout. This defers +// the emit out of arbitrary call contexts — in particular out of +// `updateProps`, which runs inside the mounting transaction where +// synchronously re-entering React is not safe — while keeping it in the same +// frame: the layout pass runs before the frame is displayed. +- (void)_safeAreaInsetsMayHaveChanged +{ + if (!_eventEmitter) { + return; + } + + // The view has not been mounted or laid out yet, so the insets we would + // compute are not the ones the view ends up with. + if (self.window == nil || CGSizeEqualToSize(self.bounds.size, CGSizeZero)) { + return; + } + + // Only a change of the insets triggers an event. The frame is part of the + // payload but not of the trigger: a view that moves without its overlap with + // the system UI changing stays silent, which is what makes observing views + // safe to place inside scroll views. + UIEdgeInsets insets = self.safeAreaInsets; + if (_lastSentSafeAreaInsets != nil && + RCTEdgeInsetsEqualWithThreshold(insets, _lastSentSafeAreaInsets.UIEdgeInsetsValue, 1.0 / RCTScreenScale())) { + return; + } + + UIView *referenceView = RCTParentViewControllerOfView(self).view ?: self.window; + CGRect frame = [self convertRect:self.bounds toView:referenceView]; + + _lastSentSafeAreaInsets = [NSValue valueWithUIEdgeInsets:insets]; + + static_cast(*_eventEmitter) + .onSafeAreaInsetsChange( + EdgeInsets{ + .left = (Float)insets.left, + .top = (Float)insets.top, + .right = (Float)insets.right, + .bottom = (Float)insets.bottom}, + RCTRectFromCGRect(frame)); +} + +// The prop is checked here rather than inside the helper so that views which +// do not use it only pay for a branch on a prop they already have in hand. +- (BOOL)_observesSafeAreaInsets +{ + return static_cast(*_props).onSafeAreaInsetsChange; +} + +- (void)safeAreaInsetsDidChange +{ + [super safeAreaInsetsDidChange]; + if ([self _observesSafeAreaInsets]) { + [self setNeedsLayout]; + } +} + +- (void)didMoveToWindow +{ + [super didMoveToWindow]; + if ([self _observesSafeAreaInsets]) { + [self setNeedsLayout]; + } +} + +- (void)layoutSubviews +{ + [super layoutSubviews]; + // Both the insets and the frame depend on where the view sits in the window, + // so moving or resizing it changes them without UIKit notifying us. + if ([self _observesSafeAreaInsets]) { + [self _safeAreaInsetsMayHaveChanged]; + } +} + - (BOOL)isJSResponder { return _isJSResponder; @@ -775,6 +889,7 @@ - (void)prepareForRecycle _filterLayer = nil; [self clearExistingBackgroundImageLayers]; + _lastSentSafeAreaInsets = nil; _propKeysManagedByAnimated_DO_NOT_USE_THIS_IS_BROKEN = nil; _eventEmitter.reset(); _isJSResponder = NO; diff --git a/packages/react-native/ReactAndroid/api/ReactAndroid.api b/packages/react-native/ReactAndroid/api/ReactAndroid.api index 46a95696a53f..9e14fd1d6a93 100644 --- a/packages/react-native/ReactAndroid/api/ReactAndroid.api +++ b/packages/react-native/ReactAndroid/api/ReactAndroid.api @@ -3232,6 +3232,7 @@ public abstract class com/facebook/react/uimanager/BaseViewManager : com/faceboo public fun setMoveShouldSetResponder (Landroid/view/View;Z)V public fun setMoveShouldSetResponderCapture (Landroid/view/View;Z)V public fun setNativeId (Landroid/view/View;Ljava/lang/String;)V + public fun setOnSafeAreaInsetsChange (Landroid/view/View;Z)V public fun setOpacity (Landroid/view/View;F)V public fun setOutlineColor (Landroid/view/View;Ljava/lang/Integer;)V public fun setOutlineOffset (Landroid/view/View;F)V @@ -4572,6 +4573,7 @@ public final class com/facebook/react/uimanager/ViewProps { public static final field NONE Ljava/lang/String; public static final field NUMBER_OF_LINES Ljava/lang/String; public static final field ON Ljava/lang/String; + public static final field ON_SAFE_AREA_INSETS_CHANGE Ljava/lang/String; public static final field OPACITY Ljava/lang/String; public static final field OUTLINE_COLOR Ljava/lang/String; public static final field OUTLINE_OFFSET Ljava/lang/String; diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java index 9affa257fa5f..d396f6e97f56 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java @@ -37,6 +37,8 @@ import com.facebook.react.uimanager.events.EventDispatcher; import com.facebook.react.uimanager.events.FocusEvent; import com.facebook.react.uimanager.events.PointerEventHelper; +import com.facebook.react.uimanager.events.SafeAreaInsetsChangeEvent; +import com.facebook.react.uimanager.internal.SafeAreaInsetsObserver; import com.facebook.react.uimanager.style.OutlineStyle; import com.facebook.react.uimanager.util.ReactFindViewUtil; import java.util.ArrayList; @@ -74,6 +76,10 @@ public BaseViewManager(@Nullable ReactApplicationContext reactContext) { @Override protected @Nullable T prepareToRecycleView(@NonNull ThemedReactContext reactContext, T view) { + // Stops safe area observation and clears its tag; the next user of the + // view re-enables it through the prop if needed. + SafeAreaInsetsObserver.setEnabled(view, false); + // Reset tags view.setTag(null); view.setTag(R.id.pointer_events, null); @@ -297,6 +303,15 @@ public void setRenderToHardwareTexture(@NonNull T view, boolean useHWTexture) { view.setTag(R.id.use_hardware_layer, useHWTexture); } + /** + * Views only observe safe area insets while a JavaScript handler is attached, so views that do + * not use the prop are not affected. + */ + @ReactProp(name = ViewProps.ON_SAFE_AREA_INSETS_CHANGE, defaultBoolean = false) + public void setOnSafeAreaInsetsChange(@NonNull T view, boolean onSafeAreaInsetsChange) { + SafeAreaInsetsObserver.setEnabled(view, onSafeAreaInsetsChange); + } + @ReactProp(name = ViewProps.TEST_ID) public void setTestId(@NonNull T view, @Nullable String testId) { view.setTag(R.id.react_test_id, testId); @@ -823,6 +838,9 @@ protected void onAfterUpdateTransaction(@NonNull T view) { .put( "topAccessibilityAction", MapBuilder.of("registrationName", "onAccessibilityAction")) + .put( + SafeAreaInsetsChangeEvent.EVENT_NAME, + MapBuilder.of("registrationName", ViewProps.ON_SAFE_AREA_INSETS_CHANGE)) .build()); return eventTypeConstants; } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManagerDelegate.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManagerDelegate.kt index d2164e77b192..c1d63abe3a7e 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManagerDelegate.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManagerDelegate.kt @@ -164,6 +164,8 @@ public abstract class BaseViewManagerDelegate< mViewManager.setPointerMoveCapture(view, value as Boolean? ?: false) ViewProps.ON_CLICK -> mViewManager.setClick(view, value as Boolean? ?: false) ViewProps.ON_CLICK_CAPTURE -> mViewManager.setClickCapture(view, value as Boolean? ?: false) + ViewProps.ON_SAFE_AREA_INSETS_CHANGE -> + mViewManager.setOnSafeAreaInsetsChange(view, value as Boolean? ?: false) } } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewProps.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewProps.kt index 281c390a7578..1d0a305b2f2b 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewProps.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewProps.kt @@ -158,6 +158,7 @@ public object ViewProps { public const val SHADOW_COLOR: String = "shadowColor" public const val Z_INDEX: String = "zIndex" public const val RENDER_TO_HARDWARE_TEXTURE: String = "renderToHardwareTextureAndroid" + public const val ON_SAFE_AREA_INSETS_CHANGE: String = "experimental_onSafeAreaInsetsChange" public const val ACCESSIBILITY_LABEL: String = "accessibilityLabel" public const val ACCESSIBILITY_COLLECTION: String = "accessibilityCollection" public const val ACCESSIBILITY_COLLECTION_ITEM: String = "accessibilityCollectionItem" diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/events/SafeAreaInsetsChangeEvent.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/events/SafeAreaInsetsChangeEvent.kt new file mode 100644 index 000000000000..fdfa4329633f --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/events/SafeAreaInsetsChangeEvent.kt @@ -0,0 +1,65 @@ +/* + * 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. + */ + +package com.facebook.react.uimanager.events + +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.WritableMap +import com.facebook.react.uimanager.PixelUtil.pxToDp + +/** + * Emitted when the part of a view that is covered by the system UI, or the position of that view in + * the window, changes. + * + * Dispatched synchronously so that the layout depending on the insets is mounted in the frame the + * insets changed in, rather than the one after it. + */ +internal class SafeAreaInsetsChangeEvent( + surfaceId: Int, + viewTag: Int, + private val insetTop: Int, + private val insetRight: Int, + private val insetBottom: Int, + private val insetLeft: Int, + private val frameX: Int, + private val frameY: Int, + private val frameWidth: Int, + private val frameHeight: Int, +) : Event(surfaceId, viewTag) { + + override fun getEventName(): String = EVENT_NAME + + override fun getEventData(): WritableMap = + Arguments.createMap().apply { + putMap( + "insets", + Arguments.createMap().apply { + putDouble("top", insetTop.toDp()) + putDouble("right", insetRight.toDp()) + putDouble("bottom", insetBottom.toDp()) + putDouble("left", insetLeft.toDp()) + }, + ) + putMap( + "frame", + Arguments.createMap().apply { + putDouble("x", frameX.toDp()) + putDouble("y", frameY.toDp()) + putDouble("width", frameWidth.toDp()) + putDouble("height", frameHeight.toDp()) + }, + ) + } + + override fun experimental_isSynchronous(): Boolean = true + + internal companion object { + const val EVENT_NAME: String = "topSafeAreaInsetsChange" + + private fun Int.toDp(): Double = toFloat().pxToDp().toDouble() + } +} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/internal/SafeAreaInsetsObserver.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/internal/SafeAreaInsetsObserver.kt new file mode 100644 index 000000000000..5512d2e7c5dc --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/internal/SafeAreaInsetsObserver.kt @@ -0,0 +1,212 @@ +/* + * 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. + */ + +package com.facebook.react.uimanager.internal + +import android.graphics.Rect +import android.view.View +import android.view.ViewGroup +import android.view.ViewTreeObserver +import androidx.core.graphics.Insets +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import com.facebook.react.R +import com.facebook.react.uimanager.UIManagerHelper +import com.facebook.react.uimanager.events.SafeAreaInsetsChangeEvent +import kotlin.math.max +import kotlin.math.min + +/** + * Observes the part of a view that is covered by the system UI, and emits + * [SafeAreaInsetsChangeEvent] whenever it, or the position of the view in the window, changes. + * + * One observer is attached per view that sets the `onSafeAreaInsetsChange` prop. Views without the + * prop never get an observer, and so pay nothing for this. + */ +internal class SafeAreaInsetsObserver private constructor(private val view: View) : + ViewTreeObserver.OnPreDrawListener, View.OnAttachStateChangeListener { + + // Scratch state, reused so that observing a view allocates nothing per frame. + private val visibleRect = Rect() + private val frameRect = Rect() + private val insets = IntArray(4) + private val lastInsets = IntArray(4) + + private var hasLastInsets = false + private var isListening = false + + private fun start() { + view.addOnAttachStateChangeListener(this) + if (view.isAttachedToWindow) { + onViewAttachedToWindow(view) + } + } + + private fun stop() { + view.removeOnAttachStateChangeListener(this) + stopListening() + hasLastInsets = false + } + + private fun startListening() { + if (!isListening) { + isListening = true + view.viewTreeObserver.addOnPreDrawListener(this) + } + } + + private fun stopListening() { + if (isListening) { + isListening = false + view.viewTreeObserver.removeOnPreDrawListener(this) + } + } + + override fun onViewAttachedToWindow(v: View) { + // The insets and the frame both depend on where the view ends up in the window, which is only + // known once it has been laid out. A pre-draw listener is the cheapest hook that catches every + // change: window insets, layout, and scrolling ancestors alike. + startListening() + maybeEmit() + } + + override fun onViewDetachedFromWindow(v: View) { + stopListening() + } + + override fun onPreDraw(): Boolean { + maybeEmit() + return true + } + + private fun maybeEmit() { + // Only a change of the insets triggers an event. The frame is part of the + // payload but not of the trigger: a view that moves (scrolling, layout) + // without its overlap with the system UI changing stays silent. This is + // what makes observing views safe to place inside scroll views — and it + // prevents feedback loops, since the synchronous render caused by an event + // produces a new frame, which runs this pre-draw listener again. + if (!computeSafeAreaInsets(view, visibleRect, insets)) { + return + } + if (hasLastInsets && insets.contentEquals(lastInsets)) { + return + } + val frame = getFrame(view, frameRect) ?: return + val eventDispatcher = + UIManagerHelper.getEventDispatcher(UIManagerHelper.getReactContext(view)) ?: return + // Recorded only once the event is actually dispatched, so a failed lookup + // above does not permanently swallow this inset value. + insets.copyInto(lastInsets) + hasLastInsets = true + eventDispatcher.dispatchEvent( + SafeAreaInsetsChangeEvent( + surfaceId = UIManagerHelper.getSurfaceId(view), + viewTag = view.id, + insetTop = insets[TOP], + insetRight = insets[RIGHT], + insetBottom = insets[BOTTOM], + insetLeft = insets[LEFT], + frameX = frame.left, + frameY = frame.top, + frameWidth = frame.width(), + frameHeight = frame.height(), + ), + ) + } + + companion object { + private const val TOP = 0 + private const val RIGHT = 1 + private const val BOTTOM = 2 + private const val LEFT = 3 + + /** + * Starts or stops observing safe area insets for [view]. Safe to call repeatedly with the same + * value. + */ + @JvmStatic + fun setEnabled(view: View, enabled: Boolean) { + val existing = view.getTag(R.id.safe_area_insets_observer) as? SafeAreaInsetsObserver + if (enabled == (existing != null)) { + return + } + if (enabled) { + val observer = SafeAreaInsetsObserver(view) + view.setTag(R.id.safe_area_insets_observer, observer) + observer.start() + } else { + view.setTag(R.id.safe_area_insets_observer, null) + existing?.stop() + } + } + + /** + * The insets of the window that overlap [view], in the view's own coordinate space. A view that + * does not reach under the system UI has no insets. + * + * Also used with the window's decor view to report window-level safe area insets through the + * `Dimensions` module. + */ + @JvmStatic + fun getSafeAreaInsets(view: View): Insets? { + val insets = IntArray(4) + if (!computeSafeAreaInsets(view, Rect(), insets)) { + return null + } + return Insets.of(insets[LEFT], insets[TOP], insets[RIGHT], insets[BOTTOM]) + } + + /** + * Writes the insets of [view] into [out], ordered [TOP], [RIGHT], [BOTTOM], [LEFT], using + * [visibleRect] as scratch space. Returns false when they cannot be computed, leaving [out] + * untouched. + */ + private fun computeSafeAreaInsets(view: View, visibleRect: Rect, out: IntArray): Boolean { + // The view has not been laid out yet. + if (view.width == 0 || view.height == 0) { + return false + } + val rootView = view.rootView + val windowInsets = + ViewCompat.getRootWindowInsets(rootView)?.getInsets( + WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout(), + ) ?: return false + + if (!view.getGlobalVisibleRect(visibleRect)) { + // The view is fully clipped by an ancestor (e.g. scrolled out of a + // scroll view); the rect is undefined in that case, and a view that is + // not visible has no meaningful insets. + return false + } + out[TOP] = max(windowInsets.top - visibleRect.top, 0) + out[RIGHT] = + max(min(visibleRect.left + view.width - rootView.width, 0) + windowInsets.right, 0) + out[BOTTOM] = + max(min(visibleRect.top + view.height - rootView.height, 0) + windowInsets.bottom, 0) + out[LEFT] = max(windowInsets.left - visibleRect.left, 0) + return true + } + + /** The frame of [view] in the coordinate space of the window, written into [out]. */ + private fun getFrame(view: View, out: Rect): Rect? { + val rootView = view.rootView as? ViewGroup ?: return null + if (view.parent == null) { + return null + } + view.getDrawingRect(out) + try { + rootView.offsetDescendantRectToMyCoords(view, out) + } catch (e: IllegalArgumentException) { + // Thrown when the view is not a descendant of its own root view, which can happen while it + // is being unmounted. + return null + } + return out + } + } +} diff --git a/packages/react-native/ReactAndroid/src/main/res/views/uimanager/values/ids.xml b/packages/react-native/ReactAndroid/src/main/res/views/uimanager/values/ids.xml index 0e51a358eb77..a4820e5d8da1 100644 --- a/packages/react-native/ReactAndroid/src/main/res/views/uimanager/values/ids.xml +++ b/packages/react-native/ReactAndroid/src/main/res/views/uimanager/values/ids.xml @@ -82,4 +82,7 @@ + + + diff --git a/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewEventEmitter.cpp b/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewEventEmitter.cpp index 4e981efd3f80..5f3b261ae664 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewEventEmitter.cpp +++ b/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewEventEmitter.cpp @@ -32,6 +32,42 @@ void BaseViewEventEmitter::onAccessibilityEscape() const { dispatchEvent("accessibilityEscape"); } +#pragma mark - Safe area + +void BaseViewEventEmitter::onSafeAreaInsetsChange( + const EdgeInsets& insets, + const Rect& frame) const { + // Dispatched synchronously and as a discrete event so that React processes it + // before the current frame is presented. Both the thread this is called from + // (the UI thread) and the JavaScript thread are blocked until React has + // finished rendering. + experimental_flushSync([this, insets, frame]() { + dispatchEvent( + "safeAreaInsetsChange", + [insets, frame](jsi::Runtime& runtime) { + auto payload = jsi::Object(runtime); + { + auto insetsPayload = jsi::Object(runtime); + insetsPayload.setProperty(runtime, "top", insets.top); + insetsPayload.setProperty(runtime, "right", insets.right); + insetsPayload.setProperty(runtime, "bottom", insets.bottom); + insetsPayload.setProperty(runtime, "left", insets.left); + payload.setProperty(runtime, "insets", insetsPayload); + } + { + auto framePayload = jsi::Object(runtime); + framePayload.setProperty(runtime, "x", frame.origin.x); + framePayload.setProperty(runtime, "y", frame.origin.y); + framePayload.setProperty(runtime, "width", frame.size.width); + framePayload.setProperty(runtime, "height", frame.size.height); + payload.setProperty(runtime, "frame", framePayload); + } + return payload; + }, + RawEvent::Category::Discrete); + }); +} + #pragma mark - Layout void BaseViewEventEmitter::onLayout(const LayoutMetrics& layoutMetrics) const { diff --git a/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewEventEmitter.h b/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewEventEmitter.h index 8d9978a80fc2..01bef6388b7c 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewEventEmitter.h +++ b/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewEventEmitter.h @@ -14,6 +14,7 @@ #include #include +#include #include "TouchEventEmitter.h" @@ -34,6 +35,19 @@ class BaseViewEventEmitter : public TouchEventEmitter { void onLayout(const LayoutMetrics &layoutMetrics) const; +#pragma mark - Safe area + + /* + * Emits `onSafeAreaInsetsChange` with the portion of the view that is covered + * by the system UI (status bar, home indicator, display cutouts, ...) and the + * frame of the view at the time of the event. + * + * The event is dispatched synchronously, blocking the thread it is called + * from until React has re-rendered, so that the layout that depends on the + * insets is mounted in the same frame the insets changed in. + */ + void onSafeAreaInsetsChange(const EdgeInsets &insets, const Rect &frame) const; + #pragma mark - Focus void onFocus() const; void onBlur() const; diff --git a/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewProps.cpp b/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewProps.cpp index 1cb30b0ed6a8..713ab1470fd0 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewProps.cpp +++ b/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewProps.cpp @@ -303,6 +303,12 @@ BaseViewProps::BaseViewProps( "onLayout", sourceProps.onLayout, {})), + onSafeAreaInsetsChange(convertRawProp( + context, + rawProps, + "experimental_onSafeAreaInsetsChange", + sourceProps.onSafeAreaInsetsChange, + {})), events(convertRawProp(context, rawProps, sourceProps.events, {})), collapsable(convertRawProp( context, @@ -373,6 +379,8 @@ void BaseViewProps::setProp( RAW_SET_PROP_SWITCH_CASE_BASIC(isolation); RAW_SET_PROP_SWITCH_CASE_BASIC(hitSlop); RAW_SET_PROP_SWITCH_CASE_BASIC(onLayout); + RAW_SET_PROP_SWITCH_CASE( + onSafeAreaInsetsChange, "experimental_onSafeAreaInsetsChange"); RAW_SET_PROP_SWITCH_CASE_BASIC(collapsable); RAW_SET_PROP_SWITCH_CASE_BASIC(collapsableChildren); RAW_SET_PROP_SWITCH_CASE_BASIC(removeClippedSubviews); @@ -609,6 +617,10 @@ SharedDebugStringConvertibleList BaseViewProps::getDebugProps() const { "backgroundImage", backgroundImage, defaultBaseViewProps.backgroundImage), + debugStringConvertibleItem( + "experimental_onSafeAreaInsetsChange", + onSafeAreaInsetsChange, + defaultBaseViewProps.onSafeAreaInsetsChange), }; } #endif diff --git a/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewProps.h b/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewProps.h index c78c4f38729b..b72c5f944f63 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewProps.h +++ b/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewProps.h @@ -103,6 +103,7 @@ class BaseViewProps : public YogaStylableProps, public AccessibilityProps { PointerEventsMode pointerEvents{}; EdgeInsets hitSlop{}; bool onLayout{}; + bool onSafeAreaInsetsChange{}; ViewEvents events{}; diff --git a/packages/react-native/ReactCommon/react/renderer/components/view/ViewShadowNode.cpp b/packages/react-native/ReactCommon/react/renderer/components/view/ViewShadowNode.cpp index a166a90546c6..5a4460301ff6 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/view/ViewShadowNode.cpp +++ b/packages/react-native/ReactCommon/react/renderer/components/view/ViewShadowNode.cpp @@ -55,7 +55,8 @@ void ViewShadowNode::initialize() noexcept { viewProps.accessibilityViewIsModal || viewProps.importantForAccessibility != ImportantForAccessibility::Auto || viewProps.removeClippedSubviews || viewProps.cursor != Cursor::Auto || - !viewProps.filter.empty() || + // Observing the safe area requires a host view to observe with. + viewProps.onSafeAreaInsetsChange || !viewProps.filter.empty() || viewProps.mixBlendMode != BlendMode::Normal || viewProps.isolation == Isolation::Isolate || HostPlatformViewTraitsInitializer::formsStackingContext(viewProps) || diff --git a/packages/react-native/ReactCommon/react/renderer/components/view/platform/android/react/renderer/components/view/HostPlatformViewProps.cpp b/packages/react-native/ReactCommon/react/renderer/components/view/platform/android/react/renderer/components/view/HostPlatformViewProps.cpp index 2ddd629b4cc2..5b948463d2be 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/view/platform/android/react/renderer/components/view/HostPlatformViewProps.cpp +++ b/packages/react-native/ReactCommon/react/renderer/components/view/platform/android/react/renderer/components/view/HostPlatformViewProps.cpp @@ -564,6 +564,10 @@ folly::dynamic HostPlatformViewProps::getDiffProps( result["onLayout"] = onLayout; } + if (onSafeAreaInsetsChange != oldProps->onSafeAreaInsetsChange) { + result["experimental_onSafeAreaInsetsChange"] = onSafeAreaInsetsChange; + } + if (zIndex != oldProps->zIndex) { result["zIndex"] = zIndex.has_value() ? zIndex.value() : folly::dynamic(nullptr); diff --git a/packages/react-native/src/private/components/view/warnOnRepeatedSafeAreaInsetsChanges.js b/packages/react-native/src/private/components/view/warnOnRepeatedSafeAreaInsetsChanges.js new file mode 100644 index 000000000000..e03553610b89 --- /dev/null +++ b/packages/react-native/src/private/components/view/warnOnRepeatedSafeAreaInsetsChanges.js @@ -0,0 +1,78 @@ +/** + * 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 {SafeAreaInsetsChangeEvent} from '../../../../Libraries/Types/CoreEventTypes'; + +const DISPATCH_WINDOW_MS = 1000; +const MAX_DISPATCHES_PER_WINDOW = 10; + +type DispatchRate = { + count: number, + windowStart: number, + warned: boolean, +}; + +const dispatchRates: WeakMap = new WeakMap(); + +/** + * Wraps an `experimental_onSafeAreaInsetsChange` handler with a development + * check for a view that reports insets over and over. + * + * The system UI does not move many times a second, so a sustained stream of + * events means the layout is feeding the insets back into the position of the + * observed view: it pads itself by the insets it reports, which moves it, which + * changes its insets. Every one of those events renders synchronously, blocking + * the UI thread, so the loop is paid for in frames. + */ +export default function warnOnRepeatedSafeAreaInsetsChanges( + onSafeAreaInsetsChange: (event: SafeAreaInsetsChangeEvent) => unknown, +): (event: SafeAreaInsetsChangeEvent) => unknown { + return event => { + // The target identifies the view without keeping it alive; events dispatched + // without one are simply not counted. + const target = event.target; + if (target != null && typeof target === 'object') { + warnIfDispatchingTooOften(target); + } + return onSafeAreaInsetsChange(event); + }; +} + +function warnIfDispatchingTooOften(target: interface {}): void { + const now = performance.now(); + let dispatchRate: ?DispatchRate = dispatchRates.get(target); + if (dispatchRate == null) { + const newDispatchRate: DispatchRate = { + count: 0, + windowStart: now, + warned: false, + }; + dispatchRates.set(target, newDispatchRate); + dispatchRate = newDispatchRate; + } + if (dispatchRate.warned) { + return; + } + if (now - dispatchRate.windowStart > DISPATCH_WINDOW_MS) { + dispatchRate.windowStart = now; + dispatchRate.count = 0; + } + dispatchRate.count++; + if (dispatchRate.count > MAX_DISPATCHES_PER_WINDOW) { + dispatchRate.warned = true; + console.warn( + `\`experimental_onSafeAreaInsetsChange\` fired more than ${MAX_DISPATCHES_PER_WINDOW} ` + + `times in ${DISPATCH_WINDOW_MS}ms on a single view. The safe area insets of a view ` + + 'only change when the system UI moves or the view does, so this is usually a loop: ' + + 'the view is laid out from the insets it reports, which moves it, which changes its ' + + 'insets. Each event renders synchronously, so the loop costs frames.', + ); + } +} diff --git a/packages/rn-tester/js/examples/SafeAreaInsets/SafeAreaInsetsExample.js b/packages/rn-tester/js/examples/SafeAreaInsets/SafeAreaInsetsExample.js new file mode 100644 index 000000000000..50a3bbd17c35 --- /dev/null +++ b/packages/rn-tester/js/examples/SafeAreaInsets/SafeAreaInsetsExample.js @@ -0,0 +1,372 @@ +/** + * 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 + */ + +'use strict'; + +import type {RNTesterModuleExample} from '../../types/RNTesterTypes'; +import type {SafeAreaInsetsChangeEvent} from 'react-native/Libraries/Types/CoreEventTypes'; + +import RNTesterText from '../../components/RNTesterText'; +import * as React from 'react'; +import {useCallback, useState} from 'react'; +import { + Button, + Modal, + ScrollView, + StyleSheet, + TextInput, + View, +} from 'react-native'; + +type Insets = SafeAreaInsetsChangeEvent['nativeEvent']['insets']; +type Frame = SafeAreaInsetsChangeEvent['nativeEvent']['frame']; + +function useSafeAreaInsets(): [ + ?Insets, + ?Frame, + (SafeAreaInsetsChangeEvent) => void, +] { + const [state, setState] = useState(null); + const onSafeAreaInsetsChange = useCallback( + (event: SafeAreaInsetsChangeEvent) => { + setState({ + insets: event.nativeEvent.insets, + frame: event.nativeEvent.frame, + }); + }, + [], + ); + return [state?.insets, state?.frame, onSafeAreaInsetsChange]; +} + +function InsetsReadoutExample(): React.Node { + const [insets, frame, onSafeAreaInsetsChange] = useSafeAreaInsets(); + + return ( + + + {insets == null + ? 'Waiting for insets…' + : `insets: {top: ${insets.top}, right: ${insets.right}, bottom: ${insets.bottom}, left: ${insets.left}}`} + + + {frame == null + ? '' + : `frame: {x: ${frame.x}, y: ${frame.y}, width: ${frame.width}, height: ${frame.height}}`} + + + This view does not reach under the system UI, so its insets are zero. + + + ); +} + +function FullScreenModalContent({onClose}: {onClose: () => void}): React.Node { + const [insets, , onSafeAreaInsetsChange] = useSafeAreaInsets(); + const [applied, setApplied] = useState(false); + + // The view observes the safe area but no event has been received yet. With + // synchronous dispatch this state is committed but never displayed: the + // event fires while this tree is being mounted and the insets are applied + // before the frame is presented. If a frame ever renders in this state, the + // dispatch was not synchronous. + const waitingForInsets = applied && insets == null; + + return ( + + + + {insets != null + ? `top: ${insets.top}, right: ${insets.right}, bottom: ${insets.bottom}, left: ${insets.left}` + : waitingForInsets + ? 'Observing the safe area, inset event not received yet — this state should never be visible.' + : 'Insets not applied: the content extends under the system UI.'} + + + Applying the insets and rotating the device both update the padding in + the same frame, without the content jumping. + + {!applied ? ( +