From 42878155db744fed96a3062fc8680ae6a2cdc5c5 Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Mon, 21 Sep 2026 14:43:12 -0400 Subject: [PATCH] Render the internal SafeAreaView from the safe area insets prop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The core surfaces that cannot depend on `react-native-safe-area-context` — LogBox, the element inspector, `InputAccessoryView` — get their safe area padding from a private component that until now wrapped the native `RCTSafeAreaView`. It applies the prop instead, in JavaScript. The insets come from the synchronous inset event, which is processed in the frame that mounts the view, so the first frame is already padded. Two consequences, both visible in the updated LogBox snapshots: these surfaces now apply safe area padding on Android too, where they previously fell back to a plain `View`, and they re-render when insets arrive rather than being padded natively. The native implementations are untouched here — the deprecated public `SafeAreaView` still uses them. This only moves the internal component onto the prop, so the two can be compared against each other before the native side is removed. --- .../TextInput/InputAccessoryView.js | 2 +- .../__tests__/ViewSafeAreaInsets-itest.js | 39 ++++++++++ .../LogBox/UI/LogBoxInspectorFooterButton.js | 2 +- .../LogBox/UI/LogBoxInspectorHeader.js | 14 ++-- .../LogBoxNotificationContainer-test.js.snap | 72 +++++++++++-------- packages/react-native/ReactNativeApi.d.ts | 5 +- packages/react-native/index.js.flow | 1 + .../SafeAreaView_INTERNAL_DO_NOT_USE.js | 69 ++++++++++++++---- .../__tests__/SafeAreaView-itest.js | 3 +- .../elementinspector/InspectorPanel.js | 2 +- .../RNTesterPlatformTestResultView.js | 42 ++++++++--- private/helloworld/App.tsx | 19 ++++- 12 files changed, 196 insertions(+), 74 deletions(-) rename packages/react-native/{Libraries/Components/SafeAreaView => src/private/components/safeareaview}/__tests__/SafeAreaView-itest.js (91%) diff --git a/packages/react-native/Libraries/Components/TextInput/InputAccessoryView.js b/packages/react-native/Libraries/Components/TextInput/InputAccessoryView.js index 5ded8e707aed..c9e3f4c0b97c 100644 --- a/packages/react-native/Libraries/Components/TextInput/InputAccessoryView.js +++ b/packages/react-native/Libraries/Components/TextInput/InputAccessoryView.js @@ -8,7 +8,7 @@ * @format */ -import SafeAreaView from '../../Components/SafeAreaView/SafeAreaView'; +import SafeAreaView from '../../../src/private/components/safeareaview/SafeAreaView_INTERNAL_DO_NOT_USE'; import StyleSheet, { type ColorValue, type ViewStyleProp, diff --git a/packages/react-native/Libraries/Components/View/__tests__/ViewSafeAreaInsets-itest.js b/packages/react-native/Libraries/Components/View/__tests__/ViewSafeAreaInsets-itest.js index bfbd1a24515f..007b51149526 100644 --- a/packages/react-native/Libraries/Components/View/__tests__/ViewSafeAreaInsets-itest.js +++ b/packages/react-native/Libraries/Components/View/__tests__/ViewSafeAreaInsets-itest.js @@ -12,6 +12,7 @@ import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; import type {HostInstance} from 'react-native'; +import SafeAreaView from '../../../../src/private/components/safeareaview/SafeAreaView_INTERNAL_DO_NOT_USE'; import * as Fantom from '@react-native/fantom'; import * as React from 'react'; import {createRef} from 'react'; @@ -116,3 +117,41 @@ describe('experimental_onSafeAreaInsetsChange', () => { ).toEqual(); }); }); + +describe('', () => { + it('applies the insets it receives as padding', () => { + const root = Fantom.createRoot(); + const nodeRef = createRef(); + + Fantom.runTask(() => { + root.render(); + }); + + expect( + root + .getRenderedOutput({ + props: ['paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft'], + }) + .toJSX(), + ).toEqual(); + + Fantom.dispatchNativeEvent(nodeRef, 'safeAreaInsetsChange', { + insets: INSETS, + }); + + expect( + root + .getRenderedOutput({ + props: ['paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft'], + }) + .toJSX(), + ).toEqual( + , + ); + }); +}); diff --git a/packages/react-native/Libraries/LogBox/UI/LogBoxInspectorFooterButton.js b/packages/react-native/Libraries/LogBox/UI/LogBoxInspectorFooterButton.js index 3db512ed3d43..3a3d5da97792 100644 --- a/packages/react-native/Libraries/LogBox/UI/LogBoxInspectorFooterButton.js +++ b/packages/react-native/Libraries/LogBox/UI/LogBoxInspectorFooterButton.js @@ -8,7 +8,7 @@ * @format */ -import SafeAreaView from '../../Components/SafeAreaView/SafeAreaView'; +import SafeAreaView from '../../../src/private/components/safeareaview/SafeAreaView_INTERNAL_DO_NOT_USE'; import View from '../../Components/View/View'; import StyleSheet from '../../StyleSheet/StyleSheet'; import Text from '../../Text/Text'; diff --git a/packages/react-native/Libraries/LogBox/UI/LogBoxInspectorHeader.js b/packages/react-native/Libraries/LogBox/UI/LogBoxInspectorHeader.js index a5b5329d440e..06569cab0d4c 100644 --- a/packages/react-native/Libraries/LogBox/UI/LogBoxInspectorHeader.js +++ b/packages/react-native/Libraries/LogBox/UI/LogBoxInspectorHeader.js @@ -8,10 +8,9 @@ * @format */ -import type {ViewProps} from '../../Components/View/ViewPropTypes'; import type {LogLevel} from '../Data/LogBoxLog'; -import SafeAreaView from '../../Components/SafeAreaView/SafeAreaView'; +import SafeAreaView from '../../../src/private/components/safeareaview/SafeAreaView_INTERNAL_DO_NOT_USE'; import View from '../../Components/View/View'; import StyleSheet from '../../StyleSheet/StyleSheet'; import Text from '../../Text/Text'; @@ -27,13 +26,10 @@ type Props = Readonly<{ level: LogLevel, }>; -const LogBoxInspectorHeaderSafeArea: React.ComponentType = - Platform.OS === 'android' ? View : SafeAreaView; - export default function LogBoxInspectorHeader(props: Props): React.Node { if (props.level === 'syntax') { return ( - + - + ); } @@ -56,7 +52,7 @@ export default function LogBoxInspectorHeader(props: Props): React.Node { const titleText = `Log ${props.selectedIndex + 1} of ${props.total}`; return ( - + props.onSelectIndex(nextIndex)} /> - + ); } diff --git a/packages/react-native/Libraries/LogBox/__tests__/__snapshots__/LogBoxNotificationContainer-test.js.snap b/packages/react-native/Libraries/LogBox/__tests__/__snapshots__/LogBoxNotificationContainer-test.js.snap index 6799302e9474..7c65b0a79669 100644 --- a/packages/react-native/Libraries/LogBox/__tests__/__snapshots__/LogBoxNotificationContainer-test.js.snap +++ b/packages/react-native/Libraries/LogBox/__tests__/__snapshots__/LogBoxNotificationContainer-test.js.snap @@ -1,16 +1,20 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`LogBoxNotificationContainer should render both an error and warning notification 1`] = ` - - + `; exports[`LogBoxNotificationContainer should render null with no logs 1`] = `null`; @@ -119,16 +123,20 @@ exports[`LogBoxNotificationContainer should render selected fatal error even whe exports[`LogBoxNotificationContainer should render selected syntax error even when disabled 1`] = `null`; exports[`LogBoxNotificationContainer should render the latest error notification 1`] = ` - - + `; exports[`LogBoxNotificationContainer should render the latest warning notification 1`] = ` - - + `; diff --git a/packages/react-native/ReactNativeApi.d.ts b/packages/react-native/ReactNativeApi.d.ts index cb67856cb4b3..7d194d4db35b 100644 --- a/packages/react-native/ReactNativeApi.d.ts +++ b/packages/react-native/ReactNativeApi.d.ts @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<2ceaa8e472a53fc667994edb63ccd995>> + * @generated SignedSource<<1faa2820bf867aa599cdd390c5c90b00>> * * This file was generated by scripts/js-api/build-types/index.js. */ @@ -5968,7 +5968,8 @@ export { RootViewStyleProvider, // d5909148 Runnable, // 594dd93a Runnables, // 4367c557 - SafeAreaInsetsChangeEvent, // cedb5309 + SafeAreaInsets, // 64d7a605 + SafeAreaInsetsChangeEvent, // 4ffd35e5 SafeAreaView, // d917b6ec SafeAreaViewInstance, // 2b5d4391 ScaledSize, // 07e417c7 diff --git a/packages/react-native/index.js.flow b/packages/react-native/index.js.flow index 3d015144404b..8dadcac64496 100644 --- a/packages/react-native/index.js.flow +++ b/packages/react-native/index.js.flow @@ -485,6 +485,7 @@ export type { NativeTouchEvent, NativeUIEvent, ResponderSyntheticEvent, + SafeAreaInsets, SafeAreaInsetsChangeEvent, ScrollEvent, TargetedEvent, diff --git a/packages/react-native/src/private/components/safeareaview/SafeAreaView_INTERNAL_DO_NOT_USE.js b/packages/react-native/src/private/components/safeareaview/SafeAreaView_INTERNAL_DO_NOT_USE.js index e6b5cdd5f522..0ea40f01bbf6 100644 --- a/packages/react-native/src/private/components/safeareaview/SafeAreaView_INTERNAL_DO_NOT_USE.js +++ b/packages/react-native/src/private/components/safeareaview/SafeAreaView_INTERNAL_DO_NOT_USE.js @@ -9,23 +9,62 @@ */ import type {ViewProps} from '../../../../Libraries/Components/View/ViewPropTypes'; +import type { + SafeAreaInsets, + SafeAreaInsetsChangeEvent, +} from '../../../../Libraries/Types/CoreEventTypes'; +import type {HostInstance} from '../../types/HostInstance'; import View from '../../../../Libraries/Components/View/View'; -import UIManager from '../../../../Libraries/ReactNative/UIManager'; -import Platform from '../../../../Libraries/Utilities/Platform'; +import I18nManager from '../../../../Libraries/ReactNative/I18nManager'; import * as React from 'react'; +import {useCallback, useMemo, useState} from 'react'; -const exported: component( - ref?: React.RefSetter>, - ...ViewProps -) = Platform.select({ - ios: require('../../../../src/private/components/safeareaview/specs/RCTSafeAreaViewNativeComponent') - .default, - android: UIManager.hasViewManagerConfig('RCTSafeAreaView') - ? require('../../../../src/private/components/safeareaview/specs/RCTSafeAreaViewNativeComponent') - .default - : View, - default: View, -}); +/** + * Renders its children within the safe area of the device, by applying the part + * of the view that is covered by the system UI as padding. + */ +component SafeAreaView( + ref?: React.RefSetter, + ...props: ViewProps +) { + const {style, experimental_onSafeAreaInsetsChange, ...otherProps} = props; + const [insets, setInsets] = useState(null); + + const handleSafeAreaInsetsChange = useCallback( + (event: SafeAreaInsetsChangeEvent) => { + setInsets(event.nativeEvent.insets); + experimental_onSafeAreaInsetsChange?.(event); + }, + [experimental_onSafeAreaInsetsChange], + ); + + const paddingStyle = useMemo(() => { + if (insets == null) { + return null; + } + // Insets are physical edges, but Yoga remaps paddingLeft/paddingRight to + // start/end when I18nManager's swapLeftAndRightInRTL is on, which would + // pad the mirror-image edge in RTL. Swap the values so the physical edge + // keeps its inset. + const {isRTL, doLeftAndRightSwapInRTL} = I18nManager.getConstants(); + const swap = isRTL && doLeftAndRightSwapInRTL; + return { + paddingTop: insets.top, + paddingRight: swap ? insets.left : insets.right, + paddingBottom: insets.bottom, + paddingLeft: swap ? insets.right : insets.left, + }; + }, [insets]); + + return ( + + ); +} -export default exported; +export default SafeAreaView; diff --git a/packages/react-native/Libraries/Components/SafeAreaView/__tests__/SafeAreaView-itest.js b/packages/react-native/src/private/components/safeareaview/__tests__/SafeAreaView-itest.js similarity index 91% rename from packages/react-native/Libraries/Components/SafeAreaView/__tests__/SafeAreaView-itest.js rename to packages/react-native/src/private/components/safeareaview/__tests__/SafeAreaView-itest.js index 0fcae9f2e282..cb84b97d23e7 100644 --- a/packages/react-native/Libraries/Components/SafeAreaView/__tests__/SafeAreaView-itest.js +++ b/packages/react-native/src/private/components/safeareaview/__tests__/SafeAreaView-itest.js @@ -10,9 +10,10 @@ import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; +import SafeAreaView from '../SafeAreaView_INTERNAL_DO_NOT_USE'; import * as Fantom from '@react-native/fantom'; import * as React from 'react'; -import {SafeAreaView, Text} from 'react-native'; +import {Text} from 'react-native'; describe('', () => { it('renders with children', () => { diff --git a/packages/react-native/src/private/devsupport/devmenu/elementinspector/InspectorPanel.js b/packages/react-native/src/private/devsupport/devmenu/elementinspector/InspectorPanel.js index 9e5d04f30038..b4195f431486 100644 --- a/packages/react-native/src/private/devsupport/devmenu/elementinspector/InspectorPanel.js +++ b/packages/react-native/src/private/devsupport/devmenu/elementinspector/InspectorPanel.js @@ -12,7 +12,7 @@ import type {ElementsHierarchy, InspectedElement} from './Inspector'; -import SafeAreaView from '../../../../../Libraries/Components/SafeAreaView/SafeAreaView'; +import SafeAreaView from '../../../components/safeareaview/SafeAreaView_INTERNAL_DO_NOT_USE'; import * as React from 'react'; const ScrollView = diff --git a/packages/rn-tester/js/examples/Experimental/PlatformTest/RNTesterPlatformTestResultView.js b/packages/rn-tester/js/examples/Experimental/PlatformTest/RNTesterPlatformTestResultView.js index c0f0e2b06282..6821dca645b7 100644 --- a/packages/rn-tester/js/examples/Experimental/PlatformTest/RNTesterPlatformTestResultView.js +++ b/packages/rn-tester/js/examples/Experimental/PlatformTest/RNTesterPlatformTestResultView.js @@ -12,7 +12,12 @@ import type { PlatformTestResult, PlatformTestResultStatus, } from './RNTesterPlatformTestTypes'; -import type {ListRenderItemInfo, TextStyle} from 'react-native'; +import type { + ListRenderItemInfo, + SafeAreaInsets, + SafeAreaInsetsChangeEvent, + TextStyle, +} from 'react-native'; import RNTesterPlatformTestMinimizedResultView from './RNTesterPlatformTestMinimizedResultView'; import RNTesterPlatformTestResultsText from './RNTesterPlatformTestResultsText'; @@ -24,7 +29,6 @@ import { KeyboardAvoidingView, Modal, Platform, - SafeAreaView, StyleSheet, Switch, Text, @@ -87,7 +91,7 @@ function FilterModalButton(props: FilterModalProps) { animationType="fade" presentationStyle="overFullScreen" transparent={true}> - + @@ -123,7 +127,7 @@ function FilterModalButton(props: FilterModalProps) { - + ); @@ -230,6 +234,13 @@ export default function RNTesterPlatformTestResultView( ); const [resultsExpanded, setResultsExpanded] = useState(false); + const [safeAreaInsets, setSafeAreaInsets] = useState(null); + const handleSafeAreaInsetsChange = useCallback( + (event: SafeAreaInsetsChangeEvent) => { + setSafeAreaInsets(event.nativeEvent.insets); + }, + [], + ); const handleReset = useCallback(() => { setFilterFailStatus(false); @@ -266,12 +277,21 @@ export default function RNTesterPlatformTestResultView( animationType="slide" onRequestClose={handleMaximizedPress} visible={resultsExpanded}> - + Results @@ -308,7 +328,7 @@ export default function RNTesterPlatformTestResultView( - + ); diff --git a/private/helloworld/App.tsx b/private/helloworld/App.tsx index e692a7dd84d1..19c0a8358ff8 100644 --- a/private/helloworld/App.tsx +++ b/private/helloworld/App.tsx @@ -8,8 +8,9 @@ */ import * as React from 'react'; +import {useState} from 'react'; import { - SafeAreaView, + type SafeAreaInsets, ScrollView, StatusBar, StyleSheet, @@ -20,9 +21,21 @@ import { function App(): React.ReactNode { const isDarkMode = useColorScheme() === 'dark'; + const [insets, setInsets] = useState(null); return ( - + + setInsets(event.nativeEvent.insets) + }> @@ -31,7 +44,7 @@ function App(): React.ReactNode { - + ); }