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..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 @@ -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 4da54f9eee0..30002f4d858 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 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..0954fac9d4f --- /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 00000000000..4e190d99e98 --- /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 00000000000..78099959b06 --- /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 00000000000..8ec968edbd2 --- /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 2b6cb15790f..1fdc83cf720 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');