Skip to content

Commit 1e7051a

Browse files
committed
fix(react-router): fix relative link resolution in nested outlets with splat routes
1 parent f491e92 commit 1e7051a

File tree

4 files changed

+345
-21
lines changed

4 files changed

+345
-21
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/test/base/src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import TabsContext from './pages/tab-context/TabContext';
4242
import Tabs from './pages/tabs/Tabs';
4343
import TabsSecondary from './pages/tabs/TabsSecondary';
4444
import Overlays from './pages/overlays/Overlays';
45+
import NestedTabsRelativeLinks from './pages/nested-tabs-relative-links/NestedTabsRelativeLinks';
4546

4647
setupIonicReact();
4748

@@ -73,6 +74,7 @@ const App: React.FC = () => {
7374
<Route path="/nested-params/*" element={<NestedParams />} />
7475
{/* Test root-level relative path - no leading slash */}
7576
<Route path="relative-paths/*" element={<RelativePaths />} />
77+
<Route path="/nested-tabs-relative-links/*" element={<NestedTabsRelativeLinks />} />
7678
</IonRouterOutlet>
7779
</IonReactRouter>
7880
</IonApp>
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
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="relative"> inside nested
25+
* outlets with index routes, the relative path resolution can produce
26+
* incorrect URLs (e.g., /tab1/tab1/abc instead of /tab1/abc).
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 with outer router outlet
188+
const NestedTabsRelativeLinks: React.FC = () => (
189+
<IonRouterOutlet>
190+
{/* Catch-all route that renders the tabs container */}
191+
<Route path="*" element={<TabsContainer />} />
192+
</IonRouterOutlet>
193+
);
194+
195+
export default NestedTabsRelativeLinks;
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
const port = 3000;
2+
3+
/**
4+
* Tests for relative links within nested IonRouterOutlet components.
5+
*
6+
* This specifically tests the scenario where:
7+
* 1. IonRouterOutlet has a catch-all route (*) containing IonTabs
8+
* 2. Inside tabs, there's another outlet with nested routes using index routes
9+
* 3. React Router's <Link to="relative"> is used for navigation
10+
*
11+
* The expected behavior is:
12+
* - <Link to="page-a"> at /nested-tabs-relative-links/tab1 should produce
13+
* href="/nested-tabs-relative-links/tab1/page-a" (not /tab1/tab1/page-a)
14+
* - <Link to="/nested-tabs-relative-links/tab1/page-a"> should work and not 404
15+
*/
16+
describe('Nested Tabs with Relative Links', () => {
17+
it('should navigate to tab1 by default', () => {
18+
cy.visit(`http://localhost:${port}/nested-tabs-relative-links`);
19+
cy.ionPageVisible('nested-tabs-relative-tab1');
20+
cy.get('[data-testid="tab1-content"]').should('exist');
21+
});
22+
23+
it('should have correct href for relative link', () => {
24+
cy.visit(`http://localhost:${port}/nested-tabs-relative-links/tab1`);
25+
cy.ionPageVisible('nested-tabs-relative-tab1');
26+
27+
// Check that the relative link has the correct href
28+
// It should be /nested-tabs-relative-links/tab1/page-a, NOT /tab1/tab1/page-a
29+
cy.get('[data-testid="link-relative-page-a"]')
30+
.should('have.attr', 'href', '/nested-tabs-relative-links/tab1/page-a');
31+
});
32+
33+
it('should navigate to Page A via relative link', () => {
34+
cy.visit(`http://localhost:${port}/nested-tabs-relative-links/tab1`);
35+
cy.ionPageVisible('nested-tabs-relative-tab1');
36+
37+
// Click the relative link
38+
cy.get('[data-testid="link-relative-page-a"]').click();
39+
40+
// Should be at Page A
41+
cy.ionPageVisible('nested-tabs-relative-page-a');
42+
cy.get('[data-testid="page-a-content"]').should('exist');
43+
44+
// URL should be correct
45+
cy.url().should('include', '/nested-tabs-relative-links/tab1/page-a');
46+
// URL should NOT have duplicate path segments
47+
cy.url().should('not.include', '/tab1/tab1/');
48+
});
49+
50+
it('should navigate to Page A via absolute link', () => {
51+
cy.visit(`http://localhost:${port}/nested-tabs-relative-links/tab1`);
52+
cy.ionPageVisible('nested-tabs-relative-tab1');
53+
54+
// Click the absolute link
55+
cy.get('[data-testid="link-absolute-page-a"]').click();
56+
57+
// Should be at Page A (not 404)
58+
cy.ionPageVisible('nested-tabs-relative-page-a');
59+
cy.get('[data-testid="page-a-content"]').should('exist');
60+
61+
// Should NOT show 404
62+
cy.get('[data-testid="not-found"]').should('not.exist');
63+
});
64+
65+
it('should navigate to Page B via relative link', () => {
66+
cy.visit(`http://localhost:${port}/nested-tabs-relative-links/tab1`);
67+
cy.ionPageVisible('nested-tabs-relative-tab1');
68+
69+
// Click the relative link to page B
70+
cy.get('[data-testid="link-relative-page-b"]').click();
71+
72+
// Should be at Page B
73+
cy.ionPageVisible('nested-tabs-relative-page-b');
74+
cy.get('[data-testid="page-b-content"]').should('exist');
75+
76+
// URL should be correct
77+
cy.url().should('include', '/nested-tabs-relative-links/tab1/page-b');
78+
});
79+
80+
it('should navigate to Page A and back', () => {
81+
cy.visit(`http://localhost:${port}/nested-tabs-relative-links/tab1`);
82+
cy.ionPageVisible('nested-tabs-relative-tab1');
83+
84+
// Navigate to Page A
85+
cy.get('[data-testid="link-relative-page-a"]').click();
86+
cy.ionPageVisible('nested-tabs-relative-page-a');
87+
88+
// Go back
89+
cy.ionBackClick('nested-tabs-relative-page-a');
90+
91+
// Should be back at Tab 1
92+
cy.ionPageVisible('nested-tabs-relative-tab1');
93+
});
94+
95+
it('should directly visit Page A via URL', () => {
96+
cy.visit(`http://localhost:${port}/nested-tabs-relative-links/tab1/page-a`);
97+
98+
// Should be at Page A (not 404)
99+
cy.ionPageVisible('nested-tabs-relative-page-a');
100+
cy.get('[data-testid="page-a-content"]').should('exist');
101+
});
102+
103+
it('should switch tabs and maintain correct relative link resolution', () => {
104+
cy.visit(`http://localhost:${port}/nested-tabs-relative-links/tab1`);
105+
cy.ionPageVisible('nested-tabs-relative-tab1');
106+
107+
// Switch to Tab 2
108+
cy.ionTabClick('Tab 2');
109+
cy.ionPageVisible('nested-tabs-relative-tab2');
110+
111+
// Switch back to Tab 1
112+
cy.ionTabClick('Tab 1');
113+
cy.ionPageVisible('nested-tabs-relative-tab1');
114+
115+
// The relative link should still have correct href
116+
cy.get('[data-testid="link-relative-page-a"]')
117+
.should('have.attr', 'href', '/nested-tabs-relative-links/tab1/page-a');
118+
});
119+
});

0 commit comments

Comments
 (0)