Skip to content

Commit fc334cc

Browse files
committed
fix(react-router): restore back navigation and swipe over a root splat route
1 parent 420f31c commit fc334cc

4 files changed

Lines changed: 75 additions & 20 deletions

File tree

‎packages/react-router/src/ReactRouter/ReactRouterViewStack.tsx‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { analyzeRouteChildren, computeParentPath } from './utils/computeParentPa
1515
import { derivePathnameToMatch, matchPath } from './utils/pathMatching';
1616
import { normalizePathnameForComparison } from './utils/pathNormalization';
1717
import { extractRouteChildren, isNavigateElement } from './utils/routeElements';
18-
import { isForwardPush, sortViewsBySpecificity } from './utils/viewItemUtils';
18+
import { isForwardPush, isSwipeRevealed, sortViewsBySpecificity } from './utils/viewItemUtils';
1919

2020
/**
2121
* Delay in milliseconds before removing a Navigate view item after a redirect.
@@ -510,7 +510,7 @@ export class ReactRouterViewStack extends ViewStacks {
510510
}
511511
}
512512

513-
if (hasSpecificMatch) {
513+
if (hasSpecificMatch && !isSwipeRevealed(viewItem)) {
514514
// A splat can be the outlet's container page rather than a "not found" fallback.
515515
// Pushed over it is the page underneath, so unmounting it destroys its state and
516516
// leaves nothing for back to reveal. Hiding it below covers that. A view with no
@@ -636,8 +636,11 @@ export class ReactRouterViewStack extends ViewStacks {
636636

637637
// Persist the mount path for subsequent calls, mirroring StackManager.outletMountPath.
638638
// Unlike outletParentPaths (cleared when parentPath is undefined), the mount path is
639-
// intentionally sticky — it anchors the outlet's scope and is only removed in clear().
640-
if (result.outletMountPath && !this.outletMountPaths.has(outletId)) {
639+
// intentionally sticky, it anchors the outlet's scope and is only removed in clear().
640+
// A root outlet is skipped because it is mounted under nothing, so an inferred path
641+
// would scope it to whatever route was active. parentPathnameBase is undefined
642+
// exactly when the outlet has no parent matches.
643+
if (parentPathnameBase && result.outletMountPath && !this.outletMountPaths.has(outletId)) {
641644
this.outletMountPaths.set(outletId, result.outletMountPath);
642645
}
643646
}

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

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import {
2020
import { derivePathnameToMatch, matchPath } from './utils/pathMatching';
2121
import { stripTrailingSlash } from './utils/pathNormalization';
2222
import { extractRouteChildren, getRoutesChildren, isNavigateElement } from './utils/routeElements';
23-
import { isForwardPush, isOverMatchingRoute } from './utils/viewItemUtils';
23+
import { clearSwipeRevealed, isForwardPush, isOverMatchingRoute, markSwipeRevealed } from './utils/viewItemUtils';
2424

2525
/**
2626
* Delay in milliseconds before unmounting a view after a transition completes.
@@ -119,6 +119,8 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
119119
routerOutletElement: HTMLIonRouterOutletElement | undefined;
120120
prevProps?: StackManagerProps;
121121
skipTransition: boolean;
122+
/** The view item the in-flight swipe gesture revealed, so the same one gets cleared. */
123+
private swipeRevealedViewItem?: ViewItem;
122124

123125
stackContextValue: StackContextState = {
124126
registerIonPage: this.registerIonPage.bind(this),
@@ -220,7 +222,9 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
220222
hasWildcardRoute,
221223
});
222224

223-
if (result.outletMountPath && !this.outletMountPath) {
225+
// A root outlet is mounted under nothing, so caching the inferred path would scope
226+
// it to whatever route was active and make every other pathname look out of scope.
227+
if (!this.isRootOutlet && result.outletMountPath && !this.outletMountPath) {
224228
this.outletMountPath = result.outletMountPath;
225229
}
226230

@@ -305,6 +309,12 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
305309
return false;
306310
}
307311

312+
/** Clears the reveal flag from the view item the gesture marked. */
313+
private clearSwipeRevealedView(): void {
314+
clearSwipeRevealed(this.swipeRevealedViewItem);
315+
this.swipeRevealedViewItem = undefined;
316+
}
317+
308318
/**
309319
* Handles out-of-scope outlet. Returns true if transition should be aborted.
310320
*/
@@ -525,9 +535,7 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
525535
routeInfo.lastPathname.startsWith(containerBase + '/') || routeInfo.lastPathname === containerBase;
526536

527537
// A root-level "/*" leaves an empty base, so both checks above are true for every
528-
// pathname and the shortcut would skip every navigation in this outlet. That only
529-
// stops the shortcut misfiring. The root "/*" shape has other problems and is still
530-
// not fully working, which framework-react.md covers.
538+
// pathname and the shortcut would otherwise skip every navigation in this outlet.
531539
if (containerBase !== '' && currentInContainer && previousInContainer) {
532540
const updatedMatch = matchComponent(
533541
enteringViewItem.reactElement,
@@ -1089,6 +1097,7 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
10891097
}
10901098
this.waitingForIonPage = false;
10911099
this.preservedViewItems.clear();
1100+
this.clearSwipeRevealedView();
10921101

10931102
// Hide all views in this outlet before clearing.
10941103
// This is critical for nested outlets - when the parent component unmounts,
@@ -1124,6 +1133,11 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
11241133
return;
11251134
}
11261135

