Skip to content

Commit f70231a

Browse files
committed
fix(react-router): resolve relative route matching inside root-level splat routes
1 parent 17902a6 commit f70231a

File tree

5 files changed

+306
-19
lines changed

5 files changed

+306
-19
lines changed

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: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import TabsSecondary from './pages/tabs/TabsSecondary';
4444
import TabHistoryIsolation from './pages/tab-history-isolation/TabHistoryIsolation';
4545
import Overlays from './pages/overlays/Overlays';
4646
import NestedTabsRelativeLinks from './pages/nested-tabs-relative-links/NestedTabsRelativeLinks';
47+
import RootSplatTabs from './pages/root-splat-tabs/RootSplatTabs';
4748

4849
setupIonicReact();
4950

@@ -77,6 +78,7 @@ const App: React.FC = () => {
7778
{/* Test root-level relative path - no leading slash */}
7879
<Route path="relative-paths/*" element={<RelativePaths />} />
7980
<Route path="/nested-tabs-relative-links/*" element={<NestedTabsRelativeLinks />} />
81+
<Route path="/root-splat-tabs/*" element={<RootSplatTabs />} />
8082
</IonRouterOutlet>
8183
</IonReactRouter>
8284
</IonApp>

packages/react-router/test/base/src/pages/nested-tabs-relative-links/NestedTabsRelativeLinks.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -184,10 +184,9 @@ const TabsContainer: React.FC = () => (
184184
</IonTabs>
185185
);
186186

187-
// Top-level component with outer router outlet
187+
// Top-level component - splat route renders tabs
188188
const NestedTabsRelativeLinks: React.FC = () => (
189189
<IonRouterOutlet>
190-
{/* Catch-all route that renders the tabs container */}
191190
<Route path="*" element={<TabsContainer />} />
192191
</IonRouterOutlet>
193192
);
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
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+
* Test page for root-level splat routes with relative tab paths.
22+
*
23+
* Structure: Outer splat route "*" renders IonTabs, with relative paths
24+
* like "tab1/*" (no leading slash) inside the tabs outlet.
25+
*
26+
* This tests the fix for routes with relative paths inside root-level splat routes.
27+
*/
28+
29+
// Tab content with relative links for testing
30+
const Tab1Content: React.FC = () => {
31+
return (
32+
<IonPage data-pageid="root-splat-tab1">
33+
<IonHeader>
34+
<IonToolbar>
35+
<IonTitle>Tab 1</IonTitle>
36+
</IonToolbar>
37+
</IonHeader>
38+
<IonContent>
39+
<div data-testid="root-splat-tab1-content">
40+
<p>Tab 1 - Home Page (Root Splat Test)</p>
41+
<Link to="page-a" data-testid="link-relative-page-a">
42+
Go to Page A (relative)
43+
</Link>
44+
<br />
45+
<Link to="/root-splat-tabs/tab1/page-a" data-testid="link-absolute-page-a">
46+
Go to Page A (absolute)
47+
</Link>
48+
</div>
49+
</IonContent>
50+
</IonPage>
51+
);
52+
};
53+
54+
const PageA: React.FC = () => {
55+
return (
56+
<IonPage data-pageid="root-splat-page-a">
57+
<IonHeader>
58+
<IonToolbar>
59+
<IonButtons slot="start">
60+
<IonBackButton defaultHref="/root-splat-tabs/tab1" />
61+
</IonButtons>
62+
<IonTitle>Page A</IonTitle>
63+
</IonToolbar>
64+
</IonHeader>
65+
<IonContent>
66+
<div data-testid="root-splat-page-a-content">
67+
This is Page A within Tab 1 (Root Splat Test)
68+
</div>
69+
</IonContent>
70+
</IonPage>
71+
);
72+
};
73+
74+
// Nested router outlet for Tab 1 - matches customer's RouterOutletTab1
75+
const Tab1RouterOutlet: React.FC = () => {
76+
return (
77+
<IonPage>
78+
<IonRouterOutlet>
79+
<Route index element={<Tab1Content />} />
80+
<Route path="page-a" element={<PageA />} />
81+
</IonRouterOutlet>
82+
</IonPage>
83+
);
84+
};
85+
86+
const Tab2Content: React.FC = () => {
87+
return (
88+
<IonPage data-pageid="root-splat-tab2">
89+
<IonHeader>
90+
<IonToolbar>
91+
<IonTitle>Tab 2</IonTitle>
92+
</IonToolbar>
93+
</IonHeader>
94+
<IonContent>
95+
<div data-testid="root-splat-tab2-content">
96+
Tab 2 Content (Root Splat Test)
97+
</div>
98+
</IonContent>
99+
</IonPage>
100+
);
101+
};
102+
103+
const Tab3Content: React.FC = () => {
104+
return (
105+
<IonPage data-pageid="root-splat-tab3">
106+
<IonHeader>
107+
<IonToolbar>
108+
<IonTitle>Tab 3</IonTitle>
109+
</IonToolbar>
110+
</IonHeader>
111+
<IonContent>
112+
<div data-testid="root-splat-tab3-content">
113+
Tab 3 Content (Root Splat Test)
114+
</div>
115+
</IonContent>
116+
</IonPage>
117+
);
118+
};
119+
120+
const NotFoundPage: React.FC = () => {
121+
return (
122+
<IonPage data-pageid="root-splat-404">
123+
<IonContent>
124+
<p data-testid="root-splat-not-found">404 - Not Found (Root Splat Test)</p>
125+
</IonContent>
126+
</IonPage>
127+
);
128+
};
129+
130+
// Tabs rendered directly inside a catch-all splat route
131+
const TabsWithSplatRoutes: React.FC = () => {
132+
return (
133+
<IonTabs>
134+
<IonRouterOutlet>
135+
{/* Using RELATIVE path "tab1/*" (no leading slash) - the key test case */}
136+
<Route path="tab1/*" element={<Tab1RouterOutlet />} />
137+
<Route path="tab2" element={<Tab2Content />} />
138+
<Route path="tab3" element={<Tab3Content />} />
139+
<Route index element={<Navigate to="tab1" replace />} />
140+
<Route path="*" element={<NotFoundPage />} />
141+
</IonRouterOutlet>
142+
<IonTabBar slot="bottom">
143+
<IonTabButton tab="tab1" href="/root-splat-tabs/tab1">
144+
<IonIcon icon={triangle} />
145+
<IonLabel>Tab 1</IonLabel>
146+
</IonTabButton>
147+
<IonTabButton tab="tab2" href="/root-splat-tabs/tab2">
148+
<IonIcon icon={ellipse} />
149+
<IonLabel>Tab 2</IonLabel>
150+
</IonTabButton>
151+
<IonTabButton tab="tab3" href="/root-splat-tabs/tab3">
152+
<IonIcon icon={square} />
153+
<IonLabel>Tab 3</IonLabel>
154+
</IonTabButton>
155+
</IonTabBar>
156+
</IonTabs>
157+
);
158+
};
159+
160+
// Main component - renders tabs directly (no outlet wrapper)
161+
const RootSplatTabs: React.FC = () => <TabsWithSplatRoutes />;
162+
163+
export default RootSplatTabs;
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
const port = 3000;
2+
3+
/**
4+
* Tests for relative paths (e.g., "tab1/*") inside root-level splat routes (*).
5+
* Verifies the fix for routes not matching when parent is a splat-only route.
6+
*/
7+
describe('Root Splat Tabs - Customer Reproduction', () => {
8+
it('should navigate to tab1 by default when visiting /root-splat-tabs', () => {
9+
cy.visit(`http://localhost:${port}/root-splat-tabs`);
10+
// Should redirect to tab1 and show tab1 content
11+
cy.ionPageVisible('root-splat-tab1');
12+
cy.get('[data-testid="root-splat-tab1-content"]').should('exist');
13+
});
14+
15+
it('should load tab1 when directly visiting /root-splat-tabs/tab1', () => {
16+
cy.visit(`http://localhost:${port}/root-splat-tabs/tab1`);
17+
// CRITICAL: This should show tab1 content, NOT 404
18+
cy.ionPageVisible('root-splat-tab1');
19+
cy.get('[data-testid="root-splat-tab1-content"]').should('exist');
20+
cy.get('[data-testid="root-splat-not-found"]').should('not.exist');
21+
});
22+
23+
it('should load Page A when directly visiting /root-splat-tabs/tab1/page-a', () => {
24+
cy.visit(`http://localhost:${port}/root-splat-tabs/tab1/page-a`);
25+
// CRITICAL: This should show Page A, NOT 404
26+
// This is the exact issue the customer reported
27+
cy.ionPageVisible('root-splat-page-a');
28+
cy.get('[data-testid="root-splat-page-a-content"]').should('exist');
29+
cy.get('[data-testid="root-splat-not-found"]').should('not.exist');
30+
});
31+
32+
it('should navigate to Page A via relative link', () => {
33+
cy.visit(`http://localhost:${port}/root-splat-tabs/tab1`);
34+
cy.ionPageVisible('root-splat-tab1');
35+
36+
// Click the relative link
37+
cy.get('[data-testid="link-relative-page-a"]').click();
38+
39+
// Should be at Page A (not 404)
40+
cy.ionPageVisible('root-splat-page-a');
41+
cy.get('[data-testid="root-splat-page-a-content"]').should('exist');
42+
cy.get('[data-testid="root-splat-not-found"]').should('not.exist');
43+
44+
// URL should be correct
45+
cy.url().should('include', '/root-splat-tabs/tab1/page-a');
46+
});
47+
48+
it('should navigate to Page A via absolute link', () => {
49+
cy.visit(`http://localhost:${port}/root-splat-tabs/tab1`);
50+
cy.ionPageVisible('root-splat-tab1');
51+
52+
// Click the absolute link
53+
cy.get('[data-testid="link-absolute-page-a"]').click();
54+
55+
// Should be at Page A (not 404)
56+
cy.ionPageVisible('root-splat-page-a');
57+
cy.get('[data-testid="root-splat-page-a-content"]').should('exist');
58+
cy.get('[data-testid="root-splat-not-found"]').should('not.exist');
59+
});
60+
61+
it('should have correct href for relative link', () => {
62+
cy.visit(`http://localhost:${port}/root-splat-tabs/tab1`);
63+
cy.ionPageVisible('root-splat-tab1');
64+
65+
// The relative link should resolve to the correct absolute href
66+
cy.get('[data-testid="link-relative-page-a"]')
67+
.should('have.attr', 'href', '/root-splat-tabs/tab1/page-a');
68+
});
69+
70+
it('should navigate between tabs correctly', () => {
71+
cy.visit(`http://localhost:${port}/root-splat-tabs/tab1`);
72+
cy.ionPageVisible('root-splat-tab1');
73+
74+
// Switch to Tab 2
75+
cy.ionTabClick('Tab 2');
76+
cy.ionPageVisible('root-splat-tab2');
77+
78+
// Switch back to Tab 1
79+
cy.ionTabClick('Tab 1');
80+
cy.ionPageVisible('root-splat-tab1');
81+
});
82+
83+
it('should navigate to Page A and back to Tab 1', () => {
84+
cy.visit(`http://localhost:${port}/root-splat-tabs/tab1`);
85+
cy.ionPageVisible('root-splat-tab1');
86+
87+
// Navigate to Page A
88+
cy.get('[data-testid="link-relative-page-a"]').click();
89+
cy.ionPageVisible('root-splat-page-a');
90+
91+
// Go back
92+
cy.ionBackClick('root-splat-page-a');
93+
cy.ionPageVisible('root-splat-tab1');
94+
});
95+
});

0 commit comments

Comments
 (0)