Skip to content

Commit b49c1cc

Browse files
authored
fix(react): Support lazy-loaded routes and components. (#15039)
Fixes: #15027 This PR adds support for lazily loaded components and routes inside `Suspend` on react-router pageloads / navigations.
1 parent 5bc0894 commit b49c1cc

File tree

7 files changed

+205
-21
lines changed

7 files changed

+205
-21
lines changed

dev-packages/e2e-tests/test-applications/react-create-browser-router/src/index.tsx

+10-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import * as Sentry from '@sentry/react';
2-
import React from 'react';
2+
import React, { lazy, Suspense } from 'react';
33
import ReactDOM from 'react-dom/client';
44
import {
55
RouterProvider,
@@ -42,13 +42,22 @@ Sentry.init({
4242
});
4343

4444
const sentryCreateBrowserRouter = Sentry.wrapCreateBrowserRouterV6(createBrowserRouter);
45+
const LazyLoadedUser = lazy(() => import('./pages/LazyLoadedUser'));
4546

4647
const router = sentryCreateBrowserRouter(
4748
[
4849
{
4950
path: '/',
5051
element: <Index />,
5152
},
53+
{
54+
path: '/lazy-loaded-user/*',
55+
element: (
56+
<Suspense fallback={<div>Loading...</div>}>
57+
<LazyLoadedUser />
58+
</Suspense>
59+
),
60+
},
5261
{
5362
path: '/user/:id',
5463
element: <User />,

dev-packages/e2e-tests/test-applications/react-create-browser-router/src/pages/Index.tsx

+3
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ const Index = () => {
1616
<Link to="/user/5" id="navigation">
1717
navigate
1818
</Link>
19+
<Link to="/lazy-loaded-user/5/foo" id="lazy-navigation">
20+
lazy navigate
21+
</Link>
1922
</>
2023
);
2124
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import * as Sentry from '@sentry/react';
2+
// biome-ignore lint/nursery/noUnusedImports: Need React import for JSX
3+
import * as React from 'react';
4+
import { Route, Routes } from 'react-router-dom';
5+
6+
const SentryRoutes = Sentry.withSentryReactRouterV6Routing(Routes);
7+
8+
const InnerRoute = () => (
9+
<SentryRoutes>
10+
<Route path=":innerId" element={<p id="content">I am a lazy loaded user</p>} />
11+
</SentryRoutes>
12+
);
13+
14+
export default InnerRoute;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import * as Sentry from '@sentry/react';
2+
import * as React from 'react';
3+
import { Route, Routes } from 'react-router-dom';
4+
5+
const SentryRoutes = Sentry.withSentryReactRouterV6Routing(Routes);
6+
const InnerRoute = React.lazy(() => import('./LazyLoadedInnerRoute'));
7+
8+
const LazyLoadedUser = () => {
9+
return (
10+
<SentryRoutes>
11+
<Route
12+
path=":id/*"
13+
element={
14+
<React.Suspense fallback={<p>Loading...</p>}>
15+
<InnerRoute />
16+
</React.Suspense>
17+
}
18+
/>
19+
</SentryRoutes>
20+
);
21+
};
22+
23+
export default LazyLoadedUser;

dev-packages/e2e-tests/test-applications/react-create-browser-router/tests/transactions.test.ts

+102
Original file line numberDiff line numberDiff line change
@@ -76,3 +76,105 @@ test('Captures a navigation transaction', async ({ page }) => {
7676

7777
expect(transactionEvent.spans).toEqual([]);
7878
});
79+
80+
test('Captures a lazy pageload transaction', async ({ page }) => {
81+
const transactionEventPromise = waitForTransaction('react-create-browser-router', event => {
82+
return event.contexts?.trace?.op === 'pageload';
83+
});
84+
85+
await page.goto('/lazy-loaded-user/5/foo');
86+
87+
const transactionEvent = await transactionEventPromise;
88+
expect(transactionEvent.contexts?.trace).toEqual({
89+
data: expect.objectContaining({
90+
'sentry.idle_span_finish_reason': 'idleTimeout',
91+
'sentry.op': 'pageload',
92+
'sentry.origin': 'auto.pageload.react.reactrouter_v6',
93+
'sentry.sample_rate': 1,
94+
'sentry.source': 'route',
95+
}),
96+
op: 'pageload',
97+
span_id: expect.stringMatching(/[a-f0-9]{16}/),
98+
trace_id: expect.stringMatching(/[a-f0-9]{32}/),
99+
origin: 'auto.pageload.react.reactrouter_v6',
100+
});
101+
102+
expect(transactionEvent).toEqual(
103+
expect.objectContaining({
104+
transaction: '/lazy-loaded-user/:id/:innerId',
105+
type: 'transaction',
106+
transaction_info: {
107+
source: 'route',
108+
},
109+
}),
110+
);
111+
112+
expect(await page.innerText('id=content')).toContain('I am a lazy loaded user');
113+
114+
expect(transactionEvent.spans).toEqual(
115+
expect.arrayContaining([
116+
// This one is the outer lazy route
117+
expect.objectContaining({
118+
op: 'resource.script',
119+
origin: 'auto.resource.browser.metrics',
120+
}),
121+
// This one is the inner lazy route
122+
expect.objectContaining({
123+
op: 'resource.script',
124+
origin: 'auto.resource.browser.metrics',
125+
}),
126+
]),
127+
);
128+
});
129+
130+
test('Captures a lazy navigation transaction', async ({ page }) => {
131+
const transactionEventPromise = waitForTransaction('react-create-browser-router', event => {
132+
return event.contexts?.trace?.op === 'navigation';
133+
});
134+
135+
await page.goto('/');
136+
const linkElement = page.locator('id=lazy-navigation');
137+
await linkElement.click();
138+
139+
const transactionEvent = await transactionEventPromise;
140+
expect(transactionEvent.contexts?.trace).toEqual({
141+
data: expect.objectContaining({
142+
'sentry.idle_span_finish_reason': 'idleTimeout',
143+
'sentry.op': 'navigation',
144+
'sentry.origin': 'auto.navigation.react.reactrouter_v6',
145+
'sentry.sample_rate': 1,
146+
'sentry.source': 'route',
147+
}),
148+
op: 'navigation',
149+
span_id: expect.stringMatching(/[a-f0-9]{16}/),
150+
trace_id: expect.stringMatching(/[a-f0-9]{32}/),
151+
origin: 'auto.navigation.react.reactrouter_v6',
152+
});
153+
154+
expect(transactionEvent).toEqual(
155+
expect.objectContaining({
156+
transaction: '/lazy-loaded-user/:id/:innerId',
157+
type: 'transaction',
158+
transaction_info: {
159+
source: 'route',
160+
},
161+
}),
162+
);
163+
164+
expect(await page.innerText('id=content')).toContain('I am a lazy loaded user');
165+
166+
expect(transactionEvent.spans).toEqual(
167+
expect.arrayContaining([
168+
// This one is the outer lazy route
169+
expect.objectContaining({
170+
op: 'resource.script',
171+
origin: 'auto.resource.browser.metrics',
172+
}),
173+
// This one is the inner lazy route
174+
expect.objectContaining({
175+
op: 'resource.script',
176+
origin: 'auto.resource.browser.metrics',
177+
}),
178+
]),
179+
);
180+
});

packages/react/src/reactrouterv6-compat-utils.tsx

+45-17
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@ export interface ReactRouterOptions {
6161

6262
type V6CompatibleVersion = '6' | '7';
6363

64+
// Keeping as a global variable for cross-usage in multiple functions
65+
const allRoutes = new Set<RouteObject>();
66+
6467
/**
6568
* Creates a wrapCreateBrowserRouter function that can be used with all React Router v6 compatible versions.
6669
*/
@@ -81,6 +84,10 @@ export function createV6CompatibleWrapCreateBrowserRouter<
8184
}
8285

8386
return function (routes: RouteObject[], opts?: Record<string, unknown> & { basename?: string }): TRouter {
87+
routes.forEach(route => {
88+
allRoutes.add(route);
89+
});
90+
8491
const router = createRouterFunction(routes, opts);
8592
const basename = opts?.basename;
8693

@@ -90,19 +97,40 @@ export function createV6CompatibleWrapCreateBrowserRouter<
9097
// This is the earliest convenient time to update the transaction name.
9198
// Callbacks to `router.subscribe` are not called for the initial load.
9299
if (router.state.historyAction === 'POP' && activeRootSpan) {
93-
updatePageloadTransaction(activeRootSpan, router.state.location, routes, undefined, basename);
100+
updatePageloadTransaction(
101+
activeRootSpan,
102+
router.state.location,
103+
routes,
104+
undefined,
105+
basename,
106+
Array.from(allRoutes),
107+
);
94108
}
95109

96110
router.subscribe((state: RouterState) => {
97-
const location = state.location;
98111
if (state.historyAction === 'PUSH' || state.historyAction === 'POP') {
99-
handleNavigation({
100-
location,
101-
routes,
102-
navigationType: state.historyAction,
103-
version,
104-
basename,
105-
});
112+
// Wait for the next render if loading an unsettled route
113+
if (state.navigation.state !== 'idle') {
114+
requestAnimationFrame(() => {
115+
handleNavigation({
116+
location: state.location,
117+
routes,
118+
navigationType: state.historyAction,
119+
version,
120+
basename,
121+
allRoutes: Array.from(allRoutes),
122+
});
123+
});
124+
} else {
125+
handleNavigation({
126+
location: state.location,
127+
routes,
128+
navigationType: state.historyAction,
129+
version,
130+
basename,
131+
allRoutes: Array.from(allRoutes),
132+
});
133+
}
106134
}
107135
});
108136

@@ -137,6 +165,10 @@ export function createV6CompatibleWrapCreateMemoryRouter<
137165
initialIndex?: number;
138166
},
139167
): TRouter {
168+
routes.forEach(route => {
169+
allRoutes.add(route);
170+
});
171+
140172
const router = createRouterFunction(routes, opts);
141173
const basename = opts?.basename;
142174

@@ -162,7 +194,7 @@ export function createV6CompatibleWrapCreateMemoryRouter<
162194
: router.state.location;
163195

164196
if (router.state.historyAction === 'POP' && activeRootSpan) {
165-
updatePageloadTransaction(activeRootSpan, location, routes, undefined, basename);
197+
updatePageloadTransaction(activeRootSpan, location, routes, undefined, basename, Array.from(allRoutes));
166198
}
167199

168200
router.subscribe((state: RouterState) => {
@@ -174,6 +206,7 @@ export function createV6CompatibleWrapCreateMemoryRouter<
174206
navigationType: state.historyAction,
175207
version,
176208
basename,
209+
allRoutes: Array.from(allRoutes),
177210
});
178211
}
179212
});
@@ -248,8 +281,6 @@ export function createV6CompatibleWrapUseRoutes(origUseRoutes: UseRoutes, versio
248281
return origUseRoutes;
249282
}
250283

251-
const allRoutes: Set<RouteObject> = new Set();
252-
253284
const SentryRoutes: React.FC<{
254285
children?: React.ReactNode;
255286
routes: RouteObject[];
@@ -319,7 +350,6 @@ export function handleNavigation(opts: {
319350
allRoutes?: RouteObject[];
320351
}): void {
321352
const { location, routes, navigationType, version, matches, basename, allRoutes } = opts;
322-
323353
const branches = Array.isArray(matches) ? matches : _matchRoutes(routes, location, basename);
324354

325355
const client = getClient();
@@ -553,7 +583,7 @@ function updatePageloadTransaction(
553583
): void {
554584
const branches = Array.isArray(matches)
555585
? matches
556-
: (_matchRoutes(routes, location, basename) as unknown as RouteMatch[]);
586+
: (_matchRoutes(allRoutes || routes, location, basename) as unknown as RouteMatch[]);
557587

558588
if (branches) {
559589
let name,
@@ -569,7 +599,7 @@ function updatePageloadTransaction(
569599
[name, source] = getNormalizedName(routes, location, branches, basename);
570600
}
571601

572-
getCurrentScope().setTransactionName(name);
602+
getCurrentScope().setTransactionName(name || '/');
573603

574604
if (activeRootSpan) {
575605
activeRootSpan.updateName(name);
@@ -592,8 +622,6 @@ export function createV6CompatibleWithSentryReactRouterRouting<P extends Record<
592622
return Routes;
593623
}
594624

595-
const allRoutes: Set<RouteObject> = new Set();
596-
597625
const SentryRoutes: React.FC<P> = (props: P) => {
598626
const isMountRenderPass = React.useRef(true);
599627

packages/react/src/types.ts

+8-3
Original file line numberDiff line numberDiff line change
@@ -182,10 +182,14 @@ export interface RouterInit {
182182
hydrationData?: HydrationState;
183183
}
184184

185+
export type NavigationState = {
186+
state: 'idle' | 'loading' | 'submitting';
187+
};
188+
185189
export type NavigationStates = {
186-
Idle: any;
187-
Loading: any;
188-
Submitting: any;
190+
Idle: NavigationState;
191+
Loading: NavigationState;
192+
Submitting: NavigationState;
189193
};
190194

191195
export type Navigation = NavigationStates[keyof NavigationStates];
@@ -202,6 +206,7 @@ export declare enum HistoryAction {
202206
export interface RouterState {
203207
historyAction: Action | HistoryAction | any;
204208
location: Location;
209+
navigation: Navigation;
205210
}
206211
export interface Router<TState extends RouterState = RouterState> {
207212
state: TState;

0 commit comments

Comments
 (0)