Skip to content
14 changes: 13 additions & 1 deletion packages/react-router/src/ReactRouter/ReactRouterViewStack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -469,7 +469,9 @@ export class ReactRouterViewStack extends ViewStacks {
let parentPath: string | undefined = undefined;
try {
// Only attempt parent path computation for non-root outlets
if (outletId !== 'routerOutlet') {
// Root outlets have IDs like 'routerOutlet' or 'routerOutlet-2'
const isRootOutlet = outletId.startsWith('routerOutlet');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this is being used in several files, why not create a utility for it?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mean, I haven't up until this point because it's such a simple single line of code, but I do agree that it probably should still be extracted into a utility - especially because it's not impossible that it changes at some point in the future. I think most of the refactoring I'll save until I have the RR6 branch more consolidated, I still have stuff pretty spread out and I'm not even sure how similar this file is in my next PR. But I think after the main RR6 branch is approved and all of the sub branches are merged into it, a single refactoring sweep would be a good idea to help clean up things like this and extremely large code blocks.

if (!isRootOutlet) {
const routeChildren = extractRouteChildren(ionRouterOutlet.props.children);
const { hasRelativeRoutes, hasIndexRoute, hasWildcardRoute } = analyzeRouteChildren(routeChildren);

Expand Down Expand Up @@ -713,7 +715,17 @@ export class ReactRouterViewStack extends ViewStacks {
return false;
}

// For empty path routes, only match if we're at the same level as when the view was created.
// This prevents an empty path view item from being reused for different routes.
if (isDefaultRoute) {
const previousPathnameBase = v.routeData?.match?.pathnameBase || '';
const normalizedBase = normalizePathnameForComparison(previousPathnameBase);
const normalizedPathname = normalizePathnameForComparison(pathname);

if (normalizedPathname !== normalizedBase) {
return false;
}

match = {
params: {},
pathname,
Expand Down
88 changes: 66 additions & 22 deletions packages/react-router/src/ReactRouter/StackManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,28 +109,36 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
return undefined;
}

// If this is a nested outlet (has an explicit ID like "main"),
// we need to figure out what part of the path was already matched
if (this.id !== 'routerOutlet' && this.ionRouterOutlet) {
// Check if this outlet has route children to analyze
if (this.ionRouterOutlet) {
const routeChildren = extractRouteChildren(this.ionRouterOutlet.props.children);
const { hasRelativeRoutes, hasIndexRoute, hasWildcardRoute } = analyzeRouteChildren(routeChildren);

const result = computeParentPath({
currentPathname,
outletMountPath: this.outletMountPath,
routeChildren,
hasRelativeRoutes,
hasIndexRoute,
hasWildcardRoute,
});
// Root outlets have IDs like 'routerOutlet' or 'routerOutlet-2'
// But even outlets with auto-generated IDs may need parent path computation
// if they have relative routes (indicating they're nested outlets)
const isRootOutlet = this.id.startsWith('routerOutlet');
const needsParentPath = !isRootOutlet || hasRelativeRoutes || hasIndexRoute;

if (needsParentPath) {
const result = computeParentPath({
currentPathname,
outletMountPath: this.outletMountPath,
routeChildren,
hasRelativeRoutes,
hasIndexRoute,
hasWildcardRoute,
});

// Update the outlet mount path if it was set
if (result.outletMountPath && !this.outletMountPath) {
this.outletMountPath = result.outletMountPath;
}

// Update the outlet mount path if it was set
if (result.outletMountPath && !this.outletMountPath) {
this.outletMountPath = result.outletMountPath;
return result.parentPath;
}

return result.parentPath;
}

return this.outletMountPath;
}

Expand Down Expand Up @@ -246,7 +254,9 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
parentPath: string | undefined,
leavingViewItem: ViewItem | undefined
): boolean {
if (this.id === 'routerOutlet' || parentPath !== undefined || !this.ionRouterOutlet) {
// Root outlets have IDs like 'routerOutlet' or 'routerOutlet-2'
const isRootOutlet = this.id.startsWith('routerOutlet');
if (isRootOutlet || parentPath !== undefined || !this.ionRouterOutlet) {
return false;
}

Expand Down Expand Up @@ -283,7 +293,9 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
enteringViewItem: ViewItem | undefined,
leavingViewItem: ViewItem | undefined
): boolean {
if (this.id === 'routerOutlet' || enteringRoute || enteringViewItem) {
// Root outlets have IDs like 'routerOutlet' or 'routerOutlet-2'
const isRootOutlet = this.id.startsWith('routerOutlet');
if (isRootOutlet || enteringRoute || enteringViewItem) {
return false;
}

Expand Down Expand Up @@ -933,7 +945,8 @@ function findRouteByRouteInfo(node: React.ReactNode, routeInfo: RouteInfo, paren

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

// Check if we have relative routes (routes that don't start with '/')
const hasRelativeRoutes = sortedRoutes.some((r) => r.props.path && !r.props.path.startsWith('/'));
Expand All @@ -942,22 +955,53 @@ function findRouteByRouteInfo(node: React.ReactNode, routeInfo: RouteInfo, paren
// SIMPLIFIED: Trust React Router 6's matching more, compute relative path when parent is known
if ((hasRelativeRoutes || hasIndexRoute) && parentPath) {
const parentPrefix = parentPath.replace('/*', '');
const normalizedParent = stripTrailingSlash(parentPrefix);
// Normalize both paths to start with '/' for consistent comparison
const normalizedParent = stripTrailingSlash(parentPrefix.startsWith('/') ? parentPrefix : `/${parentPrefix}`);
const normalizedPathname = stripTrailingSlash(routeInfo.pathname);

// Only compute relative path if pathname is within parent scope
if (normalizedPathname.startsWith(normalizedParent + '/') || normalizedPathname === normalizedParent) {
const pathSegments = routeInfo.pathname.split('/').filter(Boolean);
const parentSegments = normalizedParent.split('/').filter(Boolean);
const relativeSegments = pathSegments.slice(parentSegments.length);
pathnameToMatch = relativeSegments.join('/'); // Empty string is valid for index routes
relativePathnameToMatch = relativeSegments.join('/'); // Empty string is valid for index routes
}
}

// Find the first matching route
for (const child of sortedRoutes) {
const childPath = child.props.path as string | undefined;
const isAbsoluteRoute = childPath && childPath.startsWith('/');

// Determine which pathname to match against:
// - For absolute routes: use the original full pathname
// - For relative routes with a parent: use the computed relative pathname
// - For relative routes at root level (no parent): use the original pathname
// (matchPath will handle the relative-to-absolute normalization)
const pathnameToMatch = isAbsoluteRoute ? originalPathname : relativePathnameToMatch;

// Determine the path portion to match:
// - For absolute routes: use derivePathnameToMatch
// - For relative routes at root level (no parent): use original pathname
// directly since matchPath normalizes both path and pathname
// - For relative routes with parent: use derivePathnameToMatch for wildcards,
// or the computed relative pathname for non-wildcards
let pathForMatch: string;
if (isAbsoluteRoute) {
pathForMatch = derivePathnameToMatch(pathnameToMatch, childPath);
} else if (!parentPath && childPath) {
// Root-level relative route: use the full pathname and let matchPath
// handle the normalization (it adds '/' to both path and pathname)
pathForMatch = originalPathname;
} else if (childPath && childPath.includes('*')) {
// Relative wildcard route with parent path: use derivePathnameToMatch
pathForMatch = derivePathnameToMatch(pathnameToMatch, childPath);
} else {
pathForMatch = pathnameToMatch;
}

const match = matchPath({
pathname: pathnameToMatch,
pathname: pathForMatch,
componentProps: child.props,
});

Expand Down
34 changes: 21 additions & 13 deletions packages/react-router/src/ReactRouter/utils/pathMatching.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,8 @@ interface MatchPathOptions {
export const matchPath = ({ pathname, componentProps }: MatchPathOptions): PathMatch<string> | null => {
const { path, index, ...restProps } = componentProps;

// Handle index routes
// Handle index routes - they match when pathname is empty or just "/"
if (index && !path) {
// Index routes match when there's no additional path after the parent route
// For example, in a nested outlet at /routing/*, the index route matches
// when the relative path is empty (i.e., we're exactly at /routing)

// If pathname is empty or just "/", it should match the index route
if (pathname === '' || pathname === '/') {
return {
params: {},
Expand All @@ -46,17 +41,27 @@ export const matchPath = ({ pathname, componentProps }: MatchPathOptions): PathM
},
};
}

// Otherwise, index routes don't match when there's additional path
return null;
}

if (!path) {
// Handle empty path routes - they match when pathname is also empty or just "/"
if (path === '' || path === undefined) {
if (pathname === '' || pathname === '/') {
return {
params: {},
pathname: pathname,
pathnameBase: pathname || '/',
pattern: {
path: '',
caseSensitive: restProps.caseSensitive ?? false,
end: restProps.end ?? true,
},
};
}
return null;
}

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

// No match found
return null;
}

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

const trimmedPath = fullPathname.startsWith('/') ? fullPathname.slice(1) : fullPathname;
if (!trimmedPath) {
return '';
// For root-level relative routes (pathname is "/" and routePath is relative),
// return the full pathname so matchPath can normalize both.
// This allows routes like <Route path="foo/*" .../> at root level to work correctly.
return fullPathname;
}

const fullSegments = trimmedPath.split('/').filter(Boolean);
Expand Down
3 changes: 3 additions & 0 deletions packages/react-router/test/base/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import MultipleTabs from './pages/muiltiple-tabs/MultipleTabs';
import NestedOutlet from './pages/nested-outlet/NestedOutlet';
import NestedOutlet2 from './pages/nested-outlet/NestedOutlet2';
import NestedParams from './pages/nested-params/NestedParams';
import RelativePaths from './pages/relative-paths/RelativePaths';
import { OutletRef } from './pages/outlet-ref/OutletRef';
import Params from './pages/params/Params';
import Refs from './pages/refs/Refs';
Expand Down Expand Up @@ -72,6 +73,8 @@ const App: React.FC = () => {
<Route path="/overlays" element={<Overlays />} />
<Route path="/params/:id" element={<Params />} />
<Route path="/nested-params/*" element={<NestedParams />} />
{/* Test root-level relative path - no leading slash */}
<Route path="relative-paths/*" element={<RelativePaths />} />
</IonRouterOutlet>
</IonReactRouter>
</IonApp>
Expand Down
3 changes: 3 additions & 0 deletions packages/react-router/test/base/src/pages/Main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@ const Main: React.FC = () => {
<IonItem routerLink="/nested-params">
<IonLabel>Nested Params</IonLabel>
</IonItem>
<IonItem routerLink="/relative-paths">
<IonLabel>Relative Paths</IonLabel>
</IonItem>
</IonList>
</IonContent>
</IonPage>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import {
IonContent,
IonHeader,
IonPage,
IonTitle,
IonToolbar,
IonRouterOutlet,
IonList,
IonItem,
IonLabel,
IonBackButton,
IonButtons,
} from '@ionic/react';
import React from 'react';
import { Route } from 'react-router-dom';

/**
* This test page verifies that IonRouterOutlet correctly handles
* relative paths (paths without a leading slash) the same way
* React Router 6's Routes component does.
*/

const RelativePathsHome: React.FC = () => {
return (
<IonPage data-pageid="relative-paths-home">
<IonHeader>
<IonToolbar>
<IonButtons slot="start">
<IonBackButton defaultHref="/" />
</IonButtons>
<IonTitle>Relative Paths Test</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent>
<IonList>
<IonItem routerLink="/relative-paths/page-a">
<IonLabel>Go to Page A (absolute path route)</IonLabel>
</IonItem>
<IonItem routerLink="/relative-paths/page-b">
<IonLabel>Go to Page B (relative path route)</IonLabel>
</IonItem>
</IonList>
</IonContent>
</IonPage>
);
};

const PageA: React.FC = () => {
return (
<IonPage data-pageid="relative-paths-page-a">
<IonHeader>
<IonToolbar>
<IonButtons slot="start">
<IonBackButton defaultHref="/relative-paths" />
</IonButtons>
<IonTitle>Page A</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent>
<div data-testid="page-a-content">
This is Page A - route defined with absolute path
</div>
</IonContent>
</IonPage>
);
};

const PageB: React.FC = () => {
return (
<IonPage data-pageid="relative-paths-page-b">
<IonHeader>
<IonToolbar>
<IonButtons slot="start">
<IonBackButton defaultHref="/relative-paths" />
</IonButtons>
<IonTitle>Page B</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent>
<div data-testid="page-b-content">
This is Page B - route defined with relative path (no leading slash)
</div>
</IonContent>
</IonPage>
);
};

const RelativePaths: React.FC = () => {
return (
<IonRouterOutlet>
{/* Route with absolute path (has leading slash) - this should work */}
<Route path="/relative-paths/page-a" element={<PageA />} />

{/* Route with relative path (no leading slash) */}
<Route path="page-b" element={<PageB />} />

{/* Home route - using relative path */}
<Route path="" element={<RelativePathsHome />} />
</IonRouterOutlet>
);
};

export default RelativePaths;
Loading
Loading