Skip to content

Commit 0b280bc

Browse files
author
home
committed
fix(ios): serialize Fabric modal visibility transitions
1 parent d2a6ce5 commit 0b280bc

2 files changed

Lines changed: 263 additions & 10 deletions

File tree

packages/react-native/React/Fabric/Mounting/ComponentViews/Modal/RCTModalHostViewComponentView.mm

Lines changed: 45 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ @implementation RCTModalHostViewComponentView {
119119
BOOL _shouldAnimatePresentation;
120120
BOOL _shouldPresent;
121121
BOOL _isPresented;
122+
BOOL _isTransitioning;
122123
BOOL _modalInPresentation;
123124
}
124125

@@ -169,43 +170,69 @@ - (void)dismissViewController:(UIViewController *)modalViewController
169170

170171
- (void)ensurePresentedOnlyIfNeeded
171172
{
173+
// UIKit presentation and dismissal are asynchronous. Reconcile the latest
174+
// visibility only after the current transition has completed.
175+
if (_isTransitioning) {
176+
return;
177+
}
178+
172179
BOOL shouldBePresented = !_isPresented && _shouldPresent && self.window;
173180
if (shouldBePresented) {
174181
[self saveAccessibilityFocusedView];
175182
self.viewController.presentationController.delegate = self;
176183
self.viewController.modalInPresentation = _modalInPresentation;
177184

185+
RCTFabricModalHostViewController *viewController = self.viewController;
178186
_isPresented = YES;
179-
[self presentViewController:self.viewController
187+
_isTransitioning = YES;
188+
[self presentViewController:viewController
180189
animated:_shouldAnimatePresentation
181190
completion:^{
191+
if (self->_viewController != viewController) {
192+
// The host was recycled while UIKit was presenting it.
193+
[self dismissViewController:viewController animated:NO completion:nil];
194+
return;
195+
}
196+
self->_isTransitioning = NO;
182197
auto eventEmitter = [self modalEventEmitter];
183198
if (eventEmitter) {
184199
eventEmitter->onShow(ModalHostViewEventEmitter::OnShow{});
185200
}
201+
[self ensurePresentedOnlyIfNeeded];
186202
}];
203+
return;
187204
}
188205

189206
BOOL shouldBeHidden = _isPresented && (!_shouldPresent || !self.superview);
190207
if (shouldBeHidden) {
191-
_isPresented = NO;
208+
_isTransitioning = YES;
209+
RCTFabricModalHostViewController *viewController = self.viewController;
192210
// To animate dismissal of view controller, snapshot of
193211
// view hierarchy needs to be added to the UIViewController.
194-
UIView *snapshot = [self.viewController.view snapshotViewAfterScreenUpdates:NO];
212+
UIView *snapshot = [viewController.view snapshotViewAfterScreenUpdates:NO];
195213
if (_shouldPresent) {
196-
[self.viewController.view addSubview:snapshot];
214+
[viewController.view addSubview:snapshot];
197215
}
198216

199-
[self dismissViewController:self.viewController
217+
[self dismissViewController:viewController
200218
animated:_shouldAnimatePresentation
201219
completion:^{
202220
[snapshot removeFromSuperview];
203-
auto eventEmitter = [self modalEventEmitter];
204-
if (eventEmitter) {
205-
eventEmitter->onDismiss(ModalHostViewEventEmitter::OnDismiss{});
221+
if (self->_viewController != viewController) {
222+
return;
206223
}
207-
208-
[self restoreAccessibilityFocusedView];
224+
self->_isPresented = NO;
225+
self->_isTransitioning = NO;
226+
// A dismissal for an earlier visibility request must not
227+
// unmount the contents of a modal that is reopening.
228+
if (!self->_shouldPresent || !self.superview) {
229+
auto eventEmitter = [self modalEventEmitter];
230+
if (eventEmitter) {
231+
eventEmitter->onDismiss(ModalHostViewEventEmitter::OnDismiss{});
232+
}
233+
[self restoreAccessibilityFocusedView];
234+
}
235+
[self ensurePresentedOnlyIfNeeded];
209236
}];
210237
}
211238
}
@@ -277,7 +304,15 @@ - (void)prepareForRecycle
277304
{
278305
[super prepareForRecycle];
279306
_state.reset();
307+
_viewController.delegate = nil;
308+
_viewController.presentationController.delegate = nil;
309+
if (_isPresented && !_isTransitioning) {
310+
[self dismissViewController:_viewController animated:NO completion:nil];
311+
}
312+
// In-flight completions retain the old controller and clean it up without
313+
// mutating a subsequently reused host.
280314
_viewController = nil;
315+
_isTransitioning = NO;
281316
_isPresented = NO;
282317
_shouldPresent = NO;
283318
}

packages/react-native/React/Tests/Mounting/RCTModalHostViewComponentViewTests.mm

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
#import <React/RCTModalHostViewComponentView.h>
99
#import <XCTest/XCTest.h>
10+
#import <react/renderer/components/FBReactNativeSpec/Props.h>
1011

1112
#if TARGET_OS_IOS
1213

@@ -108,4 +109,221 @@ - (void)testDismissViewControllerWithNoPresentedChildDismissesDirectly
108109

109110
@end
110111

112+
// Completes UIKit transitions explicitly, so the tests exercise overlapping
113+
// visibility requests without depending on animation timing.
114+
@interface RCTControlledModalHostView : RCTModalHostViewComponentView
115+
@property (nonatomic, assign) BOOL completeSynchronously;
116+
@property (nonatomic, strong) NSMutableArray *presentations;
117+
@property (nonatomic, strong) NSMutableArray *dismissals;
118+
@property (nonatomic, strong) NSMutableArray<UIViewController *> *presentedControllers;
119+
@property (nonatomic, strong) NSMutableArray<UIViewController *> *dismissedControllers;
120+
- (void)setVisible:(BOOL)visible;
121+
- (void)completePresentation;
122+
- (void)completeDismissal;
123+
@end
124+
125+
@implementation RCTControlledModalHostView
126+
127+
- (instancetype)initWithFrame:(CGRect)frame
128+
{
129+
if (self = [super initWithFrame:frame]) {
130+
_presentations = [NSMutableArray new];
131+
_dismissals = [NSMutableArray new];
132+
_presentedControllers = [NSMutableArray new];
133+
_dismissedControllers = [NSMutableArray new];
134+
}
135+
return self;
136+
}
137+
138+
- (void)setVisible:(BOOL)visible
139+
{
140+
auto props = std::make_shared<facebook::react::ModalHostViewProps>();
141+
props->visible = visible;
142+
[self updateProps:props oldProps:nullptr];
143+
}
144+
145+
- (void)presentViewController:(UIViewController *)controller
146+
animated:(BOOL)animated
147+
completion:(void (^)(void))completion
148+
{
149+
[_presentedControllers addObject:controller];
150+
if (completion) {
151+
if (self.completeSynchronously) {
152+
completion();
153+
} else {
154+
[_presentations addObject:[completion copy]];
155+
}
156+
}
157+
}
158+
159+
- (void)dismissViewController:(UIViewController *)controller
160+
animated:(BOOL)animated
161+
completion:(void (^)(void))completion
162+
{
163+
[_dismissedControllers addObject:controller];
164+
if (completion) {
165+
if (self.completeSynchronously) {
166+
completion();
167+
} else {
168+
[_dismissals addObject:[completion copy]];
169+
}
170+
}
171+
}
172+
173+
- (void)completePresentation
174+
{
175+
void (^completion)(void) = _presentations.firstObject;
176+
[_presentations removeObjectAtIndex:0];
177+
completion();
178+
}
179+
180+
- (void)completeDismissal
181+
{
182+
void (^completion)(void) = _dismissals.firstObject;
183+
[_dismissals removeObjectAtIndex:0];
184+
completion();
185+
}
186+
187+
@end
188+
189+
@interface RCTModalHostViewTransitionTests : XCTestCase
190+
@property (nonatomic, strong) UIWindow *window;
191+
@property (nonatomic, strong) RCTControlledModalHostView *view;
192+
@end
193+
194+
@implementation RCTModalHostViewTransitionTests
195+
196+
- (void)setUp
197+
{
198+
[super setUp];
199+
self.window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 100, 100)];
200+
self.view = [[RCTControlledModalHostView alloc] initWithFrame:self.window.bounds];
201+
[self.view setVisible:NO];
202+
[self.window addSubview:self.view];
203+
}
204+
205+
- (void)tearDown
206+
{
207+
[self.view removeFromSuperview];
208+
[self.view prepareForRecycle];
209+
[self.view.presentations removeAllObjects];
210+
[self.view.dismissals removeAllObjects];
211+
self.view = nil;
212+
self.window = nil;
213+
[super tearDown];
214+
}
215+
216+
- (void)testSynchronousTransitionsCanBeRepeated
217+
{
218+
self.view.completeSynchronously = YES;
219+
[self.view setVisible:YES];
220+
[self.view setVisible:NO];
221+
[self.view setVisible:YES];
222+
[self.view setVisible:NO];
223+
XCTAssertEqual(self.view.presentedControllers.count, 2u);
224+
XCTAssertEqual(self.view.dismissedControllers.count, 2u);
225+
XCTAssertEqual(self.view.presentations.count, 0u);
226+
XCTAssertEqual(self.view.dismissals.count, 0u);
227+
}
228+
229+
- (void)testHideWaitsForPresentationToComplete
230+
{
231+
[self.view setVisible:YES];
232+
[self.view setVisible:NO];
233+
XCTAssertEqual(self.view.presentedControllers.count, 1u);
234+
XCTAssertEqual(self.view.dismissedControllers.count, 0u);
235+
[self.view completePresentation];
236+
XCTAssertEqual(self.view.dismissedControllers.count, 1u);
237+
[self.view completeDismissal];
238+
}
239+
240+
- (void)testReopenWaitsForDismissalToComplete
241+
{
242+
[self.view setVisible:YES];
243+
[self.view completePresentation];
244+
[self.view setVisible:NO];
245+
[self.view setVisible:YES];
246+
XCTAssertEqual(self.view.presentedControllers.count, 1u);
247+
[self.view completeDismissal];
248+
XCTAssertEqual(self.view.presentedControllers.count, 2u);
249+
[self.view completePresentation];
250+
}
251+
252+
- (void)testFinalHiddenRequestCancelsPendingReopen
253+
{
254+
[self.view setVisible:YES];
255+
[self.view completePresentation];
256+
[self.view setVisible:NO];
257+
[self.view setVisible:YES];
258+
[self.view setVisible:NO];
259+
XCTAssertEqual(self.view.dismissedControllers.count, 1u);
260+
[self.view completeDismissal];
261+
XCTAssertEqual(self.view.presentedControllers.count, 1u);
262+
}
263+
264+
- (void)testFinalVisibleRequestCancelsPendingHide
265+
{
266+
[self.view setVisible:YES];
267+
[self.view setVisible:NO];
268+
[self.view setVisible:YES];
269+
[self.view completePresentation];
270+
XCTAssertEqual(self.view.presentedControllers.count, 1u);
271+
XCTAssertEqual(self.view.dismissedControllers.count, 0u);
272+
}
273+
274+
- (void)testDetachingDuringPresentationDismissesAfterCompletion
275+
{
276+
[self.view setVisible:YES];
277+
[self.view removeFromSuperview];
278+
XCTAssertEqual(self.view.dismissedControllers.count, 0u);
279+
[self.view completePresentation];
280+
XCTAssertEqual(self.view.dismissedControllers.count, 1u);
281+
[self.view completeDismissal];
282+
}
283+
284+
- (void)testRecyclingPresentedHostDismissesItsController
285+
{
286+
[self.view setVisible:YES];
287+
[self.view completePresentation];
288+
[self.view prepareForRecycle];
289+
XCTAssertEqual(self.view.dismissedControllers.count, 1u);
290+
XCTAssertEqual(self.view.dismissedControllers.firstObject, self.view.presentedControllers.firstObject);
291+
}
292+
293+
- (void)testOldPresentationCompletionCleansUpOnlyItsControllerAfterReuse
294+
{
295+
[self.view setVisible:YES];
296+
UIViewController *oldController = self.view.presentedControllers.firstObject;
297+
[self.view prepareForRecycle];
298+
[self.view setVisible:YES];
299+
UIViewController *newController = self.view.presentedControllers.lastObject;
300+
XCTAssertNotEqual(oldController, newController);
301+
[self.view completePresentation];
302+
XCTAssertEqual(self.view.dismissedControllers.count, 1u);
303+
XCTAssertEqual(self.view.dismissedControllers.firstObject, oldController);
304+
[self.view setVisible:NO];
305+
XCTAssertEqual(self.view.dismissedControllers.count, 1u);
306+
[self.view completePresentation];
307+
XCTAssertEqual(self.view.dismissedControllers.count, 2u);
308+
XCTAssertEqual(self.view.dismissedControllers.lastObject, newController);
309+
[self.view completeDismissal];
310+
}
311+
312+
- (void)testOldDismissalCompletionDoesNotChangeReusedHost
313+
{
314+
[self.view setVisible:YES];
315+
[self.view completePresentation];
316+
[self.view setVisible:NO];
317+
[self.view prepareForRecycle];
318+
[self.view setVisible:YES];
319+
[self.view completeDismissal];
320+
[self.view setVisible:NO];
321+
XCTAssertEqual(self.view.dismissedControllers.count, 1u);
322+
[self.view completePresentation];
323+
XCTAssertEqual(self.view.dismissedControllers.count, 2u);
324+
[self.view completeDismissal];
325+
}
326+
327+
@end
328+
111329
#endif

0 commit comments

Comments
 (0)