Skip to content

Commit c8ea46d

Browse files
committed
fix(react-router): prevent swipe-back gesture from starting when entering and leaving views resolve to the same parameterized route
1 parent 5d95d91 commit c8ea46d

File tree

5 files changed

+306
-1
lines changed

5 files changed

+306
-1
lines changed

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1073,10 +1073,16 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
10731073
enteringViewItem?.ionPageElement && document.body.contains(enteringViewItem.ionPageElement)
10741074
);
10751075

1076+
// For wildcard/parameterized routes, the pattern path (e.g. "/foo/*") will
1077+
// never equal the resolved pathname (e.g. "/foo/bar"), so the pattern check
1078+
// alone isn't sufficient. Also, verify the entering view's resolved pathname
1079+
// differs from the current pathname — if they match, the entering and leaving
1080+
// views are the same and the swipe gesture shouldn't start.
10761081
const canStartSwipe =
10771082
!!enteringViewItem &&
10781083
(enteringViewItem.mount || ionPageInDocument) &&
1079-
enteringViewItem.routeData.match.pattern.path !== routeInfo.pathname;
1084+
enteringViewItem.routeData.match.pattern.path !== routeInfo.pathname &&
1085+
enteringViewItem.routeData.match.pathname !== routeInfo.pathname;
10801086

10811087
return canStartSwipe;
10821088
};

packages/react-router/test/base/src/App.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ import MultiStepBack from './pages/multi-step-back/MultiStepBack';
6161
import DirectionNoneBack from './pages/direction-none-back/DirectionNoneBack';
6262
import TabSearchParams from './pages/tab-search-params/TabSearchParams';
6363
import { Step1, Step2, Step3, Step4 } from './pages/replace-params/ReplaceParams';
64+
import { ParamSwipeBack, ParamSwipeBackB } from './pages/param-swipe-back/ParamSwipeBack';
6465

6566
setupIonicReact();
6667

