@@ -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