1136+
// A completed swipe keeps its reveal flag past goBack() so the deactivation scan does
1137+
// not re-hide the page between the gesture ending and the URL settling. Clear it at
1138+
// the start of the transition for the new pathname.
1139+
this.clearSwipeRevealedView();
1140+
11271141
// Find entering and leaving view items
11281142
const viewItems = this.findViewItems(routeInfo);
11291143
let enteringViewItem = viewItems.enteringViewItem;
@@ -1435,6 +1449,10 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
14351449
};
14361450

14371451
const onStart = async () => {
1452+
// Core only calls onEnd when a progress animation exists, so with animations off a
1453+
// previous gesture can leave its mark behind. Drop any stale one before marking.
1454+
this.clearSwipeRevealedView();
1455+
14381456
const { routeInfo } = this.props;
14391457
const swipeBackRouteInfo = this.getSwipeBackRouteInfo();
14401458
const enteringViewItem = this.findEnteringViewForSwipe(swipeBackRouteInfo);
@@ -1465,6 +1483,12 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
14651483

14661484
// When the gesture starts, kick off a transition controlled via swipe gesture
14671485
if (enteringViewItem && leavingViewItem) {
1486+
// Without a leaving view there is no progress animation, and core only calls
1487+
// swipeHandler.onEnd when one exists, so nothing would clear the mark until the
1488+
// next transition.
1489+
markSwipeRevealed(enteringViewItem);
1490+
this.swipeRevealedViewItem = enteringViewItem;
1491+
14681492
await this.transitionPage(routeInfo, enteringViewItem, leavingViewItem, 'back', true);
14691493
}
14701494

@@ -1492,6 +1516,8 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
14921516
const enteringViewItem = this.findEnteringViewForSwipe(swipeBackRouteInfo);
14931517
const leavingViewItem = this.context.findViewItemByRouteInfo(routeInfo, this.id, false);
14941518

1519+
this.clearSwipeRevealedView();
1520+
14951521
// Don't hide if entering and leaving are the same (parameterized route edge case)
14961522
if (enteringViewItem !== leavingViewItem && enteringViewItem?.ionPageElement !== undefined) {
14971523
hideIonPageElement(enteringViewItem.ionPageElement);

‎packages/react-router/src/ReactRouter/utils/viewItemUtils.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,30 @@ export const isOverMatchingRoute = (route: { path?: string; index?: boolean }):
5555
export const isForwardPush = (routeInfo: Pick<RouteInfo, 'routeAction' | 'routeDirection'>): boolean =>
5656
routeInfo.routeAction === 'push' && routeInfo.routeDirection === 'forward';
5757

58+
const swipeRevealed = new WeakSet<ViewItem>();
59+
60+
/**
61+
* Marks the page a swipe-back gesture has revealed. For the length of the drag that page
62+
* is on screen while a more specific sibling still matches the current pathname, and the
63+
* deactivation scan in `renderViewItem` would otherwise re-hide it on the next render and
64+
* leave the user dragging a blank page.
65+
*/
66+
export const markSwipeRevealed = (viewItem: ViewItem | undefined): void => {
67+
if (viewItem) {
68+
swipeRevealed.add(viewItem);
69+
}
70+
};
71+
72+
/** Drops the mark once the gesture ends, so the view is hidden normally again. */
73+
export const clearSwipeRevealed = (viewItem: ViewItem | undefined): void => {
74+
if (viewItem) {
75+
swipeRevealed.delete(viewItem);
76+
}
77+
};
78+
79+
/** True while a swipe-back gesture is showing this view. */
80+
export const isSwipeRevealed = (viewItem: ViewItem): boolean => swipeRevealed.has(viewItem);
81+
5882
/**
5983
* Sorts view items by route specificity (most specific first).
6084
*

‎packages/react-router/test/base/tests/e2e/playwright/splat-sibling.spec.ts‎

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -50,17 +50,14 @@ test.describe('splat route with a more specific sibling', () => {
5050
await expect(page.locator('[data-testid="count"]')).toHaveText('3');
5151
});
5252

53-
/*
54-
Swipe-to-go-back over a splat container. Animations must be on, so the gesture reveals
55-
the page underneath rather than a commit doing it.
56-
57-
The gesture starts, but the entering page goes back to display:none with
58-
ion-page-hidden re-applied, so the drag shows nothing and the navigation never
59-
commits. Neither the renderViewItem deactivation scan nor the splat's stored
60-
routeData.match is the cause, both were tried. No follow-up ticket yet, so there is no
61-
annotation on this one, and it is not issue 31477.
62-
*/
63-
test.fixme('reveals the splat page while swiping back', async ({ page }) => {
53+
// Animations stay on here, so the gesture reveals the page underneath rather than a
54+
// commit doing it.
55+
test('reveals the splat page while swiping back', async ({ page }, testInfo) => {
56+
testInfo.annotations.push({
57+
type: 'issue',
58+
description: 'https://github.com/ionic-team/ionic-framework/issues/31477',
59+
});
60+
6461
await page.goto('/splat-sibling?ionic:mode=ios');
6562
await ionPageVisible(page, 'splat-sibling-home');
6663

@@ -69,6 +66,11 @@ test.describe('splat route with a more specific sibling', () => {
6966

7067
await page.locator('[data-testid="open-detail"]').click();
7168
await ionPageVisible(page, 'splat-sibling-detail');
69+
// ionPageHidden resolves early here, because the deactivation scan applies
70+
// ion-page-hidden at render time rather than on commit, so wait out the push
71+
// transition before starting the gesture.
72+
await ionPageHidden(page, 'splat-sibling-home');
73+
await page.waitForTimeout(600);
7274

7375
await ionSwipeToGoBack(page, true, 'ion-router-outlet#splat-sibling-outlet');
7476

0 commit comments

Comments
 (0)