Skip to content

Commit c283319

Browse files
authored
fix(react-router): isolate tab history to prevent cross-tab back navigation (#30854)
Issue number: resolves internal --------- <!-- Please do not submit updates to dependencies unless it fixes an issue. --> <!-- Please try to limit your pull request to one type (bugfix, feature, etc). Submit multiple pull requests if needed. --> ## What is the current behavior? When switching between tabs using the tab bar in a React Router 6 application, clicking IonBackButton incorrectly navigates back to the previous tab. Each tab should have its own isolated navigation history stack, and the back button should only navigate within the current tab's history. ## What is the new behavior? Tab navigation history is now properly isolated. When switching tabs via the tab bar: - The back button only navigates within the current tab's history - Pressing back on a tab root (with no navigation history) does nothing instead of navigating to the previous tab or default route - Within-tab navigation continues to work correctly (e.g., navigating to a details page and back) - Tab history is preserved when switching away and returning to a tab ## Does this introduce a breaking change? - [ ] Yes - [X] No <!-- If this introduces a breaking change: 1. Describe the impact and migration path for existing applications below. 2. Update the BREAKING.md file with the breaking change. 3. Add "BREAKING CHANGE: [...]" to the commit description when merging. See https://github.com/ionic-team/ionic-framework/blob/main/docs/CONTRIBUTING.md#footer for more information. --> ## Other information <!-- Any other information that is important to this PR such as screenshots of how the component looks before and after the change. --> Current dev build: ``` 8.7.12-dev.11765377112.16762e5b ```
2 parents 2ff49c4 + 6762e5b commit c283319

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)