From 4025098e143b33a1b04eb6974b4af92e6ff6e0c1 Mon Sep 17 00:00:00 2001 From: ShaneK Date: Thu, 11 Dec 2025 12:48:43 -0800 Subject: [PATCH 1/4] fix(tabs): select correct tab when routes have similar prefixes --- .../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 | 39 +++++++++ packages/vue/src/components/IonTabBar.ts | 19 +++- 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 | 21 +++++ .../src/views/tabs-similar-prefixes/Home2.vue | 21 +++++ .../src/views/tabs-similar-prefixes/Home3.vue | 21 +++++ .../TabsSimilarPrefixes.vue | 71 +++++++++++++++ .../vue/test/base/tests/e2e/specs/tabs.cy.js | 39 +++++++++ 13 files changed, 363 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 92fde774ddd..124b00f6990 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 634af89f075..c8ea117f60e 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 3873cd3d5b5..e0dbdffcf6a 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 00000000000..78672dc075d --- /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 544fdc47c41..319066d46cc 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,43 @@ 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. + */ + 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 4da54f9eee0..1d65d25ea8b 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,7 @@ 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 ab5850e33c0..e518550fd01 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 a3ddbdf99c2..d37ab45bbea 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 00000000000..2a0ef281ed6 --- /dev/null +++ b/packages/vue/test/base/src/views/tabs-similar-prefixes/Home.vue @@ -0,0 +1,21 @@ + + + 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 00000000000..215aeb6220d --- /dev/null +++ b/packages/vue/test/base/src/views/tabs-similar-prefixes/Home2.vue @@ -0,0 +1,21 @@ + + + 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 00000000000..67fa86693df --- /dev/null +++ b/packages/vue/test/base/src/views/tabs-similar-prefixes/Home3.vue @@ -0,0 +1,21 @@ + + + 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 00000000000..10a14a362e6 --- /dev/null +++ b/packages/vue/test/base/src/views/tabs-similar-prefixes/TabsSimilarPrefixes.vue @@ -0,0 +1,71 @@ + + + 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 2b6cb15790f..1a7904f2730 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,43 @@ 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. + */ + 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'); From e797ca024cb1ef6311bbbdb39388a06a40c4c61e Mon Sep 17 00:00:00 2001 From: ShaneK Date: Thu, 11 Dec 2025 13:04:21 -0800 Subject: [PATCH 2/4] chore(lint): fix lint --- packages/vue/src/components/IonTabBar.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/vue/src/components/IonTabBar.ts b/packages/vue/src/components/IonTabBar.ts index 1d65d25ea8b..30002f4d858 100644 --- a/packages/vue/src/components/IonTabBar.ts +++ b/packages/vue/src/components/IonTabBar.ts @@ -152,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 && matchesTab(currentRoute.pathname, href); + return ( + currentRoute?.pathname && matchesTab(currentRoute.pathname, href) + ); }); /** From c6ce34c3de3d57d92e10b75fd51d3461d4f59705 Mon Sep 17 00:00:00 2001 From: Shane Date: Tue, 16 Dec 2025 12:27:35 -0800 Subject: [PATCH 3/4] chore(tests): add issue reference to test files Co-authored-by: Maria Hutt --- packages/react/test/base/tests/e2e/specs/tabs/tabs.cy.ts | 2 ++ packages/vue/test/base/tests/e2e/specs/tabs.cy.js | 2 ++ 2 files changed, 4 insertions(+) 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 319066d46cc..34b4ee4f1e6 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 @@ -2,6 +2,8 @@ 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', () => { 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 1a7904f2730..1fdc83cf720 100644 --- a/packages/vue/test/base/tests/e2e/specs/tabs.cy.js +++ b/packages/vue/test/base/tests/e2e/specs/tabs.cy.js @@ -2,6 +2,8 @@ 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', () => { From e5b70de6ac4798b870e3a2ebf641d0c583144f27 Mon Sep 17 00:00:00 2001 From: Shane Date: Tue, 16 Dec 2025 13:35:04 -0800 Subject: [PATCH 4/4] chore(tabs): use script setup syntax for vue tests Co-authored-by: Brandy Smith --- .../src/views/tabs-similar-prefixes/Home.vue | 9 ++----- .../src/views/tabs-similar-prefixes/Home2.vue | 9 ++----- .../src/views/tabs-similar-prefixes/Home3.vue | 9 ++----- .../TabsSimilarPrefixes.vue | 27 ++++--------------- 4 files changed, 11 insertions(+), 43 deletions(-) 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 index 2a0ef281ed6..0954fac9d4f 100644 --- a/packages/vue/test/base/src/views/tabs-similar-prefixes/Home.vue +++ b/packages/vue/test/base/src/views/tabs-similar-prefixes/Home.vue @@ -11,11 +11,6 @@ - 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 index 215aeb6220d..4e190d99e98 100644 --- a/packages/vue/test/base/src/views/tabs-similar-prefixes/Home2.vue +++ b/packages/vue/test/base/src/views/tabs-similar-prefixes/Home2.vue @@ -11,11 +11,6 @@ - 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 index 67fa86693df..78099959b06 100644 --- a/packages/vue/test/base/src/views/tabs-similar-prefixes/Home3.vue +++ b/packages/vue/test/base/src/views/tabs-similar-prefixes/Home3.vue @@ -11,11 +11,6 @@ - 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 index 10a14a362e6..8ec968edbd2 100644 --- a/packages/vue/test/base/src/views/tabs-similar-prefixes/TabsSimilarPrefixes.vue +++ b/packages/vue/test/base/src/views/tabs-similar-prefixes/TabsSimilarPrefixes.vue @@ -39,33 +39,16 @@ -