diff --git a/packages/react-router/src/ReactRouter/ReactRouterViewStack.tsx b/packages/react-router/src/ReactRouter/ReactRouterViewStack.tsx index fa9e002102d..92036790203 100644 --- a/packages/react-router/src/ReactRouter/ReactRouterViewStack.tsx +++ b/packages/react-router/src/ReactRouter/ReactRouterViewStack.tsx @@ -1,7 +1,8 @@ import type { RouteInfo, ViewItem } from '@ionic/react'; import { IonRoute, ViewLifeCycleManager, ViewStacks, generateId } from '@ionic/react'; import React from 'react'; -import { matchPath } from 'react-router'; + +import { matchPath } from './utils/matchPath'; export class ReactRouterViewStack extends ViewStacks { constructor() { @@ -23,21 +24,16 @@ export class ReactRouterViewStack extends ViewStacks { ionRoute: false, }; - const matchProps = { - exact: reactElement.props.exact, - path: reactElement.props.path || reactElement.props.from, - component: reactElement.props.component, - }; - - const match = matchPath(routeInfo.pathname, matchProps); - if (reactElement.type === IonRoute) { viewItem.ionRoute = true; viewItem.disableIonPageManagement = reactElement.props.disableIonPageManagement; } viewItem.routeData = { - match, + match: matchPath({ + pathname: routeInfo.pathname, + componentProps: reactElement.props, + }), childProps: reactElement.props, }; @@ -106,7 +102,7 @@ export class ReactRouterViewStack extends ViewStacks { } findLeavingViewItemByRouteInfo(routeInfo: RouteInfo, outletId?: string, mustBeIonRoute = true) { - const { viewItem } = this.findViewItemByPath(routeInfo.lastPathname!, outletId, false, mustBeIonRoute); + const { viewItem } = this.findViewItemByPath(routeInfo.lastPathname!, outletId, mustBeIonRoute); return viewItem; } @@ -115,7 +111,10 @@ export class ReactRouterViewStack extends ViewStacks { return viewItem; } - private findViewItemByPath(pathname: string, outletId?: string, forceExact?: boolean, mustBeIonRoute?: boolean) { + /** + * Returns the matching view item and the match result for a given pathname. + */ + private findViewItemByPath(pathname: string, outletId?: string, mustBeIonRoute?: boolean) { let viewItem: ViewItem | undefined; let match: ReturnType | undefined; let viewStack: ViewItem[]; @@ -140,16 +139,24 @@ export class ReactRouterViewStack extends ViewStacks { if (mustBeIonRoute && !v.ionRoute) { return false; } - const matchProps = { - exact: forceExact ? true : v.routeData.childProps.exact, - path: v.routeData.childProps.path || v.routeData.childProps.from, - component: v.routeData.childProps.component, - }; - const myMatch = matchPath(pathname, matchProps); - if (myMatch) { - viewItem = v; - match = myMatch; - return true; + + match = 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)) { + viewItem = v; + return true; + } } return false; } @@ -171,13 +178,9 @@ export class ReactRouterViewStack extends ViewStacks { } } -function matchComponent(node: React.ReactElement, pathname: string, forceExact?: boolean) { - const matchProps = { - exact: forceExact ? true : node.props.exact, - path: node.props.path || node.props.from, - component: node.props.component, - }; - const match = matchPath(pathname, matchProps); - - return match; +function matchComponent(node: React.ReactElement, pathname: string) { + return matchPath({ + pathname, + componentProps: node.props, + }); } diff --git a/packages/react-router/src/ReactRouter/StackManager.tsx b/packages/react-router/src/ReactRouter/StackManager.tsx index 1acfd484683..708de651391 100644 --- a/packages/react-router/src/ReactRouter/StackManager.tsx +++ b/packages/react-router/src/ReactRouter/StackManager.tsx @@ -1,9 +1,9 @@ import type { RouteInfo, StackContextState, ViewItem } from '@ionic/react'; import { RouteManagerContext, StackContext, generateId, getConfig } from '@ionic/react'; import React from 'react'; -import { matchPath } from 'react-router-dom'; import { clonePageElement } from './clonePageElement'; +import { matchPath } from './utils/matchPath'; // TODO(FW-2959): types @@ -433,12 +433,10 @@ export default StackManager; function matchRoute(node: React.ReactNode, routeInfo: RouteInfo) { let matchedNode: React.ReactNode; React.Children.forEach(node as React.ReactElement, (child: React.ReactElement) => { - const matchProps = { - exact: child.props.exact, - path: child.props.path || child.props.from, - component: child.props.component, - }; - const match = matchPath(routeInfo.pathname, matchProps); + const match = matchPath({ + pathname: routeInfo.pathname, + componentProps: child.props, + }); if (match) { matchedNode = child; } @@ -459,12 +457,11 @@ function matchRoute(node: React.ReactNode, routeInfo: RouteInfo) { } function matchComponent(node: React.ReactElement, pathname: string, forceExact?: boolean) { - const matchProps = { - exact: forceExact ? true : node.props.exact, - path: node.props.path || node.props.from, - component: node.props.component, - }; - const match = matchPath(pathname, matchProps); - - return match; + return matchPath({ + pathname, + componentProps: { + ...node.props, + exact: forceExact, + }, + }); } diff --git a/packages/react-router/src/ReactRouter/utils/matchPath.ts b/packages/react-router/src/ReactRouter/utils/matchPath.ts new file mode 100644 index 00000000000..891eda08bb2 --- /dev/null +++ b/packages/react-router/src/ReactRouter/utils/matchPath.ts @@ -0,0 +1,47 @@ +import { matchPath as reactRouterMatchPath } from 'react-router'; + +interface MatchPathOptions { + /** + * The pathname to match against. + */ + pathname: string; + /** + * The props to match against, they are identical to the matching props `Route` accepts. + */ + componentProps: { + path?: string; + from?: string; + component?: any; + exact?: boolean; + }; +} + +/** + * @see https://v5.reactrouter.com/web/api/matchPath + */ +export const matchPath = ({ + pathname, + componentProps, +}: MatchPathOptions): false | ReturnType => { + const { exact, component } = componentProps; + + const path = componentProps.path || componentProps.from; + /*** + * The props to match against, they are identical + * to the matching props `Route` accepts. It could also be a string + * or an array of strings as shortcut for `{ path }`. + */ + const matchProps = { + exact, + path, + component, + }; + + const match = reactRouterMatchPath(pathname, matchProps); + + if (!match) { + return false; + } + + return match; +}; diff --git a/packages/react-router/test/base/src/pages/routing/Details.tsx b/packages/react-router/test/base/src/pages/routing/Details.tsx index 17905911a45..94aeeb6dbaf 100644 --- a/packages/react-router/test/base/src/pages/routing/Details.tsx +++ b/packages/react-router/test/base/src/pages/routing/Details.tsx @@ -24,10 +24,6 @@ const Details: React.FC = () => { return () => console.log('Home Details unmount'); }, []); - // useIonViewWillEnter(() => { - // console.log('IVWE Details') - // }) - const nextId = parseInt(id, 10) + 1; return ( @@ -58,6 +54,9 @@ const Details: React.FC = () => { Go to Settings Details 1 +
+
+ ); diff --git a/packages/react-router/test/base/tests/e2e/specs/routing.cy.js b/packages/react-router/test/base/tests/e2e/specs/routing.cy.js index 950a27d157b..1289bb30651 100644 --- a/packages/react-router/test/base/tests/e2e/specs/routing.cy.js +++ b/packages/react-router/test/base/tests/e2e/specs/routing.cy.js @@ -309,6 +309,41 @@ describe('Routing Tests', () => { cy.ionPageDoesNotExist('home-details-page-1'); cy.ionPageVisible('home-page'); }); + + it('should mount new view item instances of parameterized routes', () => { + cy.visit(`http://localhost:${port}/routing/tabs/home/details/1`); + + cy.get('div.ion-page[data-pageid=home-details-page-1]') + .get('[data-testid="details-input"]') + .should('have.value', ''); + + cy.get('div.ion-page[data-pageid=home-details-page-1] [data-testid="details-input"]').type('1'); + + cy.ionNav('ion-button', 'Go to Details 2'); + cy.ionPageVisible('home-details-page-2'); + + cy.get('div.ion-page[data-pageid=home-details-page-2] [data-testid="details-input"]').should('have.value', ''); + + cy.get('div.ion-page[data-pageid=home-details-page-2] [data-testid="details-input"]').type('2'); + + cy.ionNav('ion-button', 'Go to Details 3'); + cy.ionPageVisible('home-details-page-3'); + + cy.get('div.ion-page[data-pageid=home-details-page-3] [data-testid="details-input"]').should('have.value', ''); + + cy.get('div.ion-page[data-pageid=home-details-page-3] [data-testid="details-input"]').type('3'); + + cy.ionBackClick('home-details-page-3'); + cy.ionPageVisible('home-details-page-2'); + + cy.get('div.ion-page[data-pageid=home-details-page-2] [data-testid="details-input"]').should('have.value', '2'); + + cy.ionBackClick('home-details-page-2'); + cy.ionPageVisible('home-details-page-1'); + + cy.get('div.ion-page[data-pageid=home-details-page-1] [data-testid="details-input"]').should('have.value', '1'); + }); + /* Tests to add: Test that lifecycle events fire