From 03fb422bfa775e3e9dd695ea1857fa88d4245ecd Mon Sep 17 00:00:00 2001 From: Shane Date: Tue, 16 Dec 2025 16:11:10 -0800 Subject: [PATCH] fix(tabs): select correct tab when routes have similar prefixes (#30863) Issue number: resolves #30448 --------- ## 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 ## Other information Current dev build: ``` 8.7.13-dev.11765486444.14025098 ``` --------- Co-authored-by: Maria Hutt Co-authored-by: Brandy Smith --- .../src/components/navigation/IonTabBar.tsx | 18 +++- packages/react/test/base/src/App.tsx | 2 + packages/react/test/base/src/pages/Main.tsx | 3 + .../base/src/pages/TabsSimilarPrefixes.tsx | 87 +++++++++++++++++++ .../test/base/tests/e2e/specs/tabs/tabs.cy.ts | 41 +++++++++ packages/vue/src/components/IonTabBar.ts | 21 ++++- packages/vue/test/base/src/router/index.ts | 22 +++++ packages/vue/test/base/src/views/Home.vue | 3 + .../src/views/tabs-similar-prefixes/Home.vue | 16 ++++ .../src/views/tabs-similar-prefixes/Home2.vue | 16 ++++ .../src/views/tabs-similar-prefixes/Home3.vue | 16 ++++ .../TabsSimilarPrefixes.vue | 54 ++++++++++++ .../vue/test/base/tests/e2e/specs/tabs.cy.js | 41 +++++++++ 13 files changed, 337 insertions(+), 3 deletions(-) create mode 100644 packages/react/test/base/src/pages/TabsSimilarPrefixes.tsx create mode 100644 packages/vue/test/base/src/views/tabs-similar-prefixes/Home.vue create mode 100644 packages/vue/test/base/src/views/tabs-similar-prefixes/Home2.vue create mode 100644 packages/vue/test/base/src/views/tabs-similar-prefixes/Home3.vue create mode 100644 packages/vue/test/base/src/views/tabs-similar-prefixes/TabsSimilarPrefixes.vue diff --git a/packages/react/src/components/navigation/IonTabBar.tsx b/packages/react/src/components/navigation/IonTabBar.tsx index 92fde774dd..124b00f699 100644 --- a/packages/react/src/components/navigation/IonTabBar.tsx +++ b/packages/react/src/components/navigation/IonTabBar.tsx @@ -40,6 +40,20 @@ interface IonTabBarState { // TODO(FW-2959): types +/** + * Checks if pathname matches the tab's href using path segment matching. + * Avoids false matches like /home2 matching /home by requiring exact match + * or a path segment boundary (/). + */ +const matchesTab = (pathname: string, href: string | undefined): boolean => { + if (href === undefined) { + return false; + } + + const normalizedHref = href.endsWith('/') && href !== '/' ? href.slice(0, -1) : href; + return pathname === normalizedHref || pathname.startsWith(normalizedHref + '/'); +}; + class IonTabBarUnwrapped extends React.PureComponent { context!: React.ContextType; @@ -79,7 +93,7 @@ class IonTabBarUnwrapped extends React.PureComponent { const href = tabs[key].originalHref; - return this.props.routeInfo!.pathname.startsWith(href); + return matchesTab(this.props.routeInfo!.pathname, href); }); if (activeTab) { @@ -121,7 +135,7 @@ class IonTabBarUnwrapped extends React.PureComponent { const href = state.tabs[key].originalHref; - return props.routeInfo!.pathname.startsWith(href); + return matchesTab(props.routeInfo!.pathname, href); }); // Check to see if the tab button href has changed, and if so, update it in the tabs state diff --git a/packages/react/test/base/src/App.tsx b/packages/react/test/base/src/App.tsx index 634af89f07..c8ea117f60 100644 --- a/packages/react/test/base/src/App.tsx +++ b/packages/react/test/base/src/App.tsx @@ -28,6 +28,7 @@ import Tabs from './pages/Tabs'; import TabsBasic from './pages/TabsBasic'; import NavComponent from './pages/navigation/NavComponent'; import TabsDirectNavigation from './pages/TabsDirectNavigation'; +import TabsSimilarPrefixes from './pages/TabsSimilarPrefixes'; import IonModalConditional from './pages/overlay-components/IonModalConditional'; import IonModalConditionalSibling from './pages/overlay-components/IonModalConditionalSibling'; import IonModalDatetimeButton from './pages/overlay-components/IonModalDatetimeButton'; @@ -67,6 +68,7 @@ const App: React.FC = () => ( + diff --git a/packages/react/test/base/src/pages/Main.tsx b/packages/react/test/base/src/pages/Main.tsx index 3873cd3d5b..e0dbdffcf6 100644 --- a/packages/react/test/base/src/pages/Main.tsx +++ b/packages/react/test/base/src/pages/Main.tsx @@ -46,6 +46,9 @@ const Main: React.FC = () => { Tabs with Direct Navigation + + Tabs with Similar Route Prefixes + Icons diff --git a/packages/react/test/base/src/pages/TabsSimilarPrefixes.tsx b/packages/react/test/base/src/pages/TabsSimilarPrefixes.tsx new file mode 100644 index 0000000000..78672dc075 --- /dev/null +++ b/packages/react/test/base/src/pages/TabsSimilarPrefixes.tsx @@ -0,0 +1,87 @@ +import { + IonContent, + IonHeader, + IonIcon, + IonLabel, + IonPage, + IonRouterOutlet, + IonTabBar, + IonTabButton, + IonTabs, + IonTitle, + IonToolbar, +} from '@ionic/react'; +import { homeOutline, radioOutline, libraryOutline } from 'ionicons/icons'; +import React from 'react'; +import { Route, Redirect } from 'react-router-dom'; + +const HomePage: React.FC = () => ( + + + + Home + + + +
Home Content
+
+
+); + +const Home2Page: React.FC = () => ( + + + + Home 2 + + + +
Home 2 Content
+
+
+); + +const Home3Page: React.FC = () => ( + + + + Home 3 + + + +
Home 3 Content
+
+
+); + +const TabsSimilarPrefixes: React.FC = () => { + return ( + + + + } exact={true} /> + } exact={true} /> + } exact={true} /> + + + + + + Home + + + + + Home 2 + + + + + Home 3 + + + + ); +}; + +export default TabsSimilarPrefixes; diff --git a/packages/react/test/base/tests/e2e/specs/tabs/tabs.cy.ts b/packages/react/test/base/tests/e2e/specs/tabs/tabs.cy.ts index 544fdc47c4..34b4ee4f1e 100644 --- a/packages/react/test/base/tests/e2e/specs/tabs/tabs.cy.ts +++ b/packages/react/test/base/tests/e2e/specs/tabs/tabs.cy.ts @@ -1,4 +1,45 @@ describe('IonTabs', () => { + /** + * Verifies that tabs with similar route prefixes (e.g., /home, /home2, /home3) + * correctly select the matching tab instead of the first prefix match. + * + * @see https://github.com/ionic-team/ionic-framework/issues/30448 + */ + describe('Similar Route Prefixes', () => { + it('should select the correct tab when routes have similar prefixes', () => { + cy.visit('/tabs-similar-prefixes/home2'); + + cy.get('[data-testid="home2-content"]').should('be.visible'); + cy.get('[data-testid="home2-tab"]').should('have.class', 'tab-selected'); + cy.get('[data-testid="home-tab"]').should('not.have.class', 'tab-selected'); + }); + + it('should select the correct tab when navigating via tab buttons', () => { + cy.visit('/tabs-similar-prefixes/home'); + + cy.get('[data-testid="home-tab"]').should('have.class', 'tab-selected'); + cy.get('[data-testid="home2-tab"]').should('not.have.class', 'tab-selected'); + + cy.get('[data-testid="home2-tab"]').click(); + cy.get('[data-testid="home2-tab"]').should('have.class', 'tab-selected'); + cy.get('[data-testid="home-tab"]').should('not.have.class', 'tab-selected'); + + cy.get('[data-testid="home3-tab"]').click(); + cy.get('[data-testid="home3-tab"]').should('have.class', 'tab-selected'); + cy.get('[data-testid="home-tab"]').should('not.have.class', 'tab-selected'); + cy.get('[data-testid="home2-tab"]').should('not.have.class', 'tab-selected'); + }); + + it('should select the correct tab when directly navigating to home3', () => { + cy.visit('/tabs-similar-prefixes/home3'); + + cy.get('[data-testid="home3-content"]').should('be.visible'); + cy.get('[data-testid="home3-tab"]').should('have.class', 'tab-selected'); + cy.get('[data-testid="home-tab"]').should('not.have.class', 'tab-selected'); + cy.get('[data-testid="home2-tab"]').should('not.have.class', 'tab-selected'); + }); + }); + describe('With IonRouterOutlet', () => { beforeEach(() => { cy.visit('/tabs/tab1'); diff --git a/packages/vue/src/components/IonTabBar.ts b/packages/vue/src/components/IonTabBar.ts index 4da54f9eee..30002f4d85 100644 --- a/packages/vue/src/components/IonTabBar.ts +++ b/packages/vue/src/components/IonTabBar.ts @@ -24,6 +24,23 @@ interface TabBarData { const isTabButton = (child: any) => child.type?.name === "IonTabButton"; +/** + * Checks if pathname matches the tab's href using path segment matching. + * Avoids false matches like /home2 matching /home by requiring exact match + * or a path segment boundary (/). + */ +const matchesTab = (pathname: string, href: string | undefined): boolean => { + if (href === undefined) { + return false; + } + + const normalizedHref = + href.endsWith("/") && href !== "/" ? href.slice(0, -1) : href; + return ( + pathname === normalizedHref || pathname.startsWith(normalizedHref + "/") + ); +}; + const getTabs = (nodes: VNode[]) => { let tabs: VNode[] = []; nodes.forEach((node: VNode) => { @@ -135,7 +152,9 @@ export const IonTabBar = defineComponent({ const tabKeys = Object.keys(tabs); let activeTab = tabKeys.find((key) => { const href = tabs[key].originalHref; - return currentRoute?.pathname.startsWith(href); + return ( + currentRoute?.pathname && matchesTab(currentRoute.pathname, href) + ); }); /** diff --git a/packages/vue/test/base/src/router/index.ts b/packages/vue/test/base/src/router/index.ts index ab5850e33c..e518550fd0 100644 --- a/packages/vue/test/base/src/router/index.ts +++ b/packages/vue/test/base/src/router/index.ts @@ -165,6 +165,28 @@ const routes: Array = [ path: '/tabs-basic', component: () => import('@/views/TabsBasic.vue') }, + { + path: '/tabs-similar-prefixes/', + component: () => import('@/views/tabs-similar-prefixes/TabsSimilarPrefixes.vue'), + children: [ + { + path: '', + redirect: '/tabs-similar-prefixes/home' + }, + { + path: 'home', + component: () => import('@/views/tabs-similar-prefixes/Home.vue'), + }, + { + path: 'home2', + component: () => import('@/views/tabs-similar-prefixes/Home2.vue'), + }, + { + path: 'home3', + component: () => import('@/views/tabs-similar-prefixes/Home3.vue'), + } + ] + }, ] const router = createRouter({ diff --git a/packages/vue/test/base/src/views/Home.vue b/packages/vue/test/base/src/views/Home.vue index a3ddbdf99c..d37ab45bbe 100644 --- a/packages/vue/test/base/src/views/Home.vue +++ b/packages/vue/test/base/src/views/Home.vue @@ -50,6 +50,9 @@ Tabs with Basic Navigation + + Tabs with Similar Route Prefixes + Lifecycle diff --git a/packages/vue/test/base/src/views/tabs-similar-prefixes/Home.vue b/packages/vue/test/base/src/views/tabs-similar-prefixes/Home.vue new file mode 100644 index 0000000000..0954fac9d4 --- /dev/null +++ b/packages/vue/test/base/src/views/tabs-similar-prefixes/Home.vue @@ -0,0 +1,16 @@ + + + diff --git a/packages/vue/test/base/src/views/tabs-similar-prefixes/Home2.vue b/packages/vue/test/base/src/views/tabs-similar-prefixes/Home2.vue new file mode 100644 index 0000000000..4e190d99e9 --- /dev/null +++ b/packages/vue/test/base/src/views/tabs-similar-prefixes/Home2.vue @@ -0,0 +1,16 @@ + + + diff --git a/packages/vue/test/base/src/views/tabs-similar-prefixes/Home3.vue b/packages/vue/test/base/src/views/tabs-similar-prefixes/Home3.vue new file mode 100644 index 0000000000..78099959b0 --- /dev/null +++ b/packages/vue/test/base/src/views/tabs-similar-prefixes/Home3.vue @@ -0,0 +1,16 @@ + + + diff --git a/packages/vue/test/base/src/views/tabs-similar-prefixes/TabsSimilarPrefixes.vue b/packages/vue/test/base/src/views/tabs-similar-prefixes/TabsSimilarPrefixes.vue new file mode 100644 index 0000000000..8ec968edbd --- /dev/null +++ b/packages/vue/test/base/src/views/tabs-similar-prefixes/TabsSimilarPrefixes.vue @@ -0,0 +1,54 @@ + + + diff --git a/packages/vue/test/base/tests/e2e/specs/tabs.cy.js b/packages/vue/test/base/tests/e2e/specs/tabs.cy.js index 2b6cb15790..1fdc83cf72 100644 --- a/packages/vue/test/base/tests/e2e/specs/tabs.cy.js +++ b/packages/vue/test/base/tests/e2e/specs/tabs.cy.js @@ -1,4 +1,45 @@ describe('Tabs', () => { + /** + * Verifies that tabs with similar route prefixes (e.g., /home, /home2, /home3) + * correctly select the matching tab instead of the first prefix match. + * + * @see https://github.com/ionic-team/ionic-framework/issues/30448 + */ + describe('Similar Route Prefixes', () => { + it('should select the correct tab when routes have similar prefixes', () => { + cy.visit('/tabs-similar-prefixes/home2'); + + cy.get('[data-testid="home2-content"]').should('be.visible'); + cy.get('[data-testid="home2-tab"]').should('have.class', 'tab-selected'); + cy.get('[data-testid="home-tab"]').should('not.have.class', 'tab-selected'); + }); + + it('should select the correct tab when navigating via tab buttons', () => { + cy.visit('/tabs-similar-prefixes/home'); + + cy.get('[data-testid="home-tab"]').should('have.class', 'tab-selected'); + cy.get('[data-testid="home2-tab"]').should('not.have.class', 'tab-selected'); + + cy.get('[data-testid="home2-tab"]').click(); + cy.get('[data-testid="home2-tab"]').should('have.class', 'tab-selected'); + cy.get('[data-testid="home-tab"]').should('not.have.class', 'tab-selected'); + + cy.get('[data-testid="home3-tab"]').click(); + cy.get('[data-testid="home3-tab"]').should('have.class', 'tab-selected'); + cy.get('[data-testid="home-tab"]').should('not.have.class', 'tab-selected'); + cy.get('[data-testid="home2-tab"]').should('not.have.class', 'tab-selected'); + }); + + it('should select the correct tab when directly navigating to home3', () => { + cy.visit('/tabs-similar-prefixes/home3'); + + cy.get('[data-testid="home3-content"]').should('be.visible'); + cy.get('[data-testid="home3-tab"]').should('have.class', 'tab-selected'); + cy.get('[data-testid="home-tab"]').should('not.have.class', 'tab-selected'); + cy.get('[data-testid="home2-tab"]').should('not.have.class', 'tab-selected'); + }); + }); + describe('With IonRouterOutlet', () => { it('should go back from child pages', () => { cy.visit('/tabs');