From 0b280bc6679125666e8290346ef13e663cf52ef1 Mon Sep 17 00:00:00 2001 From: home <> Date: Fri, 18 Sep 2026 09:55:47 +0900 Subject: [PATCH] fix(ios): serialize Fabric modal visibility transitions --- .../Modal/RCTModalHostViewComponentView.mm | 55 ++++- .../RCTModalHostViewComponentViewTests.mm | 218 ++++++++++++++++++ 2 files changed, 263 insertions(+), 10 deletions(-) diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/Modal/RCTModalHostViewComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/Modal/RCTModalHostViewComponentView.mm index 7c09d9cf73ef..ce74ccc55b1e 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/Modal/RCTModalHostViewComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/Modal/RCTModalHostViewComponentView.mm @@ -119,6 +119,7 @@ @implementation RCTModalHostViewComponentView { BOOL _shouldAnimatePresentation; BOOL _shouldPresent; BOOL _isPresented; + BOOL _isTransitioning; BOOL _modalInPresentation; } @@ -169,43 +170,69 @@ - (void)dismissViewController:(UIViewController *)modalViewController - (void)ensurePresentedOnlyIfNeeded { + // UIKit presentation and dismissal are asynchronous. Reconcile the latest + // visibility only after the current transition has completed. + if (_isTransitioning) { + return; + } + BOOL shouldBePresented = !_isPresented && _shouldPresent && self.window; if (shouldBePresented) { [self saveAccessibilityFocusedView]; self.viewController.presentationController.delegate = self; self.viewController.modalInPresentation = _modalInPresentation; + RCTFabricModalHostViewController *viewController = self.viewController; _isPresented = YES; - [self presentViewController:self.viewController + _isTransitioning = YES; + [self presentViewController:viewController animated:_shouldAnimatePresentation completion:^{ + if (self->_viewController != viewController) { + // The host was recycled while UIKit was presenting it. + [self dismissViewController:viewController animated:NO completion:nil]; + return; + } + self->_isTransitioning = NO; auto eventEmitter = [self modalEventEmitter]; if (eventEmitter) { eventEmitter->onShow(ModalHostViewEventEmitter::OnShow{}); } + [self ensurePresentedOnlyIfNeeded]; }]; + return; } BOOL shouldBeHidden = _isPresented && (!_shouldPresent || !self.superview); if (shouldBeHidden) { - _isPresented = NO; + _isTransitioning = YES; + RCTFabricModalHostViewController *viewController = self.viewController; // To animate dismissal of view controller, snapshot of // view hierarchy needs to be added to the UIViewController. - UIView *snapshot = [self.viewController.view snapshotViewAfterScreenUpdates:NO]; + UIView *snapshot = [viewController.view snapshotViewAfterScreenUpdates:NO]; if (_shouldPresent) { - [self.viewController.view addSubview:snapshot]; + [viewController.view addSubview:snapshot]; } - [self dismissViewController:self.viewController + [self dismissViewController:viewController animated:_shouldAnimatePresentation completion:^{ [snapshot removeFromSuperview]; - auto eventEmitter = [self modalEventEmitter]; - if (eventEmitter) { - eventEmitter->onDismiss(ModalHostViewEventEmitter::OnDismiss{}); + if (self->_viewController != viewController) { + return; } - - [self restoreAccessibilityFocusedView]; + self->_isPresented = NO; + self->_isTransitioning = NO; + // A dismissal for an earlier visibility request must not + // unmount the contents of a modal that is reopening. + if (!self->_shouldPresent || !self.superview) { + auto eventEmitter = [self modalEventEmitter]; + if (eventEmitter) { + eventEmitter->onDismiss(ModalHostViewEventEmitter::OnDismiss{}); + } + [self restoreAccessibilityFocusedView]; + } + [self ensurePresentedOnlyIfNeeded]; }]; } } @@ -277,7 +304,15 @@ - (void)prepareForRecycle { [super prepareForRecycle]; _state.reset(); + _viewController.delegate = nil; + _viewController.presentationController.delegate = nil; + if (_isPresented && !_isTransitioning) { + [self dismissViewController:_viewController animated:NO completion:nil]; + } + // In-flight completions retain the old controller and clean it up without + // mutating a subsequently reused host. _viewController = nil; + _isTransitioning = NO; _isPresented = NO; _shouldPresent = NO; } diff --git a/packages/react-native/React/Tests/Mounting/RCTModalHostViewComponentViewTests.mm b/packages/react-native/React/Tests/Mounting/RCTModalHostViewComponentViewTests.mm index e634297b5a0d..3d26570ccf0c 100644 --- a/packages/react-native/React/Tests/Mounting/RCTModalHostViewComponentViewTests.mm +++ b/packages/react-native/React/Tests/Mounting/RCTModalHostViewComponentViewTests.mm @@ -7,6 +7,7 @@ #import #import +#import #if TARGET_OS_IOS @@ -108,4 +109,221 @@ - (void)testDismissViewControllerWithNoPresentedChildDismissesDirectly @end +// Completes UIKit transitions explicitly, so the tests exercise overlapping +// visibility requests without depending on animation timing. +@interface RCTControlledModalHostView : RCTModalHostViewComponentView +@property (nonatomic, assign) BOOL completeSynchronously; +@property (nonatomic, strong) NSMutableArray *presentations; +@property (nonatomic, strong) NSMutableArray *dismissals; +@property (nonatomic, strong) NSMutableArray *presentedControllers; +@property (nonatomic, strong) NSMutableArray *dismissedControllers; +- (void)setVisible:(BOOL)visible; +- (void)completePresentation; +- (void)completeDismissal; +@end + +@implementation RCTControlledModalHostView + +- (instancetype)initWithFrame:(CGRect)frame +{ + if (self = [super initWithFrame:frame]) { + _presentations = [NSMutableArray new]; + _dismissals = [NSMutableArray new]; + _presentedControllers = [NSMutableArray new]; + _dismissedControllers = [NSMutableArray new]; + } + return self; +} + +- (void)setVisible:(BOOL)visible +{ + auto props = std::make_shared(); + props->visible = visible; + [self updateProps:props oldProps:nullptr]; +} + +- (void)presentViewController:(UIViewController *)controller + animated:(BOOL)animated + completion:(void (^)(void))completion +{ + [_presentedControllers addObject:controller]; + if (completion) { + if (self.completeSynchronously) { + completion(); + } else { + [_presentations addObject:[completion copy]]; + } + } +} + +- (void)dismissViewController:(UIViewController *)controller + animated:(BOOL)animated + completion:(void (^)(void))completion +{ + [_dismissedControllers addObject:controller]; + if (completion) { + if (self.completeSynchronously) { + completion(); + } else { + [_dismissals addObject:[completion copy]]; + } + } +} + +- (void)completePresentation +{ + void (^completion)(void) = _presentations.firstObject; + [_presentations removeObjectAtIndex:0]; + completion(); +} + +- (void)completeDismissal +{ + void (^completion)(void) = _dismissals.firstObject; + [_dismissals removeObjectAtIndex:0]; + completion(); +} + +@end + +@interface RCTModalHostViewTransitionTests : XCTestCase +@property (nonatomic, strong) UIWindow *window; +@property (nonatomic, strong) RCTControlledModalHostView *view; +@end + +@implementation RCTModalHostViewTransitionTests + +- (void)setUp +{ + [super setUp]; + self.window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 100, 100)]; + self.view = [[RCTControlledModalHostView alloc] initWithFrame:self.window.bounds]; + [self.view setVisible:NO]; + [self.window addSubview:self.view]; +} + +- (void)tearDown +{ + [self.view removeFromSuperview]; + [self.view prepareForRecycle]; + [self.view.presentations removeAllObjects]; + [self.view.dismissals removeAllObjects]; + self.view = nil; + self.window = nil; + [super tearDown]; +} + +- (void)testSynchronousTransitionsCanBeRepeated +{ + self.view.completeSynchronously = YES; + [self.view setVisible:YES]; + [self.view setVisible:NO]; + [self.view setVisible:YES]; + [self.view setVisible:NO]; + XCTAssertEqual(self.view.presentedControllers.count, 2u); + XCTAssertEqual(self.view.dismissedControllers.count, 2u); + XCTAssertEqual(self.view.presentations.count, 0u); + XCTAssertEqual(self.view.dismissals.count, 0u); +} + +- (void)testHideWaitsForPresentationToComplete +{ + [self.view setVisible:YES]; + [self.view setVisible:NO]; + XCTAssertEqual(self.view.presentedControllers.count, 1u); + XCTAssertEqual(self.view.dismissedControllers.count, 0u); + [self.view completePresentation]; + XCTAssertEqual(self.view.dismissedControllers.count, 1u); + [self.view completeDismissal]; +} + +- (void)testReopenWaitsForDismissalToComplete +{ + [self.view setVisible:YES]; + [self.view completePresentation]; + [self.view setVisible:NO]; + [self.view setVisible:YES]; + XCTAssertEqual(self.view.presentedControllers.count, 1u); + [self.view completeDismissal]; + XCTAssertEqual(self.view.presentedControllers.count, 2u); + [self.view completePresentation]; +} + +- (void)testFinalHiddenRequestCancelsPendingReopen +{ + [self.view setVisible:YES]; + [self.view completePresentation]; + [self.view setVisible:NO]; + [self.view setVisible:YES]; + [self.view setVisible:NO]; + XCTAssertEqual(self.view.dismissedControllers.count, 1u); + [self.view completeDismissal]; + XCTAssertEqual(self.view.presentedControllers.count, 1u); +} + +- (void)testFinalVisibleRequestCancelsPendingHide +{ + [self.view setVisible:YES]; + [self.view setVisible:NO]; + [self.view setVisible:YES]; + [self.view completePresentation]; + XCTAssertEqual(self.view.presentedControllers.count, 1u); + XCTAssertEqual(self.view.dismissedControllers.count, 0u); +} + +- (void)testDetachingDuringPresentationDismissesAfterCompletion +{ + [self.view setVisible:YES]; + [self.view removeFromSuperview]; + XCTAssertEqual(self.view.dismissedControllers.count, 0u); + [self.view completePresentation]; + XCTAssertEqual(self.view.dismissedControllers.count, 1u); + [self.view completeDismissal]; +} + +- (void)testRecyclingPresentedHostDismissesItsController +{ + [self.view setVisible:YES]; + [self.view completePresentation]; + [self.view prepareForRecycle]; + XCTAssertEqual(self.view.dismissedControllers.count, 1u); + XCTAssertEqual(self.view.dismissedControllers.firstObject, self.view.presentedControllers.firstObject); +} + +- (void)testOldPresentationCompletionCleansUpOnlyItsControllerAfterReuse +{ + [self.view setVisible:YES]; + UIViewController *oldController = self.view.presentedControllers.firstObject; + [self.view prepareForRecycle]; + [self.view setVisible:YES]; + UIViewController *newController = self.view.presentedControllers.lastObject; + XCTAssertNotEqual(oldController, newController); + [self.view completePresentation]; + XCTAssertEqual(self.view.dismissedControllers.count, 1u); + XCTAssertEqual(self.view.dismissedControllers.firstObject, oldController); + [self.view setVisible:NO]; + XCTAssertEqual(self.view.dismissedControllers.count, 1u); + [self.view completePresentation]; + XCTAssertEqual(self.view.dismissedControllers.count, 2u); + XCTAssertEqual(self.view.dismissedControllers.lastObject, newController); + [self.view completeDismissal]; +} + +- (void)testOldDismissalCompletionDoesNotChangeReusedHost +{ + [self.view setVisible:YES]; + [self.view completePresentation]; + [self.view setVisible:NO]; + [self.view prepareForRecycle]; + [self.view setVisible:YES]; + [self.view completeDismissal]; + [self.view setVisible:NO]; + XCTAssertEqual(self.view.dismissedControllers.count, 1u); + [self.view completePresentation]; + XCTAssertEqual(self.view.dismissedControllers.count, 2u); + [self.view completeDismissal]; +} + +@end + #endif