Skip to content

Commit e807ca8

Browse files
committed
feat(react-router): add navigateRoot to useIonRouter
1 parent 9960706 commit e807ca8

File tree

8 files changed

+182
-3
lines changed

8 files changed

+182
-3
lines changed

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -655,6 +655,27 @@ export const IonRouter = ({ children, registerHistoryListener }: PropsWithChildr
655655
navigate(path, { replace: routeAction !== 'push' });
656656
};
657657

658+
/**
659+
* Navigates to a new root path, clearing Ionic's navigation history so that
660+
* canGoBack() returns false after the transition. All previously mounted views
661+
* are unmounted. Useful for post-login / post-logout root navigation.
662+
*
663+
* @param pathname The path to navigate to.
664+
* @param routeAnimation An optional custom animation builder.
665+
*/
666+
const handleNavigateRoot = (pathname: string, routeAnimation?: AnimationBuilder) => {
667+
currentTab.current = undefined;
668+
forwardStack.current = [];
669+
670+
incomingRouteParams.current = {
671+
routeAction: 'replace',
672+
routeDirection: 'root',
673+
routeAnimation,
674+
};
675+
676+
navigate(pathname, { replace: true });
677+
};
678+
658679
const routeMangerContextValue: RouteManagerContextState = {
659680
canGoBack: () => locationHistory.current.canGoBack(),
660681
clearOutlet: viewStack.current.clear,
@@ -678,6 +699,7 @@ export const IonRouter = ({ children, registerHistoryListener }: PropsWithChildr
678699
onNativeBack={handleNativeBack}
679700
onNavigateBack={handleNavigateBack}
680701
onNavigate={handleNavigate}
702+
onNavigateRoot={handleNavigateRoot}
681703
onSetCurrentTab={handleSetCurrentTab}
682704
onChangeTab={handleChangeTab}
683705
onResetTab={handleResetTab}

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

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,31 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
279279
return true;
280280
}
281281

282+
/**
283+
* Handles root navigation by unmounting all non-entering views in this outlet.
284+
* Fires ionViewWillLeave / ionViewDidLeave only on views that are currently visible.
285+
* Views that are mounted but not visible (e.g., pages earlier in the back stack)
286+
* are silently unmounted without lifecycle events, consistent with the behavior
287+
* of out-of-scope outlet cleanup.
288+
*/
289+
private handleRootNavigation(enteringViewItem: ViewItem | undefined): void {
290+
const allViewsInOutlet = this.context.getViewItemsForOutlet(this.id);
291+
allViewsInOutlet.forEach((viewItem) => {
292+
if (viewItem === enteringViewItem) {
293+
return;
294+
}
295+
if (viewItem.ionPageElement && isViewVisible(viewItem.ionPageElement)) {
296+
viewItem.ionPageElement.dispatchEvent(
297+
new CustomEvent('ionViewWillLeave', { bubbles: false, cancelable: false })
298+
);
299+
viewItem.ionPageElement.dispatchEvent(
300+
new CustomEvent('ionViewDidLeave', { bubbles: false, cancelable: false })
301+
);
302+
}
303+
this.context.unMountViewItem(viewItem);
304+
});
305+
}
306+
282307
/**
283308
* Handles nested outlet with relative routes but no parent path. Returns true to abort.
284309
*/
@@ -875,8 +900,8 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
875900
// Find entering and leaving view items
876901
const viewItems = this.findViewItems(routeInfo);
877902
let enteringViewItem = viewItems.enteringViewItem;
878-
const leavingViewItem = viewItems.leavingViewItem;
879-
const shouldUnmountLeavingViewItem = this.shouldUnmountLeavingView(routeInfo, enteringViewItem, leavingViewItem);
903+
let leavingViewItem = viewItems.leavingViewItem;
904+
let shouldUnmountLeavingViewItem = this.shouldUnmountLeavingView(routeInfo, enteringViewItem, leavingViewItem);
880905

881906
// Get parent path for nested outlets
882907
const parentPath = this.getParentPath();
@@ -886,6 +911,13 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
886911
return;
887912
}
888913

