mirror of
https://github.com/ionic-team/ionic-framework.git
synced 2026-03-13 10:22:08 +08:00
fix(react-router): render relative catch-all * routes in nested IonRouterOutlet
This commit is contained in:
@@ -226,6 +226,13 @@ export class ReactRouterViewStack extends ViewStacks {
|
||||
*/
|
||||
private outletParentPaths = new Map<string, string>();
|
||||
|
||||
/**
|
||||
* Stores the computed mount path for each outlet.
|
||||
* Fed back into computeParentPath on subsequent calls to stabilize
|
||||
* the parent path computation across navigations (mirrors StackManager.outletMountPath).
|
||||
*/
|
||||
private outletMountPaths = new Map<string, string>();
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
@@ -440,11 +447,12 @@ export class ReactRouterViewStack extends ViewStacks {
|
||||
viewItem.routeData.match = match;
|
||||
}
|
||||
|
||||
// Deactivate wildcard routes and catch-all routes (empty path) when we have specific route matches
|
||||
// This prevents "Not found" or fallback pages from showing alongside valid routes
|
||||
// Deactivate wildcard (catch-all) and empty-path (default) routes when a more-specific route matches.
|
||||
// This prevents "Not found" or fallback pages from showing alongside valid routes.
|
||||
if (routePath === '*' || routePath === '') {
|
||||
// Check if any other view in this outlet has a match for the current route
|
||||
const hasSpecificMatch = this.getViewItemsForOutlet(viewItem.outletId).some((v) => {
|
||||
const outletViews = this.getViewItemsForOutlet(viewItem.outletId);
|
||||
let hasSpecificMatch = outletViews.some((v) => {
|
||||
if (v.id === viewItem.id) return false; // Skip self
|
||||
const vRoutePath = v.reactElement?.props?.path || '';
|
||||
if (vRoutePath === '*' || vRoutePath === '') return false; // Skip other wildcard/empty routes
|
||||
@@ -454,6 +462,28 @@ export class ReactRouterViewStack extends ViewStacks {
|
||||
return !!vMatch;
|
||||
});
|
||||
|
||||
// For catch-all * routes, also deactivate when the pathname matches the outlet's
|
||||
// parent path exactly. This means there are no remaining segments for the wildcard
|
||||
// to catch, so the empty-path or index route should handle it instead.
|
||||
if (!hasSpecificMatch && routePath === '*') {
|
||||
const outletParentPath = this.outletParentPaths.get(viewItem.outletId);
|
||||
if (outletParentPath) {
|
||||
const normalizedParent = normalizePathnameForComparison(outletParentPath);
|
||||
const normalizedPathname = normalizePathnameForComparison(routeInfo.pathname);
|
||||
if (normalizedPathname === normalizedParent) {
|
||||
// Check if there's an empty-path or index view item that should handle this
|
||||
const hasDefaultRoute = outletViews.some((v) => {
|
||||
if (v.id === viewItem.id) return false;
|
||||
const vRoutePath = v.reactElement?.props?.path;
|
||||
return vRoutePath === '' || vRoutePath === undefined || !!v.routeData?.childProps?.index;
|
||||
});
|
||||
if (hasDefaultRoute) {
|
||||
hasSpecificMatch = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasSpecificMatch) {
|
||||
viewItem.mount = false;
|
||||
if (viewItem.ionPageElement) {
|
||||
@@ -544,35 +574,42 @@ export class ReactRouterViewStack extends ViewStacks {
|
||||
) => {
|
||||
const viewItems = this.getViewItemsForOutlet(outletId);
|
||||
|
||||
// Determine parentPath for nested outlets to properly evaluate index routes
|
||||
// Determine parentPath for outlets with relative or index routes.
|
||||
// This populates outletParentPaths for findViewItemByPath's matchView
|
||||
// and the catch-all deactivation logic in renderViewItem.
|
||||
let parentPath: string | undefined = undefined;
|
||||
try {
|
||||
// Only attempt parent path computation for non-root outlets
|
||||
// Root outlets have IDs like 'routerOutlet' or 'routerOutlet-2'
|
||||
const isRootOutlet = outletId.startsWith('routerOutlet');
|
||||
if (!isRootOutlet) {
|
||||
const routeChildren = extractRouteChildren(ionRouterOutlet.props.children);
|
||||
const { hasRelativeRoutes, hasIndexRoute, hasWildcardRoute } = analyzeRouteChildren(routeChildren);
|
||||
const routeChildren = extractRouteChildren(ionRouterOutlet.props.children);
|
||||
const { hasRelativeRoutes, hasIndexRoute, hasWildcardRoute } = analyzeRouteChildren(routeChildren);
|
||||
|
||||
if (hasRelativeRoutes || hasIndexRoute) {
|
||||
const result = computeParentPath({
|
||||
currentPathname: routeInfo.pathname,
|
||||
outletMountPath: undefined,
|
||||
routeChildren,
|
||||
hasRelativeRoutes,
|
||||
hasIndexRoute,
|
||||
hasWildcardRoute,
|
||||
});
|
||||
parentPath = result.parentPath;
|
||||
if (hasRelativeRoutes || hasIndexRoute) {
|
||||
const result = computeParentPath({
|
||||
currentPathname: routeInfo.pathname,
|
||||
outletMountPath: this.outletMountPaths.get(outletId),
|
||||
routeChildren,
|
||||
hasRelativeRoutes,
|
||||
hasIndexRoute,
|
||||
hasWildcardRoute,
|
||||
});
|
||||
parentPath = result.parentPath;
|
||||
|
||||
// Persist the mount path for subsequent calls, mirroring StackManager.outletMountPath.
|
||||
// Unlike outletParentPaths (cleared when parentPath is undefined), the mount path is
|
||||
// intentionally sticky — it anchors the outlet's scope and is only removed in clear().
|
||||
if (result.outletMountPath && !this.outletMountPaths.has(outletId)) {
|
||||
this.outletMountPaths.set(outletId, result.outletMountPath);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Non-fatal: if we fail to compute parentPath, fall back to previous behavior
|
||||
}
|
||||
|
||||
// Store the computed parentPath for use in findViewItemByPath
|
||||
// Store the computed parentPath for use in findViewItemByPath.
|
||||
// Clear stale entries when parentPath is undefined (e.g., navigated out of scope).
|
||||
if (parentPath !== undefined) {
|
||||
this.outletParentPaths.set(outletId, parentPath);
|
||||
} else if (this.outletParentPaths.has(outletId)) {
|
||||
this.outletParentPaths.delete(outletId);
|
||||
}
|
||||
|
||||
// Sync child elements with stored viewItems (e.g. to reflect new props)
|
||||
@@ -731,6 +768,7 @@ export class ReactRouterViewStack extends ViewStacks {
|
||||
// a wildcard view, it should not be reused for subsequent navigations.
|
||||
// A fresh wildcard view will be created by createViewItem when needed.
|
||||
if ((viewItemPath === '*' || viewItemPath === '/*') && !v.mount) return false;
|
||||
|
||||
const isIndexRoute = !!v.routeData.childProps.index;
|
||||
const previousMatch = v.routeData?.match;
|
||||
const result = v.reactElement ? matchComponent(v.reactElement, pathname) : null;
|
||||
@@ -743,6 +781,21 @@ export class ReactRouterViewStack extends ViewStacks {
|
||||
viewItem = v;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Empty path routes (path="") should match when the pathname matches the
|
||||
// outlet's parent path exactly (no remaining segments). matchComponent doesn't
|
||||
// handle this because it lacks parent path context. Without this check, a
|
||||
// catch-all * view item (which matches any pathname) would be incorrectly
|
||||
// returned instead of the empty path route on back navigation.
|
||||
if (viewItemPath === '' && !isIndexRoute && outletParentPath) {
|
||||
const normalizedParent = normalizePathnameForComparison(outletParentPath);
|
||||
const normalizedPathname = normalizePathnameForComparison(pathname);
|
||||
if (normalizedPathname === normalizedParent) {
|
||||
match = createDefaultMatch(pathname, v.routeData.childProps);
|
||||
viewItem = v;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (result) {
|
||||
@@ -886,6 +939,7 @@ export class ReactRouterViewStack extends ViewStacks {
|
||||
*/
|
||||
clear = (outletId: string) => {
|
||||
this.outletParentPaths.delete(outletId);
|
||||
this.outletMountPaths.delete(outletId);
|
||||
return super.clear(outletId);
|
||||
};
|
||||
|
||||
|
||||
@@ -321,15 +321,28 @@ export const computeParentPath = (options: ComputeParentPathOptions): ParentPath
|
||||
// should catch the full remaining path instead.
|
||||
// Literal routes (e.g., "settings", "redirect") can still match beyond
|
||||
// the wildcard depth to support redirect scenarios.
|
||||
//
|
||||
// Also don't let empty/default path routes (path="" or undefined) drive
|
||||
// the parent deeper than a wildcard match. An empty path route matching
|
||||
// when remainingPath is "" just means all segments were consumed — it's
|
||||
// not a meaningful specific match.
|
||||
const shouldSkipParameterized =
|
||||
(outletMountPath && parentPath.length > outletMountPath.length) ||
|
||||
(!outletMountPath && firstWildcardMatch);
|
||||
if (shouldSkipParameterized) {
|
||||
if (shouldSkipParameterized || firstWildcardMatch) {
|
||||
const matchingRoute = findFirstSpecificMatchingRoute(routeChildren, remainingPath);
|
||||
if (matchingRoute && isPurelyParameterized(matchingRoute.props.path as string)) {
|
||||
continue;
|
||||
if (matchingRoute) {
|
||||
const matchingPath = matchingRoute.props.path as string | undefined;
|
||||
const isEmptyPath = !matchingPath || matchingPath === '';
|
||||
if (shouldSkipParameterized && (isPurelyParameterized(matchingPath as string) || isEmptyPath)) {
|
||||
continue;
|
||||
}
|
||||
if (firstWildcardMatch && isEmptyPath) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
firstSpecificMatch = parentPath;
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { ViewItem } from '@ionic/react';
|
||||
/**
|
||||
* Sorts view items by route specificity (most specific first).
|
||||
*
|
||||
* Sort order aligns with findRouteByRouteInfo in StackManager.tsx:
|
||||
* Sort order aligns with findViewItemByPath in ReactRouterViewStack.tsx:
|
||||
* 1. Index routes come first
|
||||
* 2. Wildcard-only routes (* or /*) come last
|
||||
* 3. Exact matches (no wildcards/params) come before wildcard/param routes
|
||||
|
||||
@@ -39,6 +39,9 @@ const RelativePathsHome: React.FC = () => {
|
||||
<IonItem routerLink="/relative-paths/page-b">
|
||||
<IonLabel>Go to Page B (relative path route)</IonLabel>
|
||||
</IonItem>
|
||||
<IonItem routerLink="/relative-paths/unknown-page">
|
||||
<IonLabel>Go to Unknown Page (catch-all route)</IonLabel>
|
||||
</IonItem>
|
||||
</IonList>
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
@@ -85,6 +88,26 @@ const PageB: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const CatchAllPage: React.FC = () => {
|
||||
return (
|
||||
<IonPage data-pageid="relative-paths-catch-all">
|
||||
<IonHeader>
|
||||
<IonToolbar>
|
||||
<IonButtons slot="start">
|
||||
<IonBackButton defaultHref="/relative-paths" />
|
||||
</IonButtons>
|
||||
<IonTitle>Not Found</IonTitle>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
<IonContent>
|
||||
<div data-testid="catch-all-content">
|
||||
This page was not found - caught by relative * route
|
||||
</div>
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
};
|
||||
|
||||
const RelativePaths: React.FC = () => {
|
||||
return (
|
||||
<IonRouterOutlet>
|
||||
@@ -94,6 +117,9 @@ const RelativePaths: React.FC = () => {
|
||||
{/* Route with relative path (no leading slash) */}
|
||||
<Route path="page-b" element={<PageB />} />
|
||||
|
||||
{/* Catch-all route - using relative wildcard */}
|
||||
<Route path="*" element={<CatchAllPage />} />
|
||||
|
||||
{/* Home route - using relative path */}
|
||||
<Route path="" element={<RelativePathsHome />} />
|
||||
</IonRouterOutlet>
|
||||
|
||||
@@ -41,4 +41,28 @@ describe('Relative Paths Tests', () => {
|
||||
cy.ionBackClick('relative-paths-page-b');
|
||||
cy.ionPageVisible('relative-paths-home');
|
||||
});
|
||||
|
||||
it('should render catch-all * route for unknown paths via navigation', () => {
|
||||
cy.visit(`http://localhost:${port}/relative-paths`);
|
||||
cy.ionPageVisible('relative-paths-home');
|
||||
cy.ionNav('ion-item', 'Go to Unknown Page');
|
||||
cy.ionPageVisible('relative-paths-catch-all');
|
||||
cy.ionPageHidden('relative-paths-home');
|
||||
cy.get('[data-testid="catch-all-content"]').should('contain', 'not found');
|
||||
});
|
||||
|
||||
it('should render catch-all * route for unknown paths via direct URL', () => {
|
||||
cy.visit(`http://localhost:${port}/relative-paths/some-nonexistent-page`);
|
||||
cy.ionPageVisible('relative-paths-catch-all');
|
||||
cy.get('[data-testid="catch-all-content"]').should('contain', 'not found');
|
||||
});
|
||||
|
||||
it('should navigate to catch-all and back to home', () => {
|
||||
cy.visit(`http://localhost:${port}/relative-paths`);
|
||||
cy.ionPageVisible('relative-paths-home');
|
||||
cy.ionNav('ion-item', 'Go to Unknown Page');
|
||||
cy.ionPageVisible('relative-paths-catch-all');
|
||||
cy.ionBackClick('relative-paths-catch-all');
|
||||
cy.ionPageVisible('relative-paths-home');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user