Repository navigation
Add an experimental_onSafeAreaInsetsChange view prop - #58109
janicduplessis wants to merge 1 commit into
Conversation
ade05bd to
1973d38
Compare
014eb37 to
016438c
Compare
) Summary: `EventEmitter::experimental_flushSync` only *requests* an event beat; the beat is 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. Anything that reports layout-driven state to JS synchronously (`VirtualView` mode changes, and safe area insets in the PRs that build on this) renders a frame late in exactly the cases that matter. `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 gets its `display` call after the whole layout pass and before the transaction is committed. That layer needs to live in the tree being committed, so the beat has to know which tree that is — and the emitter tells it: - `experimental_flushSync` carries the **tag of the emitting view** through `EventDispatcher` and `EventQueue` to `EventBeat::requestSynchronous(Tag)`, with `kNoTag` (#58531) meaning no view attribution; a no-argument overload keeps unattributed requesters and the existing tests unchanged. The emitter reads the tag from its `ShadowNodeFamily` at flush time; `kNoTag` if the family is already gone. - `AppleEventBeat` resolves the tag to the layer of the view's **window** through a resolver injected by `RCTSurfacePresenter` (`findComponentViewWithTag:` on the mounting registry — nullable, non-creating, main thread) and attaches its flusher layer there. 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 within one cycle coalesce into a single induce. One 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. **Risk:** this changes when queued events are flushed on iOS for every `experimental_flushSync` caller — today `VirtualView`, and safe area insets with the PRs on top. The worst case is a beat processed a frame *earlier* than before, inside a Core Animation commit; the run loop observer path is untouched and still catches anything the display phase misses (an emitter with no tag, an unmounted view, a request off the main thread). Android ignores the tag. Revert is self-contained. ## Design Q&A: **What happens when two views in different windows update at once?** Each requesting window gets its own dirty flusher layer (the map is keyed by host layer), and the first `display` to fire induces the beat, which drains the whole event queue — every window's updates mount before that commit presents. The remaining flushers hit the `isEventBeatRequested_` guard and no-op, so it is one beat total, not one per window. If windows ever commit in separate transactions, each request still resolves within its own window's cycle, since its layer sits in the tree that emitted it. Only requesting windows carry a dirty layer. **Can the tag point at the wrong view — after an unmount, or a recycled view?** No. The tag comes from the emitter's `ShadowNodeFamily`, and a family keeps one tag for its whole life, across clones and state updates; if the family is already gone the flush carries `kNoTag` and skips the resolver. What changes over a view's life — its window — is read live: the tag resolves to a view at flush time and `view.window.layer` is looked up then, so a view that moved between windows targets its current tree. A view mid-unmount or recycled resolves to nil (the registry erases the entry and recycled views get tag `0`) and degrades to run-loop-observer timing. The tag only ever influences *where the induce is scheduled*, never what is delivered or to whom, so the blast radius of any staleness is one frame of timing, not correctness. **Does `VirtualView` need changes to benefit?** No — its sync mode-change flush goes through its own emitter, so the tag attribution is automatic. The case this improves is a mode change emitted during Core Animation layout (a resize pulling a virtualized item into view): on `main` that renders one frame late; here the induce lands in the display phase of the VirtualView's own window, including inside a full screen modal. ## Changelog: [INTERNAL] - Process synchronous event beats in the frame that requested them on iOS, scheduling the induce on the requesting view's window Pull Request resolved: #58530 Test Plan: New unit tests in `EventBeatTest.cpp` cover the beat semantics: a synchronous request during an already-scheduled asynchronous beat, coalescing, and induce ordering. They drive the protected `induce` through a subclass standing in for the platform. On device, with the safe area insets prop from the PRs above merged on top: an RNTester example renders a loud marker (yellow background) while a view observes the safe area but has not received an inset event yet, so any presented marker frame means the dispatch was not synchronous. The full apply → landscape → portrait sequence **inside a full screen modal** on an iPhone 17 Pro simulator, decomposed with ffmpeg into 982 frames and every frame scanned for the marker color — **zero marker frames**, and mid-rotation frames already carry the incoming orientation's insets, so the padding animates with the rotation. Scoped honestly: the first inset event after setting the prop is processed at the call site, so the marker primarily proves no regression; the same-frame path for layout-driven changes rests on the by-construction argument above plus the rotation frames. https://github.com/user-attachments/assets/0f2db837-c9c0-4457-96c2-847b7aecf10e `yarn fantom .../ViewSafeAreaInsets-itest.js` passes 4/4 with the prop merged on top. C++ API snapshots regenerated (`scripts/cxx-api/parser`, Doxygen 1.16.1): the deltas are the `requestSynchronous` overload pair, `EventEmitter::getTag`, the resolver type, and the `AppleEventBeat` constructor and destructor. --- **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 is the bottom of the stack, so its diff is already just this change. 👉 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 An earlier variant that targeted the surface's root view instead of the view's window was closed in #58528; its review thread carries the analysis behind the window-based resolution. #58108 was the per-window predecessor this supersedes. Reviewed By: javache Differential Revision: D120200496 Pulled By: Abbondanzo fbshipit-source-id: b06ecb30935837abd6561f54674afef0cdf215a8
016438c to
62b86aa
Compare
|
@Abbondanzo has imported this pull request. If you are a Meta employee, you can view this in D121015233. |
43adb2a to
f5d8d36
Compare
|
Re-ran the device checks from the test plan on this commit (f5d8d36, since rebased onto current main as 60dae49 — sentinel ivar, Android first-emit deferred to pre-draw, insets-only payload). iPhone 17 Pro simulator and an Android 16 emulator. The Android column is the one to look at: its first emit now goes through the pre-draw listener instead of the prop setter, and rotation was the one case the prototype never exercised. A view inside the safe area reads zero insets
Full screen view padded by its own insets — the pink (unpadded) area lines up with the system UIPortrait, then landscape; in landscape the top inset follows the notch / status bar to the side, and the portrait values read back correctly after rotating back.
The iOS landscape capture is from a build with #58632 applied: re-taking it showed the readout text clipped at its previous width after rotation — a stale paragraph drawing frame when the mount runs in the display phase, which that standalone PR fixes. Synchronous dispatch, frame by frameApply insets → rotate → rotate back. The example shows a yellow marker while a view observes the safe area but has not received an inset event yet, so any presented marker frame means the dispatch was not synchronous. Every frame of each recording was scanned for the marker color. iOS — 828 frames, rotations at frames 194 and 523, 0 marker frames marker.mp4Android — 194 frames, rotations at frames 107 and 183, 0 marker frames a-marker.mp4Loop warningThe example's deliberately mispositioned view (offset by the insets it reports) loops; LogBox shows one warning and it stays at one.
Two things worth knowing. The loop runs at a few thousand events/s on iOS but a few hundred on Android — Android's is bounded by the pre-draw cadence (one emit per frame), iOS's re-layout cycles faster; both are well past the warning threshold. And the Android recording is a 216×480 downscale from the tool, coarser than the iOS one, but the modal's pink background is clearly detectable at that size (7,400 px in a mid frame), so the yellow marker — a larger, more saturated block — would have been too; the zero is real. |
ef1eb75 to
60dae49
Compare
Reports the part of a view that is covered by the system UI, as a view prop:
```jsx
<View
experimental_onSafeAreaInsetsChange={({nativeEvent: {insets}}) => {
// insets: {top, right, bottom, left}
}}
/>
```
`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
are a plain `UIEdgeInsets` ivar (a negative sentinel marks "none sent yet",
since insets are never negative); the only unconditional cost is a branch in
`layoutSubviews`,
`didMoveToWindow` and `safeAreaInsetsDidChange`.
**Cost when used.** Events fire only when the *insets* change, 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.
The payload is the insets alone. A frame is deliberately not included: it
would only be current as of the last inset change, since the trigger is
inset-only, and reporting it needs a coordinate space that differs between
platforms (the enclosing view controller on iOS, the window on Android). A
view that needs its own frame has `onLayout` and `measureInWindow`, which
stay current; the window's frame is in `Dimensions`.
60dae49 to
07fcf31
Compare
…ews (#58632) Summary: `RCTParagraphComponentView` derives the text view's frame and drawing frame in `layoutSubviews`, requested with `setNeedsLayout` from `updateState` and `updateLayoutMetrics`. The layout pass itself dates from #46081, but until #57408 `drawRect:` read the content frame straight from `_layoutMetrics`, which is always current; #57408 moved drawing onto a `drawingFrame` computed in `layoutSubviews`, so drawing now assumes a layout pass will run before the view is displayed. It does not when the mount itself happens inside Core Animation's display phase — which is where `AppleEventBeat` processes a synchronous event requested during layout (#58530). This transaction's layout pass is already over, so the text view displays the new attributed string in the previous drawing frame and the text is cut off at the old width. The queued `layoutSubviews` updates the frame in the next transaction, but `drawingFrame` is a plain property that does not invalidate the display, so the clipped drawing stays until something else redraws the view. Nothing on `main` emits a synchronous event from layout yet, so this is latent today; #58109 is the first thing that does (`layoutSubviews` reporting safe area insets on rotation) and it hits this on every rotation. The frames are now computed in `finalizeUpdates:`, which the mounting manager calls [once per view after all of a mutation's `update*` calls](https://github.com/react/react-native/blob/f03f6c2b856e7d923f8af13d7ef452eb4bb21cd4/packages/react-native/React/Fabric/Mounting/RCTMountingManager.mm#L108-L134). That coalesces state and layout-metrics changes the same way the layout pass did — one computation per mount, as before — without depending on a layout pass that may already have happened, and it removes the extra layout pass altogether. The `layoutSubviews` override goes away; `updateState` and `updateLayoutMetrics` keep their `setNeedsDisplay`. ## Changelog: [IOS] [FIXED] - Text no longer renders clipped when mounted from a synchronous event during layout Pull Request resolved: #58632 Test Plan: Reproduced with #58109 on top of this branch, RNTester "Safe area insets" example, iPhone 17 Pro simulator: present the padded modal, apply insets, rotate. The readout text is re-rendered synchronously from the rotation's layout pass. | Before | After | |:---:|:---:| | <img src="https://github.com/user-attachments/assets/092b162b-1b28-4b02-b584-4c28d66a96b5" width="440" /> | <img src="https://github.com/user-attachments/assets/52ae2ce8-75c7-4b2c-9e90-006e68b2588e" width="440" /> | Before: `top: 0, right: 62, bottom: 19.99996` — the accessibility label carries the full `…9482421875, left: 62`, and the layout box is the right size; only the drawing is cut at the previous width. The paragraph under it overflows on one line for the same reason. After: both render at their new width. Rotating back to portrait renders correctly too, and ordinary text throughout RNTester (normal asynchronous mounts) is unchanged. --- Found while testing #58109, which hits this on every rotation; standalone, that stack does not include this change. Reviewed By: andrewdacenko, javache Differential Revision: D121209981 Pulled By: Abbondanzo fbshipit-source-id: 6fda49771b08d489623e2bb35c5b59ec46ac03da
Summary:
Reports the part of a view that is covered by the system UI, as a view prop:
```jsx
<View
experimental_onSafeAreaInsetsChange={({nativeEvent: {insets}}) => {
// insets: {top, right, bottom, left}
}}
/>
```
`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 lets both sides go away is native code reporting inset values to JavaScript — today the library's own [`RNCSafeAreaProvider`](https://github.com/AppAndFlow/react-native-safe-area-context/blob/main/src/specs/NativeSafeAreaProvider.ts) component. This adds that primitive as a view prop, 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.
The event is dispatched synchronously through `experimental_flushSync`, so the layout that depends on the insets is mounted in the frame the insets changed in (on iOS that relies on react#58530 for events emitted from `layoutSubviews`). A full inset event — dispatch, JS render, commit, mount — is about 3 ms in a debug build re-rendering a small component, paid per inset change rather than per frame.
**Two things I'd like input on:**
- Whether blocking the UI thread on every inset change is acceptable, or should be opt-in per view.
- The cost when unused: a `bool` in `BaseViewProps` like `onLayout`, and a branch on it in `layoutSubviews`, `didMoveToWindow` and `safeAreaInsetsDidChange` on every view. Worth a look from someone who profiles that path. On Android nothing is attached unless the prop is set.
In development, `View` wraps the handler and warns once per view above ten events in a second. The system UI does not move that often, so a sustained stream means the layout is feeding the insets back into the view's own position — offset by what it reports, it moves out from under the system UI, which changes what it reports. The check is one JavaScript implementation for both platforms and surfaces in LogBox with a stack rather than in logcat.
### Design decisions
**Events fire only when the insets change, not on the view moving.** An earlier iteration also fired on frame changes and sustained ~5,000 events/s on an idle screen: each synchronous render produces a new frame, which re-runs the pre-draw listener, which emits again. Triggering on insets alone makes that loop structurally impossible, so a view moving *within* the safe area is silent — 50 observing rows in the example's scroll benchmark emit nothing while scrolling (event counters on both platforms), and scroll frame times matched 0 rows on the prototype in react#57967. A view moving *through* a system-bar band is a different case: its insets change every frame it overlaps the band, each one a synchronous render. That is inherent to reporting insets and is the cost the open question above is about; it is not covered by the benchmark, whose rows sit in a bounded container.
**The payload is the insets alone; no frame.** With an inset-only trigger a frame would only be current as of the last inset change, and it needs a coordinate space that differs per platform. `onLayout` and `measureInWindow` give a view a frame that stays current.
**A sentinel, not a pointer, for "no event sent yet" on iOS.** The last-sent insets are a plain `UIEdgeInsets` ivar initialized to `{-1, -1, -1, -1}`; insets are never negative, so `top >= 0` means one was sent. An `NSValue *` that is nil until the first event was the alternative — 24 bytes smaller per view, but a heap allocation per inset change and boxing on every comparison.
**Observation is (re)started whenever the prop is set, not only on its transitions.** Recycled views keep their last props, so `oldViewProps` of a freshly reused view is not a reliable baseline for a transition diff; the sentinel is reset in `prepareForRecycle` for the same reason.
**Nothing emits from inside the prop setter.** Setting the prop runs inside the mounting transaction, where synchronously re-entering React is not safe. On iOS everything that might have changed the insets (`didMoveToWindow`, `safeAreaInsetsDidChange`, the prop being set) only marks the view as needing layout, and the emit happens in `layoutSubviews`; on Android the observer's first emit waits for the pre-draw listener rather than running from `setEnabled`. Both still land in the same frame, since layout and pre-draw run before the frame is displayed.
**The warning wraps the handler in `View`, not in either native observer.** In production the wrapper is the identity function, so the module stays out of the bundle. Wrapping does not touch the native prop: function props are normalized to `true` before props are diffed ([`ReactNativeAttributePayload.js`](https://github.com/react/react-native/blob/ab2ea649e6/packages/react-native/Libraries/ReactNative/ReactFabricPublicInstance/ReactNativeAttributePayload.js#L253-L267)), so a fresh wrapper per render produces no update. Counts live in a `WeakMap` keyed by the event target, so views that never loop pay nothing.
**Two Android wiring details:**
- The prop is forwarded through `BaseViewManagerDelegate`; components with generated delegates (Switch, DrawerLayout, …) route base props through it, not the reflection-based `ReactProp` path.
- `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
Pull Request resolved: react#58109
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 reads zero insets; a full screen view padding itself by its own insets lines up with the system UI in portrait and landscape on both platforms; the scroll benchmark counts events on both platforms. Screenshots and the synchronous-dispatch frame captures are in react#57967, the prototype this splits. The example also grows the mistake the warning catches — a view positioned by the insets it reports — behind a button, and it logs once.
Fantom (`ViewSafeAreaInsets-itest.js`, `ViewSafeAreaInsetsWarning-itest.js`):
- **Delivery and opt-in** — the event reaches the handler with the insets; a view without the prop is never its target.
- **View flattening** — a layout-only view is flattened away; the same view is kept once it has the prop, since observing needs a host view (asserted both ways).
- **Warning** — silence at a plausible rate (20 changes 200 ms apart), one warning per view under a loop, per-view counting, and the handler still receiving its event, with a mocked clock.
On device:
- **View recycling** — scrolling a long list of observing rows in and out; recycled rows report their own insets, not a previous occupant's, on both platforms.
- **Clipped Android views** — rows scrolled out of a `ScrollView` emit nothing instead of garbage overlap values (event counters in the scroll benchmark).
- **Multi-scene iPad** — with `UIApplicationSupportsMultipleScenes` enabled in a local RNTester build (it ships off): two windows, two React instances, one shared key window, correct per-window insets across tiling, fullscreen, rotation and keyboard.
**Known gaps, 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.
- Android rotation was not exercised: the RNTester activity kept its orientation on my emulator. The same pre-draw listener drives it.
- The loop warning only covers `View`. The prop is on `BaseViewProps`, so `Text`, `Image` and `ScrollView` accept it too; `View` is where it is used in practice.
- The loop warning's heuristic (more than ten events in a second) also fires for a view dragged slowly across a system-bar band, which is a legitimate stream. I have not seen it in practice; a threshold on *alternating* values would distinguish the two if it turns out to matter.
---
**Stack** — split out of react#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. The display-phase event beat this builds on landed as react#58530.
This is the bottom of the stack, so its diff is already just this change.
👉 1. react#58109 — Add an `experimental_onSafeAreaInsetsChange` view prop
2. react#58110 — Report the window safe area insets through Dimensions
3. react#58112 — Render the internal SafeAreaView from the safe area insets prop
4. react#58113 — Remove the native SafeAreaView and the deprecated public export
Reviewed By: andrewdacenko
Differential Revision: D121015233
Pulled By: Abbondanzo
|
@Abbondanzo merged this pull request in 8d658f4. |
…ne Duo) (react#59529) Summary: `<ScrollView>`'s `contentInsetAdjustmentBehavior` prop provides built-in safe area handling today on the vertical axis (e.g. above the home indicator), **however** is missing safe area handling on the cross axis (i.e. iPhone Duo vertical rail). Since react#58109 (`experimental_onSafeAreaInsetsChange`), we now have the API to conveniently patch this behaviour in core. **From the user's perspective** This presents a very frequent code authoring issue. Typical ScrollView code at the top of a layout today (e.g. app screen in Expo Router): ```jsx <ScrollView contentInsetAdjustmentBehavior="automatic" contentContainerStyle={styles.content}> {children} </ScrollView> ``` Without this diff, needs to become: ```jsx <ScrollView contentInsetAdjustmentBehavior="automatic"> // ['left', 'right'] compensates for ScrollView only handling 'bottom' today <SafeAreaView edges={['left', 'right']} style={styles.content}> {children} </SafeAreaView> </ScrollView> ``` ⬆️ `contentInsetAdjustmentBehavior="automatic"` isn't doing its job on the horizontal safe areas on a device such as iPhone Duo. With this diff the scroll view handles it alone: | Before, iPhone Duo folded (`automatic`) | After | |---|---| | <img src="https://github.com/user-attachments/assets/9252e804-c69d-413b-9439-04df77d949c7" width="300" /> | <img src="https://github.com/user-attachments/assets/cf64c244-682e-48f9-9616-ddea724fa080" width="300" /> | And the code sample becomes resolved: - Users can preserve/revert to the original `ScrollView`-only hierarchy — **backwards compatible** ✅ - Apps that update to the next RN version fresh and try to support iPhone Duo don't face this issue at all ✅ **Why this is a bug** Remember that: - `<ScrollView contentInsetAdjustmentBehavior="automatic">` already handles the bottom safe area inset on relevant devices. - As with the bottom inset case, the horizontal insets case needs to pad the ScrollView itself (**not** be wrapped in a page container) — so that the right hand scroll indicator hits the device edge as expected. And again, with iOS 27.1 and iPhone Duo, a typical React Native screen with a `<ScrollView>` (e.g. in Expo Router) that handled safe area before, does not work now. This is a combination of: - Our choice in Expo to intentionally not SafeArea-wrap every screen — for the sake of apps being able to draw under the rail when needed. - And as above, the incorrectness of the SafeAreaView > ScrollView hierarchy (reversed), which breaks the system scrollbar expectation. `"automatic"` should be automatic. By fixing this we **delete the problem** for all RN users. **Changes** - `ScrollView` observes its own safe area when the behavior is not `'never'` and pads the content container across the scrollable axis, on top of the padding its style resolves to on that edge. - The native scroll view removes the content inset UIKit adds across that axis for the safe area, so the safe area applies once: under `'always'`, on an axis that always bounces, and when a navigation stack registers the scroll view as its content scroll view, as react-native-screens does for the first scroll view of a screen. The `contentInset` prop stays as given along the scrollable axis. - The `horizontal` prop reaches the native view on iOS, as it already did on Android, so the scroll view knows which axis it scrolls along. - Add a full screen RNTester example under ScrollView that switches between the four values, vertical and horizontal. **Impact**⚠️ This is behaviour change fixing both iPhone Duo in portrait (a recent use case) **AND** phones with camera cutouts/home indicators in landscape. At the same time, if anyone was handling this before with a nested SafeAreaView, they play nicely and is **backwards compatible**. - The only user-side breakage possible is we now apply a left/right safe area in landscape where the app did want to draw close to the device edges. We should note this clearly in the changelog. **Notes** - The padding goes on the content container rather than narrowing the scroll view, so the background and the scroll indicator keep the screen edge, matching UIKit's own tables. A percentage padding on the container resolves to 0 before the inset is added. - Android is **unchanged**: the prop is documented iOS only and the native scroll view ignores it. Changelog: [iOS][Fixed] - **ScrollView**: `contentInsetAdjustmentBehavior` keeps content clear of the safe area across the scrollable axis, such as the rail on iPhone Duo and the notch in landscape Test Plan: Validation of this split: - Ran `js1 test --runTestsByPath` for `ScrollView-safeAreaContentPadding-test.js` and `getSafeAreaContentPadding-test.js`: 20 tests passed. Includes native opt-in coordination, switching to never, and horizontal true-to-undefined/null. - Regenerated C++ API snapshots in the native preparation. - Independent static review found no remaining code blocker. - Not re-run on iOS: old-JS/new-native behaviour, true-to-true recycling, first mount, rotation and behaviour switching. These still need runtime verification. Upstream visual verification of the combined implementation, supplied with the import and not re-run for this split: ``` yarn test packages/react-native/src/private/components/scrollview ``` RNTester on an iPhone Duo simulator (iOS 27.1), ScrollView > Content Inset Adjustment Behavior, which opens a full screen modal: | iPhone Duo folded, never | iPhone Duo folded, automatic | iPhone Duo folded, always | |---|---|---| | <img src="https://github.com/user-attachments/assets/4dfa447d-d403-4733-a090-76ac8af821bb" width="300" /> | <img src="https://github.com/user-attachments/assets/c16983c4-7858-4a94-bab2-0f826f0f91a7" width="300" /> | <img src="https://github.com/user-attachments/assets/2b508dbf-9adf-4b18-9509-f0bcf15aa8e7" width="300" /> | | Rows run under the rail | ✅ Rows stop at the rail; the container's background still runs under it | ✅ Rows stop at the rail, with no sideways offset from UIKit's content inset | | iPhone Duo unfolded, automatic | iPhone Duo unfolded, always | |---|---| | <img src="https://github.com/user-attachments/assets/2e6a0b7a-4c8a-4895-b26a-7069ff84e307" width="400" /> | <img src="https://github.com/user-attachments/assets/1216d844-18fe-43ea-99a9-944b2d66fe97" width="400" /> | | ✅ Rows stop at the rail on the open display | ✅ Same, with no sideways offset | | iPhone Duo folded, automatic, while scrolling | |---| | <img src="https://github.com/user-attachments/assets/ba785d2a-d635-47d4-98ff-ead21450fd8b" width="300" /> | | ✅ The scroll indicator stays at the device edge, since the scroll view itself still spans the screen | iPhone 18 Pro simulator (iOS 27.0) in landscape, the same example. Both sides carry a safe area inset here, the camera cutout side and the opposite edge: | Before, iPhone 18 Pro landscape (`automatic`) | After | |---|---| | <img src="https://github.com/user-attachments/assets/c8365985-ba30-4be1-967d-d8cd0b6bb85d" width="400" /> | <img src="https://github.com/user-attachments/assets/27637ab5-acc4-4134-95b9-e6421734340b" width="400" /> | | Rows run under the cutout and to the far edge, as `never` does | ✅ Rows stop at both insets; the container's background still runs edge to edge | | iPhone 18 Pro landscape, never | iPhone 18 Pro landscape, always | |---|---| | <img src="https://github.com/user-attachments/assets/9aab7554-6cdd-49d2-b545-2b5039fb013d" width="400" /> | <img src="https://github.com/user-attachments/assets/662810c2-562f-4530-b423-bc41d5384b33" width="400" /> | | Rows run under the cutout and to the far edge | ✅ Rows stop at both insets, with no sideways offset on either side | **Nesting the layout with `<SafeAreaView>` (backwards compatible)** Build with a temporary toggle added in the example that wraps the rows in `react-native-safe-area-context`'s `SafeAreaView`, with a yellow background (today's temporarily necessary authoring behaviour): ```jsx <ScrollView contentInsetAdjustmentBehavior={behavior}> <SafeAreaView edges={['left', 'right']} style={{backgroundColor: '#f5d76e'}}> {rows} </SafeAreaView> </ScrollView> ``` | automatic | automatic, inner SafeAreaView | never, inner SafeAreaView | |---|---|---| | <img src="https://github.com/user-attachments/assets/2b430401-7488-4fac-83a2-18cf9b2e6b18" width="300" /> | <img src="https://github.com/user-attachments/assets/1ced6214-d9f1-413f-8281-f3f146df7f29" width="300" /> | <img src="https://github.com/user-attachments/assets/40e0aed6-5b88-4a67-9893-eecbed9717f6" width="300" /> | | Rows stop at the rail | ✅ Rows stop at the same place: the content container is already clear of the rail, so the inner view's own safe area is zero and it adds no padding | ✅ The inner view pads instead, and rows stop at the same place | - ✅ Switching between the values re-lays the content out in place, and the modal opens clear of the rail with `automatic` selected. -⚠️ Not run: Fantom, which targets Android, where the behavior is off; the padding helper is covered by Jest instead. Reviewed By: andrewdacenko Differential Revision: D127787712 Pulled By: cipolleschi
…ne Duo) (react#59529) Summary: `<ScrollView>`'s `contentInsetAdjustmentBehavior` prop provides built-in safe area handling today on the vertical axis (e.g. above the home indicator), **however** is missing safe area handling on the cross axis (i.e. iPhone Duo vertical rail). Since react#58109 (`experimental_onSafeAreaInsetsChange`), we now have the API to conveniently patch this behaviour in core. **From the user's perspective** This presents a very frequent code authoring issue. Typical ScrollView code at the top of a layout today (e.g. app screen in Expo Router): ```jsx <ScrollView contentInsetAdjustmentBehavior="automatic" contentContainerStyle={styles.content}> {children} </ScrollView> ``` Without this diff, needs to become: ```jsx <ScrollView contentInsetAdjustmentBehavior="automatic"> // ['left', 'right'] compensates for ScrollView only handling 'bottom' today <SafeAreaView edges={['left', 'right']} style={styles.content}> {children} </SafeAreaView> </ScrollView> ``` ⬆️ `contentInsetAdjustmentBehavior="automatic"` isn't doing its job on the horizontal safe areas on a device such as iPhone Duo. With this diff the scroll view handles it alone: | Before, iPhone Duo folded (`automatic`) | After | |---|---| | <img src="https://github.com/user-attachments/assets/9252e804-c69d-413b-9439-04df77d949c7" width="300" /> | <img src="https://github.com/user-attachments/assets/cf64c244-682e-48f9-9616-ddea724fa080" width="300" /> | And the code sample becomes resolved: - Users can preserve/revert to the original `ScrollView`-only hierarchy — **backwards compatible** ✅ - Apps that update to the next RN version fresh and try to support iPhone Duo don't face this issue at all ✅ **Why this is a bug** Remember that: - `<ScrollView contentInsetAdjustmentBehavior="automatic">` already handles the bottom safe area inset on relevant devices. - As with the bottom inset case, the horizontal insets case needs to pad the ScrollView itself (**not** be wrapped in a page container) — so that the right hand scroll indicator hits the device edge as expected. And again, with iOS 27.1 and iPhone Duo, a typical React Native screen with a `<ScrollView>` (e.g. in Expo Router) that handled safe area before, does not work now. This is a combination of: - Our choice in Expo to intentionally not SafeArea-wrap every screen — for the sake of apps being able to draw under the rail when needed. - And as above, the incorrectness of the SafeAreaView > ScrollView hierarchy (reversed), which breaks the system scrollbar expectation. `"automatic"` should be automatic. By fixing this we **delete the problem** for all RN users. **Changes** - `ScrollView` observes its own safe area when the behavior is not `'never'` and pads the content container across the scrollable axis, on top of the padding its style resolves to on that edge. - The native scroll view removes the content inset UIKit adds across that axis for the safe area, so the safe area applies once: under `'always'`, on an axis that always bounces, and when a navigation stack registers the scroll view as its content scroll view, as react-native-screens does for the first scroll view of a screen. The `contentInset` prop stays as given along the scrollable axis. - The `horizontal` prop reaches the native view on iOS, as it already did on Android, so the scroll view knows which axis it scrolls along. - Add a full screen RNTester example under ScrollView that switches between the four values, vertical and horizontal. **Impact**⚠️ This is behaviour change fixing both iPhone Duo in portrait (a recent use case) **AND** phones with camera cutouts/home indicators in landscape. At the same time, if anyone was handling this before with a nested SafeAreaView, they play nicely and is **backwards compatible**. - The only user-side breakage possible is we now apply a left/right safe area in landscape where the app did want to draw close to the device edges. We should note this clearly in the changelog. **Notes** - The padding goes on the content container rather than narrowing the scroll view, so the background and the scroll indicator keep the screen edge, matching UIKit's own tables. A percentage padding on the container resolves to 0 before the inset is added. - Android is **unchanged**: the prop is documented iOS only and the native scroll view ignores it. Changelog: [iOS][Fixed] - **ScrollView**: `contentInsetAdjustmentBehavior` keeps content clear of the safe area across the scrollable axis, such as the rail on iPhone Duo and the notch in landscape Test Plan: Validation of this split: - Ran `js1 test --runTestsByPath` for `ScrollView-safeAreaContentPadding-test.js` and `getSafeAreaContentPadding-test.js`: 20 tests passed. Includes native opt-in coordination, switching to never, and horizontal true-to-undefined/null. - Regenerated C++ API snapshots in the native preparation. - Independent static review found no remaining code blocker. - Not re-run on iOS: old-JS/new-native behaviour, true-to-true recycling, first mount, rotation and behaviour switching. These still need runtime verification. Upstream visual verification of the combined implementation, supplied with the import and not re-run for this split: ``` yarn test packages/react-native/src/private/components/scrollview ``` RNTester on an iPhone Duo simulator (iOS 27.1), ScrollView > Content Inset Adjustment Behavior, which opens a full screen modal: | iPhone Duo folded, never | iPhone Duo folded, automatic | iPhone Duo folded, always | |---|---|---| | <img src="https://github.com/user-attachments/assets/4dfa447d-d403-4733-a090-76ac8af821bb" width="300" /> | <img src="https://github.com/user-attachments/assets/c16983c4-7858-4a94-bab2-0f826f0f91a7" width="300" /> | <img src="https://github.com/user-attachments/assets/2b508dbf-9adf-4b18-9509-f0bcf15aa8e7" width="300" /> | | Rows run under the rail | ✅ Rows stop at the rail; the container's background still runs under it | ✅ Rows stop at the rail, with no sideways offset from UIKit's content inset | | iPhone Duo unfolded, automatic | iPhone Duo unfolded, always | |---|---| | <img src="https://github.com/user-attachments/assets/2e6a0b7a-4c8a-4895-b26a-7069ff84e307" width="400" /> | <img src="https://github.com/user-attachments/assets/1216d844-18fe-43ea-99a9-944b2d66fe97" width="400" /> | | ✅ Rows stop at the rail on the open display | ✅ Same, with no sideways offset | | iPhone Duo folded, automatic, while scrolling | |---| | <img src="https://github.com/user-attachments/assets/ba785d2a-d635-47d4-98ff-ead21450fd8b" width="300" /> | | ✅ The scroll indicator stays at the device edge, since the scroll view itself still spans the screen | iPhone 18 Pro simulator (iOS 27.0) in landscape, the same example. Both sides carry a safe area inset here, the camera cutout side and the opposite edge: | Before, iPhone 18 Pro landscape (`automatic`) | After | |---|---| | <img src="https://github.com/user-attachments/assets/c8365985-ba30-4be1-967d-d8cd0b6bb85d" width="400" /> | <img src="https://github.com/user-attachments/assets/27637ab5-acc4-4134-95b9-e6421734340b" width="400" /> | | Rows run under the cutout and to the far edge, as `never` does | ✅ Rows stop at both insets; the container's background still runs edge to edge | | iPhone 18 Pro landscape, never | iPhone 18 Pro landscape, always | |---|---| | <img src="https://github.com/user-attachments/assets/9aab7554-6cdd-49d2-b545-2b5039fb013d" width="400" /> | <img src="https://github.com/user-attachments/assets/662810c2-562f-4530-b423-bc41d5384b33" width="400" /> | | Rows run under the cutout and to the far edge | ✅ Rows stop at both insets, with no sideways offset on either side | **Nesting the layout with `<SafeAreaView>` (backwards compatible)** Build with a temporary toggle added in the example that wraps the rows in `react-native-safe-area-context`'s `SafeAreaView`, with a yellow background (today's temporarily necessary authoring behaviour): ```jsx <ScrollView contentInsetAdjustmentBehavior={behavior}> <SafeAreaView edges={['left', 'right']} style={{backgroundColor: '#f5d76e'}}> {rows} </SafeAreaView> </ScrollView> ``` | automatic | automatic, inner SafeAreaView | never, inner SafeAreaView | |---|---|---| | <img src="https://github.com/user-attachments/assets/2b430401-7488-4fac-83a2-18cf9b2e6b18" width="300" /> | <img src="https://github.com/user-attachments/assets/1ced6214-d9f1-413f-8281-f3f146df7f29" width="300" /> | <img src="https://github.com/user-attachments/assets/40e0aed6-5b88-4a67-9893-eecbed9717f6" width="300" /> | | Rows stop at the rail | ✅ Rows stop at the same place: the content container is already clear of the rail, so the inner view's own safe area is zero and it adds no padding | ✅ The inner view pads instead, and rows stop at the same place | - ✅ Switching between the values re-lays the content out in place, and the modal opens clear of the rail with `automatic` selected. -⚠️ Not run: Fantom, which targets Android, where the behavior is off; the padding helper is covered by Jest instead. Reviewed By: andrewdacenko Differential Revision: D127787712 Pulled By: cipolleschi
…ayouts - Patch React Native 0.88.1 with react/react-native#58109 and #59194 (iOS and shared parts): a ScrollView with contentInsetAdjustmentBehavior pads its content by the side safe area, so rows clear the Duo's button column while backgrounds run under it. React Native builds from source for the native half. - Remove useWideLayout, sideSafeAreaLayout, toGridRows, the Home card grid and the two-column Settings. Screens keep their layout and grow, as the HIG asks. - Settings, Organization, Hosts and Files changed opt into automatic insets; the readable column and the diff viewport size from the safe-area width.
#9000) * feat(mobile): adapt layouts for iPhone Duo's unfolded display and iPad On iPhone Duo the status bar and the navigation buttons sit in an 84 pt column on the window's right edge, folded and unfolded. Screens laid out on the full window width, so content ran under that column, and the unfolded display (951 x 669 pt) showed a 720 pt phone column in a wide landscape window. iPad showed the same phone column. - The authenticated stacks pad their screens by the side safe-area insets, so nothing sits under the Duo's button column. Home pads inside its own stack so its header still reaches the column. - useWideLayout decides layout on the safe-area width, never the window width: wide from 700 pt (unfolded Duo, every iPad), phone layout below (every iPhone, folded Duo). It re-reads on every resize, so fold and unfold re-lay the open screen. - Home shows workspaces as a card grid when wide: 320 pt minimum cards, 2 columns on the unfolded Duo and iPad portrait, 3 on iPad landscape. - Settings puts the account card beside the sections when wide. - Pages and Files changed size their grid and code pane from the safe-area width. - ListRow keeps its trailing value visible: a long subtitle now truncates instead of hiding the value (the GitHub row lost "Not connected" and its chevron on every iPhone). A test fails when code outside useWideLayout reads the raw window width. * fix(mobile): pad sheets by their own safe area and give cloud grid cells card padding The stacks padded every screen by the window's side insets, so a form sheet floating inside the unfolded iPhone Duo's safe area still got the 84 pt right inset. Screens now pad by their own side safe area, measured natively, so full-screen routes still clear the right-edge button column and floating sheets get none. Transparent modals keep drawing over the whole window. Cloud workspace rows in Home's wide grid used their list padding; they now take the same card padding as host workspace cells. * fix(mobile): key Home grid rows by every cell A row was keyed by its first cell only, so a workspace filling the empty slot of a partly filled row left the key unchanged and the list never reported it visible: its diff stats stayed unloaded until a scroll. * test(mobile): cover the Home grid row key Moves the row key next to toGridRows so a test can hold that the key changes when a cell fills a row's empty slot. * chore(mobile): upgrade to Expo SDK 58 and build on Xcode 27.1 for iPhone Duo iPhone Duo shows an app built with an older SDK in a compatibility window, without the vertical bar rail. The `sdk-57` EAS image is Xcode 26.6, so the layout work in this PR never reached Duo users. Every build profile now uses `macos-tahoe-26.6-xcode-27.1` (iOS 27.1 SDK), guarded by eas-ios-image.test.ts. - expo 58.0.21, react-native 0.88.1, react-native-screens 4.30.1, gesture-handler 3.3.0, webview 14.0.1, reanimated 4.7.1, worklets 0.13.0. - React and @types/react 19.3.0 across the monorepo: react-native 0.88 needs react ^19.3.0, and the shared packages pin react exactly. - SDK 58 packages published on 2026-10-28 are in minimumReleaseAgeExcludes. - react-native-screens: the form sheet scroll view fix is upstream since 4.30 (#4651); the guard test now checks the installed source. The interactive push hunks are re-applied on 4.30.1. expo-observe 58.0.16 still asks for Swift 6, so its patch carries over. - react-native 0.88 types core components as functions: refs use ViewInstance / ScrollViewInstance / TextInputInstance, and the TextInput event data types are now TextInput*Event. - react-native-webview 14 defaults `WebView<P = undefined>`, which makes every prop `never`; PageFrame passes `object`. - masked-view 0.3.2 types its class on NativeMethods, which 0.88 no longer exports; FadeOut casts it to a component type. - babel-preset-expo 58.0.22. Mobile typecheck and tests (247) pass. Not yet built natively. * fix(mobile): sign-in and Home on Expo SDK 58 - Root stack names the sign-in screen (auth)/sign-in. expo-router 58 names a group without a layout by its child route, so the (auth) guard matched nothing and a signed-in user stayed on sign-in. - uniwind 1.13.0. 1.8.0 re-exports a fixed list of react-native names and lacks AssetRegistry, which RN 0.88 exports and expo-asset 58 imports, so Home crashed on its first image. * feat(mobile): native side safe area on iPhone Duo; drop the JS wide layouts - Patch React Native 0.88.1 with react/react-native#58109 and #59194 (iOS and shared parts): a ScrollView with contentInsetAdjustmentBehavior pads its content by the side safe area, so rows clear the Duo's button column while backgrounds run under it. React Native builds from source for the native half. - Remove useWideLayout, sideSafeAreaLayout, toGridRows, the Home card grid and the two-column Settings. Screens keep their layout and grow, as the HIG asks. - Settings, Organization, Hosts and Files changed opt into automatic insets; the readable column and the diff viewport size from the safe-area width. * fix(mobile): settings and Files changed clear the Duo button column without moving their top contentInsetAdjustmentBehavior on these four lists also inset the content below the header: on iPhone that moved Settings' avatar, and in Files changed it opened a gap under the sticky file header. A left/right SafeAreaView around the content clears the side column and leaves the top as it was. * feat(mobile): Home lays workspaces out as a grid on the open iPhone Duo and iPad Serhii's card grid from #9000, sized from the safe-area width by a Home-only hook. The open Duo gets two columns, so its fold runs between them. * fix(mobile): keep screen content below the header on Expo SDK 58 expo-router 58 lays every iOS 26 screen under a transparent Liquid Glass bar and leaves the inset to each screen, so Settings' avatar and the filter sheets' rows sat under their headers on every iPhone. Restore the opaque header and content below it, as on SDK 57. --------- Co-authored-by: Satya Patel <14907857+saddlepaddle@users.noreply.github.com> Co-authored-by: Satya Patel <satyapatel111@gmail.com>








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 lets 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, soSafeAreaProvidercan swap its native component for a plainView.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.
The event is dispatched synchronously through
experimental_flushSync, so the layout that depends on the insets is mounted in the frame the insets changed in (on iOS that relies on #58530 for events emitted fromlayoutSubviews; text re-rendered by them draws correctly once #58632 lands). A full inset event — dispatch, JS render, commit, mount — is about 3 ms in a debug build re-rendering a small component, paid per inset change rather than per frame.Two things I'd like input on:
boolinBaseViewPropslikeonLayout, and a branch on it inlayoutSubviews,didMoveToWindowandsafeAreaInsetsDidChangeon every view. Worth a look from someone who profiles that path. On Android nothing is attached unless the prop is set.In development,
Viewwraps the handler and warns once per view above ten events in a second. The system UI does not move that often, so a sustained stream means the layout is feeding the insets back into the view's own position — offset by what it reports, it moves out from under the system UI, which changes what it reports. The check is one JavaScript implementation for both platforms and surfaces in LogBox with a stack rather than in logcat.Changelog:
[GENERAL] [ADDED] - Add an
experimental_onSafeAreaInsetsChangeview 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 loopTest Plan:
RNTester, new "Safe area insets" example, on an iPhone 17 Pro simulator and an Android 16 emulator: a view inside the safe area reads zero insets; a full screen view padding itself by its own insets lines up with the system UI in portrait and landscape on both platforms; the scroll benchmark counts events on both platforms. Screenshots and the synchronous-dispatch frame captures are in #57967, the prototype this splits. The example also grows the mistake the warning catches — a view positioned by the insets it reports — behind a button, and it logs once.
Fantom (
ViewSafeAreaInsets-itest.js,ViewSafeAreaInsetsWarning-itest.js):On device:
ScrollViewemit nothing instead of garbage overlap values (event counters in the scroll benchmark).UIApplicationSupportsMultipleScenesenabled in a local RNTester build (it ships off): two windows, two React instances, one shared key window, correct per-window insets across tiling, fullscreen, rotation and keyboard.Known gaps, 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.layoutSubviewsfor the next transaction, so its event lands a frame late. It needs one observer's synchronous render to resize a second observer; I have not hit it.View. The prop is onBaseViewProps, soText,ImageandScrollViewaccept it too;Viewis where it is used in practice.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. Each PR is one commit on top of the previous one. The display-phase event beat this builds on landed as #58530. #58632 is a standalone fix for text drawn from a display-phase mount, which the prop exposes on rotation.This is the bottom of the stack, so its diff is already just this change.
👉 1. #58109 — Add an
experimental_onSafeAreaInsetsChangeview prop2. #58110 — Report the window safe area insets through Dimensions
3. #58112 — Render the internal SafeAreaView from the safe area insets prop
4. #58113 — Remove the native SafeAreaView and the deprecated public export