Skip to content

Commit 289b12c

Browse files
committed
fix(react-router): fix swipe-to-go-back view unmounting and cross-outlet navigation
1 parent 36c6b13 commit 289b12c

File tree

1 file changed

+182
-27
lines changed

1 file changed

+182
-27
lines changed

packages/react-router/src/ReactRouter/StackManager.tsx

Lines changed: 182 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -207,9 +207,42 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
207207
}
208208

209209
if (routeInfo.routeAction === 'replace') {
210-
return true;
210+
// For replace actions, decide whether to unmount the leaving view.
211+
// The key question is: are these routes in the same navigation context?
212+
const enteringRoutePath = enteringViewItem?.reactElement?.props?.path as string | undefined;
213+
const leavingRoutePath = leavingViewItem?.reactElement?.props?.path as string | undefined;
214+
215+
// Never unmount the root path "/" - it's the main entry point for back navigation
216+
if (leavingRoutePath === '/' || leavingRoutePath === '') {
217+
return false;
218+
}
219+
220+
if (enteringRoutePath && leavingRoutePath) {
221+
// Get parent paths to check if routes share a common parent
222+
const getParentPath = (path: string) => {
223+
const normalized = path.replace(/\/\*$/, ''); // Remove trailing /*
224+
const lastSlash = normalized.lastIndexOf('/');
225+
return lastSlash > 0 ? normalized.substring(0, lastSlash) : '/';
226+
};
227+
228+
const enteringParent = getParentPath(enteringRoutePath);
229+
const leavingParent = getParentPath(leavingRoutePath);
230+
231+
// Unmount if:
232+
// 1. Routes are siblings (same parent, e.g., /page1 and /page2, or /foo/page1 and /foo/page2)
233+
// 2. Entering is a child of leaving (redirect, e.g., /tabs -> /tabs/tab1)
234+
const areSiblings = enteringParent === leavingParent && enteringParent !== '/';
235+
const isChildRedirect =
236+
enteringRoutePath.startsWith(leavingRoutePath) ||
237+
(leavingRoutePath.endsWith('/*') && enteringRoutePath.startsWith(leavingRoutePath.slice(0, -2)));
238+
239+
return areSiblings || isChildRedirect;
240+
}
241+
242+
return false;
211243
}
212244

