Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 13 additions & 15 deletions packages/react-router/src/ReactRouter/ReactRouterViewStack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Routes>
// 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;
}
});

Expand Down
3 changes: 3 additions & 0 deletions packages/react-router/test/base/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -136,6 +137,8 @@ const App: React.FC = () => {
<Route path="/navigate-root/page-b" element={<NavigateRootPageB />} />
<Route path="/navigate-root/page-c" element={<NavigateRootPageC />} />
<Route path="/suspense-outlet/*" element={<SuspenseOutlet />} />
<Route path="/props-update-routes/*" element={<PropsUpdateRoutesWrapper />} />
<Route path="/props-update-direct/*" element={<PropsUpdateDirect />} />
</IonRouterOutlet>
</IonReactRouter>
</IonApp>
Expand Down
6 changes: 6 additions & 0 deletions packages/react-router/test/base/src/pages/Main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,12 @@ const Main: React.FC = () => {
<IonItem routerLink="/route-context-shape">
<IonLabel>Route Context Shape</IonLabel>
</IonItem>
<IonItem routerLink="/props-update-routes/child">
<IonLabel>Props Update (Routes wrapper)</IonLabel>
</IonItem>
<IonItem routerLink="/props-update-direct/child">
<IonLabel>Props Update (direct)</IonLabel>
</IonItem>
</IonList>
</IonContent>
</IonPage>
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <Routes>, the RR6 equivalent of the issue's <Switch>
* - `direct`: routes as direct <Route> 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<ChildProps> = ({ name, setName }) => {
return (
<IonPage data-pageid="props-update-child">
<IonHeader>
<IonToolbar>
<IonTitle>Props Update</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent>
<TestDescription>
Tap the button. Its label should change from "Viktor" to "another", confirming the child
re-renders with the updated parent prop.
</TestDescription>
<IonButton data-testid="name-button" onClick={() => setName('another')}>
{name}
</IonButton>
</IonContent>
</IonPage>
);
};

/** Routes wrapped in <Routes> - the RR6 equivalent of the <Switch> shape in #31157. */
export const PropsUpdateRoutesWrapper: React.FC = () => {
const [name, setName] = useState('Viktor');
return (
<IonRouterOutlet>
<Routes>
<Route path="child" element={<ChildPage name={name} setName={setName} />} />
<Route index element={<Navigate to="child" replace />} />
</Routes>
</IonRouterOutlet>
);
};

/** Routes as direct <Route> children of the outlet - the original #19986 shape. */
export const PropsUpdateDirect: React.FC = () => {
const [name, setName] = useState('Viktor');
return (
<IonRouterOutlet>
<Route path="child" element={<ChildPage name={name} setName={setName} />} />
<Route index element={<Navigate to="child" replace />} />
</IonRouterOutlet>
);
};
Original file line number Diff line number Diff line change
@@ -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 <Routes> 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');
});
});
Loading