Prerequisites
Ionic Framework Version
v9.x
Current Behavior
In @ionic/react-router 9.0.x (checked on 9.0.5), switching tabs does not fire ionViewWillEnter, ionViewDidEnter, ionViewWillLeave or ionViewDidLeave. The same happens for any link with routerDirection="none", such as the menu items in the React side-menu starter. Pages that load their data in useIonViewWillEnter render, but the data never loads (as it'd do in i.e. 8.8.19).
The events are not dispatched at all. A capturing listener on the ion-router-outlet element receives nothing either, so this isn't a problem with the hooks.
Expected Behavior
Lifecycle events to fire on every navigation, whether or not it animates. The React lifecycle docs say ionViewWillEnter "is called every time the view is navigated to (regardless if initialized or not)".
Steps to Reproduce
- Clone the repro (official React tabs starter), then run
npm install and npm run dev.
- Open
/tab1. The page lists ionViewWillEnter and ionViewDidEnter, counter 2.
- Click Tab 2 in the tab bar. Observe that Tab 2 lists nothing, counter 0.
- Click Tab 1 again. Observe that Tab 1 still lists only its original two entries. Neither switch recorded a leave or an enter.
- Back on Tab 1, use the "Try these navigations" list.
routerDirection="none" behaves like the tab bar: no events on either page. routerDirection="forward", routerDirection="root" and a forward push to the Details page (and the back button from it) all record the expected leave and enter entries.
Code Reproduction URL
https://github.com/baenans/ionic-9-router-issue-repro
Ionic Info
Ionic:
Ionic CLI : 7.2.1
Ionic Framework : @ionic/react 9.0.5
Capacitor:
Capacitor CLI : 8.5.2
@capacitor/core : 8.5.2
System:
NodeJS : v26.10.0
npm : 11.19.1
OS : macOS
Additional Information
It looks like the non-animated branch in StackManager.transitionPage() (L1644-L1646 in 9.0.5) skips routerOutlet.commit() and swaps the page classes itself, to avoid intermediate paints. Core dispatches the four lifecycle events from inside commit(), so skipping it drops them. In 8.8.19 this path called commit() with duration: 0 (L351-L357). Tab switches and routerDirection="none" both take this branch.
routerDirection="root" isn't a workaround. It unmounts the other pages in the outlet and drops back history, and it can't be used for tab switches.
This change has worked for us as a patch: dispatching the four events in the fast path in the same order commit() uses, around the class swap at StackManager.tsx L1735-L1746.
await waitForComponentsReady();
// Bail out if the component unmounted during waitForComponentsReady
if (!this._isMounted) return;
+ // Fire lifecycle events since we're skipping commit() which normally dispatches them.
+ // Must run after waitForComponentsReady: on first mount PageManager registers the page
+ // before attaching its listeners, so earlier events would be lost.
+ // Skip leave events (and hiding) if a newer transition already made leavingEl the entering view
+ const isLeaving = myGeneration === this.transitionGeneration || leavingEl !== this.transitionEnteringElement;
+ if (isLeaving) {
+ leavingEl.dispatchEvent(new CustomEvent('ionViewWillLeave', { bubbles: false, cancelable: false }));
+ }
+ enteringEl.dispatchEvent(new CustomEvent('ionViewWillEnter', { bubbles: false, cancelable: false }));
+
// 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');
}
+
+ enteringEl.dispatchEvent(new CustomEvent('ionViewDidEnter', { bubbles: false, cancelable: false }));
+ if (isLeaving) {
+ leavingEl.dispatchEvent(new CustomEvent('ionViewDidLeave', { bubbles: false, cancelable: false }));
+ }
Prerequisites
Ionic Framework Version
v9.x
Current Behavior
In
@ionic/react-router9.0.x (checked on 9.0.5), switching tabs does not fireionViewWillEnter,ionViewDidEnter,ionViewWillLeaveorionViewDidLeave. The same happens for any link withrouterDirection="none", such as the menu items in the React side-menu starter. Pages that load their data inuseIonViewWillEnterrender, but the data never loads (as it'd do in i.e. 8.8.19).The events are not dispatched at all. A capturing listener on the
ion-router-outletelement receives nothing either, so this isn't a problem with the hooks.Expected Behavior
Lifecycle events to fire on every navigation, whether or not it animates. The React lifecycle docs say
ionViewWillEnter"is called every time the view is navigated to (regardless if initialized or not)".Steps to Reproduce
npm installandnpm run dev./tab1. The page listsionViewWillEnterandionViewDidEnter, counter 2.routerDirection="none"behaves like the tab bar: no events on either page.routerDirection="forward",routerDirection="root"and a forward push to the Details page (and the back button from it) all record the expected leave and enter entries.Code Reproduction URL
https://github.com/baenans/ionic-9-router-issue-repro
Ionic Info
Ionic:
Ionic CLI : 7.2.1
Ionic Framework : @ionic/react 9.0.5
Capacitor:
Capacitor CLI : 8.5.2
@capacitor/core : 8.5.2
System:
NodeJS : v26.10.0
npm : 11.19.1
OS : macOS
Additional Information
It looks like the non-animated branch in
StackManager.transitionPage()(L1644-L1646 in 9.0.5) skipsrouterOutlet.commit()and swaps the page classes itself, to avoid intermediate paints. Core dispatches the four lifecycle events from insidecommit(), so skipping it drops them. In 8.8.19 this path calledcommit()withduration: 0(L351-L357). Tab switches androuterDirection="none"both take this branch.routerDirection="root"isn't a workaround. It unmounts the other pages in the outlet and drops back history, and it can't be used for tab switches.This change has worked for us as a patch: dispatching the four events in the fast path in the same order
commit()uses, around the class swap atStackManager.tsxL1735-L1746.await waitForComponentsReady(); // Bail out if the component unmounted during waitForComponentsReady if (!this._isMounted) return; + // Fire lifecycle events since we're skipping commit() which normally dispatches them. + // Must run after waitForComponentsReady: on first mount PageManager registers the page + // before attaching its listeners, so earlier events would be lost. + // Skip leave events (and hiding) if a newer transition already made leavingEl the entering view + const isLeaving = myGeneration === this.transitionGeneration || leavingEl !== this.transitionEnteringElement; + if (isLeaving) { + leavingEl.dispatchEvent(new CustomEvent('ionViewWillLeave', { bubbles: false, cancelable: false })); + } + enteringEl.dispatchEvent(new CustomEvent('ionViewWillEnter', { bubbles: false, cancelable: false })); + // 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'); } + + enteringEl.dispatchEvent(new CustomEvent('ionViewDidEnter', { bubbles: false, cancelable: false })); + if (isLeaving) { + leavingEl.dispatchEvent(new CustomEvent('ionViewDidLeave', { bubbles: false, cancelable: false })); + }