Skip to content

Commit 71e55ad

Browse files
committed
chore(react-router): refactor
1 parent 397b6f7 commit 71e55ad

File tree

7 files changed

+865
-997
lines changed

7 files changed

+865
-997
lines changed

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

Lines changed: 38 additions & 150 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,25 @@ import type { RouteInfo, ViewItem } from '@ionic/react';
99
import { IonRoute, ViewLifeCycleManager, ViewStacks } from '@ionic/react';
1010
import React from 'react';
1111
import 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';
1415
import { derivePathnameToMatch } from './utils/derivePathnameToMatch';
15-
import { findRoutesNode } from './utils/findRoutesNode';
1616
import { 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

1832
const 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

5755
const 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

Comments
 (0)