914+
// Handle root navigation: unmount all non-entering views
915+
if (routeInfo.routeDirection === 'root') {
916+
this.handleRootNavigation(enteringViewItem);
917+
leavingViewItem = undefined;
918+
shouldUnmountLeavingViewItem = false;
919+
}
920+
889921
// Clear any pending out-of-scope unmount timeout
890922
if (this.outOfScopeUnmountTimeout) {
891923
clearTimeout(this.outOfScopeUnmountTimeout);

packages/react-router/test/base/src/App.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ import { ParamSwipeBack, ParamSwipeBackB } from './pages/param-swipe-back/ParamS
6565
import TabLifecycle from './pages/tab-lifecycle/TabLifecycle';
6666
import TabLifecycleOutside from './pages/tab-lifecycle/TabLifecycleOutside';
6767
import { RouterLinkModifierClick, RouterLinkModifierClickTarget } from './pages/router-link-modifier-click/RouterLinkModifierClick';
68+
import { NavigateRootPageA, NavigateRootPageB, NavigateRootPageC } from './pages/navigate-root/NavigateRoot';
6869

6970
setupIonicReact();
7071

@@ -125,6 +126,9 @@ const App: React.FC = () => {
125126
<Route path="/tab-lifecycle-outside" element={<TabLifecycleOutside />} />
126127
<Route path="/router-link-modifier-click" element={<RouterLinkModifierClick />} />
127128
<Route path="/router-link-modifier-click/target" element={<RouterLinkModifierClickTarget />} />
129+
<Route path="/navigate-root/page-a" element={<NavigateRootPageA />} />
130+
<Route path="/navigate-root/page-b" element={<NavigateRootPageB />} />
131+
<Route path="/navigate-root/page-c" element={<NavigateRootPageC />} />
128132
</IonRouterOutlet>
129133
</IonReactRouter>
130134
</IonApp>

packages/react-router/test/base/src/pages/Main.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,9 @@ const Main: React.FC = () => {
137137
<IonItem routerLink="/router-link-modifier-click">
138138
<IonLabel>Router Link Modifier Click</IonLabel>
139139
</IonItem>
140+
<IonItem routerLink="/navigate-root/page-a">
141+
<IonLabel>Navigate Root</IonLabel>
142+
</IonItem>
140143
</IonList>
141144
</IonContent>
142145
</IonPage>
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import {
2+
IonContent,
3+
IonHeader,
4+
IonPage,
5+
IonTitle,
6+
IonToolbar,
7+
IonButton,
8+
IonButtons,
9+
IonBackButton,
10+
useIonRouter,
11+
} from '@ionic/react';
12+
import React from 'react';
13+
14+
export const NavigateRootPageA: React.FC = () => {
15+
const ionRouter = useIonRouter();
16+
17+
return (
18+
<IonPage data-pageid="navigate-root-page-a">
19+
<IonHeader>
20+
<IonToolbar>
21+
<IonButtons slot="start">
22+
<IonBackButton />
23+
</IonButtons>
24+
<IonTitle>Page A</IonTitle>
25+
</IonToolbar>
26+
</IonHeader>
27+
<IonContent>
28+
<p id="page-a-can-go-back">{ionRouter.canGoBack() ? 'yes' : 'no'}</p>
29+
<IonButton id="go-to-page-b" routerLink="/navigate-root/page-b">
30+
Go to Page B
31+
</IonButton>
32+
</IonContent>
33+
</IonPage>
34+
);
35+
};
36+
37+
export const NavigateRootPageB: React.FC = () => (
38+
<IonPage data-pageid="navigate-root-page-b">
39+
<IonHeader>
40+
<IonToolbar>
41+
<IonButtons slot="start">
42+
<IonBackButton />
43+
</IonButtons>
44+
<IonTitle>Page B</IonTitle>
45+
</IonToolbar>
46+
</IonHeader>
47+
<IonContent>
48+
<IonButton id="go-to-page-c" routerLink="/navigate-root/page-c">
49+
Go to Page C
50+
</IonButton>
51+
</IonContent>
52+
</IonPage>
53+
);
54+
55+
export const NavigateRootPageC: React.FC = () => {
56+
const ionRouter = useIonRouter();
57+
58+
return (
59+
<IonPage data-pageid="navigate-root-page-c">
60+
<IonHeader>
61+
<IonToolbar>
62+
<IonButtons slot="start">
63+
<IonBackButton />
64+
</IonButtons>
65+
<IonTitle>Page C</IonTitle>
66+
</IonToolbar>
67+
</IonHeader>
68+
<IonContent>
69+
<p id="page-c-can-go-back">{ionRouter.canGoBack() ? 'yes' : 'no'}</p>
70+
<IonButton id="navigate-root-to-page-a" onClick={() => ionRouter.navigateRoot('/navigate-root/page-a')}>
71+
Navigate Root to Page A
72+
</IonButton>
73+
</IonContent>
74+
</IonPage>
75+
);
76+
};
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { test, expect } from '@playwright/test';
2+
import { ionPageVisible, ionPageDoesNotExist, withTestingMode } from './utils/test-utils';
3+
4+
test.describe('Navigate Root', () => {
5+
test('clears navigation stack and unmounts previous views', async ({ page }) => {
6+
await page.goto(withTestingMode('/navigate-root/page-a'));
7+
await ionPageVisible(page, 'navigate-root-page-a');
8+
await expect(page.locator('#page-a-can-go-back')).toHaveText('no');
9+
10+
// 1. Push to page B, then page C to build up history
11+
await page.locator('#go-to-page-b').click();
12+
await ionPageVisible(page, 'navigate-root-page-b');
13+
14+
await page.locator('#go-to-page-c').click();
15+
await ionPageVisible(page, 'navigate-root-page-c');
16+
await expect(page.locator('#page-c-can-go-back')).toHaveText('yes');
17+
18+
// 2. Navigate root — should clear stack and unmount previous views
19+
await page.locator('#navigate-root-to-page-a').click();
20+
await ionPageVisible(page, 'navigate-root-page-a');
21+
await expect(page).toHaveURL(/\/navigate-root\/page-a/);
22+
await ionPageDoesNotExist(page, 'navigate-root-page-b');
23+
await ionPageDoesNotExist(page, 'navigate-root-page-c');
24+
await expect(page.locator('#page-a-can-go-back')).toHaveText('no');
25+
});
26+
});

packages/react/src/components/IonRouterContext.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export interface IonRouterContextState {
1414
animationBuilder?: AnimationBuilder
1515
) => void;
1616
back: (animationBuilder?: AnimationBuilder) => void;
17+
navigateRoot: (pathname: string, animationBuilder?: AnimationBuilder) => void;
1718
canGoBack: () => boolean;
1819
nativeBack: () => void;
1920
}
@@ -26,6 +27,9 @@ export const IonRouterContext = React.createContext<IonRouterContextState>({
2627
back: () => {
2728
throw new Error('An Ionic Router is required for IonRouterContext');
2829
},
30+
navigateRoot: () => {
31+
throw new Error('An Ionic Router is required for IonRouterContext');
32+
},
2933
canGoBack: () => {
3034
throw new Error('An Ionic Router is required for IonRouterContext');
3135
},
@@ -45,10 +49,11 @@ export function useIonRouter(): UseIonRouterResult {
4549
back: context.back,
4650
push: context.push,
4751
goBack: context.back,
52+
navigateRoot: context.navigateRoot,
4853
canGoBack: context.canGoBack,
4954
routeInfo: context.routeInfo,
5055
}),
51-
[context.back, context.push, context.canGoBack, context.routeInfo]
56+
[context.back, context.push, context.navigateRoot, context.canGoBack, context.routeInfo]
5257
);
5358
}
5459

@@ -78,6 +83,13 @@ export type UseIonRouterResult = {
7883
* @param animationBuilder - Optional - A custom transition animation to use
7984
*/
8085
goBack(animationBuilder?: AnimationBuilder): void;
86+
/**
87+
* Navigates to a new root pathname, clearing the navigation history and unmounting all previous views.
88+
* After navigation, canGoBack() will return false. Useful for navigating to a new root after login/logout.
89+
* @param pathname - The path to navigate to
90+
* @param animationBuilder - Optional - A custom transition animation to use
91+
*/
92+
navigateRoot(pathname: string, animationBuilder?: AnimationBuilder): void;
8193
/**
8294
* Determines if there are any additional routes in the Router's history. However, routing is not prevented if the browser's history has more entries. Returns true if more entries exist, false if not.
8395
*/

packages/react/src/routing/NavManager.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ interface NavManagerProps {
2626
options?: any,
2727
tab?: string
2828
) => void;
29+
onNavigateRoot: (pathname: string, animationBuilder?: AnimationBuilder) => void;
2930
onSetCurrentTab: (tab: string, routeInfo: RouteInfo) => void;
3031
onChangeTab: (tab: string, path: string, routeOptions?: any) => void;
3132
onResetTab: (tab: string, path: string, routeOptions?: any) => void;
@@ -48,6 +49,9 @@ export class NavManager extends React.PureComponent<NavManagerProps, NavContextS
4849
back: (animationBuilder?: AnimationBuilder) => {
4950
this.goBack(undefined, animationBuilder);
5051
},
52+
navigateRoot: (pathname: string, animationBuilder?: AnimationBuilder) => {
53+
this.props.onNavigateRoot(pathname, animationBuilder);
54+
},
5155
canGoBack: () => this.props.locationHistory.canGoBack(),
5256
nativeBack: () => this.props.onNativeBack(),
5357
routeInfo: this.props.routeInfo,

0 commit comments

Comments
 (0)