Skip to content

Commit 03fb422

Browse files
ShaneKthetaPCbrandyscarney
authored
fix(tabs): select correct tab when routes have similar prefixes (#30863)
Issue number: resolves #30448 --------- <!-- 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 using ion-tabs with routes that share a common prefix (e.g., `/home`, `/home2`, `/home3`), navigating to `/home2` incorrectly highlights the `/home` tab. This occurs because the tab matching logic uses `pathname.startsWith(href)`, which causes `/home2` to match `/home` since `/home2` starts with `/home`. ## What is the new behavior? Tab selection now uses path segment matching instead of simple prefix matching. A tab's href will only match if the pathname is an exact match OR starts with the href followed by a / (for nested routes). This ensures /home2 no longer incorrectly matches /home, while still allowing /home/details to correctly match the /home 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.13-dev.11765486444.14025098 ``` --------- Co-authored-by: Maria Hutt <[email protected]> Co-authored-by: Brandy Smith <[email protected]>
1 parent 82de33b commit 03fb422

File tree

13 files changed

+337
-3
lines changed

13 files changed

+337
-3
lines changed

packages/react/src/components/navigation/IonTabBar.tsx

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,20 @@ interface IonTabBarState {
4040

4141
// TODO(FW-2959): types
4242

43+
/**
44+
* Checks if pathname matches the tab's href using path segment matching.
45+
* Avoids false matches like /home2 matching /home by requiring exact match
46+
* or a path segment boundary (/).
47+
*/
48+
const matchesTab = (pathname: string, href: string | undefined): boolean => {
49+
if (href === undefined) {
50+
return false;
51+
}
52+
53+
const normalizedHref = href.endsWith('/') && href !== '/' ? href.slice(0, -1) : href;
54+
return pathname === normalizedHref || pathname.startsWith(normalizedHref + '/');
55+
};
56+
4357
class IonTabBarUnwrapped extends React.PureComponent<InternalProps, IonTabBarState> {
4458
context!: React.ContextType<typeof NavContext>;
4559

@@ -79,7 +93,7 @@ class IonTabBarUnwrapped extends React.PureComponent<InternalProps, IonTabBarSta
7993
const tabKeys = Object.keys(tabs);
8094
const activeTab = tabKeys.find((key) => {
8195
const href = tabs[key].originalHref;
82-
return this.props.routeInfo!.pathname.startsWith(href);
96+
return matchesTab(this.props.routeInfo!.pathname, href);
8397
});
8498

8599
if (activeTab) {
@@ -121,7 +135,7 @@ class IonTabBarUnwrapped extends React.PureComponent<InternalProps, IonTabBarSta
121135
const tabKeys = Object.keys(state.tabs);
122136
const activeTab = tabKeys.find((key) => {
123137
const href = state.tabs[key].originalHref;
124-
return props.routeInfo!.pathname.startsWith(href);
138+
return matchesTab(props.routeInfo!.pathname, href);
125139
});
126140

127141
// Check to see if the tab button href has changed, and if so, update it in the tabs state

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import Tabs from './pages/Tabs';
2828
import TabsBasic from './pages/TabsBasic';
2929
import NavComponent from './pages/navigation/NavComponent';
3030
import TabsDirectNavigation from './pages/TabsDirectNavigation';
31+
import TabsSimilarPrefixes from './pages/TabsSimilarPrefixes';
3132
import IonModalConditional from './pages/overlay-components/IonModalConditional';
3233
import IonModalConditionalSibling from './pages/overlay-components/IonModalConditionalSibling';
3334
import IonModalDatetimeButton from './pages/overlay-components/IonModalDatetimeButton';
@@ -67,6 +68,7 @@ const App: React.FC = () => (
6768
<Route path="/tabs" component={Tabs} />
6869
<Route path="/tabs-basic" component={TabsBasic} />
6970
<Route path="/tabs-direct-navigation" component={TabsDirectNavigation} />
71+
<Route path="/tabs-similar-prefixes" component={TabsSimilarPrefixes} />
7072
<Route path="/icons" component={Icons} />
7173
<Route path="/inputs" component={Inputs} />
7274
<Route path="/reorder-group" component={ReorderGroup} />

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ const Main: React.FC<MainProps> = () => {
4646
<IonItem routerLink="/tabs-direct-navigation">
4747
<IonLabel>Tabs with Direct Navigation</IonLabel>
4848
</IonItem>
49+
<IonItem routerLink="/tabs-similar-prefixes">
50+
<IonLabel>Tabs with Similar Route Prefixes</IonLabel>
51+
</IonItem>
4952
<IonItem routerLink="/icons">
5053
<IonLabel>Icons</IonLabel>
5154
</IonItem>
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import {
2+
IonContent,
3+
IonHeader,
4+
IonIcon,
5+
IonLabel,
6+
IonPage,
7+
IonRouterOutlet,
8+
IonTabBar,
9+
IonTabButton,
10+
IonTabs,
11+
IonTitle,
12+
IonToolbar,
13+
} from '@ionic/react';
14+
import { homeOutline, radioOutline, libraryOutline } from 'ionicons/icons';
15+
import React from 'react';
16+
import { Route, Redirect } from 'react-router-dom';
17+
18+
const HomePage: React.FC = () => (
19+
<IonPage data-testid="home-page">
20+
<IonHeader>
21+
<IonToolbar>
22+
<IonTitle>Home</IonTitle>
23+
</IonToolbar>
24+
</IonHeader>
25+
<IonContent>
26+
<div data-testid="home-content">Home Content</div>
27+
</IonContent>
28+
</IonPage>
29+
);
30+
31+
const Home2Page: React.FC = () => (
32+
<IonPage data-testid="home2-page">
33+
<IonHeader>
34+
<IonToolbar>
35+
<IonTitle>Home 2</IonTitle>
36+
</IonToolbar>
37+
</IonHeader>
38+
<IonContent>
39+
<div data-testid="home2-content">Home 2 Content</div>
40+
</IonContent>
41+
</IonPage>
42+
);
43+
44+
const Home3Page: React.FC = () => (
45+
<IonPage data-testid="home3-page">
46+
<IonHeader>
47+
<IonToolbar>
48+
<IonTitle>Home 3</IonTitle>
49+
</IonToolbar>
50+
</IonHeader>
51+
<IonContent>
52+
<div data-testid="home3-content">Home 3 Content</div>
53+
</IonContent>
54+
</IonPage>
55+
);
56+
57+
const TabsSimilarPrefixes: React.FC = () => {
58+
return (
59+
<IonTabs data-testid="tabs-similar-prefixes">
60+
<IonRouterOutlet>
61+
<Redirect exact path="/tabs-similar-prefixes" to="/tabs-similar-prefixes/home" />
62+
<Route path="/tabs-similar-prefixes/home" render={() => <HomePage />} exact={true} />
63+
<Route path="/tabs-similar-prefixes/home2" render={() => <Home2Page />} exact={true} />
64+
<Route path="/tabs-similar-prefixes/home3" render={() => <Home3Page />} exact={true} />
65+
</IonRouterOutlet>
66+
67+
<IonTabBar slot="bottom" data-testid="tab-bar">
68+
<IonTabButton tab="home" href="/tabs-similar-prefixes/home" data-testid="home-tab">
69+
<IonIcon icon={homeOutline}></IonIcon>
70+
<IonLabel>Home</IonLabel>
71+
</IonTabButton>
72+
73+
<IonTabButton tab="home2" href="/tabs-similar-prefixes/home2" data-testid="home2-tab">
74+
<IonIcon icon={radioOutline}></IonIcon>
75+
<IonLabel>Home 2</IonLabel>
76+
</IonTabButton>
77+
78+
<IonTabButton tab="home3" href="/tabs-similar-prefixes/home3" data-testid="home3-tab">
79+
<IonIcon icon={libraryOutline}></IonIcon>
80+
<IonLabel>Home 3</IonLabel>
81+
</IonTabButton>
82+
</IonTabBar>
83+
</IonTabs>
84+
);
85+
};
86+
87+
export default TabsSimilarPrefixes;

packages/react/test/base/tests/e2e/specs/tabs/tabs.cy.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,45 @@
11
describe('IonTabs', () => {
2+
/**
3+
* Verifies that tabs with similar route prefixes (e.g., /home, /home2, /home3)
4+
* correctly select the matching tab instead of the first prefix match.
5+
*
6+
* @see https://github.com/ionic-team/ionic-framework/issues/30448
7+
*/
8+
describe('Similar Route Prefixes', () => {
9+
it('should select the correct tab when routes have similar prefixes', () => {
10+
cy.visit('/tabs-similar-prefixes/home2');
11+
12+
cy.get('[data-testid="home2-content"]').should('be.visible');
13+
cy.get('[data-testid="home2-tab"]').should('have.class', 'tab-selected');
14+
cy.get('[data-testid="home-tab"]').should('not.have.class', 'tab-selected');
15+
});
16+
17+
it('should select the correct tab when navigating via tab buttons', () => {
18+
cy.visit('/tabs-similar-prefixes/home');
19+
20+
cy.get('[data-testid="home-tab"]').should('have.class', 'tab-selected');
21+
cy.get('[data-testid="home2-tab"]').should('not.have.class', 'tab-selected');
22+
23+
cy.get('[data-testid="home2-tab"]').click();
24+
cy.get('[data-testid="home2-tab"]').should('have.class', 'tab-selected');
25+
cy.get('[data-testid="home-tab"]').should('not.have.class', 'tab-selected');
26+
27+
cy.get('[data-testid="home3-tab"]').click();
28+
cy.get('[data-testid="home3-tab"]').should('have.class', 'tab-selected');
29+
cy.get('[data-testid="home-tab"]').should('not.have.class', 'tab-selected');
30+
cy.get('[data-testid="home2-tab"]').should('not.have.class', 'tab-selected');
31+
});
32+
33+
it('should select the correct tab when directly navigating to home3', () => {
34+
cy.visit('/tabs-similar-prefixes/home3');
35+
36+
cy.get('[data-testid="home3-content"]').should('be.visible');
37+
cy.get('[data-testid="home3-tab"]').should('have.class', 'tab-selected');
38+
cy.get('[data-testid="home-tab"]').should('not.have.class', 'tab-selected');
39+
cy.get('[data-testid="home2-tab"]').should('not.have.class', 'tab-selected');
40+
});
41+
});
42+
243
describe('With IonRouterOutlet', () => {
344
beforeEach(() => {
445
cy.visit('/tabs/tab1');

packages/vue/src/components/IonTabBar.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,23 @@ interface TabBarData {
2424

2525
const isTabButton = (child: any) => child.type?.name === "IonTabButton";
2626

27+
/**
28+
* Checks if pathname matches the tab's href using path segment matching.
29+
* Avoids false matches like /home2 matching /home by requiring exact match
30+
* or a path segment boundary (/).
31+
*/
32+
const matchesTab = (pathname: string, href: string | undefined): boolean => {
33+
if (href === undefined) {
34+
return false;
35+
}
36+
37+
const normalizedHref =
38+
href.endsWith("/") && href !== "/" ? href.slice(0, -1) : href;
39+
return (
40+
pathname === normalizedHref || pathname.startsWith(normalizedHref + "/")
41+
);
42+
};
43+
2744
const getTabs = (nodes: VNode[]) => {
2845
let tabs: VNode[] = [];
2946
nodes.forEach((node: VNode) => {
@@ -135,7 +152,9 @@ export const IonTabBar = defineComponent({
135152
const tabKeys = Object.keys(tabs);
136153
let activeTab = tabKeys.find((key) => {
137154
const href = tabs[key].originalHref;
138-
return currentRoute?.pathname.startsWith(href);
155+
return (
156+
currentRoute?.pathname && matchesTab(currentRoute.pathname, href)
157+
);
139158
});
140159

141160
/**

packages/vue/test/base/src/router/index.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,28 @@ const routes: Array<RouteRecordRaw> = [
165165
path: '/tabs-basic',
166166
component: () => import('@/views/TabsBasic.vue')
167167
},
168+
{
169+
path: '/tabs-similar-prefixes/',
170+
component: () => import('@/views/tabs-similar-prefixes/TabsSimilarPrefixes.vue'),
171+
children: [
172+
{
173+
path: '',
174+
redirect: '/tabs-similar-prefixes/home'
175+
},
176+
{
177+
path: 'home',
178+
component: () => import('@/views/tabs-similar-prefixes/Home.vue'),
179+
},
180+
{
181+
path: 'home2',
182+
component: () => import('@/views/tabs-similar-prefixes/Home2.vue'),
183+
},
184+
{
185+
path: 'home3',
186+
component: () => import('@/views/tabs-similar-prefixes/Home3.vue'),
187+
}
188+
]
189+
},
168190
]
169191

170192
const router = createRouter({

packages/vue/test/base/src/views/Home.vue

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@
5050
<ion-item router-link="/tabs-basic" id="tab-basic">
5151
<ion-label>Tabs with Basic Navigation</ion-label>
5252
</ion-item>
53+
<ion-item router-link="/tabs-similar-prefixes" id="tabs-similar-prefixes">
54+
<ion-label>Tabs with Similar Route Prefixes</ion-label>
55+
</ion-item>
5356
<ion-item router-link="/lifecycle" id="lifecycle">
5457
<ion-label>Lifecycle</ion-label>
5558
</ion-item>
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<template>
2+
<ion-page data-pageid="home">
3+
<ion-header>
4+
<ion-toolbar>
5+
<ion-title>Home</ion-title>
6+
</ion-toolbar>
7+
</ion-header>
8+
<ion-content>
9+
<div data-testid="home-content">Home Content</div>
10+
</ion-content>
11+
</ion-page>
12+
</template>
13+
14+
<script setup lang="ts">
15+
import { IonContent, IonHeader, IonPage, IonTitle, IonToolbar } from '@ionic/vue';
16+
</script>
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<template>
2+
<ion-page data-pageid="home2">
3+
<ion-header>
4+
<ion-toolbar>
5+
<ion-title>Home 2</ion-title>
6+
</ion-toolbar>
7+
</ion-header>
8+
<ion-content>
9+
<div data-testid="home2-content">Home 2 Content</div>
10+
</ion-content>
11+
</ion-page>
12+
</template>
13+
14+
<script setup lang="ts">
15+
import { IonContent, IonHeader, IonPage, IonTitle, IonToolbar } from '@ionic/vue';
16+
</script>

0 commit comments

Comments
 (0)