mirror of
https://github.com/ionic-team/ionic-framework.git
synced 2026-03-13 10:22:08 +08:00
chore(react-router): refactor
This commit is contained in:
@@ -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<string> | 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);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
274
packages/react-router/src/ReactRouter/utils/computeParentPath.ts
Normal file
274
packages/react-router/src/ReactRouter/utils/computeParentPath.ts
Normal file
@@ -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 };
|
||||
};
|
||||
@@ -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 `<Routes />` 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 `<Routes />` component are most likely
|
||||
// The children of the `<Routes />` component are most likely
|
||||
// (and should be) the `<Route />` components.
|
||||
return (routesNode as React.ReactElement).props.children;
|
||||
}
|
||||
@@ -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<ReturnType<typeof matchRoutes>>[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;
|
||||
}
|
||||
37
packages/react-router/src/ReactRouter/utils/normalizePath.ts
Normal file
37
packages/react-router/src/ReactRouter/utils/normalizePath.ts
Normal file
@@ -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);
|
||||
};
|
||||
43
packages/react-router/src/ReactRouter/utils/routeUtils.ts
Normal file
43
packages/react-router/src/ReactRouter/utils/routeUtils.ts
Normal file
@@ -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;
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user