@@ -267,6 +267,24 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
267267 }
268268 this . wasInScope = false ;
269269
270+ // For ionPage outlets whose parent outlet has swipe-to-go-back enabled,
271+ // preserve child views so they remain visible during the swipe gesture.
272+ // Without this, the deferred unmount removes child pages before the gesture
273+ // starts, showing an empty shell when swiping back. Views are cleaned up
274+ // when the parent outlet pops this view (componentWillUnmount -> clearOutlet).
275+ //
276+ // Lifecycle events are skipped in this branch because the view stays mounted
277+ // and visible. Firing ionViewWillLeave/DidLeave here would be asymmetric with
278+ // no matching ionViewWillEnter/DidEnter when the view comes back in scope.
279+ const isIonPageOutlet = this . routerOutletElement ?. classList . contains ( 'ion-page' ) ;
280+ if ( isIonPageOutlet ) {
281+ const parentOutlet = this . routerOutletElement ?. parentElement ?. closest < HTMLIonRouterOutletElement > ( 'ion-router-outlet' ) ;
282+ if ( parentOutlet ?. swipeGesture === true ) {
283+ this . dismissPresentedOverlays ( ) ;
284+ return true ;
285+ }
286+ }
287+
270288 // Fire lifecycle events on any visible view before unmounting.
271289 // When navigating away from a tabbed section, the parent outlet fires
272290 // ionViewDidLeave on the tabs container, but the active tab child page
@@ -1141,17 +1159,64 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
11411159 } , VIEW_UNMOUNT_DELAY_MS ) ;
11421160 }
11431161
1162+ /**
1163+ * Dismisses every presented Ionic overlay in the document. Core moves overlays
1164+ * to ion-app when presented, so they are no longer descendants of this outlet
1165+ * and stay visible even after the outlet is hidden, blocking the entering view.
1166+ *
1167+ * Scope is document-wide because the original outlet-to-overlay DOM linkage is
1168+ * lost after presentation. Overlays without a trackable presenting element
1169+ * cannot be safely attributed to a specific outlet.
1170+ */
1171+ private dismissPresentedOverlays ( ) : void {
1172+ type PresentedOverlay =
1173+ | HTMLIonModalElement
1174+ | HTMLIonPopoverElement
1175+ | HTMLIonActionSheetElement
1176+ | HTMLIonAlertElement
1177+ | HTMLIonLoadingElement ;
1178+ // Matches the overlay set tracked by core's getPresentedOverlays (see
1179+ // core/src/utils/overlays.ts). An overlay is "presented" when it lacks the
1180+ // `overlay-hidden` class.
1181+ const overlaySelector = 'ion-modal, ion-popover, ion-action-sheet, ion-alert, ion-loading' ;
1182+ document . querySelectorAll < PresentedOverlay > ( overlaySelector ) . forEach ( ( overlay ) => {
1183+ if ( overlay . classList . contains ( 'overlay-hidden' ) || typeof overlay . dismiss !== 'function' ) {
1184+ return ;
1185+ }
1186+ overlay . dismiss ( ) . catch ( ( ) => {
1187+ /* Overlay may already be dismissing or its canDismiss guard may block it. */
1188+ } ) ;
1189+ } ) ;
1190+ }
1191+
1192+ /**
1193+ * Resolves the entering view for a swipe-back gesture.
1194+ *
1195+ * Prefers a view owned by this outlet. Falls back to searching all outlets only
1196+ * when the candidate's ion-page element is a descendant of this outlet. Without
1197+ * the containment guard, a nested child outlet can claim ownership of a sibling
1198+ * outlet's view, running the swipe gesture on the wrong router outlet.
1199+ */
1200+ private findEnteringViewForSwipe ( swipeBackRouteInfo : RouteInfo ) : ViewItem | undefined {
1201+ const enteringViewItem = this . context . findViewItemByRouteInfo ( swipeBackRouteInfo , this . id , false ) ;
1202+ if ( enteringViewItem ) {
1203+ return enteringViewItem ;
1204+ }
1205+ const candidate = this . context . findViewItemByRouteInfo ( swipeBackRouteInfo , undefined , false ) ;
1206+ if ( candidate ?. ionPageElement && this . routerOutletElement ?. contains ( candidate . ionPageElement ) ) {
1207+ return candidate ;
1208+ }
1209+ return undefined ;
1210+ }
1211+
11441212 /**
11451213 * Configures swipe-to-go-back gesture for the router outlet.
11461214 */
11471215 async setupRouterOutlet ( routerOutlet : HTMLIonRouterOutletElement ) {
11481216 const canStart = ( ) => {
11491217 const { routeInfo } = this . props ;
11501218 const swipeBackRouteInfo = this . getSwipeBackRouteInfo ( ) ;
1151- let enteringViewItem = this . context . findViewItemByRouteInfo ( swipeBackRouteInfo , this . id , false ) ;
1152- if ( ! enteringViewItem ) {
1153- enteringViewItem = this . context . findViewItemByRouteInfo ( swipeBackRouteInfo , undefined , false ) ;
1154- }
1219+ const enteringViewItem = this . findEnteringViewForSwipe ( swipeBackRouteInfo ) ;
11551220
11561221 // View might have mount=false but ionPageElement still in DOM
11571222 const ionPageInDocument = Boolean (
@@ -1175,11 +1240,7 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
11751240 const onStart = async ( ) => {
11761241 const { routeInfo } = this . props ;
11771242 const swipeBackRouteInfo = this . getSwipeBackRouteInfo ( ) ;
1178- // First try to find the view in the current outlet, then search all outlets
1179- let enteringViewItem = this . context . findViewItemByRouteInfo ( swipeBackRouteInfo , this . id , false ) ;
1180- if ( ! enteringViewItem ) {
1181- enteringViewItem = this . context . findViewItemByRouteInfo ( swipeBackRouteInfo , undefined , false ) ;
1182- }
1243+ const enteringViewItem = this . findEnteringViewForSwipe ( swipeBackRouteInfo ) ;
11831244 const leavingViewItem = this . context . findViewItemByRouteInfo ( routeInfo , this . id , false ) ;
11841245
11851246 // Ensure the entering view is mounted so React keeps rendering it during the gesture.
@@ -1206,11 +1267,7 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
12061267 // Swipe gesture was aborted - re-hide the page that was going to enter
12071268 const { routeInfo } = this . props ;
12081269 const swipeBackRouteInfo = this . getSwipeBackRouteInfo ( ) ;
1209- // First try to find the view in the current outlet, then search all outlets
1210- let enteringViewItem = this . context . findViewItemByRouteInfo ( swipeBackRouteInfo , this . id , false ) ;
1211- if ( ! enteringViewItem ) {
1212- enteringViewItem = this . context . findViewItemByRouteInfo ( swipeBackRouteInfo , undefined , false ) ;
1213- }
1270+ const enteringViewItem = this . findEnteringViewForSwipe ( swipeBackRouteInfo ) ;
12141271 const leavingViewItem = this . context . findViewItemByRouteInfo ( routeInfo , this . id , false ) ;
12151272
12161273 // Don't hide if entering and leaving are the same (parameterized route edge case)
0 commit comments