diff --git a/packages/react-router/src/ReactRouter/ReactRouterViewStack.tsx b/packages/react-router/src/ReactRouter/ReactRouterViewStack.tsx index 32598cb22ba..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 = [ @@ -469,7 +477,9 @@ export class ReactRouterViewStack extends ViewStacks { let parentPath: string | undefined = undefined; try { // Only attempt parent path computation for non-root outlets - if (outletId !== 'routerOutlet') { + // Root outlets have IDs like 'routerOutlet' or 'routerOutlet-2' + const isRootOutlet = outletId.startsWith('routerOutlet'); + if (!isRootOutlet) { const routeChildren = extractRouteChildren(ionRouterOutlet.props.children); const { hasRelativeRoutes, hasIndexRoute, hasWildcardRoute } = analyzeRouteChildren(routeChildren); @@ -713,7 +723,17 @@ export class ReactRouterViewStack extends ViewStacks { return false; } + // For empty path routes, only match if we're at the same level as when the view was created. + // This prevents an empty path view item from being reused for different routes. if (isDefaultRoute) { + const previousPathnameBase = v.routeData?.match?.pathnameBase || ''; + const normalizedBase = normalizePathnameForComparison(previousPathnameBase); + const normalizedPathname = normalizePathnameForComparison(pathname); + + if (normalizedPathname !== normalizedBase) { + return false; + } + match = { params: {}, pathname, diff --git a/packages/react-router/src/ReactRouter/StackManager.tsx b/packages/react-router/src/ReactRouter/StackManager.tsx index 4ce73c561b0..3a58b74be8d 100644 --- a/packages/react-router/src/ReactRouter/StackManager.tsx +++ b/packages/react-router/src/ReactRouter/StackManager.tsx @@ -109,28 +109,36 @@ export class StackManager extends React.PureComponent { return undefined; } - // If this is a nested outlet (has an explicit ID like "main"), - // we need to figure out what part of the path was already matched - if (this.id !== 'routerOutlet' && this.ionRouterOutlet) { + // Check if this outlet has route children to analyze + if (this.ionRouterOutlet) { const routeChildren = extractRouteChildren(this.ionRouterOutlet.props.children); const { hasRelativeRoutes, hasIndexRoute, hasWildcardRoute } = analyzeRouteChildren(routeChildren); - const result = computeParentPath({ - currentPathname, - outletMountPath: this.outletMountPath, - routeChildren, - hasRelativeRoutes, - hasIndexRoute, - hasWildcardRoute, - }); + // Root outlets have IDs like 'routerOutlet' or 'routerOutlet-2' + // But even outlets with auto-generated IDs may need parent path computation + // if they have relative routes (indicating they're nested outlets) + const isRootOutlet = this.id.startsWith('routerOutlet'); + const needsParentPath = !isRootOutlet || hasRelativeRoutes || hasIndexRoute; + + if (needsParentPath) { + const result = computeParentPath({ + currentPathname, + outletMountPath: this.outletMountPath, + routeChildren, + hasRelativeRoutes, + hasIndexRoute, + hasWildcardRoute, + }); + + // Update the outlet mount path if it was set + if (result.outletMountPath && !this.outletMountPath) { + this.outletMountPath = result.outletMountPath; + } - // Update the outlet mount path if it was set - if (result.outletMountPath && !this.outletMountPath) { - this.outletMountPath = result.outletMountPath; + return result.parentPath; } - - return result.parentPath; } + return this.outletMountPath; } @@ -246,7 +254,9 @@ export class StackManager extends React.PureComponent { parentPath: string | undefined, leavingViewItem: ViewItem | undefined ): boolean { - if (this.id === 'routerOutlet' || parentPath !== undefined || !this.ionRouterOutlet) { + // Root outlets have IDs like 'routerOutlet' or 'routerOutlet-2' + const isRootOutlet = this.id.startsWith('routerOutlet'); + if (isRootOutlet || parentPath !== undefined || !this.ionRouterOutlet) { return false; } @@ -283,7 +293,9 @@ export class StackManager extends React.PureComponent { enteringViewItem: ViewItem | undefined, leavingViewItem: ViewItem | undefined ): boolean { - if (this.id === 'routerOutlet' || enteringRoute || enteringViewItem) { + // Root outlets have IDs like 'routerOutlet' or 'routerOutlet-2' + const isRootOutlet = this.id.startsWith('routerOutlet'); + if (isRootOutlet || enteringRoute || enteringViewItem) { return false; } @@ -933,7 +945,8 @@ function findRouteByRouteInfo(node: React.ReactNode, routeInfo: RouteInfo, paren // For nested routes in React Router 6, we need to extract the relative path // that this outlet should be responsible for matching - let pathnameToMatch = routeInfo.pathname; + const originalPathname = routeInfo.pathname; + let relativePathnameToMatch = routeInfo.pathname; // Check if we have relative routes (routes that don't start with '/') const hasRelativeRoutes = sortedRoutes.some((r) => r.props.path && !r.props.path.startsWith('/')); @@ -942,7 +955,8 @@ function findRouteByRouteInfo(node: React.ReactNode, routeInfo: RouteInfo, paren // SIMPLIFIED: Trust React Router 6's matching more, compute relative path when parent is known if ((hasRelativeRoutes || hasIndexRoute) && parentPath) { const parentPrefix = parentPath.replace('/*', ''); - const normalizedParent = stripTrailingSlash(parentPrefix); + // Normalize both paths to start with '/' for consistent comparison + const normalizedParent = stripTrailingSlash(parentPrefix.startsWith('/') ? parentPrefix : `/${parentPrefix}`); const normalizedPathname = stripTrailingSlash(routeInfo.pathname); // Only compute relative path if pathname is within parent scope @@ -950,14 +964,44 @@ function findRouteByRouteInfo(node: React.ReactNode, routeInfo: RouteInfo, paren const pathSegments = routeInfo.pathname.split('/').filter(Boolean); const parentSegments = normalizedParent.split('/').filter(Boolean); const relativeSegments = pathSegments.slice(parentSegments.length); - pathnameToMatch = relativeSegments.join('/'); // Empty string is valid for index routes + relativePathnameToMatch = relativeSegments.join('/'); // Empty string is valid for index routes } } // Find the first matching route for (const child of sortedRoutes) { + const childPath = child.props.path as string | undefined; + const isAbsoluteRoute = childPath && childPath.startsWith('/'); + + // Determine which pathname to match against: + // - For absolute routes: use the original full pathname + // - For relative routes with a parent: use the computed relative pathname + // - For relative routes at root level (no parent): use the original pathname + // (matchPath will handle the relative-to-absolute normalization) + const pathnameToMatch = isAbsoluteRoute ? originalPathname : relativePathnameToMatch; + + // Determine the path portion to match: + // - For absolute routes: use derivePathnameToMatch + // - For relative routes at root level (no parent): use original pathname + // directly since matchPath normalizes both path and pathname + // - For relative routes with parent: use derivePathnameToMatch for wildcards, + // or the computed relative pathname for non-wildcards + let pathForMatch: string; + if (isAbsoluteRoute) { + pathForMatch = derivePathnameToMatch(pathnameToMatch, childPath); + } else if (!parentPath && childPath) { + // Root-level relative route: use the full pathname and let matchPath + // handle the normalization (it adds '/' to both path and pathname) + pathForMatch = originalPathname; + } else if (childPath && childPath.includes('*')) { + // Relative wildcard route with parent path: use derivePathnameToMatch + pathForMatch = derivePathnameToMatch(pathnameToMatch, childPath); + } else { + pathForMatch = pathnameToMatch; + } + const match = matchPath({ - pathname: pathnameToMatch, + pathname: pathForMatch, componentProps: child.props, }); 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/src/ReactRouter/utils/pathMatching.ts b/packages/react-router/src/ReactRouter/utils/pathMatching.ts index a0ab74164f7..623564a407a 100644 --- a/packages/react-router/src/ReactRouter/utils/pathMatching.ts +++ b/packages/react-router/src/ReactRouter/utils/pathMatching.ts @@ -27,13 +27,8 @@ interface MatchPathOptions { export const matchPath = ({ pathname, componentProps }: MatchPathOptions): PathMatch | null => { const { path, index, ...restProps } = componentProps; - // Handle index routes + // Handle index routes - they match when pathname is empty or just "/" if (index && !path) { - // Index routes match when there's no additional path after the parent route - // For example, in a nested outlet at /routing/*, the index route matches - // when the relative path is empty (i.e., we're exactly at /routing) - - // If pathname is empty or just "/", it should match the index route if (pathname === '' || pathname === '/') { return { params: {}, @@ -46,17 +41,27 @@ export const matchPath = ({ pathname, componentProps }: MatchPathOptions): PathM }, }; } - - // Otherwise, index routes don't match when there's additional path return null; } - if (!path) { + // Handle empty path routes - they match when pathname is also empty or just "/" + if (path === '' || path === undefined) { + if (pathname === '' || pathname === '/') { + return { + params: {}, + pathname: pathname, + pathnameBase: pathname || '/', + pattern: { + path: '', + caseSensitive: restProps.caseSensitive ?? false, + end: restProps.end ?? true, + }, + }; + } return null; } - // For relative paths in nested routes (those that don't start with '/'), - // use React Router's matcher against a normalized path. + // For relative paths (don't start with '/'), normalize both path and pathname for matching if (!path.startsWith('/')) { const matchOptions: Parameters[0] = { path: `/${path}`, @@ -83,7 +88,6 @@ export const matchPath = ({ pathname, componentProps }: MatchPathOptions): PathM }; } - // No match found return null; } @@ -109,13 +113,17 @@ export const matchPath = ({ pathname, componentProps }: MatchPathOptions): PathM * strip off the already-matched parent segments so React Router receives the remainder. */ export const derivePathnameToMatch = (fullPathname: string, routePath?: string): string => { + // For absolute or empty routes, use the full pathname as-is if (!routePath || routePath === '' || routePath.startsWith('/')) { return fullPathname; } const trimmedPath = fullPathname.startsWith('/') ? fullPathname.slice(1) : fullPathname; if (!trimmedPath) { - return ''; + // For root-level relative routes (pathname is "/" and routePath is relative), + // return the full pathname so matchPath can normalize both. + // This allows routes like at root level to work correctly. + return fullPathname; } const fullSegments = trimmedPath.split('/').filter(Boolean); diff --git a/packages/react-router/test/base/src/App.tsx b/packages/react-router/test/base/src/App.tsx index a97c04f5d71..50c5a04d94b 100644 --- a/packages/react-router/test/base/src/App.tsx +++ b/packages/react-router/test/base/src/App.tsx @@ -31,6 +31,7 @@ import MultipleTabs from './pages/muiltiple-tabs/MultipleTabs'; import NestedOutlet from './pages/nested-outlet/NestedOutlet'; import NestedOutlet2 from './pages/nested-outlet/NestedOutlet2'; import NestedParams from './pages/nested-params/NestedParams'; +import RelativePaths from './pages/relative-paths/RelativePaths'; import { OutletRef } from './pages/outlet-ref/OutletRef'; import Params from './pages/params/Params'; import Refs from './pages/refs/Refs'; @@ -42,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(); @@ -72,6 +75,10 @@ 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 4f87061e347..cb19b72d997 100644 --- a/packages/react-router/test/base/src/pages/Main.tsx +++ b/packages/react-router/test/base/src/pages/Main.tsx @@ -77,6 +77,15 @@ const Main: React.FC = () => { Nested Params + + 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/relative-paths/RelativePaths.tsx b/packages/react-router/test/base/src/pages/relative-paths/RelativePaths.tsx new file mode 100644 index 00000000000..73d2fe8f496 --- /dev/null +++ b/packages/react-router/test/base/src/pages/relative-paths/RelativePaths.tsx @@ -0,0 +1,103 @@ +import { + IonContent, + IonHeader, + IonPage, + IonTitle, + IonToolbar, + IonRouterOutlet, + IonList, + IonItem, + IonLabel, + IonBackButton, + IonButtons, +} from '@ionic/react'; +import React from 'react'; +import { Route } from 'react-router-dom'; + +/** + * This test page verifies that IonRouterOutlet correctly handles + * relative paths (paths without a leading slash) the same way + * React Router 6's Routes component does. + */ + +const RelativePathsHome: React.FC = () => { + return ( + + + + + + + Relative Paths Test + + + + + + Go to Page A (absolute path route) + + + Go to Page B (relative path route) + + + + + ); +}; + +const PageA: React.FC = () => { + return ( + + + + + + + Page A + + + +
+ This is Page A - route defined with absolute path +
+
+
+ ); +}; + +const PageB: React.FC = () => { + return ( + + + + + + + Page B + + + +
+ This is Page B - route defined with relative path (no leading slash) +
+
+
+ ); +}; + +const RelativePaths: React.FC = () => { + return ( + + {/* Route with absolute path (has leading slash) - this should work */} + } /> + + {/* Route with relative path (no leading slash) */} + } /> + + {/* Home route - using relative path */} + } /> + + ); +}; + +export default RelativePaths; 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/relative-paths.cy.js b/packages/react-router/test/base/tests/e2e/specs/relative-paths.cy.js new file mode 100644 index 00000000000..b88e061c592 --- /dev/null +++ b/packages/react-router/test/base/tests/e2e/specs/relative-paths.cy.js @@ -0,0 +1,44 @@ +const port = 3000; + +/** + * Tests for relative path handling in IonRouterOutlet. + * Verifies that routes with relative paths (no leading slash) work + * the same as absolute paths, matching React Router 6 behavior. + */ +describe('Relative Paths Tests', () => { + it('should navigate to the relative paths home page', () => { + cy.visit(`http://localhost:${port}/relative-paths`); + cy.ionPageVisible('relative-paths-home'); + }); + + it('should navigate to Page A (defined with absolute path)', () => { + cy.visit(`http://localhost:${port}/relative-paths`); + cy.ionPageVisible('relative-paths-home'); + cy.ionNav('ion-item', 'Go to Page A'); + cy.ionPageVisible('relative-paths-page-a'); + cy.get('[data-testid="page-a-content"]').should('contain', 'Page A'); + }); + + it('should navigate to Page B (defined with relative path - no leading slash)', () => { + cy.visit(`http://localhost:${port}/relative-paths`); + cy.ionPageVisible('relative-paths-home'); + cy.ionNav('ion-item', 'Go to Page B'); + cy.ionPageVisible('relative-paths-page-b'); + cy.get('[data-testid="page-b-content"]').should('contain', 'Page B'); + }); + + it('should navigate directly to Page B via URL', () => { + cy.visit(`http://localhost:${port}/relative-paths/page-b`); + cy.ionPageVisible('relative-paths-page-b'); + cy.get('[data-testid="page-b-content"]').should('contain', 'Page B'); + }); + + it('should navigate to Page B and back', () => { + cy.visit(`http://localhost:${port}/relative-paths`); + cy.ionPageVisible('relative-paths-home'); + cy.ionNav('ion-item', 'Go to Page B'); + cy.ionPageVisible('relative-paths-page-b'); + cy.ionBackClick('relative-paths-page-b'); + cy.ionPageVisible('relative-paths-home'); + }); +}); 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'); + }); +});