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 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'; import StackManager from './StackManager'; export interface LocationState { direction?: RouterDirection; routerOptions?: { as?: string; unmount?: boolean }; } interface IonRouterProps { registerHistoryListener: (cb: (location: HistoryLocation, action: HistoryAction) => void) => void; } export const IonRouter = ({ children, registerHistoryListener }: PropsWithChildren) => { const location = useLocation(); const params = useParams(); const navigate = useNavigate(); const didMountRef = useRef(false); const locationHistory = useRef(new LocationHistory()); const currentTab = useRef(undefined); const viewStack = useRef(new ReactRouterViewStack()); const incomingRouteParams = useRef | null>(null); const [routeInfo, setRouteInfo] = useState({ id: generateId('routeInfo'), pathname: location.pathname, search: location.search, }); useEffect(() => { didMountRef.current = true; }, []); /** * 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; /** * 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 { // If the action is 'push' or 'pop', we want to use the current route. leavingLocationInfo = locationHistory.current.current(); } } else { /** * 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) { // An external navigation was triggered. if (!incomingRouteParams.current) { /** * A `REPLACE` action can be triggered by React Router's * `` component. */ if (action === 'REPLACE') { incomingRouteParams.current = { routeAction: 'replace', routeDirection: 'none', tab: currentTab.current, }; } /** * A `POP` action can be triggered by the browser's back/forward * button. */ if (action === 'POP') { 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 = locationHistory.current.findLastLocation(currentRoute); incomingRouteParams.current = { ...prevInfo, routeAction: 'pop', routeDirection: 'back' }; // It's a non-linear history path like a direct link. } else { incomingRouteParams.current = { routeAction: 'pop', routeDirection: 'none', tab: currentTab.current, }; } } // 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: currentTab.current, }; } } let routeInfo: RouteInfo; /** * 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 = { ...(incomingRouteParams.current as RouteInfo), lastPathname: leavingLocationInfo.pathname, }; locationHistory.current.add(routeInfo); /** * A new route is being created since it's not re-activating * an existing route. */ } else { const isPushed = incomingRouteParams.current?.routeAction === 'push' && incomingRouteParams.current.routeDirection === 'forward'; routeInfo = { id: generateId('routeInfo'), ...incomingRouteParams, lastPathname: leavingLocationInfo.pathname, // The URL we just came from pathname: location.pathname, // The current (destination) URL search: location.search, 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') { // 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 = 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 = locationHistory.current.current(); /** * 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 = currentPushedBy !== undefined && currentPushedBy !== routeInfo.pathname ? currentPushedBy : routeInfo.pushedByRoute; routeInfo.lastPathname = currentRouteInfo?.pathname || routeInfo.lastPathname; routeInfo.prevRouteLastPathname = currentRouteInfo?.lastPathname; routeInfo.pushedByRoute = pushedByRoute; /** * When replacing routes we should still prefer * any custom direction/animation that the developer * has specified when navigating first instead of relying * on previously used directions/animations. */ routeInfo.routeDirection = routeInfo.routeDirection || currentRouteInfo?.routeDirection; routeInfo.routeAnimation = routeInfo.routeAnimation || currentRouteInfo?.routeAnimation; } locationHistory.current.add(routeInfo); } setRouteInfo(routeInfo); } // Reset for the next navigation. incomingRouteParams.current = null; }; /** * 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. */ 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 || '')); } }; /** * 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 = locationHistory.current.current(); // It's a linear navigation. if (routeInfo && routeInfo.pushedByRoute) { const prevInfo = locationHistory.current.findLastLocation(routeInfo); if (prevInfo) { /** * This needs to be passed to handleNavigate * otherwise incomingRouteParams.routeAnimation * will be overridden. */ const incomingAnimation = routeAnimation || routeInfo.routeAnimation; 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 || /** * We need to exclude tab switches/tab * context changes here because tabbed * navigation is not linear, but router.back() * will go back in a linear fashion. */ (prevInfo.pathname === routeInfo.pushedByRoute && routeInfo.tab === '' && prevInfo.tab === '') ) { navigate(-1); } else { /** * 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 { handleNavigate(defaultHref as string, 'pop', 'back', routeAnimation); } /** * No `pushedByRoute` * e.g., initial page load */ } else { 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); } 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, }; return ( {children} ); }; IonRouter.displayName = 'IonRouter';