diff --git a/packages/react-router/src/ReactRouter/ReactRouterViewStack.tsx b/packages/react-router/src/ReactRouter/ReactRouterViewStack.tsx index b823c458599..b7e448977ad 100644 --- a/packages/react-router/src/ReactRouter/ReactRouterViewStack.tsx +++ b/packages/react-router/src/ReactRouter/ReactRouterViewStack.tsx @@ -643,21 +643,19 @@ export class ReactRouterViewStack extends ViewStacks { this.outletParentPaths.delete(outletId); } - // Sync child elements with stored viewItems (e.g. to reflect new props) - React.Children.forEach(ionRouterOutlet.props.children, (child: React.ReactElement) => { - // Ensure the child is a valid React element since we - // might have whitespace strings or other non-element children - if (React.isValidElement(child)) { - // Find view item by exact path match to avoid wildcard routes overwriting specific routes - const childPath = (child.props as any).path; - const viewItem = viewItems.find((v) => { - const viewItemPath = v.reactElement?.props?.path; - // Only update if paths match exactly (prevents wildcard routes from overwriting specific routes) - return viewItemPath === childPath; - }); - if (viewItem) { - viewItem.reactElement = child; - } + // Re-sync each route element onto its stored viewItem so prop changes from a + // parent re-render reach the child. extractRouteChildren unwraps the + // wrapper, which has no path of its own. Without it we'd iterate props.children + // directly, never match a viewItem, and the child's props would go stale. + extractRouteChildren(ionRouterOutlet.props.children).forEach((child) => { + // Match on exact path so a wildcard route doesn't overwrite a specific one. + const childPath = (child.props as any).path; + const viewItem = viewItems.find((v) => { + const viewItemPath = v.reactElement?.props?.path; + return viewItemPath === childPath; + }); + if (viewItem) { + viewItem.reactElement = child; } }); diff --git a/packages/react-router/test/base/src/App.tsx b/packages/react-router/test/base/src/App.tsx index 354900fec17..cc22a9e158d 100644 --- a/packages/react-router/test/base/src/App.tsx +++ b/packages/react-router/test/base/src/App.tsx @@ -69,6 +69,7 @@ import TabLifecycleOutside from './pages/tab-lifecycle/TabLifecycleOutside'; import { RouterLinkModifierClick, RouterLinkModifierClickTarget } from './pages/router-link-modifier-click/RouterLinkModifierClick'; import { NavigateRootPageA, NavigateRootPageB, NavigateRootPageC } from './pages/navigate-root/NavigateRoot'; import SuspenseOutlet from './pages/suspense-outlet/SuspenseOutlet'; +import { PropsUpdateDirect, PropsUpdateRoutesWrapper } from './pages/props-update/PropsUpdate'; setupIonicReact(); @@ -136,6 +137,8 @@ const App: React.FC = () => { } /> } /> } /> + } /> + } /> diff --git a/packages/react-router/test/base/src/pages/Main.tsx b/packages/react-router/test/base/src/pages/Main.tsx index 9888bf40d91..08ea63ed64d 100644 --- a/packages/react-router/test/base/src/pages/Main.tsx +++ b/packages/react-router/test/base/src/pages/Main.tsx @@ -186,6 +186,12 @@ const Main: React.FC = () => { Route Context Shape + + Props Update (Routes wrapper) + + + Props Update (direct) + diff --git a/packages/react-router/test/base/src/pages/props-update/PropsUpdate.tsx b/packages/react-router/test/base/src/pages/props-update/PropsUpdate.tsx new file mode 100644 index 00000000000..6ab23c2d600 --- /dev/null +++ b/packages/react-router/test/base/src/pages/props-update/PropsUpdate.tsx @@ -0,0 +1,67 @@ +/** + * Reproduces https://github.com/ionic-team/ionic-framework/issues/31157 + * (and the original https://github.com/ionic-team/ionic-framework/issues/19986). + * + * A parent passes state to a child rendered inside an IonRouterOutlet. Clicking the + * button updates that state, and the child should re-render with the new prop value. + * + * Two variants: + * - `routes-wrapper`: routes wrapped in , the RR6 equivalent of the issue's + * - `direct`: routes as direct children of the outlet (the #19986 shape) + */ + +import { IonButton, IonContent, IonHeader, IonPage, IonRouterOutlet, IonTitle, IonToolbar } from '@ionic/react'; +import React, { useState } from 'react'; +import { Navigate, Route, Routes } from 'react-router-dom'; + +import TestDescription from '../../components/TestDescription'; + +interface ChildProps { + name: string; + setName: (name: string) => void; +} + +const ChildPage: React.FC = ({ name, setName }) => { + return ( + + + + Props Update + + + + + Tap the button. Its label should change from "Viktor" to "another", confirming the child + re-renders with the updated parent prop. + + setName('another')}> + {name} + + + + ); +}; + +/** Routes wrapped in - the RR6 equivalent of the shape in #31157. */ +export const PropsUpdateRoutesWrapper: React.FC = () => { + const [name, setName] = useState('Viktor'); + return ( + + + } /> + } /> + + + ); +}; + +/** Routes as direct children of the outlet - the original #19986 shape. */ +export const PropsUpdateDirect: React.FC = () => { + const [name, setName] = useState('Viktor'); + return ( + + } /> + } /> + + ); +}; diff --git a/packages/react-router/test/base/tests/e2e/playwright/props-update.spec.ts b/packages/react-router/test/base/tests/e2e/playwright/props-update.spec.ts new file mode 100644 index 00000000000..2b4e28df6f1 --- /dev/null +++ b/packages/react-router/test/base/tests/e2e/playwright/props-update.spec.ts @@ -0,0 +1,42 @@ +import { test, expect } from '@playwright/test'; +import { ionPageVisible, withTestingMode } from './utils/test-utils'; + +/** + * Reproduces https://github.com/ionic-team/ionic-framework/issues/31157 + * (and the original https://github.com/ionic-team/ionic-framework/issues/19986). + * + * A parent passes state to a child rendered inside an IonRouterOutlet. Clicking the + * button updates that state, and the child should re-render with the new value + * ("Viktor" -> "another"). + */ +test.describe('Props Update inside IonRouterOutlet', () => { + test('routes wrapped in should update child props on parent state change', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/31157', + }); + await page.goto(withTestingMode('/props-update-routes/child')); + await ionPageVisible(page, 'props-update-child'); + + const button = page.locator('[data-testid="name-button"]'); + await expect(button).toHaveText('Viktor'); + + await button.click(); + await expect(button).toHaveText('another'); + }); + + test('routes as direct children should update child props on parent state change', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/19986', + }); + await page.goto(withTestingMode('/props-update-direct/child')); + await ionPageVisible(page, 'props-update-child'); + + const button = page.locator('[data-testid="name-button"]'); + await expect(button).toHaveText('Viktor'); + + await button.click(); + await expect(button).toHaveText('another'); + }); +});