Skip to content

Commit 60cedf0

Browse files
authored
fix(react-router): resolve relative route matching inside root-level splat routes (#30861)
Issue number: resolves internal --------- <!-- Please do not submit updates to dependencies unless it fixes an issue. --> <!-- Please try to limit your pull request to one type (bugfix, feature, etc). Submit multiple pull requests if needed. --> ## What is the current behavior? Routes with relative paths (e.g., `path="tab1/*"`) inside root-level splat routes (`path="*"`) do not match correctly. The parent path computation returns an incorrect value, causing routes to 404. Developers must use absolute paths (e.g., `path="/tab1/*"`) as a workaround. ## What is the new behavior? Routes with relative paths now correctly match when the parent is a splat-only route. The `computeParentPath` function checks at root level for embedded wildcard routes (like `tab1/*`) as a fallback when no match is found at deeper levels, allowing relative paths to work correctly inside splat route parents. ## Does this introduce a breaking change? - [ ] Yes - [X] No <!-- If this introduces a breaking change: 1. Describe the impact and migration path for existing applications below. 2. Update the BREAKING.md file with the breaking change. 3. Add "BREAKING CHANGE: [...]" to the commit description when merging. See https://github.com/ionic-team/ionic-framework/blob/main/docs/CONTRIBUTING.md#footer for more information. --> ## Other information Current dev build: ``` 8.7.13-dev.11765477700.112ae0a3 ```
1 parent 4c4c069 commit 60cedf0

File tree

8 files changed

+655
-38
lines changed

8 files changed

+655
-38
lines changed

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

Lines changed: 29 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -384,30 +384,38 @@ export class ReactRouterViewStack extends ViewStacks {
384384

385385
// For relative route paths, we need to compute an absolute pathnameBase
386386
// by combining the parent's pathnameBase with the matched portion
387-
let absolutePathnameBase = routeMatch?.pathnameBase || routeInfo.pathname;
388387
const routePath = routeElement.props.path;
389388
const isRelativePath = routePath && !routePath.startsWith('/');
390389
const isIndexRoute = !!routeElement.props.index;
391-
392-
if (isRelativePath || isIndexRoute) {
393-
// Get the parent's pathnameBase to build the absolute path
394-
const parentPathnameBase =
395-
parentMatches.length > 0 ? parentMatches[parentMatches.length - 1].pathnameBase : '/';
396-
397-
// For relative paths, the matchPath returns a relative pathnameBase
398-
// We need to make it absolute by prepending the parent's base
399-
if (routeMatch?.pathnameBase && isRelativePath) {
400-
// Strip leading slash if present in the relative match
401-
const relativeBase = routeMatch.pathnameBase.startsWith('/')
402-
? routeMatch.pathnameBase.slice(1)
403-
: routeMatch.pathnameBase;
404-
405-
absolutePathnameBase =
406-
parentPathnameBase === '/' ? `/${relativeBase}` : `${parentPathnameBase}/${relativeBase}`;
407-
} else if (isIndexRoute) {
408-
// Index routes should use the parent's base as their base
409-
absolutePathnameBase = parentPathnameBase;
410-
}
390+
const isSplatOnlyRoute = routePath === '*' || routePath === '/*';
391+
392+
// Get parent's pathnameBase for relative path resolution
393+
const parentPathnameBase =
394+
parentMatches.length > 0 ? parentMatches[parentMatches.length - 1].pathnameBase : '/';
395+
396+
// Start with the match's pathnameBase, falling back to routeInfo.pathname
397+
// BUT: splat-only routes should use parent's base (v7_relativeSplatPath behavior)
398+
let absolutePathnameBase: string;
399+
400+
if (isSplatOnlyRoute) {
401+
// Splat routes should NOT contribute their matched portion to pathnameBase
402+
// This aligns with React Router v7's v7_relativeSplatPath behavior
403+
// Without this, relative links inside splat routes get double path segments
404+
absolutePathnameBase = parentPathnameBase;
405+
} else if (isRelativePath && routeMatch?.pathnameBase) {
406+
// For relative paths with a pathnameBase, combine with parent
407+
const relativeBase = routeMatch.pathnameBase.startsWith('/')
408+
? routeMatch.pathnameBase.slice(1)
409+
: routeMatch.pathnameBase;
410+
411+
absolutePathnameBase =
412+
parentPathnameBase === '/' ? `/${relativeBase}` : `${parentPathnameBase}/${relativeBase}`;
413+
} else if (isIndexRoute) {
414+
// Index routes should use the parent's base as their base
415+
absolutePathnameBase = parentPathnameBase;
416+
} else {
417+
// Default: use the match's pathnameBase or the current pathname
418+
absolutePathnameBase = routeMatch?.pathnameBase || routeInfo.pathname;
411419
}
412420

413421
const contextMatches = [

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

Lines changed: 45 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -44,26 +44,39 @@ export const computeCommonPrefix = (paths: string[]): string => {
4444
};
4545

4646
/**
47-
* Checks if a route is a specific match (not wildcard or index).
48-
*
49-
* @param route The route element to check.
50-
* @param remainingPath The remaining path to match against.
51-
* @returns True if the route specifically matches the remaining path.
47+
* Checks if a route path is a "splat-only" route (just `*` or `/*`).
5248
*/
53-
export const isSpecificRouteMatch = (route: React.ReactElement, remainingPath: string): boolean => {
54-
const routePath = route.props.path;
55-
const isWildcardOnly = routePath === '*' || routePath === '/*';
56-
const isIndex = route.props.index;
49+
const isSplatOnlyRoute = (routePath: string | undefined): boolean => {
50+
return routePath === '*' || routePath === '/*';
51+
};
5752

58-
// Skip wildcards and index routes
59-
if (isIndex || isWildcardOnly) {
53+
/**
54+
* Checks if a route has an embedded wildcard (e.g., "tab1/*" but not "*" or "/*").
55+
*/
56+
const hasEmbeddedWildcard = (routePath: string | undefined): boolean => {
57+
return !!routePath && routePath.includes('*') && !isSplatOnlyRoute(routePath);
58+
};
59+
60+
/**
61+
* Checks if a route with an embedded wildcard matches a pathname.
62+
*/
63+
const matchesEmbeddedWildcardRoute = (route: React.ReactElement, pathname: string): boolean => {
64+
const routePath = route.props.path as string | undefined;
65+
if (!hasEmbeddedWildcard(routePath)) {
6066
return false;
6167
}
68+
return !!matchPath({ pathname, componentProps: route.props });
69+
};
6270

63-
return !!matchPath({
64-
pathname: remainingPath,
65-
componentProps: route.props,
66-
});
71+
/**
72+
* Checks if a route is a specific match (not wildcard-only or index).
73+
*/
74+
export const isSpecificRouteMatch = (route: React.ReactElement, remainingPath: string): boolean => {
75+
const routePath = route.props.path;
76+
if (route.props.index || isSplatOnlyRoute(routePath)) {
77+
return false;
78+
}
79+
return !!matchPath({ pathname: remainingPath, componentProps: route.props });
6780
};
6881

6982
/**
@@ -142,12 +155,16 @@ export const computeParentPath = (options: ComputeParentPathOptions): ParentPath
142155
let firstWildcardMatch: string | undefined = undefined;
143156
let indexMatchAtMount: string | undefined = undefined;
144157

158+
// Start at i = 1 (normal case: strip at least one segment for parent path)
145159
for (let i = 1; i <= segments.length; i++) {
146160
const parentPath = '/' + segments.slice(0, i).join('/');
147161
const remainingPath = segments.slice(i).join('/');
148162

149-
// Check for specific (non-wildcard, non-index) route matches
150-
const hasSpecificMatch = routeChildren.some((route) => isSpecificRouteMatch(route, remainingPath));
163+
// Check for specific route matches (non-wildcard-only, non-index)
164+
// Also check routes with embedded wildcards (e.g., "tab1/*")
165+
const hasSpecificMatch = routeChildren.some(
166+
(route) => isSpecificRouteMatch(route, remainingPath) || matchesEmbeddedWildcardRoute(route, remainingPath)
167+
);
151168
if (hasSpecificMatch && !firstSpecificMatch) {
152169
firstSpecificMatch = parentPath;
153170
// Found a specific match - this is our answer for non-index routes
@@ -198,6 +215,17 @@ export const computeParentPath = (options: ComputeParentPathOptions): ParentPath
198215
}
199216
}
200217

218+
// Fallback: check at root level (i = 0) for embedded wildcard routes.
219+
// This handles outlets inside root-level splat routes where routes like
220+
// "tab1/*" need to match the full pathname.
221+
if (!firstSpecificMatch) {
222+
const fullRemainingPath = segments.join('/');
223+
const hasRootLevelMatch = routeChildren.some((route) => matchesEmbeddedWildcardRoute(route, fullRemainingPath));
224+
if (hasRootLevelMatch) {
225+
firstSpecificMatch = '/';
226+
}
227+
}
228+
201229
// Determine the best parent path:
202230
// 1. Specific match (routes like tabs/*, favorites) - highest priority
203231
// 2. Wildcard match (route path="*") - catches unmatched segments

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ import Tabs from './pages/tabs/Tabs';
4343
import TabsSecondary from './pages/tabs/TabsSecondary';
4444
import TabHistoryIsolation from './pages/tab-history-isolation/TabHistoryIsolation';
4545
import Overlays from './pages/overlays/Overlays';
46+
import NestedTabsRelativeLinks from './pages/nested-tabs-relative-links/NestedTabsRelativeLinks';
47+
import RootSplatTabs from './pages/root-splat-tabs/RootSplatTabs';
4648

4749
setupIonicReact();
4850

@@ -75,6 +77,8 @@ const App: React.FC = () => {
7577
<Route path="/nested-params/*" element={<NestedParams />} />
7678
{/* Test root-level relative path - no leading slash */}
7779
<Route path="relative-paths/*" element={<RelativePaths />} />
80+
<Route path="/nested-tabs-relative-links/*" element={<NestedTabsRelativeLinks />} />
81+
<Route path="/root-splat-tabs/*" element={<RootSplatTabs />} />
7882
</IonRouterOutlet>
7983
</IonReactRouter>
8084
</IonApp>

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,12 @@ const Main: React.FC = () => {
8080
<IonItem routerLink="/relative-paths">
8181
<IonLabel>Relative Paths</IonLabel>
8282
</IonItem>
83+
<IonItem routerLink="/nested-tabs-relative-links">
84+
<IonLabel>Nested Tabs Relative Links</IonLabel>
85+
</IonItem>
86+
<IonItem routerLink="/root-splat-tabs">
87+
<IonLabel>Root Splat Tabs</IonLabel>
88+
</IonItem>
8389
</IonList>
8490
</IonContent>
8591
</IonPage>
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
import {
2+
IonContent,
3+
IonHeader,
4+
IonPage,
5+
IonTitle,
6+
IonToolbar,
7+
IonRouterOutlet,
8+
IonTabs,
9+
IonTabBar,
10+
IonTabButton,
11+
IonIcon,
12+
IonLabel,
13+
IonBackButton,
14+
IonButtons,
15+
} from '@ionic/react';
16+
import { triangle, ellipse, square } from 'ionicons/icons';
17+
import React from 'react';
18+
import { Link, Navigate, Route } from 'react-router-dom';
19+
20+
/**
21+
* This test page verifies that relative links work correctly within
22+
* nested IonRouterOutlet components, specifically in a tabs-based layout.
23+
*
24+
* Issue: When using React Router's <Link to="page-a"> inside the tab1 route
25+
* with nested outlets and index routes, the relative path resolution can produce
26+
* incorrect URLs (e.g., /tab1/tab1/page-a instead of /tab1/page-a).
27+
*
28+
* This test also verifies that absolute links work when a catch-all route
29+
* is present.
30+
*/
31+
32+
// Tab content with relative links for testing
33+
const Tab1Content: React.FC = () => {
34+
return (
35+
<IonPage data-pageid="nested-tabs-relative-tab1">
36+
<IonHeader>
37+
<IonToolbar>
38+
<IonTitle>Tab 1</IonTitle>
39+
</IonToolbar>
40+
</IonHeader>
41+
<IonContent>
42+
<div data-testid="tab1-content">
43+
<p>Tab 1 - Home Page</p>
44+
{/* Relative link - should navigate to /nested-tabs-relative-links/tab1/page-a */}
45+
<Link to="page-a" data-testid="link-relative-page-a">
46+
Go to Page A (relative)
47+
</Link>
48+
<br />
49+
{/* Absolute link - should also work */}
50+
<Link to="/nested-tabs-relative-links/tab1/page-a" data-testid="link-absolute-page-a">
51+
Go to Page A (absolute)
52+
</Link>
53+
<br />
54+
{/* Another relative link */}
55+
<Link to="page-b" data-testid="link-relative-page-b">
56+
Go to Page B (relative)
57+
</Link>
58+
</div>
59+
</IonContent>
60+
</IonPage>
61+
);
62+
};
63+
64+
const PageA: React.FC = () => {
65+
return (
66+
<IonPage data-pageid="nested-tabs-relative-page-a">
67+
<IonHeader>
68+
<IonToolbar>
69+
<IonButtons slot="start">
70+
<IonBackButton defaultHref="/nested-tabs-relative-links/tab1" />
71+
</IonButtons>
72+
<IonTitle>Page A</IonTitle>
73+
</IonToolbar>
74+
</IonHeader>
75+
<IonContent>
76+
<div data-testid="page-a-content">
77+
This is Page A within Tab 1
78+
</div>
79+
</IonContent>
80+
</IonPage>
81+
);
82+
};
83+
84+
const PageB: React.FC = () => {
85+
return (
86+
<IonPage data-pageid="nested-tabs-relative-page-b">
87+
<IonHeader>
88+
<IonToolbar>
89+
<IonButtons slot="start">
90+
<IonBackButton defaultHref="/nested-tabs-relative-links/tab1" />
91+
</IonButtons>
92+
<IonTitle>Page B</IonTitle>
93+
</IonToolbar>
94+
</IonHeader>
95+
<IonContent>
96+
<div data-testid="page-b-content">
97+
This is Page B within Tab 1
98+
</div>
99+
</IonContent>
100+
</IonPage>
101+
);
102+
};
103+
104+
// Nested router outlet for Tab 1 - similar to user's RouterOutletTab1
105+
const Tab1RouterOutlet: React.FC = () => {
106+
return (
107+
<IonRouterOutlet>
108+
<Route index element={<Tab1Content />} />
109+
<Route path="page-a" element={<PageA />} />
110+
<Route path="page-b" element={<PageB />} />
111+
</IonRouterOutlet>
112+
);
113+
};
114+
115+
const Tab2Content: React.FC = () => {
116+
return (
117+
<IonPage data-pageid="nested-tabs-relative-tab2">
118+
<IonHeader>
119+
<IonToolbar>
120+
<IonTitle>Tab 2</IonTitle>
121+
</IonToolbar>
122+
</IonHeader>
123+
<IonContent>
124+
<div data-testid="tab2-content">
125+
Tab 2 Content
126+
</div>
127+
</IonContent>
128+
</IonPage>
129+
);
130+
};
131+
132+
const Tab3Content: React.FC = () => {
133+
return (
134+
<IonPage data-pageid="nested-tabs-relative-tab3">
135+
<IonHeader>
136+
<IonToolbar>
137+
<IonTitle>Tab 3</IonTitle>
138+
</IonToolbar>
139+
</IonHeader>
140+
<IonContent>
141+
<div data-testid="tab3-content">
142+
Tab 3 Content
143+
</div>
144+
</IonContent>
145+
</IonPage>
146+
);
147+
};
148+
149+
// Main tabs component - wraps tabs with catch-all route (similar to user's reproduction)
150+
const TabsContainer: React.FC = () => (
151+
<IonTabs>
152+
<IonRouterOutlet>
153+
{/* Tab 1 has nested routes with index route */}
154+
<Route path="tab1/*" element={<Tab1RouterOutlet />} />
155+
<Route path="tab2" element={<Tab2Content />} />
156+
<Route path="tab3" element={<Tab3Content />} />
157+
<Route index element={<Navigate to="tab1" replace />} />
158+
{/* Catch-all 404 route - this presence caused issues with absolute links */}
159+
<Route
160+
path="*"
161+
element={
162+
<IonPage data-pageid="nested-tabs-relative-404">
163+
<IonContent>
164+
<p data-testid="not-found">404 - Not Found</p>
165+
</IonContent>
166+
</IonPage>
167+
}
168+
/>
169+
</IonRouterOutlet>
170+
<IonTabBar slot="bottom">
171+
<IonTabButton tab="tab1" href="/nested-tabs-relative-links/tab1">
172+
<IonIcon icon={triangle} />
173+
<IonLabel>Tab 1</IonLabel>
174+
</IonTabButton>
175+
<IonTabButton tab="tab2" href="/nested-tabs-relative-links/tab2">
176+
<IonIcon icon={ellipse} />
177+
<IonLabel>Tab 2</IonLabel>
178+
</IonTabButton>
179+
<IonTabButton tab="tab3" href="/nested-tabs-relative-links/tab3">
180+
<IonIcon icon={square} />
181+
<IonLabel>Tab 3</IonLabel>
182+
</IonTabButton>
183+
</IonTabBar>
184+
</IonTabs>
185+
);
186+
187+
// Top-level component - splat route renders tabs
188+
const NestedTabsRelativeLinks: React.FC = () => (
189+
<IonRouterOutlet>
190+
<Route path="*" element={<TabsContainer />} />
191+
</IonRouterOutlet>
192+
);
193+
194+
export default NestedTabsRelativeLinks;

0 commit comments

Comments
 (0)