Skip to content

Add an experimental_onSafeAreaInsetsChange view prop - #58109

Open
janicduplessis wants to merge 2 commits into
react:mainfrom
janicduplessis:safe-area/2-safe-area-insets-prop
Open

janicduplessis wants to merge 2 commits into
react:mainfrom
janicduplessis:safe-area/2-safe-area-insets-prop

Conversation

@janicduplessis

@janicduplessis janicduplessis commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Summary:

Reports the part of a view that is covered by the system UI, as a view prop:

<View
  experimental_onSafeAreaInsetsChange={({nativeEvent: {insets, frame}}) => {
    // insets: {top, right, bottom, left}, frame: {x, y, width, height}
  }}
/>

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 would let both sides go away is native code reporting inset values to JavaScript — today the library's own RNCSafeAreaProvider component. This adds that primitive as a view prop instead, with the payload the library already uses, so SafeAreaProvider can swap its native component for a plain View with no API change on its side.

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 props the view already holds and the last-sent insets live behind a single pointer ivar that stays nil unless the view observes, so the only unconditional cost is a branch in layoutSubviews, didMoveToWindow and safeAreaInsetsDidChange — worth a look from someone who profiles that path.

Cost when used. Events fire only when the insets change; the frame is in the payload but not in the trigger. A view moving inside a scroll view therefore emits nothing, and scroll frame times with 50 observing rows match 0 observers (the "Scroll benchmark" section of the new example, with event counters on both platforms). An earlier frame-triggered iteration sustained ~5,000 events/s on an idle screen — each synchronous render produces a new frame, which re-runs the pre-draw listener — and the inset-only trigger makes that loop structurally impossible. The consequence is that frame is "as of the last inset change": a consumer wanting continuously fresh frames doesn't get them.

One full synchronous inset event (dispatch → JS render → commit → mount, timed in native, 6 runs) is 2.1–3.1 ms on iOS and 2.9–3.3 ms on Android in debug builds re-rendering a small component, paid per inset change rather than per frame.

Open questions I'd like input on: whether frame belongs in the payload at all (the library needs it for SafeAreaFrameContext, but it is derivable with measureInWindow), and whether blocking the UI thread on every inset change is acceptable or should be opt-in per view.

Stacked on #58108, which makes the synchronous dispatch land in the same frame on iOS. Without it this still works, the event just arrives a frame late there. This PR's diff includes that one until it merges — review the top commit.

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 one implementation covers iOS and Android and the warning surfaces in LogBox with a JavaScript stack instead of in logcat. That placement has one gap worth naming: the prop is on BaseViewProps, so Text, Image and ScrollView accept it too and are not checked. View is where it is used in practice.

The production branch is the identity function, so the module stays out of the bundle. The native prop is unaffected either way — function props are normalized to true before props are diffed (ReactNativeAttributePayload.js), so a fresh wrapper per render does not produce an update. Counts are kept per view in a WeakMap keyed by the event target, so views that do not loop pay nothing.

Also folded in from review: the prop is forwarded through BaseViewManagerDelegate for components with generated delegates (Switch, DrawerLayout, …), and 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

Test Plan:

RNTester, new "Safe area insets" example, on an iPhone 17 Pro simulator and an Android 16 emulator. Screenshots of the readout, of a full screen view padding itself by its own insets in portrait and landscape on both platforms, and the synchronous-dispatch frame captures are all in #57967, the prototype this stack splits.

yarn fantom packages/react-native/Libraries/Components/View/__tests__/ViewSafeAreaInsets-itest.js          # 4 passed
yarn fantom packages/react-native/Libraries/Components/View/__tests__/ViewSafeAreaInsetsWarning-itest.js   # 4 passed

The warning tests cover silence at a plausible rate (20 changes 200 ms apart), one warning per view under a loop, per-view counting, and that the handler still receives its event — all with a mocked clock. RNTester grows the mistake it warns about (a view positioned by the insets it reports) behind a button.

Edge cases exercised, each with a test or a device check:

  • View flattening — the prop marks the view as forming a stacking context so the host view the observer needs cannot be optimized away (Fantom test).
  • View recycling — observation state is re-derived from the new props on both platforms rather than diffed against a recycled view's stale ones.
  • Clipped Android views — rows scrolled out of a ScrollView emit nothing instead of garbage overlap values.
  • Multi-scene iPad — two windows, two React instances, one shared key window: correct per-window insets across tiling, fullscreen, rotation and keyboard.

Known gaps, noted but 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; and Android rotation was not exercised because the RNTester activity kept its orientation on my emulator, though the same pre-draw listener drives it.

Also ran yarn flow-check, yarn build-types and the C++ API snapshot regeneration on this branch.


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 PR's own change, without the ones below it: 34 files.

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

@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 Aug 24, 2026
@facebook-github-tools facebook-github-tools Bot added the Contributor A React Native contributor. label Aug 24, 2026
@janicduplessis
janicduplessis force-pushed the safe-area/2-safe-area-insets-prop branch from ade05bd to 1973d38 Compare September 16, 2026 13:50
@janicduplessis
janicduplessis marked this pull request as ready for review September 16, 2026 13:51
@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 16, 2026
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.
Reports the part of a view that is covered by the system UI, as a view prop:

```jsx
<View
  experimental_onSafeAreaInsetsChange={({nativeEvent: {insets, frame}}) => {
    // 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.
@janicduplessis
janicduplessis force-pushed the safe-area/2-safe-area-insets-prop branch from 1973d38 to 74f286c Compare September 16, 2026 16:09
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. 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