diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-cross-usage/src/index.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-cross-usage/src/index.tsx index bfcc527ded1b..089b27ab974a 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-cross-usage/src/index.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-7-cross-usage/src/index.tsx @@ -89,6 +89,14 @@ const ProjectsRoutes = () => ( ); const router = sentryCreateBrowserRouter([ + { + path: '/post/:post', + element:
Post
, + children: [ + { index: true, element:
Post Index
}, + { path: '/post/:post/related', element:
Related Posts
}, + ], + }, { children: [ { diff --git a/packages/react/src/reactrouter-compat-utils/instrumentation.tsx b/packages/react/src/reactrouter-compat-utils/instrumentation.tsx index bf57fdbd74dc..a72c4fd05378 100644 --- a/packages/react/src/reactrouter-compat-utils/instrumentation.tsx +++ b/packages/react/src/reactrouter-compat-utils/instrumentation.tsx @@ -106,7 +106,8 @@ export interface ReactRouterOptions { type V6CompatibleVersion = '6' | '7'; // Keeping as a global variable for cross-usage in multiple functions -const allRoutes = new Set(); +// only exported for testing purposes +export const allRoutes = new Set(); /** * Processes resolved routes by adding them to allRoutes and checking for nested async handlers. @@ -679,7 +680,8 @@ export function handleNavigation(opts: { } } -function addRoutesToAllRoutes(routes: RouteObject[]): void { +/* Only exported for testing purposes */ +export function addRoutesToAllRoutes(routes: RouteObject[]): void { routes.forEach(route => { const extractedChildRoutes = getChildRoutesRecursively(route); diff --git a/packages/react/src/reactrouter-compat-utils/utils.ts b/packages/react/src/reactrouter-compat-utils/utils.ts index c0750c17c57c..d6501d0e4dbf 100644 --- a/packages/react/src/reactrouter-compat-utils/utils.ts +++ b/packages/react/src/reactrouter-compat-utils/utils.ts @@ -45,21 +45,27 @@ export function pathIsWildcardAndHasChildren(path: string, branch: RouteMatch within ) */ +export 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; + const reconstructedPath = + pathBuilder && pathBuilder.length > 0 + ? pathBuilder + : _stripBasename + ? stripBasenameFromPathname(pathname, basename) + : pathname; + + let formattedPath = + // If the path ends with a wildcard suffix, remove both the slash and the asterisk + reconstructedPath.slice(-2) === '/*' ? reconstructedPath.slice(0, -2) : reconstructedPath; + + // If the path ends with a slash, remove it (but keep single '/') + if (formattedPath.length > 1 && formattedPath[formattedPath.length - 1] === '/') { + formattedPath = formattedPath.slice(0, -1); + } return [formattedPath, 'route']; } diff --git a/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx b/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx index 0eeeeb342287..4785849f1192 100644 --- a/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx +++ b/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx @@ -10,6 +10,7 @@ import { createReactRouterV6CompatibleTracingIntegration, updateNavigationSpan, } from '../../src/reactrouter-compat-utils'; +import { addRoutesToAllRoutes, allRoutes } from '../../src/reactrouter-compat-utils/instrumentation'; import type { Location, RouteObject } from '../../src/types'; const mockUpdateName = vi.fn(); @@ -47,6 +48,7 @@ vi.mock('../../src/reactrouter-compat-utils/utils', () => ({ initializeRouterUtils: vi.fn(), getGlobalLocation: vi.fn(() => ({ pathname: '/test', search: '', hash: '' })), getGlobalPathname: vi.fn(() => '/test'), + routeIsDescendant: vi.fn(() => false), })); vi.mock('../../src/reactrouter-compat-utils/lazy-routes', () => ({ @@ -141,3 +143,193 @@ describe('reactrouter-compat-utils/instrumentation', () => { }); }); }); + +describe('addRoutesToAllRoutes', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetModules(); + allRoutes.clear(); + }); + + it('should add simple routes without nesting', () => { + const routes = [ + { path: '/', element:
}, + { path: '/user/:id', element:
}, + { path: '/group/:group/:user?', element:
}, + ]; + + addRoutesToAllRoutes(routes); + const allRoutesArr = Array.from(allRoutes); + + expect(allRoutesArr).toHaveLength(3); + expect(allRoutesArr).toEqual( + expect.arrayContaining([ + expect.objectContaining({ path: '/' }), + expect.objectContaining({ path: '/user/:id' }), + expect.objectContaining({ path: '/group/:group/:user?' }), + ]), + ); + + // Verify exact structure matches manual testing results + allRoutesArr.forEach(route => { + expect(route).toHaveProperty('element'); + expect(route.element).toHaveProperty('props'); + }); + }); + + it('should handle complex nested routes with multiple levels', () => { + const routes = [ + { path: '/', element:
}, + { path: '/user/:id', element:
}, + { path: '/group/:group/:user?', element:
}, + { + path: '/v1/post/:post', + element:
, + children: [ + { path: 'featured', element:
}, + { path: '/v1/post/:post/related', element:
}, + { + element:
More Nested Children
, + children: [{ path: 'edit', element:
Edit Post
}], + }, + ], + }, + { + path: '/v2/post/:post', + element:
, + children: [ + { index: true, element:
}, + { path: 'featured', element:
}, + { path: '/v2/post/:post/related', element:
}, + ], + }, + ]; + + addRoutesToAllRoutes(routes); + const allRoutesArr = Array.from(allRoutes); + + expect(allRoutesArr).toEqual([ + { path: '/', element:
}, + { path: '/user/:id', element:
}, + { path: '/group/:group/:user?', element:
}, + // v1 routes ---- + { + path: '/v1/post/:post', + element:
, + children: [ + { element:
, path: 'featured' }, + { element:
, path: '/v1/post/:post/related' }, + { children: [{ element:
Edit Post
, path: 'edit' }], element:
More Nested Children
}, + ], + }, + { element:
, path: 'featured' }, + { element:
, path: '/v1/post/:post/related' }, + { children: [{ element:
Edit Post
, path: 'edit' }], element:
More Nested Children
}, + { element:
Edit Post
, path: 'edit' }, + // v2 routes --- + { + path: '/v2/post/:post', + element: expect.objectContaining({ type: 'div', props: {} }), + children: [ + { element:
, index: true }, + { element:
, path: 'featured' }, + { element:
, path: '/v2/post/:post/related' }, + ], + }, + { element:
, index: true }, + { element:
, path: 'featured' }, + { element:
, path: '/v2/post/:post/related' }, + ]); + }); + + it('should handle routes with nested index routes', () => { + const routes = [ + { + path: '/dashboard', + element:
, + children: [ + { index: true, element:
Dashboard Index
}, + { path: 'settings', element:
Settings
}, + ], + }, + ]; + + addRoutesToAllRoutes(routes); + const allRoutesArr = Array.from(allRoutes); + + expect(allRoutesArr).toEqual([ + { + path: '/dashboard', + element: expect.objectContaining({ type: 'div' }), + children: [ + { element:
Dashboard Index
, index: true }, + { element:
Settings
, path: 'settings' }, + ], + }, + { element:
Dashboard Index
, index: true }, + { element:
Settings
, path: 'settings' }, + ]); + }); + + it('should handle deeply nested routes with layout wrappers', () => { + const routes = [ + { + path: '/', + element:
Root
, + children: [ + { path: 'dashboard', element:
Dashboard
}, + { + element:
AuthLayout
, + children: [{ path: 'login', element:
Login
}], + }, + ], + }, + ]; + + addRoutesToAllRoutes(routes); + const allRoutesArr = Array.from(allRoutes); + + expect(allRoutesArr).toEqual([ + { + path: '/', + element: expect.objectContaining({ type: 'div', props: { children: 'Root' } }), + children: [ + { + path: 'dashboard', + element: expect.objectContaining({ type: 'div', props: { children: 'Dashboard' } }), + }, + { + element: expect.objectContaining({ type: 'div', props: { children: 'AuthLayout' } }), + children: [ + { + path: 'login', + element: expect.objectContaining({ type: 'div', props: { children: 'Login' } }), + }, + ], + }, + ], + }, + { element:
Dashboard
, path: 'dashboard' }, + { + children: [{ element:
Login
, path: 'login' }], + element:
AuthLayout
, + }, + { element:
Login
, path: 'login' }, + ]); + }); + + it('should not duplicate routes when called multiple times', () => { + const routes = [ + { path: '/', element:
}, + { path: '/about', element:
}, + ]; + + addRoutesToAllRoutes(routes); + const firstCount = allRoutes.size; + + addRoutesToAllRoutes(routes); + const secondCount = allRoutes.size; + + expect(firstCount).toBe(secondCount); + }); +}); diff --git a/packages/react/test/reactrouter-compat-utils/utils.test.ts b/packages/react/test/reactrouter-compat-utils/utils.test.ts index 91885940db31..9ff48e7450bc 100644 --- a/packages/react/test/reactrouter-compat-utils/utils.test.ts +++ b/packages/react/test/reactrouter-compat-utils/utils.test.ts @@ -436,7 +436,7 @@ describe('reactrouter-compat-utils/utils', () => { ]; const result = getNormalizedName(routes, location, branches, ''); - expect(result).toEqual(['', 'route']); + expect(result).toEqual(['/', 'route']); }); it('should handle simple route path', () => {