Skip to content

Commit 801e7cd

Browse files
committed
fix(react-router): allow swipe back over a splat container page
1 parent fc334cc commit 801e7cd

8 files changed

Lines changed: 370 additions & 35 deletions

File tree

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

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1417,22 +1417,22 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
14171417
const { routeInfo } = this.props;
14181418
const swipeBackRouteInfo = this.getSwipeBackRouteInfo();
14191419
const enteringViewItem = this.findEnteringViewForSwipe(swipeBackRouteInfo);
1420+
const leavingViewItem = this.context.findViewItemByRouteInfo(routeInfo, this.id, false);
14201421

14211422
// View might have mount=false but ionPageElement still in DOM
14221423
const ionPageInDocument = Boolean(
14231424
enteringViewItem?.ionPageElement && document.body.contains(enteringViewItem.ionPageElement)
14241425
);
14251426

1426-
// For wildcard/parameterized routes, the pattern path (e.g. "/foo/*") will
1427-
// never equal the resolved pathname (e.g. "/foo/bar"), so the pattern check
1428-
// alone isn't sufficient. Also, verify the entering view's resolved pathname
1429-
// differs from the current pathname — if they match, the entering and leaving
1430-
// views are the same and the swipe gesture shouldn't start.
1427+
// A splat consumes whatever is left of the pathname, so its match resolves to the whole
1428+
// current pathname and comparing pathnames can't tell a container page underneath a
1429+
// pushed sibling from the page being left. Compare the view items instead, like onEnd
1430+
// does. Without a leaving view onStart skips the transition, so reject that here too.
14311431
const canStartSwipe =
14321432
!!enteringViewItem &&
1433+
!!leavingViewItem &&
14331434
(enteringViewItem.mount || ionPageInDocument) &&
1434-
enteringViewItem.routeData.match.pattern.path !== routeInfo.pathname &&
1435-
enteringViewItem.routeData.match.pathname !== routeInfo.pathname;
1435+
enteringViewItem !== leavingViewItem;
14361436

14371437
debug('SwipeBackCanStart', () => ({
14381438
outletId: this.id,
@@ -1442,6 +1442,7 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
14421442
enteringViewPath: enteringViewItem?.reactElement?.props?.path,
14431443
enteringMount: enteringViewItem?.mount,
14441444
ionPageInDocument,
1445+
leavingViewId: leavingViewItem?.id,
14451446
canStartSwipe,
14461447
}));
14471448

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

Lines changed: 7 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,9 @@
1-
import { IonApp, setupIonicReact, LogLevel, IonRouterOutlet } from '@ionic/react';
1+
import { IonApp, IonRouterOutlet } from '@ionic/react';
22
import React from 'react';
33
import { Route, Navigate } from 'react-router-dom';
44

5-
/* Core CSS required for Ionic components to work properly */
6-
import '@ionic/react/css/core.css';
7-
8-
/* Basic CSS for apps built with Ionic */
9-
import '@ionic/react/css/normalize.css';
10-
import '@ionic/react/css/structure.css';
11-
import '@ionic/react/css/typography.css';
12-
13-
/* Optional CSS utils that can be commented out */
14-
import '@ionic/react/css/display.css';
15-
import '@ionic/react/css/flex-utils.css';
16-
import '@ionic/react/css/float-elements.css';
17-
import '@ionic/react/css/padding.css';
18-
import '@ionic/react/css/text-alignment.css';
19-
import '@ionic/react/css/text-transformation.css';
20-
21-
/* Theme variables */
22-
import './theme/variables.css';
5+
/* Ionic CSS and setupIonicReact */
6+
import './ionic-setup';
237
import Main from './pages/Main';
248

259
import { IonReactRouter } from '@ionic/react-router';
@@ -66,17 +50,17 @@ import { Step1, Step2, Step3, Step4 } from './pages/replace-params/ReplaceParams
6650
import { ParamSwipeBack, ParamSwipeBackB } from './pages/param-swipe-back/ParamSwipeBack';
6751
import TabLifecycle from './pages/tab-lifecycle/TabLifecycle';
6852
import TabLifecycleOutside from './pages/tab-lifecycle/TabLifecycleOutside';
69-
import { RouterLinkModifierClick, RouterLinkModifierClickTarget } from './pages/router-link-modifier-click/RouterLinkModifierClick';
53+
import {
54+
RouterLinkModifierClick,
55+
RouterLinkModifierClickTarget,
56+
} from './pages/router-link-modifier-click/RouterLinkModifierClick';
7057
import { NavigateRootPageA, NavigateRootPageB, NavigateRootPageC } from './pages/navigate-root/NavigateRoot';
7158
import SuspenseOutlet from './pages/suspense-outlet/SuspenseOutlet';
7259
import { PropsUpdateDirect, PropsUpdateRoutesWrapper } from './pages/props-update/PropsUpdate';
7360
import DisabledButton from './pages/disabled-button/DisabledButton';
7461
import SplatSibling from './pages/splat-sibling/SplatSibling';
7562
import { EmptyPathSibling, IndexSibling } from './pages/index-sibling/IndexSibling';
7663

77-
// Debug logs on so failing specs include the navigation diagnostics.
78-
setupIonicReact({ logLevel: LogLevel.DEBUG });
79-
8064
const App: React.FC = () => {
8165
return (
8266
<IonApp>

‎packages/react-router/test/base/src/index.tsx‎

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,17 @@ import React from 'react';
22
import { createRoot } from 'react-dom/client';
33

44
import App from './App';
5+
import RootSplatSiblingApp from './root-splat-sibling/RootSplatSiblingApp';
6+
import { ROOT_SPLAT_SIBLING_BASENAME } from './root-splat-sibling/basename';
7+
8+
/**
9+
* A root-level splat route swallows every pathname in its outlet, so it can't share App's
10+
* route tree. It gets its own root, picked here by pathname before anything renders.
11+
*/
12+
const { pathname } = window.location;
13+
const isRootSplatSibling =
14+
pathname === ROOT_SPLAT_SIBLING_BASENAME || pathname.startsWith(`${ROOT_SPLAT_SIBLING_BASENAME}/`);
515

616
const container = document.getElementById('root');
717
const root = createRoot(container!);
8-
root.render(
9-
<React.StrictMode>
10-
<App />
11-
</React.StrictMode>
12-
);
18+
root.render(<React.StrictMode>{isRootSplatSibling ? <RootSplatSiblingApp /> : <App />}</React.StrictMode>);
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/**
2+
* Ionic CSS and runtime config. Both App and RootSplatSiblingApp import this because
3+
* App.test.tsx renders App with no index.tsx in the graph.
4+
*
5+
* Debug logging is on so a failing spec includes the navigation diagnostics.
6+
*/
7+
import { setupIonicReact, LogLevel } from '@ionic/react';
8+
9+
/* Core CSS required for Ionic components to work properly */
10+
import '@ionic/react/css/core.css';
11+
12+
/* Basic CSS for apps built with Ionic */
13+
import '@ionic/react/css/normalize.css';
14+
import '@ionic/react/css/structure.css';
15+
import '@ionic/react/css/typography.css';
16+
17+
/* Optional CSS utils that can be commented out */
18+
import '@ionic/react/css/display.css';
19+
import '@ionic/react/css/flex-utils.css';
20+
import '@ionic/react/css/float-elements.css';
21+
import '@ionic/react/css/padding.css';
22+
import '@ionic/react/css/text-alignment.css';
23+
import '@ionic/react/css/text-transformation.css';
24+
25+
/* Theme variables */
26+
import './theme/variables.css';
27+
28+
setupIonicReact({ logLevel: LogLevel.DEBUG });

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
IonLabel,
1111
} from '@ionic/react';
1212
import React from 'react';
13+
import { ROOT_SPLAT_SIBLING_BASENAME } from '../root-splat-sibling/basename';
1314

1415
const Main: React.FC = () => {
1516
return (
@@ -162,6 +163,10 @@ const Main: React.FC = () => {
162163
<IonItem routerLink="/empty-path-sibling">
163164
<IonLabel>Empty Path Sibling</IonLabel>
164165
</IonItem>
166+
{/* A separate React root, so a plain href rather than a routerLink. */}
167+
<IonItem href={`${ROOT_SPLAT_SIBLING_BASENAME}/feed`}>
168+
<IonLabel>Root Splat Sibling</IonLabel>
169+
</IonItem>
165170
</IonList>
166171

167172
<IonList>
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import {
2+
IonApp,
3+
IonBackButton,
4+
IonButtons,
5+
IonContent,
6+
IonHeader,
7+
IonIcon,
8+
IonItem,
9+
IonLabel,
10+
IonList,
11+
IonPage,
12+
IonRouterOutlet,
13+
IonTabBar,
14+
IonTabButton,
15+
IonTabs,
16+
IonTitle,
17+
IonToolbar,
18+
} from '@ionic/react';
19+
import { IonReactRouter } from '@ionic/react-router';
20+
import { ellipse, triangle } from 'ionicons/icons';
21+
import React, { useState } from 'react';
22+
import { Navigate, Route, useParams } from 'react-router-dom';
23+
24+
/* Ionic CSS and setupIonicReact */
25+
import '../ionic-setup';
26+
import { ROOT_SPLAT_SIBLING_BASENAME } from './basename';
27+
import TestDescription from '../components/TestDescription';
28+
29+
/**
30+
* A splat container page holding tabs, next to a more specific sibling that gets pushed over
31+
* the whole tab bar, in the root outlet.
32+
*
33+
* A root catch-all can't live in the shared route tree in App.tsx without swallowing every
34+
* other spec's pathname, so this app gets its own basename and index.tsx mounts it instead
35+
* of App for that prefix.
36+
*/
37+
38+
/**
39+
* The two spellings of a root splat fail differently, so "?splat=bare" runs the same flows
40+
* against a bare "*" instead of "/*". Stamped onto the tabs page as data-splat so a test can
41+
* confirm which spelling is live.
42+
*/
43+
const splatPath = new URLSearchParams(window.location.search).get('splat') === 'bare' ? '*' : '/*';
44+
45+
/** A fresh mount gets a fresh instance id, which tells a remount apart from a reveal. */
46+
let instanceCounter = 0;
47+
const nextInstanceId = () => `tabs-${++instanceCounter}`;
48+
49+
const Feed: React.FC = () => {
50+
const [count, setCount] = useState(0);
51+
52+
return (
53+
<IonContent>
54+
<IonList>
55+
<IonItem button data-testid="increment" onClick={() => setCount((c) => c + 1)}>
56+
<IonLabel>Increment</IonLabel>
57+
</IonItem>
58+
<IonItem detail routerLink="/feed/12" data-testid="open-detail">
59+
<IonLabel>Open item 12</IonLabel>
60+
</IonItem>
61+
</IonList>
62+
<div data-testid="count">{count}</div>
63+
<TestDescription>
64+
Increment the counter, then open item 12. The detail page should push over the whole tab bar, leaving these tabs
65+
mounted behind it. Going back should reveal the same tabs with the counter unchanged.
66+
</TestDescription>
67+
</IonContent>
68+
);
69+
};
70+
71+
const FeedTab: React.FC = () => (
72+
<IonPage data-pageid="root-splat-sibling-feed">
73+
<IonHeader>
74+
<IonToolbar>
75+
<IonTitle>Feed</IonTitle>
76+
</IonToolbar>
77+
</IonHeader>
78+
<Feed />
79+
</IonPage>
80+
);
81+
82+
const ProfileTab: React.FC = () => (
83+
<IonPage data-pageid="root-splat-sibling-profile">
84+
<IonHeader>
85+
<IonToolbar>
86+
<IonTitle>Profile</IonTitle>
87+
</IonToolbar>
88+
</IonHeader>
89+
<IonContent>
90+
<div data-testid="profile-content">Profile</div>
91+
</IonContent>
92+
</IonPage>
93+
);
94+
95+
/** The container page the splat route renders. */
96+
const Tabs: React.FC = () => {
97+
const [instanceId] = useState(nextInstanceId);
98+
99+
return (
100+
// These land on both the .ion-page div and the ion-tabs element, so select with div.ion-page.
101+
<IonTabs data-pageid="root-splat-sibling-tabs" data-instance={instanceId} data-splat={splatPath}>
102+
<IonRouterOutlet id="root-splat-sibling-tabs-outlet">
103+
<Route path="/feed" element={<FeedTab />} />
104+
<Route path="/profile" element={<ProfileTab />} />
105+
<Route path="/" element={<Navigate to="/feed" replace />} />
106+
</IonRouterOutlet>
107+
<IonTabBar slot="bottom">
108+
<IonTabButton tab="feed" href="/feed" data-testid="tab-feed">
109+
<IonIcon icon={triangle} />
110+
<IonLabel>Feed</IonLabel>
111+
</IonTabButton>
112+
<IonTabButton tab="profile" href="/profile" data-testid="tab-profile">
113+
<IonIcon icon={ellipse} />
114+
<IonLabel>Profile</IonLabel>
115+
</IonTabButton>
116+
</IonTabBar>
117+
</IonTabs>
118+
);
119+
};
120+
121+
const Detail: React.FC = () => {
122+
const { id } = useParams<{ id: string }>();
123+
124+
return (
125+
<IonPage data-pageid="root-splat-sibling-detail">
126+
<IonHeader>
127+
<IonToolbar>
128+
<IonButtons slot="start">
129+
<IonBackButton defaultHref="/feed" />
130+
</IonButtons>
131+
<IonTitle>Detail</IonTitle>
132+
</IonToolbar>
133+
</IonHeader>
134+
<IonContent>
135+
<div data-testid="detail-id">{id}</div>
136+
</IonContent>
137+
</IonPage>
138+
);
139+
};
140+
141+
const RootSplatSiblingApp: React.FC = () => (
142+
<IonApp>
143+
<IonReactRouter basename={ROOT_SPLAT_SIBLING_BASENAME}>
144+
<IonRouterOutlet id="root-splat-sibling-outlet">
145+
<Route path="/feed/:id" element={<Detail />} />
146+
<Route path={splatPath} element={<Tabs />} />
147+
</IonRouterOutlet>
148+
</IonReactRouter>
149+
</IonApp>
150+
);
151+
152+
export default RootSplatSiblingApp;
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
/**
2+
* The prefix the root splat sibling app is served under, appended to Vite's base so
3+
* previews keep working.
4+
*
5+
* Kept apart from the app itself so Main.tsx can link to it without pulling a second
6+
* IonReactRouter into App's module graph.
7+
*/
8+
export const ROOT_SPLAT_SIBLING_BASENAME = `${import.meta.env?.BASE_URL?.replace(/\/$/, '') || ''}/root-splat-sibling`;

0 commit comments

Comments
 (0)