Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 29 additions & 21 deletions packages/react-router/src/ReactRouter/ReactRouterViewStack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -384,30 +384,38 @@ export class ReactRouterViewStack extends ViewStacks {

// For relative route paths, we need to compute an absolute pathnameBase
// by combining the parent's pathnameBase with the matched portion
let absolutePathnameBase = routeMatch?.pathnameBase || routeInfo.pathname;
const routePath = routeElement.props.path;
const isRelativePath = routePath && !routePath.startsWith('/');
const isIndexRoute = !!routeElement.props.index;

if (isRelativePath || isIndexRoute) {
// Get the parent's pathnameBase to build the absolute path
const parentPathnameBase =
parentMatches.length > 0 ? parentMatches[parentMatches.length - 1].pathnameBase : '/';

// For relative paths, the matchPath returns a relative pathnameBase
// We need to make it absolute by prepending the parent's base
if (routeMatch?.pathnameBase && isRelativePath) {
// Strip leading slash if present in the relative match
const relativeBase = routeMatch.pathnameBase.startsWith('/')
? routeMatch.pathnameBase.slice(1)
: routeMatch.pathnameBase;

absolutePathnameBase =
parentPathnameBase === '/' ? `/${relativeBase}` : `${parentPathnameBase}/${relativeBase}`;
} else if (isIndexRoute) {
// Index routes should use the parent's base as their base
absolutePathnameBase = parentPathnameBase;
}
const isSplatOnlyRoute = routePath === '*' || routePath === '/*';

// Get parent's pathnameBase for relative path resolution
const parentPathnameBase =
parentMatches.length > 0 ? parentMatches[parentMatches.length - 1].pathnameBase : '/';

// Start with the match's pathnameBase, falling back to routeInfo.pathname
// BUT: splat-only routes should use parent's base (v7_relativeSplatPath behavior)
let absolutePathnameBase: string;

if (isSplatOnlyRoute) {
// Splat routes should NOT contribute their matched portion to pathnameBase
// This aligns with React Router v7's v7_relativeSplatPath behavior
// Without this, relative links inside splat routes get double path segments
absolutePathnameBase = parentPathnameBase;
} else if (isRelativePath && routeMatch?.pathnameBase) {
// For relative paths with a pathnameBase, combine with parent
const relativeBase = routeMatch.pathnameBase.startsWith('/')
? routeMatch.pathnameBase.slice(1)
: routeMatch.pathnameBase;

absolutePathnameBase =
parentPathnameBase === '/' ? `/${relativeBase}` : `${parentPathnameBase}/${relativeBase}`;
} else if (isIndexRoute) {
// Index routes should use the parent's base as their base
absolutePathnameBase = parentPathnameBase;
} else {
// Default: use the match's pathnameBase or the current pathname
absolutePathnameBase = routeMatch?.pathnameBase || routeInfo.pathname;
}

const contextMatches = [
Expand Down
62 changes: 45 additions & 17 deletions packages/react-router/src/ReactRouter/utils/computeParentPath.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,26 +44,39 @@ export const computeCommonPrefix = (paths: string[]): string => {
};

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

// Skip wildcards and index routes
if (isIndex || isWildcardOnly) {
/**
* Checks if a route has an embedded wildcard (e.g., "tab1/*" but not "*" or "/*").
*/
const hasEmbeddedWildcard = (routePath: string | undefined): boolean => {
return !!routePath && routePath.includes('*') && !isSplatOnlyRoute(routePath);
};

/**
* Checks if a route with an embedded wildcard matches a pathname.
*/
const matchesEmbeddedWildcardRoute = (route: React.ReactElement, pathname: string): boolean => {
const routePath = route.props.path as string | undefined;
if (!hasEmbeddedWildcard(routePath)) {
return false;
}
return !!matchPath({ pathname, componentProps: route.props });
};

return !!matchPath({
pathname: remainingPath,
componentProps: route.props,
});
/**
* Checks if a route is a specific match (not wildcard-only or index).
*/
export const isSpecificRouteMatch = (route: React.ReactElement, remainingPath: string): boolean => {
const routePath = route.props.path;
if (route.props.index || isSplatOnlyRoute(routePath)) {
return false;
}
return !!matchPath({ pathname: remainingPath, componentProps: route.props });
};

/**
Expand Down Expand Up @@ -142,12 +155,16 @@ export const computeParentPath = (options: ComputeParentPathOptions): ParentPath
let firstWildcardMatch: string | undefined = undefined;
let indexMatchAtMount: string | undefined = undefined;

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

// Check for specific (non-wildcard, non-index) route matches
const hasSpecificMatch = routeChildren.some((route) => isSpecificRouteMatch(route, remainingPath));
// Check for specific route matches (non-wildcard-only, non-index)
// Also check routes with embedded wildcards (e.g., "tab1/*")
const hasSpecificMatch = routeChildren.some(
(route) => isSpecificRouteMatch(route, remainingPath) || matchesEmbeddedWildcardRoute(route, remainingPath)
);
if (hasSpecificMatch && !firstSpecificMatch) {
firstSpecificMatch = parentPath;
// Found a specific match - this is our answer for non-index routes
Expand Down Expand Up @@ -198,6 +215,17 @@ export const computeParentPath = (options: ComputeParentPathOptions): ParentPath
}
}

// Fallback: check at root level (i = 0) for embedded wildcard routes.
// This handles outlets inside root-level splat routes where routes like
// "tab1/*" need to match the full pathname.
if (!firstSpecificMatch) {
const fullRemainingPath = segments.join('/');
const hasRootLevelMatch = routeChildren.some((route) => matchesEmbeddedWildcardRoute(route, fullRemainingPath));
if (hasRootLevelMatch) {
firstSpecificMatch = '/';
}
}

// Determine the best parent path:
// 1. Specific match (routes like tabs/*, favorites) - highest priority
// 2. Wildcard match (route path="*") - catches unmatched segments
Expand Down
4 changes: 4 additions & 0 deletions packages/react-router/test/base/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ import Tabs from './pages/tabs/Tabs';
import TabsSecondary from './pages/tabs/TabsSecondary';
import TabHistoryIsolation from './pages/tab-history-isolation/TabHistoryIsolation';
import Overlays from './pages/overlays/Overlays';
import NestedTabsRelativeLinks from './pages/nested-tabs-relative-links/NestedTabsRelativeLinks';
import RootSplatTabs from './pages/root-splat-tabs/RootSplatTabs';

setupIonicReact();

Expand Down Expand Up @@ -75,6 +77,8 @@ const App: React.FC = () => {
<Route path="/nested-params/*" element={<NestedParams />} />
{/* Test root-level relative path - no leading slash */}
<Route path="relative-paths/*" element={<RelativePaths />} />
<Route path="/nested-tabs-relative-links/*" element={<NestedTabsRelativeLinks />} />
<Route path="/root-splat-tabs/*" element={<RootSplatTabs />} />
</IonRouterOutlet>
</IonReactRouter>
</IonApp>
Expand Down
6 changes: 6 additions & 0 deletions packages/react-router/test/base/src/pages/Main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,12 @@ const Main: React.FC = () => {
<IonItem routerLink="/relative-paths">
<IonLabel>Relative Paths</IonLabel>
</IonItem>
<IonItem routerLink="/nested-tabs-relative-links">
<IonLabel>Nested Tabs Relative Links</IonLabel>
</IonItem>
<IonItem routerLink="/root-splat-tabs">
<IonLabel>Root Splat Tabs</IonLabel>
</IonItem>
</IonList>
</IonContent>
</IonPage>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import {
IonContent,
IonHeader,
IonPage,
IonTitle,
IonToolbar,
IonRouterOutlet,
IonTabs,
IonTabBar,
IonTabButton,
IonIcon,
IonLabel,
IonBackButton,
IonButtons,
} from '@ionic/react';
import { triangle, ellipse, square } from 'ionicons/icons';
import React from 'react';
import { Link, Navigate, Route } from 'react-router-dom';

/**
* This test page verifies that relative links work correctly within
* nested IonRouterOutlet components, specifically in a tabs-based layout.
*
* Issue: When using React Router's <Link to="page-a"> inside the tab1 route
* with nested outlets and index routes, the relative path resolution can produce
* incorrect URLs (e.g., /tab1/tab1/page-a instead of /tab1/page-a).
*
* This test also verifies that absolute links work when a catch-all route
* is present.
*/

// Tab content with relative links for testing
const Tab1Content: React.FC = () => {
return (
<IonPage data-pageid="nested-tabs-relative-tab1">
<IonHeader>
<IonToolbar>
<IonTitle>Tab 1</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent>
<div data-testid="tab1-content">
<p>Tab 1 - Home Page</p>
{/* Relative link - should navigate to /nested-tabs-relative-links/tab1/page-a */}
<Link to="page-a" data-testid="link-relative-page-a">
Go to Page A (relative)
</Link>
<br />
{/* Absolute link - should also work */}
<Link to="/nested-tabs-relative-links/tab1/page-a" data-testid="link-absolute-page-a">
Go to Page A (absolute)
</Link>
<br />
{/* Another relative link */}
<Link to="page-b" data-testid="link-relative-page-b">
Go to Page B (relative)
</Link>
</div>
</IonContent>
</IonPage>
);
};

const PageA: React.FC = () => {
return (
<IonPage data-pageid="nested-tabs-relative-page-a">
<IonHeader>
<IonToolbar>
<IonButtons slot="start">
<IonBackButton defaultHref="/nested-tabs-relative-links/tab1" />
</IonButtons>
<IonTitle>Page A</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent>
<div data-testid="page-a-content">
This is Page A within Tab 1
</div>
</IonContent>
</IonPage>
);
};

const PageB: React.FC = () => {
return (
<IonPage data-pageid="nested-tabs-relative-page-b">
<IonHeader>
<IonToolbar>
<IonButtons slot="start">
<IonBackButton defaultHref="/nested-tabs-relative-links/tab1" />
</IonButtons>
<IonTitle>Page B</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent>
<div data-testid="page-b-content">
This is Page B within Tab 1
</div>
</IonContent>
</IonPage>
);
};

// Nested router outlet for Tab 1 - similar to user's RouterOutletTab1
const Tab1RouterOutlet: React.FC = () => {
return (
<IonRouterOutlet>
<Route index element={<Tab1Content />} />
<Route path="page-a" element={<PageA />} />
<Route path="page-b" element={<PageB />} />
</IonRouterOutlet>
);
};

const Tab2Content: React.FC = () => {
return (
<IonPage data-pageid="nested-tabs-relative-tab2">
<IonHeader>
<IonToolbar>
<IonTitle>Tab 2</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent>
<div data-testid="tab2-content">
Tab 2 Content
</div>
</IonContent>
</IonPage>
);
};

const Tab3Content: React.FC = () => {
return (
<IonPage data-pageid="nested-tabs-relative-tab3">
<IonHeader>
<IonToolbar>
<IonTitle>Tab 3</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent>
<div data-testid="tab3-content">
Tab 3 Content
</div>
</IonContent>
</IonPage>
);
};

// Main tabs component - wraps tabs with catch-all route (similar to user's reproduction)
const TabsContainer: React.FC = () => (
<IonTabs>
<IonRouterOutlet>
{/* Tab 1 has nested routes with index route */}
<Route path="tab1/*" element={<Tab1RouterOutlet />} />
<Route path="tab2" element={<Tab2Content />} />
<Route path="tab3" element={<Tab3Content />} />
<Route index element={<Navigate to="tab1" replace />} />
{/* Catch-all 404 route - this presence caused issues with absolute links */}
<Route
path="*"
element={
<IonPage data-pageid="nested-tabs-relative-404">
<IonContent>
<p data-testid="not-found">404 - Not Found</p>
</IonContent>
</IonPage>
}
/>
</IonRouterOutlet>
<IonTabBar slot="bottom">
<IonTabButton tab="tab1" href="/nested-tabs-relative-links/tab1">
<IonIcon icon={triangle} />
<IonLabel>Tab 1</IonLabel>
</IonTabButton>
<IonTabButton tab="tab2" href="/nested-tabs-relative-links/tab2">
<IonIcon icon={ellipse} />
<IonLabel>Tab 2</IonLabel>
</IonTabButton>
<IonTabButton tab="tab3" href="/nested-tabs-relative-links/tab3">
<IonIcon icon={square} />
<IonLabel>Tab 3</IonLabel>
</IonTabButton>
</IonTabBar>
</IonTabs>
);

// Top-level component - splat route renders tabs
const NestedTabsRelativeLinks: React.FC = () => (
<IonRouterOutlet>
<Route path="*" element={<TabsContainer />} />
</IonRouterOutlet>
);

export default NestedTabsRelativeLinks;
Loading
Loading