Skip to content

Commit 8d658f4

Browse files
janicduplessismeta-codesync[bot]
authored andcommitted
Add an experimental_onSafeAreaInsetsChange view prop (#58109)
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 #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 #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: #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 #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 #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 #58530. This is the bottom of the stack, so its diff is already just this change. 👉 1. #58109 — Add an `experimental_onSafeAreaInsetsChange` view prop 2. #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 Reviewed By: javache Differential Revision: D121015233 Pulled By: Abbondanzo fbshipit-source-id: 8480c49bec09ef4a404bd30623c075d077547849
1 parent ca3e76a commit 8d658f4

39 files changed

Lines changed: 1179 additions & 8 deletions

‎packages/react-native/Libraries/Components/View/View.js‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,21 @@
99
*/
1010

1111
import type {HostInstance} from '../../../src/private/types/HostInstance';
12+
import type {SafeAreaInsetsChangeEvent} from '../../Types/CoreEventTypes';
1213
import type {ViewProps} from './ViewPropTypes';
1314

1415
import TextAncestorContext from '../../Text/TextAncestorContext';
1516
import ViewNativeComponent from './ViewNativeComponent';
1617
import * as React from 'react';
1718
import {use} from 'react';
1819

20+
const warnOnRepeatedSafeAreaInsetsChanges: (
21+
onSafeAreaInsetsChange: (event: SafeAreaInsetsChangeEvent) => unknown,
22+
) => (event: SafeAreaInsetsChangeEvent) => unknown = __DEV__
23+
? require('../../../src/private/components/view/warnOnRepeatedSafeAreaInsetsChanges')
24+
.default
25+
: onSafeAreaInsetsChange => onSafeAreaInsetsChange;
26+
1927
export type ViewInstance = HostInstance;
2028

2129
/**
@@ -115,6 +123,15 @@ component View(ref?: React.RefSetter<ViewInstance>, ...props: ViewProps) {
115123
};
116124
}
117125

126+
if (__DEV__) {
127+
const onSafeAreaInsetsChange =
128+
resolvedProps.experimental_onSafeAreaInsetsChange;
129+
if (onSafeAreaInsetsChange != null) {
130+
resolvedProps.experimental_onSafeAreaInsetsChange =
131+
warnOnRepeatedSafeAreaInsetsChanges(onSafeAreaInsetsChange);
132+
}
133+
}
134+
118135
const actualView =
119136
ref == null ? (
120137
<ViewNativeComponent {...resolvedProps} />

‎packages/react-native/Libraries/Components/View/ViewPropTypes.js‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import type {
2323
LayoutRectangle,
2424
MouseEvent,
2525
PointerEvent,
26+
SafeAreaInsetsChangeEvent,
2627
} from '../../Types/CoreEventTypes';
2728
import type {
2829
AccessibilityActionEvent,
@@ -63,6 +64,28 @@ type DirectEventProps = Readonly<{
6364
*/
6465
onLayout?: ?(event: LayoutChangeEvent) => unknown,
6566

67+
/**
68+
* Invoked when the part of this view that is covered by the system UI
69+
* (status bar, navigation bar, home indicator, display cutouts, ...)
70+
* changes, with:
71+
*
72+
* `{nativeEvent: {insets: {top, right, bottom, left}}}`
73+
*
74+
* `insets` are relative to this view: an inset is only non-zero for the part
75+
* of the view that actually overlaps the system UI.
76+
*
77+
* The event is dispatched synchronously, so the rendering it schedules is
78+
* applied in the same frame the insets changed in.
79+
*
80+
* Setting this prop makes the view observe safe area changes; views without
81+
* it are unaffected.
82+
*
83+
* @experimental
84+
*/
85+
experimental_onSafeAreaInsetsChange?: ?(
86+
event: SafeAreaInsetsChangeEvent,
87+
) => unknown,
88+
6689
/**
6790
* When `accessible` is `true`, the system will invoke this function when the
6891
* user performs the magic tap gesture.
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @flow strict-local
8+
* @format
9+
*/
10+
11+
import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment';
12+
13+
import type {HostInstance} from 'react-native';
14+
15+
import * as Fantom from '@react-native/fantom';
16+
import * as React from 'react';
17+
import {createRef} from 'react';
18+
import {View} from 'react-native';
19+
20+
const INSETS = {top: 44, right: 0, bottom: 34, left: 0};
21+
22+
describe('experimental_onSafeAreaInsetsChange', () => {
23+
it('delivers the insets of the view', () => {
24+
const root = Fantom.createRoot();
25+
const nodeRef = createRef<HostInstance>();
26+
const onSafeAreaInsetsChange = jest.fn();
27+
28+
Fantom.runTask(() => {
29+
root.render(
30+
<View
31+
ref={nodeRef}
32+
experimental_onSafeAreaInsetsChange={event => {
33+
onSafeAreaInsetsChange(event.nativeEvent);
34+
}}
35+
/>,
36+
);
37+
});
38+
39+
Fantom.dispatchNativeEvent(nodeRef, 'safeAreaInsetsChange', {
40+
insets: INSETS,
41+
});
42+
43+
expect(onSafeAreaInsetsChange).toHaveBeenCalledTimes(1);
44+
const [event] = onSafeAreaInsetsChange.mock.lastCall;
45+
expect(event.insets).toEqual(INSETS);
46+
});
47+
48+
it('is not delivered to views that did not opt in', () => {
49+
const root = Fantom.createRoot();
50+
const nodeRef = createRef<HostInstance>();
51+
52+
Fantom.runTask(() => {
53+
// Without the prop nothing keeps a layout-only view from being flattened
54+
// away, so it has to be kept explicitly to have a host view to inspect.
55+
root.render(<View collapsable={false} ref={nodeRef} />);
56+
});
57+
58+
// The prop is what makes the view observe the safe area, so a view without
59+
// it is never the target of the event.
60+
expect(
61+
root
62+
.getRenderedOutput({props: ['experimental_onSafeAreaInsetsChange']})
63+
.toJSX(),
64+
).toEqual(<rn-view />);
65+
});
66+
67+
it('prevents the view from being flattened', () => {
68+
const root = Fantom.createRoot();
69+
70+
// A layout-only view is ordinarily flattened away. The same view is kept
71+
// once it observes the safe area, since observing requires a host view.
72+
Fantom.runTask(() => {
73+
root.render(
74+
<View>
75+
<View collapsable={false} />
76+
</View>,
77+
);
78+
});
79+
80+
expect(
81+
root
82+
.getRenderedOutput({props: ['experimental_onSafeAreaInsetsChange']})
83+
.toJSX(),
84+
).toEqual(<rn-view />);
85+
86+
Fantom.runTask(() => {
87+
root.render(
88+
<View experimental_onSafeAreaInsetsChange={() => {}}>
89+
<View collapsable={false} />
90+
</View>,
91+
);
92+
});
93+
94+
expect(
95+
root
96+
.getRenderedOutput({props: ['experimental_onSafeAreaInsetsChange']})
97+
.toJSX(),
98+
).toEqual(
99+
<rn-view experimental_onSafeAreaInsetsChange="true">
100+
<rn-view />
101+
</rn-view>,
102+
);
103+
});
104+
105+
it('is reflected in the props of the view when set', () => {
106+
const root = Fantom.createRoot();
107+
108+
Fantom.runTask(() => {
109+
root.render(<View experimental_onSafeAreaInsetsChange={() => {}} />);
110+
});
111+
112+
expect(
113+
root
114+
.getRenderedOutput({props: ['experimental_onSafeAreaInsetsChange']})
115+
.toJSX(),
116+
).toEqual(<rn-view experimental_onSafeAreaInsetsChange="true" />);
117+
});
118+
});
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @flow strict-local
8+
* @format
9+
*/
10+
11+
import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment';
12+
13+
import type {HighResTimeStampMock} from '@react-native/fantom/src/HighResTimeStampMock';
14+
import type {HostInstance} from 'react-native';
15+
16+
import * as Fantom from '@react-native/fantom';
17+
import * as React from 'react';
18+
import {createRef} from 'react';
19+
import {View} from 'react-native';
20+
21+
const INSETS = {top: 44, right: 0, bottom: 34, left: 0};
22+
23+
function renderObservingView(): {current: HostInstance | null} {
24+
const nodeRef = createRef<HostInstance>();
25+
const root = Fantom.createRoot();
26+
Fantom.runTask(() => {
27+
root.render(
28+
<View ref={nodeRef} experimental_onSafeAreaInsetsChange={() => {}} />,
29+
);
30+
});
31+
return nodeRef;
32+
}
33+
34+
function dispatchInsetsChange(nodeRef: {current: HostInstance | null}) {
35+
Fantom.dispatchNativeEvent(nodeRef, 'safeAreaInsetsChange', {
36+
insets: INSETS,
37+
});
38+
}
39+
40+
describe('experimental_onSafeAreaInsetsChange warning', () => {
41+
const originalConsoleWarn = console.warn;
42+
let mockConsoleWarn: JestMockFn<ReadonlyArray<unknown>, void>;
43+
let mockClock: ?HighResTimeStampMock;
44+
45+
beforeEach(() => {
46+
mockConsoleWarn = jest.fn();
47+
// $FlowFixMe[cannot-write]
48+
console.warn = mockConsoleWarn;
49+
mockClock = Fantom.installHighResTimeStampMock();
50+
});
51+
52+
afterEach(() => {
53+
// $FlowFixMe[cannot-write]
54+
console.warn = originalConsoleWarn;
55+
mockClock?.uninstall();
56+
mockClock = null;
57+
});
58+
59+
it('stays silent while the insets change at a plausible rate', () => {
60+
const nodeRef = renderObservingView();
61+
62+
// A rotation, a keyboard, a split view: a handful of changes, spread out.
63+
for (let i = 0; i < 20; i++) {
64+
dispatchInsetsChange(nodeRef);
65+
mockClock?.advanceTimeBy(200);
66+
}
67+
68+
expect(mockConsoleWarn).not.toHaveBeenCalled();
69+
});
70+
71+
it('warns once when a single view loops within the window', () => {
72+
const nodeRef = renderObservingView();
73+
74+
for (let i = 0; i < 11; i++) {
75+
dispatchInsetsChange(nodeRef);
76+
mockClock?.advanceTimeBy(16);
77+
}
78+
79+
expect(mockConsoleWarn).toHaveBeenCalledTimes(1);
80+
expect(mockConsoleWarn.mock.lastCall[0]).toContain(
81+
'`experimental_onSafeAreaInsetsChange` fired more than 10 times in 1000ms',
82+
);
83+
84+
// The loop keeps running; the warning does not.
85+
for (let i = 0; i < 50; i++) {
86+
dispatchInsetsChange(nodeRef);
87+
mockClock?.advanceTimeBy(16);
88+
}
89+
90+
expect(mockConsoleWarn).toHaveBeenCalledTimes(1);
91+
});
92+
93+
it('counts each view separately', () => {
94+
const nodeRefA = renderObservingView();
95+
const nodeRefB = renderObservingView();
96+
97+
for (let i = 0; i < 10; i++) {
98+
dispatchInsetsChange(nodeRefA);
99+
dispatchInsetsChange(nodeRefB);
100+
mockClock?.advanceTimeBy(16);
101+
}
102+
103+
expect(mockConsoleWarn).not.toHaveBeenCalled();
104+
105+
dispatchInsetsChange(nodeRefA);
106+
107+
expect(mockConsoleWarn).toHaveBeenCalledTimes(1);
108+
});
109+
110+
it('still delivers the event to the handler', () => {
111+
const nodeRef = createRef<HostInstance>();
112+
const onSafeAreaInsetsChange = jest.fn();
113+
const root = Fantom.createRoot();
114+
Fantom.runTask(() => {
115+
root.render(
116+
<View
117+
ref={nodeRef}
118+
experimental_onSafeAreaInsetsChange={event => {
119+
onSafeAreaInsetsChange(event.nativeEvent);
120+
}}
121+
/>,
122+
);
123+
});
124+
125+
dispatchInsetsChange(nodeRef);
126+
127+
expect(onSafeAreaInsetsChange).toHaveBeenCalledTimes(1);
128+
expect(onSafeAreaInsetsChange.mock.lastCall[0].insets).toEqual(INSETS);
129+
});
130+
});

‎packages/react-native/Libraries/NativeComponent/BaseViewConfig.android.js‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,9 @@ const directEventTypes = {
204204
topLayout: {
205205
registrationName: 'onLayout',
206206
},
207+
topSafeAreaInsetsChange: {
208+
registrationName: 'experimental_onSafeAreaInsetsChange',
209+
},
207210
};
208211

209212
const validAttributesForNonEventProps = {
@@ -404,6 +407,7 @@ const validAttributesForNonEventProps = {
404407
// Props for bubbling and direct events
405408
const validAttributesForEventProps = {
406409
onLayout: true,
410+
experimental_onSafeAreaInsetsChange: true,
407411

408412
// PanResponder handlers
409413
onMoveShouldSetResponder: true,

‎packages/react-native/Libraries/NativeComponent/BaseViewConfig.ios.js‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,9 @@ const directEventTypes = {
179179
topLayout: {
180180
registrationName: 'onLayout',
181181
},
182+
topSafeAreaInsetsChange: {
183+
registrationName: 'experimental_onSafeAreaInsetsChange',
184+
},
182185
onGestureHandlerEvent: DynamicallyInjectedByGestureHandler({
183186
registrationName: 'onGestureHandlerEvent',
184187
}),
@@ -380,6 +383,7 @@ const validAttributesForNonEventProps = {
380383
// Props for bubbling and direct events
381384
const validAttributesForEventProps = ConditionallyIgnoredEventHandlers({
382385
onLayout: true,
386+
experimental_onSafeAreaInsetsChange: true,
383387
onMagicTap: true,
384388

385389
// Accessibility

‎packages/react-native/Libraries/Types/CoreEventTypes.js‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,23 @@ export type LayoutChangeEvent = NativeSyntheticEvent<
7676
}>,
7777
>;
7878

79+
export type SafeAreaInsets = Readonly<{
80+
top: number,
81+
right: number,
82+
bottom: number,
83+
left: number,
84+
}>;
85+
86+
export type SafeAreaInsetsChangeEvent = NativeSyntheticEvent<
87+
Readonly<{
88+
/**
89+
* The part of the view that is covered by the system UI, in the view's own
90+
* coordinate space.
91+
*/
92+
insets: SafeAreaInsets,
93+
}>,
94+
>;
95+
7996
/**
8097
* @deprecated Use `TextLayoutEvent` instead.
8198
*/

0 commit comments

Comments
 (0)