Skip to content

Commit 57c8b22

Browse files
committed
Reimplement
1 parent 00fa011 commit 57c8b22

File tree

8 files changed

+678
-173
lines changed

8 files changed

+678
-173
lines changed

dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/index.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,12 @@ const router = sentryCreateBrowserRouter(
5050
lazyChildren: () => import('./pages/InnerLazyRoutes').then(module => module.someMoreNestedRoutes),
5151
},
5252
},
53+
{
54+
path: '/another-lazy',
55+
handle: {
56+
lazyChildren: () => import('./pages/AnotherLazyRoutes').then(module => module.anotherNestedRoutes),
57+
},
58+
},
5359
{
5460
path: '/static',
5561
element: <>Hello World</>,
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import React from 'react';
2+
import { Link } from 'react-router-dom';
3+
4+
export const anotherNestedRoutes = [
5+
{
6+
path: 'sub',
7+
children: [
8+
{
9+
index: true,
10+
element: (
11+
<div id="another-lazy-route">
12+
Another Lazy Route
13+
<Link to="/lazy/inner/999/888/777" id="navigate-to-inner">
14+
Navigate to Inner Lazy Route
15+
</Link>
16+
</div>
17+
),
18+
},
19+
{
20+
path: ':id',
21+
children: [
22+
{
23+
index: true,
24+
element: <div id="another-lazy-route-with-id">Another Lazy Route with ID</div>,
25+
},
26+
{
27+
path: ':subId',
28+
element: (
29+
<div id="another-lazy-route-deep">
30+
Another Deep Lazy Route
31+
<Link to="/lazy/inner/111/222/333" id="navigate-to-inner-from-deep">
32+
Navigate to Inner from Deep
33+
</Link>
34+
</div>
35+
),
36+
},
37+
],
38+
},
39+
],
40+
},
41+
];

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,14 @@ const Index = () => {
77
<Link to="/lazy/inner/123/456/789" id="navigation">
88
navigate
99
</Link>
10+
<br />
11+
<Link to="/another-lazy/sub" id="navigation-to-another">
12+
Navigate to Another Lazy Route
13+
</Link>
14+
<br />
15+
<Link to="/another-lazy/sub/555/666" id="navigation-to-another-deep">
16+
Navigate to Another Deep Lazy Route
17+
</Link>
1018
</>
1119
);
1220
};

dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/InnerLazyRoutes.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import React from 'react';
2+
import { Link } from 'react-router-dom';
23

34
export const someMoreNestedRoutes = [
45
{
@@ -24,9 +25,15 @@ export const someMoreNestedRoutes = [
2425
},
2526
{
2627
path: ':someAnotherId',
27-
element: <div id="innermost-lazy-route">
28-
Rendered
29-
</div>,
28+
element: (
29+
<div id="innermost-lazy-route">
30+
Rendered
31+
<br />
32+
<Link to="/another-lazy/sub/888/999" id="navigate-to-another-from-inner">
33+
Navigate to Another Lazy Route
34+
</Link>
35+
</div>
36+
),
3037
},
3138
],
3239
},

dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/transactions.test.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,3 +54,111 @@ test('Creates a navigation transaction inside a lazy route', async ({ page }) =>
5454
expect(event.type).toBe('transaction');
5555
expect(event.contexts?.trace?.op).toBe('navigation');
5656
});
57+
58+
test('Creates navigation transactions between two different lazy routes', async ({ page }) => {
59+
// First, navigate to the "another-lazy" route
60+
const firstTransactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => {
61+
return (
62+
!!transactionEvent?.transaction &&
63+
transactionEvent.contexts?.trace?.op === 'navigation' &&
64+
transactionEvent.transaction === '/another-lazy/sub/:id/:subId'
65+
);
66+
});
67+
68+
await page.goto('/');
69+
70+
// Navigate to another lazy route first
71+
const navigationToAnotherDeep = page.locator('id=navigation-to-another-deep');
72+
await expect(navigationToAnotherDeep).toBeVisible();
73+
await navigationToAnotherDeep.click();
74+
75+
const firstEvent = await firstTransactionPromise;
76+
77+
// Check if the first lazy route content is rendered
78+
const anotherLazyContent = page.locator('id=another-lazy-route-deep');
79+
await expect(anotherLazyContent).toBeVisible();
80+
81+
// Validate the first transaction event
82+
expect(firstEvent.transaction).toBe('/another-lazy/sub/:id/:subId');
83+
expect(firstEvent.type).toBe('transaction');
84+
expect(firstEvent.contexts?.trace?.op).toBe('navigation');
85+
86+
// Now navigate from the first lazy route to the second lazy route
87+
const secondTransactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => {
88+
return (
89+
!!transactionEvent?.transaction &&
90+
transactionEvent.contexts?.trace?.op === 'navigation' &&
91+
transactionEvent.transaction === '/lazy/inner/:id/:anotherId/:someAnotherId'
92+
);
93+
});
94+
95+
// Click the navigation link from within the first lazy route to the second lazy route
96+
const navigationToInnerFromDeep = page.locator('id=navigate-to-inner-from-deep');
97+
await expect(navigationToInnerFromDeep).toBeVisible();
98+
await navigationToInnerFromDeep.click();
99+
100+
const secondEvent = await secondTransactionPromise;
101+
102+
// Check if the second lazy route content is rendered
103+
const innerLazyContent = page.locator('id=innermost-lazy-route');
104+
await expect(innerLazyContent).toBeVisible();
105+
106+
// Validate the second transaction event
107+
expect(secondEvent.transaction).toBe('/lazy/inner/:id/:anotherId/:someAnotherId');
108+
expect(secondEvent.type).toBe('transaction');
109+
expect(secondEvent.contexts?.trace?.op).toBe('navigation');
110+
});
111+
112+
test('Creates navigation transactions from inner lazy route to another lazy route', async ({ page }) => {
113+
// First, navigate to the inner lazy route
114+
const firstTransactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => {
115+
return (
116+
!!transactionEvent?.transaction &&
117+
transactionEvent.contexts?.trace?.op === 'navigation' &&
118+
transactionEvent.transaction === '/lazy/inner/:id/:anotherId/:someAnotherId'
119+
);
120+
});
121+
122+
await page.goto('/');
123+
124+
// Navigate to inner lazy route first
125+
const navigationToInner = page.locator('id=navigation');
126+
await expect(navigationToInner).toBeVisible();
127+
await navigationToInner.click();
128+
129+
const firstEvent = await firstTransactionPromise;
130+
131+
// Check if the inner lazy route content is rendered
132+
const innerLazyContent = page.locator('id=innermost-lazy-route');
133+
await expect(innerLazyContent).toBeVisible();
134+
135+
// Validate the first transaction event
136+
expect(firstEvent.transaction).toBe('/lazy/inner/:id/:anotherId/:someAnotherId');
137+
expect(firstEvent.type).toBe('transaction');
138+
expect(firstEvent.contexts?.trace?.op).toBe('navigation');
139+
140+
// Now navigate from the inner lazy route to another lazy route
141+
const secondTransactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => {
142+
return (
143+
!!transactionEvent?.transaction &&
144+
transactionEvent.contexts?.trace?.op === 'navigation' &&
145+
transactionEvent.transaction === '/another-lazy/sub/:id/:subId'
146+
);
147+
});
148+
149+
// Click the navigation link from within the inner lazy route to another lazy route
150+
const navigationToAnotherFromInner = page.locator('id=navigate-to-another-from-inner');
151+
await expect(navigationToAnotherFromInner).toBeVisible();
152+
await navigationToAnotherFromInner.click();
153+
154+
const secondEvent = await secondTransactionPromise;
155+
156+
// Check if the another lazy route content is rendered
157+
const anotherLazyContent = page.locator('id=another-lazy-route-deep');
158+
await expect(anotherLazyContent).toBeVisible();
159+
160+
// Validate the second transaction event
161+
expect(secondEvent.transaction).toBe('/another-lazy/sub/:id/:subId');
162+
expect(secondEvent.type).toBe('transaction');
163+
expect(secondEvent.contexts?.trace?.op).toBe('navigation');
164+
});
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import type { Span, TransactionSource } from '@sentry/core';
2+
import { addNonEnumerableProperty, debug, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, spanToJSON } from '@sentry/core';
3+
import { DEBUG_BUILD } from './debug-build';
4+
import { resolveRouteNameAndSource } from './reactrouterv6-compat-utils';
5+
import type { Location, MatchRoutes, RouteMatch, RouteObject } from './types';
6+
7+
/**
8+
* Updates a navigation span with the correct route name after lazy routes have been loaded.
9+
*/
10+
export function updateNavigationSpanWithLazyRoutes(
11+
activeRootSpan: Span,
12+
location: Location,
13+
allRoutes: RouteObject[],
14+
forceUpdate = false,
15+
matchRoutes: MatchRoutes,
16+
rebuildRoutePathFromAllRoutes: (allRoutes: RouteObject[], location: Location) => string,
17+
locationIsInsideDescendantRoute: (location: Location, routes: RouteObject[]) => boolean,
18+
getNormalizedName: (
19+
routes: RouteObject[],
20+
location: Location,
21+
branches: RouteMatch[],
22+
basename?: string,
23+
) => [string, TransactionSource],
24+
prefixWithSlash: (path: string) => string,
25+
): void {
26+
// Check if this span has already been named to avoid multiple updates
27+
// But allow updates if this is a forced update (e.g., when lazy routes are loaded)
28+
const hasBeenNamed =
29+
!forceUpdate &&
30+
(
31+
activeRootSpan as {
32+
__sentry_navigation_name_set__?: boolean;
33+
}
34+
)?.__sentry_navigation_name_set__;
35+
36+
if (!hasBeenNamed) {
37+
// Get fresh branches for the current location with all loaded routes
38+
const currentBranches = matchRoutes(allRoutes, location);
39+
const [name, source] = resolveRouteNameAndSource(
40+
location,
41+
allRoutes,
42+
allRoutes,
43+
(currentBranches as RouteMatch[]) || [],
44+
'',
45+
locationIsInsideDescendantRoute,
46+
rebuildRoutePathFromAllRoutes,
47+
getNormalizedName,
48+
prefixWithSlash,
49+
);
50+
51+
// Only update if we have a valid name and the span hasn't finished
52+
const spanJson = spanToJSON(activeRootSpan);
53+
if (name && !spanJson.timestamp) {
54+
activeRootSpan.updateName(name);
55+
activeRootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, source);
56+
57+
// Mark this span as having its name set to prevent future updates
58+
addNonEnumerableProperty(
59+
activeRootSpan as { __sentry_navigation_name_set__?: boolean },
60+
'__sentry_navigation_name_set__',
61+
true,
62+
);
63+
}
64+
}
65+
}
66+
67+
/**
68+
* Creates a proxy wrapper for an async handler function.
69+
*/
70+
export function createAsyncHandlerProxy(
71+
originalFunction: (...args: unknown[]) => unknown,
72+
route: RouteObject,
73+
handlerKey: string,
74+
processResolvedRoutes: (resolvedRoutes: RouteObject[], parentRoute?: RouteObject, currentLocation?: Location) => void,
75+
): (...args: unknown[]) => unknown {
76+
const proxy = new Proxy(originalFunction, {
77+
apply(target: (...args: unknown[]) => unknown, thisArg, argArray) {
78+
const result = target.apply(thisArg, argArray);
79+
handleAsyncHandlerResult(result, route, handlerKey, processResolvedRoutes);
80+
return result;
81+
},
82+
});
83+
84+
addNonEnumerableProperty(proxy, '__sentry_proxied__', true);
85+
86+
return proxy;
87+
}
88+
89+
/**
90+
* Handles the result of an async handler function call.
91+
*/
92+
export function handleAsyncHandlerResult(
93+
result: unknown,
94+
route: RouteObject,
95+
handlerKey: string,
96+
processResolvedRoutes: (resolvedRoutes: RouteObject[], parentRoute?: RouteObject, currentLocation?: Location) => void,
97+
): void {
98+
if (
99+
result &&
100+
typeof result === 'object' &&
101+
'then' in result &&
102+
typeof (result as Promise<unknown>).then === 'function'
103+
) {
104+
(result as Promise<unknown>)
105+
.then((resolvedRoutes: unknown) => {
106+
if (Array.isArray(resolvedRoutes)) {
107+
processResolvedRoutes(resolvedRoutes, route);
108+
}
109+
})
110+
.catch((e: unknown) => {
111+
DEBUG_BUILD && debug.warn(`Error resolving async handler '${handlerKey}' for route`, route, e);
112+
});
113+
} else if (Array.isArray(result)) {
114+
processResolvedRoutes(result, route);
115+
}
116+
}
117+
118+
/**
119+
* Recursively checks a route for async handlers and sets up Proxies to add discovered child routes to allRoutes when called.
120+
*/
121+
export function checkRouteForAsyncHandler(
122+
route: RouteObject,
123+
processResolvedRoutes: (resolvedRoutes: RouteObject[], parentRoute?: RouteObject, currentLocation?: Location) => void,
124+
): void {
125+
// Set up proxies for any functions in the route's handle
126+
if (route.handle && typeof route.handle === 'object') {
127+
for (const key of Object.keys(route.handle)) {
128+
const maybeFn = route.handle[key];
129+
if (typeof maybeFn === 'function' && !(maybeFn as { __sentry_proxied__?: boolean }).__sentry_proxied__) {
130+
route.handle[key] = createAsyncHandlerProxy(maybeFn, route, key, processResolvedRoutes);
131+
}
132+
}
133+
}
134+
135+
// Recursively check child routes
136+
if (Array.isArray(route.children)) {
137+
for (const child of route.children) {
138+
checkRouteForAsyncHandler(child, processResolvedRoutes);
139+
}
140+
}
141+
}

0 commit comments

Comments
 (0)