diff --git a/packages/react-router/src/ReactRouter/ReactRouterViewStack.tsx b/packages/react-router/src/ReactRouter/ReactRouterViewStack.tsx index 76fa4bfd0c9..fc1df86f6e8 100644 --- a/packages/react-router/src/ReactRouter/ReactRouterViewStack.tsx +++ b/packages/react-router/src/ReactRouter/ReactRouterViewStack.tsx @@ -384,30 +384,38 @@ export class ReactRouterViewStack extends ViewStacks { // For relative route paths, we need to compute an absolute pathnameBase // by combining the parent's pathnameBase with the matched portion - let absolutePathnameBase = routeMatch?.pathnameBase || routeInfo.pathname; const routePath = routeElement.props.path; const isRelativePath = routePath && !routePath.startsWith('/'); const isIndexRoute = !!routeElement.props.index; - - if (isRelativePath || isIndexRoute) { - // Get the parent's pathnameBase to build the absolute path - const parentPathnameBase = - parentMatches.length > 0 ? parentMatches[parentMatches.length - 1].pathnameBase : '/'; - - // For relative paths, the matchPath returns a relative pathnameBase - // We need to make it absolute by prepending the parent's base - if (routeMatch?.pathnameBase && isRelativePath) { - // Strip leading slash if present in the relative match - const relativeBase = routeMatch.pathnameBase.startsWith('/') - ? routeMatch.pathnameBase.slice(1) - : routeMatch.pathnameBase; - - absolutePathnameBase = - parentPathnameBase === '/' ? `/${relativeBase}` : `${parentPathnameBase}/${relativeBase}`; - } else if (isIndexRoute) { - // Index routes should use the parent's base as their base - absolutePathnameBase = parentPathnameBase; - } + const isSplatOnlyRoute = routePath === '*' || routePath === '/*'; + + // Get parent's pathnameBase for relative path resolution + const parentPathnameBase = + parentMatches.length > 0 ? parentMatches[parentMatches.length - 1].pathnameBase : '/'; + + // Start with the match's pathnameBase, falling back to routeInfo.pathname + // BUT: splat-only routes should use parent's base (v7_relativeSplatPath behavior) + let absolutePathnameBase: string; + + if (isSplatOnlyRoute) { + // Splat routes should NOT contribute their matched portion to pathnameBase + // This aligns with React Router v7's v7_relativeSplatPath behavior + // Without this, relative links inside splat routes get double path segments + absolutePathnameBase = parentPathnameBase; + } else if (isRelativePath && routeMatch?.pathnameBase) { + // For relative paths with a pathnameBase, combine with parent + const relativeBase = routeMatch.pathnameBase.startsWith('/') + ? routeMatch.pathnameBase.slice(1) + : routeMatch.pathnameBase; + + absolutePathnameBase = + parentPathnameBase === '/' ? `/${relativeBase}` : `${parentPathnameBase}/${relativeBase}`; + } else if (isIndexRoute) { + // Index routes should use the parent's base as their base + absolutePathnameBase = parentPathnameBase; + } else { + // Default: use the match's pathnameBase or the current pathname + absolutePathnameBase = routeMatch?.pathnameBase || routeInfo.pathname; } const contextMatches = [ diff --git a/packages/react-router/src/ReactRouter/utils/computeParentPath.ts b/packages/react-router/src/ReactRouter/utils/computeParentPath.ts index 3f47672a445..338efc28347 100644 --- a/packages/react-router/src/ReactRouter/utils/computeParentPath.ts +++ b/packages/react-router/src/ReactRouter/utils/computeParentPath.ts @@ -44,26 +44,39 @@ export const computeCommonPrefix = (paths: string[]): string => { }; /** - * Checks if a route is a specific match (not wildcard or index). - * - * @param route The route element to check. - * @param remainingPath The remaining path to match against. - * @returns True if the route specifically matches the remaining path. + * Checks if a route path is a "splat-only" route (just `*` or `/*`). */ -export const isSpecificRouteMatch = (route: React.ReactElement, remainingPath: string): boolean => { - const routePath = route.props.path; - const isWildcardOnly = routePath === '*' || routePath === '/*'; - const isIndex = route.props.index; +const isSplatOnlyRoute = (routePath: string | undefined): boolean => { + return routePath === '*' || routePath === '/*'; +}; - // Skip wildcards and index routes - if (isIndex || isWildcardOnly) { +/** + * Checks if a route has an embedded wildcard (e.g., "tab1/*" but not "*" or "/*"). + */ +const hasEmbeddedWildcard = (routePath: string | undefined): boolean => { + return !!routePath && routePath.includes('*') && !isSplatOnlyRoute(routePath); +}; + +/** + * Checks if a route with an embedded wildcard matches a pathname. + */ +const matchesEmbeddedWildcardRoute = (route: React.ReactElement, pathname: string): boolean => { + const routePath = route.props.path as string | undefined; + if (!hasEmbeddedWildcard(routePath)) { return false; } + return !!matchPath({ pathname, componentProps: route.props }); +}; - return !!matchPath({ - pathname: remainingPath, - componentProps: route.props, - }); +/** + * Checks if a route is a specific match (not wildcard-only or index). + */ +export const isSpecificRouteMatch = (route: React.ReactElement, remainingPath: string): boolean => { + const routePath = route.props.path; + if (route.props.index || isSplatOnlyRoute(routePath)) { + return false; + } + return !!matchPath({ pathname: remainingPath, componentProps: route.props }); }; /** @@ -142,12 +155,16 @@ export const computeParentPath = (options: ComputeParentPathOptions): ParentPath let firstWildcardMatch: string | undefined = undefined; let indexMatchAtMount: string | undefined = undefined; + // Start at i = 1 (normal case: strip at least one segment for parent path) for (let i = 1; i <= segments.length; i++) { const parentPath = '/' + segments.slice(0, i).join('/'); const remainingPath = segments.slice(i).join('/'); - // Check for specific (non-wildcard, non-index) route matches - const hasSpecificMatch = routeChildren.some((route) => isSpecificRouteMatch(route, remainingPath)); + // Check for specific route matches (non-wildcard-only, non-index) + // Also check routes with embedded wildcards (e.g., "tab1/*") + const hasSpecificMatch = routeChildren.some( + (route) => isSpecificRouteMatch(route, remainingPath) || matchesEmbeddedWildcardRoute(route, remainingPath) + ); if (hasSpecificMatch && !firstSpecificMatch) { firstSpecificMatch = parentPath; // Found a specific match - this is our answer for non-index routes @@ -198,6 +215,17 @@ export const computeParentPath = (options: ComputeParentPathOptions): ParentPath } } + // Fallback: check at root level (i = 0) for embedded wildcard routes. + // This handles outlets inside root-level splat routes where routes like + // "tab1/*" need to match the full pathname. + if (!firstSpecificMatch) { + const fullRemainingPath = segments.join('/'); + const hasRootLevelMatch = routeChildren.some((route) => matchesEmbeddedWildcardRoute(route, fullRemainingPath)); + if (hasRootLevelMatch) { + firstSpecificMatch = '/'; + } + } + // Determine the best parent path: // 1. Specific match (routes like tabs/*, favorites) - highest priority // 2. Wildcard match (route path="*") - catches unmatched segments diff --git a/packages/react-router/test/base/src/App.tsx b/packages/react-router/test/base/src/App.tsx index 609cd2d37a0..50c5a04d94b 100644 --- a/packages/react-router/test/base/src/App.tsx +++ b/packages/react-router/test/base/src/App.tsx @@ -43,6 +43,8 @@ import Tabs from './pages/tabs/Tabs'; import TabsSecondary from './pages/tabs/TabsSecondary'; import TabHistoryIsolation from './pages/tab-history-isolation/TabHistoryIsolation'; import Overlays from './pages/overlays/Overlays'; +import NestedTabsRelativeLinks from './pages/nested-tabs-relative-links/NestedTabsRelativeLinks'; +import RootSplatTabs from './pages/root-splat-tabs/RootSplatTabs'; setupIonicReact(); @@ -75,6 +77,8 @@ const App: React.FC = () => { } /> {/* Test root-level relative path - no leading slash */} } /> + } /> + } /> diff --git a/packages/react-router/test/base/src/pages/Main.tsx b/packages/react-router/test/base/src/pages/Main.tsx index f378b42f7bf..cb19b72d997 100644 --- a/packages/react-router/test/base/src/pages/Main.tsx +++ b/packages/react-router/test/base/src/pages/Main.tsx @@ -80,6 +80,12 @@ const Main: React.FC = () => { Relative Paths + + Nested Tabs Relative Links + + + Root Splat Tabs + diff --git a/packages/react-router/test/base/src/pages/nested-tabs-relative-links/NestedTabsRelativeLinks.tsx b/packages/react-router/test/base/src/pages/nested-tabs-relative-links/NestedTabsRelativeLinks.tsx new file mode 100644 index 00000000000..8b51f82d39f --- /dev/null +++ b/packages/react-router/test/base/src/pages/nested-tabs-relative-links/NestedTabsRelativeLinks.tsx @@ -0,0 +1,194 @@ +import { + IonContent, + IonHeader, + IonPage, + IonTitle, + IonToolbar, + IonRouterOutlet, + IonTabs, + IonTabBar, + IonTabButton, + IonIcon, + IonLabel, + IonBackButton, + IonButtons, +} from '@ionic/react'; +import { triangle, ellipse, square } from 'ionicons/icons'; +import React from 'react'; +import { Link, Navigate, Route } from 'react-router-dom'; + +/** + * This test page verifies that relative links work correctly within + * nested IonRouterOutlet components, specifically in a tabs-based layout. + * + * Issue: When using React Router's inside the tab1 route + * with nested outlets and index routes, the relative path resolution can produce + * incorrect URLs (e.g., /tab1/tab1/page-a instead of /tab1/page-a). + * + * This test also verifies that absolute links work when a catch-all route + * is present. + */ + +// Tab content with relative links for testing +const Tab1Content: React.FC = () => { + return ( + + + + Tab 1 + + + +
+

Tab 1 - Home Page

+ {/* Relative link - should navigate to /nested-tabs-relative-links/tab1/page-a */} + + Go to Page A (relative) + +
+ {/* Absolute link - should also work */} + + Go to Page A (absolute) + +
+ {/* Another relative link */} + + Go to Page B (relative) + +
+
+
+ ); +}; + +const PageA: React.FC = () => { + return ( + + + + + + + Page A + + + +
+ This is Page A within Tab 1 +
+
+
+ ); +}; + +const PageB: React.FC = () => { + return ( + + + + + + + Page B + + + +
+ This is Page B within Tab 1 +
+
+
+ ); +}; + +// Nested router outlet for Tab 1 - similar to user's RouterOutletTab1 +const Tab1RouterOutlet: React.FC = () => { + return ( + + } /> + } /> + } /> + + ); +}; + +const Tab2Content: React.FC = () => { + return ( + + + + Tab 2 + + + +
+ Tab 2 Content +
+
+
+ ); +}; + +const Tab3Content: React.FC = () => { + return ( + + + + Tab 3 + + + +
+ Tab 3 Content +
+
+
+ ); +}; + +// Main tabs component - wraps tabs with catch-all route (similar to user's reproduction) +const TabsContainer: React.FC = () => ( + + + {/* Tab 1 has nested routes with index route */} + } /> + } /> + } /> + } /> + {/* Catch-all 404 route - this presence caused issues with absolute links */} + + +

