Skip to content
Merged
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
1 change: 1 addition & 0 deletions packages/react-router/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/react-router/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"dist/"
],
"dependencies": {
"@ionic/core": "^9.0.5",
"@ionic/react": "^9.0.5",
"tslib": "*"
},
Expand Down
65 changes: 51 additions & 14 deletions packages/react-router/src/ReactRouter/StackManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@
* particularly with animations and swipe gestures.
*/

import {
LIFECYCLE_DID_ENTER,
LIFECYCLE_DID_LEAVE,
LIFECYCLE_WILL_ENTER,
LIFECYCLE_WILL_LEAVE,
} from '@ionic/core/components';
import type { RouteInfo, StackContextState, ViewItem } from '@ionic/react';
import { IonRoute, RouteManagerContext, StackContext, createDebugLogger, generateId, getConfig } from '@ionic/react';
import React from 'react';
Expand Down Expand Up @@ -88,6 +94,19 @@ const revealIonPageForSwipeBack = (element: HTMLElement | undefined): void => {
}
};

type ViewLifecycleEvent =
| typeof LIFECYCLE_WILL_ENTER
| typeof LIFECYCLE_DID_ENTER
| typeof LIFECYCLE_WILL_LEAVE
| typeof LIFECYCLE_DID_LEAVE;

/** Dispatches a view lifecycle event the way core's `lifecycle()` does. */
const dispatchLifecycleEvent = (element: HTMLElement | undefined, eventName: ViewLifecycleEvent): void => {
if (element) {
element.dispatchEvent(new CustomEvent(eventName, { bubbles: false, cancelable: false }));
}
};

/**
* A leaf view is "preservable" on browser-back (pop) when its React state
* should survive a forward-pop round-trip. Non-parameterized leaf paths
Expand Down Expand Up @@ -357,12 +376,8 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
const allViewsInOutlet = this.context.getViewItemsForOutlet(this.id);
allViewsInOutlet.forEach((viewItem) => {
if (viewItem.ionPageElement && isViewVisible(viewItem.ionPageElement)) {
viewItem.ionPageElement.dispatchEvent(
new CustomEvent('ionViewWillLeave', { bubbles: false, cancelable: false })
);
viewItem.ionPageElement.dispatchEvent(
new CustomEvent('ionViewDidLeave', { bubbles: false, cancelable: false })
);
dispatchLifecycleEvent(viewItem.ionPageElement, LIFECYCLE_WILL_LEAVE);
dispatchLifecycleEvent(viewItem.ionPageElement, LIFECYCLE_DID_LEAVE);
}
});

Expand Down Expand Up @@ -399,12 +414,8 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
return;
}
if (viewItem.ionPageElement && isViewVisible(viewItem.ionPageElement)) {
viewItem.ionPageElement.dispatchEvent(
new CustomEvent('ionViewWillLeave', { bubbles: false, cancelable: false })
);
viewItem.ionPageElement.dispatchEvent(
new CustomEvent('ionViewDidLeave', { bubbles: false, cancelable: false })
);
dispatchLifecycleEvent(viewItem.ionPageElement, LIFECYCLE_WILL_LEAVE);
dispatchLifecycleEvent(viewItem.ionPageElement, LIFECYCLE_DID_LEAVE);
}
this.context.unMountViewItem(viewItem);
});
Expand Down Expand Up @@ -1737,10 +1748,36 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
// Bail out if the component unmounted during waitForComponentsReady
if (!this._isMounted) return;

const isCurrent = myGeneration === this.transitionGeneration;
// A page the newest transition is entering is not leaving after all.
const isLeaving = isCurrent || leavingEl !== this.transitionEnteringElement;
// Already hidden means the leave events have fired. This checks the class rather
// than `isViewVisible` because a nested outlet marks its leaving page
// `visibility: hidden` before we get here and still needs `ionViewDidLeave`.
const announceLeaving = isLeaving && !leavingEl.classList.contains('ion-page-hidden');

/**
* Dispatch the lifecycle events, since we skipped `commit()`. Only the
* newest transition fires the entering events, and the class swap follows
* all four so the ordering matches core's `transition()`.
*
* These run after `waitForComponentsReady` because on a first mount the
* page has not attached its listeners yet.
*/
if (announceLeaving) {
dispatchLifecycleEvent(leavingEl, LIFECYCLE_WILL_LEAVE);
}
if (isCurrent) {
dispatchLifecycleEvent(enteringEl, LIFECYCLE_WILL_ENTER);
dispatchLifecycleEvent(enteringEl, LIFECYCLE_DID_ENTER);
}
if (announceLeaving) {
dispatchLifecycleEvent(leavingEl, LIFECYCLE_DID_LEAVE);
}

// Swap visibility synchronously - show entering, hide leaving
// Skip hiding if a newer transition already made leavingEl the entering view
enteringEl.classList.remove('ion-page-invisible');
if (myGeneration === this.transitionGeneration || leavingEl !== this.transitionEnteringElement) {
if (isLeaving) {
leavingEl.classList.add('ion-page-hidden');
leavingEl.setAttribute('aria-hidden', 'true');
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,28 @@ import {
IonRouterOutlet,
IonBackButton,
IonButtons,
useIonViewDidEnter,
useIonViewDidLeave,
useIonViewWillEnter,
useIonViewWillLeave,
} from '@ionic/react';
import React from 'react';
import { Route, Navigate } from 'react-router-dom';

import TestDescription from '../../components/TestDescription';
import { pushLifecycleEvent } from '../../utils';

/**
* Tests that IonBackButton works correctly after navigating with
* routerDirection="none". The back button should use history to
* determine the previous page, not fall back to defaultHref.
*/
const PageA: React.FC = () => {
useIonViewWillEnter(() => pushLifecycleEvent('a:ionViewWillEnter'));
useIonViewDidEnter(() => pushLifecycleEvent('a:ionViewDidEnter'));
useIonViewWillLeave(() => pushLifecycleEvent('a:ionViewWillLeave'));
useIonViewDidLeave(() => pushLifecycleEvent('a:ionViewDidLeave'));

return (
<IonPage data-pageid="direction-none-page-a">
<IonHeader>
Expand All @@ -41,6 +51,11 @@ const PageA: React.FC = () => {
};

const PageB: React.FC = () => {
useIonViewWillEnter(() => pushLifecycleEvent('b:ionViewWillEnter'));
useIonViewDidEnter(() => pushLifecycleEvent('b:ionViewDidEnter'));
useIonViewWillLeave(() => pushLifecycleEvent('b:ionViewWillLeave'));
useIonViewDidLeave(() => pushLifecycleEvent('b:ionViewDidLeave'));

return (
<IonPage data-pageid="direction-none-page-b">
<IonHeader>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,7 @@ import React from 'react';
import { Route, Navigate } from 'react-router';

import TestDescription from '../../components/TestDescription';

const pushEvent = (event: string) => {
(window as any).lifecycleEvents = (window as any).lifecycleEvents || [];
(window as any).lifecycleEvents.push(event);
};
import { pushLifecycleEvent } from '../../utils';

const TabLifecycle: React.FC = () => {
return (
Expand All @@ -50,10 +46,10 @@ const TabLifecycle: React.FC = () => {
};

const HomeTab: React.FC = () => {
useIonViewWillEnter(() => pushEvent('home:ionViewWillEnter'));
useIonViewDidEnter(() => pushEvent('home:ionViewDidEnter'));
useIonViewWillLeave(() => pushEvent('home:ionViewWillLeave'));
useIonViewDidLeave(() => pushEvent('home:ionViewDidLeave'));
useIonViewWillEnter(() => pushLifecycleEvent('home:ionViewWillEnter'));
useIonViewDidEnter(() => pushLifecycleEvent('home:ionViewDidEnter'));
useIonViewWillLeave(() => pushLifecycleEvent('home:ionViewWillLeave'));
useIonViewDidLeave(() => pushLifecycleEvent('home:ionViewDidLeave'));

return (
<IonPage data-pageid="tab-lifecycle-home">
Expand All @@ -73,10 +69,10 @@ const HomeTab: React.FC = () => {
};

const SettingsTab: React.FC = () => {
useIonViewWillEnter(() => pushEvent('settings:ionViewWillEnter'));
useIonViewDidEnter(() => pushEvent('settings:ionViewDidEnter'));
useIonViewWillLeave(() => pushEvent('settings:ionViewWillLeave'));
useIonViewDidLeave(() => pushEvent('settings:ionViewDidLeave'));
useIonViewWillEnter(() => pushLifecycleEvent('settings:ionViewWillEnter'));
useIonViewDidEnter(() => pushLifecycleEvent('settings:ionViewDidEnter'));
useIonViewWillLeave(() => pushLifecycleEvent('settings:ionViewWillLeave'));
useIonViewDidLeave(() => pushLifecycleEvent('settings:ionViewDidLeave'));

return (
<IonPage data-pageid="tab-lifecycle-settings">
Expand Down
1 change: 1 addition & 0 deletions packages/react-router/test/base/src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './generateId';
export * from './lifecycleEvents';
export * from './dev';
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/** Records a view lifecycle event on `window.lifecycleEvents` for a spec to assert on. */
export const pushLifecycleEvent = (event: string) => {
(window as any).lifecycleEvents = (window as any).lifecycleEvents || [];
(window as any).lifecycleEvents.push(event);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { test, expect, type Page } from '@playwright/test';
import { ionPageVisible, resetLifecycleEvents, settledLifecycleEvents, withTestingMode } from './utils/test-utils';

/**
* A navigation with routerDirection="none" is not animated, but it must still
* fire the four view lifecycle events, in the same order an animated one does.
*/
test.describe('routerDirection="none" lifecycle events', () => {
const expectedEvents = ['a:ionViewWillLeave', 'b:ionViewWillEnter', 'b:ionViewDidEnter', 'a:ionViewDidLeave'];

const goToPageA = async (page: Page) => {
await page.goto(withTestingMode('/direction-none-back/a'));
await ionPageVisible(page, 'direction-none-page-a');
await resetLifecycleEvents(page);
};

test('should fire enter and leave events on a routerDirection="none" navigation', async ({ page }, testInfo) => {
testInfo.annotations.push({
type: 'issue',
description: 'https://github.com/ionic-team/ionic-framework/issues/31479',
});

await goToPageA(page);

await page.locator('#go-none').click();
await ionPageVisible(page, 'direction-none-page-b');

expect(await settledLifecycleEvents(page)).toEqual(expectedEvents);
});

/**
* The control. A forward navigation keeps its direction, so it takes the
* regular transition path, and its event order is the one the test above
* has to match.
*/
test('should fire the same events for a forward navigation', async ({ page }) => {
await goToPageA(page);

await page.locator('#go-forward').click();
await ionPageVisible(page, 'direction-none-page-b');

expect(await settledLifecycleEvents(page)).toEqual(expectedEvents);
});
});
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import { test, expect } from '@playwright/test';
import { ionPageVisible, ionTabClick, trackPeakMatchCount, withTestingMode } from './utils/test-utils';
import {
ionPageVisible,
ionTabClick,
resetLifecycleEvents,
settledLifecycleEvents,
trackPeakMatchCount,
withTestingMode,
} from './utils/test-utils';

test.describe('Tab Lifecycle Events', () => {
test.beforeEach(async ({ page }) => {
Expand All @@ -17,12 +24,12 @@ test.describe('Tab Lifecycle Events', () => {
await page.goto(withTestingMode('/tab-lifecycle/home'));
await ionPageVisible(page, 'tab-lifecycle-home');

await page.evaluate(() => { (window as any).lifecycleEvents = []; });
await resetLifecycleEvents(page);

await page.locator('#go-outside').click();
await ionPageVisible(page, 'tab-lifecycle-outside');

const events = await page.evaluate(() => (window as any).lifecycleEvents as string[]);
const events = await settledLifecycleEvents(page);
expect(events).toContain('home:ionViewWillLeave');
expect(events).toContain('home:ionViewDidLeave');
});
Expand All @@ -39,12 +46,12 @@ test.describe('Tab Lifecycle Events', () => {
await ionTabClick(page, 'Settings');
await ionPageVisible(page, 'tab-lifecycle-settings');

await page.evaluate(() => { (window as any).lifecycleEvents = []; });
await resetLifecycleEvents(page);

await page.locator('#go-outside-settings').click();
await ionPageVisible(page, 'tab-lifecycle-outside');

const events = await page.evaluate(() => (window as any).lifecycleEvents as string[]);
const events = await settledLifecycleEvents(page);
expect(events).toContain('settings:ionViewWillLeave');
expect(events).toContain('settings:ionViewDidLeave');
});
Expand All @@ -61,16 +68,63 @@ test.describe('Tab Lifecycle Events', () => {
await page.locator('#go-outside').click();
await ionPageVisible(page, 'tab-lifecycle-outside');

await page.evaluate(() => { (window as any).lifecycleEvents = []; });
await resetLifecycleEvents(page);

await page.locator('#go-back-to-tabs').click();
await ionPageVisible(page, 'tab-lifecycle-home');

const events = await page.evaluate(() => (window as any).lifecycleEvents as string[]);
const events = await settledLifecycleEvents(page);
expect(events).toContain('home:ionViewWillEnter');
expect(events).toContain('home:ionViewDidEnter');
});

test('should fire enter and leave events when switching tabs', async ({ page }, testInfo) => {
testInfo.annotations.push({
type: 'issue',
description: 'https://github.com/ionic-team/ionic-framework/issues/31479',
});

await page.goto(withTestingMode('/tab-lifecycle/home'));
await ionPageVisible(page, 'tab-lifecycle-home');

await resetLifecycleEvents(page);

await ionTabClick(page, 'Settings');
await ionPageVisible(page, 'tab-lifecycle-settings');

expect(await settledLifecycleEvents(page)).toEqual([
'home:ionViewWillLeave',
'settings:ionViewWillEnter',
'settings:ionViewDidEnter',
'home:ionViewDidLeave',
]);
});

test('should fire enter and leave events when switching back to a visited tab', async ({ page }, testInfo) => {
testInfo.annotations.push({
type: 'issue',
description: 'https://github.com/ionic-team/ionic-framework/issues/31479',
});

await page.goto(withTestingMode('/tab-lifecycle/home'));
await ionPageVisible(page, 'tab-lifecycle-home');

await ionTabClick(page, 'Settings');
await ionPageVisible(page, 'tab-lifecycle-settings');

await resetLifecycleEvents(page);

await ionTabClick(page, 'Home');
await ionPageVisible(page, 'tab-lifecycle-home');

expect(await settledLifecycleEvents(page)).toEqual([
'settings:ionViewWillLeave',
'home:ionViewWillEnter',
'home:ionViewDidEnter',
'settings:ionViewDidLeave',
]);
});

// A duplicate tab page, even briefly, fails this spec's page assertions on a
// strict mode violation.
test('should not duplicate the tab page in the DOM while returning to the tabs', async ({ page }) => {
Expand Down
Loading
Loading