Add an onSafeAreaInsetsChange view prop - #57967
Draft
janicduplessis wants to merge 16 commits into
Draft
Conversation
|
Warning JavaScript API change detected This PR commits an update to
This change was flagged as: |
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
force-pushed
the
safe-area-insets-view-prop
branch
from
August 14, 2026 22:02
979132a to
66de1bd
Compare
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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary:
Prototype, opened for discussion rather than for landing as-is.
SafeAreaViewis deprecated in favour ofreact-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'sRNCSafeAreaProvidercomponent. This adds that primitive as a view prop instead:The payload is deliberately identical to the library's
onInsetsChange, soSafeAreaProvidercan swap its native component for a plainViewwith 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.safeAreaInsetson iOS; root windowsystemBars() | displayCutout()insets clipped to the view's rect on Android) so the semantics match.Window insets in
Dimensions.Dimensions.get('window').safeAreaInsets(anduseWindowDimensions) 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 existingchangeevent. This is what letsreact-native-safe-area-contextdrop its last native module (initialWindowMetrics); the library-side prototype consuming all of this is appandflow/react-native-safe-area-context#752.The native
SafeAreaViewimplementations are deleted entirely — this is the payoff of having the primitive in core. The deprecated publicSafeAreaViewcomponent is now backed by the JS implementation built on the prop, which renders identically (verified with the RNTesterSafeAreaViewexample) and works on every platform instead of iOS only. Gone: the C++ shadow node/state/descriptor, the iOS component view, the Android view + view manager, the codegen spec, and their registrations — the removal commit is -797/+92 lines, andreact-native-safe-area-contextcan do the same on its side (appandflow/react-native-safe-area-context#752). Behaviour changes that fall out:InputAccessoryViewnow apply safe area padding on Android too — they previously fell back to a plainView, since the nativeSafeAreaViewwas iOS-only. Relative insets mean this can't double-pad a surface that is already inside the safe area.Synchronous dispatch. The event goes out through
EventEmitter::experimental_flushSyncas aDiscreteevent, the same mechanismVirtualViewuses. 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
boolinBaseViewProps(likeonLayout), and native only observes the safe area when it is set — aUIViewthat doesn't set it never computes insets, and an Android view never gets a pre-draw listener. iOS pays for one ivar check inlayoutSubviews/didMoveToWindow, which are now overridden onRCTViewComponentView; that's the only unconditional cost I could not avoid, and it's worth a look from someone who profiles this path.Scroll views and cost when used. Benchmarked with 50 observing rows inside a
ScrollView(the "Scroll benchmark" section of the RNTester example), since a view sliding around the screen is the worst case for a per-view inset subscription:framein the payload is "the frame as of the last inset change". A consumer that wants continuously fresh frames (e.g. the library'sSafeAreaFrameContextduring scrolling) doesn't get them from this event — arguably correct for a safe-area primitive, but worth a decision.didUpdateDimensionsre-renders everyuseWindowDimensionsconsumer) blocks the UI thread for the full batch — measured up to ~24 ms in debug during rotation in RNTester. Inherent toexperimental_flushSyncsemantics rather than to this event specifically, but the sync path makes it easier to hit.Other properties verified:
safeAreaInsetsexclude the keyboard for a regular full-screen view; Android excludes theime()inset type) — consistent, and matchingreact-native-safe-area-context. The example includes a text input to check this.AppleEventBeat). Measured: mounting 10 observing views is one ~2.7 ms beat. An earlier per-callsite flush approach measured perfectly linear cost (10 flushes × flat ~0.9 ms — the fixed overhead of runtime handoff/commit/mount dominates), which is what motivated moving the fix into the platform beat.EventBeatsemantics the display-phase induce relies on are covered by new unit tests (EventBeatTest.cpp): a synchronous request is processed at theinducethat follows it, and an induce issued from within the beat callback defers instead of re-entering.Open questions I'd like input on:
onSafeAreaInsetsChangeright, and should it ship prefixed (experimental_/unstable_) first?framebe in the payload at all? The library needs it forSafeAreaFrameContext, but it's derivable withmeasureInWindow.Changelog:
[GENERAL] [ADDED] - Add an
onSafeAreaInsetsChangeview prop andDimensions.get('window').safeAreaInsets, reporting the part of a view / the window covered by the system UITest 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').safeAreaInsetsreports 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 fix beyond calling
experimental_flushSync: it only requests a synchronous beat, and the queue is processed at the nextEventBeat::induce. On Android the platform induces within the frame before drawing, so this is already same-frame. On iOS the beat's run-loop observer (order 0) runs before Core Animation's commit observer (order 2M) — but the inset events are emitted fromlayoutSubviews, i.e. from inside CA's commit cycle, after the beat already ran for that turn, landing them one frame late. The fix is inAppleEventBeat: a synchronous request made on the main thread additionally schedules an induce in the display phase of the current commit cycle (CA runs layout → display → commit, so a zero-sized layer marked dirty during layout has itsdisplaycalled after the whole layout pass but before the commit). Theexperimental_flushSyncAPI is unchanged, iOS becomes structurally the same as Android, and batching falls out: all requests made during one layout pass are processed in a single beat — mounting 10 observing views runs one ~2.7 ms beat instead of the 10 × ~0.9 ms sequential flushes a per-callsite approach would cost. Verified with the marker probe on the final implementation: zero unpadded frames on a bare mount, rotation still animating the padding within the transition.New Fantom tests — the event payload reaching JS, the prop reaching C++ props, the internal
SafeAreaViewturning insets into padding, and the physical-pixel scaling of theDimensionsinsets.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.