Skip to content

Commit 04e9c97

Browse files
committed
fix(react-router): preserve nested ionPage views during parent swipe-back
1 parent 7082943 commit 04e9c97

2 files changed

Lines changed: 144 additions & 14 deletions

File tree

packages/react-router/src/ReactRouter/StackManager.tsx

Lines changed: 71 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -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)
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { test, expect } from '@playwright/test';
2+
import { ionPageVisible } from './utils/test-utils';
3+
import { ionSwipeToGoBack } from './utils/drag-utils';
4+
5+
const IOS_MODE = 'ionic:mode=ios';
6+
7+
/**
8+
* Tests that swipe-to-go-back works correctly for ionPage outlets
9+
* (nested IonRouterOutlet components with the ionPage prop).
10+
*
11+
* Bug: when navigating from one ionPage outlet (section-a) to a sibling
12+
* (section-b), the previous outlet's child content was destroyed by the
13+
* deferred unmount. During swipe-back, the entering page was an empty shell.
14+
*
15+
* Root causes:
16+
* 1. When the current outlet had no matching view, the fallback search
17+
* across all outlets accepted any candidate without verifying the
18+
* candidate's page element actually lives inside the current outlet.
19+
* A nested child outlet could then run the swipe gesture on a sibling's
20+
* view, driving the transition on the wrong router outlet.
21+
* 2. The deferred unmount in handleOutOfScopeOutlet removed section-a's child
22+
* content before the swipe gesture could reveal it.
23+
*/
24+
test.describe('ionPage outlet swipe-to-go-back', () => {
25+
test('section-a content should be visible during swipe-back from section-b', async ({ page }) => {
26+
// Navigate to modal-aria-hidden test page (has sibling ionPage outlets)
27+
await page.goto(`/modal-aria-hidden?${IOS_MODE}`);
28+
await ionPageVisible(page, 'modal-page-a');
29+
30+
// Open modal and navigate to Section B without dismissing
31+
await page.locator('#openModal').click();
32+
await page.locator('ion-modal').waitFor({ state: 'visible' });
33+
await page.locator('#navigateToB').click();
34+
await ionPageVisible(page, 'modal-page-b');
35+
36+
// Start a swipe-back gesture and hold mid-way
37+
const outlet = page.locator('ion-router-outlet#modal-aria-hidden-root');
38+
const box = await outlet.boundingBox();
39+
if (!box) throw new Error('Root outlet not found');
40+
41+
await page.mouse.move(box.x, box.y + box.height / 2);
42+
await page.mouse.down();
43+
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2, { steps: 10 });
44+
await page.waitForTimeout(300);
45+
46+
// Mid-swipe: section-a should be visible (not display:none) with its child content
47+
const sectionA = page.locator('#section-a');
48+
const computedDisplay = await sectionA.evaluate((el: HTMLElement) => getComputedStyle(el).display);
49+
expect(computedDisplay).not.toBe('none');
50+
51+
const pageACount = await sectionA.locator('[data-pageid="modal-page-a"]').count();
52+
expect(pageACount).toBe(1);
53+
54+
// Release the gesture
55+
await page.mouse.up();
56+
});
57+
58+
test('should abort swipe-back and stay on section-b', async ({ page }) => {
59+
await page.goto(`/modal-aria-hidden?${IOS_MODE}`);
60+
await ionPageVisible(page, 'modal-page-a');
61+
62+
await page.locator('#openModal').click();
63+
await page.locator('ion-modal').waitFor({ state: 'visible' });
64+
await page.locator('#navigateToB').click();
65+
await ionPageVisible(page, 'modal-page-b');
66+
67+
// Abort the swipe-back gesture (small swipe)
68+
await ionSwipeToGoBack(page, false, 'ion-router-outlet#modal-aria-hidden-root');
69+
70+
// Should still be on section B
71+
await ionPageVisible(page, 'modal-page-b');
72+
});
73+
});

0 commit comments

Comments
 (0)