Skip to content

Commit fa8babb

Browse files
authored
fix(react): Remove routes from shared set on <Routes> unmount (#22792)
Removes a `<Routes>`'s routes from the module-level `allRoutes` set when it unmounts, so they can't be matched against a later, unrelated navigation. The set accumulated every route ever mounted and never removed any. Once two independent routers had each been mounted, `matchRoutes` ran over the union of both, so a navigation into one could pick up a param name from the other and produce a hybrid transaction name like `/bar/:fooId` instead of `/bar/:barId`. I've taken out the route adding logic from the effect we had and added another one so that we can isolate the add/remove to the same effect to avoid relying on refs that can be fidgity in dev/prod and strict modes. closes #22782 Will backport to v10
1 parent 2a913fd commit fa8babb

4 files changed

Lines changed: 120 additions & 7 deletions

File tree

dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/src/index.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,13 +103,31 @@ const DeepTeamRoutes = () => (
103103
</SentryRoutes>
104104
);
105105

106+
// Two independent descendant <SentryRoutes> trees that each contribute a single-segment param leaf
107+
// (`:fooId` / `:barId`). Because `allRoutes` is a shared module-level set, once both have mounted a
108+
// navigation into one can pick up the param name from the other, yielding a hybrid name like
109+
// `/bar/:fooId` instead of `/bar/:barId` (see issue #22782).
110+
const FooRoutes = () => (
111+
<SentryRoutes>
112+
<Route path=":fooId" element={<div id="foo">Foo</div>} />
113+
</SentryRoutes>
114+
);
115+
116+
const BarRoutes = () => (
117+
<SentryRoutes>
118+
<Route path=":barId" element={<div id="bar">Bar</div>} />
119+
</SentryRoutes>
120+
);
121+
106122
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
107123
root.render(
108124
<BrowserRouter>
109125
<SentryRoutes>
110126
<Route path="/" element={<Index />} />
111127
<Route path="child/*" element={<ChildRoutes />} />
112128
<Route path="workspace/*" element={<DeepTeamRoutes />} />
129+
<Route path="foo/*" element={<FooRoutes />} />
130+
<Route path="bar/*" element={<BarRoutes />} />
113131
<Route path="/*" element={<ProjectsRoutes />} />
114132
</SentryRoutes>
115133
</BrowserRouter>,

dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/src/pages/Index.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ const Index = () => {
1616
<Link to="/workspace/team/u123" id="deep-member-navigation">
1717
navigate deep member
1818
</Link>
19+
<Link to="/foo/123" id="foo-navigation">
20+
navigate foo
21+
</Link>
22+
<Link to="/bar/456" id="bar-navigation">
23+
navigate bar
24+
</Link>
1925
</>
2026
);
2127
};

dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/tests/transactions.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,66 @@ test('resolves deep wildcard chain with three levels of nesting - pageload', asy
293293
});
294294
});
295295

