diff --git a/ios/RNNComponentViewController.mm b/ios/RNNComponentViewController.mm index 63854e7bce..97cc785a1b 100644 --- a/ios/RNNComponentViewController.mm +++ b/ios/RNNComponentViewController.mm @@ -155,6 +155,11 @@ - (void)updateSearchResultsForSearchController:(UISearchController *)searchContr isFocused:searchController.searchBar.isFirstResponder]; } +- (void)destroyReactView { + [self.reactView removeFromSuperview]; + self.reactView = nil; +} + - (void)screenPopped { [_eventEmitter sendScreenPoppedEvent:self.layoutInfo.componentId]; } diff --git a/ios/RNNStackController.mm b/ios/RNNStackController.mm index 5037226684..f1234769da 100644 --- a/ios/RNNStackController.mm +++ b/ios/RNNStackController.mm @@ -42,7 +42,82 @@ - (void)mergeChildOptions:(RNNNavigationOptions *)options child:(UIViewControlle - (UIViewController *)popViewControllerAnimated:(BOOL)animated { [self prepareForPop]; - return [super popViewControllerAnimated:animated]; + UIViewController *previousTop = self.topViewController; + UIView *snapshot = [self snapshotTopView:animated]; + + UIViewController *poppedVC = [super popViewControllerAnimated:animated]; + if (!poppedVC) { + [snapshot removeFromSuperview]; + return nil; + } + + id coordinator = self.transitionCoordinator; + if (coordinator && coordinator.isInteractive) { + // Interactive pop (swipe-back): remove snapshot overlay — UIKit shows the live + // view during the gesture. Skip early teardown so the React view stays alive + // if the gesture is cancelled. The delegate's didShowViewController handles + // cleanup once the animation finishes. + [snapshot removeFromSuperview]; + } else { + [self teardownPoppedControllers:@[ poppedVC ] previousTop:previousTop]; + } + return poppedVC; +} + +- (NSArray *)popToViewController:(UIViewController *)viewController + animated:(BOOL)animated { + UIViewController *previousTop = self.topViewController; + UIView *snapshot = [self snapshotTopView:animated]; + + NSArray *poppedVCs = + [super popToViewController:viewController animated:animated]; + if (poppedVCs.count > 0) { + [self teardownPoppedControllers:poppedVCs previousTop:previousTop]; + } else { + [snapshot removeFromSuperview]; + } + return poppedVCs; +} + +- (NSArray *)popToRootViewControllerAnimated:(BOOL)animated { + UIViewController *previousTop = self.topViewController; + UIView *snapshot = [self snapshotTopView:animated]; + + NSArray *poppedVCs = + [super popToRootViewControllerAnimated:animated]; + if (poppedVCs.count > 0) { + [self teardownPoppedControllers:poppedVCs previousTop:previousTop]; + } else { + [snapshot removeFromSuperview]; + } + return poppedVCs; +} + +#pragma mark - React view teardown + +- (UIView *)snapshotTopView:(BOOL)animated { + if (!animated) return nil; + UIViewController *topVC = self.topViewController; + if (!topVC.isViewLoaded || !topVC.view.window) return nil; + + UIView *snapshot = [topVC.view snapshotViewAfterScreenUpdates:NO]; + if (snapshot) { + snapshot.frame = topVC.view.bounds; + snapshot.autoresizingMask = + UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; + [topVC.view addSubview:snapshot]; + } + return snapshot; +} + +- (void)teardownPoppedControllers:(NSArray *)poppedVCs + previousTop:(UIViewController *)previousTop { + for (UIViewController *vc in poppedVCs) { + if (vc == previousTop && [vc isKindOfClass:[RNNComponentViewController class]]) { + [[(RNNComponentViewController *)vc reactView] componentDidDisappear]; + } + [vc destroyReactView]; + } } - (BOOL)navigationBar:(UINavigationBar *)navigationBar shouldPopItem:(UINavigationItem *)item { diff --git a/ios/StackControllerDelegate.mm b/ios/StackControllerDelegate.mm index 114623dea5..a8941d332b 100644 --- a/ios/StackControllerDelegate.mm +++ b/ios/StackControllerDelegate.mm @@ -56,6 +56,7 @@ - (void)navigationController:(UINavigationController *)navigationController if (_presentedViewController && ![navigationController.viewControllers containsObject:_presentedViewController]) { [_presentedViewController screenPopped]; + [_presentedViewController destroyReactView]; _isPopping = NO; } diff --git a/ios/UIViewController+LayoutProtocol.h b/ios/UIViewController+LayoutProtocol.h index e8f0f818e2..ab7ded3855 100644 --- a/ios/UIViewController+LayoutProtocol.h +++ b/ios/UIViewController+LayoutProtocol.h @@ -18,6 +18,8 @@ typedef void (^RNNReactViewReadyCompletionBlock)(void); - (void)destroy; +- (void)destroyReactView; + - (void)mergeOptions:(RNNNavigationOptions *)options; - (void)mergeChildOptions:(RNNNavigationOptions *)options child:(UIViewController *)child; diff --git a/playground/e2e/Stack.test.js b/playground/e2e/Stack.test.js index 9e6006f430..49c1cb33d5 100644 --- a/playground/e2e/Stack.test.js +++ b/playground/e2e/Stack.test.js @@ -183,6 +183,15 @@ describe('Stack', () => { await expect(elementByLabel('pop promise resolved with: ChildId')).toBeVisible(); }); + it.e2e('pop and re-push same component should not have stale unmount', async () => { + await elementById(TestIDs.PUSH_UNMOUNT_RACE_BTN).tap(); + await sleep(800); + await expect(elementByLabel('loaded')).toBeVisible(); + await elementById(TestIDs.POP_AND_REPUSH_BTN).tap(); + await sleep(1000); + await expect(elementByLabel('loaded')).toBeVisible(); + }); + it('pop from root screen should do nothing', async () => { await elementById(TestIDs.POP_BTN).tap(); await expect(elementById(TestIDs.STACK_SCREEN_HEADER)).toBeVisible(); diff --git a/playground/src/screens/Screens.ts b/playground/src/screens/Screens.ts index 56aea1916c..3f1f044dc2 100644 --- a/playground/src/screens/Screens.ts +++ b/playground/src/screens/Screens.ts @@ -122,6 +122,7 @@ const Screens = { }, SystemUiOptions: SystemUiOptions, StatusBarFirstTab, + UnmountRace: 'UnmountRace', KeyboardScreen: 'KeyboardScreen', TopBarBackground: 'TopBarBackground', Toast: 'Toast', diff --git a/playground/src/screens/StackScreen.tsx b/playground/src/screens/StackScreen.tsx index 445c668d6d..e8cf80d1cd 100644 --- a/playground/src/screens/StackScreen.tsx +++ b/playground/src/screens/StackScreen.tsx @@ -21,6 +21,7 @@ const { SET_STACK_ROOT_WITH_ID_BTN, STACK_COMMANDS_BTN, SET_ROOT_NAVIGATION_TAB, + PUSH_UNMOUNT_RACE_BTN, POP_BTN, } = testIDs; @@ -83,6 +84,11 @@ export default class StackScreen extends React.Component { testID={STACK_COMMANDS_BTN} onPress={this.pushStackCommands} /> +