Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions ios/RNNComponentViewController.mm
Original file line number Diff line number Diff line change
Expand Up @@ -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];
}
Expand Down
77 changes: 76 additions & 1 deletion ios/RNNStackController.mm
Original file line number Diff line number Diff line change
Expand Up @@ -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<UIViewControllerTransitionCoordinator> 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<UIViewController *> *)popToViewController:(UIViewController *)viewController
animated:(BOOL)animated {
UIViewController *previousTop = self.topViewController;
UIView *snapshot = [self snapshotTopView:animated];

NSArray<UIViewController *> *poppedVCs =
[super popToViewController:viewController animated:animated];
if (poppedVCs.count > 0) {
[self teardownPoppedControllers:poppedVCs previousTop:previousTop];
} else {
[snapshot removeFromSuperview];
}
return poppedVCs;
}

- (NSArray<UIViewController *> *)popToRootViewControllerAnimated:(BOOL)animated {
UIViewController *previousTop = self.topViewController;
UIView *snapshot = [self snapshotTopView:animated];

NSArray<UIViewController *> *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<UIViewController *> *)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 {
Expand Down
1 change: 1 addition & 0 deletions ios/StackControllerDelegate.mm
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ - (void)navigationController:(UINavigationController *)navigationController
if (_presentedViewController &&
![navigationController.viewControllers containsObject:_presentedViewController]) {
[_presentedViewController screenPopped];
[_presentedViewController destroyReactView];
_isPopping = NO;
}

Expand Down
2 changes: 2 additions & 0 deletions ios/UIViewController+LayoutProtocol.h
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ typedef void (^RNNReactViewReadyCompletionBlock)(void);

- (void)destroy;

- (void)destroyReactView;

- (void)mergeOptions:(RNNNavigationOptions *)options;

- (void)mergeChildOptions:(RNNNavigationOptions *)options child:(UIViewController *)child;
Expand Down
9 changes: 9 additions & 0 deletions playground/e2e/Stack.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
1 change: 1 addition & 0 deletions playground/src/screens/Screens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ const Screens = {
},
SystemUiOptions: SystemUiOptions,
StatusBarFirstTab,
UnmountRace: 'UnmountRace',
KeyboardScreen: 'KeyboardScreen',
TopBarBackground: 'TopBarBackground',
Toast: 'Toast',
Expand Down
14 changes: 14 additions & 0 deletions playground/src/screens/StackScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -83,6 +84,11 @@ export default class StackScreen extends React.Component<NavigationProps> {
testID={STACK_COMMANDS_BTN}
onPress={this.pushStackCommands}
/>
<Button
label="Push Unmount Race"
testID={PUSH_UNMOUNT_RACE_BTN}
onPress={this.pushUnmountRace}
/>
<Button label="Pop" testID={POP_BTN} onPress={this.pop} />
</Root>
);
Expand Down Expand Up @@ -166,5 +172,13 @@ export default class StackScreen extends React.Component<NavigationProps> {

pushStackCommands = () => Navigation.push(this, component(Screens.StackCommands));

pushUnmountRace = () =>
Navigation.push(this, {
component: {
name: Screens.UnmountRace,
passProps: { stackComponentId: this.props.componentId },
},
});

pop = () => Navigation.pop(this);
}
80 changes: 80 additions & 0 deletions playground/src/screens/UnmountRaceScreen.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import React from 'react';
import { Text, View, StyleSheet } from 'react-native';
import { NavigationProps } from 'react-native-navigation';
import Root from '../components/Root';
import Button from '../components/Button';
import Navigation from '../services/Navigation';
import Screens from './Screens';
import testIDs from '../testIDs';

let sharedData: string | null = null;

interface Props extends NavigationProps {
stackComponentId?: string;
}

export default class UnmountRaceScreen extends React.Component<Props, { data: string }> {
static options() {
return {
topBar: {
title: {
text: 'Unmount Race',
},
},
};
}

state = { data: 'loading' };
private checkTimer: ReturnType<typeof setTimeout> | null = null;

componentDidMount() {
sharedData = 'loaded';
this.checkTimer = setTimeout(() => {
this.setState({ data: sharedData ?? 'stale_unmount' });
}, 600);
}

componentWillUnmount() {
if (this.checkTimer) {
clearTimeout(this.checkTimer);
}
sharedData = null;
}

render() {
return (
<Root componentId={this.props.componentId}>
<View style={styles.container}>
<Text testID={testIDs.UNMOUNT_RACE_DATA} style={styles.text}>
{this.state.data}
</Text>
</View>
<Button
label="Pop and Re-push"
testID={testIDs.POP_AND_REPUSH_BTN}
onPress={this.popAndRepush}
/>
<Button label="Pop" testID={testIDs.POP_BTN} onPress={this.pop} />
</Root>
);
}

popAndRepush = () => {
const stackId = this.props.stackComponentId;
if (!stackId) return;
Navigation.pop(this.props.componentId);
Navigation.push(stackId, {
component: {
name: Screens.UnmountRace,
passProps: { stackComponentId: stackId },
},
});
};

pop = () => Navigation.pop(this);
}

const styles = StyleSheet.create({
container: { padding: 20, alignItems: 'center' },
text: { fontSize: 24 },
});
1 change: 1 addition & 0 deletions playground/src/screens/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ function registerScreens() {
() => require('./SideMenuRightScreen').default
);
Navigation.registerComponent(Screens.Stack, () => require('./StackScreen').default);
Navigation.registerComponent(Screens.UnmountRace, () => require('./UnmountRaceScreen').default);
Navigation.registerComponent(Screens.SystemUiOptions, () => require('./SystemUiOptions').default);
Navigation.registerComponent(
Screens.StatusBarFirstTab,
Expand Down
4 changes: 4 additions & 0 deletions playground/src/testIDs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,10 @@ const testIDs = {
TOGGLE_PLACEMENT_BTN: 'TOGGLE_PLACEMENT_BTN',
MOUNTED_SCREENS_TEXT: 'MOUNTED_SCREENS_TEXT',

PUSH_UNMOUNT_RACE_BTN: 'PUSH_UNMOUNT_RACE_BTN',
UNMOUNT_RACE_DATA: 'UNMOUNT_RACE_DATA',
POP_AND_REPUSH_BTN: 'POP_AND_REPUSH_BTN',

GOTO_TOPBAR_TITLE_TEST: 'GOTO_TOPBAR_TITLE_TEST',
TOPBAR_TITLE_TEXT: 'TOPBAR_TITLE_TEXT',
TOPBAR_TITLE_AVATAR: 'TOPBAR_TITLE_AVATAR',
Expand Down