Skip to content

Commit 50f28fc

Browse files
committed
fix(react): guard against strict mode double-mount in OutletPageManager
1 parent 1d33df4 commit 50f28fc

2 files changed

Lines changed: 97 additions & 0 deletions

File tree

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { test, expect } from '@playwright/test';
2+
import { ionPageVisible, ionGoBack, ionGoForward, withTestingMode } from './utils/test-utils';
3+
4+
/**
5+
* Regression tests for browser forward navigation to routes that use
6+
* IonRouterOutlet with ionPage prop (OutletPageManager).
7+
*
8+
* Bug: navigating forward to a route with an ionPage outlet, going back,
9+
* then using browser forward would leave the ionPage outlet stuck with
10+
* ion-page-invisible (opacity: 0), rendering a blank page.
11+
*
12+
* Root cause: React strict mode (or createRoot) double-fires componentDidMount.
13+
* Each call scheduled an async componentOnReady callback. The second callback
14+
* re-added ion-page-invisible after the first callback's transition removed it.
15+
* registerIonPage returned early (same element), so no transition re-ran.
16+
*/
17+
test.describe('Browser forward: ionPage outlet visibility', () => {
18+
test('modal-aria-hidden page should be visible after back then forward (no animations)', async ({ page }) => {
19+
await page.goto(withTestingMode('/'));
20+
await ionPageVisible(page, 'home');
21+
22+
// Navigate to modal-aria-hidden (has ionPage outlets)
23+
await page.locator('ion-item').filter({ hasText: 'Modal Aria Hidden' }).click();
24+
await page.waitForTimeout(300);
25+
await expect(page).toHaveURL(/modal-aria-hidden\/section-a/);
26+
27+
// Verify Page A is visible
28+
await ionPageVisible(page, 'modal-page-a');
29+
30+
// Go back to home
31+
await ionGoBack(page, '/');
32+
await ionPageVisible(page, 'home');
33+
34+
// Browser forward
35+
await ionGoForward(page, 'modal-aria-hidden/section-a');
36+
37+
// Page A must be visible (not stuck with ion-page-invisible)
38+
await ionPageVisible(page, 'modal-page-a');
39+
});
40+
41+
test('nested-outlet2 home should be visible after back then forward (no animations)', async ({ page }) => {
42+
await page.goto(withTestingMode('/'));
43+
await ionPageVisible(page, 'home');
44+
45+
// Navigate to nested-outlet2 (has ionPage outlets)
46+
await page.locator('ion-item').filter({ hasText: 'Nested Outlet 2' }).click();
47+
await page.waitForTimeout(300);
48+
await expect(page).toHaveURL(/nested-outlet2\/home/);
49+
50+
// Go back to home
51+
await ionGoBack(page, '/');
52+
await ionPageVisible(page, 'home');
53+
54+
// Browser forward
55+
await ionGoForward(page, 'nested-outlet2/home');
56+
57+
// Nested outlet page must be visible
58+
const homePage = page.locator('div.ion-page[data-pageid="home"]').filter({
59+
has: page.locator('ion-title:has-text("Home")'),
60+
});
61+
await expect(homePage).toBeVisible();
62+
await expect(homePage).not.toHaveClass(/ion-page-invisible/);
63+
await expect(homePage).not.toHaveClass(/ion-page-hidden/);
64+
});
65+
66+
test('modal-aria-hidden page should be visible after back then forward (with animations)', async ({ page }) => {
67+
await page.goto('/');
68+
await ionPageVisible(page, 'home');
69+
70+
// Navigate to modal-aria-hidden
71+
await page.locator('ion-item').filter({ hasText: 'Modal Aria Hidden' }).click();
72+
await page.waitForTimeout(500);
73+
await expect(page).toHaveURL(/modal-aria-hidden\/section-a/);
74+
await ionPageVisible(page, 'modal-page-a');
75+
76+
// Go back
77+
await page.goBack();
78+
await page.waitForTimeout(500);
79+
await ionPageVisible(page, 'home');
80+
81+
// Browser forward
82+
await page.goForward();
83+
await page.waitForTimeout(500);
84+
await expect(page).toHaveURL(/modal-aria-hidden\/section-a/);
85+
86+
// Page A must be visible
87+
await ionPageVisible(page, 'modal-page-a');
88+
});
89+
});

packages/react/src/routing/OutletPageManager.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,14 @@ export class OutletPageManager extends React.Component<OutletPageManagerProps> {
4646
*/
4747
if (!this.outletIsReady) {
4848
componentOnReady(this.ionRouterOutlet, () => {
49+
/**
50+
* Guard against duplicate callbacks from React strict mode double-mount.
51+
* Both componentDidMount calls pass the outer !outletIsReady check before
52+
* either async callback fires. Without this inner guard, the second callback
53+
* re-adds ion-page-invisible after the first callback's transition removed it,
54+
* and registerIonPage returns early (same element), leaving the page invisible.
55+
*/
56+
if (this.outletIsReady) return;
4957
this.outletIsReady = true;
5058

5159
/**

0 commit comments

Comments
 (0)