Skip to content

Alternative: schedule the display-phase event beat induce on the requesting surface - #58528

Closed
janicduplessis wants to merge 3 commits into
react:mainfrom
janicduplessis:safe-area/1-alt-root-view-flusher
Closed

janicduplessis wants to merge 3 commits into
react:mainfrom
janicduplessis:safe-area/1-alt-root-view-flusher

Conversation

@janicduplessis

@janicduplessis janicduplessis commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Summary:

Alternative to #58108's display-phase scheduling, opened for comparison — one of the two should land, not both. The stack in #58108#58113 works as-is on top of either.

Both PRs solve the same problem: a synchronous event beat requested while Core Animation is already laying out (an event emitted from layoutSubviews) is only processed on the next frame by the run loop observer. #58108 schedules an induce in the display phase of the current commit cycle by keeping a zero-sized flusher layer in every visible window of every foreground scene, since only a layer inside a committing tree is guaranteed a display phase, and the beat does not know which tree that is.

This PR gives it that knowledge instead. experimental_flushSync now carries the surface of the requesting emitter — every emitter is given its ShadowNodeFamily at creation, and the family knows its surface — through EventDispatcher and EventQueue to EventBeat::requestSynchronous(std::optional<SurfaceId>). AppleEventBeat resolves the surface to its root view's layer through a resolver injected by RCTSurfacePresenter from the surface registry, and attaches the single flusher layer there. A request made from layout runs inside the commit of exactly that tree, so the layer is guaranteed a display phase in the same cycle.

What this buys over #58108:

  • No layers inserted into windows React Native does not own (keyboard and system windows of foreground scenes).
  • No reliance on Core Animation committing all windows in one transaction — the multi-scene iPad caveat documented in Add an onSafeAreaInsetsChange view prop #57967 is structurally gone.
  • One dirty layer per requesting surface per frame instead of one per visible window.
  • VirtualView's synchronous flushes get the same targeting through their own emitter, with no change at the call site.

The cost is API: the experimental_flushSync chain changes signature across four core types (all experimental_, Android ignores the parameter), where #58108 is self-contained in AppleEventBeat. An absent surface — an emitter whose family is gone, or a future caller without one — falls back to the run loop observer's ordinary schedule.

Known rough edges, flagged for review: requestSynchronous's defaulted std::optional argument on a virtual should likely become an overload, and the C++ API snapshots are not regenerated for the new signatures yet.

Changelog:

[INTERNAL] - Schedule the synchronous event beat induce on the requesting surface's root view

Test Plan:

Same marker methodology as #58108 (any presented frame of the yellow observing-without-insets marker means the dispatch was not synchronous), re-run on this implementation with RNTester on an iPhone 17 Pro simulator: the full apply → landscape → portrait capture below was decomposed with ffmpeg into 950 frames and every frame scanned for the marker color — zero marker frames. Mid-rotation frames already show the incoming orientation's insets (top: 62, bottom: 34 while the rotation animation is still in flight), so the padding animates with the transition.

ViewSafeAreaInsets-itest passes 4/4 with the prop stack merged on top; EventBeatTest covers the unchanged beat semantics through the base-class default.

proto-capture.mp4

`EventEmitter::experimental_flushSync` only *requests* a beat, which is
processed at the next `EventBeat::induce`. On Android the induce happens
within the frame, before drawing, so a synchronous request made during
layout is processed in that frame. On iOS it is not: 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 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 has
its `display` called after the whole layout pass and before the transaction
is committed. A layer is kept in every visible window of every foreground
scene, since the request can come from any of them — a modal and the LogBox
are windows of their own — and only a layer in the tree being committed is
guaranteed a display this cycle. Requests within one cycle coalesce into a
single induce, so mounting ten observing views is one beat rather than ten.

Two related fixes 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), and `induce` becomes public so platform beats can call
it from a callback. `AppleEventBeat.cpp` becomes `.mm` for the Objective-C.

Covered by new unit tests in `EventBeatTest.cpp`.