296+
test('does not mix param names across independent descendant routers', async ({ page }) => {
297+
const pageloadTxnPromise = waitForTransaction('react-router-6-descendant-routes', async transactionEvent => {
298+
return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload';
299+
});
300+
301+
const fooNavigationTxnPromise = waitForTransaction('react-router-6-descendant-routes', async transactionEvent => {
302+
return (
303+
transactionEvent.contexts?.trace?.op === 'navigation' &&
304+
transactionEvent.contexts?.trace?.data?.['url.path'] === '/foo/123'
305+
);
306+
});
307+
308+
const barNavigationTxnPromise = waitForTransaction('react-router-6-descendant-routes', async transactionEvent => {
309+
return (
310+
transactionEvent.contexts?.trace?.op === 'navigation' &&
311+
transactionEvent.contexts?.trace?.data?.['url.path'] === '/bar/456'
312+
);
313+
});
314+
315+
await page.goto(`/`);
316+
await pageloadTxnPromise;
317+
318+
// Mount the first descendant router (`foo/*` -> `:fooId`), which populates the shared `allRoutes` set.
319+
const [, fooNavigationTxn] = await Promise.all([page.locator('id=foo-navigation').click(), fooNavigationTxnPromise]);
320+
321+
expect((await page.innerHTML('#root')).includes('Foo')).toBe(true);
322+
expect(fooNavigationTxn).toMatchObject({
323+
transaction: '/foo/:fooId',
324+
transaction_info: { source: 'route' },
325+
});
326+
327+
// Return to the index so we can navigate into the second, unrelated descendant router client-side.
328+
// A fresh page load would reset the module-level `allRoutes` and hide the bug.
329+
await page.goBack();
330+
await page.locator('id=bar-navigation').waitFor();
331+
332+
// Now mount the second descendant router (`bar/*` -> `:barId`). With the accumulation bug, the name
333+
// comes out as the hybrid `/bar/:fooId`.
334+
const [, barNavigationTxn] = await Promise.all([page.locator('id=bar-navigation').click(), barNavigationTxnPromise]);
335+
336+
expect((await page.innerHTML('#root')).includes('Bar')).toBe(true);
337+
expect(barNavigationTxn).toMatchObject({
338+
contexts: {
339+
trace: {
340+
op: 'navigation',
341+
origin: 'auto.navigation.react.reactrouter_v6',
342+
data: {
343+
'sentry.source': 'route',
344+
'url.template': '/bar/:barId',
345+
'url.path': '/bar/456',
346+
},
347+
},
348+
},
349+
transaction: '/bar/:barId',
350+
transaction_info: {
351+
source: 'route',
352+
},
353+
});
354+
});
355+
296356
test('resolves deep wildcard chain with three levels of nesting - navigation', async ({ page }) => {
297357
const pageloadTxnPromise = waitForTransaction('react-router-6-descendant-routes', async transactionEvent => {
298358
return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload';

packages/react/src/reactrouter-compat-utils/instrumentation.tsx

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -766,13 +766,20 @@ export function createV6CompatibleWrapUseRoutes(origUseRoutes: UseRoutes, versio
766766
const stableLocationParam =
767767
typeof locationArg === 'string' || locationArg?.pathname ? (locationArg as { pathname: string }) : location;
768768

769+
// Register this `<Routes>`'s routes in the shared set for as long as it is mounted, removing them on
770+
// unmount so they don't leak into later unrelated navigations (#22782). Tying add and remove to the
771+
// same effect lifecycle keeps it correct under StrictMode's mount/unmount/remount.
772+
useIsomorphicLayoutEffect(() => {
773+
const added = addRoutesToAllRoutes(routes);
774+
775+
return () => removeRoutesFromAllRoutes(added);
776+
});
777+
769778
useIsomorphicLayoutEffect(() => {
770779
const normalizedLocation =
771780
typeof stableLocationParam === 'string' ? { pathname: stableLocationParam } : stableLocationParam;
772781

773782
if (isMountRenderPass.current) {
774-
addRoutesToAllRoutes(routes);
775-
776783
updatePageloadTransaction({
777784
activeRootSpan: getActiveRootSpan(),
778785
location: normalizedLocation,
@@ -1049,14 +1056,29 @@ export function handleNavigation(opts: {
10491056
}
10501057

10511058
/* Only exported for testing purposes */
1052-
export function addRoutesToAllRoutes(routes: RouteObject[]): void {
1059+
export function addRoutesToAllRoutes(routes: RouteObject[]): RouteObject[] {
1060+
const added: RouteObject[] = [];
10531061
routes.forEach(route => {
10541062
const extractedChildRoutes = getChildRoutesRecursively(route);
10551063

10561064
extractedChildRoutes.forEach(r => {
10571065
allRoutes.add(r);
1066+
added.push(r);
10581067
});
10591068
});
1069+
1070+
return added;
1071+
}
1072+
1073+
/**
1074+
* Removes routes previously added via `addRoutesToAllRoutes` from the shared set. Called when a
1075+
* `<Routes>` unmounts so its routes don't linger and get matched against later, unrelated navigations
1076+
* (which produced hybrid names like `/bar/:fooId` across independent routers - see #22782).
1077+
*/
1078+
function removeRoutesFromAllRoutes(routes: RouteObject[]): void {
1079+
routes.forEach(route => {
1080+
allRoutes.delete(route);
1081+
});
10601082
}
10611083

10621084
function getChildRoutesRecursively(route: RouteObject, allRoutes: Set<RouteObject> = new Set()): Set<RouteObject> {
@@ -1355,13 +1377,20 @@ export function createV6CompatibleWithSentryReactRouterRouting<P extends Record<
13551377
const location = _useLocation();
13561378
const navigationType = _useNavigationType();
13571379

1380+
const routes = _createRoutesFromChildren(props.children) as RouteObject[];
1381+
1382+
// Register this `<Routes>`'s routes in the shared set for as long as it is mounted, removing them on
1383+
// unmount so they don't leak into later unrelated navigations (#22782). Tying add and remove to the
1384+
// same effect lifecycle keeps it correct under StrictMode's mount/unmount/remount.
1385+
useIsomorphicLayoutEffect(() => {
1386+
const added = addRoutesToAllRoutes(routes);
1387+
1388+
return () => removeRoutesFromAllRoutes(added);
1389+
});
1390+
13581391
useIsomorphicLayoutEffect(
13591392
() => {
1360-
const routes = _createRoutesFromChildren(props.children) as RouteObject[];
1361-
13621393
if (isMountRenderPass.current) {
1363-
addRoutesToAllRoutes(routes);
1364-
13651394
updatePageloadTransaction({
13661395
activeRootSpan: getActiveRootSpan(),
13671396
location,

0 commit comments

Comments
 (0)