From cccf2906703a88fe441479b06f477963862d9a31 Mon Sep 17 00:00:00 2001 From: Maria Hutt Date: Thu, 8 May 2025 14:54:40 -0700 Subject: [PATCH] refactor(reactrouterviewstack): update for rr6 and improvements --- .../src/ReactRouter/ReactRouterViewStack.tsx | 283 ++++++++++++------ 1 file changed, 187 insertions(+), 96 deletions(-) diff --git a/packages/react-router/src/ReactRouter/ReactRouterViewStack.tsx b/packages/react-router/src/ReactRouter/ReactRouterViewStack.tsx index 9203679020..be25c2b5c8 100644 --- a/packages/react-router/src/ReactRouter/ReactRouterViewStack.tsx +++ b/packages/react-router/src/ReactRouter/ReactRouterViewStack.tsx @@ -1,20 +1,44 @@ +/** + * ReactRouterViewStack is a custom navigation manager used in Ionic React apps + * to map React Router route elements (such as ) 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 { IonRoute, ViewLifeCycleManager, ViewStacks, generateId } from '@ionic/react'; +import { + IonRoute, + ViewLifeCycleManager, + ViewStacks, + generateId, +} from '@ionic/react'; import React from 'react'; +import type { PathMatch } from 'react-router'; +import { Routes } from 'react-router'; import { matchPath } from './utils/matchPath'; export class ReactRouterViewStack extends ViewStacks { constructor() { 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 = { id: generateId('viewItem'), outletId, @@ -38,149 +62,216 @@ export class ReactRouterViewStack extends ViewStacks { }; return viewItem; - } + }; - getChildrenToRender(outletId: string, ionRouterOutlet: React.ReactElement, routeInfo: RouteInfo) { + /** + * 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 to support nested routing and ensure remounting + * - Adds a unique key to 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); + } + + return ( + this.remove(viewItem)} + > + {/** + * Wrapped in to ensure React Router v6 correctly processes nested route elements + * `key` is provided to enforce remounting of Routes when switching between view items. + */} + + {React.cloneElement(viewItem.reactElement)} + + + ); + }; + + /** + * 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 to manage lifecycle and rendering + */ + getChildrenToRender = ( + outletId: string, + ionRouterOutlet: React.ReactElement, + routeInfo: RouteInfo + ) => { const viewItems = this.getViewItemsForOutlet(outletId); - // Sync latest routes with viewItems - React.Children.forEach(ionRouterOutlet.props.children, (child: React.ReactElement) => { - const viewItem = viewItems.find((v) => { - return matchComponent(child, v.routeData.childProps.path || v.routeData.childProps.from); - }); - if (viewItem) { - viewItem.reactElement = child; - } - }); - - const children = viewItems.map((viewItem) => { - let clonedChild; - if (viewItem.ionRoute && !viewItem.disableIonPageManagement) { - clonedChild = ( - this.remove(viewItem)} - > - {React.cloneElement(viewItem.reactElement, { - computedMatch: viewItem.routeData.match, - })} - + // Sync child elements with stored viewItems (e.g. to reflect new props) + React.Children.forEach( + ionRouterOutlet.props.children, + (child: React.ReactElement) => { + const viewItem = viewItems.find((v) => + matchComponent(child, v.routeData.childProps.path) ); - } else { - const match = matchComponent(viewItem.reactElement, routeInfo.pathname); - clonedChild = ( - this.remove(viewItem)} - > - {React.cloneElement(viewItem.reactElement, { - computedMatch: viewItem.routeData.match, - })} - - ); - - if (!match && viewItem.routeData.match) { - viewItem.routeData.match = undefined; - viewItem.mount = false; + if (viewItem) { + viewItem.reactElement = child; } } + ); - return clonedChild; - }); - return children; - } + // Render all view items using renderViewItem + return viewItems.map((viewItem) => this.renderViewItem(viewItem, routeInfo)); + }; - findViewItemByRouteInfo(routeInfo: RouteInfo, outletId?: string, updateMatch?: boolean) { - const { viewItem, match } = this.findViewItemByPath(routeInfo.pathname, outletId); + /** + * Finds a view item matching the current route, optionally updating its match state. + */ + findViewItemByRouteInfo = ( + routeInfo: RouteInfo, + outletId?: string, + updateMatch?: boolean + ) => { + const { viewItem, match } = this.findViewItemByPath( + routeInfo.pathname, + outletId + ); const shouldUpdateMatch = updateMatch === undefined || updateMatch === true; if (shouldUpdateMatch && viewItem && match) { viewItem.routeData.match = match; } 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 match: ReturnType | undefined; + let match: PathMatch | null = null; let viewStack: ViewItem[]; if (outletId) { viewStack = this.getViewItemsForOutlet(outletId); viewStack.some(matchView); - if (!viewItem) { - viewStack.some(matchDefaultRoute); - } + if (!viewItem) viewStack.some(matchDefaultRoute); } else { const viewItems = this.getAllViewItems(); viewItems.some(matchView); - if (!viewItem) { - viewItems.some(matchDefaultRoute); - } + if (!viewItem) viewItems.some(matchDefaultRoute); + } + + if (!viewItem && process.env.NODE_ENV !== 'production') { + console.warn(`[ReactRouterViewStack] No matching view item found for: ${pathname}`); } return { viewItem, match }; + /** + * Matches a route path with dynamic parameters (e.g. /tabs/:id) + */ function matchView(v: ViewItem) { - if (mustBeIonRoute && !v.ionRoute) { - return false; - } + if (mustBeIonRoute && !v.ionRoute) return false; - match = matchPath({ + const result = matchPath({ pathname, componentProps: v.routeData.childProps, }); - if (match) { - /** - * Even though we have a match from react-router, we do not know if the match - * 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)) { + 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; } + /** + * Matches a view with no path prop (default fallback route). + */ 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 && !v.routeData.childProps.from) { - match = { - path: pathname, - url: pathname, - isExact: true, - params: {}, - }; + if (!v.routeData.childProps.path) { + match = createDefaultMatch(pathname); viewItem = v; return true; } 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) { return matchPath({ pathname, componentProps: node.props, }); } + +/** + * Creates a default match object for a fallback route. + */ +function createDefaultMatch(pathname: string): PathMatch { + return { + params: {}, + pathname, + pathnameBase: pathname, + pattern: { + path: pathname, + caseSensitive: false, + end: true, + }, + }; +}