From 24e7f604724c679e1d6d6183fc9087770bf3cfba Mon Sep 17 00:00:00 2001 From: paufau Date: Wed, 2 Sep 2026 14:34:45 +0400 Subject: [PATCH 1/2] Fix crash on close before render --- .../java/com/multiplemodals/RNTModalView.kt | 2 +- example/demo-components/src/DemoScreen.tsx | 57 ++++++++------ .../demo-components/src/modals.config.json | 8 ++ .../src/modals/flash-modal/FlashModal.tsx | 74 +++++++++++++++++++ example/e2e/flows/flash_dismiss.yaml | 30 ++++++++ .../RNTModalViewController.m | 24 ++++-- 6 files changed, 164 insertions(+), 31 deletions(-) create mode 100644 example/demo-components/src/modals/flash-modal/FlashModal.tsx create mode 100644 example/e2e/flows/flash_dismiss.yaml diff --git a/android/src/main/java/com/multiplemodals/RNTModalView.kt b/android/src/main/java/com/multiplemodals/RNTModalView.kt index a69d2c5..5ce6135 100644 --- a/android/src/main/java/com/multiplemodals/RNTModalView.kt +++ b/android/src/main/java/com/multiplemodals/RNTModalView.kt @@ -174,7 +174,7 @@ class RNTModalView(context: Context): ViewGroup(context), LifecycleEventListener return modalView.childCount } - override fun getChildAt(index: Int): View { + override fun getChildAt(index: Int): View? { return modalView.getChildAt(index) } diff --git a/example/demo-components/src/DemoScreen.tsx b/example/demo-components/src/DemoScreen.tsx index eee3f47..cc2f93e 100644 --- a/example/demo-components/src/DemoScreen.tsx +++ b/example/demo-components/src/DemoScreen.tsx @@ -1,22 +1,23 @@ -import {ComponentType, useCallback, useMemo, useState} from 'react'; -import {ScrollView, StyleSheet, View} from 'react-native'; -import {SafeAreaView} from 'react-native-safe-area-context'; -import {ScenarioCard} from './components/scenario-card/ScenarioCard'; -import {Typography} from './components/typography/Typography'; -import {AnimatedFadeModal} from './modals/animated-fade/AnimatedFadeModal'; -import {BlockingModal} from './modals/blocking/BlockingModal'; -import {BlurredModal} from './modals/blurred/BlurredModal'; -import {DefaultModal} from './modals/default/DefaultModal'; -import {FullScreenNoBackgroundModal} from './modals/full-screen-no-bg/FullScreenNoBackgroundModal'; -import {GesturedModal} from './modals/gestured/GesturedModal'; -import {InBottomTabsModal} from './modals/in-bottom-tabs-modal/InBottomTabsModal'; -import {ReanimatedModal} from './modals/reanimated/ReanimatedModal'; -import {SimpleModal} from './modals/simple/SimpleModal'; -import {AnimatedSlideModal} from './modals/slide/AnimatedSlideModal'; -import {WithNavigationInsideModal} from './modals/with-navigation-inside/WithNavigationInsideModal'; -import {useTheme} from './theme/colors'; -import {IS_FABRIC} from './constants'; -import {EmbeddedModal} from './modals/embedded/EmbeddedModal'; +import { ComponentType, useCallback, useMemo, useState } from 'react'; +import { ScrollView, StyleSheet, View } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { ScenarioCard } from './components/scenario-card/ScenarioCard'; +import { Typography } from './components/typography/Typography'; +import { AnimatedFadeModal } from './modals/animated-fade/AnimatedFadeModal'; +import { BlockingModal } from './modals/blocking/BlockingModal'; +import { BlurredModal } from './modals/blurred/BlurredModal'; +import { DefaultModal } from './modals/default/DefaultModal'; +import { FullScreenNoBackgroundModal } from './modals/full-screen-no-bg/FullScreenNoBackgroundModal'; +import { GesturedModal } from './modals/gestured/GesturedModal'; +import { InBottomTabsModal } from './modals/in-bottom-tabs-modal/InBottomTabsModal'; +import { ReanimatedModal } from './modals/reanimated/ReanimatedModal'; +import { SimpleModal } from './modals/simple/SimpleModal'; +import { AnimatedSlideModal } from './modals/slide/AnimatedSlideModal'; +import { WithNavigationInsideModal } from './modals/with-navigation-inside/WithNavigationInsideModal'; +import { useTheme } from './theme/colors'; +import { IS_FABRIC } from './constants'; +import { EmbeddedModal } from './modals/embedded/EmbeddedModal'; +import { FlashModal } from './modals/flash-modal/FlashModal'; type DemoCase = { id: string; @@ -28,7 +29,7 @@ type DemoCase = { }; export const DemoScreen = () => { - const {colors} = useTheme(); + const { colors } = useTheme(); const [activeCases, setActiveCases] = useState([]); @@ -130,6 +131,12 @@ export const DemoScreen = () => { description: 'A modal that contains a navigation stack inside it.', Component: WithNavigationInsideModal, }, + { + id: 'flash-modal', + title: 'Flash Modal', + description: 'Tap Flash: a submodal is dismissed before it renders', + Component: FlashModal, + }, ], [openModal], ); @@ -163,16 +170,18 @@ export const DemoScreen = () => { return ( + style={[styles.safeArea, { backgroundColor: colors.background }]} + > - + contentContainerStyle={styles.scrollContent} + > + Current Architecture: {IS_FABRIC ? 'Fabric 🚀' : 'Paper ✈️'} - {demoCases.map(({title, description, id}) => ( + {demoCases.map(({ title, description, id }) => ( = ({ + onRequestDismiss, + testID, + title, +}) => { + const { colors } = useTheme(); + const [isLoading, setLoading] = useState(false); + + const flash = () => { + setLoading(true); + setTimeout(() => setLoading(false), 0); + }; + + return ( + + + + {title} + + + + + + + + {isLoading ? ( + + + + ) : null} + + ); +}; + +const styles = StyleSheet.create({ + contentContainer: { alignItems: 'center', justifyContent: 'center' }, + modal: { + width: '80%', + borderRadius: 24, + borderWidth: 1, + padding: 20, + gap: 16, + }, + title: { + textAlign: 'center', + fontSize: 26, + lineHeight: 30, + fontWeight: '500', + }, + buttons: { gap: 16 }, +}); diff --git a/example/e2e/flows/flash_dismiss.yaml b/example/e2e/flows/flash_dismiss.yaml new file mode 100644 index 0000000..c3a21e2 --- /dev/null +++ b/example/e2e/flows/flash_dismiss.yaml @@ -0,0 +1,30 @@ +appId: ${APP_ID} +env: + TEST_NAME: flash_dismiss +--- +# Regression guard for the "dismiss before first render" crash. Flashing a +# submodal (open, then dismiss on the next tick before it renders a frame) +- scrollUntilVisible: + direction: up + element: + id: screen-top +- scrollUntilVisible: + element: + id: ${MODAL_ID}-open-button + centerElement: true +- tapOn: + id: ${MODAL_ID}-open-button +- assertVisible: + id: ${MODAL_ID}-flash-button +- repeat: + times: 10 + commands: + - tapOn: + id: ${MODAL_ID}-flash-button +# If any flash crashed the app, the button is gone and this assertion fails. +- assertVisible: + id: ${MODAL_ID}-flash-button +- tapOn: + id: ${MODAL_ID}-close-button +- assertNotVisible: + id: ${MODAL_ID}-close-button diff --git a/ios/Library/RNTModalViewController/RNTModalViewController.m b/ios/Library/RNTModalViewController/RNTModalViewController.m index d581919..a0a9c33 100644 --- a/ios/Library/RNTModalViewController/RNTModalViewController.m +++ b/ios/Library/RNTModalViewController/RNTModalViewController.m @@ -22,6 +22,10 @@ - (instancetype)initWithDelegate:(id)delegate { } - (void)setupReactSubview:(UIView *)subview { + if (!self.reactSubviewContainer || !self.view) { + return; + } + [self.view addSubview:self.reactSubviewContainer]; self.reactSubviewContainer.translatesAutoresizingMaskIntoConstraints = NO; @@ -65,16 +69,24 @@ - (void)presentOn:(UIViewController *)parentVC onView:(UIView *)parentView { - (void)dismiss { UIView *prevReactSubviewContainer = self.reactSubviewContainer; - self.reactSubviewContainer = [self.reactSubviewContainer snapshotViewAfterScreenUpdates:NO]; - [prevReactSubviewContainer removeFromSuperview]; + UIView *snapshot = [self.reactSubviewContainer snapshotViewAfterScreenUpdates:NO]; - [self setupReactSubview:self.reactSubviewContainer]; - [self.outAnimation prepareAnimation:self.reactSubviewContainer]; - [self.outAnimation animate:self.reactSubviewContainer completion:^(BOOL finished) { + if (snapshot) { + self.reactSubviewContainer = snapshot; + [prevReactSubviewContainer removeFromSuperview]; + + [self setupReactSubview:self.reactSubviewContainer]; + [self.outAnimation prepareAnimation:self.reactSubviewContainer]; + [self.outAnimation animate:self.reactSubviewContainer completion:^(BOOL finished) { + [self willMoveToParentViewController:nil]; + [self.view removeFromSuperview]; + [self removeFromParentViewController]; + }]; + } else { [self willMoveToParentViewController:nil]; [self.view removeFromSuperview]; [self removeFromParentViewController]; - }]; + } } - (void)addReactSubview:(UIView *)view { From 7ce716e8b29f370ddad6a5eafe69ce88080637c7 Mon Sep 17 00:00:00 2001 From: paufau Date: Wed, 2 Sep 2026 16:14:55 +0400 Subject: [PATCH 2/2] Add automatic integration with safe-area-context to support SafeAreaView and insets without additional overhead --- example/demo-components/src/DemoScreen.tsx | 8 +++ .../demo-components/src/modals.config.json | 8 +++ .../modals/edge-to-edge/EdgeToEdgeModal.tsx | 61 +++++++++++++++++ src/ModalView.tsx | 67 ++++++++++--------- src/integrations/SafeAreaProvider.tsx | 29 ++++++++ 5 files changed, 141 insertions(+), 32 deletions(-) create mode 100644 example/demo-components/src/modals/edge-to-edge/EdgeToEdgeModal.tsx create mode 100644 src/integrations/SafeAreaProvider.tsx diff --git a/example/demo-components/src/DemoScreen.tsx b/example/demo-components/src/DemoScreen.tsx index cc2f93e..21efac5 100644 --- a/example/demo-components/src/DemoScreen.tsx +++ b/example/demo-components/src/DemoScreen.tsx @@ -18,6 +18,7 @@ import { useTheme } from './theme/colors'; import { IS_FABRIC } from './constants'; import { EmbeddedModal } from './modals/embedded/EmbeddedModal'; import { FlashModal } from './modals/flash-modal/FlashModal'; +import { EdgeToEdgeModal } from './modals/edge-to-edge/EdgeToEdgeModal'; type DemoCase = { id: string; @@ -137,6 +138,13 @@ export const DemoScreen = () => { description: 'Tap Flash: a submodal is dismissed before it renders', Component: FlashModal, }, + { + id: 'edge-to-edge', + title: 'Edge To Edge', + description: + 'Content stays within the safe area while the backdrop covers system bars.', + Component: EdgeToEdgeModal, + }, ], [openModal], ); diff --git a/example/demo-components/src/modals.config.json b/example/demo-components/src/modals.config.json index 487f075..3fb6ea1 100644 --- a/example/demo-components/src/modals.config.json +++ b/example/demo-components/src/modals.config.json @@ -103,6 +103,14 @@ "e2e": { "scenarios": ["flash_dismiss"] } + }, + { + "id": "edge-to-edge", + "title": "Edge To Edge", + "description": "A full-screen modal whose content stays within the safe area while the backdrop covers the system bars.", + "e2e": { + "scenarios": ["simple_dismiss_by_button"] + } } ] } \ No newline at end of file diff --git a/example/demo-components/src/modals/edge-to-edge/EdgeToEdgeModal.tsx b/example/demo-components/src/modals/edge-to-edge/EdgeToEdgeModal.tsx new file mode 100644 index 0000000..a2f932c --- /dev/null +++ b/example/demo-components/src/modals/edge-to-edge/EdgeToEdgeModal.tsx @@ -0,0 +1,61 @@ +import { FC } from 'react'; +import { StyleSheet, Text, View } from 'react-native'; +import { ModalView } from 'react-native-multiple-modals'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { AlertContent } from '../../components/alert-content/AlertContent'; +import { BaseModalProps } from '../BaseModal'; + +type EdgeToEdgeModalProps = BaseModalProps; + +export const EdgeToEdgeModal: FC = props => { + return ( + + + + ↑ TOP EDGE ↑ + + + + + + ↓ BOTTOM EDGE ↓ + + + + ); +}; + +const styles = StyleSheet.create({ + contentContainer: { + flex: 1, + }, + body: { + flex: 1, + justifyContent: 'space-between', + alignItems: 'center', + }, + edge: { + alignSelf: 'stretch', + paddingVertical: 18, + alignItems: 'center', + backgroundColor: '#e53935', + }, + topEdge: {}, + bottomEdge: {}, + edgeText: { + color: 'white', + fontWeight: '700', + fontSize: 16, + }, +}); diff --git a/src/ModalView.tsx b/src/ModalView.tsx index bdac54b..3fc2923 100644 --- a/src/ModalView.tsx +++ b/src/ModalView.tsx @@ -5,6 +5,7 @@ import { Platform, Pressable, StatusBar, StyleSheet, View } from 'react-native'; import { LayoutInspectorProvider } from './LayoutInspectorProvider'; import { ScrollContextResetter } from './ScrollContextResetter'; import { GestureHandlerRootView } from './integrations/GestureHandlerRootView'; +import { SafeAreaProvider } from './integrations/SafeAreaProvider'; import RNTModalView from './newarch/NativeRNTModalView'; import type { ModalViewProps } from './types'; @@ -40,39 +41,41 @@ export const ModalView: FC = ({ animationType={animationType} > - - {isIOS && statusBar && !disableDefaultStatusBarIOS ? ( - - ) : null} - - {showBackdrop && ( - - onRequestDismiss?.(DismissalSource.Backdrop)} + + + {isIOS && statusBar && !disableDefaultStatusBarIOS ? ( + + ) : null} + + {showBackdrop && ( + + onRequestDismiss?.(DismissalSource.Backdrop)} + > + {renderBackdrop ? ( + renderBackdrop() + ) : ( + + )} + + + )} + + - {renderBackdrop ? ( - renderBackdrop() - ) : ( - - )} - - - )} - - - {children} - - - - + {children} + + + + + ); diff --git a/src/integrations/SafeAreaProvider.tsx b/src/integrations/SafeAreaProvider.tsx new file mode 100644 index 0000000..2891adb --- /dev/null +++ b/src/integrations/SafeAreaProvider.tsx @@ -0,0 +1,29 @@ +import { ReactNode } from 'react'; + +const importSafeAreaProvider = () => { + const Errors = { + SafeAreaProviderNotFound: new Error( + 'SafeAreaProvider not found. Please report the issue: https://github.com/paufau/react-native-multiple-modals/issues', + ), + }; + + try { + const SafeAreaProviderComponent = + require('react-native-safe-area-context').SafeAreaProvider; + + if (!SafeAreaProviderComponent) { + throw Errors.SafeAreaProviderNotFound; + } + + return SafeAreaProviderComponent; + } catch (e) { + if (e === Errors.SafeAreaProviderNotFound) { + console.error(e); + } + + // Fallback component used if the library is not found + return ({ children }: { children: ReactNode }) => children; + } +}; + +export const SafeAreaProvider = importSafeAreaProvider();