Skip to content

Commit b439ef7

Browse files
janicduplessismeta-codesync[bot]
authored andcommitted
Process synchronous event beats in the frame that requested them (#58530)
Summary: `EventEmitter::experimental_flushSync` only *requests* an event beat; the beat is 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. Anything that reports layout-driven state to JS synchronously (`VirtualView` mode changes, and safe area insets in the PRs that build on this) renders a frame late in exactly the cases that matter. `AppleEventBeat` now also 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 gets its `display` call after the whole layout pass and before the transaction is committed. That layer needs to live in the tree being committed, so the beat has to know which tree that is — and the emitter tells it: - `experimental_flushSync` carries the **tag of the emitting view** through `EventDispatcher` and `EventQueue` to `EventBeat::requestSynchronous(Tag)`, with `kNoTag` (#58531) meaning no view attribution; a no-argument overload keeps unattributed requesters and the existing tests unchanged. The emitter reads the tag from its `ShadowNodeFamily` at flush time; `kNoTag` if the family is already gone. - `AppleEventBeat` resolves the tag to the layer of the view's **window** through a resolver injected by `RCTSurfacePresenter` (`findComponentViewWithTag:` on the mounting registry — nullable, non-creating, main thread) and attaches its flusher layer there. 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 within one cycle coalesce into a single induce. One 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. **Risk:** this changes when queued events are flushed on iOS for every `experimental_flushSync` caller — today `VirtualView`, and safe area insets with the PRs on top. The worst case is a beat processed a frame *earlier* than before, inside a Core Animation commit; the run loop observer path is untouched and still catches anything the display phase misses (an emitter with no tag, an unmounted view, a request off the main thread). Android ignores the tag. Revert is self-contained. ## Design Q&A: **What happens when two views in different windows update at once?** Each requesting window gets its own dirty flusher layer (the map is keyed by host layer), and the first `display` to fire induces the beat, which drains the whole event queue — every window's updates mount before that commit presents. The remaining flushers hit the `isEventBeatRequested_` guard and no-op, so it is one beat total, not one per window. If windows ever commit in separate transactions, each request still resolves within its own window's cycle, since its layer sits in the tree that emitted it. Only requesting windows carry a dirty layer. **Can the tag point at the wrong view — after an unmount, or a recycled view?** No. The tag comes from the emitter's `ShadowNodeFamily`, and a family keeps one tag for its whole life, across clones and state updates; if the family is already gone the flush carries `kNoTag` and skips the resolver. What changes over a view's life — its window — is read live: the tag resolves to a view at flush time and `view.window.layer` is looked up then, so a view that moved between windows targets its current tree. A view mid-unmount or recycled resolves to nil (the registry erases the entry and recycled views get tag `0`) and degrades to run-loop-observer timing. The tag only ever influences *where the induce is scheduled*, never what is delivered or to whom, so the blast radius of any staleness is one frame of timing, not correctness. **Does `VirtualView` need changes to benefit?** No — its sync mode-change flush goes through its own emitter, so the tag attribution is automatic. The case this improves is a mode change emitted during Core Animation layout (a resize pulling a virtualized item into view): on `main` that renders one frame late; here the induce lands in the display phase of the VirtualView's own window, including inside a full screen modal. ## Changelog: [INTERNAL] - Process synchronous event beats in the frame that requested them on iOS, scheduling the induce on the requesting view's window Pull Request resolved: #58530 Test Plan: New unit tests in `EventBeatTest.cpp` cover the beat semantics: a synchronous request during an already-scheduled asynchronous beat, coalescing, and induce ordering. They drive the protected `induce` through a subclass standing in for the platform. On device, with the safe area insets prop from the PRs above merged on top: an RNTester example renders a loud marker (yellow background) while a view observes the safe area but has not received an inset event yet, so any presented marker frame means the dispatch was not synchronous. The full apply → landscape → portrait sequence **inside a full screen modal** on an iPhone 17 Pro simulator, decomposed with ffmpeg into 982 frames and every frame scanned for the marker color — **zero marker frames**, and mid-rotation frames already carry the incoming orientation's insets, so the padding animates with the rotation. Scoped honestly: the first inset event after setting the prop is processed at the call site, so the marker primarily proves no regression; the same-frame path for layout-driven changes rests on the by-construction argument above plus the rotation frames. https://github.com/user-attachments/assets/0f2db837-c9c0-4457-96c2-847b7aecf10e `yarn fantom .../ViewSafeAreaInsets-itest.js` passes 4/4 with the prop merged on top. C++ API snapshots regenerated (`scripts/cxx-api/parser`, Doxygen 1.16.1): the deltas are the `requestSynchronous` overload pair, `EventEmitter::getTag`, the resolver type, and the `AppleEventBeat` constructor and destructor. --- **Stack** — split out of #57967, which stays open as the prototype and design discussion. GitHub will not take a fork branch as a pull request base, so each of these targets `main` and its diff contains the ones below it until they merge. Each PR is one commit on top of the previous one. This is the bottom of the stack, so its diff is already just this change. 👉 1. #58530 — Process synchronous event beats in the frame that requested them 2. #58109 — Add an `experimental_onSafeAreaInsetsChange` view prop 3. #58110 — Report the window safe area insets through Dimensions 4. #58112 — Render the internal SafeAreaView from the safe area insets prop 5. #58113 — Remove the native SafeAreaView and the deprecated public export An earlier variant that targeted the surface's root view instead of the view's window was closed in #58528; its review thread carries the analysis behind the window-based resolution. #58108 was the per-window predecessor this supersedes. Reviewed By: javache Differential Revision: D120200496 Pulled By: Abbondanzo fbshipit-source-id: b06ecb30935837abd6561f54674afef0cdf215a8
1 parent 68542c5 commit b439ef7

22 files changed

Lines changed: 450 additions & 56 deletions

‎packages/react-native/React/Fabric/AppleEventBeat.cpp‎

Lines changed: 0 additions & 31 deletions
This file was deleted.

‎packages/react-native/React/Fabric/AppleEventBeat.h‎

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,18 @@
77

88
#pragma once
99

10+
#include <functional>
11+
#include <memory>
12+
#include <optional>
13+
14+
#import <QuartzCore/QuartzCore.h>
15+
1016
#include <ReactCommon/RuntimeExecutor.h>
1117
#include <react/renderer/core/EventBeat.h>
1218
#include <react/utils/RunLoopObserver.h>
1319

20+
@class RCTEventBeatFlusherLayer;
21+
1422
namespace facebook::react {
1523

1624
class RuntimeScheduler;
@@ -19,13 +27,38 @@ class RuntimeScheduler;
1927
* Event beat associated with JavaScript runtime.
2028
* The beat is called on `RuntimeExecutor`'s thread induced by the UI thread
2129
* event loop.
30+
*
31+
* A synchronous request made while Core Animation is laying out the current
32+
* frame (the run loop observer that induces the beat has already run at that
33+
* point) is additionally induced from the display phase of the same commit
34+
* cycle, so that its effects are mounted before the frame is presented. The
35+
* induce is scheduled on the layer of the requesting view's window — the root
36+
* of the tree Core Animation is laying out when the request is made from
37+
* layout.
2238
*/
2339
class AppleEventBeat : public EventBeat, public RunLoopObserver::Delegate {
2440
public:
41+
/*
42+
* Resolves the layer of the window containing the view with the given tag.
43+
* Called on the main thread; returns nil when the view is not mounted or
44+
* not attached to a window.
45+
*/
46+
using WindowLayerResolver = std::function<CALayer *(Tag)>;
47+
2548
AppleEventBeat(
2649
std::shared_ptr<OwnerBox> ownerBox,
2750
std::unique_ptr<const RunLoopObserver> uiRunLoopObserver,
28-
RuntimeScheduler &RuntimeScheduler);
51+
RuntimeScheduler &RuntimeScheduler,
52+
WindowLayerResolver windowLayerResolver);
53+
AppleEventBeat(const AppleEventBeat &) = delete;
54+
AppleEventBeat &operator=(const AppleEventBeat &) = delete;
55+
AppleEventBeat(AppleEventBeat &&) = delete;
56+
AppleEventBeat &operator=(AppleEventBeat &&) = delete;
57+
58+
~AppleEventBeat() override;
59+
60+
using EventBeat::requestSynchronous;
61+
void requestSynchronous(Tag tag) const override;
2962

3063
#pragma mark - RunLoopObserver::Delegate
3164

@@ -34,6 +67,9 @@ class AppleEventBeat : public EventBeat, public RunLoopObserver::Delegate {
3467

3568
private:
3669
std::unique_ptr<const RunLoopObserver> uiRunLoopObserver_;
70+
WindowLayerResolver windowLayerResolver_;
71+
NSMapTable<CALayer *, RCTEventBeatFlusherLayer *> *layers_;
72+
void (^onDisplay_)(void);
3773
};
3874

3975
} // namespace facebook::react
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
/*
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
#include "AppleEventBeat.h"
9+
10+
#import <QuartzCore/QuartzCore.h>
11+
#import <React/RCTUtils.h>
12+
13+
#include <react/debug/react_native_assert.h>
14+
15+
/*
16+
* A zero-sized layer whose only purpose is to run a callback during the
17+
* display phase of a Core Animation commit. Core Animation processes a commit
18+
* as layout → display → (repeat until stable) → commit, so a layer marked as
19+
* needing display during the layout phase has its `display` called after the
20+
* whole layout pass but before the transaction is committed.
21+
*/
22+
@interface RCTEventBeatFlusherLayer : CALayer
23+
- (instancetype)initWithOnDisplay:(void (^)(void))onDisplay;
24+
@end
25+
26+
@implementation RCTEventBeatFlusherLayer {
27+
void (^_onDisplay)(void);
28+
}
29+
30+
- (instancetype)initWithOnDisplay:(void (^)(void))onDisplay
31+
{
32+
self = [super init];
33+
if (self != nil) {
34+
_onDisplay = [onDisplay copy];
35+
self.frame = CGRectZero;
36+
}
37+
return self;
38+
}
39+
40+
- (void)display
41+
{
42+
_onDisplay();
43+
}
44+
45+
// The layer is not a visual element; never participate in animations.
46+
- (id<CAAction>)actionForKey:(NSString *)event
47+
{
48+
return nil;
49+
}
50+
51+
@end
52+
53+
namespace facebook::react {
54+
55+
AppleEventBeat::AppleEventBeat(
56+
std::shared_ptr<OwnerBox> ownerBox,
57+
std::unique_ptr<const RunLoopObserver> uiRunLoopObserver,
58+
RuntimeScheduler &runtimeScheduler,
59+
WindowLayerResolver windowLayerResolver)
60+
: EventBeat(std::move(ownerBox), runtimeScheduler),
61+
uiRunLoopObserver_(std::move(uiRunLoopObserver)),
62+
windowLayerResolver_(std::move(windowLayerResolver)),
63+
layers_([NSMapTable weakToStrongObjectsMapTable])
64+
{
65+
std::weak_ptr<const void> weakOwner = ownerBox_->owner;
66+
onDisplay_ = ^{
67+
// The owner (indirectly) retains the event beat; if it is gone, so is
68+
// the beat this induces.
69+
auto owner = weakOwner.lock();
70+
if (!owner) {
71+
return;
72+
}
73+
this->induce();
74+
};
75+
76+
uiRunLoopObserver_->setDelegate(this);
77+
uiRunLoopObserver_->enable();
78+
}
79+
80+
AppleEventBeat::~AppleEventBeat()
81+
{
82+
// The beat can be destroyed on any thread; layer mutations belong on the
83+
// main thread. The block only retains the layers, and a display happening
84+
// before it executes is made safe by the owner check above.
85+
NSMapTable<CALayer *, RCTEventBeatFlusherLayer *> *layers = layers_;
86+
RCTExecuteOnMainQueue(^{
87+
NSEnumerator<RCTEventBeatFlusherLayer *> *enumerator = layers.objectEnumerator;
88+
RCTEventBeatFlusherLayer *layer = enumerator.nextObject;
89+
while (layer != nil) {
90+
[layer removeFromSuperlayer];
91+
layer = enumerator.nextObject;
92+
}
93+
});
94+
}
95+
96+
void AppleEventBeat::requestSynchronous(Tag tag) const
97+
{
98+
EventBeat::requestSynchronous(tag);
99+
100+
if (tag == kNoTag || !RCTIsMainQueue()) {
101+
return;
102+
}
103+
CALayer *hostLayer = windowLayerResolver_ ? windowLayerResolver_(tag) : nil;
104+
if (hostLayer == nil) {
105+
return;
106+
}
107+
RCTEventBeatFlusherLayer *layer = [layers_ objectForKey:hostLayer];
108+
if (layer == nil) {
109+
layer = [[RCTEventBeatFlusherLayer alloc] initWithOnDisplay:onDisplay_];
110+
[layers_ setObject:layer forKey:hostLayer];
111+
}
112+
if (layer.superlayer != hostLayer) {
113+
[layer removeFromSuperlayer];
114+
[hostLayer addSublayer:layer];
115+
}
116+
[layer setNeedsDisplay];
117+
}
118+
119+
void AppleEventBeat::activityDidChange(
120+
const RunLoopObserver::Delegate *delegate,
121+
RunLoopObserver::Activity /*activity*/) const noexcept
122+
{
123+
react_native_assert(delegate == this);
124+
induce();
125+
}
126+
127+
} // namespace facebook::react

‎packages/react-native/React/Fabric/RCTSurfacePresenter.mm‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -292,11 +292,17 @@ - (RCTScheduler *)_createScheduler
292292
toolbox.runtimeExecutor = runtimeExecutor;
293293
toolbox.bridgelessBindingsExecutor = _bridgelessBindingsExecutor;
294294

295-
toolbox.eventBeatFactory =
296-
[runtimeScheduler](std::shared_ptr<EventBeat::OwnerBox> ownerBox) -> std::unique_ptr<EventBeat> {
295+
__weak RCTMountingManager *weakMountingManager = _mountingManager;
296+
toolbox.eventBeatFactory = [runtimeScheduler, weakMountingManager](
297+
std::shared_ptr<EventBeat::OwnerBox> ownerBox) -> std::unique_ptr<EventBeat> {
297298
auto runLoopObserver =
298299
std::make_unique<const MainRunLoopObserver>(RunLoopObserver::Activity::BeforeWaiting, ownerBox->owner);
299-
return std::make_unique<AppleEventBeat>(std::move(ownerBox), std::move(runLoopObserver), *runtimeScheduler);
300+
auto windowLayerResolver = [weakMountingManager](Tag tag) -> CALayer * {
301+
RCTMountingManager *mountingManager = weakMountingManager;
302+
return [mountingManager.componentViewRegistry findComponentViewWithTag:tag].window.layer;
303+
};
304+
return std::make_unique<AppleEventBeat>(
305+
std::move(ownerBox), std::move(runLoopObserver), *runtimeScheduler, std::move(windowLayerResolver));
300306
};
301307

302308
RCTScheduler *scheduler = [[RCTScheduler alloc] initWithToolbox:toolbox];

‎packages/react-native/ReactCommon/react/renderer/core/EventBeat.cpp‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ void EventBeat::request() const {
2626
}
2727

2828
void EventBeat::requestSynchronous() const {
29+
requestSynchronous(kNoTag);
30+
}
31+
32+
void EventBeat::requestSynchronous(Tag /*tag*/) const {
2933
react_native_assert(
3034
beatCallback_ &&
3135
"Unexpected state: EventBeat::setBeatCallback was not called before EventBeat::requestSynchronous.");
@@ -53,7 +57,14 @@ void EventBeat::induce() const {
5357
isEventBeatRequested_ = false;
5458

5559
if (isBeatCallbackScheduled_) {
56-
return;
60+
// An asynchronous beat is already scheduled but has not run yet. A
61+
// synchronous request must not be stranded behind it (it would silently
62+
// lose its this-frame guarantee, and the leftover flag would make an
63+
// unrelated later beat blocking), so it proceeds and processes the queue
64+
// now; the already scheduled beat will simply find an empty queue.
65+
if (!isSynchronousRequested_) {
66+
return;
67+
}
5768
}
5869

5970
isBeatCallbackScheduled_ = true;

‎packages/react-native/ReactCommon/react/renderer/core/EventBeat.h‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
#include <react/cxxstableapi/UmbrellaGuard.h>
1111

12+
#include <react/renderer/core/ReactPrimitives.h>
1213
#include <atomic>
1314
#include <functional>
1415
#include <memory>
@@ -110,8 +111,17 @@ class EventBeat {
110111
* thread────────────────────┴─────────────────────────┴▶
111112
* Both JS and UI thread are
112113
* blocked.
114+
*
115+
* `tag` is the view the request originates from, or `kNoTag` when unknown.
116+
* Platform implementations use it to schedule an induce where that view
117+
* renders, and fall back to their ordinary beat timing without it.
118+
*/
119+
virtual void requestSynchronous(Tag tag) const;
120+
121+
/*
122+
* Convenience for requesters with no view attribution.
113123
*/
114-
virtual void requestSynchronous() const;
124+
void requestSynchronous() const;
115125

116126
/*
117127
* The callback will be executed once a consumer (for example EventQueue)

‎packages/react-native/ReactCommon/react/renderer/core/EventDispatcher.cpp‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,8 @@ void EventDispatcher::dispatchEvent(RawEvent&& rawEvent) const {
3737
eventQueue_.enqueueEvent(std::move(rawEvent));
3838
}
3939

40-
void EventDispatcher::experimental_flushSync() const {
41-
eventQueue_.experimental_flushSync();
40+
void EventDispatcher::experimental_flushSync(Tag tag) const {
41+
eventQueue_.experimental_flushSync(tag);
4242
}
4343

4444
void EventDispatcher::dispatchStateUpdate(

‎packages/react-native/ReactCommon/react/renderer/core/EventDispatcher.h‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
#include <react/renderer/core/EventLogger.h>
1515
#include <react/renderer/core/EventQueue.h>
1616
#include <react/renderer/core/EventQueueProcessor.h>
17+
#include <react/renderer/core/ReactPrimitives.h>
1718
#include <react/renderer/core/StatePipe.h>
1819
#include <react/renderer/core/StateUpdate.h>
1920
#include <memory>
@@ -46,7 +47,7 @@ class EventDispatcher {
4647
/*
4748
* Experimental API exposed to support EventEmitter::experimental_flushSync.
4849
*/
49-
void experimental_flushSync() const;
50+
void experimental_flushSync(Tag tag) const;
5051

5152
/*
5253
* Dispatches a raw event with asynchronous batched priority. Before the

‎packages/react-native/ReactCommon/react/renderer/core/EventEmitter.cpp‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77

88
#include "EventEmitter.h"
99

10+
#include <react/renderer/core/ShadowNodeFamily.h>
11+
1012
#include <cxxreact/TraceSection.h>
1113
#include <folly/dynamic.h>
1214
#include <jsi/jsi.h>
@@ -234,6 +236,13 @@ void EventEmitter::setShadowNodeFamily(
234236
shadowNodeFamily_ = std::move(shadowNodeFamily);
235237
}
236238

239+
Tag EventEmitter::getTag() const {
240+
if (auto family = shadowNodeFamily_.lock()) {
241+
return family->getTag();
242+
}
243+
return kNoTag;
244+
}
245+
237246
const SharedEventTarget& EventEmitter::getEventTarget() const {
238247
return eventTarget_;
239248
}

‎packages/react-native/ReactCommon/react/renderer/core/EventEmitter.h‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ class EventEmitter {
7777
}
7878

7979
syncFunc();
80-
eventDispatcher->experimental_flushSync();
80+
eventDispatcher->experimental_flushSync(getTag());
8181
}
8282

8383
/*
@@ -133,6 +133,12 @@ class EventEmitter {
133133
private:
134134
friend class UIManagerBinding;
135135

136+
/*
137+
* The tag of the view this emitter belongs to, or `kNoTag` once its family
138+
* is gone.
139+
*/
140+
Tag getTag() const;
141+
136142
SharedEventTarget eventTarget_;
137143
std::weak_ptr<const ShadowNodeFamily> shadowNodeFamily_;
138144

0 commit comments

Comments
 (0)