Skip to content

Add an onSafeAreaInsetsChange view prop - #57967

Draft
janicduplessis wants to merge 17 commits into
react:mainfrom
janicduplessis:safe-area-insets-view-prop
Draft

Add an onSafeAreaInsetsChange view prop#57967
janicduplessis wants to merge 17 commits into
react:mainfrom
janicduplessis:safe-area-insets-view-prop

Conversation

@janicduplessis

@janicduplessis janicduplessis commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Summary:

Prototype, opened for discussion rather than for landing as-is.

SafeAreaView is deprecated in favour of react-native-safe-area-context (per 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 JS — today the library's RNCSafeAreaProvider component. This adds that primitive as a view prop instead:

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

The payload is identical to the library's onInsetsChange, so SafeAreaProvider can swap its native component for a plain View with no API change on its side, and the inset math follows the library's so the semantics match. Insets are relative to the view: a view laid out inside the safe area reports zeros, which is what makes it composable and stops nested providers from double-padding.

Window insets in Dimensions. Dimensions.get('window').safeAreaInsets (and useWindowDimensions) reports the safe area insets of the window using the same native computation — available synchronously at startup and updated through the existing change event. This replaces the library's last native module (initialWindowMetrics); the library-side prototype consuming all of this is appandflow/react-native-safe-area-context#752.

The native SafeAreaView implementations are deleted entirely — the payoff of having the primitive in core. The deprecated public component is now backed by a JS implementation built on the prop, renders identically (verified with the RNTester SafeAreaView example), and works on every platform instead of iOS only. The removal commit is -797/+92 lines: C++ shadow node/state/descriptor, iOS component view, Android view + view manager, codegen spec, and registrations. Two behaviour changes fall out: LogBox, the element inspector and InputAccessoryView now apply safe area padding on Android too (they previously fell back to a plain View), and these surfaces re-render when insets arrive instead of being padded natively.

Synchronous dispatch. The event goes out through EventEmitter::experimental_flushSync as a Discrete event, the same mechanism VirtualView uses: the UI and JS threads block until React has re-rendered, 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. One platform fix was needed: experimental_flushSync only requests a beat, processed at the next EventBeat::induce. Android induces within the frame before drawing, so it is already same-frame; on iOS the beat's run-loop observer runs before Core Animation's commit observer, while the inset events are emitted from layoutSubviewsinside CA's commit cycle — landing them one frame late. AppleEventBeat now additionally schedules an induce in the display phase of the current commit cycle (CA runs layout → display → commit; a zero-sized layer marked dirty during layout gets display after the whole layout pass, before the commit). The experimental_flushSync API is unchanged, iOS becomes structurally the same as Android, and batching falls out: all requests in one layout pass are processed in a single beat — mounting 10 observing views is one ~2.7 ms beat, where a per-callsite flush measured a perfectly linear 10 × ~0.9 ms. The beat semantics are covered by new unit tests (EventBeatTest.cpp).

Cost when unused. The prop is a bool in BaseViewProps (like onLayout); native only observes the safe area when it is set. The only unconditional cost is one ivar check in layoutSubviews/didMoveToWindow, now overridden on RCTViewComponentView — worth a look from someone who profiles that path.

Cost when used. Benchmarked with 50 observing rows inside a ScrollView (the "Scroll benchmark" section of the example), since a view sliding around the screen is the worst case:

  • Events fire only when the insets change — the frame is in the payload but not in the trigger, so in-safe-area scrolling emits nothing (verified by event counters on both platforms), and scroll frame times with 50 observers match 0 observers. An earlier frame-triggered iteration emitted per view per frame and sustained a feedback storm (~5,000 events/s on an idle screen, since each synchronous render produces a new frame that re-runs the pre-draw listener); the inset-only trigger makes that loop structurally impossible. Consequence: frame in the payload is "as of the last inset change" — a consumer wanting continuously fresh frames (the library's SafeAreaFrameContext during scrolling) doesn't get them.
  • One full synchronous inset event (dispatch → JS render → commit → mount, timed in native, 6 runs): 2.1–3.1 ms iOS, 2.9–3.3 ms Android, debug builds re-rendering a small component; release Hermes should be well below that, and the cost scales with what the app re-renders. Paid per actual inset change, not per frame. Caveat: the synchronous flush drains the whole pending queue, so an inset change coinciding with other queued work (rotation re-rendering every useWindowDimensions consumer) blocks for the full batch — up to ~24 ms in debug during rotation in RNTester.
  • The keyboard does not change the reported insets on either platform (iOS safeAreaInsets exclude it for full-screen views; Android excludes ime()) — consistent, matching the library.

Open questions I'd like input on:

  • Naming — is onSafeAreaInsetsChange right, and should it ship prefixed (experimental_/unstable_) first?
  • Should frame be in the payload at all? The library needs it for SafeAreaFrameContext, but it's derivable with measureInWindow.
  • Whether blocking the UI thread on every inset change is acceptable, or whether this should be opt-in per view.
  • Legacy architecture is not covered; the prop is Fabric-only.

Changelog:

[GENERAL] [ADDED] - Add an onSafeAreaInsetsChange view prop and Dimensions.get('window').safeAreaInsets, reporting the part of a view / the window covered by the system UI

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 reports zero insets and its frame (iOS left, Android right):

A full screen view padding itself by its own insets — the unpadded (pink) area lines up exactly with the system UI on both platforms, including in landscape:

LogBox (a converted call site) still clears the home indicator, and Dimensions.get('window').safeAreaInsets reports the same values as the prop on both platforms:

Synchronous rendering. The modal in the example opens without the prop; pressing "Apply insets" attaches it. The example renders a loud marker for the in-between state — yellow background while the view observes the safe area but no inset event has been received — so the dispatch timing is directly visible: any displayed yellow frame means the event was not synchronous.

With synchronous dispatch the marker state is committed but never presented. Consecutive captured frames, no yellow frame anywhere in the capture:

Full capture (apply → landscape → portrait), decomposed with ffmpeg and checked frame by frame — zero yellow frames, no stale insets, padding animating with the rotation:

sync-final3.mp4

The same sequence with sync dispatch disabled (plain async dispatchEvent): the marker is presented for one frame on apply, and during rotations the incoming layout renders with the previous orientation's insets, correcting itself over the following frames (scrub the rotations):

async-marker-v.mp4
yarn fantom packages/react-native/Libraries/Components/View/__tests__/ViewSafeAreaInsets-itest.js
yarn fantom packages/react-native/Libraries/Utilities/__tests__/Dimensions-itest.js

Known gaps, noted but not addressed here:

  • FabricUIManager's per-frame synchronous-event dedupe can drop a second inset change for the same view landing within one frame; in practice insets don't change twice per frame.
  • getGlobalVisibleRect mixes coordinate spaces for partially clipped views (inherited from safe-area-context's implementation).
  • Android rotation not exercised (the RNTester activity kept its orientation on my emulator) — the same pre-draw listener drives it.

@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 14, 2026
@facebook-github-tools facebook-github-tools Bot added the Contributor A React Native contributor. label Aug 14, 2026
@github-actions

Copy link
Copy Markdown

Warning

JavaScript API change detected

This PR commits an update to ReactNativeApi.d.ts, indicating a change to React Native's public JavaScript API.

  • Please include a clear changelog message.
  • This change will be subject to additional review.

This change was flagged as: POTENTIALLY_BREAKING

Reports the part of a view that is covered by the system UI, dispatched
synchronously so that layout depending on the insets lands in the frame
the insets changed in.

Replaces every use of the deprecated SafeAreaView inside core (LogBox,
the element inspector, InputAccessoryView) with a JS implementation
built on the prop.
Triggers now mark the view as needing layout instead of emitting inline,
so the synchronous React render never re-enters from inside the mounting
transaction (updateProps / didMoveToWindow). The layout pass runs before
the frame is displayed, so the same-frame guarantee is unchanged.
Dimensions.get('window').safeAreaInsets exposes the part of the window
covered by the system UI, available synchronously at startup and updated
through the existing change event. Uses the same native inset
computation as the onSafeAreaInsetsChange view prop.
@janicduplessis
janicduplessis force-pushed the safe-area-insets-view-prop branch from 979132a to 66de1bd Compare August 14, 2026 22:02
A freshly mounted view cannot receive its first inset event before its
first frame is presented, even with synchronous dispatch — the event
requires the view to be mounted and laid out. Seeding the padding from
Dimensions makes the first frame correct; the event keeps it correct,
relative to the view, from then on.
… frame

experimental_flushSync only requests a synchronous beat; the queue is
still processed at the next induce, one frame boundary later. For a
freshly mounted view that lands the inset padding one frame after the
view is first presented.

An opt-in immediate mode processes the queue at the call site instead:
the emit happens during the layout pass of the frame, the resulting
commit mounts inline through the mounting manager's follow-up
transaction loop, and the padding is part of the first presented frame.
Verified with a full-bleed view mounted with no animation and null
initial insets: zero unpadded frames.

Existing experimental_flushSync callers are unchanged.
Demonstrates the synchronous layout directly: the modal opens without
the prop, and applying it pads the content in the same frame.
The state between attaching onSafeAreaInsetsChange and receiving the
first event renders with a yellow background: with synchronous dispatch
it is committed but never presented, so any displayed yellow frame means
the dispatch was not synchronous.
@mrousavy

Copy link
Copy Markdown
Contributor

This is amazing!! Been missing this for years

The frame is part of the event payload but no longer part of the
trigger: a view that moves (scrolling, layout) without its overlap with
the system UI changing stays silent. Benchmarked with 50 observing rows
inside a scroll view; the previous frame-based trigger emitted a
synchronous event per view per frame while scrolling, and sustained a
feedback storm afterwards (the synchronous render produces a new frame,
which runs the pre-draw listener again) — ~5,000 events and ~55 rendered
frames per second on an idle screen. With the inset-only trigger the
same scene emits one event per row as it becomes visible and nothing
afterwards, and scroll frame times match a scene with no observers.

Also treat views fully clipped by an ancestor as having no insets on
Android: getGlobalVisibleRect leaves the rect undefined for them, which
fed garbage into the inset math and oscillated the computed values.

Adds a scroll benchmark section to the RNTester example.
Covers the immediate mode used by the safe area inset event: a
synchronous request processed by calling induce at the call site, and
the guarantee that an induce issued from within the beat callback does
not re-enter it.
The keyboard does not change the reported insets on either platform:
iOS safeAreaInsets do not include the keyboard for a regular full
screen view, and Android excludes the ime() inset type.
The deprecated SafeAreaView component is now backed by the JS
implementation built on onSafeAreaInsetsChange, which behaves
identically (verified with the RNTester SafeAreaView example) and works
on every platform instead of iOS only.

Deletes the C++ shadow node, state and component descriptor, the iOS
component view, the Android view and view manager, the codegen spec,
and their registrations.
…uests

Replaces the opt-in immediate mode on experimental_flushSync with a fix
at the platform level, restoring the plain API. The run loop observer
that ordinarily induces the beat runs before Core Animation commits the
frame, so a synchronous request made while Core Animation is already
laying out (an event emitted from layoutSubviews) was only processed on
the next frame. AppleEventBeat now also schedules an induce in the
display phase of the current commit cycle — Core Animation runs display
after the whole layout pass but before committing — via a zero-sized
layer attached to the key window.

This makes iOS structurally match Android, where the beat already runs
within the frame before drawing, and batches for free: all synchronous
requests made during one layout pass are processed in a single beat.
Mounting 10 observing views previously ran 10 separate flushes of ~0.9ms
each with the immediate mode; it now runs one ~2.7ms beat. The marker
probe still shows zero unpadded frames on a bare mount, and rotation
still updates the padding within the transition.
- Views with onSafeAreaInsetsChange now form a stacking context so view
  flattening cannot optimize away the host view the observer needs
  (with a Fantom test covering it)
- Remove the stale React-FabricSafeAreaView target from Package.swift,
  missed by the SafeAreaView removal
- iOS: fix observation state on recycled views (compare against current
  state, not stale oldViewProps); guard the display-phase flusher layer
  against use after the EventBeat owner is gone and tear it down on the
  main queue
- EventBeat: a synchronous request is no longer stranded behind an
  already-scheduled asynchronous beat (with a test)
- Rewrite the nested-request EventBeat test to model how platform
  implementations actually defer the induce
- Android: only attach the observer under Fabric; record lastInsets only
  once the event is actually dispatched; detach the observer when a view
  is recycled
- Docs: the frame in the payload is a snapshot at the time of the event,
  not a trigger
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.

2 participants