@@ -66,6 +66,22 @@ const showIonPageElement = (element: HTMLElement | undefined): void => {
6666 }
6767} ;
6868
69+ /**
70+ * Variant of `showIonPageElement` for the swipe-back gesture start. Clears
71+ * `display: none` and the hidden class/attribute so the entering view is
72+ * visible, but intentionally keeps any inline `transform` and `opacity` set
73+ * by core's prior forward transition. The gesture's progress animation starts
74+ * from that pose, so clearing them here would cause a visible jump before
75+ * core's progress animation takes over.
76+ */
77+ const revealIonPageForSwipeBack = ( element : HTMLElement | undefined ) : void => {
78+ if ( element ) {
79+ element . style . removeProperty ( 'display' ) ;
80+ element . classList . remove ( 'ion-page-hidden' ) ;
81+ element . removeAttribute ( 'aria-hidden' ) ;
82+ }
83+ } ;
84+
6985/**
7086 * A leaf view is "preservable" on browser-back (pop) when its React state
7187 * should survive a forward-pop round-trip. Non-parameterized leaf paths
@@ -113,6 +129,15 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
113129 * duplicate transitions during rapid navigation (e.g., Navigate redirects)
114130 */
115131 private lastTransition ?: { enteringId : string ; leavingId ?: string } ;
132+ /**
133+ * Views that have been explicitly kept alive by the pop-preserve logic
134+ * (shouldPreserveLeavingView) so a future forward-pop can restore their React
135+ * state. These are candidates for cleanup when a fresh push invalidates the
136+ * forward-history path that made them reachable. Views mounted through
137+ * normal forward-push (which keeps the leaving view alive by default) are
138+ * NOT tracked here.
139+ */
140+ private preservedViewItems = new Set < ViewItem > ( ) ;
116141 /** Tracks whether the component is mounted to guard async transition paths. */
117142 private _isMounted = false ;
118143 /** In-flight requestAnimationFrame IDs from transitionPage, cancelled on unmount. */
@@ -505,6 +530,9 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
505530 if ( ! enteringViewItem . mount ) {
506531 enteringViewItem . mount = true ;
507532 }
533+ // A view that becomes the entering view is no longer a stale preserved view.
534+ // It's back in the active navigation path, so drop it from the cleanup set.
535+ this . preservedViewItems . delete ( enteringViewItem ) ;
508536
509537 // Check visibility state BEFORE showing entering view
510538 const enteringWasVisible = enteringViewItem . ionPageElement && isViewVisible ( enteringViewItem . ionPageElement ) ;
@@ -581,6 +609,8 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
581609 routeInfo . routeAction === 'pop' && isViewItemPreservableOnPop ( leavingViewItem ) ;
582610 if ( routeInfo . routeAction !== 'replace' && ! shouldPreserveLeavingView ) {
583611 leavingViewItem . mount = false ;
612+ } else if ( shouldPreserveLeavingView ) {
613+ this . preservedViewItems . add ( leavingViewItem ) ;
584614 }
585615 this . handleLeavingViewUnmount ( routeInfo , enteringViewItem , leavingViewItem ) ;
586616 }
@@ -595,9 +625,11 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
595625 }
596626
597627 /**
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.
628+ * Unmounts views previously kept alive by the pop-preserve logic when a fresh
629+ * push invalidates the forward-history path that made them reachable. Only
630+ * iterates views explicitly tracked in `preservedViewItems` so that views
631+ * naturally mounted through forward-push (the default leaving-view behavior)
632+ * are left untouched.
601633 */
602634 private cleanupPreservedViewsOnPush (
603635 routeInfo : RouteInfo ,
@@ -607,19 +639,21 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
607639 if ( routeInfo . routeAction !== 'push' ) {
608640 return ;
609641 }
642+ if ( this . preservedViewItems . size === 0 ) {
643+ return ;
644+ }
610645
611- const allViews = this . context . getViewItemsForOutlet ( this . id ) ;
612- for ( const viewItem of allViews ) {
646+ for ( const viewItem of Array . from ( this . preservedViewItems ) ) {
613647 if ( viewItem === enteringViewItem || viewItem === leavingViewItem ) {
648+ this . preservedViewItems . delete ( viewItem ) ;
614649 continue ;
615650 }
616651 if ( ! viewItem . mount ) {
617- continue ;
618- }
619- if ( ! isViewItemPreservableOnPop ( viewItem ) ) {
652+ this . preservedViewItems . delete ( viewItem ) ;
620653 continue ;
621654 }
622655 viewItem . mount = false ;
656+ this . preservedViewItems . delete ( viewItem ) ;
623657 const viewToUnmount = viewItem ;
624658 setTimeout ( ( ) => {
625659 this . context . unMountViewItem ( viewToUnmount ) ;
@@ -863,6 +897,8 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
863897 routeInfo . routeAction === 'pop' && isViewItemPreservableOnPop ( leavingViewItem ) ;
864898 if ( routeInfo . routeAction !== 'replace' && ! shouldPreserveLeavingView ) {
865899 leavingViewItem . mount = false ;
900+ } else if ( shouldPreserveLeavingView ) {
901+ this . preservedViewItems . add ( leavingViewItem ) ;
866902 }
867903 this . handleLeavingViewUnmount ( routeInfo , enteringViewItem , leavingViewItem ) ;
868904 }
@@ -904,6 +940,8 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
904940 routeInfo . routeAction === 'pop' && isViewItemPreservableOnPop ( latestLeavingView ) ;
905941 if ( routeInfo . routeAction !== 'replace' && ! shouldPreserveLeavingView ) {
906942 latestLeavingView . mount = false ;
943+ } else if ( shouldPreserveLeavingView ) {
944+ this . preservedViewItems . add ( latestLeavingView ) ;
907945 }
908946 this . handleLeavingViewUnmount ( routeInfo , latestEnteringView , latestLeavingView ) ;
909947 }
@@ -1010,6 +1048,7 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
10101048 this . outOfScopeUnmountTimeout = undefined ;
10111049 }
10121050 this . waitingForIonPage = false ;
1051+ this . preservedViewItems . clear ( ) ;
10131052
10141053 // Hide all views in this outlet before clearing.
10151054 // This is critical for nested outlets - when the parent component unmounts,
@@ -1137,6 +1176,8 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
11371176 routeInfo . routeAction === 'pop' && isViewItemPreservableOnPop ( leavingViewItem ) ;
11381177 if ( shouldUnmountLeavingViewItem && ! shouldPreserveLeavingView ) {
11391178 leavingViewItem . mount = false ;
1179+ } else if ( shouldUnmountLeavingViewItem && shouldPreserveLeavingView ) {
1180+ this . preservedViewItems . add ( leavingViewItem ) ;
11401181 }
11411182 }
11421183 }
@@ -1340,6 +1381,11 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
13401381 enteringViewItem . mount = true ;
13411382 }
13421383
1384+ // Reveal synchronously. `transitionPage` defers this behind async commit,
1385+ // but the gesture's first progress frame fires in the same tick as onStart,
1386+ // so an async reveal leaves the entering page hidden until the next frame.
1387+ revealIonPageForSwipeBack ( enteringViewItem ?. ionPageElement ) ;
1388+
13431389 // When the gesture starts, kick off a transition controlled via swipe gesture
13441390 if ( enteringViewItem && leavingViewItem ) {
13451391 await this . transitionPage ( routeInfo , enteringViewItem , leavingViewItem , 'back' , true ) ;
0 commit comments