245+
// For non-replace actions, only unmount for back navigation (not forward push)
213246
const isForwardPush = routeInfo.routeAction === 'push' && (routeInfo as any).routeDirection === 'forward';
214247
if (!isForwardPush && routeInfo.routeDirection !== 'none' && enteringViewItem !== leavingViewItem) {
215248
return true;
@@ -317,9 +350,6 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
317350
leavingViewItem: ViewItem | undefined,
318351
shouldUnmountLeavingViewItem: boolean
319352
): void {
320-
// Ensure the entering view is not hidden from previous navigations
321-
showIonPageElement(enteringViewItem.ionPageElement);
322-
323353
// Handle same view item case (e.g., parameterized route changes)
324354
if (enteringViewItem === leavingViewItem) {
325355
const routePath = enteringViewItem.reactElement?.props?.path as string | undefined;
@@ -348,34 +378,93 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
348378
leavingViewItem = this.context.findViewItemByPathname(this.props.routeInfo.prevRouteLastPathname, this.id);
349379
}
350380

351-
// Skip transition if entering view is visible and leaving view is not
352-
if (
353-
enteringViewItem.ionPageElement &&
354-
isViewVisible(enteringViewItem.ionPageElement) &&
355-
leavingViewItem !== undefined &&
356-
leavingViewItem.ionPageElement &&
357-
!isViewVisible(leavingViewItem.ionPageElement)
358-
) {
359-
return;
381+
// Ensure the entering view is marked as mounted.
382+
// This is critical for views that were previously unmounted (e.g., navigating back to home).
383+
// When mount=false, the ViewLifeCycleManager doesn't render the IonPage, so the
384+
// ionPageElement reference becomes stale. By setting mount=true, we ensure the view
385+
// gets re-rendered and a new IonPage is created.
386+
if (!enteringViewItem.mount) {
387+
enteringViewItem.mount = true;
360388
}
361389

390+
// Check visibility state BEFORE showing the entering view.
391+
// This must be done before showIonPageElement to get accurate visibility state.
392+
const enteringWasVisible = enteringViewItem.ionPageElement && isViewVisible(enteringViewItem.ionPageElement);
393+
const leavingIsHidden =
394+
leavingViewItem !== undefined && leavingViewItem.ionPageElement && !isViewVisible(leavingViewItem.ionPageElement);
395+
362396
// Check for duplicate transition
363397
const currentTransition = {
364398
enteringId: enteringViewItem.id,
365399
leavingId: leavingViewItem?.id,
366400
};
367401

368-
if (
402+
const isDuplicateTransition =
369403
leavingViewItem &&
370404
this.lastTransition &&
371405
this.lastTransition.leavingId &&
372406
this.lastTransition.enteringId === currentTransition.enteringId &&
373-
this.lastTransition.leavingId === currentTransition.leavingId
374-
) {
407+
this.lastTransition.leavingId === currentTransition.leavingId;
408+
409+
// Skip transition if entering view was ALREADY visible and leaving view is not visible.
410+
// This indicates the transition has already been performed (e.g., via swipe gesture).
411+
// IMPORTANT: Only skip if both ionPageElements are the same as when the transition was last done.
412+
// If the leaving view's ionPageElement changed (e.g., component re-rendered with different IonPage),
413+
// we should NOT skip because the DOM state is inconsistent.
414+
if (enteringWasVisible && leavingIsHidden && isDuplicateTransition) {
415+
// For swipe-to-go-back, the transition animation was handled by the gesture.
416+
// We still need to set mount=false so React unmounts the leaving view.
417+
// Only do this when skipTransition is set (indicating gesture completion).
418+
if (
419+
this.skipTransition &&
420+
shouldUnmountLeavingViewItem &&
421+
leavingViewItem &&
422+
enteringViewItem !== leavingViewItem
423+
) {
424+
leavingViewItem.mount = false;
425+
// Call transitionPage with duration 0 to trigger ionViewDidLeave lifecycle
426+
// which is needed for ViewLifeCycleManager to remove the view.
427+
this.transitionPage(routeInfo, enteringViewItem, leavingViewItem, 'back');
428+
}
429+
// Clear skipTransition since we're not calling transitionPage which normally clears it
430+
this.skipTransition = false;
431+
// Must call forceUpdate to trigger re-render after mount state change
432+
this.forceUpdate();
433+
return;
434+
}
435+
436+
// Ensure the entering view is not hidden from previous navigations
437+
// This must happen AFTER the visibility check above
438+
showIonPageElement(enteringViewItem.ionPageElement);
439+
440+
// Skip if this is a duplicate transition (but visibility state didn't match above)
441+
// OR if skipTransition is set (swipe gesture already handled the animation)
442+
if (isDuplicateTransition || this.skipTransition) {
443+
// For swipe-to-go-back, we still need to handle unmounting even if visibility
444+
// conditions aren't fully met (animation might still be in progress)
445+
if (
446+
this.skipTransition &&
447+
shouldUnmountLeavingViewItem &&
448+
leavingViewItem &&
449+
enteringViewItem !== leavingViewItem
450+
) {
451+
leavingViewItem.mount = false;
452+
// For swipe-to-go-back, we need to call transitionPage with duration 0 to
453+
// trigger the ionViewDidLeave lifecycle event. The ViewLifeCycleManager
454+
// uses componentCanBeDestroyed callback to remove the view, which is
455+
// only called from ionViewDidLeave. Since the gesture animation already
456+
// completed before mount=false was set, we need to re-fire the lifecycle.
457+
this.transitionPage(routeInfo, enteringViewItem, leavingViewItem, 'back');
458+
}
459+
// Clear skipTransition since we're not calling transitionPage which normally clears it
460+
this.skipTransition = false;
461+
// Must call forceUpdate to trigger re-render after mount state change
462+
this.forceUpdate();
375463
return;
376464
}
377465

378466
this.lastTransition = currentTransition;
467+
379468
this.transitionPage(routeInfo, enteringViewItem, leavingViewItem);
380469

381470
// Handle unmounting the leaving view
@@ -386,14 +475,29 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
386475
}
387476

388477
/**
389-
* Handles the delayed unmount of the leaving view item after a replace action.
478+
* Handles the delayed unmount of the leaving view item.
479+
* For 'replace' actions: handles container route transitions specially.
480+
* For back navigation: explicitly unmounts because the ionViewDidLeave lifecycle
481+
* fires DURING transitionPage, but mount=false is set AFTER.
482+
*
483+
* @param routeInfo Current route information
484+
* @param enteringViewItem The view being navigated to
485+
* @param leavingViewItem The view being navigated from
390486
*/
391487
private handleLeavingViewUnmount(routeInfo: RouteInfo, enteringViewItem: ViewItem, leavingViewItem: ViewItem): void {
392-
if (routeInfo.routeAction !== 'replace' || !leavingViewItem.ionPageElement) {
488+
if (!leavingViewItem.ionPageElement) {
489+
return;
490+
}
491+
492+
// For push/pop actions, do NOT unmount - views are cached for navigation history.
493+
// Push: Forward navigation caches views for back navigation
494+
// Pop: Back navigation should not unmount the entering view's history
495+
// Only 'replace' actions should actually unmount views since they replace history.
496+
if (routeInfo.routeAction !== 'replace') {
393497
return;
394498
}
395499

396-
// Check if we should skip removal for nested outlet redirects
500+
// For replace actions, check if we should skip removal for nested outlet redirects
397501
const enteringRoutePath = enteringViewItem.reactElement?.props?.path as string | undefined;
398502
const leavingRoutePath = leavingViewItem.reactElement?.props?.path as string | undefined;
399503
const isEnteringContainerRoute = enteringRoutePath && enteringRoutePath.endsWith('/*');
@@ -412,6 +516,8 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
412516
const viewToUnmount = leavingViewItem;
413517
setTimeout(() => {
414518
this.context.unMountViewItem(viewToUnmount);
519+
// Trigger re-render to remove the view from DOM
520+
this.forceUpdate();
415521
}, VIEW_UNMOUNT_DELAY_MS);
416522
}
417523

@@ -472,6 +578,8 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
472578

473579
if (shouldUnmountLeavingViewItem && latestLeavingView && latestEnteringView !== latestLeavingView) {
474580
latestLeavingView.mount = false;
581+
// Call handleLeavingViewUnmount to ensure the view is properly removed
582+
this.handleLeavingViewUnmount(routeInfo, latestEnteringView, latestLeavingView);
475583
}
476584

477585
this.forceUpdate();
@@ -615,7 +723,14 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
615723
}
616724

617725
// Handle transition based on ion-page element availability
618-
if (enteringViewItem && enteringViewItem.ionPageElement) {
726+
// Check if the ionPageElement is still in the document.
727+
// If the view was previously unmounted (mount=false), the ViewLifeCycleManager
728+
// removes the React component from the tree, which removes the IonPage from the DOM.
729+
// The ionPageElement reference becomes stale and we need to wait for a new one.
730+
const ionPageIsInDocument =
731+
enteringViewItem?.ionPageElement && document.body.contains(enteringViewItem.ionPageElement);
732+
733+
if (enteringViewItem && ionPageIsInDocument) {
619734
// Clear waiting state
620735
if (this.waitingForIonPage) {
621736
this.waitingForIonPage = false;
@@ -626,8 +741,17 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
626741
}
627742

628743
this.handleReadyEnteringView(routeInfo, enteringViewItem, leavingViewItem, shouldUnmountLeavingViewItem);
629-
} else if (enteringViewItem && !enteringViewItem.ionPageElement) {
744+
} else if (enteringViewItem && !ionPageIsInDocument) {
630745
// Wait for ion-page to mount
746+
// This handles both: no ionPageElement, or stale ionPageElement (not in document)
747+
// Clear stale reference if the element is no longer in the document
748+
if (enteringViewItem.ionPageElement && !document.body.contains(enteringViewItem.ionPageElement)) {
749+
enteringViewItem.ionPageElement = undefined;
750+
}
751+
// Ensure the view is marked as mounted so ViewLifeCycleManager renders the IonPage
752+
if (!enteringViewItem.mount) {
753+
enteringViewItem.mount = true;
754+
}
631755
this.handleWaitingForIonPage(routeInfo, enteringViewItem, leavingViewItem, shouldUnmountLeavingViewItem);
632756
return;
633757
} else if (!enteringViewItem && !enteringRoute) {
@@ -659,6 +783,7 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
659783
this.pendingPageTransition = false;
660784

661785
const foundView = this.context.findViewItemByRouteInfo(routeInfo, this.id);
786+
662787
if (foundView) {
663788
const oldPageElement = foundView.ionPageElement;
664789

@@ -746,13 +871,28 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
746871

747872
const { routeInfo } = this.props;
748873
const swipeBackRouteInfo = this.getSwipeBackRouteInfo();
749-
const enteringViewItem = this.context.findViewItemByRouteInfo(swipeBackRouteInfo, this.id, false);
874+
// First try to find the view in the current outlet
875+
let enteringViewItem = this.context.findViewItemByRouteInfo(swipeBackRouteInfo, this.id, false);
876+
// If not found in current outlet, search all outlets (for cross-outlet swipe back)
877+
if (!enteringViewItem) {
878+
enteringViewItem = this.context.findViewItemByRouteInfo(swipeBackRouteInfo, undefined, false);
879+
}
880+
881+
// Check if the ionPageElement is still in the document.
882+
// A view might have mount=false but still have its ionPageElement in the DOM
883+
// (due to timing differences in unmounting).
884+
const ionPageInDocument = Boolean(
885+
enteringViewItem?.ionPageElement && document.body.contains(enteringViewItem.ionPageElement)
886+
);
750887

751888
const canStartSwipe =
752889
!!enteringViewItem &&
753-
// The root url '/' is treated as the first view item (but is never mounted),
754-
// so we do not want to swipe back to the root url.
755-
enteringViewItem.mount &&
890+
// Check if we can swipe to this view. Either:
891+
// 1. The view is mounted (mount=true), OR
892+
// 2. The view's ionPageElement is still in the document
893+
// The second case handles views that have been marked for unmount but haven't
894+
// actually been removed from the DOM yet.
895+
(enteringViewItem.mount || ionPageInDocument) &&
756896
// When on the first page it is possible for findViewItemByRouteInfo to
757897
// return the exact same view you are currently on.
758898
// Make sure that we are not swiping back to the same instances of a view.
@@ -764,9 +904,20 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
764904
const onStart = async () => {
765905
const { routeInfo } = this.props;
766906
const swipeBackRouteInfo = this.getSwipeBackRouteInfo();
767-
const enteringViewItem = this.context.findViewItemByRouteInfo(swipeBackRouteInfo, this.id, false);
907+
// First try to find the view in the current outlet, then search all outlets
908+
let enteringViewItem = this.context.findViewItemByRouteInfo(swipeBackRouteInfo, this.id, false);
909+
if (!enteringViewItem) {
910+
enteringViewItem = this.context.findViewItemByRouteInfo(swipeBackRouteInfo, undefined, false);
911+
}
768912
const leavingViewItem = this.context.findViewItemByRouteInfo(routeInfo, this.id, false);
769913

914+
// Ensure the entering view is mounted so React keeps rendering it during the gesture.
915+
// This is important when the view was previously marked for unmount but its
916+
// ionPageElement is still in the DOM.
917+
if (enteringViewItem && !enteringViewItem.mount) {
918+
enteringViewItem.mount = true;
919+
}
920+
770921
// When the gesture starts, kick off a transition controlled via swipe gesture
771922
if (enteringViewItem && leavingViewItem) {
772923
await this.transitionPage(routeInfo, enteringViewItem, leavingViewItem, 'back', true);
@@ -784,7 +935,11 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
784935
// Swipe gesture was aborted - re-hide the page that was going to enter
785936
const { routeInfo } = this.props;
786937
const swipeBackRouteInfo = this.getSwipeBackRouteInfo();
787-
const enteringViewItem = this.context.findViewItemByRouteInfo(swipeBackRouteInfo, this.id, false);
938+
// First try to find the view in the current outlet, then search all outlets
939+
let enteringViewItem = this.context.findViewItemByRouteInfo(swipeBackRouteInfo, this.id, false);
940+
if (!enteringViewItem) {
941+
enteringViewItem = this.context.findViewItemByRouteInfo(swipeBackRouteInfo, undefined, false);
942+
}
788943
const leavingViewItem = this.context.findViewItemByRouteInfo(routeInfo, this.id, false);
789944

790945
// Don't hide if entering and leaving are the same (parameterized route edge case)

0 commit comments

Comments
 (0)