Skip to content

Process synchronous event beats in the frame that requested them - #58530

Closed
janicduplessis wants to merge 1 commit into
react:mainfrom
janicduplessis:safe-area/1-alt-view-tag-flusher
Closed

janicduplessis wants to merge 1 commit into
react:mainfrom
janicduplessis:safe-area/1-alt-view-tag-flusher

Conversation

@janicduplessis

@janicduplessis janicduplessis commented Sep 15, 2026 •

Copy link
Copy Markdown
Contributor

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 (Name the no-tag sentinel and use it for the hardcoded -1 tags #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

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.

tag-capture.mp4

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.

@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 15, 2026
@facebook-github-tools facebook-github-tools Bot added the Contributor A React Native contributor. label Sep 15, 2026
@janicduplessis
janicduplessis force-pushed the safe-area/1-alt-view-tag-flusher branch from aa3d9fa to 7e69b03 Compare September 15, 2026 03:39
@janicduplessis janicduplessis changed the title Alternative: schedule the display-phase event beat induce on the requesting view's window Schedule the display-phase event beat induce on the requesting view's window Sep 15, 2026
@janicduplessis
janicduplessis force-pushed the safe-area/1-alt-view-tag-flusher branch from 7e69b03 to 110bae8 Compare September 15, 2026 15:12
@janicduplessis janicduplessis changed the title Schedule the display-phase event beat induce on the requesting view's window Process synchronous event beats in the frame that requested them Sep 15, 2026
@janicduplessis
janicduplessis marked this pull request as ready for review September 15, 2026 15:17
@janicduplessis

janicduplessis commented Sep 15, 2026 •

Copy link
Copy Markdown
Contributor Author

@Abbondanzo This one is ready for review, the 2nd PR of the safe area insets stack. I changed the approach a little bit from what you reviewed initially, I think it is a lot cleaner this way, just a little bit more plumbing to pass around the view tag, but allows to avoid looping through every windows which I really didn't like.

@facebook-github-tools facebook-github-tools Bot added the Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team. label Sep 15, 2026
@meta-codesync

meta-codesync Bot commented Sep 15, 2026

Copy link
Copy Markdown

@Abbondanzo has imported this pull request. If you are a Meta employee, you can view this in D120200496.

meta-codesync Bot pushed a commit that referenced this pull request Sep 16, 2026
Summary:
React tags are positive; `-1` marks the absence of one in several places with a bare literal. This names the convention — `kNoTag` in `ReactPrimitives.h`, next to the `Tag` alias and mirroring `ViewUtil.NO_SURFACE_ID` on the Android side — and replaces the literals across the renderer: `ShadowViewMutation`'s `parentTag` default, factories and comparison, the stub view tree (folding its duplicate `NO_VIEW_TAG` constant into `kNoTag`), the `CppMountItem` mirrors on Android, `UIManagerViewTransitionDelegate`'s `nativeTag` and the view transition fallback that feeds it, and the pointer-events no-override comparisons.

Split out of the safe area insets stack: #58530 threads a `Tag` with no-tag semantics through `experimental_flushSync` and wants the named sentinel, but the cleanup stands on its own and can land regardless of that PR's fate.

## Changelog:

[INTERNAL] - Name the no-tag sentinel (`kNoTag`) and use it for the hardcoded `-1` tags

Pull Request resolved: #58531

Test Plan: Compiles via the Fantom tester build; `yarn fantom` mounting-heavy suite passes 224/224 (exercises the `StubViewTree` asserts). C++ API snapshots regenerated — the only delta is the new `kNoTag` constant.

Reviewed By: javache

Differential Revision: D120199347

Pulled By: Abbondanzo

fbshipit-source-id: 4198a33fa601edff53c54bce935814b3414c5820
@janicduplessis
janicduplessis force-pushed the safe-area/1-alt-view-tag-flusher branch 3 times, most recently from 55337fc to 8290f12 Compare September 16, 2026 15:49
@janicduplessis

Copy link
Copy Markdown
Contributor Author

@Abbondanzo Rebased on top of the tag PR that landed.

@janicduplessis
janicduplessis force-pushed the safe-area/1-alt-view-tag-flusher branch from 8290f12 to d804f43 Compare September 16, 2026 16:09
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, read from its ShadowNodeFamily, 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.
@janicduplessis
janicduplessis force-pushed the safe-area/1-alt-view-tag-flusher branch from d804f43 to c73ef0f Compare September 17, 2026 14:44
@meta-codesync meta-codesync Bot closed this in b439ef7 Sep 21, 2026
@meta-codesync meta-codesync Bot added the Merged This PR has been merged. label Sep 21, 2026
@meta-codesync

meta-codesync Bot commented Sep 21, 2026

Copy link
Copy Markdown

@Abbondanzo merged this pull request in b439ef7.

meta-codesync Bot pushed a commit that referenced this pull request Sep 21, 2026
Summary:
Apply the repository C++ formatter to `RCTSurfacePresenter.mm`. The lambda parameter added in #58530 was indented one column too far, causing the `format` job on `main` to produce a tracked diff and fail.

## Changelog:

[INTERNAL] [FIXED] - Fix formatting in RCTSurfacePresenter

Pull Request resolved: #58617

Test Plan:
- `yarn format-cpp` — completes successfully and changes only `packages/react-native/React/Fabric/RCTSurfacePresenter.mm`.
- `node ./scripts/clang-format.js --check packages/react-native/React/Fabric/RCTSurfacePresenter.mm` — passes.
- `git diff --check` — passes.
- `yarn format-check-cpp` — the repository-wide check still reports pre-existing violations in vendored Hermes sources; the targeted check above passes for the changed file.

Reviewed By: cipolleschi

Differential Revision: D120989136

Pulled By: cortinico

fbshipit-source-id: ccda2f291ad60e9de7b5fffd153f939102dea238
meta-codesync Bot pushed a commit that referenced this pull request Oct 7, 2026
…ews (#58632)

Summary:
`RCTParagraphComponentView` derives the text view's frame and drawing frame in `layoutSubviews`, requested with `setNeedsLayout` from `updateState` and `updateLayoutMetrics`. The layout pass itself dates from #46081, but until #57408 `drawRect:` read the content frame straight from `_layoutMetrics`, which is always current; #57408 moved drawing onto a `drawingFrame` computed in `layoutSubviews`, so drawing now assumes a layout pass will run before the view is displayed. It does not when the mount itself happens inside Core Animation's display phase — which is where `AppleEventBeat` processes a synchronous event requested during layout (#58530). This transaction's layout pass is already over, so the text view displays the new attributed string in the previous drawing frame and the text is cut off at the old width. The queued `layoutSubviews` updates the frame in the next transaction, but `drawingFrame` is a plain property that does not invalidate the display, so the clipped drawing stays until something else redraws the view.

Nothing on `main` emits a synchronous event from layout yet, so this is latent today; #58109 is the first thing that does (`layoutSubviews` reporting safe area insets on rotation) and it hits this on every rotation.

The frames are now computed in `finalizeUpdates:`, which the mounting manager calls [once per view after all of a mutation's `update*` calls](https://github.com/react/react-native/blob/f03f6c2b856e7d923f8af13d7ef452eb4bb21cd4/packages/react-native/React/Fabric/Mounting/RCTMountingManager.mm#L108-L134). That coalesces state and layout-metrics changes the same way the layout pass did — one computation per mount, as before — without depending on a layout pass that may already have happened, and it removes the extra layout pass altogether. The `layoutSubviews` override goes away; `updateState` and `updateLayoutMetrics` keep their `setNeedsDisplay`.

## Changelog:

[IOS] [FIXED] - Text no longer renders clipped when mounted from a synchronous event during layout

Pull Request resolved: #58632

Test Plan:
Reproduced with #58109 on top of this branch, RNTester "Safe area insets" example, iPhone 17 Pro simulator: present the padded modal, apply insets, rotate. The readout text is re-rendered synchronously from the rotation's layout pass.

| Before | After |
|:---:|:---:|
| <img src="https://github.com/user-attachments/assets/092b162b-1b28-4b02-b584-4c28d66a96b5" width="440" /> | <img src="https://github.com/user-attachments/assets/52ae2ce8-75c7-4b2c-9e90-006e68b2588e" width="440" /> |

Before: `top: 0, right: 62, bottom: 19.99996` — the accessibility label carries the full `…9482421875, left: 62`, and the layout box is the right size; only the drawing is cut at the previous width. The paragraph under it overflows on one line for the same reason. After: both render at their new width. Rotating back to portrait renders correctly too, and ordinary text throughout RNTester (normal asynchronous mounts) is unchanged.

 ---

Found while testing #58109, which hits this on every rotation; standalone, that stack does not include this change.

Reviewed By: andrewdacenko, javache

Differential Revision: D121209981

Pulled By: Abbondanzo

fbshipit-source-id: 6fda49771b08d489623e2bb35c5b59ec46ac03da
Abbondanzo pushed a commit to Abbondanzo/react-native that referenced this pull request Oct 8, 2026
Summary:
Reports the part of a view that is covered by the system UI, as a view prop:

```jsx
<View
  experimental_onSafeAreaInsetsChange={({nativeEvent: {insets}}) => {
    // insets: {top, right, bottom, left}
  }}
/>
```

`SafeAreaView` is deprecated in favour of `react-native-safe-area-context` (react-native-community/discussions-and-proposals#827), 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`](https://github.com/AppAndFlow/react-native-safe-area-context/blob/main/src/specs/NativeSafeAreaProvider.ts) component. This adds that primitive as a view prop, 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.

The event is dispatched synchronously through `experimental_flushSync`, so the layout that depends on the insets is mounted in the frame the insets changed in (on iOS that relies on react#58530 for events emitted from `layoutSubviews`). A full inset event — dispatch, JS render, commit, mount — is about 3 ms in a debug build re-rendering a small component, paid per inset change rather than per frame.

**Two things I'd like input on:**
- Whether blocking the UI thread on every inset change is acceptable, or should be opt-in per view.
- The cost when unused: a `bool` in `BaseViewProps` like `onLayout`, and a branch on it in `layoutSubviews`, `didMoveToWindow` and `safeAreaInsetsDidChange` on every view. Worth a look from someone who profiles that path. On Android nothing is attached unless the prop is set.

In development, `View` wraps the handler and warns once per view above ten events in a second. The system UI does not move that often, so a sustained stream means the layout is feeding the insets back into the view's own position — offset by what it reports, it moves out from under the system UI, which changes what it reports. The check is one JavaScript implementation for both platforms and surfaces in LogBox with a stack rather than in logcat.

### Design decisions

**Events fire only when the insets change, not on the view moving.** An earlier iteration also fired on frame changes and sustained ~5,000 events/s on an idle screen: each synchronous render produces a new frame, which re-runs the pre-draw listener, which emits again. Triggering on insets alone makes that loop structurally impossible, so a view moving *within* the safe area is silent — 50 observing rows in the example's scroll benchmark emit nothing while scrolling (event counters on both platforms), and scroll frame times matched 0 rows on the prototype in react#57967. A view moving *through* a system-bar band is a different case: its insets change every frame it overlaps the band, each one a synchronous render. That is inherent to reporting insets and is the cost the open question above is about; it is not covered by the benchmark, whose rows sit in a bounded container.

**The payload is the insets alone; no frame.** With an inset-only trigger a frame would only be current as of the last inset change, and it needs a coordinate space that differs per platform. `onLayout` and `measureInWindow` give a view a frame that stays current.

**A sentinel, not a pointer, for "no event sent yet" on iOS.** The last-sent insets are a plain `UIEdgeInsets` ivar initialized to `{-1, -1, -1, -1}`; insets are never negative, so `top >= 0` means one was sent. An `NSValue *` that is nil until the first event was the alternative — 24 bytes smaller per view, but a heap allocation per inset change and boxing on every comparison.

**Observation is (re)started 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 for a transition diff; the sentinel is reset in `prepareForRecycle` for the same reason.

**Nothing emits from inside the prop setter.** Setting the prop runs inside the mounting transaction, where synchronously re-entering React is not safe. On iOS everything that might have changed the insets (`didMoveToWindow`, `safeAreaInsetsDidChange`, the prop being set) only marks the view as needing layout, and the emit happens in `layoutSubviews`; on Android the observer's first emit waits for the pre-draw listener rather than running from `setEnabled`. Both still land in the same frame, since layout and pre-draw run before the frame is displayed.

**The warning wraps the handler in `View`, not in either native observer.** In production the wrapper is the identity function, so the module stays out of the bundle. Wrapping does not touch the native prop: function props are normalized to `true` before props are diffed ([`ReactNativeAttributePayload.js`](https://github.com/react/react-native/blob/ab2ea649e6/packages/react-native/Libraries/ReactNative/ReactFabricPublicInstance/ReactNativeAttributePayload.js#L253-L267)), so a fresh wrapper per render produces no update. Counts live in a `WeakMap` keyed by the event target, so views that never loop pay nothing.

**Two Android wiring details:**
- The prop is forwarded through `BaseViewManagerDelegate`; components with generated delegates (Switch, DrawerLayout, …) route base props through it, not the reflection-based `ReactProp` path.
- `topSafeAreaInsetsChange` is exported from `BaseViewManager`'s native view config, so the event maps to the handler when native view configs are in use.

## Changelog:

[GENERAL] [ADDED] - Add an `experimental_onSafeAreaInsetsChange` view prop, reporting the part of a view that is covered by the system UI, with a development warning for views that report their insets in a loop

Pull Request resolved: react#58109

Test Plan:
RNTester, new "Safe area insets" example, on an iPhone 17 Pro simulator and an Android 16 emulator: a view inside the safe area reads zero insets; a full screen view padding itself by its own insets lines up with the system UI in portrait and landscape on both platforms; the scroll benchmark counts events on both platforms. Screenshots and the synchronous-dispatch frame captures are in react#57967, the prototype this splits. The example also grows the mistake the warning catches — a view positioned by the insets it reports — behind a button, and it logs once.

Fantom (`ViewSafeAreaInsets-itest.js`, `ViewSafeAreaInsetsWarning-itest.js`):

- **Delivery and opt-in** — the event reaches the handler with the insets; a view without the prop is never its target.
- **View flattening** — a layout-only view is flattened away; the same view is kept once it has the prop, since observing needs a host view (asserted both ways).
- **Warning** — silence at a plausible rate (20 changes 200 ms apart), one warning per view under a loop, per-view counting, and the handler still receiving its event, with a mocked clock.

On device:

- **View recycling** — scrolling a long list of observing rows in and out; recycled rows report their own insets, not a previous occupant's, on both platforms.
- **Clipped Android views** — rows scrolled out of a `ScrollView` emit nothing instead of garbage overlap values (event counters in the scroll benchmark).
- **Multi-scene iPad** — with `UIApplicationSupportsMultipleScenes` enabled in a local RNTester build (it ships off): two windows, two React instances, one shared key window, correct per-window insets across tiling, fullscreen, rotation and keyboard.

**Known gaps, not addressed here:**
- `FabricUIManager`'s per-frame synchronous-event dedupe can drop a second inset change for the same view within one frame; in practice insets don't change twice per frame.
- `getGlobalVisibleRect` mixes coordinate spaces for partially clipped views, inherited from the library's implementation.
- Android rotation was not exercised: the RNTester activity kept its orientation on my emulator. The same pre-draw listener drives it.
- The loop warning only covers `View`. The prop is on `BaseViewProps`, so `Text`, `Image` and `ScrollView` accept it too; `View` is where it is used in practice.
- The loop warning's heuristic (more than ten events in a second) also fires for a view dragged slowly across a system-bar band, which is a legitimate stream. I have not seen it in practice; a threshold on *alternating* values would distinguish the two if it turns out to matter.

 ---

**Stack** — split out of react#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. The display-phase event beat this builds on landed as react#58530.

This is the bottom of the stack, so its diff is already just this change.

👉 1. react#58109 — Add an `experimental_onSafeAreaInsetsChange` view prop
    2. react#58110 — Report the window safe area insets through Dimensions
    3. react#58112 — Render the internal SafeAreaView from the safe area insets prop
    4. react#58113 — Remove the native SafeAreaView and the deprecated public export

Reviewed By: andrewdacenko

Differential Revision: D121015233

Pulled By: Abbondanzo
meta-codesync Bot pushed a commit that referenced this pull request Oct 9, 2026
Summary:
Reports the part of a view that is covered by the system UI, as a view prop:

```jsx
<View
  experimental_onSafeAreaInsetsChange={({nativeEvent: {insets}}) => {
    // insets: {top, right, bottom, left}
  }}
/>
```

`SafeAreaView` is deprecated in favour of `react-native-safe-area-context` (react-native-community/discussions-and-proposals#827), 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`](https://github.com/AppAndFlow/react-native-safe-area-context/blob/main/src/specs/NativeSafeAreaProvider.ts) component. This adds that primitive as a view prop, 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.

The event is dispatched synchronously through `experimental_flushSync`, so the layout that depends on the insets is mounted in the frame the insets changed in (on iOS that relies on #58530 for events emitted from `layoutSubviews`). A full inset event — dispatch, JS render, commit, mount — is about 3 ms in a debug build re-rendering a small component, paid per inset change rather than per frame.

**Two things I'd like input on:**
- Whether blocking the UI thread on every inset change is acceptable, or should be opt-in per view.
- The cost when unused: a `bool` in `BaseViewProps` like `onLayout`, and a branch on it in `layoutSubviews`, `didMoveToWindow` and `safeAreaInsetsDidChange` on every view. Worth a look from someone who profiles that path. On Android nothing is attached unless the prop is set.

In development, `View` wraps the handler and warns once per view above ten events in a second. The system UI does not move that often, so a sustained stream means the layout is feeding the insets back into the view's own position — offset by what it reports, it moves out from under the system UI, which changes what it reports. The check is one JavaScript implementation for both platforms and surfaces in LogBox with a stack rather than in logcat.

### Design decisions

**Events fire only when the insets change, not on the view moving.** An earlier iteration also fired on frame changes and sustained ~5,000 events/s on an idle screen: each synchronous render produces a new frame, which re-runs the pre-draw listener, which emits again. Triggering on insets alone makes that loop structurally impossible, so a view moving *within* the safe area is silent — 50 observing rows in the example's scroll benchmark emit nothing while scrolling (event counters on both platforms), and scroll frame times matched 0 rows on the prototype in #57967. A view moving *through* a system-bar band is a different case: its insets change every frame it overlaps the band, each one a synchronous render. That is inherent to reporting insets and is the cost the open question above is about; it is not covered by the benchmark, whose rows sit in a bounded container.

**The payload is the insets alone; no frame.** With an inset-only trigger a frame would only be current as of the last inset change, and it needs a coordinate space that differs per platform. `onLayout` and `measureInWindow` give a view a frame that stays current.

**A sentinel, not a pointer, for "no event sent yet" on iOS.** The last-sent insets are a plain `UIEdgeInsets` ivar initialized to `{-1, -1, -1, -1}`; insets are never negative, so `top >= 0` means one was sent. An `NSValue *` that is nil until the first event was the alternative — 24 bytes smaller per view, but a heap allocation per inset change and boxing on every comparison.

**Observation is (re)started 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 for a transition diff; the sentinel is reset in `prepareForRecycle` for the same reason.

**Nothing emits from inside the prop setter.** Setting the prop runs inside the mounting transaction, where synchronously re-entering React is not safe. On iOS everything that might have changed the insets (`didMoveToWindow`, `safeAreaInsetsDidChange`, the prop being set) only marks the view as needing layout, and the emit happens in `layoutSubviews`; on Android the observer's first emit waits for the pre-draw listener rather than running from `setEnabled`. Both still land in the same frame, since layout and pre-draw run before the frame is displayed.

**The warning wraps the handler in `View`, not in either native observer.** In production the wrapper is the identity function, so the module stays out of the bundle. Wrapping does not touch the native prop: function props are normalized to `true` before props are diffed ([`ReactNativeAttributePayload.js`](https://github.com/react/react-native/blob/ab2ea649e6/packages/react-native/Libraries/ReactNative/ReactFabricPublicInstance/ReactNativeAttributePayload.js#L253-L267)), so a fresh wrapper per render produces no update. Counts live in a `WeakMap` keyed by the event target, so views that never loop pay nothing.

**Two Android wiring details:**
- The prop is forwarded through `BaseViewManagerDelegate`; components with generated delegates (Switch, DrawerLayout, …) route base props through it, not the reflection-based `ReactProp` path.
- `topSafeAreaInsetsChange` is exported from `BaseViewManager`'s native view config, so the event maps to the handler when native view configs are in use.

## Changelog:

[GENERAL] [ADDED] - Add an `experimental_onSafeAreaInsetsChange` view prop, reporting the part of a view that is covered by the system UI, with a development warning for views that report their insets in a loop

Pull Request resolved: #58109

Test Plan:
RNTester, new "Safe area insets" example, on an iPhone 17 Pro simulator and an Android 16 emulator: a view inside the safe area reads zero insets; a full screen view padding itself by its own insets lines up with the system UI in portrait and landscape on both platforms; the scroll benchmark counts events on both platforms. Screenshots and the synchronous-dispatch frame captures are in #57967, the prototype this splits. The example also grows the mistake the warning catches — a view positioned by the insets it reports — behind a button, and it logs once.

Fantom (`ViewSafeAreaInsets-itest.js`, `ViewSafeAreaInsetsWarning-itest.js`):

- **Delivery and opt-in** — the event reaches the handler with the insets; a view without the prop is never its target.
- **View flattening** — a layout-only view is flattened away; the same view is kept once it has the prop, since observing needs a host view (asserted both ways).
- **Warning** — silence at a plausible rate (20 changes 200 ms apart), one warning per view under a loop, per-view counting, and the handler still receiving its event, with a mocked clock.

On device:

- **View recycling** — scrolling a long list of observing rows in and out; recycled rows report their own insets, not a previous occupant's, on both platforms.
- **Clipped Android views** — rows scrolled out of a `ScrollView` emit nothing instead of garbage overlap values (event counters in the scroll benchmark).
- **Multi-scene iPad** — with `UIApplicationSupportsMultipleScenes` enabled in a local RNTester build (it ships off): two windows, two React instances, one shared key window, correct per-window insets across tiling, fullscreen, rotation and keyboard.

**Known gaps, not addressed here:**
- `FabricUIManager`'s per-frame synchronous-event dedupe can drop a second inset change for the same view within one frame; in practice insets don't change twice per frame.
- `getGlobalVisibleRect` mixes coordinate spaces for partially clipped views, inherited from the library's implementation.
- Android rotation was not exercised: the RNTester activity kept its orientation on my emulator. The same pre-draw listener drives it.
- The loop warning only covers `View`. The prop is on `BaseViewProps`, so `Text`, `Image` and `ScrollView` accept it too; `View` is where it is used in practice.
- The loop warning's heuristic (more than ten events in a second) also fires for a view dragged slowly across a system-bar band, which is a legitimate stream. I have not seen it in practice; a threshold on *alternating* values would distinguish the two if it turns out to matter.

 ---

**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. The display-phase event beat this builds on landed as #58530.

This is the bottom of the stack, so its diff is already just this change.

👉 1. #58109 — Add an `experimental_onSafeAreaInsetsChange` view prop
    2. #58110 — Report the window safe area insets through Dimensions
    3. #58112 — Render the internal SafeAreaView from the safe area insets prop
    4. #58113 — Remove the native SafeAreaView and the deprecated public export

Reviewed By: javache

Differential Revision: D121015233

Pulled By: Abbondanzo

fbshipit-source-id: 8480c49bec09ef4a404bd30623c075d077547849
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. Merged This PR has been merged. Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant