Skip to content

Commit 6762e5b

Browse files
committed
fix(react-router): isolate tab history to prevent cross-tab back navigation
1 parent 7fc43b6 commit 6762e5b

File tree

5 files changed

+305
-6
lines changed

5 files changed

+305
-6
lines changed

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

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -269,10 +269,15 @@ export const IonRouter = ({ children, registerHistoryListener }: PropsWithChildr
269269
* tab and use its `pushedByRoute`.
270270
*/
271271
const lastRoute = locationHistory.current.getCurrentRouteInfoForTab(routeInfo.tab);
272-
// This helps maintain correct back stack behavior within tabs.
273-
// If this is the first time entering this tab from a different context,
274-
// use the leaving route's pathname as the pushedByRoute to maintain the back stack.
275-
routeInfo.pushedByRoute = lastRoute?.pushedByRoute ?? leavingLocationInfo.pathname;
272+
/**
273+
* Tab bar switches (direction 'none') should not create cross-tab back
274+
* navigation. Only inherit pushedByRoute from the tab's own history.
275+
*/
276+
if (routeInfo.routeDirection === 'none') {
277+
routeInfo.pushedByRoute = lastRoute?.pushedByRoute;
278+
} else {
279+
routeInfo.pushedByRoute = lastRoute?.pushedByRoute ?? leavingLocationInfo.pathname;
280+
}
276281
// Triggered by `history.replace()` or a `<Redirect />` component, etc.
277282
} else if (routeInfo.routeAction === 'replace') {
278283
/**
@@ -465,10 +470,13 @@ export const IonRouter = ({ children, registerHistoryListener }: PropsWithChildr
465470
handleNavigate(defaultHref as string, 'pop', 'back', routeAnimation);
466471
}
467472
/**
468-
* No `pushedByRoute`
469-
* e.g., initial page load
473+
* No `pushedByRoute` (e.g., initial page load or tab root).
474+
* Tabs with no back history should not navigate.
470475
*/
471476
} else {
477+
if (routeInfo && routeInfo.tab) {
478+
return;
479+
}
472480
handleNavigate(defaultHref as string, 'pop', 'back', routeAnimation);
473481
}
474482
};

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import { SwipeToGoBack } from './pages/swipe-to-go-back/SwipToGoBack';
4040
import TabsContext from './pages/tab-context/TabContext';
4141
import Tabs from './pages/tabs/Tabs';
4242
import TabsSecondary from './pages/tabs/TabsSecondary';
43+
import TabHistoryIsolation from './pages/tab-history-isolation/TabHistoryIsolation';
4344
import Overlays from './pages/overlays/Overlays';
4445

4546
setupIonicReact();
@@ -66,6 +67,7 @@ const App: React.FC = () => {
6667
<Route path="/dynamic-ionpage-classnames" element={<DynamicIonpageClassnames />} />
6768
<Route path="/tabs/*" element={<Tabs />} />
6869
<Route path="/tabs-secondary/*" element={<TabsSecondary />} />
70+
<Route path="/tab-history-isolation/*" element={<TabHistoryIsolation />} />
6971
<Route path="/refs/*" element={<Refs />} />
7072
<Route path="/overlays" element={<Overlays />} />
7173
<Route path="/params/:id" element={<Params />} />

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,9 @@ const Main: React.FC = () => {
6868
<IonItem routerLink="/tabs" id="go-to-tabs">
6969
<IonLabel>Tabs</IonLabel>
7070
</IonItem>
71+
<IonItem routerLink="/tab-history-isolation">
72+
<IonLabel>Tab History Isolation</IonLabel>
73+
</IonItem>
7174
<IonItem routerLink="/params/0">
7275
<IonLabel>Params</IonLabel>
7376
</IonItem>
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import {
2+
IonTabs,
3+
IonRouterOutlet,
4+
IonTabBar,
5+
IonTabButton,
6+
IonIcon,
7+
IonLabel,
8+
IonPage,
9+
IonHeader,
10+
IonToolbar,
11+
IonButtons,
12+
IonBackButton,
13+
IonTitle,
14+
IonContent,
15+
IonButton,
16+
} from '@ionic/react';
17+
import { triangle, square, ellipse } from 'ionicons/icons';
18+
import React from 'react';
19+
import { Route, Navigate } from 'react-router';
20+
21+
const TabHistoryIsolation: React.FC = () => {
22+
return (
23+
<IonTabs>
24+
<IonRouterOutlet id="tab-history-isolation">
25+
<Route index element={<Navigate to="/tab-history-isolation/a" replace />} />
26+
<Route path="a" element={<TabA />} />
27+
<Route path="b" element={<TabB />} />
28+
<Route path="c" element={<TabC />} />
29+
<Route path="a/details" element={<TabADetails />} />
30+
<Route path="b/details" element={<TabBDetails />} />
31+
<Route path="c/details" element={<TabCDetails />} />
32+
</IonRouterOutlet>
33+
<IonTabBar slot="bottom">
34+
<IonTabButton tab="tab-a" href="/tab-history-isolation/a">
35+
<IonIcon icon={triangle} />
36+
<IonLabel>Tab A</IonLabel>
37+
</IonTabButton>
38+
<IonTabButton tab="tab-b" href="/tab-history-isolation/b">
39+
<IonIcon icon={square} />
40+
<IonLabel>Tab B</IonLabel>
41+
</IonTabButton>
42+
<IonTabButton tab="tab-c" href="/tab-history-isolation/c">
43+
<IonIcon icon={ellipse} />
44+
<IonLabel>Tab C</IonLabel>
45+
</IonTabButton>
46+
</IonTabBar>
47+
</IonTabs>
48+
);
49+
};
50+
51+
const TabA = () => {
52+
return (
53+
<IonPage data-pageid="tab-a">
54+
<IonHeader>
55+
<IonToolbar>
56+
<IonButtons slot="start">
57+
<IonBackButton />
58+
</IonButtons>
59+
<IonTitle>Tab A</IonTitle>
60+
</IonToolbar>
61+
</IonHeader>
62+
<IonContent>
63+
Tab A
64+
<IonButton routerLink="/tab-history-isolation/a/details" id="go-to-a-details">
65+
Go to A Details
66+
</IonButton>
67+
</IonContent>
68+
</IonPage>
69+
);
70+
};
71+
72+
const TabB = () => {
73+
return (
74+
<IonPage data-pageid="tab-b">
75+
<IonHeader>
76+
<IonToolbar>
77+
<IonButtons slot="start">
78+
<IonBackButton />
79+
</IonButtons>
80+
<IonTitle>Tab B</IonTitle>
81+
</IonToolbar>
82+
</IonHeader>
83+
<IonContent>
84+
Tab B
85+
<IonButton routerLink="/tab-history-isolation/b/details" id="go-to-b-details">
86+
Go to B Details
87+
</IonButton>
88+
</IonContent>
89+
</IonPage>
90+
);
91+
};
92+
93+
const TabC = () => {
94+
return (
95+
<IonPage data-pageid="tab-c">
96+
<IonHeader>
97+
<IonToolbar>
98+
<IonButtons slot="start">
99+
<IonBackButton />
100+
</IonButtons>
101+
<IonTitle>Tab C</IonTitle>
102+
</IonToolbar>
103+
</IonHeader>
104+
<IonContent>
105+
Tab C
106+
<IonButton routerLink="/tab-history-isolation/c/details" id="go-to-c-details">
107+
Go to C Details
108+
</IonButton>
109+
</IonContent>
110+
</IonPage>
111+
);
112+
};
113+
114+
const TabADetails = () => {
115+
return (
116+
<IonPage data-pageid="tab-a-details">
117+
<IonHeader>
118+
<IonToolbar>
119+
<IonButtons slot="start">
120+
<IonBackButton />
121+
</IonButtons>
122+
<IonTitle>Tab A Details</IonTitle>
123+
</IonToolbar>
124+
</IonHeader>
125+
<IonContent>Tab A Details</IonContent>
126+
</IonPage>
127+
);
128+
};
129+
130+
const TabBDetails = () => {
131+
return (
132+
<IonPage data-pageid="tab-b-details">
133+
<IonHeader>
134+
<IonToolbar>
135+
<IonButtons slot="start">
136+
<IonBackButton />
137+
</IonButtons>
138+
<IonTitle>Tab B Details</IonTitle>
139+
</IonToolbar>
140+
</IonHeader>
141+
<IonContent>Tab B Details</IonContent>
142+
</IonPage>
143+
);
144+
};
145+
146+
const TabCDetails = () => {
147+
return (
148+
<IonPage data-pageid="tab-c-details">
149+
<IonHeader>
150+
<IonToolbar>
151+
<IonButtons slot="start">
152+
<IonBackButton />
153+
</IonButtons>
154+
<IonTitle>Tab C Details</IonTitle>
155+
</IonToolbar>
156+
</IonHeader>
157+
<IonContent>Tab C Details</IonContent>
158+
</IonPage>
159+
);
160+
};
161+
162+
export default TabHistoryIsolation;
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
const port = 3000;
2+
3+
describe('Tab History Isolation', () => {
4+
it('should NOT navigate back to previous tab when using back button after tab bar switch', () => {
5+
cy.visit(`http://localhost:${port}/tab-history-isolation/a`);
6+
cy.ionPageVisible('tab-a');
7+
8+
cy.ionTabClick('Tab B');
9+
cy.ionPageHidden('tab-a');
10+
cy.ionPageVisible('tab-b');
11+
12+
cy.get(`div.ion-page[data-pageid=tab-b]`)
13+
.find('ion-back-button')
14+
.click({ force: true });
15+
16+
cy.wait(500);
17+
18+
cy.ionPageVisible('tab-b');
19+
cy.ionPageHidden('tab-a');
20+
cy.url().should('include', '/tab-history-isolation/b');
21+
});
22+
23+
it('should NOT allow back navigation through multiple tab switches', () => {
24+
cy.visit(`http://localhost:${port}/tab-history-isolation/a`);
25+
cy.ionPageVisible('tab-a');
26+
27+
cy.ionTabClick('Tab B');
28+
cy.ionPageHidden('tab-a');
29+
cy.ionPageVisible('tab-b');
30+
31+
cy.ionTabClick('Tab C');
32+
cy.ionPageHidden('tab-b');
33+
cy.ionPageVisible('tab-c');
34+
35+
cy.get(`div.ion-page[data-pageid=tab-c]`)
36+
.find('ion-back-button')
37+
.click({ force: true });
38+
39+
cy.wait(500);
40+
41+
cy.ionPageVisible('tab-c');
42+
cy.url().should('include', '/tab-history-isolation/c');
43+
});
44+
45+
it('should navigate back within the same tab when using back button', () => {
46+
cy.visit(`http://localhost:${port}/tab-history-isolation/a`);
47+
cy.ionPageVisible('tab-a');
48+
49+
cy.get('#go-to-a-details').click();
50+
cy.ionPageHidden('tab-a');
51+
cy.ionPageVisible('tab-a-details');
52+
53+
cy.ionBackClick('tab-a-details');
54+
cy.ionPageDoesNotExist('tab-a-details');
55+
cy.ionPageVisible('tab-a');
56+
57+
cy.url().should('include', '/tab-history-isolation/a');
58+
cy.url().should('not.include', '/details');
59+
});
60+
61+
it('should only navigate back within current tab after switching tabs and navigating', () => {
62+
cy.visit(`http://localhost:${port}/tab-history-isolation/a`);
63+
cy.ionPageVisible('tab-a');
64+
65+
cy.ionTabClick('Tab B');
66+
cy.ionPageHidden('tab-a');
67+
cy.ionPageVisible('tab-b');
68+
69+
cy.get('#go-to-b-details').click();
70+
cy.ionPageHidden('tab-b');
71+
cy.ionPageVisible('tab-b-details');
72+
73+
cy.ionBackClick('tab-b-details');
74+
cy.ionPageDoesNotExist('tab-b-details');
75+
cy.ionPageVisible('tab-b');
76+
77+
cy.url().should('include', '/tab-history-isolation/b');
78+
cy.url().should('not.include', '/details');
79+
80+
cy.get(`div.ion-page[data-pageid=tab-b]`)
81+
.find('ion-back-button')
82+
.click({ force: true });
83+
84+
cy.wait(500);
85+
86+
cy.ionPageVisible('tab-b');
87+
cy.url().should('include', '/tab-history-isolation/b');
88+
});
89+
90+
it('should preserve tab history when switching away and back', () => {
91+
cy.visit(`http://localhost:${port}/tab-history-isolation/a`);
92+
cy.ionPageVisible('tab-a');
93+
94+
cy.get('#go-to-a-details').click();
95+
cy.ionPageHidden('tab-a');
96+
cy.ionPageVisible('tab-a-details');
97+
98+
cy.ionTabClick('Tab B');
99+
cy.ionPageHidden('tab-a-details');
100+
cy.ionPageVisible('tab-b');
101+
102+
cy.ionTabClick('Tab A');
103+
cy.ionPageHidden('tab-b');
104+
cy.ionPageVisible('tab-a-details');
105+
106+
cy.ionBackClick('tab-a-details');
107+
cy.ionPageDoesNotExist('tab-a-details');
108+
cy.ionPageVisible('tab-a');
109+
});
110+
111+
it('should have no back navigation when first visiting a tab', () => {
112+
cy.visit(`http://localhost:${port}/tab-history-isolation/a`);
113+
cy.ionPageVisible('tab-a');
114+
115+
cy.get(`div.ion-page[data-pageid=tab-a]`)
116+
.find('ion-back-button')
117+
.click({ force: true });
118+
119+
cy.wait(500);
120+
121+
cy.ionPageVisible('tab-a');
122+
cy.url().should('include', '/tab-history-isolation/a');
123+
});
124+
});

0 commit comments

Comments
 (0)