@@ -9,11 +9,25 @@ import type { RouteInfo, ViewItem } from '@ionic/react';
99import { IonRoute , ViewLifeCycleManager , ViewStacks } from '@ionic/react' ;
1010import React from 'react' ;
1111import type { PathMatch } from 'react-router' ;
12- import { Navigate , Route , UNSAFE_RouteContext as RouteContext } from 'react-router-dom' ;
12+ import { Navigate , UNSAFE_RouteContext as RouteContext } from 'react-router-dom' ;
1313
14+ import { analyzeRouteChildren , computeParentPath , extractRouteChildren } from './utils/computeParentPath' ;
1415import { derivePathnameToMatch } from './utils/derivePathnameToMatch' ;
15- import { findRoutesNode } from './utils/findRoutesNode' ;
1616import { matchPath } from './utils/matchPath' ;
17+ import { normalizePathnameForComparison } from './utils/normalizePath' ;
18+ import { isNavigateElement , sortViewsBySpecificity } from './utils/routeUtils' ;
19+
20+ /**
21+ * Delay in milliseconds before removing a Navigate view item after a redirect.
22+ * This ensures the redirect navigation completes before the view is removed.
23+ */
24+ const NAVIGATE_REDIRECT_DELAY_MS = 100 ;
25+
26+ /**
27+ * Delay in milliseconds before cleaning up a view without an IonPage element.
28+ * This double-checks that the view is truly not needed before removal.
29+ */
30+ const VIEW_CLEANUP_DELAY_MS = 200 ;
1731
1832const createDefaultMatch = (
1933 fullPathname : string ,
@@ -37,22 +51,6 @@ const createDefaultMatch = (
3751 } ;
3852} ;
3953
40- const ensureLeadingSlash = ( value : string ) : string => {
41- if ( value === '' ) {
42- return '/' ;
43- }
44- return value . startsWith ( '/' ) ? value : `/${ value } ` ;
45- } ;
46-
47- const normalizePathnameForComparison = ( value : string | undefined ) : string => {
48- if ( ! value || value === '' ) {
49- return '/' ;
50- }
51- const withLeadingSlash = ensureLeadingSlash ( value ) ;
52- return withLeadingSlash . length > 1 && withLeadingSlash . endsWith ( '/' )
53- ? withLeadingSlash . slice ( 0 , - 1 )
54- : withLeadingSlash ;
55- } ;
5654
5755const computeRelativeToParent = ( pathname : string , parentPath ?: string ) : string | null => {
5856 if ( ! parentPath ) return null ;
@@ -127,9 +125,11 @@ export class ReactRouterViewStack extends ViewStacks {
127125 const newIsIndexRoute = ! ! reactElement . props . index ;
128126
129127 // For Navigate components, match by destination
130- if ( existingElement ?. type ?. name === 'Navigate' && newElement ?. type ?. name === 'Navigate' ) {
131- const existingTo = existingElement . props ?. to ;
132- const newTo = newElement . props ?. to ;
128+ const existingIsNavigate = React . isValidElement ( existingElement ) && existingElement . type === Navigate ;
129+ const newIsNavigate = React . isValidElement ( newElement ) && newElement . type === Navigate ;
130+ if ( existingIsNavigate && newIsNavigate ) {
131+ const existingTo = ( existingElement . props as { to ?: string } ) ?. to ;
132+ const newTo = ( newElement . props as { to ?: string } ) ?. to ;
133133 if ( existingTo === newTo ) {
134134 return true ;
135135 }
@@ -245,10 +245,7 @@ export class ReactRouterViewStack extends ViewStacks {
245245
246246 // Special handling for Navigate components - they should unmount after redirecting
247247 const elementComponent = viewItem . reactElement ?. props ?. element ;
248- const isNavigateComponent =
249- React . isValidElement ( elementComponent ) &&
250- ( elementComponent . type === Navigate ||
251- ( typeof elementComponent . type === 'function' && elementComponent . type . name === 'Navigate' ) ) ;
248+ const isNavigateComponent = isNavigateElement ( elementComponent ) ;
252249
253250 if ( isNavigateComponent ) {
254251 // Navigate components should only be mounted when they match
@@ -266,7 +263,7 @@ export class ReactRouterViewStack extends ViewStacks {
266263 // This ensures the redirect completes before removal
267264 setTimeout ( ( ) => {
268265 this . remove ( viewItem ) ;
269- } , 100 ) ;
266+ } , NAVIGATE_REDIRECT_DELAY_MS ) ;
270267 }
271268 }
272269
@@ -293,7 +290,7 @@ export class ReactRouterViewStack extends ViewStacks {
293290 if ( stillNotNeeded ) {
294291 this . remove ( viewItem ) ;
295292 }
296- } , 200 ) ;
293+ } , VIEW_CLEANUP_DELAY_MS ) ;
297294 } else {
298295 // Preserve it but unmount it for now
299296 viewItem . mount = false ;
@@ -444,107 +441,19 @@ export class ReactRouterViewStack extends ViewStacks {
444441 try {
445442 // Only attempt parent path computation for non-root outlets
446443 if ( outletId !== 'routerOutlet' ) {
447- const routesNode = findRoutesNode ( ionRouterOutlet . props . children ) ?? ionRouterOutlet . props . children ;
448- const routeChildren = React . Children . toArray ( routesNode ) . filter (
449- ( child ) : child is React . ReactElement => React . isValidElement ( child ) && child . type === Route
450- ) ;
451-
452- const hasRelativeRoutes = routeChildren . some ( ( route ) => {
453- const path = ( route . props as any ) . path as string | undefined ;
454- return path && ! path . startsWith ( '/' ) && path !== '*' ;
455- } ) ;
456- const hasIndexRoute = routeChildren . some ( ( route ) => ! ! ( route . props as any ) . index ) ;
444+ const routeChildren = extractRouteChildren ( ionRouterOutlet . props . children ) ;
445+ const { hasRelativeRoutes, hasIndexRoute, hasWildcardRoute } = analyzeRouteChildren ( routeChildren ) ;
457446
458447 if ( hasRelativeRoutes || hasIndexRoute ) {
459- const segments = routeInfo . pathname . split ( '/' ) . filter ( Boolean ) ;
460-
461- // Two-pass algorithm:
462- // Pass 1: Look for specific route matches OR index routes (prefer real routes)
463- // Pass 2: If no match found, use wildcard fallback
464- //
465- // Key insight: Index routes should match when remaining is empty at the longest
466- // valid parent path. Wildcards should only be used when no specific/index match exists.
467-
468- let wildcardFallbackPath : string | undefined = undefined ;
469-
470- // Pass 1: Look for specific or index matches, tracking wildcard fallback
471- for ( let i = 1 ; i <= segments . length ; i ++ ) {
472- const testParentPath = '/' + segments . slice ( 0 , i ) . join ( '/' ) ;
473- const testRemainingPath = segments . slice ( i ) . join ( '/' ) ;
474-
475- // Check for specific (non-wildcard, non-index) route matches
476- const hasSpecificMatch = routeChildren . some ( ( route ) => {
477- const props = route . props as any ;
478- const routePath = props . path as string | undefined ;
479- const isIndex = ! ! props . index ;
480- const isWildcardOnly = routePath === '*' || routePath === '/*' ;
481-
482- if ( isIndex || isWildcardOnly ) {
483- return false ;
484- }
485-
486- const m = matchPath ( { pathname : testRemainingPath , componentProps : props } ) ;
487- return ! ! m ;
488- } ) ;
489-
490- if ( hasSpecificMatch ) {
491- parentPath = testParentPath ;
492- break ;
493- }
494-
495- // Check for index match (only when remaining is empty AND no wildcard fallback)
496- // If we already found a wildcard fallback at a shorter path, it means
497- // the remaining path at that level didn't match any routes, so the
498- // index match at this longer path is not valid.
499- if ( ! wildcardFallbackPath && ( testRemainingPath === '' || testRemainingPath === '/' ) ) {
500- const hasIndexMatch = routeChildren . some ( ( route ) => ! ! ( route . props as any ) . index ) ;
501- if ( hasIndexMatch ) {
502- parentPath = testParentPath ;
503- break ;
504- }
505- }
506-
507- // Track wildcard fallback at first level where remaining is non-empty
508- // and no specific route could even START to match the remaining path
509- if ( ! wildcardFallbackPath && testRemainingPath !== '' && testRemainingPath !== '/' ) {
510- const hasWildcard = routeChildren . some ( ( route ) => {
511- const routePath = ( route . props as any ) . path ;
512- return routePath === '*' || routePath === '/*' ;
513- } ) ;
514-
515- if ( hasWildcard ) {
516- // Check if any specific route could plausibly match this remaining path
517- // by checking if the first segment overlaps with any route's first segment
518- const remainingFirstSegment = testRemainingPath . split ( '/' ) [ 0 ] ;
519- const couldAnyRouteMatch = routeChildren . some ( ( route ) => {
520- const props = route . props as any ;
521- const routePath = props . path as string | undefined ;
522- if ( ! routePath || routePath === '*' || routePath === '/*' ) return false ;
523- if ( props . index ) return false ;
524-
525- // Get the route's first segment (before any / or *)
526- const routeFirstSegment = routePath . split ( '/' ) [ 0 ] . replace ( / [ * : ] / g, '' ) ;
527- if ( ! routeFirstSegment ) return false ;
528-
529- // Check for prefix overlap (either direction)
530- return (
531- routeFirstSegment . startsWith ( remainingFirstSegment . slice ( 0 , 3 ) ) ||
532- remainingFirstSegment . startsWith ( routeFirstSegment . slice ( 0 , 3 ) )
533- ) ;
534- } ) ;
535-
536- // Only save wildcard fallback if no specific route could match
537- if ( ! couldAnyRouteMatch ) {
538- wildcardFallbackPath = testParentPath ;
539- }
540- }
541- }
542- }
543-
544- // Pass 2: If no specific/index match found, use wildcard fallback
545- if ( ! parentPath && wildcardFallbackPath ) {
546- parentPath = wildcardFallbackPath ;
547- }
448+ const result = computeParentPath ( {
449+ currentPathname : routeInfo . pathname ,
450+ outletMountPath : undefined ,
451+ routeChildren,
452+ hasRelativeRoutes,
453+ hasIndexRoute,
454+ hasWildcardRoute,
455+ } ) ;
456+ parentPath = result . parentPath ;
548457 }
549458 }
550459 } catch ( e ) {
@@ -580,10 +489,7 @@ export class ReactRouterViewStack extends ViewStacks {
580489 // and triggering unwanted redirects
581490 const renderableViewItems = uniqueViewItems . filter ( ( viewItem ) => {
582491 const elementComponent = viewItem . reactElement ?. props ?. element ;
583- const isNavigateComponent =
584- React . isValidElement ( elementComponent ) &&
585- ( elementComponent . type === Navigate ||
586- ( typeof elementComponent . type === 'function' && elementComponent . type . name === 'Navigate' ) ) ;
492+ const isNavigateComponent = isNavigateElement ( elementComponent ) ;
587493
588494 // Exclude unmounted Navigate components from rendering
589495 if ( isNavigateComponent && ! viewItem . mount ) {
@@ -675,30 +581,12 @@ export class ReactRouterViewStack extends ViewStacks {
675581 let match : PathMatch < string > | null = null ;
676582 let viewStack : ViewItem [ ] ;
677583
678- // Helper function to sort views by specificity (most specific first)
679- const sortBySpecificity = ( views : ViewItem [ ] ) => {
680- return [ ...views ] . sort ( ( a , b ) => {
681- const pathA = a . routeData . childProps . path || '' ;
682- const pathB = b . routeData . childProps . path || '' ;
683-
684- // Exact matches (no wildcards/params) come first
685- const aHasWildcard = pathA . includes ( '*' ) || pathA . includes ( ':' ) ;
686- const bHasWildcard = pathB . includes ( '*' ) || pathB . includes ( ':' ) ;
687-
688- if ( ! aHasWildcard && bHasWildcard ) return - 1 ;
689- if ( aHasWildcard && ! bHasWildcard ) return 1 ;
690-
691- // Among wildcard routes, longer paths are more specific
692- return pathB . length - pathA . length ;
693- } ) ;
694- } ;
695-
696584 if ( outletId ) {
697- viewStack = sortBySpecificity ( this . getViewItemsForOutlet ( outletId ) ) ;
585+ viewStack = sortViewsBySpecificity ( this . getViewItemsForOutlet ( outletId ) ) ;
698586 viewStack . some ( matchView ) ;
699587 if ( ! viewItem && allowDefaultMatch ) viewStack . some ( matchDefaultRoute ) ;
700588 } else {
701- const viewItems = sortBySpecificity ( this . getAllViewItems ( ) ) ;
589+ const viewItems = sortViewsBySpecificity ( this . getAllViewItems ( ) ) ;
702590 viewItems . some ( matchView ) ;
703591 if ( ! viewItem && allowDefaultMatch ) viewItems . some ( matchDefaultRoute ) ;
704592 }
0 commit comments