Skip to content
64 changes: 42 additions & 22 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 Expand Up @@ -469,7 +477,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 +723,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
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
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
Loading
Loading