diff --git a/packages/react-router/src/ReactRouter/ReactRouterViewStack.tsx b/packages/react-router/src/ReactRouter/ReactRouterViewStack.tsx index 32598cb22ba..76fa4bfd0c9 100644 --- a/packages/react-router/src/ReactRouter/ReactRouterViewStack.tsx +++ b/packages/react-router/src/ReactRouter/ReactRouterViewStack.tsx @@ -469,7 +469,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 +715,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/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..609cd2d37a0 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'; @@ -72,6 +73,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 4f87061e347..f378b42f7bf 100644 --- a/packages/react-router/test/base/src/pages/Main.tsx +++ b/packages/react-router/test/base/src/pages/Main.tsx @@ -77,6 +77,9 @@ const Main: React.FC = () => { Nested Params + + Relative Paths + 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/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'); + }); +});