Product} />
- // We should check against the branch.pathname for the number of / separators
- getNumberOfUrlSegments(pathBuilder) !== getNumberOfUrlSegments(branch.pathname) &&
- // We should not count wildcard operators in the url segments calculation
- !pathEndsWithWildcard(pathBuilder)
- ) {
- return [(_stripBasename ? '' : basename) + newPath, 'route'];
- }
-
- // if the last character of the pathbuilder is a wildcard and there are children, remove the wildcard
- if (pathIsWildcardAndHasChildren(pathBuilder, branch)) {
- pathBuilder = pathBuilder.slice(0, -1);
- }
-
- return [(_stripBasename ? '' : basename) + pathBuilder, 'route'];
- }
- }
- }
- }
- }
-
- const fallbackTransactionName = _stripBasename
- ? stripBasenameFromPathname(location.pathname, basename)
- : location.pathname || '/';
-
- return [fallbackTransactionName, 'url'];
-}
-
-function updatePageloadTransaction(
- activeRootSpan: Span | undefined,
- location: Location,
- routes: RouteObject[],
- matches?: AgnosticDataRouteMatch,
- basename?: string,
- allRoutes?: RouteObject[],
-): void {
+function updatePageloadTransaction({
+ activeRootSpan,
+ location,
+ routes,
+ matches,
+ basename,
+ allRoutes,
+}: {
+ activeRootSpan: Span | undefined;
+ location: Location;
+ routes: RouteObject[];
+ matches?: AgnosticDataRouteMatch;
+ basename?: string;
+ allRoutes?: RouteObject[];
+}): void {
const branches = Array.isArray(matches)
? matches
: (_matchRoutes(allRoutes || routes, location, basename) as unknown as RouteMatch[]);
@@ -808,7 +718,12 @@ export function createV6CompatibleWithSentryReactRouterRouting s.length > 0 && s !== ',').length;
+export function handleExistingNavigationSpan(
+ activeSpan: Span,
+ spanJson: ReturnType,
+ name: string,
+ source: TransactionSource,
+ isLikelyLazyRoute: boolean,
+): void {
+ // Check if we've already set the name for this span using a custom property
+ const hasBeenNamed = (
+ activeSpan as {
+ __sentry_navigation_name_set__?: boolean;
+ }
+ )?.__sentry_navigation_name_set__;
+
+ if (!hasBeenNamed) {
+ // This is the first time we're setting the name for this span
+ if (!spanJson.timestamp) {
+ activeSpan?.updateName(name);
+ }
+
+ // For lazy routes, don't mark as named yet so it can be updated later
+ if (!isLikelyLazyRoute) {
+ addNonEnumerableProperty(
+ activeSpan as { __sentry_navigation_name_set__?: boolean },
+ '__sentry_navigation_name_set__',
+ true,
+ );
+ }
+ }
+
+ // Always set the source attribute to keep it consistent with the current route
+ activeSpan?.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, source);
+}
+
+/**
+ * Creates a new navigation span
+ */
+export function createNewNavigationSpan(
+ client: Client,
+ name: string,
+ source: TransactionSource,
+ version: string,
+ isLikelyLazyRoute: boolean,
+): void {
+ const newSpan = startBrowserTracingNavigationSpan(client, {
+ name,
+ attributes: {
+ [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: source,
+ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation',
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: `auto.navigation.react.reactrouter_v${version}`,
+ },
+ });
+
+ // For lazy routes, don't mark as named yet so it can be updated later when the route loads
+ if (!isLikelyLazyRoute && newSpan) {
+ addNonEnumerableProperty(
+ newSpan as { __sentry_navigation_name_set__?: boolean },
+ '__sentry_navigation_name_set__',
+ true,
+ );
+ }
}
diff --git a/packages/react/src/reactrouter-compat-utils/lazy-routes.tsx b/packages/react/src/reactrouter-compat-utils/lazy-routes.tsx
new file mode 100644
index 000000000000..49923854554c
--- /dev/null
+++ b/packages/react/src/reactrouter-compat-utils/lazy-routes.tsx
@@ -0,0 +1,74 @@
+import { addNonEnumerableProperty, debug, isThenable } from '@sentry/core';
+import { DEBUG_BUILD } from '../debug-build';
+import type { Location, RouteObject } from '../types';
+
+/**
+ * Creates a proxy wrapper for an async handler function.
+ */
+export function createAsyncHandlerProxy(
+ originalFunction: (...args: unknown[]) => unknown,
+ route: RouteObject,
+ handlerKey: string,
+ processResolvedRoutes: (resolvedRoutes: RouteObject[], parentRoute?: RouteObject, currentLocation?: Location) => void,
+): (...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, processResolvedRoutes);
+ return result;
+ },
+ });
+
+ addNonEnumerableProperty(proxy, '__sentry_proxied__', true);
+
+ return proxy;
+}
+
+/**
+ * Handles the result of an async handler function call.
+ */
+export function handleAsyncHandlerResult(
+ result: unknown,
+ route: RouteObject,
+ handlerKey: string,
+ processResolvedRoutes: (resolvedRoutes: RouteObject[], parentRoute?: RouteObject, currentLocation?: Location) => void,
+): void {
+ if (isThenable(result)) {
+ (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);
+ }
+}
+
+/**
+ * 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,
+ processResolvedRoutes: (resolvedRoutes: RouteObject[], parentRoute?: RouteObject, currentLocation?: Location) => void,
+): 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, processResolvedRoutes);
+ }
+ }
+ }
+
+ // Recursively check child routes
+ if (Array.isArray(route.children)) {
+ for (const child of route.children) {
+ checkRouteForAsyncHandler(child, processResolvedRoutes);
+ }
+ }
+}
diff --git a/packages/react/src/reactrouter-compat-utils/utils.ts b/packages/react/src/reactrouter-compat-utils/utils.ts
new file mode 100644
index 000000000000..8f7abb7d548e
--- /dev/null
+++ b/packages/react/src/reactrouter-compat-utils/utils.ts
@@ -0,0 +1,286 @@
+import type { TransactionSource } from '@sentry/core';
+import type { Location, MatchRoutes, RouteMatch, RouteObject } from '../types';
+
+// Global variables that these utilities depend on
+let _matchRoutes: MatchRoutes;
+let _stripBasename: boolean = false;
+
+/**
+ * Initialize function to set dependencies that the router utilities need.
+ * Must be called before using any of the exported utility functions.
+ */
+export function initializeRouterUtils(matchRoutes: MatchRoutes, stripBasename: boolean = false): void {
+ _matchRoutes = matchRoutes;
+ _stripBasename = stripBasename;
+}
+
+/**
+ * Checks if the given routes or location context suggests this might be a lazy route scenario.
+ * This helps determine if we should delay marking navigation spans as "named" to allow for updates
+ * when lazy routes are loaded.
+ */
+export function isLikelyLazyRouteContext(routes: RouteObject[], location: Location): boolean {
+ // Check if any route in the current match has lazy properties
+ const hasLazyRoute = routes.some(route => {
+ return (
+ // React Router lazy() route
+ route.lazy ||
+ // Route with async handlers that might load child routes
+ (route.handle &&
+ typeof route.handle === 'object' &&
+ Object.values(route.handle).some(handler => typeof handler === 'function'))
+ );
+ });
+
+ if (hasLazyRoute) {
+ return true;
+ }
+
+ // Check if current route is unmatched, which might indicate a lazy route that hasn't loaded yet
+ const currentMatches = _matchRoutes(routes, location);
+ if (!currentMatches || currentMatches.length === 0) {
+ return true;
+ }
+
+ return false;
+}
+
+// Helper functions
+function pickPath(match: RouteMatch): string {
+ return trimWildcard(match.route.path || '');
+}
+
+function pickSplat(match: RouteMatch): string {
+ return match.params['*'] || '';
+}
+
+function trimWildcard(path: string): string {
+ return path[path.length - 1] === '*' ? path.slice(0, -1) : path;
+}
+
+function trimSlash(path: string): string {
+ return path[path.length - 1] === '/' ? path.slice(0, -1) : path;
+}
+
+/**
+ * Checks if a path ends with a wildcard character (*).
+ */
+export function pathEndsWithWildcard(path: string): boolean {
+ return path.endsWith('*');
+}
+
+/**
+ * Checks if a path is a wildcard and has child routes.
+ */
+export function pathIsWildcardAndHasChildren(path: string, branch: RouteMatch): boolean {
+ return (pathEndsWithWildcard(path) && !!branch.route.children?.length) || false;
+}
+
+function routeIsDescendant(route: RouteObject): boolean {
+ return !!(!route.children && route.element && route.path?.endsWith('/*'));
+}
+
+function sendIndexPath(pathBuilder: string, pathname: string, basename: string): [string, TransactionSource] {
+ const reconstructedPath = pathBuilder || _stripBasename ? stripBasenameFromPathname(pathname, basename) : pathname;
+
+ const formattedPath =
+ // If the path ends with a slash, remove it
+ reconstructedPath[reconstructedPath.length - 1] === '/'
+ ? reconstructedPath.slice(0, -1)
+ : // If the path ends with a wildcard, remove it
+ reconstructedPath.slice(-2) === '/*'
+ ? reconstructedPath.slice(0, -1)
+ : reconstructedPath;
+
+ return [formattedPath, 'route'];
+}
+
+/**
+ * Returns the number of URL segments in the given URL string.
+ * Splits at '/' or '\/' to handle regex URLs correctly.
+ *
+ * @param url - The URL string to segment.
+ * @returns The number of segments in the URL.
+ */
+export function getNumberOfUrlSegments(url: string): number {
+ // split at '/' or at '\/' to split regex urls correctly
+ return url.split(/\\?\//).filter(s => s.length > 0 && s !== ',').length;
+}
+
+/**
+ * Strip the basename from a pathname if exists.
+ *
+ * Vendored and modified from `react-router`
+ * https://github.com/remix-run/react-router/blob/462bb712156a3f739d6139a0f14810b76b002df6/packages/router/utils.ts#L1038
+ */
+function stripBasenameFromPathname(pathname: string, basename: string): string {
+ if (!basename || basename === '/') {
+ return pathname;
+ }
+
+ if (!pathname.toLowerCase().startsWith(basename.toLowerCase())) {
+ return pathname;
+ }
+
+ // We want to leave trailing slash behavior in the user's control, so if they
+ // specify a basename with a trailing slash, we should support it
+ const startIndex = basename.endsWith('/') ? basename.length - 1 : basename.length;
+ const nextChar = pathname.charAt(startIndex);
+ if (nextChar && nextChar !== '/') {
+ // pathname does not start with basename/
+ return pathname;
+ }
+
+ return pathname.slice(startIndex) || '/';
+}
+
+// Exported utility functions
+
+/**
+ * Ensures a path string starts with a forward slash.
+ */
+export function prefixWithSlash(path: string): string {
+ return path[0] === '/' ? path : `/${path}`;
+}
+
+/**
+ * Rebuilds the route path from all available routes by matching against the current location.
+ */
+export function rebuildRoutePathFromAllRoutes(allRoutes: RouteObject[], location: Location): string {
+ const matchedRoutes = _matchRoutes(allRoutes, location) as RouteMatch[];
+
+ if (!matchedRoutes || matchedRoutes.length === 0) {
+ return '';
+ }
+
+ for (const match of matchedRoutes) {
+ if (match.route.path && match.route.path !== '*') {
+ const path = pickPath(match);
+ const strippedPath = stripBasenameFromPathname(location.pathname, prefixWithSlash(match.pathnameBase));
+
+ if (location.pathname === strippedPath) {
+ return trimSlash(strippedPath);
+ }
+
+ return trimSlash(
+ trimSlash(path || '') +
+ prefixWithSlash(
+ rebuildRoutePathFromAllRoutes(
+ allRoutes.filter(route => route !== match.route),
+ {
+ pathname: strippedPath,
+ },
+ ),
+ ),
+ );
+ }
+ }
+
+ return '';
+}
+
+/**
+ * Checks if the current location is inside a descendant route (route with splat parameter).
+ */
+export function locationIsInsideDescendantRoute(location: Location, routes: RouteObject[]): boolean {
+ const matchedRoutes = _matchRoutes(routes, location) as RouteMatch[];
+
+ if (matchedRoutes) {
+ for (const match of matchedRoutes) {
+ if (routeIsDescendant(match.route) && pickSplat(match)) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+}
+
+/**
+ * Gets a normalized route name and transaction source from the current routes and location.
+ */
+export function getNormalizedName(
+ routes: RouteObject[],
+ location: Location,
+ branches: RouteMatch[],
+ basename: string = '',
+): [string, TransactionSource] {
+ if (!routes || routes.length === 0) {
+ return [_stripBasename ? stripBasenameFromPathname(location.pathname, basename) : location.pathname, 'url'];
+ }
+
+ let pathBuilder = '';
+
+ if (branches) {
+ for (const branch of branches) {
+ const route = branch.route;
+ if (route) {
+ // Early return if index route
+ if (route.index) {
+ return sendIndexPath(pathBuilder, branch.pathname, basename);
+ }
+ const path = route.path;
+
+ // If path is not a wildcard and has no child routes, append the path
+ if (path && !pathIsWildcardAndHasChildren(path, branch)) {
+ const newPath = path[0] === '/' || pathBuilder[pathBuilder.length - 1] === '/' ? path : `/${path}`;
+ pathBuilder = trimSlash(pathBuilder) + prefixWithSlash(newPath);
+
+ // If the path matches the current location, return the path
+ if (trimSlash(location.pathname) === trimSlash(basename + branch.pathname)) {
+ if (
+ // If the route defined on the element is something like
+ // Product} />
+ // We should check against the branch.pathname for the number of / separators
+ getNumberOfUrlSegments(pathBuilder) !== getNumberOfUrlSegments(branch.pathname) &&
+ // We should not count wildcard operators in the url segments calculation
+ !pathEndsWithWildcard(pathBuilder)
+ ) {
+ return [(_stripBasename ? '' : basename) + newPath, 'route'];
+ }
+
+ // if the last character of the pathbuilder is a wildcard and there are children, remove the wildcard
+ if (pathIsWildcardAndHasChildren(pathBuilder, branch)) {
+ pathBuilder = pathBuilder.slice(0, -1);
+ }
+
+ return [(_stripBasename ? '' : basename) + pathBuilder, 'route'];
+ }
+ }
+ }
+ }
+ }
+
+ const fallbackTransactionName = _stripBasename
+ ? stripBasenameFromPathname(location.pathname, basename)
+ : location.pathname || '';
+
+ return [fallbackTransactionName, 'url'];
+}
+
+/**
+ * Shared helper function to resolve route name and source
+ */
+export function resolveRouteNameAndSource(
+ location: Location,
+ routes: RouteObject[],
+ allRoutes: RouteObject[],
+ branches: RouteMatch[],
+ basename: string = '',
+): [string, TransactionSource] {
+ let name: string | undefined;
+ let source: TransactionSource = 'url';
+
+ const isInDescendantRoute = locationIsInsideDescendantRoute(location, allRoutes);
+
+ if (isInDescendantRoute) {
+ name = prefixWithSlash(rebuildRoutePathFromAllRoutes(allRoutes, location));
+ source = 'route';
+ }
+
+ if (!isInDescendantRoute || !name) {
+ [name, source] = getNormalizedName(routes, location, branches, basename);
+ }
+
+ return [name || location.pathname, source];
+}
diff --git a/packages/react/src/reactrouterv6.tsx b/packages/react/src/reactrouterv6.tsx
index a32e2bb02bf1..0c58bdb68f35 100644
--- a/packages/react/src/reactrouterv6.tsx
+++ b/packages/react/src/reactrouterv6.tsx
@@ -1,13 +1,13 @@
import type { browserTracingIntegration } from '@sentry/browser';
import type { Integration } from '@sentry/core';
-import type { ReactRouterOptions } from './reactrouterv6-compat-utils';
+import type { ReactRouterOptions } from './reactrouter-compat-utils';
import {
createReactRouterV6CompatibleTracingIntegration,
createV6CompatibleWithSentryReactRouterRouting,
createV6CompatibleWrapCreateBrowserRouter,
createV6CompatibleWrapCreateMemoryRouter,
createV6CompatibleWrapUseRoutes,
-} from './reactrouterv6-compat-utils';
+} from './reactrouter-compat-utils';
import type { CreateRouterFunction, Router, RouterState, UseRoutes } from './types';
/**
diff --git a/packages/react/src/reactrouterv7.tsx b/packages/react/src/reactrouterv7.tsx
index 5a80482cd2c3..a25e12d40e68 100644
--- a/packages/react/src/reactrouterv7.tsx
+++ b/packages/react/src/reactrouterv7.tsx
@@ -1,14 +1,14 @@
// React Router v7 uses the same integration as v6
import type { browserTracingIntegration } from '@sentry/browser';
import type { Integration } from '@sentry/core';
-import type { ReactRouterOptions } from './reactrouterv6-compat-utils';
+import type { ReactRouterOptions } from './reactrouter-compat-utils';
import {
createReactRouterV6CompatibleTracingIntegration,
createV6CompatibleWithSentryReactRouterRouting,
createV6CompatibleWrapCreateBrowserRouter,
createV6CompatibleWrapCreateMemoryRouter,
createV6CompatibleWrapUseRoutes,
-} from './reactrouterv6-compat-utils';
+} from './reactrouter-compat-utils';
import type { CreateRouterFunction, Router, RouterState, UseRoutes } from './types';
/**
diff --git a/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx b/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx
new file mode 100644
index 000000000000..840adbf0a816
--- /dev/null
+++ b/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx
@@ -0,0 +1,183 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { startBrowserTracingNavigationSpan } from '@sentry/browser';
+import type { Client, Span } from '@sentry/core';
+import { addNonEnumerableProperty } from '@sentry/core';
+import * as React from 'react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import {
+ addResolvedRoutesToParent,
+ createNewNavigationSpan,
+ createReactRouterV6CompatibleTracingIntegration,
+ handleExistingNavigationSpan,
+ updateNavigationSpan,
+} from '../../src/reactrouter-compat-utils';
+import type { Location, RouteObject } from '../../src/types';
+
+const mockUpdateName = vi.fn();
+const mockSetAttribute = vi.fn();
+const mockSpan = { updateName: mockUpdateName, setAttribute: mockSetAttribute } as unknown as Span;
+const mockClient = { addIntegration: vi.fn() } as unknown as Client;
+
+vi.mock('@sentry/core', async requireActual => {
+ const actual = await requireActual();
+ return {
+ ...(actual as any),
+ addNonEnumerableProperty: vi.fn(),
+ getActiveSpan: vi.fn(() => mockSpan),
+ getClient: vi.fn(() => mockClient),
+ getRootSpan: vi.fn(() => mockSpan),
+ spanToJSON: vi.fn(() => ({ op: 'navigation' })),
+ };
+});
+
+vi.mock('@sentry/browser', async requireActual => {
+ const actual = await requireActual();
+ return {
+ ...(actual as any),
+ startBrowserTracingNavigationSpan: vi.fn(),
+ browserTracingIntegration: vi.fn(() => ({
+ setup: vi.fn(),
+ afterAllSetup: vi.fn(),
+ name: 'BrowserTracing',
+ })),
+ };
+});
+
+vi.mock('../../src/reactrouter-compat-utils/utils', () => ({
+ resolveRouteNameAndSource: vi.fn(() => ['Test Route', 'route']),
+ initializeRouterUtils: vi.fn(),
+ getGlobalLocation: vi.fn(() => ({ pathname: '/test', search: '', hash: '' })),
+ getGlobalPathname: vi.fn(() => '/test'),
+}));
+
+vi.mock('../../src/reactrouter-compat-utils/lazy-routes', () => ({
+ checkRouteForAsyncHandler: vi.fn(),
+}));
+
+describe('reactrouter-compat-utils/instrumentation', () => {
+ const sampleLocation: Location = {
+ pathname: '/test',
+ search: '',
+ hash: '',
+ state: null,
+ key: 'default',
+ };
+
+ const sampleRoutes: RouteObject[] = [
+ { path: '/', element: Home
},
+ { path: '/about', element: About
},
+ ];
+
+ const mockMatchRoutes = vi.fn(() => []);
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('updateNavigationSpan', () => {
+ it('should update navigation span name and source when not already named', () => {
+ updateNavigationSpan(mockSpan, sampleLocation, sampleRoutes, false, mockMatchRoutes);
+
+ expect(mockUpdateName).toHaveBeenCalledWith('Test Route');
+ expect(mockSetAttribute).toHaveBeenCalledWith('sentry.source', 'route');
+ expect(addNonEnumerableProperty).toHaveBeenCalledWith(mockSpan, '__sentry_navigation_name_set__', true);
+ });
+
+ it('should not update when span already has name set', () => {
+ const spanWithNameSet = { ...mockSpan, __sentry_navigation_name_set__: true };
+
+ updateNavigationSpan(spanWithNameSet as any, sampleLocation, sampleRoutes, false, mockMatchRoutes);
+
+ expect(mockUpdateName).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('handleExistingNavigationSpan', () => {
+ it('should update span name when not already named', () => {
+ const spanJson = { op: 'navigation', data: {}, span_id: 'test', start_timestamp: 0, trace_id: 'test' };
+
+ handleExistingNavigationSpan(mockSpan, spanJson, 'Test Route', 'route', false);
+
+ expect(mockUpdateName).toHaveBeenCalledWith('Test Route');
+ expect(mockSetAttribute).toHaveBeenCalledWith('sentry.source', 'route');
+ expect(addNonEnumerableProperty).toHaveBeenCalledWith(mockSpan, '__sentry_navigation_name_set__', true);
+ });
+
+ it('should not mark as named for lazy routes', () => {
+ const spanJson = { op: 'navigation', data: {}, span_id: 'test', start_timestamp: 0, trace_id: 'test' };
+
+ handleExistingNavigationSpan(mockSpan, spanJson, 'Test Route', 'route', true);
+
+ expect(mockUpdateName).toHaveBeenCalledWith('Test Route');
+ expect(mockSetAttribute).toHaveBeenCalledWith('sentry.source', 'route');
+ expect(addNonEnumerableProperty).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('createNewNavigationSpan', () => {
+ it('should create new navigation span with correct attributes', () => {
+ createNewNavigationSpan(mockClient, 'Test Route', 'route', '6', false);
+
+ expect(startBrowserTracingNavigationSpan).toHaveBeenCalledWith(mockClient, {
+ name: 'Test Route',
+ attributes: {
+ 'sentry.source': 'route',
+ 'sentry.op': 'navigation',
+ 'sentry.origin': 'auto.navigation.react.reactrouter_v6',
+ },
+ });
+ });
+ });
+
+ describe('addResolvedRoutesToParent', () => {
+ it('should add new routes to parent with no existing children', () => {
+ const parentRoute: RouteObject = { path: '/parent', element: Parent
};
+ const resolvedRoutes = [{ path: '/child1', element: Child 1
}];
+
+ addResolvedRoutesToParent(resolvedRoutes, parentRoute);
+
+ expect(parentRoute.children).toEqual(resolvedRoutes);
+ });
+
+ it('should not add duplicate routes by path', () => {
+ const existingRoute = { path: '/duplicate', element: Existing
};
+ const parentRoute: RouteObject = {
+ path: '/parent',
+ element: Parent
,
+ children: [existingRoute],
+ };
+ const duplicateRoute = { path: '/duplicate', element: Duplicate
};
+
+ addResolvedRoutesToParent([duplicateRoute], parentRoute);
+
+ expect(parentRoute.children).toEqual([existingRoute]);
+ });
+ });
+
+ describe('createReactRouterV6CompatibleTracingIntegration', () => {
+ it('should create integration with correct setup', () => {
+ const mockUseEffect = vi.fn();
+ const mockUseLocation = vi.fn();
+ const mockUseNavigationType = vi.fn();
+ const mockCreateRoutesFromChildren = vi.fn();
+
+ const integration = createReactRouterV6CompatibleTracingIntegration(
+ {
+ useEffect: mockUseEffect,
+ useLocation: mockUseLocation,
+ useNavigationType: mockUseNavigationType,
+ createRoutesFromChildren: mockCreateRoutesFromChildren,
+ matchRoutes: mockMatchRoutes,
+ },
+ '6',
+ );
+
+ expect(integration).toHaveProperty('setup');
+ expect(integration).toHaveProperty('afterAllSetup');
+ expect(typeof integration.setup).toBe('function');
+ expect(typeof integration.afterAllSetup).toBe('function');
+ });
+ });
+});
diff --git a/packages/react/test/reactrouter-compat-utils/lazy-routes.test.ts b/packages/react/test/reactrouter-compat-utils/lazy-routes.test.ts
new file mode 100644
index 000000000000..732b893ea8f8
--- /dev/null
+++ b/packages/react/test/reactrouter-compat-utils/lazy-routes.test.ts
@@ -0,0 +1,706 @@
+import { addNonEnumerableProperty, debug } from '@sentry/core';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import {
+ checkRouteForAsyncHandler,
+ createAsyncHandlerProxy,
+ handleAsyncHandlerResult,
+} from '../../src/reactrouter-compat-utils';
+import type { RouteObject } from '../../src/types';
+
+vi.mock('@sentry/core', async requireActual => {
+ const actual = await requireActual();
+ return {
+ ...(actual as any),
+ addNonEnumerableProperty: vi.fn(),
+ debug: {
+ warn: vi.fn(),
+ },
+ };
+});
+
+vi.mock('../../src/debug-build', () => ({
+ DEBUG_BUILD: true,
+}));
+
+describe('reactrouter-compat-utils/lazy-routes', () => {
+ let mockProcessResolvedRoutes: ReturnType;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockProcessResolvedRoutes = vi.fn();
+ });
+
+ describe('createAsyncHandlerProxy', () => {
+ it('should create a proxy that calls the original function', () => {
+ const originalFunction = vi.fn(() => 'result');
+ const route: RouteObject = { path: '/test' };
+ const handlerKey = 'testHandler';
+
+ const proxy = createAsyncHandlerProxy(originalFunction, route, handlerKey, mockProcessResolvedRoutes);
+
+ const result = proxy('arg1', 'arg2');
+
+ expect(originalFunction).toHaveBeenCalledWith('arg1', 'arg2');
+ expect(result).toBe('result');
+ });
+
+ it('should preserve the original function context (this binding)', () => {
+ const context = { value: 'test' };
+ const originalFunction = vi.fn(function (this: typeof context) {
+ return this.value;
+ });
+ const route: RouteObject = { path: '/test' };
+ const handlerKey = 'testHandler';
+
+ const proxy = createAsyncHandlerProxy(originalFunction, route, handlerKey, mockProcessResolvedRoutes);
+
+ const result = proxy.call(context);
+
+ expect(originalFunction).toHaveBeenCalledWith();
+ expect(result).toBe('test');
+ });
+
+ it('should handle functions with no arguments', () => {
+ const originalFunction = vi.fn(() => 'no-args-result');
+ const route: RouteObject = { path: '/test' };
+ const handlerKey = 'testHandler';
+
+ const proxy = createAsyncHandlerProxy(originalFunction, route, handlerKey, mockProcessResolvedRoutes);
+
+ const result = proxy();
+
+ expect(originalFunction).toHaveBeenCalledWith();
+ expect(result).toBe('no-args-result');
+ });
+
+ it('should handle functions with many arguments', () => {
+ const originalFunction = vi.fn((...args) => args.length);
+ const route: RouteObject = { path: '/test' };
+ const handlerKey = 'testHandler';
+
+ const proxy = createAsyncHandlerProxy(originalFunction, route, handlerKey, mockProcessResolvedRoutes);
+
+ const result = proxy(1, 2, 3, 4, 5);
+
+ expect(originalFunction).toHaveBeenCalledWith(1, 2, 3, 4, 5);
+ expect(result).toBe(5);
+ });
+
+ it('should mark the proxy with __sentry_proxied__ property', () => {
+ const originalFunction = vi.fn();
+ const route: RouteObject = { path: '/test' };
+ const handlerKey = 'testHandler';
+
+ createAsyncHandlerProxy(originalFunction, route, handlerKey, mockProcessResolvedRoutes);
+
+ expect(addNonEnumerableProperty).toHaveBeenCalledWith(expect.any(Function), '__sentry_proxied__', true);
+ });
+
+ it('should call handleAsyncHandlerResult with the function result', () => {
+ const originalFunction = vi.fn(() => ['route1', 'route2']);
+ const route: RouteObject = { path: '/test' };
+ const handlerKey = 'testHandler';
+
+ const proxy = createAsyncHandlerProxy(originalFunction, route, handlerKey, mockProcessResolvedRoutes);
+
+ proxy();
+
+ // Since handleAsyncHandlerResult is called internally, we verify through its side effects
+ expect(mockProcessResolvedRoutes).toHaveBeenCalledWith(['route1', 'route2'], route);
+ });
+
+ it('should handle functions that throw exceptions', () => {
+ const originalFunction = vi.fn(() => {
+ throw new Error('Test error');
+ });
+ const route: RouteObject = { path: '/test' };
+ const handlerKey = 'testHandler';
+
+ const proxy = createAsyncHandlerProxy(originalFunction, route, handlerKey, mockProcessResolvedRoutes);
+
+ expect(() => proxy()).toThrow('Test error');
+ expect(originalFunction).toHaveBeenCalled();
+ });
+
+ it('should handle complex route objects', () => {
+ const originalFunction = vi.fn(() => []);
+ const route: RouteObject = {
+ path: '/complex',
+ id: 'complex-route',
+ index: false,
+ caseSensitive: true,
+ children: [{ path: 'child' }],
+ element: 'Test
',
+ };
+ const handlerKey = 'complexHandler';
+
+ const proxy = createAsyncHandlerProxy(originalFunction, route, handlerKey, mockProcessResolvedRoutes);
+ proxy();
+
+ expect(mockProcessResolvedRoutes).toHaveBeenCalledWith([], route);
+ });
+ });
+
+ describe('handleAsyncHandlerResult', () => {
+ const route: RouteObject = { path: '/test' };
+ const handlerKey = 'testHandler';
+
+ it('should handle array results directly', () => {
+ const routes: RouteObject[] = [{ path: '/route1' }, { path: '/route2' }];
+
+ handleAsyncHandlerResult(routes, route, handlerKey, mockProcessResolvedRoutes);
+
+ expect(mockProcessResolvedRoutes).toHaveBeenCalledWith(routes, route);
+ });
+
+ it('should handle empty array results', () => {
+ const routes: RouteObject[] = [];
+
+ handleAsyncHandlerResult(routes, route, handlerKey, mockProcessResolvedRoutes);
+
+ expect(mockProcessResolvedRoutes).toHaveBeenCalledWith(routes, route);
+ });
+
+ it('should handle Promise results that resolve to arrays', async () => {
+ const routes: RouteObject[] = [{ path: '/route1' }, { path: '/route2' }];
+ const promiseResult = Promise.resolve(routes);
+
+ handleAsyncHandlerResult(promiseResult, route, handlerKey, mockProcessResolvedRoutes);
+
+ // Wait for the promise to resolve
+ await promiseResult;
+
+ // Use setTimeout to wait for the async handling
+ await new Promise(resolve => setTimeout(resolve, 0));
+
+ expect(mockProcessResolvedRoutes).toHaveBeenCalledWith(routes, route);
+ });
+
+ it('should handle Promise results that resolve to empty arrays', async () => {
+ const routes: RouteObject[] = [];
+ const promiseResult = Promise.resolve(routes);
+
+ handleAsyncHandlerResult(promiseResult, route, handlerKey, mockProcessResolvedRoutes);
+
+ await promiseResult;
+ await new Promise(resolve => setTimeout(resolve, 0));
+
+ expect(mockProcessResolvedRoutes).toHaveBeenCalledWith(routes, route);
+ });
+
+ it('should handle Promise results that resolve to non-arrays', async () => {
+ const promiseResult = Promise.resolve('not an array');
+
+ handleAsyncHandlerResult(promiseResult, route, handlerKey, mockProcessResolvedRoutes);
+
+ await promiseResult;
+ await new Promise(resolve => setTimeout(resolve, 0));
+
+ expect(mockProcessResolvedRoutes).not.toHaveBeenCalled();
+ });
+
+ it('should handle Promise results that resolve to null', async () => {
+ const promiseResult = Promise.resolve(null);
+
+ handleAsyncHandlerResult(promiseResult, route, handlerKey, mockProcessResolvedRoutes);
+
+ await promiseResult;
+ await new Promise(resolve => setTimeout(resolve, 0));
+
+ expect(mockProcessResolvedRoutes).not.toHaveBeenCalled();
+ });
+
+ it('should handle Promise results that resolve to undefined', async () => {
+ const promiseResult = Promise.resolve(undefined);
+
+ handleAsyncHandlerResult(promiseResult, route, handlerKey, mockProcessResolvedRoutes);
+
+ await promiseResult;
+ await new Promise(resolve => setTimeout(resolve, 0));
+
+ expect(mockProcessResolvedRoutes).not.toHaveBeenCalled();
+ });
+
+ it('should handle Promise rejections gracefully', async () => {
+ const promiseResult = Promise.reject(new Error('Test error'));
+
+ handleAsyncHandlerResult(promiseResult, route, handlerKey, mockProcessResolvedRoutes);
+
+ // Wait for the promise to be handled
+ await new Promise(resolve => setTimeout(resolve, 0));
+
+ expect(debug.warn).toHaveBeenCalledWith(
+ expect.stringContaining('Error resolving async handler'),
+ route,
+ expect.any(Error),
+ );
+ expect(mockProcessResolvedRoutes).not.toHaveBeenCalled();
+ });
+
+ it('should handle Promise rejections with non-Error values', async () => {
+ const promiseResult = Promise.reject('string error');
+
+ handleAsyncHandlerResult(promiseResult, route, handlerKey, mockProcessResolvedRoutes);
+
+ await new Promise(resolve => setTimeout(resolve, 0));
+
+ expect(debug.warn).toHaveBeenCalledWith(
+ expect.stringContaining('Error resolving async handler'),
+ route,
+ 'string error',
+ );
+ expect(mockProcessResolvedRoutes).not.toHaveBeenCalled();
+ });
+
+ it('should ignore non-promise, non-array results', () => {
+ handleAsyncHandlerResult('string result', route, handlerKey, mockProcessResolvedRoutes);
+ handleAsyncHandlerResult(123, route, handlerKey, mockProcessResolvedRoutes);
+ handleAsyncHandlerResult({ not: 'array' }, route, handlerKey, mockProcessResolvedRoutes);
+ handleAsyncHandlerResult(null, route, handlerKey, mockProcessResolvedRoutes);
+ handleAsyncHandlerResult(undefined, route, handlerKey, mockProcessResolvedRoutes);
+
+ expect(mockProcessResolvedRoutes).not.toHaveBeenCalled();
+ });
+
+ it('should ignore boolean values', () => {
+ handleAsyncHandlerResult(true, route, handlerKey, mockProcessResolvedRoutes);
+ handleAsyncHandlerResult(false, route, handlerKey, mockProcessResolvedRoutes);
+
+ expect(mockProcessResolvedRoutes).not.toHaveBeenCalled();
+ });
+
+ it('should ignore functions as results', () => {
+ const functionResult = () => 'test';
+ handleAsyncHandlerResult(functionResult, route, handlerKey, mockProcessResolvedRoutes);
+
+ expect(mockProcessResolvedRoutes).not.toHaveBeenCalled();
+ });
+
+ it("should handle objects that look like promises but aren't", () => {
+ const fakeThenableButNotPromise = {
+ then: 'not a function',
+ };
+
+ handleAsyncHandlerResult(fakeThenableButNotPromise, route, handlerKey, mockProcessResolvedRoutes);
+
+ expect(mockProcessResolvedRoutes).not.toHaveBeenCalled();
+ });
+
+ it('should handle objects that have then property but not a function', () => {
+ const almostPromise = {
+ then: null,
+ };
+
+ handleAsyncHandlerResult(almostPromise, route, handlerKey, mockProcessResolvedRoutes);
+
+ expect(mockProcessResolvedRoutes).not.toHaveBeenCalled();
+ });
+
+ it('should handle complex route objects in async handling', async () => {
+ const complexRoute: RouteObject = {
+ path: '/complex',
+ id: 'complex-route',
+ loader: vi.fn(),
+ element: 'Complex
',
+ };
+ const routes: RouteObject[] = [{ path: '/dynamic1' }, { path: '/dynamic2' }];
+ const promiseResult = Promise.resolve(routes);
+
+ handleAsyncHandlerResult(promiseResult, complexRoute, 'complexHandler', mockProcessResolvedRoutes);
+
+ await promiseResult;
+ await new Promise(resolve => setTimeout(resolve, 0));
+
+ expect(mockProcessResolvedRoutes).toHaveBeenCalledWith(routes, complexRoute);
+ });
+
+ it('should handle nested route objects in arrays', () => {
+ const routes: RouteObject[] = [
+ {
+ path: '/parent',
+ children: [{ path: 'child1' }, { path: 'child2', children: [{ path: 'grandchild' }] }],
+ },
+ ];
+
+ handleAsyncHandlerResult(routes, route, handlerKey, mockProcessResolvedRoutes);
+
+ expect(mockProcessResolvedRoutes).toHaveBeenCalledWith(routes, route);
+ });
+ });
+
+ describe('checkRouteForAsyncHandler', () => {
+ it('should proxy functions in route.handle', () => {
+ const testFunction = vi.fn();
+ const route: RouteObject = {
+ path: '/test',
+ handle: {
+ testHandler: testFunction,
+ notAFunction: 'string value',
+ },
+ };
+
+ checkRouteForAsyncHandler(route, mockProcessResolvedRoutes);
+
+ // The function should be replaced with a proxy
+ expect(route.handle!.testHandler).not.toBe(testFunction);
+ expect(typeof route.handle!.testHandler).toBe('function');
+ expect(route.handle!.notAFunction).toBe('string value');
+ });
+
+ it('should handle multiple functions in route.handle', () => {
+ const handler1 = vi.fn();
+ const handler2 = vi.fn();
+ const handler3 = vi.fn();
+ const route: RouteObject = {
+ path: '/test',
+ handle: {
+ handler1,
+ handler2,
+ handler3,
+ nonFunction: 'not a function',
+ anotherNonFunction: 42,
+ },
+ };
+
+ checkRouteForAsyncHandler(route, mockProcessResolvedRoutes);
+
+ // All functions should be proxied
+ expect(route.handle!.handler1).not.toBe(handler1);
+ expect(route.handle!.handler2).not.toBe(handler2);
+ expect(route.handle!.handler3).not.toBe(handler3);
+ expect(typeof route.handle!.handler1).toBe('function');
+ expect(typeof route.handle!.handler2).toBe('function');
+ expect(typeof route.handle!.handler3).toBe('function');
+
+ // Non-functions should remain unchanged
+ expect(route.handle!.nonFunction).toBe('not a function');
+ expect(route.handle!.anotherNonFunction).toBe(42);
+ });
+
+ it('should not proxy already proxied functions', () => {
+ const testFunction = vi.fn();
+ // Mark function as already proxied
+ (testFunction as any).__sentry_proxied__ = true;
+
+ const route: RouteObject = {
+ path: '/test',
+ handle: {
+ testHandler: testFunction,
+ },
+ };
+
+ checkRouteForAsyncHandler(route, mockProcessResolvedRoutes);
+
+ // The function should remain unchanged
+ expect(route.handle!.testHandler).toBe(testFunction);
+ });
+
+ it('should handle mix of proxied and non-proxied functions', () => {
+ const proxiedFunction = vi.fn();
+ const normalFunction = vi.fn();
+ (proxiedFunction as any).__sentry_proxied__ = true;
+
+ const route: RouteObject = {
+ path: '/test',
+ handle: {
+ proxiedHandler: proxiedFunction,
+ normalHandler: normalFunction,
+ },
+ };
+
+ checkRouteForAsyncHandler(route, mockProcessResolvedRoutes);
+
+ // Proxied function should remain unchanged
+ expect(route.handle!.proxiedHandler).toBe(proxiedFunction);
+ // Normal function should be proxied
+ expect(route.handle!.normalHandler).not.toBe(normalFunction);
+ });
+
+ it('should recursively check child routes', () => {
+ const parentFunction = vi.fn();
+ const childFunction = vi.fn();
+
+ const route: RouteObject = {
+ path: '/parent',
+ handle: {
+ parentHandler: parentFunction,
+ },
+ children: [
+ {
+ path: 'child',
+ handle: {
+ childHandler: childFunction,
+ },
+ },
+ ],
+ };
+
+ checkRouteForAsyncHandler(route, mockProcessResolvedRoutes);
+
+ // Both parent and child functions should be proxied
+ expect(route.handle!.parentHandler).not.toBe(parentFunction);
+ expect(route.children![0].handle!.childHandler).not.toBe(childFunction);
+ });
+
+ it('should handle children without handle properties', () => {
+ const parentFunction = vi.fn();
+
+ const route: RouteObject = {
+ path: '/parent',
+ handle: {
+ parentHandler: parentFunction,
+ },
+ children: [
+ {
+ path: 'child1',
+ // No handle property
+ },
+ {
+ path: 'child2',
+ handle: undefined,
+ },
+ ],
+ };
+
+ expect(() => {
+ checkRouteForAsyncHandler(route, mockProcessResolvedRoutes);
+ }).not.toThrow();
+
+ expect(route.handle!.parentHandler).not.toBe(parentFunction);
+ });
+
+ it('should handle routes without handle property', () => {
+ const route: RouteObject = {
+ path: '/test',
+ };
+
+ expect(() => {
+ checkRouteForAsyncHandler(route, mockProcessResolvedRoutes);
+ }).not.toThrow();
+ });
+
+ it('should handle routes with null handle property', () => {
+ const route: RouteObject = {
+ path: '/test',
+ handle: null,
+ };
+
+ expect(() => {
+ checkRouteForAsyncHandler(route, mockProcessResolvedRoutes);
+ }).not.toThrow();
+ });
+
+ it('should handle routes with handle that is not an object', () => {
+ const route: RouteObject = {
+ path: '/test',
+ // @ts-expect-error - Testing edge case
+ handle: 'not an object',
+ };
+
+ expect(() => {
+ checkRouteForAsyncHandler(route, mockProcessResolvedRoutes);
+ }).not.toThrow();
+ });
+
+ it('should handle routes with handle that is a function', () => {
+ const route: RouteObject = {
+ path: '/test',
+ // @ts-expect-error - Testing edge case
+ handle: vi.fn(),
+ };
+
+ expect(() => {
+ checkRouteForAsyncHandler(route, mockProcessResolvedRoutes);
+ }).not.toThrow();
+ });
+
+ it('should handle routes without children', () => {
+ const testFunction = vi.fn();
+ const route: RouteObject = {
+ path: '/test',
+ handle: {
+ testHandler: testFunction,
+ },
+ };
+
+ expect(() => {
+ checkRouteForAsyncHandler(route, mockProcessResolvedRoutes);
+ }).not.toThrow();
+ });
+
+ it('should handle routes with null children', () => {
+ const testFunction = vi.fn();
+ const route: RouteObject = {
+ path: '/test',
+ handle: {
+ testHandler: testFunction,
+ },
+ children: null,
+ };
+
+ expect(() => {
+ checkRouteForAsyncHandler(route, mockProcessResolvedRoutes);
+ }).not.toThrow();
+ });
+
+ it('should handle routes with empty children array', () => {
+ const testFunction = vi.fn();
+ const route: RouteObject = {
+ path: '/test',
+ handle: {
+ testHandler: testFunction,
+ },
+ children: [],
+ };
+
+ expect(() => {
+ checkRouteForAsyncHandler(route, mockProcessResolvedRoutes);
+ }).not.toThrow();
+ });
+
+ it('should handle deeply nested child routes', () => {
+ const grandParentFunction = vi.fn();
+ const parentFunction = vi.fn();
+ const childFunction = vi.fn();
+
+ const route: RouteObject = {
+ path: '/grandparent',
+ handle: {
+ grandParentHandler: grandParentFunction,
+ },
+ children: [
+ {
+ path: 'parent',
+ handle: {
+ parentHandler: parentFunction,
+ },
+ children: [
+ {
+ path: 'child',
+ handle: {
+ childHandler: childFunction,
+ },
+ },
+ ],
+ },
+ ],
+ };
+
+ checkRouteForAsyncHandler(route, mockProcessResolvedRoutes);
+
+ // All functions should be proxied
+ expect(route.handle!.grandParentHandler).not.toBe(grandParentFunction);
+ expect(route.children![0].handle!.parentHandler).not.toBe(parentFunction);
+ expect(route.children![0].children![0].handle!.childHandler).not.toBe(childFunction);
+ });
+
+ it('should handle routes with complex nested structures', () => {
+ const route: RouteObject = {
+ path: '/complex',
+ handle: {
+ handler1: vi.fn(),
+ handler2: vi.fn(),
+ },
+ children: [
+ {
+ path: 'level1a',
+ handle: {
+ level1aHandler: vi.fn(),
+ },
+ children: [
+ {
+ path: 'level2a',
+ handle: {
+ level2aHandler: vi.fn(),
+ },
+ },
+ {
+ path: 'level2b',
+ // No handle
+ },
+ ],
+ },
+ {
+ path: 'level1b',
+ // No handle
+ children: [
+ {
+ path: 'level2c',
+ handle: {
+ level2cHandler: vi.fn(),
+ },
+ },
+ ],
+ },
+ ],
+ };
+
+ expect(() => {
+ checkRouteForAsyncHandler(route, mockProcessResolvedRoutes);
+ }).not.toThrow();
+
+ // Check that functions were proxied at all levels
+ expect(typeof route.handle!.handler1).toBe('function');
+ expect(typeof route.handle!.handler2).toBe('function');
+ expect(typeof route.children![0].handle!.level1aHandler).toBe('function');
+ expect(typeof route.children![0].children![0].handle!.level2aHandler).toBe('function');
+ expect(typeof route.children![1].children![0].handle!.level2cHandler).toBe('function');
+ });
+
+ it('should preserve route properties during processing', () => {
+ const originalFunction = vi.fn();
+ const route: RouteObject = {
+ path: '/preserve',
+ id: 'preserve-route',
+ caseSensitive: true,
+ index: false,
+ handle: {
+ testHandler: originalFunction,
+ },
+ element: 'Test
',
+ loader: vi.fn(),
+ };
+
+ checkRouteForAsyncHandler(route, mockProcessResolvedRoutes);
+
+ // Route properties should be preserved
+ expect(route.path).toBe('/preserve');
+ expect(route.id).toBe('preserve-route');
+ expect(route.caseSensitive).toBe(true);
+ expect(route.index).toBe(false);
+ expect(route.element).toBe('Test
');
+ expect(route.loader).toBeDefined();
+
+ // Only the handler function should be changed
+ expect(route.handle!.testHandler).not.toBe(originalFunction);
+ });
+
+ it('should handle functions with special names', () => {
+ const route: RouteObject = {
+ path: '/test',
+ handle: {
+ constructor: vi.fn(),
+ toString: vi.fn(),
+ valueOf: vi.fn(),
+ hasOwnProperty: vi.fn(),
+ '0': vi.fn(),
+ 'special-name': vi.fn(),
+ 'with spaces': vi.fn(),
+ },
+ };
+
+ expect(() => {
+ checkRouteForAsyncHandler(route, mockProcessResolvedRoutes);
+ }).not.toThrow();
+
+ // All functions should be proxied regardless of name
+ expect(typeof route.handle!.constructor).toBe('function');
+ expect(typeof route.handle!.toString).toBe('function');
+ expect(typeof route.handle!.valueOf).toBe('function');
+ expect(typeof route.handle!.hasOwnProperty).toBe('function');
+ expect(typeof route.handle!['0']).toBe('function');
+ expect(typeof route.handle!['special-name']).toBe('function');
+ expect(typeof route.handle!['with spaces']).toBe('function');
+ });
+ });
+});
diff --git a/packages/react/test/reactrouter-compat-utils/utils.test.ts b/packages/react/test/reactrouter-compat-utils/utils.test.ts
new file mode 100644
index 000000000000..91885940db31
--- /dev/null
+++ b/packages/react/test/reactrouter-compat-utils/utils.test.ts
@@ -0,0 +1,632 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import {
+ getNormalizedName,
+ getNumberOfUrlSegments,
+ initializeRouterUtils,
+ locationIsInsideDescendantRoute,
+ pathEndsWithWildcard,
+ pathIsWildcardAndHasChildren,
+ prefixWithSlash,
+ rebuildRoutePathFromAllRoutes,
+ resolveRouteNameAndSource,
+} from '../../src/reactrouter-compat-utils';
+import type { Location, MatchRoutes, RouteMatch, RouteObject } from '../../src/types';
+
+vi.mock('@sentry/browser', async requireActual => {
+ const actual = await requireActual();
+ return {
+ ...(actual as any),
+ WINDOW: {
+ location: {
+ pathname: '/test/path',
+ search: '?query=1',
+ hash: '#section',
+ href: 'https://example.com/test/path?query=1#section',
+ },
+ },
+ };
+});
+
+const mockMatchRoutes = vi.fn();
+
+describe('reactrouter-compat-utils/utils', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ initializeRouterUtils(mockMatchRoutes as MatchRoutes, false);
+ });
+
+ describe('initializeRouterUtils', () => {
+ it('should initialize with matchRoutes function', () => {
+ expect(() => {
+ initializeRouterUtils(mockMatchRoutes as MatchRoutes, false);
+ }).not.toThrow();
+ });
+
+ it('should handle custom matchRoutes function with dev mode true', () => {
+ const customMatchRoutes = vi.fn();
+ expect(() => {
+ initializeRouterUtils(customMatchRoutes as MatchRoutes, true);
+ }).not.toThrow();
+ });
+
+ it('should handle custom matchRoutes function without dev mode flag', () => {
+ const customMatchRoutes = vi.fn();
+ expect(() => {
+ initializeRouterUtils(customMatchRoutes as MatchRoutes);
+ }).not.toThrow();
+ });
+ });
+
+ describe('prefixWithSlash', () => {
+ it('should add slash to string without leading slash', () => {
+ expect(prefixWithSlash('path')).toBe('/path');
+ });
+
+ it('should not add slash to string with leading slash', () => {
+ expect(prefixWithSlash('/path')).toBe('/path');
+ });
+
+ it('should handle empty string', () => {
+ expect(prefixWithSlash('')).toBe('/');
+ });
+ });
+
+ describe('pathEndsWithWildcard', () => {
+ it('should return true for path ending with /*', () => {
+ expect(pathEndsWithWildcard('/users/*')).toBe(true);
+ });
+
+ it('should return false for path not ending with /*', () => {
+ expect(pathEndsWithWildcard('/users')).toBe(false);
+ });
+
+ it('should return true for path ending with *', () => {
+ expect(pathEndsWithWildcard('/users*')).toBe(true);
+ });
+
+ it('should return false for empty string', () => {
+ expect(pathEndsWithWildcard('')).toBe(false);
+ });
+ });
+
+ describe('pathIsWildcardAndHasChildren', () => {
+ it('should return true for wildcard path with children', () => {
+ const branch = {
+ route: {
+ path: '/users/*',
+ children: [{ path: 'profile' }],
+ },
+ params: {},
+ pathname: '/users',
+ pathnameBase: '/users',
+ } as RouteMatch;
+
+ expect(pathIsWildcardAndHasChildren('/users/*', branch)).toBe(true);
+ });
+
+ it('should return false for wildcard path without children', () => {
+ const branch = {
+ route: {
+ path: '/users/*',
+ children: [],
+ },
+ params: {},
+ pathname: '/users',
+ pathnameBase: '/users',
+ } as RouteMatch;
+
+ expect(pathIsWildcardAndHasChildren('/users/*', branch)).toBe(false);
+ });
+
+ it('should return false for non-wildcard path with children', () => {
+ const branch = {
+ route: {
+ path: '/users',
+ children: [{ path: 'profile' }],
+ },
+ params: {},
+ pathname: '/users',
+ pathnameBase: '/users',
+ } as RouteMatch;
+
+ expect(pathIsWildcardAndHasChildren('/users', branch)).toBe(false);
+ });
+
+ it('should return false for non-wildcard path without children', () => {
+ const branch = {
+ route: {
+ path: '/users',
+ },
+ params: {},
+ pathname: '/users',
+ pathnameBase: '/users',
+ } as RouteMatch;
+
+ expect(pathIsWildcardAndHasChildren('/users', branch)).toBe(false);
+ });
+
+ it('should return false when route has undefined children', () => {
+ const branch = {
+ route: {
+ path: '/users/*',
+ children: undefined,
+ },
+ params: {},
+ pathname: '/users',
+ pathnameBase: '/users',
+ } as RouteMatch;
+
+ expect(pathIsWildcardAndHasChildren('/users/*', branch)).toBe(false);
+ });
+ });
+
+ describe('getNumberOfUrlSegments', () => {
+ it('should count URL segments correctly', () => {
+ expect(getNumberOfUrlSegments('/users/123/profile')).toBe(3);
+ });
+
+ it('should handle single segment', () => {
+ expect(getNumberOfUrlSegments('/users')).toBe(1);
+ });
+
+ it('should handle root path', () => {
+ expect(getNumberOfUrlSegments('/')).toBe(0);
+ });
+
+ it('should handle empty string', () => {
+ expect(getNumberOfUrlSegments('')).toBe(0);
+ });
+
+ it('should handle regex URLs with escaped slashes', () => {
+ expect(getNumberOfUrlSegments('/users\\/profile')).toBe(2);
+ });
+
+ it('should filter out empty segments and commas', () => {
+ expect(getNumberOfUrlSegments('/users//profile,test')).toBe(2);
+ });
+ });
+
+ describe('rebuildRoutePathFromAllRoutes', () => {
+ it('should return pathname when it matches stripped path', () => {
+ const allRoutes: RouteObject[] = [{ path: '/users', element: null }];
+
+ const location: Location = { pathname: '/users' };
+
+ const mockMatches: RouteMatch[] = [
+ {
+ route: { path: '/users', element: null },
+ params: {},
+ pathname: '/users',
+ pathnameBase: '',
+ },
+ ];
+
+ mockMatchRoutes.mockReturnValue(mockMatches);
+
+ const result = rebuildRoutePathFromAllRoutes(allRoutes, location);
+ expect(result).toBe('/users');
+ });
+
+ it('should return empty string when no routes match', () => {
+ const allRoutes: RouteObject[] = [{ path: '/users', element: null }];
+
+ const location: Location = { pathname: '/nonexistent' };
+
+ mockMatchRoutes.mockReturnValue([]);
+
+ const result = rebuildRoutePathFromAllRoutes(allRoutes, location);
+ expect(result).toBe('');
+ });
+
+ it('should return empty string when no matches found', () => {
+ const allRoutes: RouteObject[] = [{ path: '/users', element: null }];
+
+ const location: Location = { pathname: '/users' };
+
+ mockMatchRoutes.mockReturnValue(null);
+
+ const result = rebuildRoutePathFromAllRoutes(allRoutes, location);
+ expect(result).toBe('');
+ });
+
+ it('should handle wildcard routes', () => {
+ const allRoutes: RouteObject[] = [{ path: '/users/*', element: null }];
+
+ const location: Location = { pathname: '/users/anything' };
+
+ const mockMatches: RouteMatch[] = [
+ {
+ route: { path: '*', element: null },
+ params: { '*': 'anything' },
+ pathname: '/users/anything',
+ pathnameBase: '/users',
+ },
+ ];
+
+ mockMatchRoutes.mockReturnValue(mockMatches);
+
+ const result = rebuildRoutePathFromAllRoutes(allRoutes, location);
+ expect(result).toBe('');
+ });
+ });
+
+ describe('locationIsInsideDescendantRoute', () => {
+ it('should return true when location is inside descendant route', () => {
+ const routes: RouteObject[] = [
+ {
+ path: '/users/*',
+ element: 'div',
+ children: undefined,
+ },
+ ];
+
+ const location: Location = { pathname: '/users/123/profile' };
+
+ const mockMatches: RouteMatch[] = [
+ {
+ route: {
+ path: '/users/*',
+ element: 'div',
+ children: undefined,
+ },
+ params: { '*': '123/profile' },
+ pathname: '/users/123/profile',
+ pathnameBase: '/users',
+ },
+ ];
+
+ mockMatchRoutes.mockReturnValue(mockMatches);
+
+ const result = locationIsInsideDescendantRoute(location, routes);
+ expect(result).toBe(true);
+ });
+
+ it('should return false when route has children (not descendant)', () => {
+ const routes: RouteObject[] = [
+ {
+ path: '/users/*',
+ element: 'div',
+ children: [{ path: 'profile' }],
+ },
+ ];
+
+ const location: Location = { pathname: '/users/123/profile' };
+
+ const mockMatches: RouteMatch[] = [
+ {
+ route: {
+ path: '/users/*',
+ element: 'div',
+ children: [{ path: 'profile' }],
+ },
+ params: { '*': '123/profile' },
+ pathname: '/users/123/profile',
+ pathnameBase: '/users',
+ },
+ ];
+
+ mockMatchRoutes.mockReturnValue(mockMatches);
+
+ const result = locationIsInsideDescendantRoute(location, routes);
+ expect(result).toBe(false);
+ });
+
+ it('should return false when route has no element', () => {
+ const routes: RouteObject[] = [
+ {
+ path: '/users/*',
+ element: null,
+ children: undefined,
+ },
+ ];
+
+ const location: Location = { pathname: '/users/123/profile' };
+
+ const mockMatches: RouteMatch[] = [
+ {
+ route: {
+ path: '/users/*',
+ element: null,
+ children: undefined,
+ },
+ params: { '*': '123/profile' },
+ pathname: '/users/123/profile',
+ pathnameBase: '/users',
+ },
+ ];
+
+ mockMatchRoutes.mockReturnValue(mockMatches);
+
+ const result = locationIsInsideDescendantRoute(location, routes);
+ expect(result).toBe(false);
+ });
+
+ it('should return false when path does not end with /*', () => {
+ const routes: RouteObject[] = [
+ {
+ path: '/users',
+ element: 'div',
+ children: undefined,
+ },
+ ];
+
+ const location: Location = { pathname: '/users' };
+
+ const mockMatches: RouteMatch[] = [
+ {
+ route: {
+ path: '/users',
+ element: 'div',
+ children: undefined,
+ },
+ params: {},
+ pathname: '/users',
+ pathnameBase: '',
+ },
+ ];
+
+ mockMatchRoutes.mockReturnValue(mockMatches);
+
+ const result = locationIsInsideDescendantRoute(location, routes);
+ expect(result).toBe(false);
+ });
+
+ it('should return false when no splat parameter', () => {
+ const routes: RouteObject[] = [
+ {
+ path: '/users/*',
+ element: 'div',
+ children: undefined,
+ },
+ ];
+
+ const location: Location = { pathname: '/users' };
+
+ const mockMatches: RouteMatch[] = [
+ {
+ route: {
+ path: '/users/*',
+ element: 'div',
+ children: undefined,
+ },
+ params: {},
+ pathname: '/users',
+ pathnameBase: '/users',
+ },
+ ];
+
+ mockMatchRoutes.mockReturnValue(mockMatches);
+
+ const result = locationIsInsideDescendantRoute(location, routes);
+ expect(result).toBe(false);
+ });
+
+ it('should return false when no matches found', () => {
+ const routes: RouteObject[] = [{ path: '/users', element: null }];
+
+ const location: Location = { pathname: '/posts' };
+
+ mockMatchRoutes.mockReturnValue(null);
+
+ const result = locationIsInsideDescendantRoute(location, routes);
+ expect(result).toBe(false);
+ });
+ });
+
+ describe('getNormalizedName', () => {
+ it('should return pathname with url source when no routes provided', () => {
+ const routes: RouteObject[] = [];
+ const location: Location = { pathname: '/test' };
+ const branches: RouteMatch[] = [];
+
+ const result = getNormalizedName(routes, location, branches);
+ expect(result).toEqual(['/test', 'url']);
+ });
+
+ it('should handle index route', () => {
+ const routes: RouteObject[] = [{ path: '/', index: true, element: null }];
+ const location: Location = { pathname: '/' };
+ const branches: RouteMatch[] = [
+ {
+ route: { path: '/', index: true, element: null },
+ params: {},
+ pathname: '/',
+ pathnameBase: '',
+ },
+ ];
+
+ const result = getNormalizedName(routes, location, branches, '');
+ expect(result).toEqual(['', 'route']);
+ });
+
+ it('should handle simple route path', () => {
+ const routes: RouteObject[] = [{ path: '/users', element: null }];
+ const location: Location = { pathname: '/users' };
+ const branches: RouteMatch[] = [
+ {
+ route: { path: '/users', element: null },
+ params: {},
+ pathname: '/users',
+ pathnameBase: '',
+ },
+ ];
+
+ const result = getNormalizedName(routes, location, branches, '');
+ expect(result).toEqual(['/users', 'route']);
+ });
+
+ it('should handle nested routes', () => {
+ const routes: RouteObject[] = [
+ { path: '/users', element: null },
+ { path: ':userId', element: null },
+ ];
+ const location: Location = { pathname: '/users/123' };
+ const branches: RouteMatch[] = [
+ {
+ route: { path: '/users', element: null },
+ params: {},
+ pathname: '/users',
+ pathnameBase: '',
+ },
+ {
+ route: { path: ':userId', element: null },
+ params: { userId: '123' },
+ pathname: '/users/123',
+ pathnameBase: '/users',
+ },
+ ];
+
+ const result = getNormalizedName(routes, location, branches, '');
+ expect(result).toEqual(['/users/:userId', 'route']);
+ });
+
+ it('should handle wildcard routes with children', () => {
+ const routes: RouteObject[] = [
+ {
+ path: '/users/*',
+ element: null,
+ children: [{ path: 'profile', element: null }],
+ },
+ ];
+ const location: Location = { pathname: '/users/profile' };
+ const branches: RouteMatch[] = [
+ {
+ route: {
+ path: '/users/*',
+ element: null,
+ children: [{ path: 'profile', element: null }],
+ },
+ params: { '*': 'profile' },
+ pathname: '/users/profile',
+ pathnameBase: '/users',
+ },
+ ];
+
+ const result = getNormalizedName(routes, location, branches, '');
+ // Function falls back to url when wildcard path with children doesn't match exact logic
+ expect(result).toEqual(['/users/profile', 'url']);
+ });
+
+ it('should handle basename stripping', () => {
+ // Initialize with stripBasename = true
+ initializeRouterUtils(mockMatchRoutes as MatchRoutes, true);
+
+ const routes: RouteObject[] = [{ path: '/users', element: null }];
+ const location: Location = { pathname: '/app/users' };
+ const branches: RouteMatch[] = [
+ {
+ route: { path: '/users', element: null },
+ params: {},
+ pathname: '/app/users',
+ pathnameBase: '/app',
+ },
+ ];
+
+ const result = getNormalizedName(routes, location, branches, '/app');
+ // Function falls back to url when basename stripping doesn't match exact logic
+ expect(result).toEqual(['/users', 'url']);
+ });
+
+ it('should fallback to pathname when no matches', () => {
+ const routes: RouteObject[] = [{ path: '/users', element: null }];
+ const location: Location = { pathname: '/posts' };
+ const branches: RouteMatch[] = [];
+
+ const result = getNormalizedName(routes, location, branches, '');
+ expect(result).toEqual(['/posts', 'url']);
+ });
+ });
+
+ describe('resolveRouteNameAndSource', () => {
+ beforeEach(() => {
+ // Reset to default stripBasename = false
+ initializeRouterUtils(mockMatchRoutes as MatchRoutes, false);
+ });
+
+ it('should use descendant route when location is inside one', () => {
+ const location: Location = { pathname: '/users/123/profile' };
+ const routes: RouteObject[] = [{ path: '/users', element: null }];
+ const allRoutes: RouteObject[] = [
+ { path: '/users/*', element: 'div' }, // element must be truthy for descendant route
+ { path: '/users', element: null },
+ ];
+ const branches: RouteMatch[] = [
+ {
+ route: { path: '/users', element: null },
+ params: {},
+ pathname: '/users',
+ pathnameBase: '',
+ },
+ ];
+
+ // Mock for descendant route check
+ const descendantMatches: RouteMatch[] = [
+ {
+ route: { path: '/users/*', element: 'div' },
+ params: { '*': '123/profile' },
+ pathname: '/users/123/profile',
+ pathnameBase: '/users',
+ },
+ ];
+
+ // Mock for rebuild route path - should return '/users'
+ const rebuildMatches: RouteMatch[] = [
+ {
+ route: { path: '/users/*', element: 'div' },
+ params: { '*': '123/profile' },
+ pathname: '/users',
+ pathnameBase: '',
+ },
+ ];
+
+ mockMatchRoutes
+ .mockReturnValueOnce(descendantMatches) // First call for descendant check
+ .mockReturnValueOnce(rebuildMatches); // Second call for path rebuild
+
+ const result = resolveRouteNameAndSource(location, routes, allRoutes, branches, '');
+ // Since locationIsInsideDescendantRoute returns true, it uses route source
+ expect(result).toEqual(['/users/123/profile', 'route']);
+ });
+
+ it('should use normalized name when not in descendant route', () => {
+ const location: Location = { pathname: '/users' };
+ const routes: RouteObject[] = [{ path: '/users', element: null }];
+ const allRoutes: RouteObject[] = [{ path: '/users', element: null }];
+ const branches: RouteMatch[] = [
+ {
+ route: { path: '/users', element: null },
+ params: {},
+ pathname: '/users',
+ pathnameBase: '',
+ },
+ ];
+
+ // Mock for descendant route check (no descendant)
+ const normalMatches: RouteMatch[] = [
+ {
+ route: { path: '/users', element: null },
+ params: {},
+ pathname: '/users',
+ pathnameBase: '',
+ },
+ ];
+
+ mockMatchRoutes.mockReturnValue(normalMatches);
+
+ const result = resolveRouteNameAndSource(location, routes, allRoutes, branches, '');
+ expect(result).toEqual(['/users', 'route']);
+ });
+
+ it('should fallback to pathname when no name resolved', () => {
+ const location: Location = { pathname: '/unknown' };
+ const routes: RouteObject[] = [];
+ const allRoutes: RouteObject[] = [];
+ const branches: RouteMatch[] = [];
+
+ mockMatchRoutes.mockReturnValue(null);
+
+ const result = resolveRouteNameAndSource(location, routes, allRoutes, branches, '');
+ expect(result).toEqual(['/unknown', 'url']);
+ });
+ });
+});
diff --git a/packages/react/test/reactrouter-cross-usage.test.tsx b/packages/react/test/reactrouter-cross-usage.test.tsx
index 57f9b4ce00cf..76cfe59f3df5 100644
--- a/packages/react/test/reactrouter-cross-usage.test.tsx
+++ b/packages/react/test/reactrouter-cross-usage.test.tsx
@@ -288,11 +288,6 @@ describe('React Router cross usage of wrappers', () => {
// It's called 1 time from the wrapped `MemoryRouter`
expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1);
-
- // It's called 3 times from the 3 `useRoutes` components
- expect(mockNavigationSpan.updateName).toHaveBeenCalledTimes(3);
- expect(mockNavigationSpan.updateName).toHaveBeenLastCalledWith('/second-level/:id/third-level/:id');
-
expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), {
name: '/second-level/:id/third-level/:id',
attributes: {
@@ -459,11 +454,6 @@ describe('React Router cross usage of wrappers', () => {
// It's called 1 time from the wrapped `MemoryRouter`
expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1);
-
- // It's called 3 times from the 3 `useRoutes` components
- expect(mockNavigationSpan.updateName).toHaveBeenCalledTimes(3);
- expect(mockNavigationSpan.updateName).toHaveBeenLastCalledWith('/second-level/:id/third-level/:id');
- expect(mockNavigationSpan.setAttribute).toHaveBeenLastCalledWith(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route');
});
});
@@ -611,10 +601,6 @@ describe('React Router cross usage of wrappers', () => {
// It's called 1 time from the wrapped `createMemoryRouter`
expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1);
- // It's called 3 times from the 3 `SentryRoutes` components
- expect(mockNavigationSpan.updateName).toHaveBeenCalledTimes(3);
- expect(mockNavigationSpan.updateName).toHaveBeenLastCalledWith('/second-level/:id/third-level/:id');
-
expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), {
name: '/second-level/:id/third-level/:id',
attributes: {
@@ -790,11 +776,6 @@ describe('React Router cross usage of wrappers', () => {
// It's called 1 time from the wrapped `MemoryRouter`
expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1);
-
- // It's called 3 times from the 2 `useRoutes` components and 1 component
- expect(mockNavigationSpan.updateName).toHaveBeenCalledTimes(3);
-
- expect(mockNavigationSpan.updateName).toHaveBeenLastCalledWith('/second-level/:id/third-level/:id');
expect(mockNavigationSpan.setAttribute).toHaveBeenLastCalledWith(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route');
});
});
diff --git a/packages/react/test/reactrouterv6-compat-utils.test.tsx b/packages/react/test/reactrouterv6-compat-utils.test.tsx
deleted file mode 100644
index 1f10d89c8558..000000000000
--- a/packages/react/test/reactrouterv6-compat-utils.test.tsx
+++ /dev/null
@@ -1,315 +0,0 @@
-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([
- ['regular path', '/projects/123/views/234', 4],
- ['single param parameterized path', '/users/:id/details', 3],
- ['multi param parameterized path', '/stores/:storeId/products/:productId', 4],
- ['regex path', String(/\/api\/post[0-9]/), 2],
- ])('%s', (_: string, input, output) => {
- 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();
- });
-});