chore(react-router): refactor

This commit is contained in:
ShaneK
2025-12-02 07:49:53 -08:00
parent 397b6f7148
commit 71e55addf9
7 changed files with 887 additions and 1019 deletions

View File

@@ -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);
}

View File

File diff suppressed because it is too large Load Diff

View 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 };
};

View File

@@ -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;
}

View File

@@ -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;
}

View 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);
};

View 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;
});
};