Skip to content

Commit 2f2d822

Browse files
committed
fix(react-router): prevent stale StackManager from destroying views on repeated out-of-scope transitions
1 parent 3fe031d commit 2f2d822

File tree

2 files changed

+58
-0
lines changed

2 files changed

+58
-0
lines changed

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,8 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
7575
private waitingForIonPage = false;
7676
private ionPageWaitTimeout?: ReturnType<typeof setTimeout>;
7777
private outOfScopeUnmountTimeout?: ReturnType<typeof setTimeout>;
78+
/** Whether this outlet was previously in scope. */
79+
private wasInScope = true;
7880
/**
7981
* Track the last transition's entering and leaving view IDs to prevent
8082
* duplicate transitions during rapid navigation (e.g., Navigate redirects)
@@ -246,9 +248,20 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
246248
*/
247249
private handleOutOfScopeOutlet(routeInfo: RouteInfo): boolean {
248250
if (!this.outletMountPath || isPathnameInScope(routeInfo.pathname, this.outletMountPath)) {
251+
this.wasInScope = true;
249252
return false;
250253
}
251254

255+
// Only run the out-of-scope cleanup on the first transition out of scope.
256+
// When parameterized routes create multiple StackManager instances with the
257+
// same outlet ID, a stale (hidden) instance must not destroy views that an
258+
// active instance just created. After the initial cleanup, the stale instance
259+
// stays dormant until its mount path becomes in-scope again.
260+
if (!this.wasInScope) {
261+
return true;
262+
}
263+
this.wasInScope = false;
264+
252265
if (this.outOfScopeUnmountTimeout) {
253266
clearTimeout(this.outOfScopeUnmountTimeout);
254267
this.outOfScopeUnmountTimeout = undefined;
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { test, expect } from '@playwright/test';
2+
import { ionPageVisible, withTestingMode } from './utils/test-utils';
3+
4+
test.describe('Nested Params', () => {
5+
6+
/**
7+
* Regression: navigating to a parameterized route, back to landing,
8+
* then to a different param, back, and to the same param again caused
9+
* an infinite loop that locked up the browser.
10+
*
11+
* Steps: user 99 -> landing -> user 42 -> landing -> user 42 (loop)
12+
*/
13+
test('should not infinite loop when revisiting a parameterized route after visiting a different param', async ({ page }) => {
14+
// Use a tight timeout -- if the infinite loop fires, the browser locks up
15+
// and this test would hang forever without it.
16+
test.setTimeout(15_000);
17+
18+
await page.goto(withTestingMode('/nested-params'));
19+
await ionPageVisible(page, 'nested-params-landing');
20+
21+
// Step 1: Navigate to user 99 details
22+
await page.locator('#go-to-user-99').click();
23+
await expect(page.getByText('Details view user: 99')).toBeVisible();
24+
25+
// Step 2: Back to landing via routerLink (push).
26+
// Use .first() because the hidden user 99 details page stays in the DOM,
27+
// leaving a duplicate #back-to-landing button.
28+
await page.locator('#back-to-landing').first().click();
29+
await ionPageVisible(page, 'nested-params-landing');
30+
31+
// Step 3: Navigate to user 42 details
32+
await page.locator('#go-to-user-42').click();
33+
await expect(page.getByText('Details view user: 42')).toBeVisible();
34+
35+
// Step 4: Back to landing via routerLink (push)
36+
await page.locator('#back-to-landing').first().click();
37+
await ionPageVisible(page, 'nested-params-landing');
38+
39+
// Step 5: Navigate to user 42 details AGAIN -- this triggered the infinite loop
40+
await page.locator('#go-to-user-42').click();
41+
await expect(page.getByText('Details view user: 42')).toBeVisible();
42+
await expect(page.getByText('Layout sees user: 42')).toBeVisible();
43+
});
44+
45+
});

0 commit comments

Comments
 (0)