Skip to content

Warn in development when a view reports its safe area insets in a loop - #58111

Draft
janicduplessis wants to merge 4 commits into
react:mainfrom
janicduplessis:safe-area/4-warn-on-inset-loops
Draft

Warn in development when a view reports its safe area insets in a loop#58111
janicduplessis wants to merge 4 commits into
react:mainfrom
janicduplessis:safe-area/4-warn-on-inset-loops

Conversation

@janicduplessis

@janicduplessis janicduplessis commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Summary:

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 one implementation covers iOS and Android and the warning surfaces in LogBox with a JavaScript stack instead of in logcat. That placement has one gap worth naming: the prop is on BaseViewProps, so Text, Image and ScrollView accept it too and are not checked. View is where it is used in practice.

The production branch is the identity function, so the module stays out of the bundle. The native prop is unaffected either way — function props are normalized to true before props are diffed (ReactNativeAttributePayload.js), so a fresh wrapper per render does not produce an update. Counts are kept per view in a WeakMap keyed by the event target, so views that do not loop pay nothing.

Stacked on #58110. This PR's diff includes the ones below it until they merge; review the top commit.

Changelog:

[GENERAL] [ADDED] - Warn in development when a view reports its safe area insets in a loop

Test Plan:

yarn fantom packages/react-native/Libraries/Components/View/__tests__/ViewSafeAreaInsetsWarning-itest.js   # 4 passed

covering silence at a plausible rate (20 changes 200 ms apart), one warning per view under a loop, per-view counting, and that the handler still receives its event — all with a mocked clock.

On device: RNTester grows the mistake it warns about, a view offset by the insets it reports. On an iPhone 17 Pro simulator, starting the loop produced 2652 inset events and exactly one warning, reaching LogBox and the app log:

`experimental_onSafeAreaInsetsChange` fired more than 10 times in 1000ms on a single view. The safe area insets of a view only change when the system UI moves or the view does, so this is usually a loop: the view is laid out from the insets it reports, which moves it, which changes its insets. Each event renders synchronously, so the loop costs frames.


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.

This PR's own change, without the ones below it: 4 files.

1. #58108 — 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. #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

`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.
`Dimensions.get('window').experimental_safeAreaInsets` (and
`useWindowDimensions`) reports the safe area insets of the window, using the
same native computation as the view prop applied to the window itself.

Unlike the prop, this is available synchronously at startup — no event has to
arrive first — and it updates through the existing `change` event. That is
what the safe area context library needs `initialWindowMetrics` for, its last
remaining native module, and it is what lets a component render padded on its
very first frame instead of correcting itself once the first inset event
lands.

The field is absent on platforms and versions that cannot report it, rather
than reported as zeros, so a consumer can tell "no insets" from "unknown".
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.
@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Aug 24, 2026
@facebook-github-tools facebook-github-tools Bot added the Contributor A React Native contributor. label Aug 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. Contributor A React Native contributor.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant