Skip to content

Add an onSafeAreaInsetsChange view prop - #57967

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

Add an onSafeAreaInsetsChange view prop#57967
janicduplessis wants to merge 10 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 deliberately identical to the library's onInsetsChange, so SafeAreaProvider can swap its native component for a plain View with no API change on its side. Insets are relative to the view: a view laid out inside the safe area reports zeros, which is what makes it composable and what stops nested providers from double-padding. The inset math follows the library's (UIView.safeAreaInsets on iOS; root window systemBars() | displayCutout() insets clipped to the view's rect on Android) so the semantics match.

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

Every use of the deprecated SafeAreaView inside core is replaced with a JS SafeAreaView built on the prop. Two behaviour changes fall out of that:

  • LogBox, the element inspector and InputAccessoryView now apply safe area padding on Android too — they previously fell back to a plain View, since the native SafeAreaView was iOS-only. Relative insets mean this can't double-pad a surface that is already inside the safe area.
  • LogBox surfaces re-render when the insets arrive, instead of being padded natively without JS involvement.

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 the layout that depends on the insets is mounted in the frame the insets changed in. That is the part the library cannot do today: rotating the device currently shows one frame with the old padding.

Cost when unused. The prop is a bool in BaseViewProps (like onLayout), and native only observes the safe area when it is set — a UIView that doesn't set it never computes insets, and an Android view never gets a pre-draw listener. iOS pays for one ivar check in layoutSubviews/didMoveToWindow, which are now overridden on RCTViewComponentView; that's the only unconditional cost I could not avoid, and it's worth a look from someone who profiles this path.

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 laid out inside the safe area reports zero insets and its real frame in window coordinates (iOS left, Android right):

A full screen view padding itself by its own insets — the unpadded (pink) area lines up exactly with the status bar / home indicator / gesture bar on both platforms, and on iOS the padding follows rotation:

The LogBox notification container, one of the converted call sites, still clears the home indicator:

Dimensions.get('window').safeAreaInsets reports the same values as the prop on both platforms:

Synchronous rendering. In the "Applying the insets as padding" example, the modal opens without the prop attached, and pressing "Apply insets" sets it. The example renders a loud marker for the in-between state — yellow background when the view observes the safe area but no inset event has been received yet — so the dispatch timing is directly visible: if a frame ever displays yellow, the event was not synchronous.

With synchronous dispatch, the marker state is committed but never presented — the event fires while the tree is being mounted and the padded tree replaces it before the frame is displayed. Consecutive captured frames, and no yellow frame exists anywhere in the capture:

On rotation, the same-transaction property shows up as animation: the inset-driven layout is committed inside the rotation transition's animation context, so the padding animates with the rotation instead of jumping after it. The full capture (apply → landscape → portrait), decomposed with ffmpeg and checked frame by frame — zero yellow frames, no frame with stale insets:

sync-marker-v.mp4

The same sequence with sync dispatch disabled (plain async dispatchEvent, same build otherwise): the marker state is presented for one frame on apply —

— and during the rotations the incoming layout renders with the previous orientation's insets, with content under the notch, correcting itself over the following frames (scrub the rotations):

async-marker-v.mp4

Getting to same-frame required one piece beyond experimental_flushSync: it only requests a synchronous beat, and the queue is still processed at the next EventBeat::induce, one frame boundary later — which lands one frame late for a view padding itself. The opt-in immediate mode added here processes the queue at the call site: the emit happens during the layout pass, the resulting React commit mounts inline through the mounting manager's existing follow-up-transaction loop, and the padding is part of the same presented frame. This also covers a freshly mounted full-screen view (verified with a probe mounted with no animation and null initial insets: zero unpadded frames). Existing experimental_flushSync callers (VirtualView, Android's dispatchEventSynchronously) are unchanged.

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

New Fantom tests — the event payload reaching JS, the prop reaching C++ props, the internal SafeAreaView turning insets into padding, and the physical-pixel scaling of the Dimensions insets.

Not exercised: rotation on Android (the RNTester activity kept its orientation on my emulator) — the same pre-draw listener drives it, but I have not seen it happen.

@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

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