Skip to content

Commit f31ff3e

Browse files
committed
fix(react-router): support React Router 6 style relative paths in IonRouterOutlet
1 parent b2a7105 commit f31ff3e

File tree

7 files changed

+251
-15
lines changed

7 files changed

+251
-15
lines changed

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -713,7 +713,17 @@ export class ReactRouterViewStack extends ViewStacks {
713713
return false;
714714
}
715715

716+
// For empty path routes, only match if we're at the same level as when the view was created.
717+
// This prevents an empty path view item from being reused for different routes.
716718
if (isDefaultRoute) {
719+
const previousPathnameBase = v.routeData?.match?.pathnameBase || '';
720+
const normalizedBase = normalizePathnameForComparison(previousPathnameBase);
721+
const normalizedPathname = normalizePathnameForComparison(pathname);
722+
723+
if (normalizedPathname !== normalizedBase) {
724+
return false;
725+
}
726+
717727
match = {
718728
params: {},
719729
pathname,

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

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -933,7 +933,8 @@ function findRouteByRouteInfo(node: React.ReactNode, routeInfo: RouteInfo, paren
933933

934934
// For nested routes in React Router 6, we need to extract the relative path
935935
// that this outlet should be responsible for matching
936-
let pathnameToMatch = routeInfo.pathname;
936+
const originalPathname = routeInfo.pathname;
937+
let relativePathnameToMatch = routeInfo.pathname;
937938

938939
// Check if we have relative routes (routes that don't start with '/')
939940
const hasRelativeRoutes = sortedRoutes.some((r) => r.props.path && !r.props.path.startsWith('/'));
@@ -950,14 +951,27 @@ function findRouteByRouteInfo(node: React.ReactNode, routeInfo: RouteInfo, paren
950951
const pathSegments = routeInfo.pathname.split('/').filter(Boolean);
951952
const parentSegments = normalizedParent.split('/').filter(Boolean);
952953
const relativeSegments = pathSegments.slice(parentSegments.length);
953-
pathnameToMatch = relativeSegments.join('/'); // Empty string is valid for index routes
954+
relativePathnameToMatch = relativeSegments.join('/'); // Empty string is valid for index routes
954955
}
955956
}
956957

957958
// Find the first matching route
958959
for (const child of sortedRoutes) {
960+
const childPath = child.props.path as string | undefined;
961+
const isAbsoluteRoute = childPath && childPath.startsWith('/');
962+
const pathnameToMatch = isAbsoluteRoute ? originalPathname : relativePathnameToMatch;
963+
964+
// Only use derivePathnameToMatch for absolute routes or wildcard patterns;
965+
// non-wildcard relative routes match directly against the computed relative pathname.
966+
let pathForMatch: string;
967+
if (isAbsoluteRoute || (childPath && childPath.includes('*'))) {
968+
pathForMatch = derivePathnameToMatch(pathnameToMatch, childPath);
969+
} else {
970+
pathForMatch = pathnameToMatch;
971+
}
972+
959973
const match = matchPath({
960-
pathname: pathnameToMatch,
974+
pathname: pathForMatch,
961975
componentProps: child.props,
962976
});
963977

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

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,8 @@ interface MatchPathOptions {
2727
export const matchPath = ({ pathname, componentProps }: MatchPathOptions): PathMatch<string> | null => {
2828
const { path, index, ...restProps } = componentProps;
2929

30-
// Handle index routes
30+
// Handle index routes - they match when pathname is empty or just "/"
3131
if (index && !path) {
32-
// Index routes match when there's no additional path after the parent route
33-
// For example, in a nested outlet at /routing/*, the index route matches
34-
// when the relative path is empty (i.e., we're exactly at /routing)
35-
36-
// If pathname is empty or just "/", it should match the index route
3732
if (pathname === '' || pathname === '/') {
3833
return {
3934
params: {},
@@ -46,17 +41,27 @@ export const matchPath = ({ pathname, componentProps }: MatchPathOptions): PathM
4641
},
4742
};
4843
}
49-
50-
// Otherwise, index routes don't match when there's additional path
5144
return null;
5245
}
5346

54-
if (!path) {
47+
// Handle empty path routes - they match when pathname is also empty or just "/"
48+
if (path === '' || path === undefined) {
49+
if (pathname === '' || pathname === '/') {
50+
return {
51+
params: {},
52+
pathname: pathname,
53+
pathnameBase: pathname || '/',
54+
pattern: {
55+
path: '',
56+
caseSensitive: restProps.caseSensitive ?? false,
57+
end: restProps.end ?? true,
58+
},
59+
};
60+
}
5561
return null;
5662
}
5763

58-
// For relative paths in nested routes (those that don't start with '/'),
59-
// use React Router's matcher against a normalized path.
64+
// For relative paths (don't start with '/'), normalize both path and pathname for matching
6065
if (!path.startsWith('/')) {
6166
const matchOptions: Parameters<typeof reactRouterMatchPath>[0] = {
6267
path: `/${path}`,
@@ -83,7 +88,6 @@ export const matchPath = ({ pathname, componentProps }: MatchPathOptions): PathM
8388
};
8489
}
8590

86-
// No match found
8791
return null;
8892
}
8993

@@ -109,6 +113,7 @@ export const matchPath = ({ pathname, componentProps }: MatchPathOptions): PathM
109113
* strip off the already-matched parent segments so React Router receives the remainder.
110114
*/
111115
export const derivePathnameToMatch = (fullPathname: string, routePath?: string): string => {
116+
// For absolute or empty routes, use the full pathname as-is
112117
if (!routePath || routePath === '' || routePath.startsWith('/')) {
113118
return fullPathname;
114119
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import MultipleTabs from './pages/muiltiple-tabs/MultipleTabs';
3131
import NestedOutlet from './pages/nested-outlet/NestedOutlet';
3232
import NestedOutlet2 from './pages/nested-outlet/NestedOutlet2';
3333
import NestedParams from './pages/nested-params/NestedParams';
34+
import RelativePaths from './pages/relative-paths/RelativePaths';
3435
import { OutletRef } from './pages/outlet-ref/OutletRef';
3536
import Params from './pages/params/Params';
3637
import Refs from './pages/refs/Refs';
@@ -70,6 +71,7 @@ const App: React.FC = () => {
7071
<Route path="/overlays" element={<Overlays />} />
7172
<Route path="/params/:id" element={<Params />} />
7273
<Route path="/nested-params/*" element={<NestedParams />} />
74+
<Route path="/relative-paths/*" element={<RelativePaths />} />
7375
</IonRouterOutlet>
7476
</IonReactRouter>
7577
</IonApp>

packages/react-router/test/base/src/pages/Main.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,9 @@ const Main: React.FC = () => {
7474
<IonItem routerLink="/nested-params">
7575
<IonLabel>Nested Params</IonLabel>
7676
</IonItem>
77+
<IonItem routerLink="/relative-paths">
78+
<IonLabel>Relative Paths</IonLabel>
79+
</IonItem>
7780
</IonList>
7881
</IonContent>
7982
</IonPage>
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import {
2+
IonContent,
3+
IonHeader,
4+
IonPage,
5+
IonTitle,
6+
IonToolbar,
7+
IonRouterOutlet,
8+
IonList,
9+
IonItem,
10+
IonLabel,
11+
IonBackButton,
12+
IonButtons,
13+
} from '@ionic/react';
14+
import React from 'react';
15+
import { Route, Outlet } from 'react-router-dom';
16+
17+
/**
18+
* This test page verifies that IonRouterOutlet correctly handles
19+
* relative paths (paths without a leading slash) the same way
20+
* React Router 6's Routes component does.
21+
*
22+
* Issue: https://github.com/ionic-team/ionic-framework/issues/24177#issuecomment-3624311206
23+
* - Routes with path="help" should work the same as path="/help"
24+
* - IonRouterOutlet should match relative paths correctly
25+
*/
26+
27+
const RelativePathsHome: React.FC = () => {
28+
return (
29+
<IonPage data-pageid="relative-paths-home">
30+
<IonHeader>
31+
<IonToolbar>
32+
<IonButtons slot="start">
33+
<IonBackButton defaultHref="/" />
34+
</IonButtons>
35+
<IonTitle>Relative Paths Test</IonTitle>
36+
</IonToolbar>
37+
</IonHeader>
38+
<IonContent>
39+
<IonList>
40+
<IonItem routerLink="/relative-paths/page-a">
41+
<IonLabel>Go to Page A (absolute path route)</IonLabel>
42+
</IonItem>
43+
<IonItem routerLink="/relative-paths/page-b">
44+
<IonLabel>Go to Page B (relative path route)</IonLabel>
45+
</IonItem>
46+
<IonItem routerLink="/relative-paths/page-c">
47+
<IonLabel>Go to Page C (relative path route)</IonLabel>
48+
</IonItem>
49+
</IonList>
50+
</IonContent>
51+
</IonPage>
52+
);
53+
};
54+
55+
const PageA: React.FC = () => {
56+
return (
57+
<IonPage data-pageid="relative-paths-page-a">
58+
<IonHeader>
59+
<IonToolbar>
60+
<IonButtons slot="start">
61+
<IonBackButton defaultHref="/relative-paths" />
62+
</IonButtons>
63+
<IonTitle>Page A</IonTitle>
64+
</IonToolbar>
65+
</IonHeader>
66+
<IonContent>
67+
<div data-testid="page-a-content">
68+
This is Page A - route defined with absolute path
69+
</div>
70+
</IonContent>
71+
</IonPage>
72+
);
73+
};
74+
75+
const PageB: React.FC = () => {
76+
return (
77+
<IonPage data-pageid="relative-paths-page-b">
78+
<IonHeader>
79+
<IonToolbar>
80+
<IonButtons slot="start">
81+
<IonBackButton defaultHref="/relative-paths" />
82+
</IonButtons>
83+
<IonTitle>Page B</IonTitle>
84+
</IonToolbar>
85+
</IonHeader>
86+
<IonContent>
87+
<div data-testid="page-b-content">
88+
This is Page B - route defined with relative path (no leading slash)
89+
</div>
90+
</IonContent>
91+
</IonPage>
92+
);
93+
};
94+
95+
const PageC: React.FC = () => {
96+
return (
97+
<IonPage data-pageid="relative-paths-page-c">
98+
<IonHeader>
99+
<IonToolbar>
100+
<IonButtons slot="start">
101+
<IonBackButton defaultHref="/relative-paths" />
102+
</IonButtons>
103+
<IonTitle>Page C</IonTitle>
104+
</IonToolbar>
105+
</IonHeader>
106+
<IonContent>
107+
<div data-testid="page-c-content">
108+
This is Page C - another route defined with relative path
109+
</div>
110+
</IonContent>
111+
</IonPage>
112+
);
113+
};
114+
115+
const RelativePaths: React.FC = () => {
116+
return (
117+
<IonRouterOutlet>
118+
{/* Route with absolute path (has leading slash) - this should work */}
119+
<Route path="/relative-paths/page-a" element={<PageA />} />
120+
121+
{/* Routes with relative paths (no leading slash) - these should also work
122+
but currently don't match in IonRouterOutlet */}
123+
<Route path="page-b" element={<PageB />} />
124+
<Route path="page-c" element={<PageC />} />
125+
126+
{/* Home route - using relative path */}
127+
<Route path="" element={<RelativePathsHome />} />
128+
</IonRouterOutlet>
129+
);
130+
};
131+
132+
export default RelativePaths;
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
const port = 3000;
2+
3+
/**
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).
14+
*/
15+
describe('Relative Paths Tests', () => {
16+
it('should navigate to the relative paths home page', () => {
17+
cy.visit(`http://localhost:${port}/relative-paths`);
18+
cy.ionPageVisible('relative-paths-home');
19+
});
20+
21+
it('should navigate to Page A (defined with absolute path)', () => {
22+
cy.visit(`http://localhost:${port}/relative-paths`);
23+
cy.ionPageVisible('relative-paths-home');
24+
cy.ionNav('ion-item', 'Go to Page A');
25+
cy.ionPageVisible('relative-paths-page-a');
26+
cy.get('[data-testid="page-a-content"]').should('contain', 'Page A');
27+
});
28+
29+
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
32+
cy.visit(`http://localhost:${port}/relative-paths`);
33+
cy.ionPageVisible('relative-paths-home');
34+
cy.ionNav('ion-item', 'Go to Page B');
35+
cy.ionPageVisible('relative-paths-page-b');
36+
cy.get('[data-testid="page-b-content"]').should('contain', 'Page B');
37+
});
38+
39+
it('should navigate to Page C (defined with relative path - no leading slash)', () => {
40+
// Another test for relative path handling
41+
cy.visit(`http://localhost:${port}/relative-paths`);
42+
cy.ionPageVisible('relative-paths-home');
43+
cy.ionNav('ion-item', 'Go to Page C');
44+
cy.ionPageVisible('relative-paths-page-c');
45+
cy.get('[data-testid="page-c-content"]').should('contain', 'Page C');
46+
});
47+
48+
it('should navigate directly to Page B via URL', () => {
49+
// Direct navigation to a page with a relative path route
50+
cy.visit(`http://localhost:${port}/relative-paths/page-b`);
51+
cy.ionPageVisible('relative-paths-page-b');
52+
cy.get('[data-testid="page-b-content"]').should('contain', 'Page B');
53+
});
54+
55+
it('should navigate directly to Page C via URL', () => {
56+
// Direct navigation to a page with a relative path route
57+
cy.visit(`http://localhost:${port}/relative-paths/page-c`);
58+
cy.ionPageVisible('relative-paths-page-c');
59+
cy.get('[data-testid="page-c-content"]').should('contain', 'Page C');
60+
});
61+
62+
it('should navigate to Page B and back', () => {
63+
cy.visit(`http://localhost:${port}/relative-paths`);
64+
cy.ionPageVisible('relative-paths-home');
65+
cy.ionNav('ion-item', 'Go to Page B');
66+
cy.ionPageVisible('relative-paths-page-b');
67+
cy.ionBackClick('relative-paths-page-b');
68+
cy.ionPageVisible('relative-paths-home');
69+
});
70+
});

0 commit comments

Comments
 (0)