Skip to content

Lazily store safe area inset state (#58959) - #58959

Open
Abbondanzo wants to merge 2 commits into
react:mainfrom
Abbondanzo:export-D124143204
Open

Abbondanzo wants to merge 2 commits into
react:mainfrom
Abbondanzo:export-D124143204

Conversation

@Abbondanzo

@Abbondanzo Abbondanzo commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Summary:

RCTViewComponentView currently reserves a UIEdgeInsets value on every
instance even though safe area observation is opt-in and rarely used. Store the
last reported insets in an associated object only after a view starts emitting
safe area inset changes, and clear that storage when observation stops or the
view is recycled.

Changelog: [Internal]

Reviewed By: javache

Differential Revision: D124143204

@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 Oct 8, 2026
@facebook-github-tools facebook-github-tools Bot added p: Facebook Partner: Facebook Partner labels Oct 8, 2026
@meta-codesync

meta-codesync Bot commented Oct 8, 2026

Copy link
Copy Markdown

@Abbondanzo has exported this pull request. If you are a Meta employee, you can view the originating Diff in D124143204.

@github-actions

github-actions Bot commented Oct 8, 2026

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_NON_BREAKING

janicduplessis and others added 2 commits October 8, 2026 11:15
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
Summary:
Pull Request resolved: react#58959

`RCTViewComponentView` currently reserves a `UIEdgeInsets` value on every
instance even though safe area observation is opt-in and rarely used. Store the
last reported insets in an associated object only after a view starts emitting
safe area inset changes, and clear that storage when observation stops or the
view is recycled.

Changelog: [Internal]

Reviewed By: javache

Differential Revision: D124143204
@meta-codesync meta-codesync Bot changed the title Lazily store safe area inset state Lazily store safe area inset state (#58959) Oct 8, 2026

This branch has not been deployed

No deployments
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. meta-exported p: Facebook Partner: Facebook Partner

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants