From 71e55addf97acbdb028b4c7ae51b68d25baaf1bb Mon Sep 17 00:00:00 2001 From: ShaneK Date: Tue, 2 Dec 2025 07:49:53 -0800 Subject: [PATCH] chore(react-router): refactor --- .../src/ReactRouter/ReactRouterViewStack.tsx | 188 +-- .../src/ReactRouter/StackManager.tsx | 1192 +++++++---------- .../ReactRouter/utils/computeParentPath.ts | 274 ++++ ...findRoutesNode.ts => getRoutesChildren.ts} | 4 +- .../utils/matchRoutesFromChildren.ts | 168 --- .../src/ReactRouter/utils/normalizePath.ts | 37 + .../src/ReactRouter/utils/routeUtils.ts | 43 + 7 files changed, 887 insertions(+), 1019 deletions(-) create mode 100644 packages/react-router/src/ReactRouter/utils/computeParentPath.ts rename packages/react-router/src/ReactRouter/utils/{findRoutesNode.ts => getRoutesChildren.ts} (79%) delete mode 100644 packages/react-router/src/ReactRouter/utils/matchRoutesFromChildren.ts create mode 100644 packages/react-router/src/ReactRouter/utils/normalizePath.ts create mode 100644 packages/react-router/src/ReactRouter/utils/routeUtils.ts diff --git a/packages/react-router/src/ReactRouter/ReactRouterViewStack.tsx b/packages/react-router/src/ReactRouter/ReactRouterViewStack.tsx index 9b686cfcba..9ab0e4502d 100644 --- a/packages/react-router/src/ReactRouter/ReactRouterViewStack.tsx +++ b/packages/react-router/src/ReactRouter/ReactRouterViewStack.tsx @@ -9,11 +9,25 @@ import type { RouteInfo, ViewItem } from '@ionic/react'; import { IonRoute, ViewLifeCycleManager, ViewStacks } from '@ionic/react'; import React from 'react'; import type { PathMatch } from 'react-router'; -import { Navigate, Route, UNSAFE_RouteContext as RouteContext } from 'react-router-dom'; +import { Navigate, UNSAFE_RouteContext as RouteContext } from 'react-router-dom'; +import { analyzeRouteChildren, computeParentPath, extractRouteChildren } from './utils/computeParentPath'; import { derivePathnameToMatch } from './utils/derivePathnameToMatch'; -import { findRoutesNode } from './utils/findRoutesNode'; import { matchPath } from './utils/matchPath'; +import { normalizePathnameForComparison } from './utils/normalizePath'; +import { isNavigateElement, sortViewsBySpecificity } from './utils/routeUtils'; + +/** + * Delay in milliseconds before removing a Navigate view item after a redirect. + * This ensures the redirect navigation completes before the view is removed. + */ +const NAVIGATE_REDIRECT_DELAY_MS = 100; + +/** + * Delay in milliseconds before cleaning up a view without an IonPage element. + * This double-checks that the view is truly not needed before removal. + */ +const VIEW_CLEANUP_DELAY_MS = 200; const createDefaultMatch = ( fullPathname: string, @@ -37,22 +51,6 @@ const createDefaultMatch = ( }; }; -const ensureLeadingSlash = (value: string): string => { - if (value === '') { - return '/'; - } - return value.startsWith('/') ? value : `/${value}`; -}; - -const normalizePathnameForComparison = (value: string | undefined): string => { - if (!value || value === '') { - return '/'; - } - const withLeadingSlash = ensureLeadingSlash(value); - return withLeadingSlash.length > 1 && withLeadingSlash.endsWith('/') - ? withLeadingSlash.slice(0, -1) - : withLeadingSlash; -}; const computeRelativeToParent = (pathname: string, parentPath?: string): string | null => { if (!parentPath) return null; @@ -127,9 +125,11 @@ export class ReactRouterViewStack extends ViewStacks { const newIsIndexRoute = !!reactElement.props.index; // For Navigate components, match by destination - if (existingElement?.type?.name === 'Navigate' && newElement?.type?.name === 'Navigate') { - const existingTo = existingElement.props?.to; - const newTo = newElement.props?.to; + const existingIsNavigate = React.isValidElement(existingElement) && existingElement.type === Navigate; + const newIsNavigate = React.isValidElement(newElement) && newElement.type === Navigate; + if (existingIsNavigate && newIsNavigate) { + const existingTo = (existingElement.props as { to?: string })?.to; + const newTo = (newElement.props as { to?: string })?.to; if (existingTo === newTo) { return true; } @@ -245,10 +245,7 @@ export class ReactRouterViewStack extends ViewStacks { // Special handling for Navigate components - they should unmount after redirecting const elementComponent = viewItem.reactElement?.props?.element; - const isNavigateComponent = - React.isValidElement(elementComponent) && - (elementComponent.type === Navigate || - (typeof elementComponent.type === 'function' && elementComponent.type.name === 'Navigate')); + const isNavigateComponent = isNavigateElement(elementComponent); if (isNavigateComponent) { // Navigate components should only be mounted when they match @@ -266,7 +263,7 @@ export class ReactRouterViewStack extends ViewStacks { // This ensures the redirect completes before removal setTimeout(() => { this.remove(viewItem); - }, 100); + }, NAVIGATE_REDIRECT_DELAY_MS); } } @@ -293,7 +290,7 @@ export class ReactRouterViewStack extends ViewStacks { if (stillNotNeeded) { this.remove(viewItem); } - }, 200); + }, VIEW_CLEANUP_DELAY_MS); } else { // Preserve it but unmount it for now viewItem.mount = false; @@ -444,107 +441,19 @@ export class ReactRouterViewStack extends ViewStacks { try { // Only attempt parent path computation for non-root outlets if (outletId !== 'routerOutlet') { - const routesNode = findRoutesNode(ionRouterOutlet.props.children) ?? ionRouterOutlet.props.children; - const routeChildren = React.Children.toArray(routesNode).filter( - (child): child is React.ReactElement => React.isValidElement(child) && child.type === Route - ); - - const hasRelativeRoutes = routeChildren.some((route) => { - const path = (route.props as any).path as string | undefined; - return path && !path.startsWith('/') && path !== '*'; - }); - const hasIndexRoute = routeChildren.some((route) => !!(route.props as any).index); + const routeChildren = extractRouteChildren(ionRouterOutlet.props.children); + const { hasRelativeRoutes, hasIndexRoute, hasWildcardRoute } = analyzeRouteChildren(routeChildren); if (hasRelativeRoutes || hasIndexRoute) { - const segments = routeInfo.pathname.split('/').filter(Boolean); - - // Two-pass algorithm: - // Pass 1: Look for specific route matches OR index routes (prefer real routes) - // Pass 2: If no match found, use wildcard fallback - // - // Key insight: Index routes should match when remaining is empty at the longest - // valid parent path. Wildcards should only be used when no specific/index match exists. - - let wildcardFallbackPath: string | undefined = undefined; - - // Pass 1: Look for specific or index matches, tracking wildcard fallback - for (let i = 1; i <= segments.length; i++) { - const testParentPath = '/' + segments.slice(0, i).join('/'); - const testRemainingPath = segments.slice(i).join('/'); - - // Check for specific (non-wildcard, non-index) route matches - const hasSpecificMatch = routeChildren.some((route) => { - const props = route.props as any; - const routePath = props.path as string | undefined; - const isIndex = !!props.index; - const isWildcardOnly = routePath === '*' || routePath === '/*'; - - if (isIndex || isWildcardOnly) { - return false; - } - - const m = matchPath({ pathname: testRemainingPath, componentProps: props }); - return !!m; - }); - - if (hasSpecificMatch) { - parentPath = testParentPath; - break; - } - - // Check for index match (only when remaining is empty AND no wildcard fallback) - // If we already found a wildcard fallback at a shorter path, it means - // the remaining path at that level didn't match any routes, so the - // index match at this longer path is not valid. - if (!wildcardFallbackPath && (testRemainingPath === '' || testRemainingPath === '/')) { - const hasIndexMatch = routeChildren.some((route) => !!(route.props as any).index); - if (hasIndexMatch) { - parentPath = testParentPath; - break; - } - } - - // Track wildcard fallback at first level where remaining is non-empty - // and no specific route could even START to match the remaining path - if (!wildcardFallbackPath && testRemainingPath !== '' && testRemainingPath !== '/') { - const hasWildcard = routeChildren.some((route) => { - const routePath = (route.props as any).path; - return routePath === '*' || routePath === '/*'; - }); - - if (hasWildcard) { - // Check if any specific route could plausibly match this remaining path - // by checking if the first segment overlaps with any route's first segment - const remainingFirstSegment = testRemainingPath.split('/')[0]; - const couldAnyRouteMatch = routeChildren.some((route) => { - const props = route.props as any; - const routePath = props.path as string | undefined; - if (!routePath || routePath === '*' || routePath === '/*') return false; - if (props.index) return false; - - // Get the route's first segment (before any / or *) - const routeFirstSegment = routePath.split('/')[0].replace(/[*:]/g, ''); - if (!routeFirstSegment) return false; - - // Check for prefix overlap (either direction) - return ( - routeFirstSegment.startsWith(remainingFirstSegment.slice(0, 3)) || - remainingFirstSegment.startsWith(routeFirstSegment.slice(0, 3)) - ); - }); - - // Only save wildcard fallback if no specific route could match - if (!couldAnyRouteMatch) { - wildcardFallbackPath = testParentPath; - } - } - } - } - - // Pass 2: If no specific/index match found, use wildcard fallback - if (!parentPath && wildcardFallbackPath) { - parentPath = wildcardFallbackPath; - } + const result = computeParentPath({ + currentPathname: routeInfo.pathname, + outletMountPath: undefined, + routeChildren, + hasRelativeRoutes, + hasIndexRoute, + hasWildcardRoute, + }); + parentPath = result.parentPath; } } } catch (e) { @@ -580,10 +489,7 @@ export class ReactRouterViewStack extends ViewStacks { // and triggering unwanted redirects const renderableViewItems = uniqueViewItems.filter((viewItem) => { const elementComponent = viewItem.reactElement?.props?.element; - const isNavigateComponent = - React.isValidElement(elementComponent) && - (elementComponent.type === Navigate || - (typeof elementComponent.type === 'function' && elementComponent.type.name === 'Navigate')); + const isNavigateComponent = isNavigateElement(elementComponent); // Exclude unmounted Navigate components from rendering if (isNavigateComponent && !viewItem.mount) { @@ -675,30 +581,12 @@ export class ReactRouterViewStack extends ViewStacks { let match: PathMatch | null = null; let viewStack: ViewItem[]; - // Helper function to sort views by specificity (most specific first) - const sortBySpecificity = (views: ViewItem[]) => { - return [...views].sort((a, b) => { - const pathA = a.routeData.childProps.path || ''; - const pathB = b.routeData.childProps.path || ''; - - // Exact matches (no wildcards/params) come first - const aHasWildcard = pathA.includes('*') || pathA.includes(':'); - const bHasWildcard = pathB.includes('*') || pathB.includes(':'); - - if (!aHasWildcard && bHasWildcard) return -1; - if (aHasWildcard && !bHasWildcard) return 1; - - // Among wildcard routes, longer paths are more specific - return pathB.length - pathA.length; - }); - }; - if (outletId) { - viewStack = sortBySpecificity(this.getViewItemsForOutlet(outletId)); + viewStack = sortViewsBySpecificity(this.getViewItemsForOutlet(outletId)); viewStack.some(matchView); if (!viewItem && allowDefaultMatch) viewStack.some(matchDefaultRoute); } else { - const viewItems = sortBySpecificity(this.getAllViewItems()); + const viewItems = sortViewsBySpecificity(this.getAllViewItems()); viewItems.some(matchView); if (!viewItem && allowDefaultMatch) viewItems.some(matchDefaultRoute); } diff --git a/packages/react-router/src/ReactRouter/StackManager.tsx b/packages/react-router/src/ReactRouter/StackManager.tsx index 41803125b1..7c8f43ce3a 100644 --- a/packages/react-router/src/ReactRouter/StackManager.tsx +++ b/packages/react-router/src/ReactRouter/StackManager.tsx @@ -7,84 +7,62 @@ import type { RouteInfo, StackContextState, ViewItem } from '@ionic/react'; import { RouteManagerContext, StackContext, generateId, getConfig } from '@ionic/react'; import React from 'react'; -import { Navigate, Route } from 'react-router-dom'; +import { Route } from 'react-router-dom'; import { clonePageElement } from './clonePageElement'; +import { + analyzeRouteChildren, + computeCommonPrefix, + computeParentPath, + extractRouteChildren, +} from './utils/computeParentPath'; import { derivePathnameToMatch } from './utils/derivePathnameToMatch'; -import { findRoutesNode } from './utils/findRoutesNode'; +import { getRoutesChildren } from './utils/getRoutesChildren'; import { matchPath } from './utils/matchPath'; +import { stripTrailingSlash } from './utils/normalizePath'; +import { isNavigateElement } from './utils/routeUtils'; /** - * Checks if a route is a specific match (not wildcard or index). + * Delay in milliseconds before unmounting a view after a transition completes. + * This ensures the page transition animation finishes before the view is removed. */ -const isSpecificRouteMatch = (route: React.ReactElement, remainingPath: string) => { - const routePath = route.props.path; - const isWildcardOnly = routePath === '*' || routePath === '/*'; - const isIndex = route.props.index; +const VIEW_UNMOUNT_DELAY_MS = 250; - // Skip wildcards and index routes - if (isIndex || isWildcardOnly) { - return false; - } - - return !!matchPath({ - pathname: remainingPath, - componentProps: route.props, - }); -}; - -// TODO(FW-2959): types +/** + * Delay in milliseconds to wait for an IonPage element to be mounted before + * proceeding with a page transition. + */ +const ION_PAGE_WAIT_TIMEOUT_MS = 50; interface StackManagerProps { routeInfo: RouteInfo; id?: string; } -// eslint-disable-next-line @typescript-eslint/no-empty-interface -interface StackManagerState {} - const isViewVisible = (el: HTMLElement) => !el.classList.contains('ion-page-invisible') && !el.classList.contains('ion-page-hidden'); /** - * Finds the longest common prefix among an array of paths. - * Used to determine the scope of an outlet with absolute routes. + * Hides an ion-page element by adding hidden class and aria attribute. */ -const computeCommonPrefix = (paths: string[]): string => { - if (paths.length === 0) return ''; - if (paths.length === 1) { - // For a single path, extract the directory-like prefix - // e.g., /dynamic-routes/home -> /dynamic-routes - const segments = paths[0].split('/').filter(Boolean); - if (segments.length > 1) { - return '/' + segments.slice(0, -1).join('/'); - } - return '/' + segments[0]; +const hideIonPageElement = (element: HTMLElement | undefined): void => { + if (element) { + element.classList.add('ion-page-hidden'); + element.setAttribute('aria-hidden', 'true'); } - - // Split all paths into segments - const segmentArrays = paths.map((p) => p.split('/').filter(Boolean)); - const minLength = Math.min(...segmentArrays.map((s) => s.length)); - - const commonSegments: string[] = []; - for (let i = 0; i < minLength; i++) { - const segment = segmentArrays[0][i]; - // Skip segments with route parameters or wildcards - if (segment.includes(':') || segment.includes('*')) { - break; - } - const allMatch = segmentArrays.every((s) => s[i] === segment); - if (allMatch) { - commonSegments.push(segment); - } else { - break; - } - } - - return commonSegments.length > 0 ? '/' + commonSegments.join('/') : ''; }; -export class StackManager extends React.PureComponent { +/** + * Shows an ion-page element by removing hidden class and aria attribute. + */ +const showIonPageElement = (element: HTMLElement | undefined): void => { + if (element) { + element.classList.remove('ion-page-hidden'); + element.removeAttribute('aria-hidden'); + } +}; + +export class StackManager extends React.PureComponent { id: string; // Unique id for the router outlet aka outletId context!: React.ContextType; ionRouterOutlet?: React.ReactElement; @@ -141,150 +119,377 @@ export class StackManager extends React.PureComponent React.isValidElement(child) && child.type === Route - ); + const routeChildren = extractRouteChildren(this.ionRouterOutlet.props.children); + const { hasRelativeRoutes, hasIndexRoute, hasWildcardRoute } = analyzeRouteChildren(routeChildren); - const hasRelativeRoutes = routeChildren.some((route) => { - const path = route.props.path; - const isRelative = path && !path.startsWith('/') && path !== '*'; - return isRelative; + const result = computeParentPath({ + currentPathname, + outletMountPath: this.outletMountPath, + routeChildren, + hasRelativeRoutes, + hasIndexRoute, + hasWildcardRoute, }); - const hasIndexRoute = routeChildren.some((route) => route.props.index); - const hasWildcardRoute = routeChildren.some((route) => { - const routePath = route.props.path; - return routePath === '*' || routePath === '/*'; - }); - - if ((hasRelativeRoutes || hasIndexRoute) && currentPathname.includes('/')) { - const segments = currentPathname.split('/').filter(Boolean); - - if (segments.length >= 1) { - // Find matches at each level, keeping track of the FIRST (shortest) match - let firstSpecificMatch: string | undefined = undefined; - let firstWildcardMatch: string | undefined = undefined; - let indexMatchAtMount: string | undefined = undefined; - - 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)); - if (hasSpecificMatch && !firstSpecificMatch) { - firstSpecificMatch = parentPath; - // Found a specific match - this is our answer for non-index routes - break; - } - - // Check if wildcard would match this remaining path - // Only if remaining is non-empty (wildcard needs something to match) - if (remainingPath !== '' && remainingPath !== '/' && hasWildcardRoute && !firstWildcardMatch) { - // Check if any specific route could plausibly match this remaining path - const remainingFirstSegment = remainingPath.split('/')[0]; - const couldAnyRouteMatch = routeChildren.some((route) => { - const routePath = route.props.path as string | undefined; - if (!routePath || routePath === '*' || routePath === '/*') return false; - if (route.props.index) return false; - - const routeFirstSegment = routePath.split('/')[0].replace(/[*:]/g, ''); - if (!routeFirstSegment) return false; - - // Check for prefix overlap (either direction) - return ( - routeFirstSegment.startsWith(remainingFirstSegment.slice(0, 3)) || - remainingFirstSegment.startsWith(routeFirstSegment.slice(0, 3)) - ); - }); - - // Only save wildcard match if no specific route could match - if (!couldAnyRouteMatch) { - firstWildcardMatch = parentPath; - // Continue looking - might find a specific match at a longer path - } - } - - // Check for index route match when remaining path is empty - // BUT only at the outlet's mount path level - if ((remainingPath === '' || remainingPath === '/') && hasIndexRoute) { - // Index route matches when current path exactly matches the mount path - // If we already have an outletMountPath, index should only match there - if (this.outletMountPath) { - if (parentPath === this.outletMountPath) { - indexMatchAtMount = parentPath; - } - } else { - // No mount path set yet - index would establish this as mount path - // But only if we haven't found a better match - indexMatchAtMount = parentPath; - } - } - } - - // Determine the best parent path: - // 1. Specific match (routes like tabs/*, favorites) - highest priority - // 2. Wildcard match (route path="*") - catches unmatched segments - // 3. Index match - only valid at the outlet's mount point, not deeper - let bestPath: string | undefined = undefined; - - if (firstSpecificMatch) { - bestPath = firstSpecificMatch; - } else if (firstWildcardMatch) { - bestPath = firstWildcardMatch; - } else if (indexMatchAtMount) { - // Only use index match if no specific or wildcard matched - // This handles the case where pathname exactly matches the mount path - bestPath = indexMatchAtMount; - } - - // Store the mount path when we first successfully match a route - if (!this.outletMountPath && bestPath) { - this.outletMountPath = bestPath; - } - - // If we have a mount path, verify the current pathname is within scope - if (this.outletMountPath && !currentPathname.startsWith(this.outletMountPath)) { - return undefined; - } - - return bestPath; - } + // Update the outlet mount path if it was set + if (result.outletMountPath && !this.outletMountPath) { + this.outletMountPath = result.outletMountPath; } - // Handle outlets with ONLY absolute routes (no relative routes or index routes) - // Compute the common prefix of all absolute routes to determine the outlet's scope - if (!hasRelativeRoutes && !hasIndexRoute) { - const absolutePathRoutes = routeChildren.filter((route) => { - const path = route.props.path; - return path && path.startsWith('/'); - }); - - if (absolutePathRoutes.length > 0) { - const absolutePaths = absolutePathRoutes.map((r) => r.props.path as string); - const commonPrefix = computeCommonPrefix(absolutePaths); - - if (commonPrefix && commonPrefix !== '/') { - // Set the mount path based on common prefix of absolute routes - if (!this.outletMountPath) { - this.outletMountPath = commonPrefix; - } - - // Check if current pathname is within scope - if (!currentPathname.startsWith(commonPrefix)) { - return undefined; - } - - return commonPrefix; - } - } - } + return result.parentPath; } return this.outletMountPath; } + /** + * Finds the entering and leaving view items for a route transition, + * handling special redirect cases. + */ + private findViewItems(routeInfo: RouteInfo): { + enteringViewItem: ViewItem | undefined; + leavingViewItem: ViewItem | undefined; + } { + let enteringViewItem = this.context.findViewItemByRouteInfo(routeInfo, this.id); + let leavingViewItem = this.context.findLeavingViewItemByRouteInfo(routeInfo, this.id); + + // If we don't have a leaving view item, but the route info indicates + // that the user has routed from a previous path, then the leaving view + // can be found by the last known pathname. + if (!leavingViewItem && routeInfo.prevRouteLastPathname) { + leavingViewItem = this.context.findViewItemByPathname(routeInfo.prevRouteLastPathname, this.id); + } + + // Special case for redirects: When a redirect happens inside a nested route, + // the entering and leaving view might be the same (the container route like tabs/*). + // In this case, we need to look at prevRouteLastPathname to find the actual + // view we're transitioning away from. + if ( + enteringViewItem && + leavingViewItem && + enteringViewItem === leavingViewItem && + routeInfo.routeAction === 'replace' && + routeInfo.prevRouteLastPathname + ) { + const actualLeavingView = this.context.findViewItemByPathname(routeInfo.prevRouteLastPathname, this.id); + if (actualLeavingView && actualLeavingView !== enteringViewItem) { + leavingViewItem = actualLeavingView; + } + } + + // Also check if we're in a redirect scenario where entering and leaving are different + // but we still need to handle the actual previous view. + if ( + enteringViewItem && + !leavingViewItem && + routeInfo.routeAction === 'replace' && + routeInfo.prevRouteLastPathname + ) { + const actualLeavingView = this.context.findViewItemByPathname(routeInfo.prevRouteLastPathname, this.id); + if (actualLeavingView && actualLeavingView !== enteringViewItem) { + leavingViewItem = actualLeavingView; + } + } + + return { enteringViewItem, leavingViewItem }; + } + + /** + * Determines if the leaving view item should be unmounted after a transition. + */ + private shouldUnmountLeavingView( + routeInfo: RouteInfo, + enteringViewItem: ViewItem | undefined, + leavingViewItem: ViewItem | undefined + ): boolean { + if (!leavingViewItem) { + return false; + } + + if (routeInfo.routeAction === 'replace') { + return true; + } + + const isForwardPush = routeInfo.routeAction === 'push' && (routeInfo as any).routeDirection === 'forward'; + if (!isForwardPush && routeInfo.routeDirection !== 'none' && enteringViewItem !== leavingViewItem) { + return true; + } + + return false; + } + + /** + * Handles the case when the outlet is out of scope (current route is outside mount path). + * Returns true if the transition should be aborted. + */ + private handleOutOfScopeOutlet(routeInfo: RouteInfo): boolean { + if (!this.outletMountPath || routeInfo.pathname.startsWith(this.outletMountPath)) { + return false; + } + + // Clear any pending unmount timeout to avoid conflicts + if (this.outOfScopeUnmountTimeout) { + clearTimeout(this.outOfScopeUnmountTimeout); + this.outOfScopeUnmountTimeout = undefined; + } + + // When an outlet is out of scope, unmount its views immediately + const allViewsInOutlet = this.context.getViewItemsForOutlet ? this.context.getViewItemsForOutlet(this.id) : []; + + // Unmount and remove all views in this outlet immediately to avoid leftover content + allViewsInOutlet.forEach((viewItem) => { + hideIonPageElement(viewItem.ionPageElement); + this.context.unMountViewItem(viewItem); + }); + + this.forceUpdate(); + return true; + } + + /** + * Handles the case when this is a nested outlet with relative routes but no valid parent path. + * Returns true if the transition should be aborted. + */ + private handleOutOfContextNestedOutlet( + parentPath: string | undefined, + leavingViewItem: ViewItem | undefined + ): boolean { + if (this.id === 'routerOutlet' || parentPath !== undefined || !this.ionRouterOutlet) { + return false; + } + + const routesChildren = getRoutesChildren(this.ionRouterOutlet.props.children) ?? this.ionRouterOutlet.props.children; + const routeChildren = React.Children.toArray(routesChildren).filter( + (child): child is React.ReactElement => React.isValidElement(child) && child.type === Route + ); + + const hasRelativeRoutes = routeChildren.some((route) => { + const path = route.props.path; + return path && !path.startsWith('/') && path !== '*'; + }); + + if (hasRelativeRoutes) { + // Hide any visible views in this outlet since it's out of scope + hideIonPageElement(leavingViewItem?.ionPageElement); + if (leavingViewItem) { + leavingViewItem.mount = false; + } + this.forceUpdate(); + return true; + } + + return false; + } + + /** + * Handles the case when a nested outlet has no matching route. + * Returns true if the transition should be aborted. + */ + private handleNoMatchingRoute( + enteringRoute: React.ReactElement | undefined, + enteringViewItem: ViewItem | undefined, + leavingViewItem: ViewItem | undefined + ): boolean { + if (this.id === 'routerOutlet' || enteringRoute || enteringViewItem) { + return false; + } + + // Hide any visible views in this outlet since it has no matching route + hideIonPageElement(leavingViewItem?.ionPageElement); + if (leavingViewItem) { + leavingViewItem.mount = false; + } + this.forceUpdate(); + return true; + } + + /** + * Handles the transition when entering view item has an ion-page element ready. + */ + private handleReadyEnteringView( + routeInfo: RouteInfo, + enteringViewItem: ViewItem, + leavingViewItem: ViewItem | undefined, + shouldUnmountLeavingViewItem: boolean + ): void { + // Ensure the entering view is not hidden from previous navigations + showIonPageElement(enteringViewItem.ionPageElement); + + // Handle same view item case (e.g., parameterized route changes) + if (enteringViewItem === leavingViewItem) { + const routePath = enteringViewItem.reactElement?.props?.path as string | undefined; + const isParameterizedRoute = routePath ? routePath.includes(':') : false; + + if (isParameterizedRoute) { + // Refresh match metadata so the component receives updated params + const updatedMatch = matchComponent(enteringViewItem.reactElement, routeInfo.pathname, true); + if (updatedMatch) { + enteringViewItem.routeData.match = updatedMatch; + } + + const enteringEl = enteringViewItem.ionPageElement; + if (enteringEl) { + enteringEl.classList.remove('ion-page-hidden', 'ion-page-invisible'); + enteringEl.removeAttribute('aria-hidden'); + } + + this.forceUpdate(); + return; + } + } + + // Try to find leaving view using prev route info if still not found + if (!leavingViewItem && this.props.routeInfo.prevRouteLastPathname) { + leavingViewItem = this.context.findViewItemByPathname(this.props.routeInfo.prevRouteLastPathname, this.id); + } + + // Skip transition if entering view is visible and leaving view is not + if ( + enteringViewItem.ionPageElement && + isViewVisible(enteringViewItem.ionPageElement) && + leavingViewItem !== undefined && + leavingViewItem.ionPageElement && + !isViewVisible(leavingViewItem.ionPageElement) + ) { + return; + } + + // Check for duplicate transition + const currentTransition = { + enteringId: enteringViewItem.id, + leavingId: leavingViewItem?.id, + }; + + if ( + leavingViewItem && + this.lastTransition && + this.lastTransition.leavingId && + this.lastTransition.enteringId === currentTransition.enteringId && + this.lastTransition.leavingId === currentTransition.leavingId + ) { + return; + } + + this.lastTransition = currentTransition; + this.transitionPage(routeInfo, enteringViewItem, leavingViewItem); + + // Handle unmounting the leaving view + if (shouldUnmountLeavingViewItem && leavingViewItem && enteringViewItem !== leavingViewItem) { + leavingViewItem.mount = false; + this.handleLeavingViewUnmount(routeInfo, enteringViewItem, leavingViewItem); + } + } + + /** + * Handles the delayed unmount of the leaving view item after a replace action. + */ + private handleLeavingViewUnmount( + routeInfo: RouteInfo, + enteringViewItem: ViewItem, + leavingViewItem: ViewItem + ): void { + if (routeInfo.routeAction !== 'replace' || !leavingViewItem.ionPageElement) { + return; + } + + // Check if we should skip removal for nested outlet redirects + const enteringRoutePath = enteringViewItem.reactElement?.props?.path as string | undefined; + const leavingRoutePath = leavingViewItem.reactElement?.props?.path as string | undefined; + const isEnteringContainerRoute = enteringRoutePath && enteringRoutePath.endsWith('/*'); + const isLeavingSpecificRoute = + leavingRoutePath && + leavingRoutePath !== '' && + leavingRoutePath !== '*' && + !leavingRoutePath.endsWith('/*') && + !leavingViewItem.reactElement?.props?.index; + + // Skip removal only for container-to-container transitions + if (isEnteringContainerRoute && !isLeavingSpecificRoute) { + return; + } + + const viewToUnmount = leavingViewItem; + setTimeout(() => { + this.context.unMountViewItem(viewToUnmount); + }, VIEW_UNMOUNT_DELAY_MS); + } + + /** + * Handles the case when entering view has no ion-page element yet (waiting for render). + */ + private handleWaitingForIonPage( + routeInfo: RouteInfo, + enteringViewItem: ViewItem, + leavingViewItem: ViewItem | undefined, + shouldUnmountLeavingViewItem: boolean + ): void { + const enteringRouteElement = enteringViewItem.reactElement?.props?.element; + + // Handle Navigate components (they never render an IonPage) + if (isNavigateElement(enteringRouteElement)) { + this.waitingForIonPage = false; + if (this.ionPageWaitTimeout) { + clearTimeout(this.ionPageWaitTimeout); + this.ionPageWaitTimeout = undefined; + } + this.pendingPageTransition = false; + + // Hide the leaving view immediately for Navigate redirects + hideIonPageElement(leavingViewItem?.ionPageElement); + + // Don't unmount if entering and leaving are the same view item + if (shouldUnmountLeavingViewItem && leavingViewItem && enteringViewItem !== leavingViewItem) { + leavingViewItem.mount = false; + } + + this.forceUpdate(); + return; + } + + // Hide leaving view while we wait for the entering view's IonPage to mount + hideIonPageElement(leavingViewItem?.ionPageElement); + + this.waitingForIonPage = true; + + if (this.ionPageWaitTimeout) { + clearTimeout(this.ionPageWaitTimeout); + } + + this.ionPageWaitTimeout = setTimeout(() => { + this.ionPageWaitTimeout = undefined; + + if (!this.waitingForIonPage) { + return; + } + this.waitingForIonPage = false; + + const latestEnteringView = this.context.findViewItemByRouteInfo(routeInfo, this.id) ?? enteringViewItem; + const latestLeavingView = this.context.findLeavingViewItemByRouteInfo(routeInfo, this.id) ?? leavingViewItem; + + if (latestEnteringView?.ionPageElement) { + this.transitionPage(routeInfo, latestEnteringView, latestLeavingView ?? undefined); + + if (shouldUnmountLeavingViewItem && latestLeavingView && latestEnteringView !== latestLeavingView) { + latestLeavingView.mount = false; + } + + this.forceUpdate(); + } + }, ION_PAGE_WAIT_TIMEOUT_MS); + + this.forceUpdate(); + } + + /** + * Gets the route info to use for finding views during swipe-to-go-back gestures. + * This pattern is used in multiple places in setupRouterOutlet. + */ + private getSwipeBackRouteInfo(): RouteInfo { + const { routeInfo } = this.props; + return this.prevProps && this.prevProps.routeInfo.pathname === routeInfo.pushedByRoute + ? this.prevProps.routeInfo + : ({ pathname: routeInfo.pushedByRoute || '' } as RouteInfo); + } + componentDidMount() { if (this.clearOutletTimeout) { /** @@ -337,10 +542,7 @@ export class StackManager extends React.PureComponent { - if (viewItem.ionPageElement) { - viewItem.ionPageElement.classList.add('ion-page-hidden'); - viewItem.ionPageElement.setAttribute('aria-hidden', 'true'); - } + hideIonPageElement(viewItem.ionPageElement); }); this.clearOutletTimeout = this.context.clearOutlet(this.id); @@ -361,444 +563,82 @@ export class StackManager extends React.PureComponent /tabs -> /tabs/home via redirect). - */ - if ( - enteringViewItem && - leavingViewItem && - enteringViewItem === leavingViewItem && - routeInfo.routeAction === 'replace' && - routeInfo.prevRouteLastPathname - ) { - const actualLeavingView = this.context.findViewItemByPathname(routeInfo.prevRouteLastPathname, this.id); - if (actualLeavingView && actualLeavingView !== enteringViewItem) { - leavingViewItem = actualLeavingView; - } - } - - /** - * Also check if we're in a redirect scenario where entering and leaving are different - * but we still need to handle the actual previous view. This handles cases where - * lastPathname doesn't match a view but prevRouteLastPathname does. - */ - if ( - enteringViewItem && - !leavingViewItem && - routeInfo.routeAction === 'replace' && - routeInfo.prevRouteLastPathname - ) { - const actualLeavingView = this.context.findViewItemByPathname(routeInfo.prevRouteLastPathname, this.id); - if (actualLeavingView && actualLeavingView !== enteringViewItem) { - leavingViewItem = actualLeavingView; - } - } - - /** - * The leaving view item should be unmounted in the following cases: - * - Navigating with `replace` (not within a nested outlet container route) - * - Navigating forward but not pushing a new view (e.g., back navigation or non-animated transition) and the leaving view is not the same as the entering view - * - * NOTE: routeOptions.unmount is handled separately - it means the React component should unmount, - * but the DOM element should remain hidden for tab navigation purposes. - * The view item should NOT be removed from the stack when routeOptions.unmount is used, - * as we may need to show it again when navigating back. - */ - const shouldUnmountLeavingViewItem = (() => { - if (!leavingViewItem) { - return false; - } - - if (routeInfo.routeAction === 'replace') { - return true; - } - - const isForwardPush = routeInfo.routeAction === 'push' && (routeInfo as any).routeDirection === 'forward'; - if (!isForwardPush && routeInfo.routeDirection !== 'none' && enteringViewItem !== leavingViewItem) { - return true; - } - - return false; - })(); - - // Match the route element to render - // For nested outlets, we need to pass parent path info - const parentPath = this.getParentPath(); - - // CRITICAL: If we have a mount path and current route is outside of it, - // don't process any routes in this outlet - it's completely out of scope - if (this.outletMountPath && !routeInfo.pathname.startsWith(this.outletMountPath)) { - // Clear any pending unmount timeout to avoid conflicts - if (this.outOfScopeUnmountTimeout) { - clearTimeout(this.outOfScopeUnmountTimeout); - this.outOfScopeUnmountTimeout = undefined; - } - - // When an outlet is out of scope, unmount its views immediately - // No transition is happening in this outlet - the transition is in the parent - const allViewsInOutlet = this.context.getViewItemsForOutlet ? this.context.getViewItemsForOutlet(this.id) : []; - - // Unmount and remove all views in this outlet immediately to avoid leftover content - allViewsInOutlet.forEach((viewItem) => { - if (viewItem.ionPageElement) { - viewItem.ionPageElement.classList.add('ion-page-hidden'); - viewItem.ionPageElement.setAttribute('aria-hidden', 'true'); - } - // Remove the view from the stack so it is no longer rendered - this.context.unMountViewItem(viewItem); - }); - - // Do not reset outletMountPath here; keeping it prevents this outlet - // from re-adopting an unrelated parent path on subsequent navigations. - this.forceUpdate(); - return; - } - - // Clear any pending out-of-scope unmount timeout since we're processing an in-scope route - if (this.outOfScopeUnmountTimeout) { - clearTimeout(this.outOfScopeUnmountTimeout); - this.outOfScopeUnmountTimeout = undefined; - } - - // If this is a nested outlet with relative routes but no valid parent path, - // it means the outlet is outside its expected routing context - if (this.id !== 'routerOutlet' && parentPath === undefined && this.ionRouterOutlet) { - const routesNode = findRoutesNode(this.ionRouterOutlet.props.children) ?? this.ionRouterOutlet.props.children; - const routeChildren = React.Children.toArray(routesNode).filter( - (child): child is React.ReactElement => React.isValidElement(child) && child.type === Route - ); - - const hasRelativeRoutes = routeChildren.some((route) => { - const path = route.props.path; - return path && !path.startsWith('/') && path !== '*'; - }); - - if (hasRelativeRoutes) { - // Hide any visible views in this outlet since it's out of scope - if (leavingViewItem && leavingViewItem.ionPageElement) { - leavingViewItem.ionPageElement.classList.add('ion-page-hidden'); - leavingViewItem.ionPageElement.setAttribute('aria-hidden', 'true'); - } - if (leavingViewItem) { - leavingViewItem.mount = false; - } - this.forceUpdate(); - return; - } - } - - const enteringRoute = findRouteByRouteInfo( - this.ionRouterOutlet?.props.children, - routeInfo, - parentPath - ) as React.ReactElement; - - // If this is a nested outlet (has an explicit ID) and no route matches, - // it means this outlet shouldn't handle this route - if (this.id !== 'routerOutlet' && !enteringRoute && !enteringViewItem) { - // Hide any visible views in this outlet since it has no matching route - if (leavingViewItem && leavingViewItem.ionPageElement) { - leavingViewItem.ionPageElement.classList.add('ion-page-hidden'); - leavingViewItem.ionPageElement.setAttribute('aria-hidden', 'true'); - } - // Unmount the leaving view to prevent components from staying active - if (leavingViewItem) { - leavingViewItem.mount = false; - } - this.forceUpdate(); - return; - } - - /** - * If we already have a view item for this route, update its element. - * Otherwise, create a new view item for the route. - */ - if (enteringViewItem && enteringRoute) { - // Update existing view item - enteringViewItem.reactElement = enteringRoute; - } else if (enteringRoute) { - enteringViewItem = this.context.createViewItem(this.id, enteringRoute, routeInfo); - this.context.addViewItem(enteringViewItem); - } - - /** - * Begin transition only if we have an ionPageElement (i.e., the page has rendered). - */ - if (enteringViewItem && enteringViewItem.ionPageElement) { - if (this.waitingForIonPage) { - this.waitingForIonPage = false; - } - if (this.ionPageWaitTimeout) { - clearTimeout(this.ionPageWaitTimeout); - this.ionPageWaitTimeout = undefined; - } - - // Ensure the entering view is not hidden by ion-page-hidden from previous navigations - const enteringEl = enteringViewItem.ionPageElement as HTMLElement; - if (enteringEl.classList.contains('ion-page-hidden')) { - enteringEl.classList.remove('ion-page-hidden'); - enteringEl.removeAttribute('aria-hidden'); - } - - /** - * If the entering view item is the same as the leaving view item, - * then we don't need to transition. - */ - if (enteringViewItem === leavingViewItem) { - const routePath = enteringViewItem.reactElement?.props?.path as string | undefined; - const isParameterizedRoute = routePath ? routePath.includes(':') : false; - - if (isParameterizedRoute) { - /** - * When the same view instance handles the new route (e.g. navigating between - * parameterised URLs), refresh its match metadata so the component receives - * the updated params, then skip triggering another transition. - */ - const updatedMatch = matchComponent(enteringViewItem.reactElement, routeInfo.pathname, true); - if (updatedMatch) { - enteringViewItem.routeData.match = updatedMatch; - } - - const enteringEl = enteringViewItem.ionPageElement; - if (enteringEl) { - enteringEl.classList.remove('ion-page-hidden', 'ion-page-invisible'); - enteringEl.removeAttribute('aria-hidden'); - } - - this.forceUpdate(); - return; - } - } - - /** - * If the leaving view is still not found, especially during a - * 'pop' (back navigation) operation, try to retrieve it using the - * previous route information that was available as a prop on the - * component. - */ - if (!leavingViewItem && this.props.routeInfo.prevRouteLastPathname) { - leavingViewItem = this.context.findViewItemByPathname(this.props.routeInfo.prevRouteLastPathname, this.id); - } - - /** - * If the entering view is already visible and the leaving view is not, the transition does not need to occur. - */ - if ( - enteringViewItem.ionPageElement && - isViewVisible(enteringViewItem.ionPageElement) && - leavingViewItem !== undefined && - leavingViewItem.ionPageElement && - !isViewVisible(leavingViewItem.ionPageElement) - ) { - return; - } - - /** - * The view should only be transitioned in the following cases: - * 1. Performing a replace or pop action, such as a swipe to go back gesture - * to animation the leaving view off the screen. - * - * 2. Navigating between top-level router outlets, such as /page-1 to /page-2; - * or navigating within a nested outlet, such as /tabs/tab-1 to /tabs/tab-2. - * - * 3. The entering view is an ion-router-outlet containing a page - * matching the current route and that hasn't already transitioned in. - * - * This should only happen when navigating directly to a nested router outlet - * route or on an initial page load (i.e. refreshing). In cases when loading - * /tabs/tab-1, we need to transition the /tabs page element into the view. - */ - - /** - * Check if we've already started a transition for the same entering/leaving pair. - * This can happen during rapid navigation (e.g., Navigate redirects) where - * multiple handlePageTransition calls occur before the first transition completes. - * - * Only skip if there's an actual leaving view involved - we don't want to skip - * transitions where leaving is undefined as those could be legitimate initial loads - * or transitions to new views. - */ - const currentTransition = { - enteringId: enteringViewItem.id, - leavingId: leavingViewItem?.id, - }; - - if ( - leavingViewItem && - this.lastTransition && - this.lastTransition.leavingId && - this.lastTransition.enteringId === currentTransition.enteringId && - this.lastTransition.leavingId === currentTransition.leavingId - ) { - return; - } - - this.lastTransition = currentTransition; - this.transitionPage(routeInfo, enteringViewItem, leavingViewItem); - - if (shouldUnmountLeavingViewItem && leavingViewItem && enteringViewItem !== leavingViewItem) { - leavingViewItem.mount = false; - // For replace actions, remove actual pages (with ionPageElement) from the stack entirely - // Don't remove utility components like Navigate that don't have ionPageElement - if (routeInfo.routeAction === 'replace' && leavingViewItem.ionPageElement) { - // Check if we should skip removal for nested outlet redirects. - // Only skip if: - // 1. The entering view's route is a container (ends with /*) - // 2. The leaving view is also a container/utility (like index redirect) - // - // If the leaving view is a specific route (like "favorites"), we should - // remove it when navigating to a container route via replace. - const enteringRoutePath = enteringViewItem.reactElement?.props?.path as string | undefined; - const leavingRoutePath = leavingViewItem.reactElement?.props?.path as string | undefined; - const isEnteringContainerRoute = enteringRoutePath && enteringRoutePath.endsWith('/*'); - const isLeavingSpecificRoute = - leavingRoutePath && - leavingRoutePath !== '' && - leavingRoutePath !== '*' && - !leavingRoutePath.endsWith('/*') && - !leavingViewItem.reactElement?.props?.index; - - // Skip removal only for container-to-container transitions (nested outlet redirects) - // Remove the leaving view if it's a specific route being replaced - if (!(isEnteringContainerRoute && !isLeavingSpecificRoute)) { - // Capture leavingViewItem for the closure since TypeScript can't - // track the outer if-block's null check through the setTimeout - const viewToUnmount = leavingViewItem; - setTimeout(() => { - // Use a timeout to ensure the transition completes before removal - this.context.unMountViewItem(viewToUnmount); - }, 250); - } - } - } - } else if (enteringViewItem && !enteringViewItem.ionPageElement) { - const enteringRouteElement = enteringViewItem.reactElement?.props?.element; - const isNavigateElement = React.isValidElement(enteringRouteElement) && enteringRouteElement.type === Navigate; - - if (isNavigateElement) { - /** - * `` components never render an IonPage. They perform an immediate - * history change instead, so waiting for `ionPageElement` would stall the redirect - * and repeatedly hide the leaving view. Treat this as a no-op transition and allow - * the follow-up navigation to proceed. - */ - this.waitingForIonPage = false; - if (this.ionPageWaitTimeout) { - clearTimeout(this.ionPageWaitTimeout); - this.ionPageWaitTimeout = undefined; - } - this.pendingPageTransition = false; - - // Hide and unmount the leaving view immediately for Navigate redirects - if (leavingViewItem?.ionPageElement) { - leavingViewItem.ionPageElement.classList.add('ion-page-hidden'); - leavingViewItem.ionPageElement.setAttribute('aria-hidden', 'true'); - } - // IMPORTANT: Don't unmount if entering and leaving are the same view item - // This happens during chained Navigate redirects where the same Navigate view item - // is being processed multiple times before it can render and trigger the redirect - if (shouldUnmountLeavingViewItem && leavingViewItem && enteringViewItem !== leavingViewItem) { - leavingViewItem.mount = false; - } - - this.forceUpdate(); - return; - } - - /** - * We have a view item but no page element yet. This can happen during - * initial page load with nested routes where the view item is created - * but the component hasn't rendered yet. - * - * Hide the leaving view immediately to avoid duplicate/overlapping content - * while we wait for the entering view's IonPage to mount, then retry the - * transition once the page is ready. - */ - if (leavingViewItem?.ionPageElement) { - leavingViewItem.ionPageElement.classList.add('ion-page-hidden'); - leavingViewItem.ionPageElement.setAttribute('aria-hidden', 'true'); - } - - this.waitingForIonPage = true; - - if (this.ionPageWaitTimeout) { - clearTimeout(this.ionPageWaitTimeout); - } - - this.ionPageWaitTimeout = setTimeout(() => { - this.ionPageWaitTimeout = undefined; - - if (!this.waitingForIonPage) { - return; - } - this.waitingForIonPage = false; - - const latestEnteringView = this.context.findViewItemByRouteInfo(routeInfo, this.id) ?? enteringViewItem; - const latestLeavingView = this.context.findLeavingViewItemByRouteInfo(routeInfo, this.id) ?? leavingViewItem; - - if (latestEnteringView?.ionPageElement) { - this.transitionPage(routeInfo, latestEnteringView, latestLeavingView ?? undefined); - - if (shouldUnmountLeavingViewItem && latestLeavingView && latestEnteringView !== latestLeavingView) { - latestLeavingView.mount = false; - } - - this.forceUpdate(); - } - }, 50); - - this.forceUpdate(); - return; - } else if (!enteringViewItem && !enteringRoute) { - /** - * No view item and no route found. This can happen during initial page load - * with nested routes where the nested router outlet hasn't rendered its - * children yet. Schedule a retry to allow nested routes to be processed. - */ - if (leavingViewItem) { - /** - * If we have a leavingView but no entering view/route, we are probably - * leaving to another outlet, so hide this leavingView. - * (e.g., /tabs/tab1 → /settings) - */ - if (leavingViewItem.ionPageElement) { - leavingViewItem.ionPageElement.classList.add('ion-page-hidden'); - leavingViewItem.ionPageElement.setAttribute('aria-hidden', 'true'); - } - if (shouldUnmountLeavingViewItem) { - leavingViewItem.mount = false; - } - } - } - - this.forceUpdate(); + return; } + + // Find entering and leaving view items + let { enteringViewItem, leavingViewItem } = this.findViewItems(routeInfo); + const shouldUnmountLeavingViewItem = this.shouldUnmountLeavingView(routeInfo, enteringViewItem, leavingViewItem); + + // Get parent path for nested outlets + const parentPath = this.getParentPath(); + + // Handle out-of-scope outlet (route outside mount path) + if (this.handleOutOfScopeOutlet(routeInfo)) { + return; + } + + // Clear any pending out-of-scope unmount timeout + if (this.outOfScopeUnmountTimeout) { + clearTimeout(this.outOfScopeUnmountTimeout); + this.outOfScopeUnmountTimeout = undefined; + } + + // Handle nested outlet with relative routes but no valid parent path + if (this.handleOutOfContextNestedOutlet(parentPath, leavingViewItem)) { + return; + } + + // Find the matching route element + const enteringRoute = findRouteByRouteInfo( + this.ionRouterOutlet?.props.children, + routeInfo, + parentPath + ) as React.ReactElement; + + // Handle nested outlet with no matching route + if (this.handleNoMatchingRoute(enteringRoute, enteringViewItem, leavingViewItem)) { + return; + } + + // Create or update the entering view item + if (enteringViewItem && enteringRoute) { + enteringViewItem.reactElement = enteringRoute; + } else if (enteringRoute) { + enteringViewItem = this.context.createViewItem(this.id, enteringRoute, routeInfo); + this.context.addViewItem(enteringViewItem); + } + + // Handle transition based on ion-page element availability + if (enteringViewItem && enteringViewItem.ionPageElement) { + // Clear waiting state + if (this.waitingForIonPage) { + this.waitingForIonPage = false; + } + if (this.ionPageWaitTimeout) { + clearTimeout(this.ionPageWaitTimeout); + this.ionPageWaitTimeout = undefined; + } + + this.handleReadyEnteringView(routeInfo, enteringViewItem, leavingViewItem, shouldUnmountLeavingViewItem); + } else if (enteringViewItem && !enteringViewItem.ionPageElement) { + // Wait for ion-page to mount + this.handleWaitingForIonPage(routeInfo, enteringViewItem, leavingViewItem, shouldUnmountLeavingViewItem); + return; + } else if (!enteringViewItem && !enteringRoute) { + // No view or route found - likely leaving to another outlet + if (leavingViewItem) { + hideIonPageElement(leavingViewItem.ionPageElement); + if (shouldUnmountLeavingViewItem) { + leavingViewItem.mount = false; + } + } + } + + this.forceUpdate(); } /** @@ -848,32 +688,17 @@ export class StackManager extends React.PureComponent { const { routeInfo } = this.props; - - // Determine the route to use for finding the view we would be navigating back to - const propsToUse = - this.prevProps && this.prevProps.routeInfo.pathname === routeInfo.pushedByRoute - ? this.prevProps.routeInfo - : ({ pathname: routeInfo.pushedByRoute || '' } as any); - // Find the view item for the route we are going back to - const enteringViewItem = this.context.findViewItemByRouteInfo(propsToUse, this.id, false); - // Find the view item for the route we are going back from + const swipeBackRouteInfo = this.getSwipeBackRouteInfo(); + const enteringViewItem = this.context.findViewItemByRouteInfo(swipeBackRouteInfo, this.id, false); const leavingViewItem = this.context.findViewItemByRouteInfo(routeInfo, this.id, false); - /** - * When the gesture starts, kick off - * a transition that is controlled - * via a swipe gesture. - */ + // When the gesture starts, kick off a transition controlled via swipe gesture if (enteringViewItem && leavingViewItem) { await this.transitionPage(routeInfo, enteringViewItem, leavingViewItem, 'back', true); } @@ -908,39 +722,17 @@ export class StackManager extends React.PureComponent { - this.forceUpdate(); // TODO: investigate why this is needed + // Callback triggers re-render when view items are modified during getChildrenToRender + this.forceUpdate(); }); return ( @@ -1109,10 +903,10 @@ function findRouteByRouteInfo(node: React.ReactNode, routeInfo: RouteInfo, paren let fallbackNode: React.ReactNode; // `` nodes are rendered inside of a node - const routesNode = findRoutesNode(node) ?? node; + const routesChildren = getRoutesChildren(node) ?? node; // Collect all route children - const routeChildren = React.Children.toArray(routesNode).filter( + const routeChildren = React.Children.toArray(routesChildren).filter( (child): child is React.ReactElement => React.isValidElement(child) && child.type === Route ); @@ -1158,8 +952,8 @@ 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 = parentPrefix.endsWith('/') ? parentPrefix.slice(0, -1) : parentPrefix; - const normalizedPathname = routeInfo.pathname.endsWith('/') ? routeInfo.pathname.slice(0, -1) : routeInfo.pathname; + const normalizedParent = stripTrailingSlash(parentPrefix); + const normalizedPathname = stripTrailingSlash(routeInfo.pathname); // Only compute relative path if pathname is within parent scope if (normalizedPathname.startsWith(normalizedParent + '/') || normalizedPathname === normalizedParent) { diff --git a/packages/react-router/src/ReactRouter/utils/computeParentPath.ts b/packages/react-router/src/ReactRouter/utils/computeParentPath.ts new file mode 100644 index 0000000000..475dfb2ff6 --- /dev/null +++ b/packages/react-router/src/ReactRouter/utils/computeParentPath.ts @@ -0,0 +1,274 @@ +import React from 'react'; +import { Route } from 'react-router-dom'; + +import { getRoutesChildren } from './getRoutesChildren'; +import { matchPath } from './matchPath'; + +/** + * Finds the longest common prefix among an array of paths. + * Used to determine the scope of an outlet with absolute routes. + * + * @param paths An array of absolute path strings. + * @returns The common prefix shared by all paths. + */ +export const computeCommonPrefix = (paths: string[]): string => { + if (paths.length === 0) return ''; + if (paths.length === 1) { + // For a single path, extract the directory-like prefix + // e.g., /dynamic-routes/home -> /dynamic-routes + const segments = paths[0].split('/').filter(Boolean); + if (segments.length > 1) { + return '/' + segments.slice(0, -1).join('/'); + } + return '/' + segments[0]; + } + + // Split all paths into segments + const segmentArrays = paths.map((p) => p.split('/').filter(Boolean)); + const minLength = Math.min(...segmentArrays.map((s) => s.length)); + + const commonSegments: string[] = []; + for (let i = 0; i < minLength; i++) { + const segment = segmentArrays[0][i]; + // Skip segments with route parameters or wildcards + if (segment.includes(':') || segment.includes('*')) { + break; + } + const allMatch = segmentArrays.every((s) => s[i] === segment); + if (allMatch) { + commonSegments.push(segment); + } else { + break; + } + } + + return commonSegments.length > 0 ? '/' + commonSegments.join('/') : ''; +}; + +/** + * 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. + */ +export const isSpecificRouteMatch = (route: React.ReactElement, remainingPath: string): boolean => { + const routePath = route.props.path; + const isWildcardOnly = routePath === '*' || routePath === '/*'; + const isIndex = route.props.index; + + // Skip wildcards and index routes + if (isIndex || isWildcardOnly) { + return false; + } + + return !!matchPath({ + pathname: remainingPath, + componentProps: route.props, + }); +}; + +/** + * Result of parent path computation. + */ +export interface ParentPathResult { + parentPath: string | undefined; + outletMountPath: string | undefined; +} + +/** + * Extracts Route children from a node (either directly or from a Routes wrapper). + * + * @param children The children to extract routes from. + * @returns An array of Route elements. + */ +export const extractRouteChildren = (children: React.ReactNode): React.ReactElement[] => { + const routesChildren = getRoutesChildren(children) ?? children; + return React.Children.toArray(routesChildren).filter( + (child): child is React.ReactElement => React.isValidElement(child) && child.type === Route + ); +}; + +interface RouteAnalysis { + hasRelativeRoutes: boolean; + hasIndexRoute: boolean; + hasWildcardRoute: boolean; + routeChildren: React.ReactElement[]; +} + +/** + * Analyzes route children to determine their characteristics. + * + * @param routeChildren The route children to analyze. + * @returns Analysis of the route characteristics. + */ +export const analyzeRouteChildren = (routeChildren: React.ReactElement[]): RouteAnalysis => { + const hasRelativeRoutes = routeChildren.some((route) => { + const path = route.props.path; + return path && !path.startsWith('/') && path !== '*'; + }); + + const hasIndexRoute = routeChildren.some((route) => route.props.index); + + const hasWildcardRoute = routeChildren.some((route) => { + const routePath = route.props.path; + return routePath === '*' || routePath === '/*'; + }); + + return { hasRelativeRoutes, hasIndexRoute, hasWildcardRoute, routeChildren }; +}; + +interface ComputeParentPathOptions { + currentPathname: string; + outletMountPath: string | undefined; + routeChildren: React.ReactElement[]; + hasRelativeRoutes: boolean; + hasIndexRoute: boolean; + hasWildcardRoute: boolean; +} + +/** + * Computes the parent path for a nested outlet based on the current pathname + * and the outlet's route configuration. + * + * The algorithm finds the shortest parent path where a route matches the remaining path. + * Priority: specific routes > wildcard routes > index routes (only at mount point) + * + * @param options The options for computing the parent path. + * @returns The computed parent path result. + */ +export const computeParentPath = (options: ComputeParentPathOptions): ParentPathResult => { + const { currentPathname, outletMountPath, routeChildren, hasRelativeRoutes, hasIndexRoute, hasWildcardRoute } = + options; + + // If this outlet previously established a mount path and the current + // pathname is outside of that scope, do not attempt to re-compute a new + // parent path. + if (outletMountPath && !currentPathname.startsWith(outletMountPath)) { + return { parentPath: undefined, outletMountPath }; + } + + if ((hasRelativeRoutes || hasIndexRoute) && currentPathname.includes('/')) { + const segments = currentPathname.split('/').filter(Boolean); + + if (segments.length >= 1) { + // Find matches at each level, keeping track of the FIRST (shortest) match + let firstSpecificMatch: string | undefined = undefined; + let firstWildcardMatch: string | undefined = undefined; + let indexMatchAtMount: string | undefined = undefined; + + 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)); + if (hasSpecificMatch && !firstSpecificMatch) { + firstSpecificMatch = parentPath; + // Found a specific match - this is our answer for non-index routes + break; + } + + // Check if wildcard would match this remaining path + // Only if remaining is non-empty (wildcard needs something to match) + if (remainingPath !== '' && remainingPath !== '/' && hasWildcardRoute && !firstWildcardMatch) { + // Check if any specific route could plausibly match this remaining path + const remainingFirstSegment = remainingPath.split('/')[0]; + const couldAnyRouteMatch = routeChildren.some((route) => { + const routePath = route.props.path as string | undefined; + if (!routePath || routePath === '*' || routePath === '/*') return false; + if (route.props.index) return false; + + const routeFirstSegment = routePath.split('/')[0].replace(/[*:]/g, ''); + if (!routeFirstSegment) return false; + + // Check for prefix overlap (either direction) + return ( + routeFirstSegment.startsWith(remainingFirstSegment.slice(0, 3)) || + remainingFirstSegment.startsWith(routeFirstSegment.slice(0, 3)) + ); + }); + + // Only save wildcard match if no specific route could match + if (!couldAnyRouteMatch) { + firstWildcardMatch = parentPath; + // Continue looking - might find a specific match at a longer path + } + } + + // Check for index route match when remaining path is empty + // BUT only at the outlet's mount path level + if ((remainingPath === '' || remainingPath === '/') && hasIndexRoute) { + // Index route matches when current path exactly matches the mount path + // If we already have an outletMountPath, index should only match there + if (outletMountPath) { + if (parentPath === outletMountPath) { + indexMatchAtMount = parentPath; + } + } else { + // No mount path set yet - index would establish this as mount path + // But only if we haven't found a better match + indexMatchAtMount = parentPath; + } + } + } + + // Determine the best parent path: + // 1. Specific match (routes like tabs/*, favorites) - highest priority + // 2. Wildcard match (route path="*") - catches unmatched segments + // 3. Index match - only valid at the outlet's mount point, not deeper + let bestPath: string | undefined = undefined; + + if (firstSpecificMatch) { + bestPath = firstSpecificMatch; + } else if (firstWildcardMatch) { + bestPath = firstWildcardMatch; + } else if (indexMatchAtMount) { + // Only use index match if no specific or wildcard matched + // This handles the case where pathname exactly matches the mount path + bestPath = indexMatchAtMount; + } + + // Store the mount path when we first successfully match a route + let newOutletMountPath = outletMountPath; + if (!outletMountPath && bestPath) { + newOutletMountPath = bestPath; + } + + // If we have a mount path, verify the current pathname is within scope + if (newOutletMountPath && !currentPathname.startsWith(newOutletMountPath)) { + return { parentPath: undefined, outletMountPath: newOutletMountPath }; + } + + return { parentPath: bestPath, outletMountPath: newOutletMountPath }; + } + } + + // Handle outlets with ONLY absolute routes (no relative routes or index routes) + // Compute the common prefix of all absolute routes to determine the outlet's scope + if (!hasRelativeRoutes && !hasIndexRoute) { + const absolutePathRoutes = routeChildren.filter((route) => { + const path = route.props.path; + return path && path.startsWith('/'); + }); + + if (absolutePathRoutes.length > 0) { + const absolutePaths = absolutePathRoutes.map((r) => r.props.path as string); + const commonPrefix = computeCommonPrefix(absolutePaths); + + if (commonPrefix && commonPrefix !== '/') { + // Set the mount path based on common prefix of absolute routes + const newOutletMountPath = outletMountPath || commonPrefix; + + // Check if current pathname is within scope + if (!currentPathname.startsWith(commonPrefix)) { + return { parentPath: undefined, outletMountPath: newOutletMountPath }; + } + + return { parentPath: commonPrefix, outletMountPath: newOutletMountPath }; + } + } + } + + return { parentPath: outletMountPath, outletMountPath }; +}; diff --git a/packages/react-router/src/ReactRouter/utils/findRoutesNode.ts b/packages/react-router/src/ReactRouter/utils/getRoutesChildren.ts similarity index 79% rename from packages/react-router/src/ReactRouter/utils/findRoutesNode.ts rename to packages/react-router/src/ReactRouter/utils/getRoutesChildren.ts index 269c974856..2af53ced45 100644 --- a/packages/react-router/src/ReactRouter/utils/findRoutesNode.ts +++ b/packages/react-router/src/ReactRouter/utils/getRoutesChildren.ts @@ -1,7 +1,7 @@ import React from 'react'; import { Routes } from 'react-router'; -export const findRoutesNode = (node: React.ReactNode) => { +export const getRoutesChildren = (node: React.ReactNode) => { // The use of `` is encouraged with React Router v6. let routesNode: React.ReactNode; React.Children.forEach(node as React.ReactElement, (child: React.ReactElement) => { @@ -11,7 +11,7 @@ export const findRoutesNode = (node: React.ReactNode) => { }); if (routesNode) { - // The childern of the `` component are most likely + // The children of the `` component are most likely // (and should be) the `` components. return (routesNode as React.ReactElement).props.children; } diff --git a/packages/react-router/src/ReactRouter/utils/matchRoutesFromChildren.ts b/packages/react-router/src/ReactRouter/utils/matchRoutesFromChildren.ts deleted file mode 100644 index 4bf877ee67..0000000000 --- a/packages/react-router/src/ReactRouter/utils/matchRoutesFromChildren.ts +++ /dev/null @@ -1,168 +0,0 @@ -import React from 'react'; -import type { RouteObject } from 'react-router'; -import { matchRoutes, Route } from 'react-router-dom'; - -// Type for the result of matchRoutes - inferred from the function return type -type RouteMatch = NonNullable>[number]; - -/** - * Sorts routes by specificity. React Router's matchRoutes respects route order, - * so we need to ensure more specific routes come before wildcards. - */ -function sortRoutesBySpecificity(routes: RouteObject[]): RouteObject[] { - return [...routes].sort((a, b) => { - const pathA = a.path || ''; - const pathB = b.path || ''; - - // Index routes come first - if (a.index && !b.index) return -1; - if (!a.index && b.index) return 1; - - // Wildcard-only routes (*) should come LAST - const aIsWildcardOnly = pathA === '*'; - const bIsWildcardOnly = pathB === '*'; - - if (!aIsWildcardOnly && bIsWildcardOnly) return -1; - if (aIsWildcardOnly && !bIsWildcardOnly) return 1; - - // Routes with more segments are more specific - const aSegments = pathA.split('/').filter(Boolean).length; - const bSegments = pathB.split('/').filter(Boolean).length; - if (aSegments !== bSegments) { - return bSegments - aSegments; - } - - // Exact matches (no wildcards/params) come before wildcard/param routes - const aHasWildcard = pathA.includes('*') || pathA.includes(':'); - const bHasWildcard = pathB.includes('*') || pathB.includes(':'); - - if (!aHasWildcard && bHasWildcard) return -1; - if (aHasWildcard && !bHasWildcard) return 1; - - // Among routes with same wildcard status, longer paths are more specific - return pathB.length - pathA.length; - }); -} - -/** - * Converts React Route children to RouteObject array for use with matchRoutes. - * This allows us to use React Router's native matching algorithm. - * Routes are sorted by specificity to ensure proper matching order. - */ -export function createRouteObjectsFromChildren(children: React.ReactNode): RouteObject[] { - const routes: RouteObject[] = []; - - React.Children.forEach(children, (child) => { - if (!React.isValidElement(child)) { - return; - } - - if (child.type !== Route) { - // Not a Route element, skip - return; - } - - const props = child.props as { - path?: string; - index?: boolean; - caseSensitive?: boolean; - element?: React.ReactNode; - children?: React.ReactNode; - }; - - const route: RouteObject = { - path: props.path, - index: props.index, - caseSensitive: props.caseSensitive, - element: props.element, - }; - - // Handle nested routes if present - if (props.children) { - route.children = createRouteObjectsFromChildren(props.children); - } - - routes.push(route); - }); - - // Sort routes by specificity before returning - return sortRoutesBySpecificity(routes); -} - -/** - * Finds the best matching route for a given pathname using React Router's matchRoutes. - * This properly handles wildcard routes, index routes, and prioritizes specific routes. - * - * @param children The React children containing Route elements - * @param pathname The pathname to match against - * @returns The matched routes or null if no match - */ -export function findMatchingRoutes(children: React.ReactNode, pathname: string): RouteMatch[] | null { - const routes = createRouteObjectsFromChildren(children); - return matchRoutes(routes, pathname); -} - -/** - * Determines the parent path for a nested outlet by finding what prefix matches - * when the routes are matched against the current pathname. - * - * This is crucial for nested routing where we need to know what portion of the - * pathname was matched by the parent outlet. - * - * @param children The Route children of this outlet - * @param pathname The current pathname - * @returns The parent path prefix, or null if no match - */ -export function computeParentPathFromRoutes( - children: React.ReactNode, - pathname: string -): { parentPath: string; matchedRoute: RouteObject } | null { - const routes = createRouteObjectsFromChildren(children); - - // Normalize pathname - const normalizedPathname = pathname.endsWith('/') && pathname.length > 1 ? pathname.slice(0, -1) : pathname; - const segments = normalizedPathname.split('/').filter(Boolean); - - // Try progressively shorter parent paths to find a match - for (let i = 1; i <= segments.length; i++) { - const testParentPath = '/' + segments.slice(0, i).join('/'); - const testRemainingPath = '/' + segments.slice(i).join('/'); - - // Try to match the remaining path against our routes - const matches = matchRoutes(routes, testRemainingPath); - - if (matches && matches.length > 0) { - const lastMatch = matches[matches.length - 1]; - const matchedRoute = lastMatch.route; - - // Skip if the matched route is an index route and there's remaining path - // Index routes should only match when the remaining path is empty - if (matchedRoute.index && testRemainingPath !== '/' && testRemainingPath !== '') { - continue; - } - - return { - parentPath: testParentPath, - matchedRoute, - }; - } - } - - // No specific match found, but we might still have a wildcard that matches - // Try matching the full pathname as just "/" - const rootMatches = matchRoutes(routes, '/'); - if (rootMatches && rootMatches.length > 0) { - const lastMatch = rootMatches[rootMatches.length - 1]; - - // Only consider this if it's an index route (we're exactly at the parent) - if (lastMatch.route.index) { - const parentPath = '/' + segments.join('/'); - return { - parentPath, - matchedRoute: lastMatch.route, - }; - } - } - - return null; -} diff --git a/packages/react-router/src/ReactRouter/utils/normalizePath.ts b/packages/react-router/src/ReactRouter/utils/normalizePath.ts new file mode 100644 index 0000000000..0ce180e4a1 --- /dev/null +++ b/packages/react-router/src/ReactRouter/utils/normalizePath.ts @@ -0,0 +1,37 @@ +/** + * Ensures the given path has a leading slash. + * + * @param value The path string to normalize. + * @returns The path with a leading slash. + */ +export const ensureLeadingSlash = (value: string): string => { + if (value === '') { + return '/'; + } + return value.startsWith('/') ? value : `/${value}`; +}; + +/** + * Strips the trailing slash from a path, unless it's the root path. + * + * @param value The path string to normalize. + * @returns The path without a trailing slash. + */ +export const stripTrailingSlash = (value: string): string => { + return value.length > 1 && value.endsWith('/') ? value.slice(0, -1) : value; +}; + +/** + * Normalizes a pathname for comparison by ensuring a leading slash + * and removing trailing slashes. + * + * @param value The pathname to normalize, can be undefined. + * @returns A normalized pathname string. + */ +export const normalizePathnameForComparison = (value: string | undefined): string => { + if (!value || value === '') { + return '/'; + } + const withLeadingSlash = ensureLeadingSlash(value); + return stripTrailingSlash(withLeadingSlash); +}; diff --git a/packages/react-router/src/ReactRouter/utils/routeUtils.ts b/packages/react-router/src/ReactRouter/utils/routeUtils.ts new file mode 100644 index 0000000000..d82bfdc98d --- /dev/null +++ b/packages/react-router/src/ReactRouter/utils/routeUtils.ts @@ -0,0 +1,43 @@ +import React from 'react'; +import { Navigate } from 'react-router-dom'; + +import type { ViewItem } from '@ionic/react'; + +/** + * Checks if a React element is a Navigate component (redirect). + * + * @param element The element to check. + * @returns True if the element is a Navigate component. + */ +export const isNavigateElement = (element: unknown): boolean => { + return ( + React.isValidElement(element) && + (element.type === Navigate || + (typeof element.type === 'function' && element.type.name === 'Navigate')) + ); +}; + +/** + * Sorts view items by route specificity (most specific first). + * - Exact matches (no wildcards/params) come first + * - Among wildcard routes, longer paths are more specific + * + * @param views The view items to sort. + * @returns A new sorted array of view items. + */ +export const sortViewsBySpecificity = (views: ViewItem[]): ViewItem[] => { + return [...views].sort((a, b) => { + const pathA = a.routeData?.childProps?.path || ''; + const pathB = b.routeData?.childProps?.path || ''; + + // Exact matches (no wildcards/params) come first + const aHasWildcard = pathA.includes('*') || pathA.includes(':'); + const bHasWildcard = pathB.includes('*') || pathB.includes(':'); + + if (!aHasWildcard && bHasWildcard) return -1; + if (aHasWildcard && !bHasWildcard) return 1; + + // Among wildcard routes, longer paths are more specific + return pathB.length - pathA.length; + }); +};