Skip to content

Commit 511ce70

Browse files
committed
fix(react-router): defer componentDidMount page transition to microtask for React 19 Suspense compatibility
1 parent 85f2372 commit 511ce70

File tree

7 files changed

+349
-30
lines changed

7 files changed

+349
-30
lines changed

packages/react-router/scripts/test_runner.sh

Lines changed: 75 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -5,34 +5,75 @@ set -x
55
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
66
cd "$SCRIPT_DIR"
77

8-
# Inside core
9-
echo "Building core..."
10-
cd ../../../core
11-
npm run build
8+
# ---------------------------------------------------------------------------
9+
# Flags
10+
# --skip-build Skip all build steps (reuse existing build artifacts)
11+
# --playwright-only Skip Cypress, run only Playwright
12+
# --spec <pattern> Filter Playwright tests by file path pattern
13+
# ---------------------------------------------------------------------------
14+
SKIP_BUILD=0
15+
PLAYWRIGHT_ONLY=0
16+
PLAYWRIGHT_SPEC=""
1217

13-
# Inside packages/react
14-
echo "Building packages/react..."
15-
cd ../packages/react
16-
npm ci
17-
npm run sync
18-
npm run build
18+
while [[ $# -gt 0 ]]; do
19+
case "$1" in
20+
--skip-build)
21+
SKIP_BUILD=1
22+
shift
23+
;;
24+
--playwright-only)
25+
PLAYWRIGHT_ONLY=1
26+
shift
27+
;;
28+
--spec)
29+
if [[ -z "$2" || "$2" == --* ]]; then
30+
echo "Error: --spec requires a value"
31+
exit 1
32+
fi
33+
PLAYWRIGHT_SPEC="$2"
34+
shift 2
35+
;;
36+
*)
37+
echo "Unknown flag: $1"
38+
echo "Usage: $0 [--skip-build] [--playwright-only] [--spec <pattern>]"
39+
exit 1
40+
;;
41+
esac
42+
done
43+
44+
if [ "$SKIP_BUILD" = "0" ]; then
45+
# Inside core
46+
echo "Building core..."
47+
cd ../../../core
48+
npm run build
1949

20-
# Inside packages/react-router
21-
echo "Building packages/react-router..."
22-
cd ../react-router
23-
npm ci
24-
npm run sync
25-
npm run build
50+
# Inside packages/react
51+
echo "Building packages/react..."
52+
cd ../packages/react
53+
npm ci
54+
npm run sync
55+
npm run build
2656

27-
# Inside packages/react-router/test
28-
echo "Building test app..."
29-
cd ./test
30-
rm -rf build/reactrouter6 || true
31-
sh ./build.sh reactrouter6
32-
cd build/reactrouter6
33-
echo "Installing dependencies..."
34-
npm install --legacy-peer-deps > npm_install.log 2>&1
35-
npm run sync
57+
# Inside packages/react-router
58+
echo "Building packages/react-router..."
59+
cd ../react-router
60+
npm ci
61+
npm run sync
62+
npm run build
63+
64+
# Inside packages/react-router/test
65+
echo "Building test app..."
66+
cd ./test
67+
rm -rf build/reactrouter6 || true
68+
sh ./build.sh reactrouter6
69+
cd build/reactrouter6
70+
echo "Installing dependencies..."
71+
npm install --legacy-peer-deps > npm_install.log 2>&1
72+
npm run sync
73+
else
74+
echo "Skipping build (--skip-build)."
75+
cd ../test/build/reactrouter6
76+
fi
3677

3778
# Install Playwright browsers if not already present
3879
npx playwright install chromium 2>/dev/null || true
@@ -63,14 +104,19 @@ for i in $(seq 1 60); do
63104
sleep 1
64105
done
65106

66-
echo "Running Cypress tests..."
67-
npm run cypress || CYPRESS_FAILED=1
107+
if [ "$PLAYWRIGHT_ONLY" = "0" ]; then
108+
echo "Running Cypress tests..."
109+
npm run cypress || CYPRESS_FAILED=1
110+
fi
68111

69112
echo "Running Playwright tests..."
70-
npx playwright test --retries=2 || PLAYWRIGHT_FAILED=1
113+
if [ -n "$PLAYWRIGHT_SPEC" ]; then
114+
npx playwright test --retries=2 "$PLAYWRIGHT_SPEC" || PLAYWRIGHT_FAILED=1
115+
else
116+
npx playwright test --retries=2 || PLAYWRIGHT_FAILED=1
117+
fi
71118

72119
if [ "${CYPRESS_FAILED:-0}" = "1" ] || [ "${PLAYWRIGHT_FAILED:-0}" = "1" ]; then
73120
echo "One or more test suites failed."
74121
exit 1
75122
fi
76-

