@@ -53,11 +53,42 @@ const hideIonPageElement = (element: HTMLElement | undefined): void => {
5353const showIonPageElement = ( element : HTMLElement | undefined ) : void => {
5454 if ( element ) {
5555 element . style . removeProperty ( 'visibility' ) ;
56+ // core transitions (md.transition.ts, ios.transition.ts) leave inline styles
57+ // on the leaving page after animation: `display: none` plus the final
58+ // keyframe values for `transform` and `opacity` (MD only). Preserved views
59+ // must clear all of these on re-entry so they render in the correct
60+ // position when the back direction entering animation is a no-op.
61+ element . style . removeProperty ( 'display' ) ;
62+ element . style . removeProperty ( 'transform' ) ;
63+ element . style . removeProperty ( 'opacity' ) ;
5664 element . classList . remove ( 'ion-page-hidden' ) ;
5765 element . removeAttribute ( 'aria-hidden' ) ;
5866 }
5967} ;
6068
69+ /**
70+ * A leaf view is "preservable" on browser-back (pop) when its React state
71+ * should survive a forward-pop round-trip. Non-parameterized leaf paths
72+ * resolve to the same view item on re-entry, so keeping them mounted retains
73+ * user-visible state (scroll, inputs, cleared lists, etc.).
74+ *
75+ * Excluded:
76+ * - Parameterized routes (`/users/:id`): each param value gets a distinct
77+ * view item, so preserving them accumulates hidden views in the DOM.
78+ * - Wildcard container routes (`/tabs/*`, `*`): wrap nested outlets and must
79+ * be destroyed so nested outlet state rebuilds cleanly on re-entry.
80+ */
81+ const isViewItemPreservableOnPop = ( viewItem : ViewItem | undefined ) : boolean => {
82+ const path = viewItem ?. reactElement ?. props ?. path as string | undefined ;
83+ if ( ! path ) {
84+ return false ;
85+ }
86+ if ( path === '*' || path . endsWith ( '/*' ) ) {
87+ return false ;
88+ }
89+ return ! path . includes ( ':' ) ;
90+ } ;
91+
6192export class StackManager extends React . PureComponent < StackManagerProps > {
6293 id : string ; // Unique id for the router outlet aka outletId
6394 context ! : React . ContextType < typeof RouteManagerContext > ;
@@ -535,17 +566,66 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
535566 this . transitionPage ( routeInfo , enteringViewItem , leavingViewItem , undefined , false , shouldSkipAnimation ) ;
536567
537568 if ( shouldUnmountLeavingViewItem && leavingViewItem && enteringViewItem !== leavingViewItem ) {
538- // For non-replace actions (back nav), set mount=false here to hide the view.
539- // For replace actions, handleLeavingViewUnmount sets mount=false only after
540- // its container-to-container guard passes, avoiding zombie state.
541- if ( routeInfo . routeAction !== 'replace' ) {
569+ // For replace actions, skip setting mount=false here. handleLeavingViewUnmount
570+ // sets it only after its container-to-container guard passes, avoiding zombie state.
571+ //
572+ // For pop (browser back) on preservable routes (see isViewItemPreservableOnPop),
573+ // keep the view alive (hidden by the transition) so its React state survives a
574+ // forward-pop round-trip. ionViewDidLeave already fired via the transitionPage()
575+ // call above. Swipe-to-go-back still destroys views through the skipTransition
576+ // path earlier in this method, matching native gesture behavior.
577+ //
578+ // handleLeavingViewUnmount below is a no-op for non-replace actions (early return),
579+ // so pop-preserved views pass through it untouched.
580+ const shouldPreserveLeavingView =
581+ routeInfo . routeAction === 'pop' && isViewItemPreservableOnPop ( leavingViewItem ) ;
582+ if ( routeInfo . routeAction !== 'replace' && ! shouldPreserveLeavingView ) {
542583 leavingViewItem . mount = false ;
543584 }
544585 this . handleLeavingViewUnmount ( routeInfo , enteringViewItem , leavingViewItem ) ;
545586 }
546587
547588 // Clean up orphaned sibling views after replace actions (redirects)
548589 this . cleanupOrphanedSiblingViews ( routeInfo , enteringViewItem , leavingViewItem ) ;
590+
591+ // On a fresh push, browser forward history is invalidated. Any views we
592+ // previously preserved on pop (to support forward navigation) are now
593+ // unreachable and should be unmounted so they don't accumulate in the DOM.
594+ this . cleanupPreservedViewsOnPush ( routeInfo , enteringViewItem , leavingViewItem ) ;
595+ }
596+
597+ /**
598+ * Unmounts any previously-preserved leaf views in this outlet when a push
599+ * action invalidates the forward history path. Runs only on `push` (not
600+ * replace/pop) and skips the entering and leaving view items.
601+ */
602+ private cleanupPreservedViewsOnPush (
603+ routeInfo : RouteInfo ,
604+ enteringViewItem : ViewItem ,
605+ leavingViewItem : ViewItem | undefined
606+ ) : void {
607+ if ( routeInfo . routeAction !== 'push' ) {
608+ return ;
609+ }
610+
611+ const allViews = this . context . getViewItemsForOutlet ( this . id ) ;
612+ for ( const viewItem of allViews ) {
613+ if ( viewItem === enteringViewItem || viewItem === leavingViewItem ) {
614+ continue ;
615+ }
616+ if ( ! viewItem . mount ) {
617+ continue ;
618+ }
619+ if ( ! isViewItemPreservableOnPop ( viewItem ) ) {
620+ continue ;
621+ }
622+ viewItem . mount = false ;
623+ const viewToUnmount = viewItem ;
624+ setTimeout ( ( ) => {
625+ this . context . unMountViewItem ( viewToUnmount ) ;
626+ this . forceUpdate ( ) ;
627+ } , VIEW_UNMOUNT_DELAY_MS ) ;
628+ }
549629 }
550630
551631 /**
@@ -779,12 +859,16 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
779859
780860 // Don't unmount if entering and leaving are the same view item
781861 if ( shouldUnmountLeavingViewItem && leavingViewItem && enteringViewItem !== leavingViewItem ) {
782- if ( routeInfo . routeAction !== 'replace' ) {
862+ const shouldPreserveLeavingView =
863+ routeInfo . routeAction === 'pop' && isViewItemPreservableOnPop ( leavingViewItem ) ;
864+ if ( routeInfo . routeAction !== 'replace' && ! shouldPreserveLeavingView ) {
783865 leavingViewItem . mount = false ;
784866 }
785867 this . handleLeavingViewUnmount ( routeInfo , enteringViewItem , leavingViewItem ) ;
786868 }
787869
870+ this . cleanupPreservedViewsOnPush ( routeInfo , enteringViewItem , leavingViewItem ) ;
871+
788872 this . forceUpdate ( ) ;
789873 return ;
790874 }
@@ -816,12 +900,16 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
816900 this . transitionPage ( routeInfo , latestEnteringView , latestLeavingView ?? undefined , undefined , false , shouldSkipAnimation ) ;
817901
818902 if ( shouldUnmountLeavingViewItem && latestLeavingView && latestEnteringView !== latestLeavingView ) {
819- if ( routeInfo . routeAction !== 'replace' ) {
903+ const shouldPreserveLeavingView =
904+ routeInfo . routeAction === 'pop' && isViewItemPreservableOnPop ( latestLeavingView ) ;
905+ if ( routeInfo . routeAction !== 'replace' && ! shouldPreserveLeavingView ) {
820906 latestLeavingView . mount = false ;
821907 }
822908 this . handleLeavingViewUnmount ( routeInfo , latestEnteringView , latestLeavingView ) ;
823909 }
824910
911+ this . cleanupPreservedViewsOnPush ( routeInfo , latestEnteringView , latestLeavingView ?? undefined ) ;
912+
825913 this . forceUpdate ( ) ;
826914 } else {
827915 /**
@@ -1045,7 +1133,9 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
10451133 // No view or route found - likely leaving to another outlet
10461134 if ( leavingViewItem ) {
10471135 hideIonPageElement ( leavingViewItem . ionPageElement ) ;
1048- if ( shouldUnmountLeavingViewItem ) {
1136+ const shouldPreserveLeavingView =
1137+ routeInfo . routeAction === 'pop' && isViewItemPreservableOnPop ( leavingViewItem ) ;
1138+ if ( shouldUnmountLeavingViewItem && ! shouldPreserveLeavingView ) {
10491139 leavingViewItem . mount = false ;
10501140 }
10511141 }
0 commit comments