Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
ebee670
Process synchronous event beats in the frame that requested them
janicduplessis Aug 24, 2026
dc6df95
Add an experimental_onSafeAreaInsetsChange view prop
janicduplessis Aug 24, 2026
e71752a
Report the window safe area insets through Dimensions
janicduplessis Aug 24, 2026
a59f60a
Warn in development when a view reports its safe area insets in a loop
janicduplessis Aug 24, 2026
295250f
Render the internal SafeAreaView from the safe area insets prop
janicduplessis Aug 24, 2026
1c9df57
Remove the native SafeAreaView and the deprecated public export
janicduplessis Aug 24, 2026
dd5c6d7
Make the stranded-synchronous-request test deterministic
janicduplessis Sep 14, 2026
33c82f5
Merge branch 'safe-area/1-event-beat-same-frame' into safe-area/2-saf…
janicduplessis Sep 14, 2026
023822a
Forward the safe area insets prop through BaseViewManagerDelegate
janicduplessis Sep 14, 2026
da2f52f
Merge branch 'safe-area/2-safe-area-insets-prop' into safe-area/3-win…
janicduplessis Sep 14, 2026
ecd88ba
Deliver inset-only window changes, and omit unmeasured insets on iOS
janicduplessis Sep 14, 2026
e97d630
Merge branch 'safe-area/3-window-insets-in-dimensions' into safe-area…
janicduplessis Sep 14, 2026
c25998b
Merge branch 'safe-area/4-warn-on-inset-loops' into safe-area/5-js-sa…
janicduplessis Sep 14, 2026
2d13fb6
Keep safe area padding on physical edges in RTL layouts
janicduplessis Sep 14, 2026
0ccebcb
Merge branch 'safe-area/5-js-safe-area-view' into safe-area/6-remove-…
janicduplessis Sep 14, 2026
0dfdc51
Migrate the HelloWorld app off the removed SafeAreaView export
janicduplessis Sep 14, 2026
6194fcc
Keep induce protected; drive it from a test subclass
janicduplessis Sep 15, 2026
fa3d84c
Merge branch 'safe-area/1-event-beat-same-frame' into safe-area/6-rem…
janicduplessis Sep 15, 2026
b9441c1
Keep the induce comment as it was
janicduplessis Sep 15, 2026
e188a19
Merge branch 'safe-area/1-event-beat-same-frame' into safe-area/6-rem…
janicduplessis Sep 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 0 additions & 4 deletions packages/eslint-plugin-react-native/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -109,10 +109,6 @@ const publicAPIMapping = {
'RefreshControlPropsIOS',
],
},
'Libraries/Components/SafeAreaView/SafeAreaView': {
default: 'SafeAreaView',
types: null,
},
'Libraries/Components/ScrollView/ScrollView': {
default: 'ScrollView',
types: [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ module.exports = new Set([
'Pressable',
'ProgressBarAndroid',
'ProgressViewIOS',
'SafeAreaView',
'ScrollView',
'SectionList',
'Slider',
Expand Down

This file was deleted.

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
* @format
*/

import SafeAreaView from '../../Components/SafeAreaView/SafeAreaView';
import SafeAreaView from '../../../src/private/components/safeareaview/SafeAreaView';
import StyleSheet, {
type ColorValue,
type ViewStyleProp,
Expand Down
22 changes: 22 additions & 0 deletions packages/react-native/Libraries/Components/View/View.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,23 @@
*/

import type {HostInstance} from '../../../src/private/types/HostInstance';
import type {SafeAreaInsetsChangeEvent} from '../../Types/CoreEventTypes';
import type {ViewProps} from './ViewPropTypes';

import TextAncestorContext from '../../Text/TextAncestorContext';
import ViewNativeComponent from './ViewNativeComponent';
import * as React from 'react';
import {use} from 'react';

// Only development builds check for a view reporting its insets in a loop; the
// production branch keeps the handler as it is, and the module out of the bundle.
const warnOnRepeatedSafeAreaInsetsChanges: (
onSafeAreaInsetsChange: (event: SafeAreaInsetsChangeEvent) => unknown,
) => (event: SafeAreaInsetsChangeEvent) => unknown = __DEV__
? require('../../../src/private/components/view/warnOnRepeatedSafeAreaInsetsChanges')
.default
: onSafeAreaInsetsChange => onSafeAreaInsetsChange;

export type ViewInstance = HostInstance;

/**
Expand Down Expand Up @@ -115,6 +125,18 @@ component View(ref?: React.RefSetter<ViewInstance>, ...props: ViewProps) {
};
}

if (__DEV__) {
// Views are the only place the prop is used in practice, so the check for a
// view reporting its insets in a loop lives here rather than on every host
// component that inherits the prop.
const onSafeAreaInsetsChange =
resolvedProps.experimental_onSafeAreaInsetsChange;
if (onSafeAreaInsetsChange != null) {
resolvedProps.experimental_onSafeAreaInsetsChange =
warnOnRepeatedSafeAreaInsetsChanges(onSafeAreaInsetsChange);
}
}

const actualView =
ref == null ? (
<ViewNativeComponent {...resolvedProps} />
Expand Down
27 changes: 27 additions & 0 deletions packages/react-native/Libraries/Components/View/ViewPropTypes.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import type {
LayoutRectangle,
MouseEvent,
PointerEvent,
SafeAreaInsetsChangeEvent,
} from '../../Types/CoreEventTypes';
import type {
AccessibilityActionEvent,
Expand Down Expand Up @@ -63,6 +64,32 @@ type DirectEventProps = Readonly<{
*/
onLayout?: ?(event: LayoutChangeEvent) => unknown,

/**
* Invoked when the part of this view that is covered by the system UI
* (status bar, navigation bar, home indicator, display cutouts, ...)
* changes, with:
*
* `{nativeEvent: {insets: {top, right, bottom, left}, frame: {x, y, width, height}}}`
*
* `insets` are relative to this view: an inset is only non-zero for the part
* of the view that actually overlaps the system UI. `frame` is the position
* of the view at the time of the event, relative to its enclosing view
* controller on iOS and to the window on Android; it does not trigger the
* event on its own, so it can be stale while the view moves without its
* insets changing.
*
* The event is dispatched synchronously, so the rendering it schedules is
* applied in the same frame the insets changed in.
*
* Setting this prop makes the view observe safe area changes; views without
* it are unaffected.
*
* @experimental
*/
experimental_onSafeAreaInsetsChange?: ?(
event: SafeAreaInsetsChangeEvent,
) => unknown,

/**
* When `accessible` is `true`, the system will invoke this function when the
* user performs the magic tap gesture.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow strict-local
* @format
*/

import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment';

import type {HostInstance} from 'react-native/src/private/types/HostInstance';

import * as Fantom from '@react-native/fantom';
import * as React from 'react';
import {createRef} from 'react';
import {View} from 'react-native';
import SafeAreaView from 'react-native/src/private/components/safeareaview/SafeAreaView';

const INSETS = {top: 44, right: 0, bottom: 34, left: 0};
const FRAME = {x: 0, y: 0, width: 390, height: 844};

describe('experimental_onSafeAreaInsetsChange', () => {
it('delivers the insets and the frame of the view', () => {
const root = Fantom.createRoot();
const nodeRef = createRef<HostInstance>();
const onSafeAreaInsetsChange = jest.fn();

Fantom.runTask(() => {
root.render(
<View
collapsable={false}
ref={nodeRef}
experimental_onSafeAreaInsetsChange={event => {
onSafeAreaInsetsChange(event.nativeEvent);
}}
/>,
);
});

Fantom.dispatchNativeEvent(nodeRef, 'safeAreaInsetsChange', {
insets: INSETS,
frame: FRAME,
});

expect(onSafeAreaInsetsChange).toHaveBeenCalledTimes(1);
const [event] = onSafeAreaInsetsChange.mock.lastCall;
expect(event.insets).toEqual(INSETS);
expect(event.frame).toEqual(FRAME);
});

it('is not delivered to views that did not opt in', () => {
const root = Fantom.createRoot();
const nodeRef = createRef<HostInstance>();

Fantom.runTask(() => {
root.render(<View collapsable={false} ref={nodeRef} />);
});

// The prop is what makes the view observe the safe area, so a view without
// it is never the target of the event.
expect(
root
.getRenderedOutput({props: ['experimental_onSafeAreaInsetsChange']})
.toJSX(),
).toEqual(<rn-view />);
});

it('prevents the view from being flattened', () => {
const root = Fantom.createRoot();

Fantom.runTask(() => {
root.render(
// A layout-only view would ordinarily be flattened away; observing the
// safe area requires a host view to observe with.
<View experimental_onSafeAreaInsetsChange={() => {}}>
<View collapsable={false} />
</View>,
);
});

expect(
root
.getRenderedOutput({props: ['experimental_onSafeAreaInsetsChange']})
.toJSX(),
).toEqual(
<rn-view experimental_onSafeAreaInsetsChange="true">
<rn-view />
</rn-view>,
);
});

it('is reflected in the props of the view when set', () => {
const root = Fantom.createRoot();

Fantom.runTask(() => {
root.render(
<View
collapsable={false}
experimental_onSafeAreaInsetsChange={() => {}}
/>,
);
});

expect(
root
.getRenderedOutput({props: ['experimental_onSafeAreaInsetsChange']})
.toJSX(),
).toEqual(<rn-view experimental_onSafeAreaInsetsChange="true" />);
});
});

describe('<SafeAreaView>', () => {
it('applies the insets it receives as padding', () => {
const root = Fantom.createRoot();
const nodeRef = createRef<HostInstance>();

Fantom.runTask(() => {
root.render(<SafeAreaView collapsable={false} ref={nodeRef} />);
});

expect(
root
.getRenderedOutput({
props: ['paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft'],
})
.toJSX(),
).toEqual(<rn-view />);

Fantom.dispatchNativeEvent(nodeRef, 'safeAreaInsetsChange', {
insets: INSETS,
frame: FRAME,
});

expect(
root
.getRenderedOutput({
props: ['paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft'],
})
.toJSX(),
).toEqual(
<rn-view
paddingBottom="34"
paddingLeft="0"
paddingRight="0"
paddingTop="44"
/>,
);
});
});
Loading