Skip to content
Open
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
2 changes: 2 additions & 0 deletions packages/react-router/test/base/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -126,6 +127,7 @@ const App: React.FC = () => {
<Route path="/navigate-root/page-b" element={<NavigateRootPageB />} />
<Route path="/navigate-root/page-c" element={<NavigateRootPageC />} />
<Route path="/suspense-outlet/*" element={<SuspenseOutlet />} />
<Route path="/outlet-unmount-before-ready" element={<OutletUnmountBeforeReady />} />
<Route path="/props-update-routes/*" element={<PropsUpdateRoutesWrapper />} />
<Route path="/props-update-direct/*" element={<PropsUpdateDirect />} />
<Route path="/splat-sibling/*" element={<SplatSibling />} />
Expand Down
3 changes: 3 additions & 0 deletions packages/react-router/test/base/src/pages/Main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ const Main: React.FC = () => {
<IonItem routerLink="/suspense-outlet/content" id="go-to-suspense-outlet">
<IonLabel>Suspense Outlet</IonLabel>
</IonItem>
<IonItem routerLink="/outlet-unmount-before-ready">
<IonLabel>Outlet Unmount Before Ready</IonLabel>
</IonItem>
</IonList>

<IonList>
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<IonRouterOutlet ionPage>
<Route path="*" element={<IonPage>Transient page</IonPage>} />
</IonRouterOutlet>
);
};

const OutletUnmountBeforeReady: React.FC = () => {
const [mounted, setMounted] = useState(false);
const [attempts, setAttempts] = useState(0);

return (
<IonPage data-pageid="outlet-unmount-before-ready">
<IonHeader>
<IonToolbar>
<IonTitle>Outlet Unmount Before Ready</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent>
<TestDescription>
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.
</TestDescription>
<IonButton
id="mount-transient-outlet"
onClick={() => {
setAttempts((count) => count + 1);
setMounted(true);
}}
>
Mount and unmount outlet
</IonButton>
<p id="attempts">{attempts}</p>
{mounted && <TransientOutlet onMounted={() => setMounted(false)} />}
</IonContent>
</IonPage>
);
};

export default OutletUnmountBeforeReady;
Original file line number Diff line number Diff line change
@@ -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) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These usually say "should," right?

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');
});
});
9 changes: 7 additions & 2 deletions packages/react/src/routing/OutletPageManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,13 @@ export class OutletPageManager extends React.Component<OutletPageManagerProps> {
* 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
Expand All @@ -65,7 +71,6 @@ export class OutletPageManager extends React.Component<OutletPageManagerProps> {
* 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');
Expand Down
Loading