Skip to content
Open
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
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