From 4ed5a8cd3f0bb22cc462f78f8125c86eb7050cb3 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Mon, 13 Jul 2026 14:36:16 +0200 Subject: [PATCH 1/4] fix react router descentant --- .gitignore | 1 + .../src/index.tsx | 15 ++ .../src/pages/Index.tsx | 3 + .../tests/transactions.test.ts | 90 ++++++++++++ .../src/reactrouter-compat-utils/utils.ts | 76 ++++++++++ .../reactrouter-descendant-routes.test.tsx | 133 ++++++++++++++++++ 6 files changed, 318 insertions(+) diff --git a/.gitignore b/.gitignore index 722587ec72c0..a710581c1a37 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ node_modules/ packages/*/package-lock.json dev-packages/*/package-lock.json package-lock.json +.pnpm-store # build and test # SDK builds diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/src/index.tsx b/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/src/index.tsx index 581014169a78..cc455b41559a 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/src/index.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/src/index.tsx @@ -74,11 +74,26 @@ const ProjectsRoutes = () => ( ); +// Descendant whose matched route (`:id` via its index) sits above further non-wildcard nested +// child routes (`:sub`). The nested subtree must not steal the transaction name from the `child/*` +// parent - the pageload/navigation name should stay `/child/:id`, not `/:id/:sub` (see issue #22194). +const ChildRoutes = () => ( + + + Child} /> + + Sub} /> + + + +); + const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement); root.render( } /> + } /> } /> , diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/src/pages/Index.tsx b/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/src/pages/Index.tsx index f0dc55c761a6..769243b552ef 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/src/pages/Index.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/src/pages/Index.tsx @@ -10,6 +10,9 @@ const Index = () => { navigate old + + navigate child + ); }; diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/tests/transactions.test.ts index 7c5a1bd7ac67..cdb4749a29b8 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/tests/transactions.test.ts @@ -61,6 +61,38 @@ test('sends a pageload transaction with a parameterized URL - alternative route' }); }); +test('keeps the parent path prefix for a descendant route with non-wildcard nested children - pageload', async ({ + page, +}) => { + const transactionPromise = waitForTransaction('react-router-6-descendant-routes', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + await page.goto(`/child/abc123`); + + const rootSpan = await transactionPromise; + + expect((await page.innerHTML('#root')).includes('Child')).toBe(true); + expect(rootSpan).toMatchObject({ + contexts: { + trace: { + op: 'pageload', + origin: 'auto.pageload.react.reactrouter_v6', + data: { + 'sentry.source': 'route', + 'url.template': '/child/:id', + 'url.path': '/child/abc123', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/child\/abc123$/), + }, + }, + }, + transaction: '/child/:id', + transaction_info: { + source: 'route', + }, + }); +}); + test('sends a navigation transaction with a parameterized URL', async ({ page }) => { const pageloadTxnPromise = waitForTransaction('react-router-6-descendant-routes', async transactionEvent => { return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; @@ -172,3 +204,61 @@ test('sends a navigation transaction with a parameterized URL - alternative rout }, }); }); + +test('keeps the parent path prefix for a descendant route with non-wildcard nested children - navigation', async ({ + page, +}) => { + const pageloadTxnPromise = waitForTransaction('react-router-6-descendant-routes', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + const navigationTxnPromise = waitForTransaction('react-router-6-descendant-routes', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; + }); + + await page.goto(`/`); + const pageloadTxn = await pageloadTxnPromise; + + expect(pageloadTxn).toMatchObject({ + contexts: { + trace: { + op: 'pageload', + origin: 'auto.pageload.react.reactrouter_v6', + data: { + 'sentry.source': 'route', + 'url.template': '/', + 'url.path': '/', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), + }, + }, + }, + transaction: '/', + transaction_info: { + source: 'route', + }, + }); + + const linkElement = page.locator('id=child-navigation'); + + const [_, navigationTxn] = await Promise.all([linkElement.click(), navigationTxnPromise]); + + expect((await page.innerHTML('#root')).includes('Child')).toBe(true); + expect(navigationTxn).toMatchObject({ + contexts: { + trace: { + op: 'navigation', + origin: 'auto.navigation.react.reactrouter_v6', + data: { + 'sentry.source': 'route', + 'url.template': '/child/:id', + 'url.path': '/child/abc123', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/child\/abc123$/), + }, + }, + }, + transaction: '/child/:id', + transaction_info: { + source: 'route', + }, + }); +}); diff --git a/packages/react/src/reactrouter-compat-utils/utils.ts b/packages/react/src/reactrouter-compat-utils/utils.ts index a43288823070..f146835f0738 100644 --- a/packages/react/src/reactrouter-compat-utils/utils.ts +++ b/packages/react/src/reactrouter-compat-utils/utils.ts @@ -180,6 +180,75 @@ export function rebuildRoutePathFromAllRoutes(allRoutes: RouteObject[], location return ''; } +/** + * Reconstructs a descendant route name that preserves the parent path prefix. + * + * `allRoutes` is a single flat set mixing every mounted `` subtree together, which loses the + * parent→descendant nesting. When a descendant `` has non-wildcard nested children (e.g. `:id` + * with an `index` and a `:sub` child), that orphaned subtree can match the full location with a higher + * React Router specificity score than the descendant-parent route (e.g. `child/*`) that actually anchors + * it. Name resolution then reconstructs from the orphan and drops the parent prefix, producing e.g. + * `/:id/:sub` for `/child/abc123` instead of `/child/:id` (see issue #22194). + * + * This helper detects the descendant-parent route that anchors the location and, if the already-resolved + * `currentName` does not preserve that parent's prefix, rebuilds the name as `/`. + * When the resolved name already starts with the parent prefix (the common, correct case), it returns + * `undefined` so the original name is kept — leaving concrete routes and wildcard-descendant chains + * untouched. + */ +function reconstructNameFromDescendantParent( + location: Location, + allRoutes: RouteObject[], + currentName: string | undefined, +): string | undefined { + const descendantParents = allRoutes.filter(routeIsDescendant); + if (descendantParents.length === 0) { + return undefined; + } + + // Match against descendant-parent routes only, so an orphaned descendant subtree can't outrank the + // route that actually anchors the location. + const matchedParents = _matchRoutes(descendantParents, location) as RouteMatch[] | null; + const parentMatch = matchedParents?.[matchedParents.length - 1]; + + // Only reconstruct when the parent consumes a splat remainder we can recurse into. + if (!parentMatch || !pickSplat(parentMatch)) { + return undefined; + } + + const parentTemplate = trimSlash(trimWildcard(parentMatch.route.path || '')); + + // Only reconstruct from a descendant parent whose leading segment is static (e.g. `child/*`). React + // Router matches static segments literally, so such a parent is guaranteed to anchor at the true root + // of the location. Descendant parents with a dynamic leading segment (e.g. a nested `:projectId/*`) + // have relative paths that can mis-anchor when matched against the absolute location, so we leave the + // existing wildcard-descendant reconstruction (which handles those chains) untouched. + const leadingSegment = parentTemplate.split('/')[0]; + if (!leadingSegment || leadingSegment.startsWith(':')) { + return undefined; + } + + const expectedPrefix = prefixWithSlash(parentTemplate); + + // If the resolved name already carries the parent prefix, it's correctly anchored - keep it. + if (currentName && (currentName === expectedPrefix || currentName.startsWith(`${expectedPrefix}/`))) { + return undefined; + } + + const remainingPathname = + stripBasenameFromPathname(location.pathname, prefixWithSlash(parentMatch.pathnameBase)) || '/'; + const remainingName = rebuildRoutePathFromAllRoutes( + allRoutes.filter(route => route !== parentMatch.route), + { pathname: remainingPathname }, + ); + + if (!remainingName) { + return undefined; + } + + return prefixWithSlash(trimSlash(trimSlash(parentTemplate) + prefixWithSlash(remainingName))); +} + /** * Checks if the current location is inside a descendant route (route with splat parameter). */ @@ -303,6 +372,13 @@ export function resolveRouteNameAndSource( [name, source] = getNormalizedName(routes, location, branches, basename); } + // Guard against orphaned descendant subtrees stealing the transaction name: if the location is + // anchored by a descendant-parent route (`.../*`) whose prefix was dropped, reconstruct with it. + const anchoredName = reconstructNameFromDescendantParent(location, allRoutes, name); + if (anchoredName) { + return [anchoredName, 'route']; + } + return [name || location.pathname, source]; } diff --git a/packages/react/test/reactrouter-descendant-routes.test.tsx b/packages/react/test/reactrouter-descendant-routes.test.tsx index e172b8b65f0b..6fcbe1a8564a 100644 --- a/packages/react/test/reactrouter-descendant-routes.test.tsx +++ b/packages/react/test/reactrouter-descendant-routes.test.tsx @@ -88,6 +88,139 @@ describe('React Router Descendant Routes', () => { }); describe('withSentryReactRouterV6Routing', () => { + it('keeps the parent path prefix for descendant routes with non-wildcard nested children - pageload', () => { + const client = createMockBrowserClient(); + setCurrentClient(client); + + client.addIntegration( + reactRouterV6BrowserTracingIntegration({ + useEffect: React.useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + }), + ); + const SentryRoutes = withSentryReactRouterV6Routing(Routes); + + // A descendant whose matched route (`:id` via its index) sits above further nested + // non-wildcard child routes (`:sub`). The nested subtree must not steal the transaction name from + // the `child/*` parent (see issue #22194). + const ChildRouter = () => ( + + + Child} /> + + Sub} /> + + + + ); + + const { container } = render( + + + } /> + + , + ); + + expect(container.innerHTML).toContain('Child'); + + expect(mockStartBrowserTracingPageLoadSpan).toHaveBeenCalledTimes(1); + expect(mockRootSpan.updateName).toHaveBeenLastCalledWith('/child/:id'); + expect(mockRootSpan.setAttribute).toHaveBeenCalledWith(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); + expect(mockRootSpan.setAttribute).toHaveBeenCalledWith(URL_TEMPLATE, '/child/:id'); + }); + + it('keeps the parent path prefix for descendant routes with non-wildcard nested children - navigation', () => { + const client = createMockBrowserClient(); + setCurrentClient(client); + + client.addIntegration( + reactRouterV6BrowserTracingIntegration({ + useEffect: React.useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + }), + ); + const SentryRoutes = withSentryReactRouterV6Routing(Routes); + + const ChildRouter = () => ( + + + Child} /> + + Sub} /> + + + + ); + + const { container } = render( + + + } /> + } /> + + , + ); + + expect(container.innerHTML).toContain('Child'); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { + name: '/child/:id', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [URL_TEMPLATE]: '/child/:id', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', + }, + }); + }); + + it('prefers a concrete sibling route over a descendant wildcard parent', () => { + const client = createMockBrowserClient(); + setCurrentClient(client); + + client.addIntegration( + reactRouterV6BrowserTracingIntegration({ + useEffect: React.useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + }), + ); + const SentryRoutes = withSentryReactRouterV6Routing(Routes); + + const ChildRouter = () => ( + + + Child} /> + + Sub} /> + + + + ); + + const { container } = render( + + + Settings} /> + } /> + + , + ); + + expect(container.innerHTML).toContain('Settings'); + expect(mockStartBrowserTracingPageLoadSpan).toHaveBeenCalledTimes(1); + expect(mockRootSpan.updateName).toHaveBeenLastCalledWith('/child/settings'); + }); + it('works with descendant wildcard routes - pageload', () => { const client = createMockBrowserClient(); setCurrentClient(client); From 0393f8f8cf48109bb48269b582156d340c9299d7 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Mon, 13 Jul 2026 14:44:36 +0200 Subject: [PATCH 2/4] refactor function to make it shorter --- .../src/reactrouter-compat-utils/utils.ts | 43 +++++-------------- 1 file changed, 10 insertions(+), 33 deletions(-) diff --git a/packages/react/src/reactrouter-compat-utils/utils.ts b/packages/react/src/reactrouter-compat-utils/utils.ts index f146835f0738..edb7b8fdb5d3 100644 --- a/packages/react/src/reactrouter-compat-utils/utils.ts +++ b/packages/react/src/reactrouter-compat-utils/utils.ts @@ -181,20 +181,11 @@ export function rebuildRoutePathFromAllRoutes(allRoutes: RouteObject[], location } /** - * Reconstructs a descendant route name that preserves the parent path prefix. + * Recovers the parent prefix for descendant `` names. * - * `allRoutes` is a single flat set mixing every mounted `` subtree together, which loses the - * parent→descendant nesting. When a descendant `` has non-wildcard nested children (e.g. `:id` - * with an `index` and a `:sub` child), that orphaned subtree can match the full location with a higher - * React Router specificity score than the descendant-parent route (e.g. `child/*`) that actually anchors - * it. Name resolution then reconstructs from the orphan and drops the parent prefix, producing e.g. - * `/:id/:sub` for `/child/abc123` instead of `/child/:id` (see issue #22194). - * - * This helper detects the descendant-parent route that anchors the location and, if the already-resolved - * `currentName` does not preserve that parent's prefix, rebuilds the name as `/`. - * When the resolved name already starts with the parent prefix (the common, correct case), it returns - * `undefined` so the original name is kept — leaving concrete routes and wildcard-descendant chains - * untouched. + * `allRoutes` flattens every mounted `` into one set, so an orphaned descendant subtree can + * outscore the `.../*` route that anchors the location and drop its prefix (e.g. `/:id/:sub` instead of `/child/:id`). + * Matching only the descendant-parent routes recovers the true anchor. */ function reconstructNameFromDescendantParent( location: Location, @@ -202,36 +193,26 @@ function reconstructNameFromDescendantParent( currentName: string | undefined, ): string | undefined { const descendantParents = allRoutes.filter(routeIsDescendant); - if (descendantParents.length === 0) { + if (!descendantParents.length) { return undefined; } - // Match against descendant-parent routes only, so an orphaned descendant subtree can't outrank the - // route that actually anchors the location. const matchedParents = _matchRoutes(descendantParents, location) as RouteMatch[] | null; const parentMatch = matchedParents?.[matchedParents.length - 1]; - - // Only reconstruct when the parent consumes a splat remainder we can recurse into. if (!parentMatch || !pickSplat(parentMatch)) { return undefined; } const parentTemplate = trimSlash(trimWildcard(parentMatch.route.path || '')); - // Only reconstruct from a descendant parent whose leading segment is static (e.g. `child/*`). React - // Router matches static segments literally, so such a parent is guaranteed to anchor at the true root - // of the location. Descendant parents with a dynamic leading segment (e.g. a nested `:projectId/*`) - // have relative paths that can mis-anchor when matched against the absolute location, so we leave the - // existing wildcard-descendant reconstruction (which handles those chains) untouched. - const leadingSegment = parentTemplate.split('/')[0]; - if (!leadingSegment || leadingSegment.startsWith(':')) { + // Only a static leading segment (e.g. `child/*`) has a fixed position in the absolute URL; dynamic + // leads (`:projectId/*`) are relative and stay with the existing wildcard-rebuild path. + if (!parentTemplate || parentTemplate.startsWith(':')) { return undefined; } const expectedPrefix = prefixWithSlash(parentTemplate); - - // If the resolved name already carries the parent prefix, it's correctly anchored - keep it. - if (currentName && (currentName === expectedPrefix || currentName.startsWith(`${expectedPrefix}/`))) { + if (currentName === expectedPrefix || currentName?.startsWith(`${expectedPrefix}/`)) { return undefined; } @@ -242,11 +223,7 @@ function reconstructNameFromDescendantParent( { pathname: remainingPathname }, ); - if (!remainingName) { - return undefined; - } - - return prefixWithSlash(trimSlash(trimSlash(parentTemplate) + prefixWithSlash(remainingName))); + return remainingName ? prefixWithSlash(`${parentTemplate}${prefixWithSlash(remainingName)}`) : undefined; } /** From 6b86f0750081e95bdf065454998669f11f0b77e3 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Mon, 13 Jul 2026 15:19:00 +0200 Subject: [PATCH 3/4] add more unit tests (dynamic-lead descendant) --- .../reactrouter-descendant-routes.test.tsx | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/packages/react/test/reactrouter-descendant-routes.test.tsx b/packages/react/test/reactrouter-descendant-routes.test.tsx index 6fcbe1a8564a..b7e068df0c97 100644 --- a/packages/react/test/reactrouter-descendant-routes.test.tsx +++ b/packages/react/test/reactrouter-descendant-routes.test.tsx @@ -221,6 +221,98 @@ describe('React Router Descendant Routes', () => { expect(mockRootSpan.updateName).toHaveBeenLastCalledWith('/child/settings'); }); + it('keeps the parent path prefix for a dynamic-lead descendant parent with non-wildcard nested children - pageload', () => { + const client = createMockBrowserClient(); + setCurrentClient(client); + + client.addIntegration( + reactRouterV6BrowserTracingIntegration({ + useEffect: React.useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + }), + ); + const SentryRoutes = withSentryReactRouterV6Routing(Routes); + + // The descendant parent has a dynamic leading segment (`:orgId/*`). The nested `:sub` subtree must + // not steal the transaction name - it should stay `/:orgId/:id`, not `/:id/:sub`. + const OrgRouter = () => ( + + + Org} /> + + Sub} /> + + + + ); + + const { container } = render( + + + } /> + + , + ); + + expect(container.innerHTML).toContain('Org'); + + expect(mockStartBrowserTracingPageLoadSpan).toHaveBeenCalledTimes(1); + expect(mockRootSpan.updateName).toHaveBeenLastCalledWith('/:orgId/:id'); + expect(mockRootSpan.setAttribute).toHaveBeenCalledWith(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); + expect(mockRootSpan.setAttribute).toHaveBeenCalledWith(URL_TEMPLATE, '/:orgId/:id'); + }); + + it('keeps the parent path prefix for a dynamic-lead descendant parent with non-wildcard nested children - navigation', () => { + const client = createMockBrowserClient(); + setCurrentClient(client); + + client.addIntegration( + reactRouterV6BrowserTracingIntegration({ + useEffect: React.useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + }), + ); + const SentryRoutes = withSentryReactRouterV6Routing(Routes); + + const OrgRouter = () => ( + + + Org} /> + + Sub} /> + + + + ); + + const { container } = render( + + + } /> + } /> + + , + ); + + expect(container.innerHTML).toContain('Org'); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { + name: '/:orgId/:id', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [URL_TEMPLATE]: '/:orgId/:id', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', + }, + }); + }); + it('works with descendant wildcard routes - pageload', () => { const client = createMockBrowserClient(); setCurrentClient(client); From 1eb71ff7bcc87f20c6cdf9a7e886f81536d8359c Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Tue, 14 Jul 2026 11:59:04 +0200 Subject: [PATCH 4/4] add deep wildcard chain test --- .../src/index.tsx | 15 +++++ .../src/pages/Index.tsx | 3 + .../tests/transactions.test.ts | 67 +++++++++++++++++++ 3 files changed, 85 insertions(+) diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/src/index.tsx b/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/src/index.tsx index cc455b41559a..4ae0c208e998 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/src/index.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/src/index.tsx @@ -88,12 +88,27 @@ const ChildRoutes = () => ( ); +// Deep wildcard chain: three levels of `/*` nesting. +// workspace/* → :teamId/* → :memberId +const DeepMemberRoutes = () => ( + + Deep Member} /> + +); + +const DeepTeamRoutes = () => ( + + } /> + +); + const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement); root.render( } /> } /> + } /> } /> , diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/src/pages/Index.tsx b/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/src/pages/Index.tsx index 769243b552ef..e0b372a51965 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/src/pages/Index.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/src/pages/Index.tsx @@ -13,6 +13,9 @@ const Index = () => { navigate child + + navigate deep member + ); }; diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/tests/transactions.test.ts index cdb4749a29b8..3c4598be922a 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/tests/transactions.test.ts @@ -262,3 +262,70 @@ test('keeps the parent path prefix for a descendant route with non-wildcard nest }, }); }); + +test('resolves deep wildcard chain with three levels of nesting - pageload', async ({ page }) => { + const transactionPromise = waitForTransaction('react-router-6-descendant-routes', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + await page.goto(`/workspace/team/u123`); + + const rootSpan = await transactionPromise; + + expect((await page.innerHTML('#root')).includes('Deep Member')).toBe(true); + expect(rootSpan).toMatchObject({ + contexts: { + trace: { + op: 'pageload', + origin: 'auto.pageload.react.reactrouter_v6', + data: { + 'sentry.source': 'route', + 'url.template': '/workspace/:teamId/:memberId', + 'url.path': '/workspace/team/u123', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/workspace\/team\/u123$/), + }, + }, + }, + transaction: '/workspace/:teamId/:memberId', + transaction_info: { + source: 'route', + }, + }); +}); + +test('resolves deep wildcard chain with three levels of nesting - navigation', async ({ page }) => { + const pageloadTxnPromise = waitForTransaction('react-router-6-descendant-routes', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + const navigationTxnPromise = waitForTransaction('react-router-6-descendant-routes', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; + }); + + await page.goto(`/`); + await pageloadTxnPromise; + + const linkElement = page.locator('id=deep-member-navigation'); + + const [_, navigationTxn] = await Promise.all([linkElement.click(), navigationTxnPromise]); + + expect((await page.innerHTML('#root')).includes('Deep Member')).toBe(true); + expect(navigationTxn).toMatchObject({ + contexts: { + trace: { + op: 'navigation', + origin: 'auto.navigation.react.reactrouter_v6', + data: { + 'sentry.source': 'route', + 'url.template': '/workspace/:teamId/:memberId', + 'url.path': '/workspace/team/u123', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/workspace\/team\/u123$/), + }, + }, + }, + transaction: '/workspace/:teamId/:memberId', + transaction_info: { + source: 'route', + }, + }); +});