404 - Not Found

+
+ + } + /> +
+ + + + Tab 1 + + + + Tab 2 + + + + Tab 3 + + +
+); + +// Top-level component - splat route renders tabs +const NestedTabsRelativeLinks: React.FC = () => ( + + } /> + +); + +export default NestedTabsRelativeLinks; diff --git a/packages/react-router/test/base/src/pages/root-splat-tabs/RootSplatTabs.tsx b/packages/react-router/test/base/src/pages/root-splat-tabs/RootSplatTabs.tsx new file mode 100644 index 00000000000..f967ac96ffd --- /dev/null +++ b/packages/react-router/test/base/src/pages/root-splat-tabs/RootSplatTabs.tsx @@ -0,0 +1,163 @@ +import { + IonContent, + IonHeader, + IonPage, + IonTitle, + IonToolbar, + IonRouterOutlet, + IonTabs, + IonTabBar, + IonTabButton, + IonIcon, + IonLabel, + IonBackButton, + IonButtons, +} from '@ionic/react'; +import { triangle, ellipse, square } from 'ionicons/icons'; +import React from 'react'; +import { Link, Navigate, Route } from 'react-router-dom'; + +/** + * Test page for root-level splat routes with relative tab paths. + * + * Structure: Outer splat route "*" renders IonTabs, with relative paths + * like "tab1/*" (no leading slash) inside the tabs outlet. + * + * This tests the fix for routes with relative paths inside root-level splat routes. + */ + +// Tab content with relative links for testing +const Tab1Content: React.FC = () => { + return ( + + + + Tab 1 + + + +
+