This is the platform half of the safe area insets work: it is what makes an
inset change reported from `layoutSubviews` render in the frame it happened
in. `VirtualView` uses the same mechanism.
The driver ticked the queue exactly twice, but the number of queued tasks
depends on thread interleaving: when the synchronous access request is
queued before the first tick, the scheduled beat's work item yields without
executing and is re-queued for a third tick that never came. Drive the
queue from the test thread with the same waitForTasks synchronization the
RuntimeScheduler tests use, asserting each stage.
experimental_flushSync now carries the surface of the emitter that
requested it (from the ShadowNodeFamily every emitter is given at
creation) through EventDispatcher and EventQueue to
EventBeat::requestSynchronous, as an optional: without a surface the
induce falls back to the run loop observer's ordinary schedule.

AppleEventBeat resolves the surface to its root view's layer via a
resolver injected by RCTSurfacePresenter from the surface registry, and
attaches the flusher layer there instead of to every visible window. A
request made from layout runs inside the commit of exactly that tree, so
the layer is guaranteed a display phase in the same cycle without
assuming all windows commit in one transaction. VirtualView's sync
flushes get the same targeting through its own emitter, unchanged.
@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Sep 14, 2026
@facebook-github-tools facebook-github-tools Bot added the Contributor A React Native contributor. label Sep 14, 2026
@janicduplessis

Copy link
Copy Markdown
Contributor Author

Closing in favor of #58108 — the exploration answered the question, against this approach.

The disqualifying finding: surface identity does not identify the committing layer tree for content inside a non-transparent <Modal>. RCTModalHostViewComponentView presents UIModalPresentationFullScreen, and UIKit detaches the presenting hierarchy — which contains the surface's root view — once the presentation settles. A sync flush from modal content therefore dirties a layer in a tree Core Animation never commits, and the same-frame induce silently degrades to run-loop-observer timing. Modals are a primary case for synchronous safe-area events, and #58108's per-window layers handle them by construction. Repairing this here would mean resolving the emitting view's tree (much deeper plumbing than a surface id) or falling back to per-window scanning when the root is detached — at which point both mechanisms coexist on top of a four-type experimental_flushSync signature change, and the remaining benefit is only fewer dirty layers per frame.

Two implementation issues found in review, recorded for any future revival:

  • The resolver's surfaceForRootTag:...view.layer is not read-only: RCTFabricSurface.view lazily creates an RCTSurfaceView and attaches a touch handler when the surface is registered but not started (or mid-reload), so the resolver could allocate an orphan view inside a CA layout phase. It must gate on the surface's stage or use a non-creating accessor.
  • EventEmitter::getSurfaceId should not take DispatchMutex (held by the JS thread across whole-tree updateMountedFlag walks during commits — a main-thread stall hazard on the flush path, and the set-once family needs no lock). The better source is the emitter's existing EventTarget, which carries an immutable surfaceId_ behind a lock-free getter.

Scope correction on the test plan above: the capture proves no regression of the marker test, but it does not prove same-frame delivery for the modal case — the first inset event after Apply insets is processed at the call site (no flusher involved), and a one-frame lag during a rotation animation is not distinguishable in the frame scan. #58108's captures don't carry this caveat because its mechanism doesn't depend on identifying the committing tree.

The branch stays up for reference.

@janicduplessis

Copy link
Copy Markdown
Contributor Author

Addendum — a variant that resolves the disqualifying finding, recorded here in case targeting is ever wanted: pass the emitting view's tag instead of its surface. EventTarget::getTag() gives the emitter the tag through the same immutable, lock-free path as getSurfaceId(), and on iOS RCTComponentViewRegistry.findComponentViewWithTag: is a nullable, non-creating, main-thread lookup already reachable from RCTSurfacePresenter. The resolver becomes tag → view → view.window.layer: the emitting view's window is by definition the root of the tree whose layout is running, so full-screen <Modal> content targets the modal's own window, and all three review findings above disappear by construction (no view-creating getter, no DispatchMutex, correct tree everywhere). The remaining cost is the same four-type experimental_flushSync signature change, which is why #58108's self-contained per-window approach still lands the stack; this is the shape a targeted follow-up should take.

@janicduplessis

Copy link
Copy Markdown
Contributor Author

The view-tag variant from the addendum above is now implemented and opened as #58530, verified with the marker methodology inside the full screen modal (982 frames, zero marker frames).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. Contributor A React Native contributor.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant