Add an experimental_onSafeAreaInsetsChange view prop - #58109
Draft
janicduplessis wants to merge 2 commits into
Draft
Add an experimental_onSafeAreaInsetsChange view prop#58109janicduplessis wants to merge 2 commits into
janicduplessis wants to merge 2 commits into
Conversation
`EventEmitter::experimental_flushSync` only *requests* a beat, which is processed at the next `EventBeat::induce`. On Android the induce happens within the frame, before drawing, so a synchronous request made during layout is processed in that frame. On iOS it is not: 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 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 has its `display` called after the whole layout pass and before the transaction is committed. A layer is kept in every visible window of every foreground scene, since the request can come from any of them — a modal and the LogBox are windows of their own — and only a layer in the tree being committed is guaranteed a display this cycle. Requests within one cycle coalesce into a single induce, so mounting ten observing views is one beat rather than ten. Two related fixes 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), and `induce` becomes public so platform beats can call it from a callback. `AppleEventBeat.cpp` becomes `.mm` for the Objective-C. Covered by new unit tests in `EventBeatTest.cpp`. This is the platform half of the safe area insets work: it is what makes an inset change reported from `layoutSubviews` render in the frame it happened in. `VirtualView` uses the same mechanism.
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.
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:
Reports the part of a view that is covered by the system UI, as a view prop:
SafeAreaViewis deprecated in favour ofreact-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 ownRNCSafeAreaProvidercomponent. This adds that primitive as a view prop instead, with the payload the library already uses, soSafeAreaProvidercan swap its native component for a plainViewwith 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
boolinBaseViewProps, likeonLayout; 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 inlayoutSubviews,didMoveToWindowandsafeAreaInsetsDidChange— 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
frameis "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
framebelongs in the payload at all (the library needs it forSafeAreaFrameContext, but it is derivable withmeasureInWindow), 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.
Changelog:
[GENERAL] [ADDED] - Add an
experimental_onSafeAreaInsetsChangeview prop, reporting the part of a view that is covered by the system UITest 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.
Edge cases exercised, each with a test or a device check:
ScrollViewemit nothing instead of garbage overlap values.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);getGlobalVisibleRectmixes 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-typesand 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
mainand its diff contains the ones below it until they merge.This PR's own change, without the ones below it: 30 files.
👉 2. #58109 — Add an
experimental_onSafeAreaInsetsChangeview prop3. #58110 — Report the window safe area insets through Dimensions
4. #58111 — Warn in development when a view reports its safe area insets in a loop
5. #58112 — Render the internal SafeAreaView from the safe area insets prop
6. #58113 — Remove the native SafeAreaView and the deprecated public export