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..b3c14a5dbbd
--- /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('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',
+ });
+
+ 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');