mirror of
https://github.com/ionic-team/ionic-framework.git
synced 2025-08-18 19:21:34 +08:00
refactor(reactrouterviewstack): update for rr6 and improvements
This commit is contained in:
@ -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 Ionic’s
|
||||||
|
* 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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
Reference in New Issue
Block a user