diff --git a/packages/react-router/src/ReactRouter/StackManager.tsx b/packages/react-router/src/ReactRouter/StackManager.tsx index 5b24ec91f6..79502eaa1b 100644 --- a/packages/react-router/src/ReactRouter/StackManager.tsx +++ b/packages/react-router/src/ReactRouter/StackManager.tsx @@ -7,7 +7,8 @@ import type { RouteInfo, StackContextState, ViewItem } from '@ionic/react'; import { IonRoute, RouteManagerContext, StackContext, generateId, getConfig } from '@ionic/react'; import React from 'react'; -import { Route, UNSAFE_RouteContext as RouteContext } from 'react-router-dom'; +import type { RouteObject } from 'react-router-dom'; +import { Route, UNSAFE_RouteContext as RouteContext, matchRoutes } from 'react-router-dom'; import { clonePageElement } from './clonePageElement'; import { @@ -19,7 +20,6 @@ import { import { derivePathnameToMatch, matchPath } from './utils/pathMatching'; import { stripTrailingSlash } from './utils/pathNormalization'; import { extractRouteChildren, getRoutesChildren, isNavigateElement } from './utils/routeElements'; -import { compareRouteSpecificity } from './utils/viewItemUtils'; /** * Delay in milliseconds before unmounting a view after a transition completes. @@ -1320,7 +1320,7 @@ export class StackManager extends React.PureComponent { // Derive the outlet's mount path from React Router's matched route context. // This eliminates the need for heuristic-based mount path discovery in // computeParentPath, since React Router already knows the matched base path. - const parentMatches = parentContext?.matches as Array<{ pathnameBase: string }> | undefined; + const parentMatches = parentContext?.matches as { pathnameBase: string }[] | undefined; const parentPathnameBase = parentMatches && parentMatches.length > 0 ? parentMatches[parentMatches.length - 1].pathnameBase @@ -1383,6 +1383,49 @@ export class StackManager extends React.PureComponent { export default StackManager; +/** + * Converts React Route elements to RouteObject format for use with matchRoutes(). + * Filters out pathless routes (which are handled by fallback logic separately). + * + * When a basename is provided, absolute route paths are relativized by stripping + * the basename prefix. This is necessary because matchRoutes() strips the basename + * from the LOCATION pathname but not from route paths — absolute paths must be + * made relative to the basename for matching to work correctly. + * + * @param routeChildren The flat array of Route/IonRoute elements from the outlet. + * @param basename The resolved parent path (without trailing slash or `/*`) used to relativize absolute paths. + */ +function routeElementsToRouteObjects(routeChildren: React.ReactElement[], basename?: string): RouteObject[] { + return routeChildren + .filter((child) => child.props.path != null || child.props.index) + .map((child): RouteObject => { + const handle = { _element: child }; + let path = child.props.path as string | undefined; + + // Relativize absolute paths by stripping the basename prefix + if (path && path.startsWith('/') && basename) { + if (path === basename) { + path = ''; + } else if (path.startsWith(basename + '/')) { + path = path.slice(basename.length + 1); + } + } + + if (child.props.index) { + return { + index: true, + handle, + caseSensitive: child.props.caseSensitive || undefined, + }; + } + return { + path, + handle, + caseSensitive: child.props.caseSensitive || undefined, + }; + }); +} + /** * Finds the `` node matching the current route info. * If no `` can be matched, a fallback node is returned. @@ -1405,102 +1448,39 @@ function findRouteByRouteInfo(node: React.ReactNode, routeInfo: RouteInfo, paren React.isValidElement(child) && (child.type === Route || child.type === IonRoute) ); - // Sort routes by specificity (most specific first) - const sortedRoutes = routeChildren.sort((a, b) => - compareRouteSpecificity( - { path: a.props.path || '', index: !!a.props.index }, - { path: b.props.path || '', index: !!b.props.index } - ) - ); + // Delegate route matching to RR6's matchRoutes(), which handles specificity ranking internally. + const basename = parentPath ? stripTrailingSlash(parentPath.replace('/*', '')) : undefined; + const routeObjects = routeElementsToRouteObjects(routeChildren, basename); + const matches = matchRoutes(routeObjects, { pathname: routeInfo.pathname }, basename); - // For nested routes in React Router 6, we need to extract the relative path - // that this outlet should be responsible for matching - 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('/')); - const hasIndexRoute = sortedRoutes.some((r) => r.props.index); - - // When parent path is known, compute the relative pathname for matching - if ((hasRelativeRoutes || hasIndexRoute) && parentPath) { - const parentPrefix = parentPath.replace('/*', ''); - // 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 - if (normalizedPathname.startsWith(normalizedParent + '/') || normalizedPathname === normalizedParent) { - const pathSegments = routeInfo.pathname.split('/').filter(Boolean); - const parentSegments = normalizedParent.split('/').filter(Boolean); - const relativeSegments = pathSegments.slice(parentSegments.length); - 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 - const pathnameToMatch = isAbsoluteRoute ? originalPathname : relativePathnameToMatch; - - // Determine the path portion to match - 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: the relative pathname was already - // computed above using the parent path, so it's already correct - pathForMatch = pathnameToMatch; - } else { - pathForMatch = pathnameToMatch; - } - - const match = matchPath({ - pathname: pathForMatch, - componentProps: child.props, - }); - - if (match) { - matchedNode = child; - break; - } - } - - if (matchedNode) { - return matchedNode; + if (matches && matches.length > 0) { + const bestMatch = matches[matches.length - 1]; + matchedNode = (bestMatch.route as any).handle?._element ?? undefined; } // Fallback: try pathless routes, but only if pathname is within scope. - let pathnameInScope = true; + if (!matchedNode) { + let pathnameInScope = true; - if (parentPath) { - pathnameInScope = isPathnameInScope(routeInfo.pathname, parentPath); - } else { - const absolutePathRoutes = routeChildren.filter((r) => r.props.path && r.props.path.startsWith('/')); - if (absolutePathRoutes.length > 0) { - const absolutePaths = absolutePathRoutes.map((r) => r.props.path as string); - const commonPrefix = computeCommonPrefix(absolutePaths); - if (commonPrefix && commonPrefix !== '/') { - pathnameInScope = routeInfo.pathname.startsWith(commonPrefix); + if (parentPath) { + pathnameInScope = isPathnameInScope(routeInfo.pathname, parentPath); + } else { + const absolutePathRoutes = routeChildren.filter((r) => r.props.path && r.props.path.startsWith('/')); + if (absolutePathRoutes.length > 0) { + const absolutePaths = absolutePathRoutes.map((r) => r.props.path as string); + const commonPrefix = computeCommonPrefix(absolutePaths); + if (commonPrefix && commonPrefix !== '/') { + pathnameInScope = routeInfo.pathname.startsWith(commonPrefix); + } } } - } - if (pathnameInScope) { - for (const child of routeChildren) { - if (!child.props.path) { - fallbackNode = child; - break; + if (pathnameInScope) { + for (const child of routeChildren) { + if (!child.props.path) { + fallbackNode = child; + break; + } } } } diff --git a/packages/react-router/test/base/tests/e2e/specs/shadow-matchroutes.cy.js b/packages/react-router/test/base/tests/e2e/specs/shadow-matchroutes.cy.js new file mode 100644 index 0000000000..49c93ae9c3 --- /dev/null +++ b/packages/react-router/test/base/tests/e2e/specs/shadow-matchroutes.cy.js @@ -0,0 +1,36 @@ +const port = 3000; + +/** + * Verifies that the matchRoutes()-based findRouteByRouteInfo works correctly + * for various route patterns: absolute, relative, nested, tabs, index. + */ +describe('matchRoutes integration', () => { + it('should match absolute routes at root outlet', () => { + cy.visit(`http://localhost:${port}/`); + cy.ionPageVisible('home'); + }); + + it('should match relative routes in nested outlet', () => { + cy.visit(`http://localhost:${port}/routing/tabs/home`); + cy.ionPageVisible('home-page'); + }); + + it('should match routes after tab switch', () => { + cy.visit(`http://localhost:${port}/routing/tabs/home`); + cy.ionPageVisible('home-page'); + + cy.ionTabClick('Settings'); + cy.ionPageVisible('settings-page'); + }); + + it('should match routes after switching tabs back', () => { + cy.visit(`http://localhost:${port}/routing/tabs/home`); + cy.ionPageVisible('home-page'); + + cy.ionTabClick('Settings'); + cy.ionPageVisible('settings-page'); + + cy.ionTabClick('Home'); + cy.ionPageVisible('home-page'); + }); +});