Skip to content

Commit fedca46

Browse files
committed
fix(react-router): preserve nested outlet params when navigating between sibling routes
1 parent cb94f73 commit fedca46

File tree

5 files changed

+138
-16
lines changed

5 files changed

+138
-16
lines changed

packages/react-router/src/ReactRouter/IonRouter.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,14 @@
66
* and animate.
77
*/
88

9-
import type { AnimationBuilder, RouteAction, RouteInfo, RouteManagerContextState, RouterDirection, RouterOptions } from '@ionic/react';
9+
import type {
10+
AnimationBuilder,
11+
RouteAction,
12+
RouteInfo,
13+
RouteManagerContextState,
14+
RouterDirection,
15+
RouterOptions,
16+
} from '@ionic/react';
1017
import { LocationHistory, NavManager, RouteManagerContext, generateId, getConfig } from '@ionic/react';
1118
import type { Action as HistoryAction, Location } from 'history';
1219
import type { PropsWithChildren } from 'react';

packages/react-router/src/ReactRouter/ReactRouterViewStack.tsx

Lines changed: 52 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -142,14 +142,24 @@ export class ReactRouterViewStack extends ViewStacks {
142142
// Special case: reuse tabs/* and other specific wildcard routes
143143
// Don't reuse index routes (empty path) or generic catch-all wildcards (*)
144144
if (existingPath === routePath && existingPath !== '' && existingPath !== '*') {
145-
// For parameterized routes (containing :param), only reuse if the ACTUAL pathname matches
146-
// This ensures /details/1 and /details/2 get separate view items and component instances
145+
// Parameterized routes need pathname matching to ensure /details/1 and /details/2
146+
// get separate view items. For wildcard routes (e.g., user/:userId/*), compare
147+
// pathnameBase to allow child path changes while preserving the parent view.
147148
const hasParams = routePath.includes(':');
149+
const isWildcard = routePath.includes('*');
148150
if (hasParams) {
149-
// Check if the existing view item's pathname matches the new pathname
150-
const existingPathname = v.routeData?.match?.pathname;
151-
if (existingPathname !== routeInfo.pathname) {
152-
return false; // Different param values, don't reuse
151+
if (isWildcard) {
152+
const existingPathnameBase = v.routeData?.match?.pathnameBase;
153+
const newMatch = matchComponent(reactElement, routeInfo.pathname, false);
154+
const newPathnameBase = newMatch?.pathnameBase;
155+
if (existingPathnameBase !== newPathnameBase) {
156+
return false;
157+
}
158+
} else {
159+
const existingPathname = v.routeData?.match?.pathname;
160+
if (existingPathname !== routeInfo.pathname) {
161+
return false;
162+
}
153163
}
154164
}
155165
return true;
@@ -339,13 +349,34 @@ export class ReactRouterViewStack extends ViewStacks {
339349
<RouteContext.Consumer key={`view-context-${viewItem.id}`}>
340350
{(parentContext) => {
341351
const parentMatches = parentContext?.matches ?? [];
342-
const accumulatedParentParams = parentMatches.reduce<Record<string, string | string[] | undefined>>(
352+
let accumulatedParentParams = parentMatches.reduce<Record<string, string | string[] | undefined>>(
343353
(acc, match) => {
344354
return { ...acc, ...match.params };
345355
},
346356
{}
347357
);
348358

359+
// If parentMatches is empty, try to extract params from view items in other outlets.
360+
// This handles cases where React context propagation doesn't work as expected
361+
// for nested router outlets.
362+
if (parentMatches.length === 0 && Object.keys(accumulatedParentParams).length === 0) {
363+
const allViewItems = this.getAllViewItems();
364+
for (const otherViewItem of allViewItems) {
365+
// Skip view items from the same outlet
366+
if (otherViewItem.outletId === viewItem.outletId) continue;
367+
368+
// Check if this view item's route could match the current pathname
369+
const otherMatch = otherViewItem.routeData?.match;
370+
if (otherMatch && otherMatch.params && Object.keys(otherMatch.params).length > 0) {
371+
// Check if the current pathname starts with this view item's matched pathname
372+
const matchedPathname = otherMatch.pathnameBase || otherMatch.pathname;
373+
if (matchedPathname && routeInfo.pathname.startsWith(matchedPathname)) {
374+
accumulatedParentParams = { ...accumulatedParentParams, ...otherMatch.params };
375+
}
376+
}
377+
}
378+
}
379+
349380
const combinedParams = {
350381
...accumulatedParentParams,
351382
...(routeMatch?.params ?? {}),
@@ -620,17 +651,27 @@ export class ReactRouterViewStack extends ViewStacks {
620651
if (result) {
621652
const hasParams = result.params && Object.keys(result.params).length > 0;
622653
const isSamePath = result.pathname === previousMatch?.pathname;
654+
const isWildcardRoute = viewItemPath.includes('*');
655+
const isParameterRoute = viewItemPath.includes(':');
623656

624657
// Don't allow view items with undefined paths to match specific routes
625658
// This prevents broken index route view items from interfering with navigation
626659
if (!viewItemPath && !isIndexRoute && pathname !== '/' && pathname !== '') {
627660
return false;
628661
}
629662

630-
// For parameterized routes, never reuse if the pathname is different
631-
// This ensures /details/1 and /details/2 get separate view items
632-
const isParameterRoute = viewItemPath.includes(':');
663+
// For parameterized routes, check if we should reuse the view item.
664+
// Wildcard routes (e.g., user/:userId/*) compare pathnameBase to allow
665+
// child path changes while preserving the parent view.
633666
if (isParameterRoute && !isSamePath) {
667+
if (isWildcardRoute) {
668+
const isSameBase = result.pathnameBase === previousMatch?.pathnameBase;
669+
if (isSameBase) {
670+
match = result;
671+
viewItem = v;
672+
return true;
673+
}
674+
}
634675
return false;
635676
}
636677

@@ -642,8 +683,7 @@ export class ReactRouterViewStack extends ViewStacks {
642683
return true;
643684
}
644685

645-
// For wildcard routes, only reuse if the pathname exactly matches
646-
const isWildcardRoute = viewItemPath.includes('*');
686+
// For wildcard routes (without params), only reuse if the pathname exactly matches
647687
if (isWildcardRoute && isSamePath) {
648688
match = result;
649689
viewItem = v;

packages/react-router/test/base/src/pages/nested-params/NestedParams.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,12 @@ const Landing: React.FC = () => (
3737
</IonHeader>
3838
<IonContent className="ion-padding">
3939
<IonLabel>A nested route will try to read the parent :userId parameter.</IonLabel>
40-
<IonButton routerLink="/nested-params/user/42/details" id="go-to-user-details" className="ion-margin-top">
40+
<IonButton routerLink="/nested-params/user/42/details" id="go-to-user-42" className="ion-margin-top">
4141
Go to User 42 Details
4242
</IonButton>
43+
<IonButton routerLink="/nested-params/user/99/details" id="go-to-user-99" className="ion-margin-top">
44+
Go to User 99 Details
45+
</IonButton>
4346
</IonContent>
4447
</IonPage>
4548
);
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
const port = 3000;
2+
3+
describe('Nested Params', () => {
4+
/*
5+
Tests that route params are correctly passed to nested routes
6+
when using parameterized wildcard routes (e.g., user/:userId/*).
7+
*/
8+
9+
it('/nested-params > Landing page should be visible', () => {
10+
cy.visit(`http://localhost:${port}/nested-params`);
11+
cy.ionPageVisible('nested-params-landing');
12+
});
13+
14+
it('/nested-params > Navigate to user details > Params should be available', () => {
15+
cy.visit(`http://localhost:${port}/nested-params`);
16+
cy.ionPageVisible('nested-params-landing');
17+
18+
cy.get('#go-to-user-42').click();
19+
20+
cy.get('[data-testid="user-layout-param"]').should('contain', 'Layout sees user: 42');
21+
cy.get('[data-testid="user-details-param"]').should('contain', 'Details view user: 42');
22+
});
23+
24+
it('/nested-params > Navigate between sibling routes > Params should be maintained', () => {
25+
cy.visit(`http://localhost:${port}/nested-params`);
26+
cy.ionPageVisible('nested-params-landing');
27+
28+
// Navigate to user 42 details
29+
cy.get('#go-to-user-42').click();
30+
cy.get('[data-testid="user-layout-param"]').should('contain', 'Layout sees user: 42');
31+
cy.get('[data-testid="user-details-param"]').should('contain', 'Details view user: 42');
32+
33+
// Navigate to settings (sibling route)
34+
cy.get('#go-to-settings').click();
35+
cy.get('[data-testid="user-layout-param"]').should('contain', 'Layout sees user: 42');
36+
cy.get('[data-testid="user-settings-param"]').should('contain', 'Settings view user: 42');
37+
38+
// Navigate back to details
39+
cy.contains('ion-button', 'Back to Details').click();
40+
cy.get('[data-testid="user-layout-param"]').should('contain', 'Layout sees user: 42');
41+
cy.get('[data-testid="user-details-param"]').should('contain', 'Details view user: 42');
42+
});
43+
44+
it('/nested-params > Direct navigation to nested route > Params should be available', () => {
45+
// Navigate directly to a nested route with params
46+
cy.visit(`http://localhost:${port}/nested-params/user/123/settings`);
47+
48+
cy.get('[data-testid="user-layout-param"]').should('contain', 'Layout sees user: 123');
49+
cy.get('[data-testid="user-settings-param"]').should('contain', 'Settings view user: 123');
50+
});
51+
52+
it('/nested-params > Different users should have different params', () => {
53+
cy.visit(`http://localhost:${port}/nested-params`);
54+
cy.ionPageVisible('nested-params-landing');
55+
56+
// Navigate to user 42
57+
cy.get('#go-to-user-42').click();
58+
cy.get('[data-testid="user-layout-param"]').should('contain', 'Layout sees user: 42');
59+
cy.get('[data-testid="user-details-param"]').should('contain', 'Details view user: 42');
60+
61+
// Go back to landing
62+
cy.go('back');
63+
cy.ionPageVisible('nested-params-landing');
64+
65+
// Navigate to user 99
66+
cy.get('#go-to-user-99').click();
67+
cy.get('[data-testid="user-layout-param"]').should('contain', 'Layout sees user: 99');
68+
cy.get('[data-testid="user-details-param"]').should('contain', 'Details view user: 99');
69+
});
70+
});

packages/react/src/routing/OutletPageManager.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ interface OutletPageManagerProps {
1212
forwardedRef?: React.ForwardedRef<HTMLIonRouterOutletElement>;
1313
routeInfo?: RouteInfo;
1414
StackManager: any; // TODO(FW-2959): type
15+
id?: string;
1516
}
1617

1718
export class OutletPageManager extends React.Component<OutletPageManagerProps> {
@@ -83,15 +84,16 @@ export class OutletPageManager extends React.Component<OutletPageManagerProps> {
8384
}
8485

8586
render() {
86-
const { StackManager, children, routeInfo, ...props } = this.props;
87+
const { StackManager, children, routeInfo, id, ...props } = this.props;
8788
return (
8889
<IonLifeCycleContext.Consumer>
8990
{(context) => {
9091
this.ionLifeCycleContext = context;
9192
return (
92-
<StackManager routeInfo={routeInfo}>
93+
<StackManager routeInfo={routeInfo} id={id}>
9394
<IonRouterOutletInner
9495
setRef={(val: HTMLIonRouterOutletElement) => (this.ionRouterOutlet = val)}
96+
id={id}
9597
{...props}
9698
>
9799
{children}

0 commit comments

Comments
 (0)