packages/react-router/src/ReactRouter/StackManager.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -833,7 +833,15 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
833833
this._isMounted = true;
834834
if (this.routerOutletElement) {
835835
this.setupRouterOutlet(this.routerOutletElement);
836-
this.handlePageTransition(this.props.routeInfo);
836+
// Defer to a microtask to avoid calling forceUpdate() synchronously during
837+
// React 19's reappearLayoutEffects phase, which re-runs componentDidMount
838+
// without a preceding componentWillUnmount and causes "Maximum update depth exceeded".
839+
const routeInfo = this.props.routeInfo;
840+
queueMicrotask(() => {
841+
if (this._isMounted && this.props.routeInfo.pathname === routeInfo.pathname) {
842+
this.handlePageTransition(routeInfo);
843+
}
844+
});
837845
}
838846
}
839847

packages/react-router/test/base/src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ import TabLifecycle from './pages/tab-lifecycle/TabLifecycle';
6666
import TabLifecycleOutside from './pages/tab-lifecycle/TabLifecycleOutside';
6767
import { RouterLinkModifierClick, RouterLinkModifierClickTarget } from './pages/router-link-modifier-click/RouterLinkModifierClick';
6868
import { NavigateRootPageA, NavigateRootPageB, NavigateRootPageC } from './pages/navigate-root/NavigateRoot';
69+
import SuspenseOutlet from './pages/suspense-outlet/SuspenseOutlet';
6970

7071
setupIonicReact();
7172

