From 4a4111c62065e66e644bdc33d4cf0e7635ab20b5 Mon Sep 17 00:00:00 2001 From: ShaneK Date: Thu, 1 Oct 2026 09:18:37 -0700 Subject: [PATCH 1/2] fix(react): skip outlet ready callback when the outlet unmounts first --- packages/react-router/test/base/src/App.tsx | 2 + .../react-router/test/base/src/pages/Main.tsx | 3 ++ .../OutletUnmountBeforeReady.tsx | 52 +++++++++++++++++++ .../outlet-unmount-before-ready.spec.ts | 28 ++++++++++ .../react/src/routing/OutletPageManager.tsx | 9 +++- 5 files changed, 92 insertions(+), 2 deletions(-) create mode 100644 packages/react-router/test/base/src/pages/outlet-unmount-before-ready/OutletUnmountBeforeReady.tsx create mode 100644 packages/react-router/test/base/tests/e2e/playwright/outlet-unmount-before-ready.spec.ts diff --git a/packages/react-router/test/base/src/App.tsx b/packages/react-router/test/base/src/App.tsx index 96cb242cedc..64dfdffb265 100644 --- a/packages/react-router/test/base/src/App.tsx +++ b/packages/react-router/test/base/src/App.tsx @@ -56,6 +56,7 @@ import { } from './pages/router-link-modifier-click/RouterLinkModifierClick'; import { NavigateRootPageA, NavigateRootPageB, NavigateRootPageC } from './pages/navigate-root/NavigateRoot'; import SuspenseOutlet from './pages/suspense-outlet/SuspenseOutlet'; +import OutletUnmountBeforeReady from './pages/outlet-unmount-before-ready/OutletUnmountBeforeReady'; import { PropsUpdateDirect, PropsUpdateRoutesWrapper } from './pages/props-update/PropsUpdate'; import DisabledButton from './pages/disabled-button/DisabledButton'; import SplatSibling from './pages/splat-sibling/SplatSibling'; @@ -126,6 +127,7 @@ const App: React.FC = () => { } /> } /> } /> + } /> } /> } /> } /> diff --git a/packages/react-router/test/base/src/pages/Main.tsx b/packages/react-router/test/base/src/pages/Main.tsx index 3dc6709f531..f23ceb89a02 100644 --- a/packages/react-router/test/base/src/pages/Main.tsx +++ b/packages/react-router/test/base/src/pages/Main.tsx @@ -58,6 +58,9 @@ const Main: React.FC = () => { Suspense Outlet + + Outlet Unmount Before Ready + diff --git a/packages/react-router/test/base/src/pages/outlet-unmount-before-ready/OutletUnmountBeforeReady.tsx b/packages/react-router/test/base/src/pages/outlet-unmount-before-ready/OutletUnmountBeforeReady.tsx new file mode 100644 index 00000000000..f8bb19ff958 --- /dev/null +++ b/packages/react-router/test/base/src/pages/outlet-unmount-before-ready/OutletUnmountBeforeReady.tsx @@ -0,0 +1,52 @@ +import { IonButton, IonContent, IonHeader, IonPage, IonRouterOutlet, IonTitle, IonToolbar } from '@ionic/react'; +import React, { useLayoutEffect, useState } from 'react'; +import { Route } from 'react-router-dom'; + +import TestDescription from '../../components/TestDescription'; + +/** + * Unmounting from a layout effect removes the outlet before it's ready. + */ +const TransientOutlet: React.FC<{ onMounted: () => void }> = ({ onMounted }) => { + useLayoutEffect(onMounted, [onMounted]); + + return ( + + Transient page} /> + + ); +}; + +const OutletUnmountBeforeReady: React.FC = () => { + const [mounted, setMounted] = useState(false); + const [attempts, setAttempts] = useState(0); + + return ( + + + + Outlet Unmount Before Ready + + + + + Tap the button a few times. Each tap mounts a nested outlet and removes it before it's ready, so the counter + should go up without any errors in the console. + + { + setAttempts((count) => count + 1); + setMounted(true); + }} + > + Mount and unmount outlet + +

{attempts}

+ {mounted && setMounted(false)} />} +
+
+ ); +}; + +export default OutletUnmountBeforeReady; diff --git a/packages/react-router/test/base/tests/e2e/playwright/outlet-unmount-before-ready.spec.ts b/packages/react-router/test/base/tests/e2e/playwright/outlet-unmount-before-ready.spec.ts new file mode 100644 index 00000000000..a1fc701b2b5 --- /dev/null +++ b/packages/react-router/test/base/tests/e2e/playwright/outlet-unmount-before-ready.spec.ts @@ -0,0 +1,28 @@ +import { test, expect } from '@playwright/test'; +import { ionPageVisible, withTestingMode } from './utils/test-utils'; + +test.describe('Outlet Unmount Before Ready', () => { + test('does not throw when an ionPage outlet unmounts before it is ready', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/31513', + }); + + const errors: string[] = []; + page.on('pageerror', (error) => errors.push(error.message)); + + await page.goto(withTestingMode('/outlet-unmount-before-ready')); + await ionPageVisible(page, 'outlet-unmount-before-ready'); + + for (let attempt = 1; attempt <= 3; attempt++) { + await page.locator('#mount-transient-outlet').click(); + await expect(page.locator('#attempts')).toHaveText(String(attempt)); + } + + // Give the async ready callback time to fire. + await page.waitForTimeout(500); + + expect(errors).toEqual([]); + await ionPageVisible(page, 'outlet-unmount-before-ready'); + }); +}); diff --git a/packages/react/src/routing/OutletPageManager.tsx b/packages/react/src/routing/OutletPageManager.tsx index abb9bda0481..056ab598534 100644 --- a/packages/react/src/routing/OutletPageManager.tsx +++ b/packages/react/src/routing/OutletPageManager.tsx @@ -46,7 +46,13 @@ export class OutletPageManager extends React.Component { * when React unmounts + remounts components. */ if (!this.outletIsReady) { - componentOnReady(this.ionRouterOutlet, () => { + const el = this.ionRouterOutlet; + componentOnReady(el, () => { + /** + * The outlet can unmount before this fires, which clears the ref. + */ + if (this.ionRouterOutlet !== el) return; + /** * Guard against duplicate callbacks from React strict mode double-mount. * Both componentDidMount calls pass the outer !outletIsReady check before @@ -65,7 +71,6 @@ export class OutletPageManager extends React.Component { * outlet's forward animation removes ion-page-invisible, preventing * a flash where the outlet is briefly visible at full opacity. */ - const el = this.ionRouterOutlet!; if (!el.classList.contains('ion-page-invisible') && !el.classList.contains('ion-page-hidden')) { el.classList.add('ion-page'); el.classList.add('ion-page-invisible'); From ae8a8eb45c676c19e2a5de0367d31f6162edd9e9 Mon Sep 17 00:00:00 2001 From: ShaneK Date: Mon, 5 Oct 2026 06:21:35 -0700 Subject: [PATCH 2/2] test(react-router): prefix outlet unmount test name with "should" --- .../tests/e2e/playwright/outlet-unmount-before-ready.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-router/test/base/tests/e2e/playwright/outlet-unmount-before-ready.spec.ts b/packages/react-router/test/base/tests/e2e/playwright/outlet-unmount-before-ready.spec.ts index a1fc701b2b5..b3c14a5dbbd 100644 --- a/packages/react-router/test/base/tests/e2e/playwright/outlet-unmount-before-ready.spec.ts +++ b/packages/react-router/test/base/tests/e2e/playwright/outlet-unmount-before-ready.spec.ts @@ -2,7 +2,7 @@ import { test, expect } from '@playwright/test'; import { ionPageVisible, withTestingMode } from './utils/test-utils'; test.describe('Outlet Unmount Before Ready', () => { - test('does not throw when an ionPage outlet unmounts before it is ready', async ({ page }, testInfo) => { + test('should not throw when an ionPage outlet unmounts before it is ready', async ({ page }, testInfo) => { testInfo.annotations.push({ type: 'issue', description: 'https://github.com/ionic-team/ionic-framework/issues/31513',