Skip to content

Commit f491e92

Browse files
committed
fix(react-router): improve relative path matching edge cases
1 parent 595392c commit f491e92

File tree

6 files changed

+65
-42
lines changed

6 files changed

+65
-42
lines changed

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -469,7 +469,9 @@ export class ReactRouterViewStack extends ViewStacks {
469469
let parentPath: string | undefined = undefined;
470470
try {
471471
// Only attempt parent path computation for non-root outlets
472-
if (outletId !== 'routerOutlet') {
472+
// Root outlets have IDs like 'routerOutlet' or 'routerOutlet-2'
473+
const isRootOutlet = outletId.startsWith('routerOutlet');
474+
if (!isRootOutlet) {
473475
const routeChildren = extractRouteChildren(ionRouterOutlet.props.children);
474476
const { hasRelativeRoutes, hasIndexRoute, hasWildcardRoute } = analyzeRouteChildren(routeChildren);
475477

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

Lines changed: 52 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -109,28 +109,36 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
109109
return undefined;
110110
}
111111

112-
// If this is a nested outlet (has an explicit ID like "main"),
113-
// we need to figure out what part of the path was already matched
114-
if (this.id !== 'routerOutlet' && this.ionRouterOutlet) {
112+
// Check if this outlet has route children to analyze
113+
if (this.ionRouterOutlet) {
115114
const routeChildren = extractRouteChildren(this.ionRouterOutlet.props.children);
116115
const { hasRelativeRoutes, hasIndexRoute, hasWildcardRoute } = analyzeRouteChildren(routeChildren);
117116

118-
const result = computeParentPath({
119-
currentPathname,
120-
outletMountPath: this.outletMountPath,
121-
routeChildren,
122-
hasRelativeRoutes,
123-
hasIndexRoute,
124-
hasWildcardRoute,
125-
});
117+
// Root outlets have IDs like 'routerOutlet' or 'routerOutlet-2'
118+
// But even outlets with auto-generated IDs may need parent path computation
119+
// if they have relative routes (indicating they're nested outlets)
120+
const isRootOutlet = this.id.startsWith('routerOutlet');
121+
const needsParentPath = !isRootOutlet || hasRelativeRoutes || hasIndexRoute;
122+
123+
if (needsParentPath) {
124+
const result = computeParentPath({
125+
currentPathname,
126+
outletMountPath: this.outletMountPath,
127+
routeChildren,
128+
hasRelativeRoutes,
129+
hasIndexRoute,
130+
hasWildcardRoute,
131+
});
132+
133+
// Update the outlet mount path if it was set
134+
if (result.outletMountPath && !this.outletMountPath) {
135+
this.outletMountPath = result.outletMountPath;
136+
}
126137

127-
// Update the outlet mount path if it was set
128-
if (result.outletMountPath && !this.outletMountPath) {
129-
this.outletMountPath = result.outletMountPath;
138+
return result.parentPath;
130139
}
131-
132-
return result.parentPath;
133140
}
141+
134142
return this.outletMountPath;
135143
}
136144

@@ -246,7 +254,9 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
246254
parentPath: string | undefined,
247255
leavingViewItem: ViewItem | undefined
248256
): boolean {
249-
if (this.id === 'routerOutlet' || parentPath !== undefined || !this.ionRouterOutlet) {
257+
// Root outlets have IDs like 'routerOutlet' or 'routerOutlet-2'
258+
const isRootOutlet = this.id.startsWith('routerOutlet');
259+
if (isRootOutlet || parentPath !== undefined || !this.ionRouterOutlet) {
250260
return false;
251261
}
252262

@@ -283,7 +293,9 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
283293
enteringViewItem: ViewItem | undefined,
284294
leavingViewItem: ViewItem | undefined
285295
): boolean {
286-
if (this.id === 'routerOutlet' || enteringRoute || enteringViewItem) {
296+
// Root outlets have IDs like 'routerOutlet' or 'routerOutlet-2'
297+
const isRootOutlet = this.id.startsWith('routerOutlet');
298+
if (isRootOutlet || enteringRoute || enteringViewItem) {
287299
return false;
288300
}
289301

@@ -943,7 +955,8 @@ function findRouteByRouteInfo(node: React.ReactNode, routeInfo: RouteInfo, paren
943955
// SIMPLIFIED: Trust React Router 6's matching more, compute relative path when parent is known
944956
if ((hasRelativeRoutes || hasIndexRoute) && parentPath) {
945957
const parentPrefix = parentPath.replace('/*', '');
946-
const normalizedParent = stripTrailingSlash(parentPrefix);
958+
// Normalize both paths to start with '/' for consistent comparison
959+
const normalizedParent = stripTrailingSlash(parentPrefix.startsWith('/') ? parentPrefix : `/${parentPrefix}`);
947960
const normalizedPathname = stripTrailingSlash(routeInfo.pathname);
948961

949962
// Only compute relative path if pathname is within parent scope
@@ -959,12 +972,29 @@ function findRouteByRouteInfo(node: React.ReactNode, routeInfo: RouteInfo, paren
959972
for (const child of sortedRoutes) {
960973
const childPath = child.props.path as string | undefined;
961974
const isAbsoluteRoute = childPath && childPath.startsWith('/');
975+
976+
// Determine which pathname to match against:
977+
// - For absolute routes: use the original full pathname
978+
// - For relative routes with a parent: use the computed relative pathname
979+
// - For relative routes at root level (no parent): use the original pathname
980+
// (matchPath will handle the relative-to-absolute normalization)
962981
const pathnameToMatch = isAbsoluteRoute ? originalPathname : relativePathnameToMatch;
963982

964-
// Only use derivePathnameToMatch for absolute routes or wildcard patterns;
965-
// non-wildcard relative routes match directly against the computed relative pathname.
983+
// Determine the path portion to match:
984+
// - For absolute routes: use derivePathnameToMatch
985+
// - For relative routes at root level (no parent): use original pathname
986+
// directly since matchPath normalizes both path and pathname
987+
// - For relative routes with parent: use derivePathnameToMatch for wildcards,
988+
// or the computed relative pathname for non-wildcards
966989
let pathForMatch: string;
967-
if (isAbsoluteRoute || (childPath && childPath.includes('*'))) {
990+
if (isAbsoluteRoute) {
991+
pathForMatch = derivePathnameToMatch(pathnameToMatch, childPath);
992+
} else if (!parentPath && childPath) {
993+
// Root-level relative route: use the full pathname and let matchPath
994+
// handle the normalization (it adds '/' to both path and pathname)
995+
pathForMatch = originalPathname;
996+
} else if (childPath && childPath.includes('*')) {
997+
// Relative wildcard route with parent path: use derivePathnameToMatch
968998
pathForMatch = derivePathnameToMatch(pathnameToMatch, childPath);
969999
} else {
9701000
pathForMatch = pathnameToMatch;

packages/react-router/src/ReactRouter/utils/pathMatching.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,10 @@ export const derivePathnameToMatch = (fullPathname: string, routePath?: string):
120120

121121
const trimmedPath = fullPathname.startsWith('/') ? fullPathname.slice(1) : fullPathname;
122122
if (!trimmedPath) {
123-
return '';
123+
// For root-level relative routes (pathname is "/" and routePath is relative),
124+
// return the full pathname so matchPath can normalize both.
125+
// This allows routes like <Route path="foo/*" .../> at root level to work correctly.
126+
return fullPathname;
124127
}
125128

126129
const fullSegments = trimmedPath.split('/').filter(Boolean);

packages/react-router/test/base/src/App.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,8 @@ const App: React.FC = () => {
7171
<Route path="/overlays" element={<Overlays />} />
7272
<Route path="/params/:id" element={<Params />} />
7373
<Route path="/nested-params/*" element={<NestedParams />} />
74-
<Route path="/relative-paths/*" element={<RelativePaths />} />
74+
{/* Test root-level relative path - no leading slash */}
75+
<Route path="relative-paths/*" element={<RelativePaths />} />
7576
</IonRouterOutlet>
7677
</IonReactRouter>
7778
</IonApp>

packages/react-router/test/base/src/pages/relative-paths/RelativePaths.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,8 +118,7 @@ const RelativePaths: React.FC = () => {
118118
{/* Route with absolute path (has leading slash) - this should work */}
119119
<Route path="/relative-paths/page-a" element={<PageA />} />
120120

121-
{/* Routes with relative paths (no leading slash) - these should also work
122-
but currently don't match in IonRouterOutlet */}
121+
{/* Routes with relative paths (no leading slash) */}
123122
<Route path="page-b" element={<PageB />} />
124123
<Route path="page-c" element={<PageC />} />
125124

packages/react-router/test/base/tests/e2e/specs/relative-paths.cy.js

Lines changed: 3 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,9 @@
11
const port = 3000;
22

33
/**
4-
* Tests for relative path handling in IonRouterOutlet
5-
*
6-
* Issue: IonRouterOutlet doesn't handle relative paths (without leading slash)
7-
* the same way React Router 6's Routes component does.
8-
*
9-
* In React Router 6, both of these should work identically:
10-
* - <Route path="/help" element={<Help />} />
11-
* - <Route path="help" element={<Help />} />
12-
*
13-
* However, IonRouterOutlet only matches the first one (with leading slash).
4+
* Tests for relative path handling in IonRouterOutlet.
5+
* Verifies that routes with relative paths (no leading slash) work
6+
* the same as absolute paths, matching React Router 6 behavior.
147
*/
158
describe('Relative Paths Tests', () => {
169
it('should navigate to the relative paths home page', () => {
@@ -27,8 +20,6 @@ describe('Relative Paths Tests', () => {
2720
});
2821

2922
it('should navigate to Page B (defined with relative path - no leading slash)', () => {
30-
// This test verifies the bug - Page B route is defined as path="page-b" (no leading slash)
31-
// It should work the same as path="/relative-paths/page-b" but currently doesn't
3223
cy.visit(`http://localhost:${port}/relative-paths`);
3324
cy.ionPageVisible('relative-paths-home');
3425
cy.ionNav('ion-item', 'Go to Page B');
@@ -37,7 +28,6 @@ describe('Relative Paths Tests', () => {
3728
});
3829

3930
it('should navigate to Page C (defined with relative path - no leading slash)', () => {
40-
// Another test for relative path handling
4131
cy.visit(`http://localhost:${port}/relative-paths`);
4232
cy.ionPageVisible('relative-paths-home');
4333
cy.ionNav('ion-item', 'Go to Page C');
@@ -46,14 +36,12 @@ describe('Relative Paths Tests', () => {
4636
});
4737

4838
it('should navigate directly to Page B via URL', () => {
49-
// Direct navigation to a page with a relative path route
5039
cy.visit(`http://localhost:${port}/relative-paths/page-b`);
5140
cy.ionPageVisible('relative-paths-page-b');
5241
cy.get('[data-testid="page-b-content"]').should('contain', 'Page B');
5342
});
5443

5544
it('should navigate directly to Page C via URL', () => {
56-
// Direct navigation to a page with a relative path route
5745
cy.visit(`http://localhost:${port}/relative-paths/page-c`);
5846
cy.ionPageVisible('relative-paths-page-c');
5947
cy.get('[data-testid="page-c-content"]').should('contain', 'Page C');

0 commit comments

Comments
 (0)