Skip to content

Commit 30994f5

Browse files
committed
fix(react-router): fire ionViewDidLeave on tab child pages when navigating away from tabs
1 parent c8ea46d commit 30994f5

6 files changed

Lines changed: 225 additions & 1 deletion

File tree

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

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,24 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
242242
this.outOfScopeUnmountTimeout = undefined;
243243
}
244244

245+
// Fire lifecycle events on any visible view before unmounting.
246+
// When navigating away from a tabbed section, the parent outlet fires
247+
// ionViewDidLeave on the tabs container, but the active tab child page
248+
// never receives its own lifecycle events because the core transition
249+
// dispatches events with bubbles:false. This ensures tab child pages
250+
// get ionViewWillLeave/ionViewDidLeave so useIonViewDidLeave fires.
251+
const allViewsInOutlet = this.context.getViewItemsForOutlet(this.id);
252+
allViewsInOutlet.forEach((viewItem) => {
253+
if (viewItem.ionPageElement && isViewVisible(viewItem.ionPageElement)) {
254+
viewItem.ionPageElement.dispatchEvent(
255+
new CustomEvent('ionViewWillLeave', { bubbles: false, cancelable: false })
256+
);
257+
viewItem.ionPageElement.dispatchEvent(
258+
new CustomEvent('ionViewDidLeave', { bubbles: false, cancelable: false })
259+
);
260+
}
261+
});
262+
245263
// Remove view items from the stack but do NOT apply ion-page-hidden.
246264
// ion-page-hidden sets display:none which immediately removes content
247265
// from the layout, causing the parent outlet's leaving page to flash
@@ -253,7 +271,6 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
253271
// commit() captures the current DOM state (with content visible) before
254272
// React processes the removal. The compositor's cached layer is unaffected
255273
// by subsequent DOM changes during the animation.
256-
const allViewsInOutlet = this.context.getViewItemsForOutlet(this.id);
257274
allViewsInOutlet.forEach((viewItem) => {
258275
this.context.unMountViewItem(viewItem);
259276
});

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ import DirectionNoneBack from './pages/direction-none-back/DirectionNoneBack';
6262
import TabSearchParams from './pages/tab-search-params/TabSearchParams';
6363
import { Step1, Step2, Step3, Step4 } from './pages/replace-params/ReplaceParams';
6464
import { ParamSwipeBack, ParamSwipeBackB } from './pages/param-swipe-back/ParamSwipeBack';
65+
import TabLifecycle from './pages/tab-lifecycle/TabLifecycle';
66+
import TabLifecycleOutside from './pages/tab-lifecycle/TabLifecycleOutside';
6567

6668
setupIonicReact();
6769