@@ -129,6 +130,7 @@ const App: React.FC = () => {
129130
<Route path="/navigate-root/page-a" element={<NavigateRootPageA />} />
130131
<Route path="/navigate-root/page-b" element={<NavigateRootPageB />} />
131132
<Route path="/navigate-root/page-c" element={<NavigateRootPageC />} />
133+
<Route path="/suspense-outlet/*" element={<SuspenseOutlet />} />
132134
</IonRouterOutlet>
133135
</IonReactRouter>
134136
</IonApp>

packages/react-router/test/base/src/pages/Main.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,9 @@ const Main: React.FC = () => {
140140
<IonItem routerLink="/navigate-root/page-a">
141141
<IonLabel>Navigate Root</IonLabel>
142142
</IonItem>
143+
<IonItem routerLink="/suspense-outlet/content" id="go-to-suspense-outlet">
144+
<IonLabel>Suspense Outlet</IonLabel>
145+
</IonItem>
143146
</IonList>
144147
</IonContent>
145148
</IonPage>
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
import {
2+
IonBackButton,
3+
IonButton,
4+
IonButtons,
5+
IonContent,
6+
IonHeader,
7+
IonPage,
8+
IonRouterOutlet,
9+
IonSpinner,
10+
IonTabBar,
11+
IonTabButton,
12+
IonTabs,
13+
IonTitle,
14+
IonToolbar,
15+
} from '@ionic/react';
16+
import React, { Suspense } from 'react';
17+
import { Navigate, Route, useParams } from 'react-router-dom';
18+
19+
/** Simulated Suspense-enabled data source (mimics React Query / SWR behavior) */
20+
21+
type Item = { id: string; name: string };
22+
23+
// Simulate a promise that resolves after a brief delay (like a network call)
24+
let resolvedItems: Item[] | null = null;
25+
let pendingPromise: Promise<Item[]> | null = null;
26+
27+
function fetchItems(): Promise<Item[]> {
28+
if (!pendingPromise) {
29+
pendingPromise = new Promise((resolve) => {
30+
setTimeout(() => {
31+
resolvedItems = [
32+
{ id: 'item-1', name: 'Item One' },
33+
{ id: 'item-2', name: 'Item Two' },
34+
{ id: 'item-3', name: 'Item Three' },
35+
];
36+
resolve(resolvedItems);
37+
}, 50);
38+
});
39+
}
40+
return pendingPromise;
41+
}
42+
43+
// Suspense-compatible resource — throws a promise when not yet resolved
44+
function createItemsResource() {
45+
const promise = fetchItems();
46+
return {
47+
read(): Item[] {
48+
if (resolvedItems !== null) {
49+
return resolvedItems;
50+
}
51+
throw promise;
52+
},
53+
};
54+
}
55+
56+
// Create one resource instance — shared across components to simulate a cache
57+
const itemsResource = createItemsResource();
58+
59+
function useItems(): Item[] {
60+
return itemsResource.read();
61+
}
62+
63+
/** Page components */
64+
65+
const ItemErrorPage: React.FC<{ message: string }> = ({ message }) => (
66+
<IonPage data-pageid="item-error-page">
67+
<IonHeader>
68+
<IonToolbar>
69+
<IonButtons slot="start">
70+
<IonBackButton defaultHref="/suspense-outlet/content" />
71+
</IonButtons>
72+
<IonTitle>Error</IonTitle>
73+
</IonToolbar>
74+
</IonHeader>
75+
<IonContent className="ion-padding">
76+
<p data-testid="error-message">{message}</p>
77+
</IonContent>
78+
</IonPage>
79+
);
80+
81+
const ItemPage: React.FC = () => {
82+
const { itemId } = useParams<{ itemId: string }>();
83+
const items = useItems(); // may suspend
84+
const item = items.find((i) => i.id === itemId);
85+
86+
if (!item) {
87+
// Item not found — renders IonPage via a child component (not directly)
88+
return <ItemErrorPage message={`Item not found: ${itemId}`} />;
89+
}
90+
91+
return (
92+
<IonPage data-pageid="item-page">
93+
<IonHeader>
94+
<IonToolbar>
95+
<IonButtons slot="start">
96+
<IonBackButton defaultHref="/suspense-outlet/content" />
97+
</IonButtons>
98+
<IonTitle>Item: {item.name}</IonTitle>
99+
</IonToolbar>
100+
</IonHeader>
101+
<IonContent className="ion-padding">
102+
<p data-testid="item-id">Item ID: {item.id}</p>
103+
</IonContent>
104+
</IonPage>
105+
);
106+
};
107+
108+
const ContentIndexPage: React.FC = () => {
109+
const items = useItems(); // may suspend
110+
111+
return (
112+
<IonPage data-pageid="content-index">
113+
<IonHeader>
114+
<IonToolbar>
115+
<IonTitle>Content Index</IonTitle>
116+
</IonToolbar>
117+
</IonHeader>
118+
<IonContent className="ion-padding">
119+
{items.map((item) => (
120+
<IonButton
121+
key={item.id}
122+
expand="block"
123+
routerLink={`/suspense-outlet/content/items/${item.id}`}
124+
data-testid={`go-to-${item.id}`}
125+
>
126+
{item.name}
127+
</IonButton>
128+
))}
129+
<IonButton
130+
expand="block"
131+
routerLink="/suspense-outlet/content/items/not-found-item"
132+
data-testid="go-to-not-found"
133+
>
134+
Go to unknown item
135+
</IonButton>
136+
</IonContent>
137+
</IonPage>
138+
);
139+
};
140+
141+
// IonRouterOutlet inside a Suspense boundary — the key part of the repro
142+
const ContentOutlet: React.FC = () => (
143+
<Suspense fallback={<IonSpinner data-testid="content-spinner" />}>
144+
<IonRouterOutlet id="suspense-content-outlet">
145+
<Route index element={<ContentIndexPage />} />
146+
<Route path="items/:itemId" element={<ItemPage />} />
147+
<Route path="items" element={<Navigate to="/suspense-outlet/content" replace />} />
148+
</IonRouterOutlet>
149+
</Suspense>
150+
);
151+
152+
const Tab1Page: React.FC = () => (
153+
<IonPage data-pageid="suspense-tab1">
154+
<IonHeader>
155+
<IonToolbar>
156+
<IonTitle>Tab 1</IonTitle>
157+
</IonToolbar>
158+
</IonHeader>
159+
<IonContent className="ion-padding">
160+
<p>Tab 1 content</p>
161+
</IonContent>
162+
</IonPage>
163+
);
164+
165+
const SuspenseOutletTabs: React.FC = () => (
166+
<IonTabs>
167+
<IonRouterOutlet id="suspense-tabs-outlet">
168+
<Route path="tab1" element={<Tab1Page />} />
169+
<Route path="content/*" element={<ContentOutlet />} />
170+
<Route index element={<Navigate to="tab1" replace />} />
171+
</IonRouterOutlet>
172+
<IonTabBar slot="bottom">
173+
<IonTabButton tab="tab1" href="/suspense-outlet/tab1">
174+
Tab 1
175+
</IonTabButton>
176+
<IonTabButton tab="content" href="/suspense-outlet/content">
177+
Content
178+
</IonTabButton>
179+
</IonTabBar>
180+
</IonTabs>
181+
);
182+
183+
// Outer router outlet — uses catch-all like the repro
184+
const SuspenseOutlet: React.FC = () => (
185+
<IonRouterOutlet id="suspense-outer-outlet">
186+
<Route path="*" element={<SuspenseOutletTabs />} />
187+
</IonRouterOutlet>
188+
);
189+
190+
export default SuspenseOutlet;

packages/react-router/test/base/tests/e2e/playwright/routing.spec.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,7 @@ test.describe('Routing Tests', () => {
302302

303303
test('/routing/tabs/home Menu > Favorites > Menu > Home with redirect, Home page should be visible, and Favorites should be destroyed', async ({ page }) => {
304304
await page.goto(withTestingMode('/routing/tabs/home'));
305+
await ionPageVisible(page, 'home-page');
305306
await ionMenuClick(page);
306307
await ionMenuNav(page, 'Favorites');
307308
await ionPageVisible(page, 'favorites-page');
@@ -313,6 +314,7 @@ test.describe('Routing Tests', () => {
313314

314315
test('/routing/tabs/home Menu > Favorites > Menu > Home with router, Home page should be visible, and Favorites should be destroyed', async ({ page }) => {
315316
await page.goto(withTestingMode('/routing/tabs/home'));
317+
await ionPageVisible(page, 'home-page');
316318
await ionMenuClick(page);
317319
await ionMenuNav(page, 'Favorites');
318320
await ionPageVisible(page, 'favorites-page');

0 commit comments

Comments
 (0)