@@ -110,6 +111,8 @@ const App: React.FC = () => {
110111
<Route path="/multi-step-back/*" element={<MultiStepBack />} />
111112
<Route path="/direction-none-back/*" element={<DirectionNoneBack />} />
112113
<Route path="/tab-search-params/*" element={<TabSearchParams />} />
114+
<Route path="/param-swipe-back/*" element={<ParamSwipeBack />} />
115+
<Route path="/param-swipe-back-b/*" element={<ParamSwipeBackB />} />
113116
<Route path="/replace-params/step1" element={<Step1 />} />
114117
<Route path="/replace-params/step2/:id" element={<Step2 />} />
115118
<Route path="/replace-params/step3/:id" element={<Step3 />} />

packages/react-router/test/base/src/pages/Main.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,9 @@ const Main: React.FC = () => {
128128
<IonItem routerLink="/replace-params/step1">
129129
<IonLabel>Replace Params</IonLabel>
130130
</IonItem>
131+
<IonItem routerLink="/param-swipe-back">
132+
<IonLabel>Param Swipe Back</IonLabel>
133+
</IonItem>
131134
</IonList>
132135
</IonContent>
133136
</IonPage>
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import {
2+
IonRouterOutlet,
3+
IonPage,
4+
IonHeader,
5+
IonToolbar,
6+
IonTitle,
7+
IonContent,
8+
IonItem,
9+
IonButton,
10+
IonButtons,
11+
IonBackButton,
12+
} from '@ionic/react';
13+
import React from 'react';
14+
import { Route } from 'react-router';
15+
import { useParams, useNavigate } from 'react-router-dom';
16+
17+
/**
18+
* Test scenarios for issue #27900:
19+
* Swipe back gesture breaks when navigating between parameterized routes.
20+
*
21+
* Reproduction A: /user/alex -> /middle -> /user/sean -> swipe back -> swipe back (broken)
22+
* Reproduction B: /item/one -> replace /item/two -> /item/two/details -> swipe back (broken)
23+
*/
24+
25+
// --- Reproduction A ---
26+
export const ParamSwipeBack: React.FC = () => {
27+
return (
28+
<IonRouterOutlet id="param-swipe-back">
29+
<Route path="/param-swipe-back" element={<Start />} />
30+
<Route path="/param-swipe-back/user/:name" element={<UserPage />} />
31+
<Route path="/param-swipe-back/middle" element={<MiddlePage />} />
32+
</IonRouterOutlet>
33+
);
34+
};
35+
36+
const Start: React.FC = () => {
37+
return (
38+
<IonPage data-pageid="psb-start">
39+
<IonHeader>
40+
<IonToolbar>
41+
<IonTitle>Param Swipe Back</IonTitle>
42+
</IonToolbar>
43+
</IonHeader>
44+
<IonContent>
45+
<IonItem routerLink="/param-swipe-back/user/alex" id="go-to-alex">
46+
Go to User Alex
47+
</IonItem>
48+
<IonItem routerLink="/param-swipe-back/user/sean" id="go-to-sean">
49+
Go to User Sean
50+
</IonItem>
51+
<IonItem routerLink="/param-swipe-back/middle" id="go-to-middle">
52+
Go to Middle
53+
</IonItem>
54+
</IonContent>
55+
</IonPage>
56+
);
57+
};
58+
59+
const UserPage: React.FC = () => {
60+
const { name } = useParams<{ name: string }>();
61+
return (
62+
<IonPage data-pageid={`psb-user-${name}`}>
63+
<IonHeader>
64+
<IonToolbar>
65+
<IonButtons slot="start">
66+
<IonBackButton />
67+
</IonButtons>
68+
<IonTitle>User {name}</IonTitle>
69+
</IonToolbar>
70+
</IonHeader>
71+
<IonContent>
72+
<div>User: {name}</div>
73+
<IonItem routerLink="/param-swipe-back/middle" id="go-to-middle">
74+
Go to Middle
75+
</IonItem>
76+
<IonItem routerLink="/param-swipe-back/user/alex" id="go-to-alex">
77+
Go to User Alex
78+
</IonItem>
79+
<IonItem routerLink="/param-swipe-back/user/sean" id="go-to-sean">
80+
Go to User Sean
81+
</IonItem>
82+
</IonContent>
83+
</IonPage>
84+
);
85+
};
86+
87+
const MiddlePage: React.FC = () => {
88+
return (
89+
<IonPage data-pageid="psb-middle">
90+
<IonHeader>
91+
<IonToolbar>
92+
<IonButtons slot="start">
93+
<IonBackButton />
94+
</IonButtons>
95+
<IonTitle>Middle Page</IonTitle>
96+
</IonToolbar>
97+
</IonHeader>
98+
<IonContent>
99+
<div>Middle Page</div>
100+
<IonItem routerLink="/param-swipe-back/user/alex" id="go-to-alex">
101+
Go to User Alex
102+
</IonItem>
103+
<IonItem routerLink="/param-swipe-back/user/sean" id="go-to-sean">
104+
Go to User Sean
105+
</IonItem>
106+
</IonContent>
107+
</IonPage>
108+
);
109+
};
110+
111+
// --- Reproduction B ---
112+
export const ParamSwipeBackB: React.FC = () => {
113+
return (
114+
<IonRouterOutlet id="param-swipe-back-b">
115+
<Route path="/param-swipe-back-b/item/:name" element={<ItemPage />} />
116+
<Route path="/param-swipe-back-b/item/:name/details" element={<ItemDetails />} />
117+
</IonRouterOutlet>
118+
);
119+
};
120+
121+
const ItemPage: React.FC = () => {
122+
const { name } = useParams<{ name: string }>();
123+
const navigate = useNavigate();
124+
return (
125+
<IonPage data-pageid={`psb-item-${name}`}>
126+
<IonHeader>
127+
<IonToolbar>
128+
<IonButtons slot="start">
129+
<IonBackButton />
130+
</IonButtons>
131+
<IonTitle>Item {name}</IonTitle>
132+
</IonToolbar>
133+
</IonHeader>
134+
<IonContent>
135+
<div>Item: {name}</div>
136+
<IonButton id="replace-with-two" onClick={() => navigate('/param-swipe-back-b/item/two', { replace: true })}>
137+
Replace with /item/two
138+
</IonButton>
139+
<IonItem routerLink={`/param-swipe-back-b/item/${name}/details`} id="go-to-details">
140+
Go to Details
141+
</IonItem>
142+
</IonContent>
143+
</IonPage>
144+
);
145+
};
146+
147+
const ItemDetails: React.FC = () => {
148+
const { name } = useParams<{ name: string }>();
149+
return (
150+
<IonPage data-pageid={`psb-item-${name}-details`}>
151+
<IonHeader>
152+
<IonToolbar>
153+
<IonButtons slot="start">
154+
<IonBackButton />
155+
</IonButtons>
156+
<IonTitle>Details for {name}</IonTitle>
157+
</IonToolbar>
158+
</IonHeader>
159+
<IonContent>
160+
<div>Details for item: {name}</div>
161+
</IonContent>
162+
</IonPage>
163+
);
164+
};
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { test, expect } from '@playwright/test';
2+
import { ionPageVisible, ionPageHidden, ionPageDoesNotExist, ionGoBack, withTestingMode } from './utils/test-utils';
3+
import { ionSwipeToGoBack } from './utils/drag-utils';
4+
5+
const IOS_MODE = 'ionic:mode=ios';
6+
7+
/**
8+
* Tests for issue #27900:
9+
* Swipe back gesture breaks with Ionic React Router on certain parameterized route changes.
10+
*
11+
* Two reproductions from the issue:
12+
* A) /foo/alex -> /baz -> /foo/sean -> swipe back (ok) -> swipe back (broken)
13+
* B) /one -> replace /two -> /two/details -> swipe back (broken)
14+
*/
15+
test.describe('Param Back Navigation (#27900)', () => {
16+
17+
// --- Reproduction A ---
18+
// Routes: /user/:name and /middle
19+
// Navigate /user/alex -> /middle -> /user/sean -> back -> back
20+
// Second back should go to /user/alex
21+
22+
test('Repro A: browser back through param routes with non-param in between', async ({ page }) => {
23+
await page.goto(withTestingMode('/param-swipe-back'));
24+
await ionPageVisible(page, 'psb-start');
25+
26+
// 1. Navigate /user/alex
27+
await page.locator('[data-pageid="psb-start"] #go-to-alex').click();
28+
await page.waitForTimeout(250);
29+
await ionPageVisible(page, 'psb-user-alex');
30+
31+
// 2. Navigate /middle
32+
await page.locator('[data-pageid="psb-user-alex"] #go-to-middle').click();
33+
await page.waitForTimeout(250);
34+
await ionPageVisible(page, 'psb-middle');
35+
36+
// 3. Navigate /user/sean
37+
await page.locator('[data-pageid="psb-middle"] #go-to-sean').click();
38+
await page.waitForTimeout(250);
39+
await ionPageVisible(page, 'psb-user-sean');
40+
41+
// 4. Back → /middle
42+
await ionGoBack(page, '/param-swipe-back/middle');
43+
await ionPageVisible(page, 'psb-middle');
44+
await ionPageDoesNotExist(page, 'psb-user-sean');
45+
46+
// 5. Back → /user/alex (this is the step that broke in #27900)
47+
await ionGoBack(page, '/param-swipe-back/user/alex');
48+
await ionPageVisible(page, 'psb-user-alex');
49+
await ionPageDoesNotExist(page, 'psb-middle');
50+
});
51+
52+
test('Repro A: swipe back through param routes with non-param in between', async ({ page }) => {
53+
await page.goto(`/param-swipe-back?${IOS_MODE}`);
54+
await ionPageVisible(page, 'psb-start');
55+
56+
await page.locator('[data-pageid="psb-start"] #go-to-alex').click();
57+
await page.waitForTimeout(250);
58+
await ionPageVisible(page, 'psb-user-alex');
59+
await ionPageHidden(page, 'psb-start');
60+
61+
await page.locator('[data-pageid="psb-user-alex"] #go-to-middle').click();
62+
await page.waitForTimeout(250);
63+
await ionPageVisible(page, 'psb-middle');
64+
await ionPageHidden(page, 'psb-user-alex');
65+
66+
await page.locator('[data-pageid="psb-middle"] #go-to-sean').click();
67+
await page.waitForTimeout(250);
68+
await ionPageVisible(page, 'psb-user-sean');
69+
await ionPageHidden(page, 'psb-middle');
70+
71+
// 4. Swipe back → /middle
72+
await ionSwipeToGoBack(page, true, 'ion-router-outlet#param-swipe-back');
73+
await ionPageVisible(page, 'psb-middle');
74+
await ionPageDoesNotExist(page, 'psb-user-sean');
75+
76+
// 5. Swipe back → /user/alex
77+
await ionSwipeToGoBack(page, true, 'ion-router-outlet#param-swipe-back');
78+
await ionPageVisible(page, 'psb-user-alex');
79+
await ionPageDoesNotExist(page, 'psb-middle');
80+
});
81+
82+
// --- Reproduction B ---
83+
// Routes: /item/:name and /item/:name/details
84+
// Navigate /item/one -> replace with /item/two -> /item/two/details -> back
85+
// Back should return to /item/two
86+
87+
test('Repro B: browser back after replace + parameterized details route', async ({ page }) => {
88+
// 1. Go to /item/one
89+
await page.goto(withTestingMode('/param-swipe-back-b/item/one'));
90+
await ionPageVisible(page, 'psb-item-one');
91+
92+
// 2. Replace route with /item/two
93+
await page.locator('#replace-with-two').click();
94+
await page.waitForTimeout(250);
95+
await ionPageVisible(page, 'psb-item-two');
96+
97+
// 3. Go to /item/two/details
98+
await page.locator('[data-pageid="psb-item-two"] #go-to-details').click();
99+
await page.waitForTimeout(250);
100+
await ionPageVisible(page, 'psb-item-two-details');
101+
102+
// 4. Back → should return to /item/two
103+
await ionGoBack(page, '/param-swipe-back-b/item/two');
104+
await ionPageVisible(page, 'psb-item-two');
105+
await ionPageDoesNotExist(page, 'psb-item-two-details');
106+
});
107+
108+
test('Repro B: swipe back after replace + parameterized details route', async ({ page }) => {
109+
// 1. Go to /item/one
110+
await page.goto(`/param-swipe-back-b/item/one?${IOS_MODE}`);
111+
await ionPageVisible(page, 'psb-item-one');
112+
113+
// 2. Replace route with /item/two
114+
await page.locator('#replace-with-two').click();
115+
await page.waitForTimeout(250);
116+
await ionPageVisible(page, 'psb-item-two');
117+
118+
// 3. Go to /item/two/details
119+
await page.locator('[data-pageid="psb-item-two"] #go-to-details').click();
120+
await page.waitForTimeout(250);
121+
await ionPageVisible(page, 'psb-item-two-details');
122+
await ionPageHidden(page, 'psb-item-two');
123+
124+
// 4. Swipe back → should return to /item/two
125+
await ionSwipeToGoBack(page, true, 'ion-router-outlet#param-swipe-back-b');
126+
await ionPageVisible(page, 'psb-item-two');
127+
await ionPageDoesNotExist(page, 'psb-item-two-details');
128+
});
129+
});

0 commit comments

Comments
 (0)