@@ -118,6 +120,8 @@ const App: React.FC = () => {
118120
<Route path="/replace-params/step3/:id" element={<Step3 />} />
119121
<Route path="/replace-params/step4/:id" element={<Step4 />} />
120122
<Route path="/replace-params" element={<Navigate to="/replace-params/step1" replace />} />
123+
<Route path="/tab-lifecycle/*" element={<TabLifecycle />} />
124+
<Route path="/tab-lifecycle-outside" element={<TabLifecycleOutside />} />
121125
</IonRouterOutlet>
122126
</IonReactRouter>
123127
</IonApp>

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,9 @@ const Main: React.FC = () => {
131131
<IonItem routerLink="/param-swipe-back">
132132
<IonLabel>Param Swipe Back</IonLabel>
133133
</IonItem>
134+
<IonItem routerLink="/tab-lifecycle">
135+
<IonLabel>Tab Lifecycle</IonLabel>
136+
</IonItem>
134137
</IonList>
135138
</IonContent>
136139
</IonPage>
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import {
2+
IonTabs,
3+
IonRouterOutlet,
4+
IonTabBar,
5+
IonTabButton,
6+
IonIcon,
7+
IonLabel,
8+
IonPage,
9+
IonHeader,
10+
IonToolbar,
11+
IonTitle,
12+
IonContent,
13+
IonButton,
14+
useIonViewDidEnter,
15+
useIonViewDidLeave,
16+
useIonViewWillEnter,
17+
useIonViewWillLeave,
18+
} from '@ionic/react';
19+
import { triangle, square } from 'ionicons/icons';
20+
import React from 'react';
21+
import { Route, Navigate } from 'react-router';
22+
23+
const pushEvent = (event: string) => {
24+
(window as any).lifecycleEvents = (window as any).lifecycleEvents || [];
25+
(window as any).lifecycleEvents.push(event);
26+
};
27+
28+
const TabLifecycle: React.FC = () => {
29+
return (
30+
<IonTabs data-pageid="tab-lifecycle">
31+
<IonRouterOutlet id="tab-lifecycle">
32+
<Route index element={<Navigate to="/tab-lifecycle/home" replace />} />
33+
<Route path="home" element={<HomeTab />} />
34+
<Route path="settings" element={<SettingsTab />} />
35+
</IonRouterOutlet>
36+
<IonTabBar slot="bottom">
37+
<IonTabButton tab="home" href="/tab-lifecycle/home">
38+
<IonIcon icon={triangle} />
39+
<IonLabel>Home</IonLabel>
40+
</IonTabButton>
41+
<IonTabButton tab="settings" href="/tab-lifecycle/settings">
42+
<IonIcon icon={square} />
43+
<IonLabel>Settings</IonLabel>
44+
</IonTabButton>
45+
</IonTabBar>
46+
</IonTabs>
47+
);
48+
};
49+
50+
const HomeTab: React.FC = () => {
51+
useIonViewWillEnter(() => pushEvent('home:ionViewWillEnter'));
52+
useIonViewDidEnter(() => pushEvent('home:ionViewDidEnter'));
53+
useIonViewWillLeave(() => pushEvent('home:ionViewWillLeave'));
54+
useIonViewDidLeave(() => pushEvent('home:ionViewDidLeave'));
55+
56+
return (
57+
<IonPage data-pageid="tab-lifecycle-home">
58+
<IonHeader>
59+
<IonToolbar>
60+
<IonTitle>Home Tab</IonTitle>
61+
</IonToolbar>
62+
</IonHeader>
63+
<IonContent>
64+
<IonButton routerLink="/tab-lifecycle-outside" id="go-outside">
65+
Go Outside Tabs
66+
</IonButton>
67+
</IonContent>
68+
</IonPage>
69+
);
70+
};
71+
72+
const SettingsTab: React.FC = () => {
73+
useIonViewWillEnter(() => pushEvent('settings:ionViewWillEnter'));
74+
useIonViewDidEnter(() => pushEvent('settings:ionViewDidEnter'));
75+
useIonViewWillLeave(() => pushEvent('settings:ionViewWillLeave'));
76+
useIonViewDidLeave(() => pushEvent('settings:ionViewDidLeave'));
77+
78+
return (
79+
<IonPage data-pageid="tab-lifecycle-settings">
80+
<IonHeader>
81+
<IonToolbar>
82+
<IonTitle>Settings Tab</IonTitle>
83+
</IonToolbar>
84+
</IonHeader>
85+
<IonContent>
86+
<IonButton routerLink="/tab-lifecycle-outside" id="go-outside-settings">
87+
Go Outside Tabs
88+
</IonButton>
89+
</IonContent>
90+
</IonPage>
91+
);
92+
};
93+
94+
export default TabLifecycle;
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import {
2+
IonPage,
3+
IonHeader,
4+
IonToolbar,
5+
IonTitle,
6+
IonContent,
7+
IonButton,
8+
IonButtons,
9+
IonBackButton,
10+
} from '@ionic/react';
11+
import React from 'react';
12+
13+
const TabLifecycleOutside: React.FC = () => {
14+
return (
15+
<IonPage data-pageid="tab-lifecycle-outside">
16+
<IonHeader>
17+
<IonToolbar>
18+
<IonButtons slot="start">
19+
<IonBackButton defaultHref="/tab-lifecycle/home" />
20+
</IonButtons>
21+
<IonTitle>Outside Tabs</IonTitle>
22+
</IonToolbar>
23+
</IonHeader>
24+
<IonContent>
25+
<IonButton routerLink="/tab-lifecycle/home" id="go-back-to-tabs">
26+
Back to Tabs
27+
</IonButton>
28+
</IonContent>
29+
</IonPage>
30+
);
31+
};
32+
33+
export default TabLifecycleOutside;
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { test, expect } from '@playwright/test';
2+
import { ionPageVisible, ionTabClick, withTestingMode } from './utils/test-utils';
3+
4+
test.describe('Tab Lifecycle Events', () => {
5+
test.beforeEach(async ({ page }) => {
6+
await page.addInitScript(() => {
7+
(window as any).lifecycleEvents = [];
8+
});
9+
});
10+
11+
test('ionViewDidLeave should fire on active tab child page when navigating away from tabs', async ({ page }, testInfo) => {
12+
testInfo.annotations.push({
13+
type: 'issue',
14+
description: 'https://outsystemsrd.atlassian.net/browse/FW-6788',
15+
});
16+
17+
await page.goto(withTestingMode('/tab-lifecycle/home'));
18+
await ionPageVisible(page, 'tab-lifecycle-home');
19+
20+
await page.evaluate(() => { (window as any).lifecycleEvents = []; });
21+
22+
await page.locator('#go-outside').click();
23+
await ionPageVisible(page, 'tab-lifecycle-outside');
24+
25+
const events = await page.evaluate(() => (window as any).lifecycleEvents as string[]);
26+
expect(events).toContain('home:ionViewWillLeave');
27+
expect(events).toContain('home:ionViewDidLeave');
28+
});
29+
30+
test('ionViewDidLeave should fire on active tab child page when navigating from non-default tab', async ({ page }, testInfo) => {
31+
testInfo.annotations.push({
32+
type: 'issue',
33+
description: 'https://outsystemsrd.atlassian.net/browse/FW-6788',
34+
});
35+
36+
await page.goto(withTestingMode('/tab-lifecycle/home'));
37+
await ionPageVisible(page, 'tab-lifecycle-home');
38+
39+
await ionTabClick(page, 'Settings');
40+
await ionPageVisible(page, 'tab-lifecycle-settings');
41+
42+
await page.evaluate(() => { (window as any).lifecycleEvents = []; });
43+
44+
await page.locator('#go-outside-settings').click();
45+
await ionPageVisible(page, 'tab-lifecycle-outside');
46+
47+
const events = await page.evaluate(() => (window as any).lifecycleEvents as string[]);
48+
expect(events).toContain('settings:ionViewWillLeave');
49+
expect(events).toContain('settings:ionViewDidLeave');
50+
});
51+
52+
test('ionViewDidEnter should fire on tab child page when navigating back to tabs', async ({ page }, testInfo) => {
53+
testInfo.annotations.push({
54+
type: 'issue',
55+
description: 'https://outsystemsrd.atlassian.net/browse/FW-6788',
56+
});
57+
58+
await page.goto(withTestingMode('/tab-lifecycle/home'));
59+
await ionPageVisible(page, 'tab-lifecycle-home');
60+
61+
await page.locator('#go-outside').click();
62+
await ionPageVisible(page, 'tab-lifecycle-outside');
63+
64+
await page.evaluate(() => { (window as any).lifecycleEvents = []; });
65+
66+
await page.locator('#go-back-to-tabs').click();
67+
await ionPageVisible(page, 'tab-lifecycle-home');
68+
69+
const events = await page.evaluate(() => (window as any).lifecycleEvents as string[]);
70+
expect(events).toContain('home:ionViewWillEnter');
71+
expect(events).toContain('home:ionViewDidEnter');
72+
});
73+
});

0 commit comments

Comments
 (0)