From 4fb7fad2abee2ce801376a0dba9254c134c52ebc Mon Sep 17 00:00:00 2001 From: ShaneK Date: Wed, 11 Mar 2026 13:28:53 -0700 Subject: [PATCH] fix(react-router): render relative catch-all * routes in nested IonRouterOutlet --- .../src/ReactRouter/ReactRouterViewStack.tsx | 96 +++++++++++++++---- .../ReactRouter/utils/computeParentPath.ts | 19 +++- .../src/ReactRouter/utils/viewItemUtils.ts | 2 +- .../pages/relative-paths/RelativePaths.tsx | 26 +++++ .../base/tests/e2e/specs/relative-paths.cy.js | 24 +++++ 5 files changed, 142 insertions(+), 25 deletions(-) diff --git a/packages/react-router/src/ReactRouter/ReactRouterViewStack.tsx b/packages/react-router/src/ReactRouter/ReactRouterViewStack.tsx index efb57b96c3..35e80fa5f6 100644 --- a/packages/react-router/src/ReactRouter/ReactRouterViewStack.tsx +++ b/packages/react-router/src/ReactRouter/ReactRouterViewStack.tsx @@ -226,6 +226,13 @@ export class ReactRouterViewStack extends ViewStacks { */ private outletParentPaths = new Map(); + /** + * 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(); + 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); }; diff --git a/packages/react-router/src/ReactRouter/utils/computeParentPath.ts b/packages/react-router/src/ReactRouter/utils/computeParentPath.ts index b36a150eb3..5af01a3e64 100644 --- a/packages/react-router/src/ReactRouter/utils/computeParentPath.ts +++ b/packages/react-router/src/ReactRouter/utils/computeParentPath.ts @@ -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; } diff --git a/packages/react-router/src/ReactRouter/utils/viewItemUtils.ts b/packages/react-router/src/ReactRouter/utils/viewItemUtils.ts index 6ded6d44be..4b212fe1bb 100644 --- a/packages/react-router/src/ReactRouter/utils/viewItemUtils.ts +++ b/packages/react-router/src/ReactRouter/utils/viewItemUtils.ts @@ -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 diff --git a/packages/react-router/test/base/src/pages/relative-paths/RelativePaths.tsx b/packages/react-router/test/base/src/pages/relative-paths/RelativePaths.tsx index 73d2fe8f49..3940328444 100644 --- a/packages/react-router/test/base/src/pages/relative-paths/RelativePaths.tsx +++ b/packages/react-router/test/base/src/pages/relative-paths/RelativePaths.tsx @@ -39,6 +39,9 @@ const RelativePathsHome: React.FC = () => { Go to Page B (relative path route) + + Go to Unknown Page (catch-all route) + @@ -85,6 +88,26 @@ const PageB: React.FC = () => { ); }; +const CatchAllPage: React.FC = () => { + return ( + + + + + + + Not Found + + + +
+ This page was not found - caught by relative * route +
+
+
+ ); +}; + const RelativePaths: React.FC = () => { return ( @@ -94,6 +117,9 @@ const RelativePaths: React.FC = () => { {/* Route with relative path (no leading slash) */} } /> + {/* Catch-all route - using relative wildcard */} + } /> + {/* Home route - using relative path */} } /> diff --git a/packages/react-router/test/base/tests/e2e/specs/relative-paths.cy.js b/packages/react-router/test/base/tests/e2e/specs/relative-paths.cy.js index b88e061c59..4491636fbc 100644 --- a/packages/react-router/test/base/tests/e2e/specs/relative-paths.cy.js +++ b/packages/react-router/test/base/tests/e2e/specs/relative-paths.cy.js @@ -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'); + }); });