diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/.gitignore b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/.gitignore
new file mode 100644
index 000000000000..84634c973eeb
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/.gitignore
@@ -0,0 +1,29 @@
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+# dependencies
+/node_modules
+/.pnp
+.pnp.js
+
+# testing
+/coverage
+
+# production
+/build
+
+# misc
+.DS_Store
+.env.local
+.env.development.local
+.env.test.local
+.env.production.local
+
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+
+/test-results/
+/playwright-report/
+/playwright/.cache/
+
+!*.d.ts
diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/.npmrc b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/.npmrc
new file mode 100644
index 000000000000..070f80f05092
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/.npmrc
@@ -0,0 +1,2 @@
+@sentry:registry=http://127.0.0.1:4873
+@sentry-internal:registry=http://127.0.0.1:4873
diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/package.json b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/package.json
new file mode 100644
index 000000000000..1c38ac1468ec
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/package.json
@@ -0,0 +1,53 @@
+{
+ "name": "react-router-7-lazy-routes",
+ "version": "0.1.0",
+ "private": true,
+ "dependencies": {
+ "@sentry/react": "latest || *",
+ "@types/react": "18.0.0",
+ "@types/react-dom": "18.0.0",
+ "express": "4.20.0",
+ "react": "18.2.0",
+ "react-dom": "18.2.0",
+ "react-router-dom": "7.6.0",
+ "react-scripts": "5.0.1",
+ "typescript": "~5.0.0"
+ },
+ "scripts": {
+ "build": "react-scripts build",
+ "start": "serve -s build",
+ "test": "playwright test",
+ "clean": "npx rimraf node_modules pnpm-lock.yaml",
+ "test:build": "pnpm install && npx playwright install && pnpm build",
+ "test:build-ts3.8": "pnpm install && pnpm add typescript@3.8 && npx playwright install && pnpm build",
+ "test:build-canary": "pnpm install && pnpm add react@canary react-dom@canary && npx playwright install && pnpm build",
+ "test:assert": "pnpm test"
+ },
+ "eslintConfig": {
+ "extends": [
+ "react-app",
+ "react-app/jest"
+ ]
+ },
+ "browserslist": {
+ "production": [
+ ">0.2%",
+ "not dead",
+ "not op_mini all"
+ ],
+ "development": [
+ "last 1 chrome version",
+ "last 1 firefox version",
+ "last 1 safari version"
+ ]
+ },
+ "devDependencies": {
+ "@playwright/test": "~1.53.2",
+ "@sentry-internal/test-utils": "link:../../../test-utils",
+ "serve": "14.0.1",
+ "npm-run-all2": "^6.2.0"
+ },
+ "volta": {
+ "extends": "../../package.json"
+ }
+}
diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/playwright.config.mjs
new file mode 100644
index 000000000000..31f2b913b58b
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/playwright.config.mjs
@@ -0,0 +1,7 @@
+import { getPlaywrightConfig } from '@sentry-internal/test-utils';
+
+const config = getPlaywrightConfig({
+ startCommand: `pnpm start`,
+});
+
+export default config;
diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/public/index.html b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/public/index.html
new file mode 100644
index 000000000000..39da76522bea
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/public/index.html
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+ React App
+
+
+
+
+
+
+
diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/globals.d.ts b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/globals.d.ts
new file mode 100644
index 000000000000..ffa61ca49acc
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/globals.d.ts
@@ -0,0 +1,5 @@
+interface Window {
+ recordedTransactions?: string[];
+ capturedExceptionId?: string;
+ sentryReplayId?: string;
+}
diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/index.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/index.tsx
new file mode 100644
index 000000000000..4570c23d06f5
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/index.tsx
@@ -0,0 +1,74 @@
+import * as Sentry from '@sentry/react';
+import React from 'react';
+import ReactDOM from 'react-dom/client';
+import {
+ Navigate,
+ PatchRoutesOnNavigationFunction,
+ RouterProvider,
+ createBrowserRouter,
+ createRoutesFromChildren,
+ matchRoutes,
+ useLocation,
+ useNavigationType,
+} from 'react-router-dom';
+import Index from './pages/Index';
+
+
+Sentry.init({
+ environment: 'qa', // dynamic sampling bias to keep transactions
+ dsn: process.env.REACT_APP_E2E_TEST_DSN,
+ integrations: [
+ Sentry.reactRouterV7BrowserTracingIntegration({
+ useEffect: React.useEffect,
+ useLocation,
+ useNavigationType,
+ createRoutesFromChildren,
+ matchRoutes,
+ trackFetchStreamPerformance: true,
+ enableAsyncRouteHandlers: true,
+ }),
+ ],
+ // We recommend adjusting this value in production, or using tracesSampler
+ // for finer control
+ tracesSampleRate: 1.0,
+ release: 'e2e-test',
+
+ tunnel: 'http://localhost:3031',
+});
+
+const sentryCreateBrowserRouter = Sentry.wrapCreateBrowserRouterV7(createBrowserRouter);
+
+const router = sentryCreateBrowserRouter(
+ [
+ {
+ path: '/',
+ element: ,
+ },
+ {
+ path: '/lazy',
+ handle: {
+ lazyChildren: () => import('./pages/InnerLazyRoutes').then(module => module.someMoreNestedRoutes),
+ },
+ },
+ {
+ path: '/static',
+ element: <>Hello World>,
+ },
+ {
+ path: '*',
+ element: ,
+ },
+ ],
+ {
+ async patchRoutesOnNavigation({ matches, patch }: Parameters[0]) {
+ const leafRoute = matches[matches.length - 1]?.route;
+ if (leafRoute?.id && leafRoute?.handle?.lazyChildren) {
+ const children = await leafRoute.handle.lazyChildren();
+ patch(leafRoute.id, children);
+ }
+ },
+ },
+);
+
+const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
+root.render();
diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/Index.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/Index.tsx
new file mode 100644
index 000000000000..e24b1b7cbff7
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/Index.tsx
@@ -0,0 +1,14 @@
+import * as React from 'react';
+import { Link } from 'react-router-dom';
+
+const Index = () => {
+ return (
+ <>
+
+ navigate
+
+ >
+ );
+};
+
+export default Index;
diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/InnerLazyRoutes.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/InnerLazyRoutes.tsx
new file mode 100644
index 000000000000..e8385e54dab5
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/InnerLazyRoutes.tsx
@@ -0,0 +1,37 @@
+import React from 'react';
+
+export const someMoreNestedRoutes = [
+ {
+ path: 'inner',
+ children: [
+ {
+ index: true,
+ element: <>Level 1>,
+ },
+ {
+ path: ':id',
+ children: [
+ {
+ index: true,
+ element: <>Level 1 ID>,
+ },
+ {
+ path: ':anotherId',
+ children: [
+ {
+ index: true,
+ element: <>Level 1 ID Another ID>,
+ },
+ {
+ path: ':someAnotherId',
+ element:
+ Rendered
+
,
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+];
diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/react-app-env.d.ts b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/react-app-env.d.ts
new file mode 100644
index 000000000000..6431bc5fc6b2
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/react-app-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/start-event-proxy.mjs
new file mode 100644
index 000000000000..5e5ac71f169b
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/start-event-proxy.mjs
@@ -0,0 +1,6 @@
+import { startEventProxyServer } from '@sentry-internal/test-utils';
+
+startEventProxyServer({
+ port: 3031,
+ proxyServerName: 'react-router-7-lazy-routes',
+});
diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/transactions.test.ts
new file mode 100644
index 000000000000..74a1fcff1faa
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/transactions.test.ts
@@ -0,0 +1,56 @@
+import { expect, test } from '@playwright/test';
+import { waitForTransaction } from '@sentry-internal/test-utils';
+
+
+test('Creates a pageload transaction with parameterized route', async ({ page }) => {
+ const transactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => {
+ return (
+ !!transactionEvent?.transaction &&
+ transactionEvent.contexts?.trace?.op === 'pageload' &&
+ transactionEvent.transaction === '/lazy/inner/:id/:anotherId/:someAnotherId'
+ );
+ });
+
+ await page.goto('/lazy/inner/1/2/3');
+ const event = await transactionPromise;
+
+
+ const lazyRouteContent = page.locator('id=innermost-lazy-route');
+
+ await expect(lazyRouteContent).toBeVisible();
+
+ // Validate the transaction event
+ expect(event.transaction).toBe('/lazy/inner/:id/:anotherId/:someAnotherId');
+ expect(event.type).toBe('transaction');
+ expect(event.contexts?.trace?.op).toBe('pageload');
+});
+
+test('Creates a navigation transaction inside a lazy route', async ({ page }) => {
+ const transactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => {
+ return (
+ !!transactionEvent?.transaction &&
+ transactionEvent.contexts?.trace?.op === 'navigation' &&
+ transactionEvent.transaction === '/lazy/inner/:id/:anotherId/:someAnotherId'
+ );
+ });
+
+ await page.goto('/');
+
+ // Check if the navigation link exists
+ const navigationLink = page.locator('id=navigation');
+ await expect(navigationLink).toBeVisible();
+
+ // Click the navigation link to navigate to the lazy route
+ await navigationLink.click();
+ const event = await transactionPromise;
+
+ // Check if the lazy route content is rendered
+ const lazyRouteContent = page.locator('id=innermost-lazy-route');
+
+ await expect(lazyRouteContent).toBeVisible();
+
+ // Validate the transaction event
+ expect(event.transaction).toBe('/lazy/inner/:id/:anotherId/:someAnotherId');
+ expect(event.type).toBe('transaction');
+ expect(event.contexts?.trace?.op).toBe('navigation');
+});
diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tsconfig.json b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tsconfig.json
new file mode 100644
index 000000000000..4cc95dc2689a
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tsconfig.json
@@ -0,0 +1,20 @@
+{
+ "compilerOptions": {
+ "target": "es2018",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "esModuleInterop": true,
+ "allowSyntheticDefaultImports": true,
+ "strict": true,
+ "forceConsistentCasingInFileNames": true,
+ "noFallthroughCasesInSwitch": true,
+ "module": "esnext",
+ "moduleResolution": "node",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react"
+ },
+ "include": ["src", "tests"]
+}
diff --git a/packages/react/src/reactrouterv6-compat-utils.tsx b/packages/react/src/reactrouterv6-compat-utils.tsx
index c711d9f3d613..f364facca1db 100644
--- a/packages/react/src/reactrouterv6-compat-utils.tsx
+++ b/packages/react/src/reactrouterv6-compat-utils.tsx
@@ -10,6 +10,7 @@ import {
} from '@sentry/browser';
import type { Client, Integration, Span, TransactionSource } from '@sentry/core';
import {
+ addNonEnumerableProperty,
debug,
getActiveSpan,
getClient,
@@ -46,6 +47,7 @@ let _useNavigationType: UseNavigationType;
let _createRoutesFromChildren: CreateRoutesFromChildren;
let _matchRoutes: MatchRoutes;
let _stripBasename: boolean = false;
+let _enableAsyncRouteHandlers: boolean = false;
const CLIENTS_WITH_INSTRUMENT_NAVIGATION = new WeakSet();
@@ -55,7 +57,20 @@ export interface ReactRouterOptions {
useNavigationType: UseNavigationType;
createRoutesFromChildren: CreateRoutesFromChildren;
matchRoutes: MatchRoutes;
+ /**
+ * Whether to strip the basename from the pathname when creating transactions.
+ *
+ * This is useful for applications that use a basename in their routing setup.
+ * @default false
+ */
stripBasename?: boolean;
+ /**
+ * Enables support for async route handlers.
+ *
+ * This allows Sentry to track and instrument routes dynamically resolved from async handlers.
+ * @default false
+ */
+ enableAsyncRouteHandlers?: boolean;
}
type V6CompatibleVersion = '6' | '7';
@@ -63,6 +78,130 @@ type V6CompatibleVersion = '6' | '7';
// Keeping as a global variable for cross-usage in multiple functions
const allRoutes = new Set();
+/**
+ * Adds resolved routes as children to the parent route.
+ * Prevents duplicate routes by checking if they already exist.
+ */
+function addResolvedRoutesToParent(resolvedRoutes: RouteObject[], parentRoute: RouteObject): void {
+ const existingChildren = parentRoute.children || [];
+
+ const newRoutes = resolvedRoutes.filter(
+ newRoute =>
+ !existingChildren.some(
+ existing =>
+ existing === newRoute ||
+ (newRoute.path && existing.path === newRoute.path) ||
+ (newRoute.id && existing.id === newRoute.id),
+ ),
+ );
+
+ if (newRoutes.length > 0) {
+ parentRoute.children = [...existingChildren, ...newRoutes];
+ }
+}
+
+/**
+ * Handles the result of an async handler function call.
+ */
+function handleAsyncHandlerResult(result: unknown, route: RouteObject, handlerKey: string): void {
+ if (
+ result &&
+ typeof result === 'object' &&
+ 'then' in result &&
+ typeof (result as Promise).then === 'function'
+ ) {
+ (result as Promise)
+ .then((resolvedRoutes: unknown) => {
+ if (Array.isArray(resolvedRoutes)) {
+ processResolvedRoutes(resolvedRoutes, route);
+ }
+ })
+ .catch((e: unknown) => {
+ DEBUG_BUILD && debug.warn(`Error resolving async handler '${handlerKey}' for route`, route, e);
+ });
+ } else if (Array.isArray(result)) {
+ processResolvedRoutes(result, route);
+ }
+}
+
+/**
+ * Processes resolved routes by adding them to allRoutes and checking for nested async handlers.
+ */
+function processResolvedRoutes(resolvedRoutes: RouteObject[], parentRoute?: RouteObject): void {
+ resolvedRoutes.forEach(child => {
+ allRoutes.add(child);
+ // Only check for async handlers if the feature is enabled
+ if (_enableAsyncRouteHandlers) {
+ checkRouteForAsyncHandler(child);
+ }
+ });
+
+ if (parentRoute) {
+ // If a parent route is provided, add the resolved routes as children to the parent route
+ addResolvedRoutesToParent(resolvedRoutes, parentRoute);
+ }
+
+ // After processing lazy routes, check if we need to update an active pageload transaction
+ const activeRootSpan = getActiveRootSpan();
+ if (activeRootSpan && spanToJSON(activeRootSpan).op === 'pageload') {
+ const location = WINDOW.location;
+ if (location) {
+ // Re-run the pageload transaction update with the newly loaded routes
+ updatePageloadTransaction(
+ activeRootSpan,
+ { pathname: location.pathname },
+ Array.from(allRoutes),
+ undefined,
+ undefined,
+ Array.from(allRoutes),
+ );
+ }
+ }
+}
+
+/**
+ * Creates a proxy wrapper for an async handler function.
+ */
+function createAsyncHandlerProxy(
+ originalFunction: (...args: unknown[]) => unknown,
+ route: RouteObject,
+ handlerKey: string,
+): (...args: unknown[]) => unknown {
+ const proxy = new Proxy(originalFunction, {
+ apply(target: (...args: unknown[]) => unknown, thisArg, argArray) {
+ const result = target.apply(thisArg, argArray);
+ handleAsyncHandlerResult(result, route, handlerKey);
+ return result;
+ },
+ });
+
+ addNonEnumerableProperty(proxy, '__sentry_proxied__', true);
+
+ return proxy;
+}
+
+/**
+ * Recursively checks a route for async handlers and sets up Proxies to add discovered child routes to allRoutes when called.
+ */
+export function checkRouteForAsyncHandler(route: RouteObject): void {
+ // Set up proxies for any functions in the route's handle
+ if (route.handle && typeof route.handle === 'object') {
+ for (const key of Object.keys(route.handle)) {
+ const maybeFn = route.handle[key];
+ if (typeof maybeFn === 'function' && !(maybeFn as { __sentry_proxied__?: boolean }).__sentry_proxied__) {
+ route.handle[key] = createAsyncHandlerProxy(maybeFn, route, key);
+ }
+ }
+ }
+
+ // Recursively check child routes
+ if (Array.isArray(route.children)) {
+ for (const child of route.children) {
+ checkRouteForAsyncHandler(child);
+ }
+ }
+}
+
/**
* Creates a wrapCreateBrowserRouter function that can be used with all React Router v6 compatible versions.
*/
@@ -85,6 +224,13 @@ export function createV6CompatibleWrapCreateBrowserRouter<
return function (routes: RouteObject[], opts?: Record & { basename?: string }): TRouter {
addRoutesToAllRoutes(routes);
+ // Check for async handlers that might contain sub-route declarations (only if enabled)
+ if (_enableAsyncRouteHandlers) {
+ for (const route of routes) {
+ checkRouteForAsyncHandler(route);
+ }
+ }
+
const router = createRouterFunction(routes, opts);
const basename = opts?.basename;
@@ -164,6 +310,13 @@ export function createV6CompatibleWrapCreateMemoryRouter<
): TRouter {
addRoutesToAllRoutes(routes);
+ // Check for async handlers that might contain sub-route declarations (only if enabled)
+ if (_enableAsyncRouteHandlers) {
+ for (const route of routes) {
+ checkRouteForAsyncHandler(route);
+ }
+ }
+
const router = createRouterFunction(routes, opts);
const basename = opts?.basename;
@@ -230,6 +383,7 @@ export function createReactRouterV6CompatibleTracingIntegration(
createRoutesFromChildren,
matchRoutes,
stripBasename,
+ enableAsyncRouteHandlers = false,
instrumentPageLoad = true,
instrumentNavigation = true,
} = options;
@@ -245,6 +399,7 @@ export function createReactRouterV6CompatibleTracingIntegration(
_matchRoutes = matchRoutes;
_createRoutesFromChildren = createRoutesFromChildren;
_stripBasename = stripBasename || false;
+ _enableAsyncRouteHandlers = enableAsyncRouteHandlers;
},
afterAllSetup(client) {
integration.afterAllSetup(client);
@@ -542,6 +697,7 @@ function getNormalizedName(
}
let pathBuilder = '';
+
if (branches) {
for (const branch of branches) {
const route = branch.route;
diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts
index 19aacffc5ac3..c25ee5df1ae3 100644
--- a/packages/react/src/types.ts
+++ b/packages/react/src/types.ts
@@ -13,6 +13,7 @@ export type Location = {
export interface NonIndexRouteObject {
caseSensitive?: boolean;
children?: RouteObject[];
+ handle?: Record;
element?: React.ReactNode | null;
errorElement?: React.ReactNode | null;
index?: any;
@@ -22,6 +23,7 @@ export interface NonIndexRouteObject {
export interface IndexRouteObject {
caseSensitive?: boolean;
children?: undefined;
+ handle?: Record;
element?: React.ReactNode | null;
errorElement?: React.ReactNode | null;
index: any;
diff --git a/packages/react/test/reactrouterv6-compat-utils.test.tsx b/packages/react/test/reactrouterv6-compat-utils.test.tsx
index 193b4aaab223..1f10d89c8558 100644
--- a/packages/react/test/reactrouterv6-compat-utils.test.tsx
+++ b/packages/react/test/reactrouterv6-compat-utils.test.tsx
@@ -1,5 +1,6 @@
-import { describe, expect } from 'vitest';
-import { getNumberOfUrlSegments } from '../src/reactrouterv6-compat-utils';
+import { describe, expect, it, test } from 'vitest';
+import { checkRouteForAsyncHandler, getNumberOfUrlSegments } from '../src/reactrouterv6-compat-utils';
+import type { RouteObject } from '../src/types';
describe('getNumberOfUrlSegments', () => {
test.each([
@@ -11,3 +12,304 @@ describe('getNumberOfUrlSegments', () => {
expect(getNumberOfUrlSegments(input)).toEqual(output);
});
});
+
+describe('checkRouteForAsyncHandler', () => {
+ it('should not create nested proxies when called multiple times on the same route', () => {
+ const mockHandler = () => Promise.resolve([]);
+ const route: RouteObject = {
+ path: '/test',
+ handle: {
+ lazyChildren: mockHandler,
+ },
+ };
+
+ checkRouteForAsyncHandler(route);
+ checkRouteForAsyncHandler(route);
+
+ const proxiedHandler = route.handle?.lazyChildren;
+ expect(typeof proxiedHandler).toBe('function');
+ expect(proxiedHandler).not.toBe(mockHandler);
+
+ expect((proxiedHandler as { __sentry_proxied__?: boolean }).__sentry_proxied__).toBe(true);
+
+ const proxyHandler = (proxiedHandler as any)?.__sentry_proxied__;
+ expect(proxyHandler).toBe(true);
+ });
+
+ it('should handle routes without handle property', () => {
+ const route: RouteObject = {
+ path: '/test',
+ };
+
+ expect(() => checkRouteForAsyncHandler(route)).not.toThrow();
+ });
+
+ it('should handle routes with non-function handle properties', () => {
+ const route: RouteObject = {
+ path: '/test',
+ handle: {
+ someData: 'not a function',
+ },
+ };
+
+ expect(() => checkRouteForAsyncHandler(route)).not.toThrow();
+ });
+
+ it('should handle routes with null/undefined handle properties', () => {
+ const route: RouteObject = {
+ path: '/test',
+ handle: null as any,
+ };
+
+ expect(() => checkRouteForAsyncHandler(route)).not.toThrow();
+ });
+
+ it('should handle routes with mixed function and non-function handle properties', () => {
+ const mockHandler = () => Promise.resolve([]);
+ const route: RouteObject = {
+ path: '/test',
+ handle: {
+ lazyChildren: mockHandler,
+ someData: 'not a function',
+ anotherData: 123,
+ },
+ };
+
+ checkRouteForAsyncHandler(route);
+
+ const proxiedHandler = route.handle?.lazyChildren;
+ expect(typeof proxiedHandler).toBe('function');
+ expect(proxiedHandler).not.toBe(mockHandler);
+ expect((proxiedHandler as { __sentry_proxied__?: boolean }).__sentry_proxied__).toBe(true);
+
+ // Non-function properties should remain unchanged
+ expect(route.handle?.someData).toBe('not a function');
+ expect(route.handle?.anotherData).toBe(123);
+ });
+
+ it('should handle nested routes with async handlers', () => {
+ const parentHandler = () => Promise.resolve([]);
+ const childHandler = () => Promise.resolve([]);
+
+ const route: RouteObject = {
+ path: '/parent',
+ handle: {
+ lazyChildren: parentHandler,
+ },
+ children: [
+ {
+ path: '/child',
+ handle: {
+ lazyChildren: childHandler,
+ },
+ },
+ ],
+ };
+
+ checkRouteForAsyncHandler(route);
+
+ // Check parent handler is proxied
+ const proxiedParentHandler = route.handle?.lazyChildren;
+ expect(typeof proxiedParentHandler).toBe('function');
+ expect(proxiedParentHandler).not.toBe(parentHandler);
+ expect((proxiedParentHandler as { __sentry_proxied__?: boolean }).__sentry_proxied__).toBe(true);
+
+ // Check child handler is proxied
+ const proxiedChildHandler = route.children?.[0]?.handle?.lazyChildren;
+ expect(typeof proxiedChildHandler).toBe('function');
+ expect(proxiedChildHandler).not.toBe(childHandler);
+ expect((proxiedChildHandler as { __sentry_proxied__?: boolean }).__sentry_proxied__).toBe(true);
+ });
+
+ it('should handle deeply nested routes', () => {
+ const level1Handler = () => Promise.resolve([]);
+ const level2Handler = () => Promise.resolve([]);
+ const level3Handler = () => Promise.resolve([]);
+
+ const route: RouteObject = {
+ path: '/level1',
+ handle: {
+ lazyChildren: level1Handler,
+ },
+ children: [
+ {
+ path: '/level2',
+ handle: {
+ lazyChildren: level2Handler,
+ },
+ children: [
+ {
+ path: '/level3',
+ handle: {
+ lazyChildren: level3Handler,
+ },
+ },
+ ],
+ },
+ ],
+ };
+
+ checkRouteForAsyncHandler(route);
+
+ // Check all handlers are proxied
+ expect((route.handle?.lazyChildren as { __sentry_proxied__?: boolean }).__sentry_proxied__).toBe(true);
+ expect((route.children?.[0]?.handle?.lazyChildren as { __sentry_proxied__?: boolean }).__sentry_proxied__).toBe(
+ true,
+ );
+ expect(
+ (route.children?.[0]?.children?.[0]?.handle?.lazyChildren as { __sentry_proxied__?: boolean }).__sentry_proxied__,
+ ).toBe(true);
+ });
+
+ it('should handle routes with multiple async handlers', () => {
+ const handler1 = () => Promise.resolve([]);
+ const handler2 = () => Promise.resolve([]);
+ const handler3 = () => Promise.resolve([]);
+
+ const route: RouteObject = {
+ path: '/test',
+ handle: {
+ lazyChildren: handler1,
+ asyncLoader: handler2,
+ dataLoader: handler3,
+ },
+ };
+
+ checkRouteForAsyncHandler(route);
+
+ // Check all handlers are proxied
+ expect((route.handle?.lazyChildren as { __sentry_proxied__?: boolean }).__sentry_proxied__).toBe(true);
+ expect((route.handle?.asyncLoader as { __sentry_proxied__?: boolean }).__sentry_proxied__).toBe(true);
+ expect((route.handle?.dataLoader as { __sentry_proxied__?: boolean }).__sentry_proxied__).toBe(true);
+ });
+
+ it('should not re-proxy already proxied functions', () => {
+ const mockHandler = () => Promise.resolve([]);
+ const route: RouteObject = {
+ path: '/test',
+ handle: {
+ lazyChildren: mockHandler,
+ },
+ };
+
+ // First call should proxy the function
+ checkRouteForAsyncHandler(route);
+ const firstProxiedHandler = route.handle?.lazyChildren;
+ expect(firstProxiedHandler).not.toBe(mockHandler);
+ expect((firstProxiedHandler as { __sentry_proxied__?: boolean }).__sentry_proxied__).toBe(true);
+
+ // Second call should not create a new proxy
+ checkRouteForAsyncHandler(route);
+ const secondProxiedHandler = route.handle?.lazyChildren;
+ expect(secondProxiedHandler).toBe(firstProxiedHandler); // Should be the same proxy
+ expect((secondProxiedHandler as { __sentry_proxied__?: boolean }).__sentry_proxied__).toBe(true);
+ });
+
+ it('should handle routes with empty children array', () => {
+ const route: RouteObject = {
+ path: '/test',
+ children: [],
+ };
+
+ expect(() => checkRouteForAsyncHandler(route)).not.toThrow();
+ });
+
+ it('should handle routes with undefined children', () => {
+ const route: RouteObject = {
+ path: '/test',
+ children: undefined,
+ };
+
+ expect(() => checkRouteForAsyncHandler(route)).not.toThrow();
+ });
+
+ it('should handle routes with null children', () => {
+ const route: RouteObject = {
+ path: '/test',
+ children: null as any,
+ };
+
+ expect(() => checkRouteForAsyncHandler(route)).not.toThrow();
+ });
+
+ it('should handle routes with non-array children', () => {
+ const route: RouteObject = {
+ path: '/test',
+ children: 'not an array' as any,
+ };
+
+ expect(() => checkRouteForAsyncHandler(route)).not.toThrow();
+ });
+
+ it('should handle routes with handle that is not an object', () => {
+ const route: RouteObject = {
+ path: '/test',
+ handle: 'not an object' as any,
+ };
+
+ expect(() => checkRouteForAsyncHandler(route)).not.toThrow();
+ });
+
+ it('should handle routes with handle that is null', () => {
+ const route: RouteObject = {
+ path: '/test',
+ handle: null as any,
+ };
+
+ expect(() => checkRouteForAsyncHandler(route)).not.toThrow();
+ });
+
+ it('should handle routes with handle that is undefined', () => {
+ const route: RouteObject = {
+ path: '/test',
+ handle: undefined as any,
+ };
+
+ expect(() => checkRouteForAsyncHandler(route)).not.toThrow();
+ });
+
+ it('should handle routes with handle that is a function', () => {
+ const route: RouteObject = {
+ path: '/test',
+ handle: (() => {}) as any,
+ };
+
+ expect(() => checkRouteForAsyncHandler(route)).not.toThrow();
+ });
+
+ it('should handle routes with handle that is a string', () => {
+ const route: RouteObject = {
+ path: '/test',
+ handle: 'string handle' as any,
+ };
+
+ expect(() => checkRouteForAsyncHandler(route)).not.toThrow();
+ });
+
+ it('should handle routes with handle that is a number', () => {
+ const route: RouteObject = {
+ path: '/test',
+ handle: 42 as any,
+ };
+
+ expect(() => checkRouteForAsyncHandler(route)).not.toThrow();
+ });
+
+ it('should handle routes with handle that is a boolean', () => {
+ const route: RouteObject = {
+ path: '/test',
+ handle: true as any,
+ };
+
+ expect(() => checkRouteForAsyncHandler(route)).not.toThrow();
+ });
+
+ it('should handle routes with handle that is an array', () => {
+ const route: RouteObject = {
+ path: '/test',
+ handle: [] as any,
+ };
+
+ expect(() => checkRouteForAsyncHandler(route)).not.toThrow();
+ });
+});