Tab 1 - Home Page (Root Splat Test)

+ + Go to Page A (relative) + +
+ + Go to Page A (absolute) + +
+
+
+ ); +}; + +const PageA: React.FC = () => { + return ( + + + + + + + Page A + + + +
+ This is Page A within Tab 1 (Root Splat Test) +
+
+
+ ); +}; + +// Nested router outlet for Tab 1 - matches customer's RouterOutletTab1 +const Tab1RouterOutlet: React.FC = () => { + return ( + + + } /> + } /> + + + ); +}; + +const Tab2Content: React.FC = () => { + return ( + + + + Tab 2 + + + +
+ Tab 2 Content (Root Splat Test) +
+
+
+ ); +}; + +const Tab3Content: React.FC = () => { + return ( + + + + Tab 3 + + + +
+ Tab 3 Content (Root Splat Test) +
+
+
+ ); +}; + +const NotFoundPage: React.FC = () => { + return ( + + +

404 - Not Found (Root Splat Test)

+
+
+ ); +}; + +// Tabs rendered directly inside a catch-all splat route +const TabsWithSplatRoutes: React.FC = () => { + return ( + + + {/* Using RELATIVE path "tab1/*" (no leading slash) - the key test case */} + } /> + } /> + } /> + } /> + } /> + + + + + Tab 1 + + + + Tab 2 + + + + Tab 3 + + + + ); +}; + +// Main component - renders tabs directly (no outlet wrapper) +const RootSplatTabs: React.FC = () => ; + +export default RootSplatTabs; diff --git a/packages/react-router/test/base/tests/e2e/specs/nested-tabs-relative-links.cy.js b/packages/react-router/test/base/tests/e2e/specs/nested-tabs-relative-links.cy.js new file mode 100644 index 00000000000..dc375e1c83f --- /dev/null +++ b/packages/react-router/test/base/tests/e2e/specs/nested-tabs-relative-links.cy.js @@ -0,0 +1,119 @@ +const port = 3000; + +/** + * Tests for relative links within nested IonRouterOutlet components. + * + * This specifically tests the scenario where: + * 1. IonRouterOutlet has a catch-all route (*) containing IonTabs + * 2. Inside tabs, there's another outlet with nested routes using index routes + * 3. React Router's is used for navigation + * + * The expected behavior is: + * - at /nested-tabs-relative-links/tab1 should produce + * href="/nested-tabs-relative-links/tab1/page-a" (not /tab1/tab1/page-a) + * - should work and not 404 + */ +describe('Nested Tabs with Relative Links', () => { + it('should navigate to tab1 by default', () => { + cy.visit(`http://localhost:${port}/nested-tabs-relative-links`); + cy.ionPageVisible('nested-tabs-relative-tab1'); + cy.get('[data-testid="tab1-content"]').should('exist'); + }); + + it('should have correct href for relative link', () => { + cy.visit(`http://localhost:${port}/nested-tabs-relative-links/tab1`); + cy.ionPageVisible('nested-tabs-relative-tab1'); + + // Check that the relative link has the correct href + // It should be /nested-tabs-relative-links/tab1/page-a, NOT /tab1/tab1/page-a + cy.get('[data-testid="link-relative-page-a"]') + .should('have.attr', 'href', '/nested-tabs-relative-links/tab1/page-a'); + }); + + it('should navigate to Page A via relative link', () => { + cy.visit(`http://localhost:${port}/nested-tabs-relative-links/tab1`); + cy.ionPageVisible('nested-tabs-relative-tab1'); + + // Click the relative link + cy.get('[data-testid="link-relative-page-a"]').click(); + + // Should be at Page A + cy.ionPageVisible('nested-tabs-relative-page-a'); + cy.get('[data-testid="page-a-content"]').should('exist'); + + // URL should be correct + cy.url().should('include', '/nested-tabs-relative-links/tab1/page-a'); + // URL should NOT have duplicate path segments + cy.url().should('not.include', '/tab1/tab1/'); + }); + + it('should navigate to Page A via absolute link', () => { + cy.visit(`http://localhost:${port}/nested-tabs-relative-links/tab1`); + cy.ionPageVisible('nested-tabs-relative-tab1'); + + // Click the absolute link + cy.get('[data-testid="link-absolute-page-a"]').click(); + + // Should be at Page A (not 404) + cy.ionPageVisible('nested-tabs-relative-page-a'); + cy.get('[data-testid="page-a-content"]').should('exist'); + + // Should NOT show 404 + cy.get('[data-testid="not-found"]').should('not.exist'); + }); + + it('should navigate to Page B via relative link', () => { + cy.visit(`http://localhost:${port}/nested-tabs-relative-links/tab1`); + cy.ionPageVisible('nested-tabs-relative-tab1'); + + // Click the relative link to page B + cy.get('[data-testid="link-relative-page-b"]').click(); + + // Should be at Page B + cy.ionPageVisible('nested-tabs-relative-page-b'); + cy.get('[data-testid="page-b-content"]').should('exist'); + + // URL should be correct + cy.url().should('include', '/nested-tabs-relative-links/tab1/page-b'); + }); + + it('should navigate to Page A and back', () => { + cy.visit(`http://localhost:${port}/nested-tabs-relative-links/tab1`); + cy.ionPageVisible('nested-tabs-relative-tab1'); + + // Navigate to Page A + cy.get('[data-testid="link-relative-page-a"]').click(); + cy.ionPageVisible('nested-tabs-relative-page-a'); + + // Go back + cy.ionBackClick('nested-tabs-relative-page-a'); + + // Should be back at Tab 1 + cy.ionPageVisible('nested-tabs-relative-tab1'); + }); + + it('should directly visit Page A via URL', () => { + cy.visit(`http://localhost:${port}/nested-tabs-relative-links/tab1/page-a`); + + // Should be at Page A (not 404) + cy.ionPageVisible('nested-tabs-relative-page-a'); + cy.get('[data-testid="page-a-content"]').should('exist'); + }); + + it('should switch tabs and maintain correct relative link resolution', () => { + cy.visit(`http://localhost:${port}/nested-tabs-relative-links/tab1`); + cy.ionPageVisible('nested-tabs-relative-tab1'); + + // Switch to Tab 2 + cy.ionTabClick('Tab 2'); + cy.ionPageVisible('nested-tabs-relative-tab2'); + + // Switch back to Tab 1 + cy.ionTabClick('Tab 1'); + cy.ionPageVisible('nested-tabs-relative-tab1'); + + // The relative link should still have correct href + cy.get('[data-testid="link-relative-page-a"]') + .should('have.attr', 'href', '/nested-tabs-relative-links/tab1/page-a'); + }); +}); diff --git a/packages/react-router/test/base/tests/e2e/specs/root-splat-tabs.cy.js b/packages/react-router/test/base/tests/e2e/specs/root-splat-tabs.cy.js new file mode 100644 index 00000000000..e9f626da4b6 --- /dev/null +++ b/packages/react-router/test/base/tests/e2e/specs/root-splat-tabs.cy.js @@ -0,0 +1,95 @@ +const port = 3000; + +/** + * Tests for relative paths (e.g., "tab1/*") inside root-level splat routes (*). + * Verifies the fix for routes not matching when parent is a splat-only route. + */ +describe('Root Splat Tabs - Customer Reproduction', () => { + it('should navigate to tab1 by default when visiting /root-splat-tabs', () => { + cy.visit(`http://localhost:${port}/root-splat-tabs`); + // Should redirect to tab1 and show tab1 content + cy.ionPageVisible('root-splat-tab1'); + cy.get('[data-testid="root-splat-tab1-content"]').should('exist'); + }); + + it('should load tab1 when directly visiting /root-splat-tabs/tab1', () => { + cy.visit(`http://localhost:${port}/root-splat-tabs/tab1`); + // CRITICAL: This should show tab1 content, NOT 404 + cy.ionPageVisible('root-splat-tab1'); + cy.get('[data-testid="root-splat-tab1-content"]').should('exist'); + cy.get('[data-testid="root-splat-not-found"]').should('not.exist'); + }); + + it('should load Page A when directly visiting /root-splat-tabs/tab1/page-a', () => { + cy.visit(`http://localhost:${port}/root-splat-tabs/tab1/page-a`); + // CRITICAL: This should show Page A, NOT 404 + // This is the exact issue the customer reported + cy.ionPageVisible('root-splat-page-a'); + cy.get('[data-testid="root-splat-page-a-content"]').should('exist'); + cy.get('[data-testid="root-splat-not-found"]').should('not.exist'); + }); + + it('should navigate to Page A via relative link', () => { + cy.visit(`http://localhost:${port}/root-splat-tabs/tab1`); + cy.ionPageVisible('root-splat-tab1'); + + // Click the relative link + cy.get('[data-testid="link-relative-page-a"]').click(); + + // Should be at Page A (not 404) + cy.ionPageVisible('root-splat-page-a'); + cy.get('[data-testid="root-splat-page-a-content"]').should('exist'); + cy.get('[data-testid="root-splat-not-found"]').should('not.exist'); + + // URL should be correct + cy.url().should('include', '/root-splat-tabs/tab1/page-a'); + }); + + it('should navigate to Page A via absolute link', () => { + cy.visit(`http://localhost:${port}/root-splat-tabs/tab1`); + cy.ionPageVisible('root-splat-tab1'); + + // Click the absolute link + cy.get('[data-testid="link-absolute-page-a"]').click(); + + // Should be at Page A (not 404) + cy.ionPageVisible('root-splat-page-a'); + cy.get('[data-testid="root-splat-page-a-content"]').should('exist'); + cy.get('[data-testid="root-splat-not-found"]').should('not.exist'); + }); + + it('should have correct href for relative link', () => { + cy.visit(`http://localhost:${port}/root-splat-tabs/tab1`); + cy.ionPageVisible('root-splat-tab1'); + + // The relative link should resolve to the correct absolute href + cy.get('[data-testid="link-relative-page-a"]') + .should('have.attr', 'href', '/root-splat-tabs/tab1/page-a'); + }); + + it('should navigate between tabs correctly', () => { + cy.visit(`http://localhost:${port}/root-splat-tabs/tab1`); + cy.ionPageVisible('root-splat-tab1'); + + // Switch to Tab 2 + cy.ionTabClick('Tab 2'); + cy.ionPageVisible('root-splat-tab2'); + + // Switch back to Tab 1 + cy.ionTabClick('Tab 1'); + cy.ionPageVisible('root-splat-tab1'); + }); + + it('should navigate to Page A and back to Tab 1', () => { + cy.visit(`http://localhost:${port}/root-splat-tabs/tab1`); + cy.ionPageVisible('root-splat-tab1'); + + // Navigate to Page A + cy.get('[data-testid="link-relative-page-a"]').click(); + cy.ionPageVisible('root-splat-page-a'); + + // Go back + cy.ionBackClick('root-splat-page-a'); + cy.ionPageVisible('root-splat-tab1'); + }); +});