From 24e7f604724c679e1d6d6183fc9087770bf3cfba Mon Sep 17 00:00:00 2001 From: paufau Date: Wed, 2 Sep 2026 14:34:45 +0400 Subject: [PATCH] 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 {