@@ -207,9 +207,40 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
207207 }
208208
209209 if ( routeInfo . routeAction === 'replace' ) {
210- return true ;
210+ const enteringRoutePath = enteringViewItem ?. reactElement ?. props ?. path as string | undefined ;
211+ const leavingRoutePath = leavingViewItem ?. reactElement ?. props ?. path as string | undefined ;
212+
213+ // Never unmount the root path "/" - it's the main entry point for back navigation
214+ if ( leavingRoutePath === '/' || leavingRoutePath === '' ) {
215+ return false ;
216+ }
217+
218+ if ( enteringRoutePath && leavingRoutePath ) {
219+ // Get parent paths to check if routes share a common parent
220+ const getParentPath = ( path : string ) => {
221+ const normalized = path . replace ( / \/ \* $ / , '' ) ; // Remove trailing /*
222+ const lastSlash = normalized . lastIndexOf ( '/' ) ;
223+ return lastSlash > 0 ? normalized . substring ( 0 , lastSlash ) : '/' ;
224+ } ;
225+
226+ const enteringParent = getParentPath ( enteringRoutePath ) ;
227+ const leavingParent = getParentPath ( leavingRoutePath ) ;
228+
229+ // Unmount if:
230+ // 1. Routes are siblings (same parent, e.g., /page1 and /page2, or /foo/page1 and /foo/page2)
231+ // 2. Entering is a child of leaving (redirect, e.g., /tabs -> /tabs/tab1)
232+ const areSiblings = enteringParent === leavingParent && enteringParent !== '/' ;
233+ const isChildRedirect =
234+ enteringRoutePath . startsWith ( leavingRoutePath ) ||
235+ ( leavingRoutePath . endsWith ( '/*' ) && enteringRoutePath . startsWith ( leavingRoutePath . slice ( 0 , - 2 ) ) ) ;
236+
237+ return areSiblings || isChildRedirect ;
238+ }
239+
240+ return false ;
211241 }
212242
243+ // For non-replace actions, only unmount for back navigation (not forward push)
213244 const isForwardPush = routeInfo . routeAction === 'push' && ( routeInfo as any ) . routeDirection === 'forward' ;
214245 if ( ! isForwardPush && routeInfo . routeDirection !== 'none' && enteringViewItem !== leavingViewItem ) {
215246 return true ;
@@ -317,9 +348,6 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
317348 leavingViewItem : ViewItem | undefined ,
318349 shouldUnmountLeavingViewItem : boolean
319350 ) : void {
320- // Ensure the entering view is not hidden from previous navigations
321- showIonPageElement ( enteringViewItem . ionPageElement ) ;
322-
323351 // Handle same view item case (e.g., parameterized route changes)
324352 if ( enteringViewItem === leavingViewItem ) {
325353 const routePath = enteringViewItem . reactElement ?. props ?. path as string | undefined ;
@@ -348,34 +376,93 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
348376 leavingViewItem = this . context . findViewItemByPathname ( this . props . routeInfo . prevRouteLastPathname , this . id ) ;
349377 }
350378
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 ;
379+ // Ensure the entering view is marked as mounted.
380+ // This is critical for views that were previously unmounted (e.g., navigating back to home).
381+ // When mount=false, the ViewLifeCycleManager doesn't render the IonPage, so the
382+ // ionPageElement reference becomes stale. By setting mount=true, we ensure the view
383+ // gets re-rendered and a new IonPage is created.
384+ if ( ! enteringViewItem . mount ) {
385+ enteringViewItem . mount = true ;
360386 }
361387
388+ // Check visibility state BEFORE showing the entering view.
389+ // This must be done before showIonPageElement to get accurate visibility state.
390+ const enteringWasVisible = enteringViewItem . ionPageElement && isViewVisible ( enteringViewItem . ionPageElement ) ;
391+ const leavingIsHidden =
392+ leavingViewItem !== undefined && leavingViewItem . ionPageElement && ! isViewVisible ( leavingViewItem . ionPageElement ) ;
393+
362394 // Check for duplicate transition
363395 const currentTransition = {
364396 enteringId : enteringViewItem . id ,
365397 leavingId : leavingViewItem ?. id ,
366398 } ;
367399
368- if (
400+ const isDuplicateTransition =
369401 leavingViewItem &&
370402 this . lastTransition &&
371403 this . lastTransition . leavingId &&
372404 this . lastTransition . enteringId === currentTransition . enteringId &&
373- this . lastTransition . leavingId === currentTransition . leavingId
374- ) {
405+ this . lastTransition . leavingId === currentTransition . leavingId ;
406+
407+ // Skip transition if entering view was ALREADY visible and leaving view is not visible.
408+ // This indicates the transition has already been performed (e.g., via swipe gesture).
409+ // IMPORTANT: Only skip if both ionPageElements are the same as when the transition was last done.
410+ // If the leaving view's ionPageElement changed (e.g., component re-rendered with different IonPage),
411+ // we should NOT skip because the DOM state is inconsistent.
412+ if ( enteringWasVisible && leavingIsHidden && isDuplicateTransition ) {
413+ // For swipe-to-go-back, the transition animation was handled by the gesture.
414+ // We still need to set mount=false so React unmounts the leaving view.
415+ // Only do this when skipTransition is set (indicating gesture completion).
416+ if (
417+ this . skipTransition &&
418+ shouldUnmountLeavingViewItem &&
419+ leavingViewItem &&
420+ enteringViewItem !== leavingViewItem
421+ ) {
422+ leavingViewItem . mount = false ;
423+ // Call transitionPage with duration 0 to trigger ionViewDidLeave lifecycle
424+ // which is needed for ViewLifeCycleManager to remove the view.
425+ this . transitionPage ( routeInfo , enteringViewItem , leavingViewItem , 'back' ) ;
426+ }
427+ // Clear skipTransition since we're not calling transitionPage which normally clears it
428+ this . skipTransition = false ;
429+ // Must call forceUpdate to trigger re-render after mount state change
430+ this . forceUpdate ( ) ;
431+ return ;
432+ }
433+
434+ // Ensure the entering view is not hidden from previous navigations
435+ // This must happen AFTER the visibility check above
436+ showIonPageElement ( enteringViewItem . ionPageElement ) ;
437+
438+ // Skip if this is a duplicate transition (but visibility state didn't match above)
439+ // OR if skipTransition is set (swipe gesture already handled the animation)
440+ if ( isDuplicateTransition || this . skipTransition ) {
441+ // For swipe-to-go-back, we still need to handle unmounting even if visibility
442+ // conditions aren't fully met (animation might still be in progress)
443+ if (
444+ this . skipTransition &&
445+ shouldUnmountLeavingViewItem &&
446+ leavingViewItem &&
447+ enteringViewItem !== leavingViewItem
448+ ) {
449+ leavingViewItem . mount = false ;
450+ // For swipe-to-go-back, we need to call transitionPage with duration 0 to
451+ // trigger the ionViewDidLeave lifecycle event. The ViewLifeCycleManager
452+ // uses componentCanBeDestroyed callback to remove the view, which is
453+ // only called from ionViewDidLeave. Since the gesture animation already
454+ // completed before mount=false was set, we need to re-fire the lifecycle.
455+ this . transitionPage ( routeInfo , enteringViewItem , leavingViewItem , 'back' ) ;
456+ }
457+ // Clear skipTransition since we're not calling transitionPage which normally clears it
458+ this . skipTransition = false ;
459+ // Must call forceUpdate to trigger re-render after mount state change
460+ this . forceUpdate ( ) ;
375461 return ;
376462 }
377463
378464 this . lastTransition = currentTransition ;
465+
379466 this . transitionPage ( routeInfo , enteringViewItem , leavingViewItem ) ;
380467
381468 // Handle unmounting the leaving view
@@ -386,14 +473,29 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
386473 }
387474
388475 /**
389- * Handles the delayed unmount of the leaving view item after a replace action.
476+ * Handles the delayed unmount of the leaving view item.
477+ * For 'replace' actions: handles container route transitions specially.
478+ * For back navigation: explicitly unmounts because the ionViewDidLeave lifecycle
479+ * fires DURING transitionPage, but mount=false is set AFTER.
480+ *
481+ * @param routeInfo Current route information
482+ * @param enteringViewItem The view being navigated to
483+ * @param leavingViewItem The view being navigated from
390484 */
391485 private handleLeavingViewUnmount ( routeInfo : RouteInfo , enteringViewItem : ViewItem , leavingViewItem : ViewItem ) : void {
392- if ( routeInfo . routeAction !== 'replace' || ! leavingViewItem . ionPageElement ) {
486+ if ( ! leavingViewItem . ionPageElement ) {
393487 return ;
394488 }
395489
396- // Check if we should skip removal for nested outlet redirects
490+ // For push/pop actions, do NOT unmount - views are cached for navigation history.
491+ // Push: Forward navigation caches views for back navigation
492+ // Pop: Back navigation should not unmount the entering view's history
493+ // Only 'replace' actions should actually unmount views since they replace history.
494+ if ( routeInfo . routeAction !== 'replace' ) {
495+ return ;
496+ }
497+
498+ // For replace actions, check if we should skip removal for nested outlet redirects
397499 const enteringRoutePath = enteringViewItem . reactElement ?. props ?. path as string | undefined ;
398500 const leavingRoutePath = leavingViewItem . reactElement ?. props ?. path as string | undefined ;
399501 const isEnteringContainerRoute = enteringRoutePath && enteringRoutePath . endsWith ( '/*' ) ;
@@ -412,6 +514,8 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
412514 const viewToUnmount = leavingViewItem ;
413515 setTimeout ( ( ) => {
414516 this . context . unMountViewItem ( viewToUnmount ) ;
517+ // Trigger re-render to remove the view from DOM
518+ this . forceUpdate ( ) ;
415519 } , VIEW_UNMOUNT_DELAY_MS ) ;
416520 }
417521
@@ -472,6 +576,8 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
472576
473577 if ( shouldUnmountLeavingViewItem && latestLeavingView && latestEnteringView !== latestLeavingView ) {
474578 latestLeavingView . mount = false ;
579+ // Call handleLeavingViewUnmount to ensure the view is properly removed
580+ this . handleLeavingViewUnmount ( routeInfo , latestEnteringView , latestLeavingView ) ;
475581 }
476582
477583 this . forceUpdate ( ) ;
@@ -615,7 +721,14 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
615721 }
616722
617723 // Handle transition based on ion-page element availability
618- if ( enteringViewItem && enteringViewItem . ionPageElement ) {
724+ // Check if the ionPageElement is still in the document.
725+ // If the view was previously unmounted (mount=false), the ViewLifeCycleManager
726+ // removes the React component from the tree, which removes the IonPage from the DOM.
727+ // The ionPageElement reference becomes stale and we need to wait for a new one.
728+ const ionPageIsInDocument =
729+ enteringViewItem ?. ionPageElement && document . body . contains ( enteringViewItem . ionPageElement ) ;
730+
731+ if ( enteringViewItem && ionPageIsInDocument ) {
619732 // Clear waiting state
620733 if ( this . waitingForIonPage ) {
621734 this . waitingForIonPage = false ;
@@ -626,8 +739,17 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
626739 }
627740
628741 this . handleReadyEnteringView ( routeInfo , enteringViewItem , leavingViewItem , shouldUnmountLeavingViewItem ) ;
629- } else if ( enteringViewItem && ! enteringViewItem . ionPageElement ) {
742+ } else if ( enteringViewItem && ! ionPageIsInDocument ) {
630743 // Wait for ion-page to mount
744+ // This handles both: no ionPageElement, or stale ionPageElement (not in document)
745+ // Clear stale reference if the element is no longer in the document
746+ if ( enteringViewItem . ionPageElement && ! document . body . contains ( enteringViewItem . ionPageElement ) ) {
747+ enteringViewItem . ionPageElement = undefined ;
748+ }
749+ // Ensure the view is marked as mounted so ViewLifeCycleManager renders the IonPage
750+ if ( ! enteringViewItem . mount ) {
751+ enteringViewItem . mount = true ;
752+ }
631753 this . handleWaitingForIonPage ( routeInfo , enteringViewItem , leavingViewItem , shouldUnmountLeavingViewItem ) ;
632754 return ;
633755 } else if ( ! enteringViewItem && ! enteringRoute ) {
@@ -657,9 +779,26 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
657779 this . ionPageWaitTimeout = undefined ;
658780 }
659781 this . pendingPageTransition = false ;
782+
660783 const foundView = this . context . findViewItemByRouteInfo ( routeInfo , this . id ) ;
784+
661785 if ( foundView ) {
662786 const oldPageElement = foundView . ionPageElement ;
787+
788+ /**
789+ * FIX for issue #28878: Reject orphaned IonPage registrations.
790+ *
791+ * When a component conditionally renders different IonPages (e.g., list vs empty state)
792+ * using React keys, and state changes simultaneously with navigation, the new IonPage
793+ * tries to register for a route we're navigating away from. This creates a stale view.
794+ *
795+ * Only reject if both pageIds exist and differ, to allow nested outlet registrations.
796+ */
797+ if ( this . shouldRejectOrphanedPage ( page , oldPageElement , routeInfo ) ) {
798+ this . hideAndRemoveOrphanedPage ( page ) ;
799+ return ;
800+ }
801+
663802 foundView . ionPageElement = page ;
664803 foundView . ionRoute = true ;
665804
@@ -675,6 +814,45 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
675814 this . handlePageTransition ( routeInfo ) ;
676815 }
677816
817+ /**
818+ * Determines if a new IonPage registration should be rejected as orphaned.
819+ * This happens when a component re-renders with a different IonPage while navigating away.
820+ */
821+ private shouldRejectOrphanedPage (
822+ newPage : HTMLElement ,
823+ oldPageElement : HTMLElement | undefined ,
824+ routeInfo : RouteInfo
825+ ) : boolean {
826+ if ( ! oldPageElement || oldPageElement === newPage ) {
827+ return false ;
828+ }
829+
830+ const newPageId = newPage . getAttribute ( 'data-pageid' ) ;
831+ const oldPageId = oldPageElement . getAttribute ( 'data-pageid' ) ;
832+
833+ // Only reject if both pageIds exist and are different
834+ if ( ! newPageId || ! oldPageId || newPageId === oldPageId ) {
835+ return false ;
836+ }
837+
838+ // Reject only if we're navigating away from this route
839+ return this . props . routeInfo . pathname !== routeInfo . pathname ;
840+ }
841+
842+ /**
843+ * Hides an orphaned IonPage and schedules its removal from the DOM.
844+ */
845+ private hideAndRemoveOrphanedPage ( page : HTMLElement ) : void {
846+ page . classList . add ( 'ion-page-hidden' ) ;
847+ page . setAttribute ( 'aria-hidden' , 'true' ) ;
848+
849+ setTimeout ( ( ) => {
850+ if ( page . parentElement ) {
851+ page . remove ( ) ;
852+ }
853+ } , VIEW_UNMOUNT_DELAY_MS ) ;
854+ }
855+
678856 /**
679857 * Configures the router outlet for the swipe-to-go-back gesture.
680858 *
@@ -691,13 +869,28 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
691869
692870 const { routeInfo } = this . props ;
693871 const swipeBackRouteInfo = this . getSwipeBackRouteInfo ( ) ;
694- const enteringViewItem = this . context . findViewItemByRouteInfo ( swipeBackRouteInfo , this . id , false ) ;
872+ // First try to find the view in the current outlet
873+ let enteringViewItem = this . context . findViewItemByRouteInfo ( swipeBackRouteInfo , this . id , false ) ;
874+ // If not found in current outlet, search all outlets (for cross-outlet swipe back)
875+ if ( ! enteringViewItem ) {
876+ enteringViewItem = this . context . findViewItemByRouteInfo ( swipeBackRouteInfo , undefined , false ) ;
877+ }
878+
879+ // Check if the ionPageElement is still in the document.
880+ // A view might have mount=false but still have its ionPageElement in the DOM
881+ // (due to timing differences in unmounting).
882+ const ionPageInDocument = Boolean (
883+ enteringViewItem ?. ionPageElement && document . body . contains ( enteringViewItem . ionPageElement )
884+ ) ;
695885
696886 const canStartSwipe =
697887 ! ! enteringViewItem &&
698- // The root url '/' is treated as the first view item (but is never mounted),
699- // so we do not want to swipe back to the root url.
700- enteringViewItem . mount &&
888+ // Check if we can swipe to this view. Either:
889+ // 1. The view is mounted (mount=true), OR
890+ // 2. The view's ionPageElement is still in the document
891+ // The second case handles views that have been marked for unmount but haven't
892+ // actually been removed from the DOM yet.
893+ ( enteringViewItem . mount || ionPageInDocument ) &&
701894 // When on the first page it is possible for findViewItemByRouteInfo to
702895 // return the exact same view you are currently on.
703896 // Make sure that we are not swiping back to the same instances of a view.
@@ -709,9 +902,20 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
709902 const onStart = async ( ) => {
710903 const { routeInfo } = this . props ;
711904 const swipeBackRouteInfo = this . getSwipeBackRouteInfo ( ) ;
712- const enteringViewItem = this . context . findViewItemByRouteInfo ( swipeBackRouteInfo , this . id , false ) ;
905+ // First try to find the view in the current outlet, then search all outlets
906+ let enteringViewItem = this . context . findViewItemByRouteInfo ( swipeBackRouteInfo , this . id , false ) ;
907+ if ( ! enteringViewItem ) {
908+ enteringViewItem = this . context . findViewItemByRouteInfo ( swipeBackRouteInfo , undefined , false ) ;
909+ }
713910 const leavingViewItem = this . context . findViewItemByRouteInfo ( routeInfo , this . id , false ) ;
714911
912+ // Ensure the entering view is mounted so React keeps rendering it during the gesture.
913+ // This is important when the view was previously marked for unmount but its
914+ // ionPageElement is still in the DOM.
915+ if ( enteringViewItem && ! enteringViewItem . mount ) {
916+ enteringViewItem . mount = true ;
917+ }
918+
715919 // When the gesture starts, kick off a transition controlled via swipe gesture
716920 if ( enteringViewItem && leavingViewItem ) {
717921 await this . transitionPage ( routeInfo , enteringViewItem , leavingViewItem , 'back' , true ) ;
@@ -729,7 +933,11 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
729933 // Swipe gesture was aborted - re-hide the page that was going to enter
730934 const { routeInfo } = this . props ;
731935 const swipeBackRouteInfo = this . getSwipeBackRouteInfo ( ) ;
732- const enteringViewItem = this . context . findViewItemByRouteInfo ( swipeBackRouteInfo , this . id , false ) ;
936+ // First try to find the view in the current outlet, then search all outlets
937+ let enteringViewItem = this . context . findViewItemByRouteInfo ( swipeBackRouteInfo , this . id , false ) ;
938+ if ( ! enteringViewItem ) {
939+ enteringViewItem = this . context . findViewItemByRouteInfo ( swipeBackRouteInfo , undefined , false ) ;
940+ }
733941 const leavingViewItem = this . context . findViewItemByRouteInfo ( routeInfo , this . id , false ) ;
734942
735943 // Don't hide if entering and leaving are the same (parameterized route edge case)
0 commit comments