From a78f6b3151452489de6ac6c296dcc4df66f4458f Mon Sep 17 00:00:00 2001 From: Maria Hutt Date: Mon, 9 Jun 2025 13:07:24 -0700 Subject: [PATCH] feat(IonRouter): migrate to functional component with react router 6 Co-authored-by: Sean Perkins <13732623+sean-perkins@users.noreply.github.com> --- .../src/ReactRouter/IonRouter.tsx | 526 +++++++++++------- 1 file changed, 319 insertions(+), 207 deletions(-) diff --git a/packages/react-router/src/ReactRouter/IonRouter.tsx b/packages/react-router/src/ReactRouter/IonRouter.tsx index fcadd6561a..7fffbea23a 100644 --- a/packages/react-router/src/ReactRouter/IonRouter.tsx +++ b/packages/react-router/src/ReactRouter/IonRouter.tsx @@ -1,16 +1,9 @@ -import type { - AnimationBuilder, - RouteAction, - RouteInfo, - RouteManagerContextState, - RouterDirection, - ViewItem, -} from '@ionic/react'; +import type { AnimationBuilder, RouteAction, RouteInfo, RouteManagerContextState, RouterDirection } from '@ionic/react'; import { LocationHistory, NavManager, RouteManagerContext, generateId, getConfig } from '@ionic/react'; import type { Action as HistoryAction, Location as HistoryLocation } from 'history'; -import React from 'react'; -import type { RouteComponentProps } from 'react-router-dom'; -import { withRouter } from 'react-router-dom'; +import type { PropsWithChildren } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; +import { useLocation, useNavigate, useParams } from 'react-router-dom'; import { IonRouteInner } from './IonRouteInner'; import { ReactRouterViewStack } from './ReactRouterViewStack'; @@ -21,162 +14,178 @@ export interface LocationState { routerOptions?: { as?: string; unmount?: boolean }; } -interface IonRouteProps extends RouteComponentProps<{}, {}, LocationState> { +interface IonRouterProps { registerHistoryListener: (cb: (location: HistoryLocation, action: HistoryAction) => void) => void; } -interface IonRouteState { - routeInfo: RouteInfo; -} +export const IonRouter = ({ children, registerHistoryListener }: PropsWithChildren) => { + const location = useLocation(); + const params = useParams(); + const navigate = useNavigate(); -class IonRouterInner extends React.PureComponent { - currentTab?: string; - exitViewFromOtherOutletHandlers: ((pathname: string) => ViewItem | undefined)[] = []; - incomingRouteParams?: Partial; - locationHistory = new LocationHistory(); - 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, - }; + const didMountRef = useRef(false); + const locationHistory = useRef(new LocationHistory()); + const currentTab = useRef(undefined); + const viewStack = useRef(new ReactRouterViewStack()); + const incomingRouteParams = useRef | null>(null); - constructor(props: IonRouteProps) { - super(props); + const [routeInfo, setRouteInfo] = useState({ + id: generateId('routeInfo'), + pathname: location.pathname, + search: location.search, + }); - const routeInfo = { - id: generateId('routeInfo'), - pathname: this.props.location.pathname, - search: this.props.location.search, - }; + useEffect(() => { + didMountRef.current = true; + }, []); - this.locationHistory.add(routeInfo); - this.handleChangeTab = this.handleChangeTab.bind(this); - this.handleResetTab = this.handleResetTab.bind(this); - this.handleNativeBack = this.handleNativeBack.bind(this); - this.handleNavigate = this.handleNavigate.bind(this); - this.handleNavigateBack = this.handleNavigateBack.bind(this); - this.props.registerHistoryListener(this.handleHistoryChange.bind(this)); - this.handleSetCurrentTab = this.handleSetCurrentTab.bind(this); - - this.state = { - 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, action: HistoryAction) { + /** + * Triggered whenever the history changes, either through user navigation + * or programmatic changes. It transforms the raw browser history changes + * into `RouteInfo` objects, which are needed Ionic's animations and + * navigation patterns. + * + * @param location The current location object from the history. + * @param action The action that triggered the history change. + */ + const handleHistoryChange = (location: HistoryLocation, action: HistoryAction) => { let leavingLocationInfo: RouteInfo; - if (this.incomingRouteParams) { - if (this.incomingRouteParams.routeAction === 'replace') { - leavingLocationInfo = this.locationHistory.previous(); + /** + * A programmatic navigation was triggered. + * e.g., ``, `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 { - leavingLocationInfo = this.locationHistory.current(); + // If the action is 'push' or 'pop', we want to use the current route. + leavingLocationInfo = locationHistory.current.current(); } } 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; + // Check if the URL has changed. 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 + * `` component. + */ if (action === 'REPLACE') { - this.incomingRouteParams = { + incomingRouteParams.current = { routeAction: 'replace', routeDirection: 'none', - tab: this.currentTab, + tab: currentTab.current, }; } + /** + * A `POP` action can be triggered by the browser's back/forward + * button. + */ 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) { - const prevInfo = this.locationHistory.findLastLocation(currentRoute); - this.incomingRouteParams = { ...prevInfo, routeAction: 'pop', routeDirection: 'back' }; + const prevInfo = locationHistory.current.findLastLocation(currentRoute); + incomingRouteParams.current = { ...prevInfo, routeAction: 'pop', routeDirection: 'back' }; + // It's a non-linear history path like a direct link. } else { - this.incomingRouteParams = { + incomingRouteParams.current = { routeAction: 'pop', routeDirection: 'none', - tab: this.currentTab, + tab: currentTab.current, }; } } - if (!this.incomingRouteParams) { - this.incomingRouteParams = { + // Still found no params, set it to a default state of forward. + if (!incomingRouteParams.current) { + incomingRouteParams.current = { routeAction: 'push', routeDirection: location.state?.direction || 'forward', routeOptions: location.state?.routerOptions, - tab: this.currentTab, + tab: currentTab.current, }; } } 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 = { - ...(this.incomingRouteParams as RouteInfo), + ...(incomingRouteParams.current as RouteInfo), 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 { const isPushed = - this.incomingRouteParams.routeAction === 'push' && this.incomingRouteParams.routeDirection === 'forward'; + incomingRouteParams.current?.routeAction === 'push' && + incomingRouteParams.current.routeDirection === 'forward'; routeInfo = { id: generateId('routeInfo'), - ...this.incomingRouteParams, - lastPathname: leavingLocationInfo.pathname, - pathname: location.pathname, + ...incomingRouteParams, + lastPathname: leavingLocationInfo.pathname, // The URL we just came from + pathname: location.pathname, // The current (destination) URL search: location.search, - params: this.props.match.params, - prevRouteLastPathname: leavingLocationInfo.lastPathname, + params: params as { [key: string]: string | string[] }, + prevRouteLastPathname: leavingLocationInfo.lastPathname, // The lastPathname of the route we are leaving }; + // It's a linear navigation. if (isPushed) { routeInfo.tab = leavingLocationInfo.tab; routeInfo.pushedByRoute = leavingLocationInfo.pathname; + // Triggered by a browser back button or handleNavigateBack. } 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; + // Navigating to a new 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; + // Triggered by `history.replace()` or a `` component, etc. } 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 - * /child to /home, we don't want the route info to - * say that /home was pushed by /home which is not correct. + * Special handling for `replace` to ensure correct `pushedByRoute` + * and `lastPathname`. + * + * 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 pushedByRoute = @@ -198,58 +207,127 @@ class IonRouterInner extends React.PureComponent { routeInfo.routeAnimation = routeInfo.routeAnimation || currentRouteInfo?.routeAnimation; } - this.locationHistory.add(routeInfo); + locationHistory.current.add(routeInfo); } - - this.setState({ - routeInfo, - }); + setRouteInfo(routeInfo); } - this.incomingRouteParams = undefined; - } + // Reset for the next navigation. + incomingRouteParams.current = null; + }; /** - * 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. + * Resets the specified tab to its initial, root route. + * + * @param tab The tab to reset. + * @param originalHref The original href for the tab. + * @param originalRouteOptions The original route options for the tab. */ - handleNativeBack() { - const history = this.props.history as any; - const goBack = history.goBack || history.back; - goBack(); - } - - handleNavigate( - path: string, - 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); + const handleResetTab = (tab: string, originalHref: string, originalRouteOptions: any) => { + const routeInfo = locationHistory.current.getFirstRouteInfoForTab(tab); + if (routeInfo) { + const newRouteInfo = { ...routeInfo }; + newRouteInfo.pathname = originalHref; + newRouteInfo.routeOptions = originalRouteOptions; + incomingRouteParams.current = { ...newRouteInfo, routeAction: 'pop', routeDirection: 'back' }; + navigate(newRouteInfo.pathname + (newRouteInfo.search || '')); } - } + }; - 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(); 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) { - const prevInfo = this.locationHistory.findLastLocation(routeInfo); + const prevInfo = locationHistory.current.findLastLocation(routeInfo); if (prevInfo) { /** * This needs to be passed to handleNavigate @@ -257,12 +335,16 @@ class IonRouterInner extends React.PureComponent { * will be overridden. */ const incomingAnimation = routeAnimation || routeInfo.routeAnimation; - this.incomingRouteParams = { + incomingRouteParams.current = { ...prevInfo, routeAction: 'pop', routeDirection: 'back', routeAnimation: incomingAnimation, }; + /** + * Check if it's a simple linear back navigation (not tabbed). + * e.g., `/home` → `/settings` → back to `/home` + */ if ( routeInfo.lastPathname === routeInfo.pushedByRoute || /** @@ -273,68 +355,98 @@ class IonRouterInner extends React.PureComponent { */ (prevInfo.pathname === routeInfo.pushedByRoute && routeInfo.tab === '' && prevInfo.tab === '') ) { - /** - * 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(); + navigate(-1); } 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 { - this.handleNavigate(defaultHref as string, 'pop', 'back', routeAnimation); + handleNavigate(defaultHref as string, 'pop', 'back', routeAnimation); } + /** + * No `pushedByRoute` + * e.g., initial page load + */ } 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 routeInfo = this.locationHistory.getFirstRouteInfoForTab(tab); - if (routeInfo) { - const newRouteInfo = { ...routeInfo }; - newRouteInfo.pathname = originalHref; - newRouteInfo.routeOptions = originalRouteOptions; - this.incomingRouteParams = { ...newRouteInfo, routeAction: 'pop', routeDirection: 'back' }; - this.props.history.push(newRouteInfo.pathname + (newRouteInfo.search || '')); - } - } + const routeMangerContextValue: RouteManagerContextState = { + canGoBack: () => locationHistory.current.canGoBack(), + clearOutlet: viewStack.current.clear, + findViewItemByPathname: viewStack.current.findViewItemByPathname, + getChildrenToRender: viewStack.current.getChildrenToRender, + goBack: () => handleNavigateBack(), + createViewItem: viewStack.current.createViewItem, + findViewItemByRouteInfo: viewStack.current.findViewItemByRouteInfo, + findLeavingViewItemByRouteInfo: viewStack.current.findLeavingViewItemByRouteInfo, + addViewItem: viewStack.current.add, + unMountViewItem: viewStack.current.remove, + }; - handleSetCurrentTab(tab: string) { - this.currentTab = tab; - const ri = { ...this.locationHistory.current() }; - if (ri.tab !== tab) { - ri.tab = tab; - this.locationHistory.update(ri); - } - } + return ( + + + {children} + + + ); +}; - render() { - return ( - - - {this.props.children} - - - ); - } -} - -export const IonRouter = withRouter(IonRouterInner); IonRouter.displayName = 'IonRouter';