From b49c1cc05e79f3854b642066ef1176ef9eb3f8d0 Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Thu, 23 Jan 2025 08:43:22 +0000 Subject: [PATCH] fix(react): Support lazy-loaded routes and components. (#15039) Fixes: https://github.com/getsentry/sentry-javascript/issues/15027 This PR adds support for lazily loaded components and routes inside `Suspend` on react-router pageloads / navigations. --- .../react-create-browser-router/src/index.tsx | 11 +- .../src/pages/Index.tsx | 3 + .../src/pages/LazyLoadedInnerRoute.tsx | 14 +++ .../src/pages/LazyLoadedUser.tsx | 23 ++++ .../tests/transactions.test.ts | 102 ++++++++++++++++++ .../react/src/reactrouterv6-compat-utils.tsx | 62 ++++++++--- packages/react/src/types.ts | 11 +- 7 files changed, 205 insertions(+), 21 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/react-create-browser-router/src/pages/LazyLoadedInnerRoute.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-create-browser-router/src/pages/LazyLoadedUser.tsx diff --git a/dev-packages/e2e-tests/test-applications/react-create-browser-router/src/index.tsx b/dev-packages/e2e-tests/test-applications/react-create-browser-router/src/index.tsx index 88f8cfa502ec..c7ad16eebcf7 100644 --- a/dev-packages/e2e-tests/test-applications/react-create-browser-router/src/index.tsx +++ b/dev-packages/e2e-tests/test-applications/react-create-browser-router/src/index.tsx @@ -1,5 +1,5 @@ import * as Sentry from '@sentry/react'; -import React from 'react'; +import React, { lazy, Suspense } from 'react'; import ReactDOM from 'react-dom/client'; import { RouterProvider, @@ -42,6 +42,7 @@ Sentry.init({ }); const sentryCreateBrowserRouter = Sentry.wrapCreateBrowserRouterV6(createBrowserRouter); +const LazyLoadedUser = lazy(() => import('./pages/LazyLoadedUser')); const router = sentryCreateBrowserRouter( [ @@ -49,6 +50,14 @@ const router = sentryCreateBrowserRouter( path: '/', element: , }, + { + path: '/lazy-loaded-user/*', + element: ( + Loading...}> + + + ), + }, { path: '/user/:id', element: , diff --git a/dev-packages/e2e-tests/test-applications/react-create-browser-router/src/pages/Index.tsx b/dev-packages/e2e-tests/test-applications/react-create-browser-router/src/pages/Index.tsx index d6b71a1d1279..12bfb12ec3a9 100644 --- a/dev-packages/e2e-tests/test-applications/react-create-browser-router/src/pages/Index.tsx +++ b/dev-packages/e2e-tests/test-applications/react-create-browser-router/src/pages/Index.tsx @@ -16,6 +16,9 @@ const Index = () => { navigate + + lazy navigate + ); }; diff --git a/dev-packages/e2e-tests/test-applications/react-create-browser-router/src/pages/LazyLoadedInnerRoute.tsx b/dev-packages/e2e-tests/test-applications/react-create-browser-router/src/pages/LazyLoadedInnerRoute.tsx new file mode 100644 index 000000000000..1410df69124b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-create-browser-router/src/pages/LazyLoadedInnerRoute.tsx @@ -0,0 +1,14 @@ +import * as Sentry from '@sentry/react'; +// biome-ignore lint/nursery/noUnusedImports: Need React import for JSX +import * as React from 'react'; +import { Route, Routes } from 'react-router-dom'; + +const SentryRoutes = Sentry.withSentryReactRouterV6Routing(Routes); + +const InnerRoute = () => ( + + I am a lazy loaded user

} /> +
+); + +export default InnerRoute; diff --git a/dev-packages/e2e-tests/test-applications/react-create-browser-router/src/pages/LazyLoadedUser.tsx b/dev-packages/e2e-tests/test-applications/react-create-browser-router/src/pages/LazyLoadedUser.tsx new file mode 100644 index 000000000000..636f99d9c8cb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-create-browser-router/src/pages/LazyLoadedUser.tsx @@ -0,0 +1,23 @@ +import * as Sentry from '@sentry/react'; +import * as React from 'react'; +import { Route, Routes } from 'react-router-dom'; + +const SentryRoutes = Sentry.withSentryReactRouterV6Routing(Routes); +const InnerRoute = React.lazy(() => import('./LazyLoadedInnerRoute')); + +const LazyLoadedUser = () => { + return ( + + Loading...

}> + + + } + /> +
+ ); +}; + +export default LazyLoadedUser; diff --git a/dev-packages/e2e-tests/test-applications/react-create-browser-router/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/react-create-browser-router/tests/transactions.test.ts index 5ecd098daf94..c35d731915d6 100644 --- a/dev-packages/e2e-tests/test-applications/react-create-browser-router/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-create-browser-router/tests/transactions.test.ts @@ -76,3 +76,105 @@ test('Captures a navigation transaction', async ({ page }) => { expect(transactionEvent.spans).toEqual([]); }); + +test('Captures a lazy pageload transaction', async ({ page }) => { + const transactionEventPromise = waitForTransaction('react-create-browser-router', event => { + return event.contexts?.trace?.op === 'pageload'; + }); + + await page.goto('/lazy-loaded-user/5/foo'); + + const transactionEvent = await transactionEventPromise; + expect(transactionEvent.contexts?.trace).toEqual({ + data: expect.objectContaining({ + 'sentry.idle_span_finish_reason': 'idleTimeout', + 'sentry.op': 'pageload', + 'sentry.origin': 'auto.pageload.react.reactrouter_v6', + 'sentry.sample_rate': 1, + 'sentry.source': 'route', + }), + op: 'pageload', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + origin: 'auto.pageload.react.reactrouter_v6', + }); + + expect(transactionEvent).toEqual( + expect.objectContaining({ + transaction: '/lazy-loaded-user/:id/:innerId', + type: 'transaction', + transaction_info: { + source: 'route', + }, + }), + ); + + expect(await page.innerText('id=content')).toContain('I am a lazy loaded user'); + + expect(transactionEvent.spans).toEqual( + expect.arrayContaining([ + // This one is the outer lazy route + expect.objectContaining({ + op: 'resource.script', + origin: 'auto.resource.browser.metrics', + }), + // This one is the inner lazy route + expect.objectContaining({ + op: 'resource.script', + origin: 'auto.resource.browser.metrics', + }), + ]), + ); +}); + +test('Captures a lazy navigation transaction', async ({ page }) => { + const transactionEventPromise = waitForTransaction('react-create-browser-router', event => { + return event.contexts?.trace?.op === 'navigation'; + }); + + await page.goto('/'); + const linkElement = page.locator('id=lazy-navigation'); + await linkElement.click(); + + const transactionEvent = await transactionEventPromise; + expect(transactionEvent.contexts?.trace).toEqual({ + data: expect.objectContaining({ + 'sentry.idle_span_finish_reason': 'idleTimeout', + 'sentry.op': 'navigation', + 'sentry.origin': 'auto.navigation.react.reactrouter_v6', + 'sentry.sample_rate': 1, + 'sentry.source': 'route', + }), + op: 'navigation', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + origin: 'auto.navigation.react.reactrouter_v6', + }); + + expect(transactionEvent).toEqual( + expect.objectContaining({ + transaction: '/lazy-loaded-user/:id/:innerId', + type: 'transaction', + transaction_info: { + source: 'route', + }, + }), + ); + + expect(await page.innerText('id=content')).toContain('I am a lazy loaded user'); + + expect(transactionEvent.spans).toEqual( + expect.arrayContaining([ + // This one is the outer lazy route + expect.objectContaining({ + op: 'resource.script', + origin: 'auto.resource.browser.metrics', + }), + // This one is the inner lazy route + expect.objectContaining({ + op: 'resource.script', + origin: 'auto.resource.browser.metrics', + }), + ]), + ); +}); diff --git a/packages/react/src/reactrouterv6-compat-utils.tsx b/packages/react/src/reactrouterv6-compat-utils.tsx index 9ca96a03b0de..db65c32cee99 100644 --- a/packages/react/src/reactrouterv6-compat-utils.tsx +++ b/packages/react/src/reactrouterv6-compat-utils.tsx @@ -61,6 +61,9 @@ export interface ReactRouterOptions { type V6CompatibleVersion = '6' | '7'; +// Keeping as a global variable for cross-usage in multiple functions +const allRoutes = new Set(); + /** * Creates a wrapCreateBrowserRouter function that can be used with all React Router v6 compatible versions. */ @@ -81,6 +84,10 @@ export function createV6CompatibleWrapCreateBrowserRouter< } return function (routes: RouteObject[], opts?: Record & { basename?: string }): TRouter { + routes.forEach(route => { + allRoutes.add(route); + }); + const router = createRouterFunction(routes, opts); const basename = opts?.basename; @@ -90,19 +97,40 @@ export function createV6CompatibleWrapCreateBrowserRouter< // This is the earliest convenient time to update the transaction name. // Callbacks to `router.subscribe` are not called for the initial load. if (router.state.historyAction === 'POP' && activeRootSpan) { - updatePageloadTransaction(activeRootSpan, router.state.location, routes, undefined, basename); + updatePageloadTransaction( + activeRootSpan, + router.state.location, + routes, + undefined, + basename, + Array.from(allRoutes), + ); } router.subscribe((state: RouterState) => { - const location = state.location; if (state.historyAction === 'PUSH' || state.historyAction === 'POP') { - handleNavigation({ - location, - routes, - navigationType: state.historyAction, - version, - basename, - }); + // Wait for the next render if loading an unsettled route + if (state.navigation.state !== 'idle') { + requestAnimationFrame(() => { + handleNavigation({ + location: state.location, + routes, + navigationType: state.historyAction, + version, + basename, + allRoutes: Array.from(allRoutes), + }); + }); + } else { + handleNavigation({ + location: state.location, + routes, + navigationType: state.historyAction, + version, + basename, + allRoutes: Array.from(allRoutes), + }); + } } }); @@ -137,6 +165,10 @@ export function createV6CompatibleWrapCreateMemoryRouter< initialIndex?: number; }, ): TRouter { + routes.forEach(route => { + allRoutes.add(route); + }); + const router = createRouterFunction(routes, opts); const basename = opts?.basename; @@ -162,7 +194,7 @@ export function createV6CompatibleWrapCreateMemoryRouter< : router.state.location; if (router.state.historyAction === 'POP' && activeRootSpan) { - updatePageloadTransaction(activeRootSpan, location, routes, undefined, basename); + updatePageloadTransaction(activeRootSpan, location, routes, undefined, basename, Array.from(allRoutes)); } router.subscribe((state: RouterState) => { @@ -174,6 +206,7 @@ export function createV6CompatibleWrapCreateMemoryRouter< navigationType: state.historyAction, version, basename, + allRoutes: Array.from(allRoutes), }); } }); @@ -248,8 +281,6 @@ export function createV6CompatibleWrapUseRoutes(origUseRoutes: UseRoutes, versio return origUseRoutes; } - const allRoutes: Set = new Set(); - const SentryRoutes: React.FC<{ children?: React.ReactNode; routes: RouteObject[]; @@ -319,7 +350,6 @@ export function handleNavigation(opts: { allRoutes?: RouteObject[]; }): void { const { location, routes, navigationType, version, matches, basename, allRoutes } = opts; - const branches = Array.isArray(matches) ? matches : _matchRoutes(routes, location, basename); const client = getClient(); @@ -553,7 +583,7 @@ function updatePageloadTransaction( ): void { const branches = Array.isArray(matches) ? matches - : (_matchRoutes(routes, location, basename) as unknown as RouteMatch[]); + : (_matchRoutes(allRoutes || routes, location, basename) as unknown as RouteMatch[]); if (branches) { let name, @@ -569,7 +599,7 @@ function updatePageloadTransaction( [name, source] = getNormalizedName(routes, location, branches, basename); } - getCurrentScope().setTransactionName(name); + getCurrentScope().setTransactionName(name || '/'); if (activeRootSpan) { activeRootSpan.updateName(name); @@ -592,8 +622,6 @@ export function createV6CompatibleWithSentryReactRouterRouting

= new Set(); - const SentryRoutes: React.FC

= (props: P) => { const isMountRenderPass = React.useRef(true); diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts index 1a40ec4fce91..b29a2dbd1cad 100644 --- a/packages/react/src/types.ts +++ b/packages/react/src/types.ts @@ -182,10 +182,14 @@ export interface RouterInit { hydrationData?: HydrationState; } +export type NavigationState = { + state: 'idle' | 'loading' | 'submitting'; +}; + export type NavigationStates = { - Idle: any; - Loading: any; - Submitting: any; + Idle: NavigationState; + Loading: NavigationState; + Submitting: NavigationState; }; export type Navigation = NavigationStates[keyof NavigationStates]; @@ -202,6 +206,7 @@ export declare enum HistoryAction { export interface RouterState { historyAction: Action | HistoryAction | any; location: Location; + navigation: Navigation; } export interface Router { state: TState;