feat(IonRouter): migrate to functional component with react router 6

Co-authored-by: Sean Perkins <13732623+sean-perkins@users.noreply.github.com>
This commit is contained in:
Maria Hutt
2025-06-09 13:07:24 -07:00
parent 37f76c72ec
commit a78f6b3151

View File

@ -1,16 +1,9 @@
import type { import type { AnimationBuilder, RouteAction, RouteInfo, RouteManagerContextState, RouterDirection } from '@ionic/react';
AnimationBuilder,
RouteAction,
RouteInfo,
RouteManagerContextState,
RouterDirection,
ViewItem,
} from '@ionic/react';
import { LocationHistory, NavManager, RouteManagerContext, generateId, getConfig } from '@ionic/react'; import { LocationHistory, NavManager, RouteManagerContext, generateId, getConfig } from '@ionic/react';
import type { Action as HistoryAction, Location as HistoryLocation } from 'history'; import type { Action as HistoryAction, Location as HistoryLocation } from 'history';
import React from 'react'; import type { PropsWithChildren } from 'react';
import type { RouteComponentProps } from 'react-router-dom'; import React, { useEffect, useRef, useState } from 'react';
import { withRouter } from 'react-router-dom'; import { useLocation, useNavigate, useParams } from 'react-router-dom';
import { IonRouteInner } from './IonRouteInner'; import { IonRouteInner } from './IonRouteInner';
import { ReactRouterViewStack } from './ReactRouterViewStack'; import { ReactRouterViewStack } from './ReactRouterViewStack';
@ -21,162 +14,178 @@ export interface LocationState {
routerOptions?: { as?: string; unmount?: boolean }; routerOptions?: { as?: string; unmount?: boolean };
} }
interface IonRouteProps extends RouteComponentProps<{}, {}, LocationState> { interface IonRouterProps {
registerHistoryListener: (cb: (location: HistoryLocation<any>, action: HistoryAction) => void) => void; registerHistoryListener: (cb: (location: HistoryLocation<any>, action: HistoryAction) => void) => void;
} }
interface IonRouteState { export const IonRouter = ({ children, registerHistoryListener }: PropsWithChildren<IonRouterProps>) => {
routeInfo: RouteInfo; const location = useLocation();
} const params = useParams();
const navigate = useNavigate();
class IonRouterInner extends React.PureComponent<IonRouteProps, IonRouteState> { const didMountRef = useRef(false);
currentTab?: string; const locationHistory = useRef(new LocationHistory());
exitViewFromOtherOutletHandlers: ((pathname: string) => ViewItem | undefined)[] = []; const currentTab = useRef<string | undefined>(undefined);
incomingRouteParams?: Partial<RouteInfo>; const viewStack = useRef(new ReactRouterViewStack());
locationHistory = new LocationHistory(); const incomingRouteParams = useRef<Partial<RouteInfo> | null>(null);
viewStack = new ReactRouterViewStack();
routeMangerContextState: RouteManagerContextState = {
canGoBack: () => this.locationHistory.canGoBack(),
clearOutlet: this.viewStack.clear,
findViewItemByPathname: this.viewStack.findViewItemByPathname,
getChildrenToRender: this.viewStack.getChildrenToRender,
goBack: () => this.handleNavigateBack(),
createViewItem: this.viewStack.createViewItem,
findViewItemByRouteInfo: this.viewStack.findViewItemByRouteInfo,
findLeavingViewItemByRouteInfo: this.viewStack.findLeavingViewItemByRouteInfo,
addViewItem: this.viewStack.add,
unMountViewItem: this.viewStack.remove,
};
constructor(props: IonRouteProps) { const [routeInfo, setRouteInfo] = useState({
super(props); id: generateId('routeInfo'),
pathname: location.pathname,
search: location.search,
});
const routeInfo = { useEffect(() => {
id: generateId('routeInfo'), didMountRef.current = true;
pathname: this.props.location.pathname, }, []);
search: this.props.location.search,
};
this.locationHistory.add(routeInfo); /**
this.handleChangeTab = this.handleChangeTab.bind(this); * Triggered whenever the history changes, either through user navigation
this.handleResetTab = this.handleResetTab.bind(this); * or programmatic changes. It transforms the raw browser history changes
this.handleNativeBack = this.handleNativeBack.bind(this); * into `RouteInfo` objects, which are needed Ionic's animations and
this.handleNavigate = this.handleNavigate.bind(this); * navigation patterns.
this.handleNavigateBack = this.handleNavigateBack.bind(this); *
this.props.registerHistoryListener(this.handleHistoryChange.bind(this)); * @param location The current location object from the history.
this.handleSetCurrentTab = this.handleSetCurrentTab.bind(this); * @param action The action that triggered the history change.
*/
this.state = { const handleHistoryChange = (location: HistoryLocation<LocationState>, action: HistoryAction) => {
routeInfo,
};
}
handleChangeTab(tab: string, path?: string, routeOptions?: any) {
if (!path) {
return;
}
const routeInfo = this.locationHistory.getCurrentRouteInfoForTab(tab);
const [pathname, search] = path.split('?');
if (routeInfo) {
this.incomingRouteParams = { ...routeInfo, routeAction: 'push', routeDirection: 'none' };
if (routeInfo.pathname === pathname) {
this.incomingRouteParams.routeOptions = routeOptions;
this.props.history.push(routeInfo.pathname + (routeInfo.search || ''));
} else {
this.incomingRouteParams.pathname = pathname;
this.incomingRouteParams.search = search ? '?' + search : undefined;
this.incomingRouteParams.routeOptions = routeOptions;
this.props.history.push(pathname + (search ? '?' + search : ''));
}
} else {
this.handleNavigate(pathname, 'push', 'none', undefined, routeOptions, tab);
}
}
handleHistoryChange(location: HistoryLocation<LocationState>, action: HistoryAction) {
let leavingLocationInfo: RouteInfo; let leavingLocationInfo: RouteInfo;
if (this.incomingRouteParams) { /**
if (this.incomingRouteParams.routeAction === 'replace') { * A programmatic navigation was triggered.
leavingLocationInfo = this.locationHistory.previous(); * e.g., `<Redirect />`, `history.push()`, or `handleNavigate()`
*/
if (incomingRouteParams) {
/**
* The current history entry is overwritten, so the previous entry
* is the one we are leaving.
*/
if (incomingRouteParams.current?.routeAction === 'replace') {
leavingLocationInfo = locationHistory.current.previous();
} else { } else {
leavingLocationInfo = this.locationHistory.current(); // If the action is 'push' or 'pop', we want to use the current route.
leavingLocationInfo = locationHistory.current.current();
} }
} else { } else {
leavingLocationInfo = this.locationHistory.current(); /**
* An external navigation was triggered
* e.g., browser back/forward button or direct link
*
* The leaving location is the current route.
*/
leavingLocationInfo = locationHistory.current.current();
} }
const leavingUrl = leavingLocationInfo.pathname + leavingLocationInfo.search; const leavingUrl = leavingLocationInfo.pathname + leavingLocationInfo.search;
// Check if the URL has changed.
if (leavingUrl !== location.pathname) { if (leavingUrl !== location.pathname) {
if (!this.incomingRouteParams) { // An external navigation was triggered.
if (!incomingRouteParams.current) {
/**
* A `REPLACE` action can be triggered by React Router's
* `<Redirect />` component.
*/
if (action === 'REPLACE') { if (action === 'REPLACE') {
this.incomingRouteParams = { incomingRouteParams.current = {
routeAction: 'replace', routeAction: 'replace',
routeDirection: 'none', routeDirection: 'none',
tab: this.currentTab, tab: currentTab.current,
}; };
} }
/**
* A `POP` action can be triggered by the browser's back/forward
* button.
*/
if (action === 'POP') { if (action === 'POP') {
const currentRoute = this.locationHistory.current(); const currentRoute = locationHistory.current.current();
/**
* Check if the current route was "pushed" by a previous route
* (indicates a linear history path).
*/
if (currentRoute && currentRoute.pushedByRoute) { if (currentRoute && currentRoute.pushedByRoute) {
const prevInfo = this.locationHistory.findLastLocation(currentRoute); const prevInfo = locationHistory.current.findLastLocation(currentRoute);
this.incomingRouteParams = { ...prevInfo, routeAction: 'pop', routeDirection: 'back' }; incomingRouteParams.current = { ...prevInfo, routeAction: 'pop', routeDirection: 'back' };
// It's a non-linear history path like a direct link.
} else { } else {
this.incomingRouteParams = { incomingRouteParams.current = {
routeAction: 'pop', routeAction: 'pop',
routeDirection: 'none', routeDirection: 'none',
tab: this.currentTab, tab: currentTab.current,
}; };
} }
} }
if (!this.incomingRouteParams) { // Still found no params, set it to a default state of forward.
this.incomingRouteParams = { if (!incomingRouteParams.current) {
incomingRouteParams.current = {
routeAction: 'push', routeAction: 'push',
routeDirection: location.state?.direction || 'forward', routeDirection: location.state?.direction || 'forward',
routeOptions: location.state?.routerOptions, routeOptions: location.state?.routerOptions,
tab: this.currentTab, tab: currentTab.current,
}; };
} }
} }
let routeInfo: RouteInfo; let routeInfo: RouteInfo;
if (this.incomingRouteParams?.id) { /**
* An existing id indicates that it's re-activating an existing route.
* e.g., tab switching or navigating back to a previous route
*/
if (incomingRouteParams.current?.id) {
routeInfo = { routeInfo = {
...(this.incomingRouteParams as RouteInfo), ...(incomingRouteParams.current as RouteInfo),
lastPathname: leavingLocationInfo.pathname, lastPathname: leavingLocationInfo.pathname,
}; };
this.locationHistory.add(routeInfo); locationHistory.current.add(routeInfo);
/**
* A new route is being created since it's not re-activating
* an existing route.
*/
} else { } else {
const isPushed = const isPushed =
this.incomingRouteParams.routeAction === 'push' && this.incomingRouteParams.routeDirection === 'forward'; incomingRouteParams.current?.routeAction === 'push' &&
incomingRouteParams.current.routeDirection === 'forward';
routeInfo = { routeInfo = {
id: generateId('routeInfo'), id: generateId('routeInfo'),
...this.incomingRouteParams, ...incomingRouteParams,
lastPathname: leavingLocationInfo.pathname, lastPathname: leavingLocationInfo.pathname, // The URL we just came from
pathname: location.pathname, pathname: location.pathname, // The current (destination) URL
search: location.search, search: location.search,
params: this.props.match.params, params: params as { [key: string]: string | string[] },
prevRouteLastPathname: leavingLocationInfo.lastPathname, prevRouteLastPathname: leavingLocationInfo.lastPathname, // The lastPathname of the route we are leaving
}; };
// It's a linear navigation.
if (isPushed) { if (isPushed) {
routeInfo.tab = leavingLocationInfo.tab; routeInfo.tab = leavingLocationInfo.tab;
routeInfo.pushedByRoute = leavingLocationInfo.pathname; routeInfo.pushedByRoute = leavingLocationInfo.pathname;
// Triggered by a browser back button or handleNavigateBack.
} else if (routeInfo.routeAction === 'pop') { } else if (routeInfo.routeAction === 'pop') {
const r = this.locationHistory.findLastLocation(routeInfo); // Find the route that pushed this one.
const r = locationHistory.current.findLastLocation(routeInfo);
routeInfo.pushedByRoute = r?.pushedByRoute; routeInfo.pushedByRoute = r?.pushedByRoute;
// Navigating to a new tab.
} else if (routeInfo.routeAction === 'push' && routeInfo.tab !== leavingLocationInfo.tab) { } else if (routeInfo.routeAction === 'push' && routeInfo.tab !== leavingLocationInfo.tab) {
// If we are switching tabs grab the last route info for the tab and use its pushedByRoute /**
const lastRoute = this.locationHistory.getCurrentRouteInfoForTab(routeInfo.tab); * If we are switching tabs grab the last route info for the
* tab and use its `pushedByRoute`.
*/
const lastRoute = locationHistory.current.getCurrentRouteInfoForTab(routeInfo.tab);
// This helps maintain correct back stack behavior within tabs.
routeInfo.pushedByRoute = lastRoute?.pushedByRoute; routeInfo.pushedByRoute = lastRoute?.pushedByRoute;
// Triggered by `history.replace()` or a `<Redirect />` component, etc.
} else if (routeInfo.routeAction === 'replace') { } else if (routeInfo.routeAction === 'replace') {
// Make sure to set the lastPathname, etc.. to the current route so the page transitions out /**
const currentRouteInfo = this.locationHistory.current(); * Make sure to set the `lastPathname`, etc.. to the current route
* so the page transitions out.
*/
const currentRouteInfo = locationHistory.current.current();
/** /**
* If going from /home to /child, then replacing from * Special handling for `replace` to ensure correct `pushedByRoute`
* /child to /home, we don't want the route info to * and `lastPathname`.
* say that /home was pushed by /home which is not correct. *
* If going from `/home` to `/child`, then replacing from
* `/child` to `/home`, we don't want the route info to
* say that `/home` was pushed by `/home` which is not correct.
*/ */
const currentPushedBy = currentRouteInfo?.pushedByRoute; const currentPushedBy = currentRouteInfo?.pushedByRoute;
const pushedByRoute = const pushedByRoute =
@ -198,58 +207,127 @@ class IonRouterInner extends React.PureComponent<IonRouteProps, IonRouteState> {
routeInfo.routeAnimation = routeInfo.routeAnimation || currentRouteInfo?.routeAnimation; routeInfo.routeAnimation = routeInfo.routeAnimation || currentRouteInfo?.routeAnimation;
} }
this.locationHistory.add(routeInfo); locationHistory.current.add(routeInfo);
} }
setRouteInfo(routeInfo);
this.setState({
routeInfo,
});
} }
this.incomingRouteParams = undefined; // Reset for the next navigation.
} incomingRouteParams.current = null;
};
/** /**
* history@4.x uses goBack(), history@5.x uses back() * Resets the specified tab to its initial, root route.
* TODO: If support for React Router <=5 is dropped *
* this logic is no longer needed. We can just * @param tab The tab to reset.
* assume back() is available. * @param originalHref The original href for the tab.
* @param originalRouteOptions The original route options for the tab.
*/ */
handleNativeBack() { const handleResetTab = (tab: string, originalHref: string, originalRouteOptions: any) => {
const history = this.props.history as any; const routeInfo = locationHistory.current.getFirstRouteInfoForTab(tab);
const goBack = history.goBack || history.back; if (routeInfo) {
goBack(); const newRouteInfo = { ...routeInfo };
} newRouteInfo.pathname = originalHref;
newRouteInfo.routeOptions = originalRouteOptions;
handleNavigate( incomingRouteParams.current = { ...newRouteInfo, routeAction: 'pop', routeDirection: 'back' };
path: string, navigate(newRouteInfo.pathname + (newRouteInfo.search || ''));
routeAction: RouteAction,
routeDirection?: RouterDirection,
routeAnimation?: AnimationBuilder,
routeOptions?: any,
tab?: string
) {
this.incomingRouteParams = Object.assign(this.incomingRouteParams || {}, {
routeAction,
routeDirection,
routeOptions,
routeAnimation,
tab,
});
if (routeAction === 'push') {
this.props.history.push(path);
} else {
this.props.history.replace(path);
} }
} };
handleNavigateBack(defaultHref: string | RouteInfo = '/', routeAnimation?: AnimationBuilder) { /**
* Handles tab changes.
*
* @param tab The tab to switch to.
* @param path The new path for the tab.
* @param routeOptions Additional route options.
*/
const handleChangeTab = (tab: string, path?: string, routeOptions?: any) => {
if (!path) {
return;
}
const routeInfo = locationHistory.current.getCurrentRouteInfoForTab(tab);
const [pathname, search] = path.split('?');
// User has navigated to the current tab before.
if (routeInfo) {
const routeParams = {
...routeInfo,
routeAction: 'push' as RouteAction,
routeDirection: 'none' as RouterDirection,
};
/**
* User is navigating to the same tab.
* e.g., `/tabs/home` → `/tabs/home`
*/
if (routeInfo.pathname === pathname) {
incomingRouteParams.current = {
...routeParams,
routeOptions,
};
navigate(routeInfo.pathname + (routeInfo.search || ''));
/**
* User is navigating to a different tab.
* e.g., `/tabs/home` → `/tabs/settings`
*/
} else {
incomingRouteParams.current = {
...routeParams,
pathname,
search: search ? '?' + search : undefined,
routeOptions,
};
navigate(pathname + (search ? '?' + search : ''));
}
// User has not navigated to this tab before.
} else {
handleNavigate(pathname, 'push', 'none', undefined, routeOptions, tab);
}
};
/**
* Set the current active tab in `locationHistory`.
* This is crucial for maintaining tab history since each tab has
* its own navigation stack.
*
* @param tab The tab to set as active.
*/
const handleSetCurrentTab = (tab: string) => {
currentTab.current = tab;
const ri = { ...locationHistory.current.current() };
if (ri.tab !== tab) {
ri.tab = tab;
locationHistory.current.update(ri);
}
};
/**
* Handles the native back button press.
* It's usually called when a user presses the platform-native back action.
*/
const handleNativeBack = () => {
navigate(-1);
};
/**
* Used to manage the back navigation within the Ionic React's routing
* system. It's deeply integrated with Ionic's view lifecycle, animations,
* and its custom history tracking (`locationHistory`) to provide a
* native-like transition and maintain correct application state.
*
* @param defaultHref The fallback URL to navigate to if there's no
* previous entry in the `locationHistory` stack.
* @param routeAnimation A custom animation builder to override the
* default "back" animation.
*/
const handleNavigateBack = (defaultHref: string | RouteInfo = '/', routeAnimation?: AnimationBuilder) => {
const config = getConfig(); const config = getConfig();
defaultHref = defaultHref ? defaultHref : config && config.get('backButtonDefaultHref' as any); defaultHref = defaultHref ? defaultHref : config && config.get('backButtonDefaultHref' as any);
const routeInfo = this.locationHistory.current(); const routeInfo = locationHistory.current.current();
// It's a linear navigation.
if (routeInfo && routeInfo.pushedByRoute) { if (routeInfo && routeInfo.pushedByRoute) {
const prevInfo = this.locationHistory.findLastLocation(routeInfo); const prevInfo = locationHistory.current.findLastLocation(routeInfo);
if (prevInfo) { if (prevInfo) {
/** /**
* This needs to be passed to handleNavigate * This needs to be passed to handleNavigate
@ -257,12 +335,16 @@ class IonRouterInner extends React.PureComponent<IonRouteProps, IonRouteState> {
* will be overridden. * will be overridden.
*/ */
const incomingAnimation = routeAnimation || routeInfo.routeAnimation; const incomingAnimation = routeAnimation || routeInfo.routeAnimation;
this.incomingRouteParams = { incomingRouteParams.current = {
...prevInfo, ...prevInfo,
routeAction: 'pop', routeAction: 'pop',
routeDirection: 'back', routeDirection: 'back',
routeAnimation: incomingAnimation, routeAnimation: incomingAnimation,
}; };
/**
* Check if it's a simple linear back navigation (not tabbed).
* e.g., `/home` → `/settings` → back to `/home`
*/
if ( if (
routeInfo.lastPathname === routeInfo.pushedByRoute || routeInfo.lastPathname === routeInfo.pushedByRoute ||
/** /**
@ -273,68 +355,98 @@ class IonRouterInner extends React.PureComponent<IonRouteProps, IonRouteState> {
*/ */
(prevInfo.pathname === routeInfo.pushedByRoute && routeInfo.tab === '' && prevInfo.tab === '') (prevInfo.pathname === routeInfo.pushedByRoute && routeInfo.tab === '' && prevInfo.tab === '')
) { ) {
/** navigate(-1);
* history@4.x uses goBack(), history@5.x uses back()
* TODO: If support for React Router <=5 is dropped
* this logic is no longer needed. We can just
* assume back() is available.
*/
const history = this.props.history as any;
const goBack = history.goBack || history.back;
goBack();
} else { } else {
this.handleNavigate(prevInfo.pathname + (prevInfo.search || ''), 'pop', 'back', incomingAnimation); /**
* It's a non-linear back navigation.
* e.g., direct link or tab switch or nested navigation with redirects
*/
handleNavigate(prevInfo.pathname + (prevInfo.search || ''), 'pop', 'back', incomingAnimation);
} }
/**
* `pushedByRoute` exists, but no corresponding previous entry in
* the history stack.
*/
} else { } else {
this.handleNavigate(defaultHref as string, 'pop', 'back', routeAnimation); handleNavigate(defaultHref as string, 'pop', 'back', routeAnimation);
} }
/**
* No `pushedByRoute`
* e.g., initial page load
*/
} else { } else {
this.handleNavigate(defaultHref as string, 'pop', 'back', routeAnimation); handleNavigate(defaultHref as string, 'pop', 'back', routeAnimation);
} }
};
/**
* Used to programmatically navigate through the app.
*
* @param path The path to navigate to.
* @param routeAction The action to take (push, replace, etc.).
* @param routeDirection The direction of the navigation (forward,
* back, etc.).
* @param routeAnimation The animation to use for the transition.
* @param routeOptions Additional options for the route.
* @param tab The tab to navigate to, if applicable.
*/
const handleNavigate = (
path: string,
routeAction: RouteAction,
routeDirection?: RouterDirection,
routeAnimation?: AnimationBuilder,
routeOptions?: any,
tab?: string
) => {
incomingRouteParams.current = Object.assign(incomingRouteParams.current || {}, {
routeAction,
routeDirection,
routeOptions,
routeAnimation,
tab,
});
navigate(path, { replace: routeAction !== 'push' });
};
if (!didMountRef.current) {
locationHistory.current.add(routeInfo);
registerHistoryListener(handleHistoryChange);
} }
handleResetTab(tab: string, originalHref: string, originalRouteOptions: any) { const routeMangerContextValue: RouteManagerContextState = {
const routeInfo = this.locationHistory.getFirstRouteInfoForTab(tab); canGoBack: () => locationHistory.current.canGoBack(),
if (routeInfo) { clearOutlet: viewStack.current.clear,
const newRouteInfo = { ...routeInfo }; findViewItemByPathname: viewStack.current.findViewItemByPathname,
newRouteInfo.pathname = originalHref; getChildrenToRender: viewStack.current.getChildrenToRender,
newRouteInfo.routeOptions = originalRouteOptions; goBack: () => handleNavigateBack(),
this.incomingRouteParams = { ...newRouteInfo, routeAction: 'pop', routeDirection: 'back' }; createViewItem: viewStack.current.createViewItem,
this.props.history.push(newRouteInfo.pathname + (newRouteInfo.search || '')); findViewItemByRouteInfo: viewStack.current.findViewItemByRouteInfo,
} findLeavingViewItemByRouteInfo: viewStack.current.findLeavingViewItemByRouteInfo,
} addViewItem: viewStack.current.add,
unMountViewItem: viewStack.current.remove,
};
handleSetCurrentTab(tab: string) { return (
this.currentTab = tab; <RouteManagerContext.Provider value={routeMangerContextValue}>
const ri = { ...this.locationHistory.current() }; <NavManager
if (ri.tab !== tab) { ionRoute={IonRouteInner}
ri.tab = tab; ionRedirect={{}}
this.locationHistory.update(ri); stackManager={StackManager}
} routeInfo={routeInfo}
} onNativeBack={handleNativeBack}
onNavigateBack={handleNavigateBack}
onNavigate={handleNavigate}
onSetCurrentTab={handleSetCurrentTab}
onChangeTab={handleChangeTab}
onResetTab={handleResetTab}
locationHistory={locationHistory.current}
>
{children}
</NavManager>
</RouteManagerContext.Provider>
);
};
render() {
return (
<RouteManagerContext.Provider value={this.routeMangerContextState}>
<NavManager
ionRoute={IonRouteInner}
ionRedirect={{}}
stackManager={StackManager}
routeInfo={this.state.routeInfo!}
onNativeBack={this.handleNativeBack}
onNavigateBack={this.handleNavigateBack}
onNavigate={this.handleNavigate}
onSetCurrentTab={this.handleSetCurrentTab}
onChangeTab={this.handleChangeTab}
onResetTab={this.handleResetTab}
locationHistory={this.locationHistory}
>
{this.props.children}
</NavManager>
</RouteManagerContext.Provider>
);
}
}
export const IonRouter = withRouter(IonRouterInner);
IonRouter.displayName = 'IonRouter'; IonRouter.displayName = 'IonRouter';