refactor(reactrouterviewstack): update for rr6 and improvements

This commit is contained in:
Maria Hutt
2025-05-08 14:54:40 -07:00
parent f0127bd874
commit cccf290670

View File

@ -1,20 +1,44 @@
/**
* ReactRouterViewStack is a custom navigation manager used in Ionic React apps
* to map React Router route elements (such as <IonRoute>) to "view items" that
* Ionic can manage in a view stack. This is critical to maintain Ionics
* animation, lifecycle, and history behavior across views.
*
* Key responsibilities:
* - Tracking route-based React elements and associating them with unique view items
* - Mapping routes to components via matchPath (React Router v6 compatible)
* - Controlling mount/unmount behavior based on active routes
* - Supporting nested outlets and default routes
*/
import type { RouteInfo, ViewItem } from '@ionic/react'; import type { RouteInfo, ViewItem } from '@ionic/react';
import { IonRoute, ViewLifeCycleManager, ViewStacks, generateId } from '@ionic/react'; import {
IonRoute,
ViewLifeCycleManager,
ViewStacks,
generateId,
} from '@ionic/react';
import React from 'react'; import React from 'react';
import type { PathMatch } from 'react-router';
import { Routes } from 'react-router';
import { matchPath } from './utils/matchPath'; import { matchPath } from './utils/matchPath';
export class ReactRouterViewStack extends ViewStacks { export class ReactRouterViewStack extends ViewStacks {
constructor() { constructor() {
super(); super();
this.createViewItem = this.createViewItem.bind(this);
this.findViewItemByRouteInfo = this.findViewItemByRouteInfo.bind(this);
this.findLeavingViewItemByRouteInfo = this.findLeavingViewItemByRouteInfo.bind(this);
this.getChildrenToRender = this.getChildrenToRender.bind(this);
this.findViewItemByPathname = this.findViewItemByPathname.bind(this);
} }
createViewItem(outletId: string, reactElement: React.ReactElement, routeInfo: RouteInfo, page?: HTMLElement) { /**
* Creates a new view item for the given outlet and react route element.
* Associates route props with the matched route path for further lookups.
*/
createViewItem = (
outletId: string,
reactElement: React.ReactElement,
routeInfo: RouteInfo,
page?: HTMLElement
) => {
const viewItem: ViewItem = { const viewItem: ViewItem = {
id: generateId('viewItem'), id: generateId('viewItem'),
outletId, outletId,
@ -38,149 +62,216 @@ export class ReactRouterViewStack extends ViewStacks {
}; };
return viewItem; return viewItem;
};
/**
* Renders a ViewLifeCycleManager for the given view item.
* Handles cleanup if the view no longer matches.
*
* - Deactivates view if it no longer matches the current route
* - Wraps the route element in <Routes> to support nested routing and ensure remounting
* - Adds a unique key to <Routes> so React Router remounts routes when switching
*/
private renderViewItem = (viewItem: ViewItem, routeInfo: RouteInfo) => {
const match = matchComponent(viewItem.reactElement, routeInfo.pathname);
if (!match && viewItem.routeData.match) {
this.deactivateView(viewItem);
} }
getChildrenToRender(outletId: string, ionRouterOutlet: React.ReactElement, routeInfo: RouteInfo) { return (
<ViewLifeCycleManager
key={`view-${viewItem.id}`}
mount={viewItem.mount}
removeView={() => this.remove(viewItem)}
>
{/**
* Wrapped in <Routes> to ensure React Router v6 correctly processes nested route elements
* `key` is provided to enforce remounting of Routes when switching between view items.
*/}
<Routes key={`routes-${viewItem.id}`}>
{React.cloneElement(viewItem.reactElement)}
</Routes>
</ViewLifeCycleManager>
);
};
/**
* Re-renders all active view items for the specified outlet.
* Ensures React elements are updated with the latest match.
*
* 1. Iterates through children of IonRouterOutlet
* 2. Updates each matching viewItem with the current child React element
* (important for updating props or changes to elements)
* 3. Returns a list of React components that will be rendered inside the outlet
* Each view is wrapped in <ViewLifeCycleManager> to manage lifecycle and rendering
*/
getChildrenToRender = (
outletId: string,
ionRouterOutlet: React.ReactElement,
routeInfo: RouteInfo
) => {
const viewItems = this.getViewItemsForOutlet(outletId); const viewItems = this.getViewItemsForOutlet(outletId);
// Sync latest routes with viewItems // Sync child elements with stored viewItems (e.g. to reflect new props)
React.Children.forEach(ionRouterOutlet.props.children, (child: React.ReactElement) => { React.Children.forEach(
const viewItem = viewItems.find((v) => { ionRouterOutlet.props.children,
return matchComponent(child, v.routeData.childProps.path || v.routeData.childProps.from); (child: React.ReactElement) => {
}); const viewItem = viewItems.find((v) =>
matchComponent(child, v.routeData.childProps.path)
);
if (viewItem) { if (viewItem) {
viewItem.reactElement = child; viewItem.reactElement = child;
} }
}); }
const children = viewItems.map((viewItem) => {
let clonedChild;
if (viewItem.ionRoute && !viewItem.disableIonPageManagement) {
clonedChild = (
<ViewLifeCycleManager
key={`view-${viewItem.id}`}
mount={viewItem.mount}
removeView={() => this.remove(viewItem)}
>
{React.cloneElement(viewItem.reactElement, {
computedMatch: viewItem.routeData.match,
})}
</ViewLifeCycleManager>
);
} else {
const match = matchComponent(viewItem.reactElement, routeInfo.pathname);
clonedChild = (
<ViewLifeCycleManager
key={`view-${viewItem.id}`}
mount={viewItem.mount}
removeView={() => this.remove(viewItem)}
>
{React.cloneElement(viewItem.reactElement, {
computedMatch: viewItem.routeData.match,
})}
</ViewLifeCycleManager>
); );
if (!match && viewItem.routeData.match) { // Render all view items using renderViewItem
viewItem.routeData.match = undefined; return viewItems.map((viewItem) => this.renderViewItem(viewItem, routeInfo));
viewItem.mount = false; };
}
}
return clonedChild; /**
}); * Finds a view item matching the current route, optionally updating its match state.
return children; */
} findViewItemByRouteInfo = (
routeInfo: RouteInfo,
findViewItemByRouteInfo(routeInfo: RouteInfo, outletId?: string, updateMatch?: boolean) { outletId?: string,
const { viewItem, match } = this.findViewItemByPath(routeInfo.pathname, outletId); updateMatch?: boolean
) => {
const { viewItem, match } = this.findViewItemByPath(
routeInfo.pathname,
outletId
);
const shouldUpdateMatch = updateMatch === undefined || updateMatch === true; const shouldUpdateMatch = updateMatch === undefined || updateMatch === true;
if (shouldUpdateMatch && viewItem && match) { if (shouldUpdateMatch && viewItem && match) {
viewItem.routeData.match = match; viewItem.routeData.match = match;
} }
return viewItem; return viewItem;
} };
findLeavingViewItemByRouteInfo(routeInfo: RouteInfo, outletId?: string, mustBeIonRoute = true) {
const { viewItem } = this.findViewItemByPath(routeInfo.lastPathname!, outletId, mustBeIonRoute);
return viewItem;
}
findViewItemByPathname(pathname: string, outletId?: string) {
const { viewItem } = this.findViewItemByPath(pathname, outletId);
return viewItem;
}
/** /**
* Returns the matching view item and the match result for a given pathname. * Finds the view item that was previously active before a route change.
*/ */
private findViewItemByPath(pathname: string, outletId?: string, mustBeIonRoute?: boolean) { findLeavingViewItemByRouteInfo = (
routeInfo: RouteInfo,
outletId?: string,
mustBeIonRoute = true
) => {
const { viewItem } = this.findViewItemByPath(
routeInfo.lastPathname!,
outletId,
mustBeIonRoute
);
return viewItem;
};
/**
* Finds a view item by pathname only, used in simpler queries.
*/
findViewItemByPathname = (pathname: string, outletId?: string) => {
const { viewItem } = this.findViewItemByPath(pathname, outletId);
return viewItem;
};
/**
* Core function that matches a given pathname against all view items.
* Returns both the matched view item and match metadata.
*/
private findViewItemByPath(
pathname: string,
outletId?: string,
mustBeIonRoute?: boolean
) {
let viewItem: ViewItem | undefined; let viewItem: ViewItem | undefined;
let match: ReturnType<typeof matchPath> | undefined; let match: PathMatch<string> | null = null;
let viewStack: ViewItem[]; let viewStack: ViewItem[];
if (outletId) { if (outletId) {
viewStack = this.getViewItemsForOutlet(outletId); viewStack = this.getViewItemsForOutlet(outletId);
viewStack.some(matchView); viewStack.some(matchView);
if (!viewItem) { if (!viewItem) viewStack.some(matchDefaultRoute);
viewStack.some(matchDefaultRoute);
}
} else { } else {
const viewItems = this.getAllViewItems(); const viewItems = this.getAllViewItems();
viewItems.some(matchView); viewItems.some(matchView);
if (!viewItem) { if (!viewItem) viewItems.some(matchDefaultRoute);
viewItems.some(matchDefaultRoute);
} }
if (!viewItem && process.env.NODE_ENV !== 'production') {
console.warn(`[ReactRouterViewStack] No matching view item found for: ${pathname}`);
} }
return { viewItem, match }; return { viewItem, match };
/**
* Matches a route path with dynamic parameters (e.g. /tabs/:id)
*/
function matchView(v: ViewItem) { function matchView(v: ViewItem) {
if (mustBeIonRoute && !v.ionRoute) { if (mustBeIonRoute && !v.ionRoute) return false;
return false;
}
match = matchPath({ const result = matchPath({
pathname, pathname,
componentProps: v.routeData.childProps, componentProps: v.routeData.childProps,
}); });
if (match) { if (result) {
const hasParams = result.params && Object.keys(result.params).length > 0;
const previousMatch = v.routeData?.match;
const isSamePath = result.pathname === previousMatch?.pathname;
if (!hasParams || isSamePath) {
match = result;
viewItem = v;
return true;
}
}
return false;
}
/** /**
* Even though we have a match from react-router, we do not know if the match * Matches a view with no path prop (default fallback route).
* is for this specific view item.
*
* To validate this, we need to check if the path and url match the view item's route data.
*/ */
const hasParameter = match.path.includes(':');
if (!hasParameter || (hasParameter && match.url === v.routeData?.match?.url)) {
viewItem = v;
return true;
}
}
return false;
}
function matchDefaultRoute(v: ViewItem) { function matchDefaultRoute(v: ViewItem) {
// try to find a route that doesn't have a path or from prop, that will be our default route if (!v.routeData.childProps.path) {
if (!v.routeData.childProps.path && !v.routeData.childProps.from) { match = createDefaultMatch(pathname);
match = {
path: pathname,
url: pathname,
isExact: true,
params: {},
};
viewItem = v; viewItem = v;
return true; return true;
} }
return false; return false;
} }
} }
/**
* Unmounts a view by clearing its match and setting mount to false.
*/
private deactivateView = (viewItem: ViewItem) => {
viewItem.routeData.match = undefined; // clear it so it's no longer active
viewItem.mount = false; // do not display the view anymore
};
} }
/**
* Utility to apply matchPath to a React element and return its match state.
*/
function matchComponent(node: React.ReactElement, pathname: string) { function matchComponent(node: React.ReactElement, pathname: string) {
return matchPath({ return matchPath({
pathname, pathname,
componentProps: node.props, componentProps: node.props,
}); });
} }
/**
* Creates a default match object for a fallback route.
*/
function createDefaultMatch(pathname: string): PathMatch<string> {
return {
params: {},
pathname,
pathnameBase: pathname,
pattern: {
path: pathname,
caseSensitive: false,
end: true,
},
};
}