From ac2bf5820cfee1e8f09ce95512076a4451ba9a9e Mon Sep 17 00:00:00 2001 From: Hirbod Mirjavadi Date: Mon, 2 Nov 2026 06:16:52 +0100 Subject: [PATCH 1/3] fix(ios): keep SafeAreaView insets current in native sheets after rotation --- ios/Fabric/RNCSafeAreaViewComponentView.mm | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/ios/Fabric/RNCSafeAreaViewComponentView.mm b/ios/Fabric/RNCSafeAreaViewComponentView.mm index 7d4d4517..1a9604cb 100644 --- a/ios/Fabric/RNCSafeAreaViewComponentView.mm +++ b/ios/Fabric/RNCSafeAreaViewComponentView.mm @@ -91,6 +91,16 @@ - (void)didMoveToWindow } } +- (void)safeAreaInsetsDidChange +{ + [super safeAreaInsetsDidChange]; + // Without a provider above it (e.g. in a native sheet), the view is its own provider and no + // provider notification reaches it, so follow its own insets here. + if (_providerView == self) { + [self updateStateIfNecessaryMeasuringCorners:YES]; + } +} + - (void)safeAreaProviderInsetsDidChange:(NSNotification *)notification { [self updateStateIfNecessaryMeasuringCorners:YES]; @@ -179,7 +189,11 @@ - (void)updateState auto newData = oldData; newData.insets = RCTEdgeInsetsFromUIEdgeInsets(_currentSafeAreaInsets); return std::make_shared(newData); - }); + }, + // Commit the new insets with the layout pass that changed them (e.g. a rotation), instead of + // waiting for the next event beat, which leaves stale insets on screen for some frames in a + // busy app. + facebook::react::EventQueue::UpdateMode::unstable_Immediate); } #pragma mark - RCTComponentViewProtocol From 0022ef79e1a8a9342a1658cde928f2e769246be1 Mon Sep 17 00:00:00 2001 From: Hirbod Mirjavadi Date: Mon, 2 Nov 2026 13:37:00 +0100 Subject: [PATCH 2/3] fix(ios): use unstable_Immediate only where React Native has it EventQueue::UpdateMode came in React Native 0.82, so passing unstable_Immediate broke the build on 0.81 and older. Detect it at compile time and fall back to the default update. --- ios/Fabric/RNCSafeAreaViewComponentView.mm | 42 ++++++++++++++++++---- 1 file changed, 36 insertions(+), 6 deletions(-) diff --git a/ios/Fabric/RNCSafeAreaViewComponentView.mm b/ios/Fabric/RNCSafeAreaViewComponentView.mm index 1a9604cb..1f90a439 100644 --- a/ios/Fabric/RNCSafeAreaViewComponentView.mm +++ b/ios/Fabric/RNCSafeAreaViewComponentView.mm @@ -12,8 +12,38 @@ #import "RNCSafeAreaProviderComponentView.h" #import "RNCSafeAreaUtils.h" +#include +#include + using namespace facebook::react; +namespace { + +// EventQueue::UpdateMode, and with it unstable_Immediate, came in React Native 0.82. It is detected +// instead of checked against a version, so older React Native and react-native-macos (which has its +// own version numbers) still build and keep the default asynchronous update. +template +struct SupportsImmediateStateUpdate : std::false_type {}; + +template +struct SupportsImmediateStateUpdate> + : std::true_type {}; + +// Commits the state update right away where React Native supports it, the way react-native-screens +// updates its screens' state. A commit that arrives while a mount transaction is running is mounted +// after it (RCTMountingManager queues a follow-up transaction), so this does not nest mounts. +template +void updateStateImmediately(const State &state, Callback &&callback) +{ + if constexpr (SupportsImmediateStateUpdate::value) { + state->updateState(std::forward(callback), Queue::UpdateMode::unstable_Immediate); + } else { + state->updateState(std::forward(callback)); + } +} + +} // namespace + @interface RNCSafeAreaViewComponentView () @end @@ -183,17 +213,17 @@ - (void)updateState return; } - _state->updateState( + // Commit the new insets with the layout pass that changed them (e.g. a rotation), instead of + // waiting for the next event beat, which leaves stale insets on screen for some frames in a + // busy app. + updateStateImmediately( + _state, [=](RNCSafeAreaViewShadowNode::ConcreteState::Data const &oldData) -> RNCSafeAreaViewShadowNode::ConcreteState::SharedData { auto newData = oldData; newData.insets = RCTEdgeInsetsFromUIEdgeInsets(_currentSafeAreaInsets); return std::make_shared(newData); - }, - // Commit the new insets with the layout pass that changed them (e.g. a rotation), instead of - // waiting for the next event beat, which leaves stale insets on screen for some frames in a - // busy app. - facebook::react::EventQueue::UpdateMode::unstable_Immediate); + }); } #pragma mark - RCTComponentViewProtocol From 9e0134def7de3bea58662e95a59217edc766ad24 Mon Sep 17 00:00:00 2001 From: Hirbod Mirjavadi Date: Mon, 2 Nov 2026 13:37:00 +0100 Subject: [PATCH 3/3] chore(example): add a form sheet example A SafeAreaView in a native formSheet, with and without a SafeAreaProvider, plus switches for a busy JS thread and for mounting 200 SafeAreaViews, to check rotation in sheets. --- example/src/App.tsx | 8 ++ example/src/FormSheetExample.tsx | 161 +++++++++++++++++++++++++++++++ 2 files changed, 169 insertions(+) create mode 100644 example/src/FormSheetExample.tsx diff --git a/example/src/App.tsx b/example/src/App.tsx index 7b25fbd4..096e0da8 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -3,6 +3,7 @@ import { DevSettings, View, Text, StatusBar } from 'react-native'; import AsyncStorage from '@react-native-async-storage/async-storage'; import AdditionalInsetsExample from './AdditionalInsetsExample'; import CornerAdaptationExample from './CornerAdaptationExample'; +import FormSheetExample from './FormSheetExample'; import HooksExample from './HooksExample'; import ListenerExample from './ListenerExample'; import SafeAreaViewExample from './SafeAreaViewExample'; @@ -20,6 +21,7 @@ type Example = | 'corner-adaptation' | 'react-navigation' | 'native-stack' + | 'form-sheet' | 'additional-insets'; export default function App() { @@ -70,6 +72,9 @@ export default function App() { DevSettings.addMenuItem('Show Native Stack Example', () => { setCurrentExample('native-stack'); }); + DevSettings.addMenuItem('Show Form Sheet Example', () => { + setCurrentExample('form-sheet'); + }); DevSettings.addMenuItem('Show Additional Insets Example', () => { setCurrentExample('additional-insets'); }); @@ -98,6 +103,9 @@ export default function App() { case 'native-stack': content = ; break; + case 'form-sheet': + content = ; + break; case 'additional-insets': content = ; break; diff --git a/example/src/FormSheetExample.tsx b/example/src/FormSheetExample.tsx new file mode 100644 index 00000000..f929983e --- /dev/null +++ b/example/src/FormSheetExample.tsx @@ -0,0 +1,161 @@ +import * as React from 'react'; +import { + Button, + ScrollView, + StyleSheet, + Switch, + Text, + View, +} from 'react-native'; +import { NavigationContainer, useNavigation } from '@react-navigation/native'; +import { + createNativeStackNavigator, + NativeStackNavigationProp, +} from '@react-navigation/native-stack'; +import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context'; + +// A SafeAreaView in a native formSheet has no SafeAreaProvider above it in the view hierarchy, so +// it is its own provider. Rotate the device with the sheet open: the red border must stay clear of +// the notch / Dynamic Island on both sides, also when "Busy JS thread" is on. The second sheet +// wraps the same content in a SafeAreaProvider, so its SafeAreaView follows the provider instead. + +type Routes = { + Home: undefined; + Sheet: undefined; + SheetWithProvider: undefined; +}; + +const Stack = createNativeStackNavigator(); + +// Blocks the JS thread for most of every second, like a busy app. With the default asynchronous +// state update the insets then lag behind a rotation by several frames. +function useBusyJsThread(enabled: boolean) { + React.useEffect(() => { + if (!enabled) { + return; + } + const id = setInterval(() => { + const end = Date.now() + 900; + while (Date.now() < end) { + // busy + } + }, 1000); + return () => clearInterval(id); + }, [enabled]); +} + +// Mounts and unmounts many SafeAreaViews, to check that the state update stays cheap while +// they mount. +function ManySafeAreaViews() { + const [count, setCount] = React.useState(0); + React.useEffect(() => { + const id = setInterval(() => setCount((c) => (c === 0 ? 200 : 0)), 500); + return () => clearInterval(id); + }, []); + return ( + + {Array.from({ length: count }, (_, i) => ( + + ))} + + ); +} + +function HomeScreen() { + const navigation = useNavigation>(); + return ( + +