Skip to content

Commit 4fb7fad

Browse files
committed
fix(react-router): render relative catch-all * routes in nested IonRouterOutlet
1 parent 903b123 commit 4fb7fad

5 files changed

Lines changed: 143 additions & 26 deletions

File tree

packages/react-router/src/ReactRouter/ReactRouterViewStack.tsx

Lines changed: 76 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,13 @@ export class ReactRouterViewStack extends ViewStacks {
226226
*/
227227
private outletParentPaths = new Map<string, string>();
228228

229+
/**
230+
* Stores the computed mount path for each outlet.
231+
* Fed back into computeParentPath on subsequent calls to stabilize
232+
* the parent path computation across navigations (mirrors StackManager.outletMountPath).
233+
*/
234+
private outletMountPaths = new Map<string, string>();
235+
229236
constructor() {
230237
super();
231238
}
@@ -440,11 +447,12 @@ export class ReactRouterViewStack extends ViewStacks {
440447
viewItem.routeData.match = match;
441448
}
442449

443-
// Deactivate wildcard routes and catch-all routes (empty path) when we have specific route matches
444-
// This prevents "Not found" or fallback pages from showing alongside valid routes
450+
// Deactivate wildcard (catch-all) and empty-path (default) routes when a more-specific route matches.
451+
// This prevents "Not found" or fallback pages from showing alongside valid routes.
445452
if (routePath === '*' || routePath === '') {
446453
// Check if any other view in this outlet has a match for the current route
447-
const hasSpecificMatch = this.getViewItemsForOutlet(viewItem.outletId).some((v) => {
454+
const outletViews = this.getViewItemsForOutlet(viewItem.outletId);
455+
let hasSpecificMatch = outletViews.some((v) => {
448456
if (v.id === viewItem.id) return false; // Skip self
449457
const vRoutePath = v.reactElement?.props?.path || '';
450458
if (vRoutePath === '*' || vRoutePath === '') return false; // Skip other wildcard/empty routes
@@ -454,6 +462,28 @@ export class ReactRouterViewStack extends ViewStacks {
454462
return !!vMatch;
455463
});
456464

465+
// For catch-all * routes, also deactivate when the pathname matches the outlet's
466+
// parent path exactly. This means there are no remaining segments for the wildcard
467+
// to catch, so the empty-path or index route should handle it instead.
468+
if (!hasSpecificMatch && routePath === '*') {
469+
const outletParentPath = this.outletParentPaths.get(viewItem.outletId);
470+
if (outletParentPath) {
471+
const normalizedParent = normalizePathnameForComparison(outletParentPath);
472+
const normalizedPathname = normalizePathnameForComparison(routeInfo.pathname);
473+
if (normalizedPathname === normalizedParent) {
474+
// Check if there's an empty-path or index view item that should handle this
475+
const hasDefaultRoute = outletViews.some((v) => {
476+
if (v.id === viewItem.id) return false;
477+
const vRoutePath = v.reactElement?.props?.path;
478+
return vRoutePath === '' || vRoutePath === undefined || !!v.routeData?.childProps?.index;
479+
});
480+
if (hasDefaultRoute) {
481+
hasSpecificMatch = true;
482+
}
483+
}
484+
}
485+
}
486+
457487
if (hasSpecificMatch) {
458488
viewItem.mount = false;
459489
if (viewItem.ionPageElement) {
@@ -544,35 +574,42 @@ export class ReactRouterViewStack extends ViewStacks {
544574
) => {
545575
const viewItems = this.getViewItemsForOutlet(outletId);
546576

547-
// Determine parentPath for nested outlets to properly evaluate index routes
577+
// Determine parentPath for outlets with relative or index routes.
578+
// This populates outletParentPaths for findViewItemByPath's matchView
579+
// and the catch-all deactivation logic in renderViewItem.
548580
let parentPath: string | undefined = undefined;
549581
try {
550-
// Only attempt parent path computation for non-root outlets
551-
// Root outlets have IDs like 'routerOutlet' or 'routerOutlet-2'
552-
const isRootOutlet = outletId.startsWith('routerOutlet');
553-
if (!isRootOutlet) {
554-
const routeChildren = extractRouteChildren(ionRouterOutlet.props.children);
555-
const { hasRelativeRoutes, hasIndexRoute, hasWildcardRoute } = analyzeRouteChildren(routeChildren);
556-
557-
if (hasRelativeRoutes || hasIndexRoute) {
558-
const result = computeParentPath({
559-
currentPathname: routeInfo.pathname,
560-
outletMountPath: undefined,
561-
routeChildren,
562-
hasRelativeRoutes,
563-
hasIndexRoute,
564-
hasWildcardRoute,
565-
});
566-
parentPath = result.parentPath;
582+
const routeChildren = extractRouteChildren(ionRouterOutlet.props.children);
583+
const { hasRelativeRoutes, hasIndexRoute, hasWildcardRoute } = analyzeRouteChildren(routeChildren);
584+
585+
if (hasRelativeRoutes || hasIndexRoute) {
586+
const result = computeParentPath({
587+
currentPathname: routeInfo.pathname,
588+
outletMountPath: this.outletMountPaths.get(outletId),
589+
routeChildren,
590+
hasRelativeRoutes,
591+
hasIndexRoute,
592+
hasWildcardRoute,
593+
});
594+
parentPath = result.parentPath;
595+
596+
// Persist the mount path for subsequent calls, mirroring StackManager.outletMountPath.
597+
// Unlike outletParentPaths (cleared when parentPath is undefined), the mount path is
598+
// intentionally sticky — it anchors the outlet's scope and is only removed in clear().
599+
if (result.outletMountPath && !this.outletMountPaths.has(outletId)) {
600+
this.outletMountPaths.set(outletId, result.outletMountPath);
567601
}
568602
}
569603
} catch (e) {
570604
// Non-fatal: if we fail to compute parentPath, fall back to previous behavior
571605
}
572606

573-
// Store the computed parentPath for use in findViewItemByPath
607+
// Store the computed parentPath for use in findViewItemByPath.
608+
// Clear stale entries when parentPath is undefined (e.g., navigated out of scope).
574609
if (parentPath !== undefined) {
575610
this.outletParentPaths.set(outletId, parentPath);
611+
} else if (this.outletParentPaths.has(outletId)) {
612+
this.outletParentPaths.delete(outletId);
576613
}
577614

578615
// Sync child elements with stored viewItems (e.g. to reflect new props)
@@ -731,6 +768,7 @@ export class ReactRouterViewStack extends ViewStacks {
731768
// a wildcard view, it should not be reused for subsequent navigations.
732769
// A fresh wildcard view will be created by createViewItem when needed.
733770
if ((viewItemPath === '*' || viewItemPath === '/*') && !v.mount) return false;
771+
734772
const isIndexRoute = !!v.routeData.childProps.index;
735773
const previousMatch = v.routeData?.match;
736774
const result = v.reactElement ? matchComponent(v.reactElement, pathname) : null;
@@ -743,6 +781,21 @@ export class ReactRouterViewStack extends ViewStacks {
743781
viewItem = v;
744782
return true;
745783
}
784+
785+
// Empty path routes (path="") should match when the pathname matches the
786+
// outlet's parent path exactly (no remaining segments). matchComponent doesn't
787+
// handle this because it lacks parent path context. Without this check, a
788+
// catch-all * view item (which matches any pathname) would be incorrectly
789+
// returned instead of the empty path route on back navigation.
790+
if (viewItemPath === '' && !isIndexRoute && outletParentPath) {
791+
const normalizedParent = normalizePathnameForComparison(outletParentPath);
792+
const normalizedPathname = normalizePathnameForComparison(pathname);
793+
if (normalizedPathname === normalizedParent) {
794+
match = createDefaultMatch(pathname, v.routeData.childProps);
795+
viewItem = v;
796+
return true;
797+
}
798+
}
746799
}
747800

748801
if (result) {
@@ -886,6 +939,7 @@ export class ReactRouterViewStack extends ViewStacks {
886939
*/
887940
clear = (outletId: string) => {
888941
this.outletParentPaths.delete(outletId);
942+
this.outletMountPaths.delete(outletId);
889943
return super.clear(outletId);
890944
};
891945

packages/react-router/src/ReactRouter/utils/computeParentPath.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -321,15 +321,28 @@ export const computeParentPath = (options: ComputeParentPathOptions): ParentPath
321321
// should catch the full remaining path instead.
322322
// Literal routes (e.g., "settings", "redirect") can still match beyond
323323
// the wildcard depth to support redirect scenarios.
324+
//
325+
// Also don't let empty/default path routes (path="" or undefined) drive
326+
// the parent deeper than a wildcard match. An empty path route matching
327+
// when remainingPath is "" just means all segments were consumed — it's
328+
// not a meaningful specific match.
324329
const shouldSkipParameterized =
325330
(outletMountPath && parentPath.length > outletMountPath.length) ||
326331
(!outletMountPath && firstWildcardMatch);
327-
if (shouldSkipParameterized) {
332+
if (shouldSkipParameterized || firstWildcardMatch) {
328333
const matchingRoute = findFirstSpecificMatchingRoute(routeChildren, remainingPath);
329-
if (matchingRoute && isPurelyParameterized(matchingRoute.props.path as string)) {
330-
continue;
334+
if (matchingRoute) {
335+
const matchingPath = matchingRoute.props.path as string | undefined;
336+
const isEmptyPath = !matchingPath || matchingPath === '';
337+
if (shouldSkipParameterized && (isPurelyParameterized(matchingPath as string) || isEmptyPath)) {
338+
continue;
339+
}
340+
if (firstWildcardMatch && isEmptyPath) {
341+
continue;
342+
}
331343
}
332344
}
345+
333346
firstSpecificMatch = parentPath;
334347
break;
335348
}

packages/react-router/src/ReactRouter/utils/viewItemUtils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { ViewItem } from '@ionic/react';
33
/**
44
* Sorts view items by route specificity (most specific first).
55
*
6-
* Sort order aligns with findRouteByRouteInfo in StackManager.tsx:
6+
* Sort order aligns with findViewItemByPath in ReactRouterViewStack.tsx:
77
* 1. Index routes come first
88
* 2. Wildcard-only routes (* or /*) come last
99
* 3. Exact matches (no wildcards/params) come before wildcard/param routes

packages/react-router/test/base/src/pages/relative-paths/RelativePaths.tsx

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ const RelativePathsHome: React.FC = () => {
3939
<IonItem routerLink="/relative-paths/page-b">
4040
<IonLabel>Go to Page B (relative path route)</IonLabel>
4141
</IonItem>
42+
<IonItem routerLink="/relative-paths/unknown-page">
43+
<IonLabel>Go to Unknown Page (catch-all route)</IonLabel>
44+
</IonItem>
4245
</IonList>
4346
</IonContent>
4447
</IonPage>
@@ -85,6 +88,26 @@ const PageB: React.FC = () => {
8588
);
8689
};
8790

91+
const CatchAllPage: React.FC = () => {
92+
return (
93+
<IonPage data-pageid="relative-paths-catch-all">
94+
<IonHeader>
95+
<IonToolbar>
96+
<IonButtons slot="start">
97+
<IonBackButton defaultHref="/relative-paths" />
98+
</IonButtons>
99+
<IonTitle>Not Found</IonTitle>
100+
</IonToolbar>
101+
</IonHeader>
102+
<IonContent>
103+
<div data-testid="catch-all-content">
104+
This page was not found - caught by relative * route
105+
</div>
106+
</IonContent>
107+
</IonPage>
108+
);
109+
};
110+
88111
const RelativePaths: React.FC = () => {
89112
return (
90113
<IonRouterOutlet>
@@ -94,6 +117,9 @@ const RelativePaths: React.FC = () => {
94117
{/* Route with relative path (no leading slash) */}
95118
<Route path="page-b" element={<PageB />} />
96119

120+
{/* Catch-all route - using relative wildcard */}
121+
<Route path="*" element={<CatchAllPage />} />
122+
97123
{/* Home route - using relative path */}
98124
<Route path="" element={<RelativePathsHome />} />
99125
</IonRouterOutlet>

packages/react-router/test/base/tests/e2e/specs/relative-paths.cy.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,4 +41,28 @@ describe('Relative Paths Tests', () => {
4141
cy.ionBackClick('relative-paths-page-b');
4242
cy.ionPageVisible('relative-paths-home');
4343
});
44+
45+
it('should render catch-all * route for unknown paths via navigation', () => {
46+
cy.visit(`http://localhost:${port}/relative-paths`);
47+
cy.ionPageVisible('relative-paths-home');
48+
cy.ionNav('ion-item', 'Go to Unknown Page');
49+
cy.ionPageVisible('relative-paths-catch-all');
50+
cy.ionPageHidden('relative-paths-home');
51+
cy.get('[data-testid="catch-all-content"]').should('contain', 'not found');
52+
});
53+
54+
it('should render catch-all * route for unknown paths via direct URL', () => {
55+
cy.visit(`http://localhost:${port}/relative-paths/some-nonexistent-page`);
56+
cy.ionPageVisible('relative-paths-catch-all');
57+
cy.get('[data-testid="catch-all-content"]').should('contain', 'not found');
58+
});
59+
60+
it('should navigate to catch-all and back to home', () => {
61+
cy.visit(`http://localhost:${port}/relative-paths`);
62+
cy.ionPageVisible('relative-paths-home');
63+
cy.ionNav('ion-item', 'Go to Unknown Page');
64+
cy.ionPageVisible('relative-paths-catch-all');
65+
cy.ionBackClick('relative-paths-catch-all');
66+
cy.ionPageVisible('relative-paths-home');
67+
});
4468
});

0 commit comments

Comments
 (0)