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(); + }); +});