Skip to content

Commit 84ae705

Browse files
committed
fix(react-router): using rr6's match routes to prevent having our own custom route matching logic
1 parent e6def6b commit 84ae705

File tree

2 files changed

+107
-91
lines changed

2 files changed

+107
-91
lines changed

packages/react-router/src/ReactRouter/StackManager.tsx

Lines changed: 71 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
import type { RouteInfo, StackContextState, ViewItem } from '@ionic/react';
88
import { IonRoute, RouteManagerContext, StackContext, generateId, getConfig } from '@ionic/react';
99
import React from 'react';
10-
import { Route, UNSAFE_RouteContext as RouteContext } from 'react-router-dom';
10+
import type { RouteObject } from 'react-router-dom';
11+
import { Route, UNSAFE_RouteContext as RouteContext, matchRoutes } from 'react-router-dom';
1112

1213
import { clonePageElement } from './clonePageElement';
1314
import {
@@ -19,7 +20,6 @@ import {
1920
import { derivePathnameToMatch, matchPath } from './utils/pathMatching';
2021
import { stripTrailingSlash } from './utils/pathNormalization';
2122
import { extractRouteChildren, getRoutesChildren, isNavigateElement } from './utils/routeElements';
22-
import { compareRouteSpecificity } from './utils/viewItemUtils';
2323

2424
/**
2525
* Delay in milliseconds before unmounting a view after a transition completes.
@@ -1320,7 +1320,7 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
13201320
// Derive the outlet's mount path from React Router's matched route context.
13211321
// This eliminates the need for heuristic-based mount path discovery in
13221322
// computeParentPath, since React Router already knows the matched base path.
1323-
const parentMatches = parentContext?.matches as Array<{ pathnameBase: string }> | undefined;
1323+
const parentMatches = parentContext?.matches as { pathnameBase: string }[] | undefined;
13241324
const parentPathnameBase =
13251325
parentMatches && parentMatches.length > 0
13261326
? parentMatches[parentMatches.length - 1].pathnameBase
@@ -1383,6 +1383,49 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
13831383

13841384
export default StackManager;
13851385

1386+
/**
1387+
* Converts React Route elements to RouteObject format for use with matchRoutes().
1388+
* Filters out pathless routes (which are handled by fallback logic separately).
1389+
*
1390+
* When a basename is provided, absolute route paths are relativized by stripping
1391+
* the basename prefix. This is necessary because matchRoutes() strips the basename
1392+
* from the LOCATION pathname but not from route paths — absolute paths must be
1393+
* made relative to the basename for matching to work correctly.
1394+
*
1395+
* @param routeChildren The flat array of Route/IonRoute elements from the outlet.
1396+
* @param basename The resolved parent path (without trailing slash or `/*`) used to relativize absolute paths.
1397+
*/
1398+
function routeElementsToRouteObjects(routeChildren: React.ReactElement[], basename?: string): RouteObject[] {
1399+
return routeChildren
1400+
.filter((child) => child.props.path != null || child.props.index)
1401+
.map((child): RouteObject => {
1402+
const handle = { _element: child };
1403+
let path = child.props.path as string | undefined;
1404+
1405+
// Relativize absolute paths by stripping the basename prefix
1406+
if (path && path.startsWith('/') && basename) {
1407+
if (path === basename) {
1408+
path = '';
1409+
} else if (path.startsWith(basename + '/')) {
1410+
path = path.slice(basename.length + 1);
1411+
}
1412+
}
1413+
1414+
if (child.props.index) {
1415+
return {
1416+
index: true,
1417+
handle,
1418+
caseSensitive: child.props.caseSensitive || undefined,
1419+
};
1420+
}
1421+
return {
1422+
path,
1423+
handle,
1424+
caseSensitive: child.props.caseSensitive || undefined,
1425+
};
1426+
});
1427+
}
1428+
13861429
/**
13871430
* Finds the `<Route />` node matching the current route info.
13881431
* If no `<Route />` can be matched, a fallback node is returned.
@@ -1405,102 +1448,39 @@ function findRouteByRouteInfo(node: React.ReactNode, routeInfo: RouteInfo, paren
14051448
React.isValidElement(child) && (child.type === Route || child.type === IonRoute)
14061449
);
14071450

1408-
// Sort routes by specificity (most specific first)
1409-
const sortedRoutes = routeChildren.sort((a, b) =>
1410-
compareRouteSpecificity(
1411-
{ path: a.props.path || '', index: !!a.props.index },
1412-
{ path: b.props.path || '', index: !!b.props.index }
1413-
)
1414-
);
1415-
1416-
// For nested routes in React Router 6, we need to extract the relative path
1417-
// that this outlet should be responsible for matching
1418-
const originalPathname = routeInfo.pathname;
1419-
let relativePathnameToMatch = routeInfo.pathname;
1420-
1421-
// Check if we have relative routes (routes that don't start with '/')
1422-
const hasRelativeRoutes = sortedRoutes.some((r) => r.props.path && !r.props.path.startsWith('/'));
1423-
const hasIndexRoute = sortedRoutes.some((r) => r.props.index);
1424-
1425-
// When parent path is known, compute the relative pathname for matching
1426-
if ((hasRelativeRoutes || hasIndexRoute) && parentPath) {
1427-
const parentPrefix = parentPath.replace('/*', '');
1428-
// Normalize both paths to start with '/' for consistent comparison
1429-
const normalizedParent = stripTrailingSlash(parentPrefix.startsWith('/') ? parentPrefix : `/${parentPrefix}`);
1430-
const normalizedPathname = stripTrailingSlash(routeInfo.pathname);
1431-
1432-
// Only compute relative path if pathname is within parent scope
1433-
if (normalizedPathname.startsWith(normalizedParent + '/') || normalizedPathname === normalizedParent) {
1434-
const pathSegments = routeInfo.pathname.split('/').filter(Boolean);
1435-
const parentSegments = normalizedParent.split('/').filter(Boolean);
1436-
const relativeSegments = pathSegments.slice(parentSegments.length);
1437-
relativePathnameToMatch = relativeSegments.join('/'); // Empty string is valid for index routes
1438-
}
1439-
}
1440-
1441-
// Find the first matching route
1442-
for (const child of sortedRoutes) {
1443-
const childPath = child.props.path as string | undefined;
1444-
const isAbsoluteRoute = childPath && childPath.startsWith('/');
1445-
1446-
// Determine which pathname to match against:
1447-
// - For absolute routes: use the original full pathname
1448-
// - For relative routes with a parent: use the computed relative pathname
1449-
// - For relative routes at root level (no parent): use the original pathname
1450-
const pathnameToMatch = isAbsoluteRoute ? originalPathname : relativePathnameToMatch;
1451-
1452-
// Determine the path portion to match
1453-
let pathForMatch: string;
1454-
if (isAbsoluteRoute) {
1455-
pathForMatch = derivePathnameToMatch(pathnameToMatch, childPath);
1456-
} else if (!parentPath && childPath) {
1457-
// Root-level relative route: use the full pathname and let matchPath
1458-
// handle the normalization (it adds '/' to both path and pathname)
1459-
pathForMatch = originalPathname;
1460-
} else if (childPath && childPath.includes('*')) {
1461-
// Relative wildcard route with parent path: the relative pathname was already
1462-
// computed above using the parent path, so it's already correct
1463-
pathForMatch = pathnameToMatch;
1464-
} else {
1465-
pathForMatch = pathnameToMatch;
1466-
}
1467-
1468-
const match = matchPath({
1469-
pathname: pathForMatch,
1470-
componentProps: child.props,
1471-
});
1451+
// Delegate route matching to RR6's matchRoutes(), which handles specificity ranking internally.
1452+
const basename = parentPath ? stripTrailingSlash(parentPath.replace('/*', '')) : undefined;
1453+
const routeObjects = routeElementsToRouteObjects(routeChildren, basename);
1454+
const matches = matchRoutes(routeObjects, { pathname: routeInfo.pathname }, basename);
14721455

1473-
if (match) {
1474-
matchedNode = child;
1475-
break;
1476-
}
1477-
}
1478-
1479-
if (matchedNode) {
1480-
return matchedNode;
1456+
if (matches && matches.length > 0) {
1457+
const bestMatch = matches[matches.length - 1];
1458+
matchedNode = (bestMatch.route as any).handle?._element ?? undefined;
14811459
}
14821460

14831461
// Fallback: try pathless routes, but only if pathname is within scope.
1484-
let pathnameInScope = true;
1462+
if (!matchedNode) {
1463+
let pathnameInScope = true;
14851464

1486-
if (parentPath) {
1487-
pathnameInScope = isPathnameInScope(routeInfo.pathname, parentPath);
1488-
} else {
1489-
const absolutePathRoutes = routeChildren.filter((r) => r.props.path && r.props.path.startsWith('/'));
1490-
if (absolutePathRoutes.length > 0) {
1491-
const absolutePaths = absolutePathRoutes.map((r) => r.props.path as string);
1492-
const commonPrefix = computeCommonPrefix(absolutePaths);
1493-
if (commonPrefix && commonPrefix !== '/') {
1494-
pathnameInScope = routeInfo.pathname.startsWith(commonPrefix);
1465+
if (parentPath) {
1466+
pathnameInScope = isPathnameInScope(routeInfo.pathname, parentPath);
1467+
} else {
1468+
const absolutePathRoutes = routeChildren.filter((r) => r.props.path && r.props.path.startsWith('/'));
1469+
if (absolutePathRoutes.length > 0) {
1470+
const absolutePaths = absolutePathRoutes.map((r) => r.props.path as string);
1471+
const commonPrefix = computeCommonPrefix(absolutePaths);
1472+
if (commonPrefix && commonPrefix !== '/') {
1473+
pathnameInScope = routeInfo.pathname.startsWith(commonPrefix);
1474+
}
14951475
}
14961476
}
1497-
}
14981477

1499-
if (pathnameInScope) {
1500-
for (const child of routeChildren) {
1501-
if (!child.props.path) {
1502-
fallbackNode = child;
1503-
break;
1478+
if (pathnameInScope) {
1479+
for (const child of routeChildren) {
1480+
if (!child.props.path) {
1481+
fallbackNode = child;
1482+
break;
1483+
}
15041484
}
15051485
}
15061486
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
const port = 3000;
2+
3+
/**
4+
* Verifies that the matchRoutes()-based findRouteByRouteInfo works correctly
5+
* for various route patterns: absolute, relative, nested, tabs, index.
6+
*/
7+
describe('matchRoutes integration', () => {
8+
it('should match absolute routes at root outlet', () => {
9+
cy.visit(`http://localhost:${port}/`);
10+
cy.ionPageVisible('home');
11+
});
12+
13+
it('should match relative routes in nested outlet', () => {
14+
cy.visit(`http://localhost:${port}/routing/tabs/home`);
15+
cy.ionPageVisible('home-page');
16+
});
17+
18+
it('should match routes after tab switch', () => {
19+
cy.visit(`http://localhost:${port}/routing/tabs/home`);
20+
cy.ionPageVisible('home-page');
21+
22+
cy.ionTabClick('Settings');
23+
cy.ionPageVisible('settings-page');
24+
});
25+
26+
it('should match routes after switching tabs back', () => {
27+
cy.visit(`http://localhost:${port}/routing/tabs/home`);
28+
cy.ionPageVisible('home-page');
29+
30+
cy.ionTabClick('Settings');
31+
cy.ionPageVisible('settings-page');
32+
33+
cy.ionTabClick('Home');
34+
cy.ionPageVisible('home-page');
35+
});
36+
});

0 commit comments

Comments
 (0)