From 7c4f5ebbd1e7c10d42af1d983b2468a37ce304a5 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Mon, 6 Jul 2026 11:15:32 +0200 Subject: [PATCH] change tracing mechanism --- .../solid-tanstack-router/src/main.tsx | 25 ++- .../tests/routing-instrumentation.test.ts | 116 ++++++++++++++ .../tanstack-router/src/main.tsx | 25 ++- .../tests/routing-instrumentation.test.ts | 120 +++++++++++++++ .../vue-tanstack-router/src/App.vue | 3 + .../vue-tanstack-router/src/main.ts | 12 +- .../tests/routing-instrumentation.test.ts | 116 ++++++++++++++ packages/react/src/tanstackrouter.ts | 119 +++++++++------ .../react/src/vendor/tanstackrouter-types.ts | 2 +- packages/solid/src/tanstackrouter.ts | 116 ++++++++------ packages/vue/src/tanstackrouter.ts | 144 ++++++++++-------- packages/vue/test/tanstackrouter.test.ts | 93 ++++++++--- 12 files changed, 703 insertions(+), 188 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/solid-tanstack-router/src/main.tsx b/dev-packages/e2e-tests/test-applications/solid-tanstack-router/src/main.tsx index 6561d96ce6b1..2c9df09d5b43 100644 --- a/dev-packages/e2e-tests/test-applications/solid-tanstack-router/src/main.tsx +++ b/dev-packages/e2e-tests/test-applications/solid-tanstack-router/src/main.tsx @@ -1,4 +1,12 @@ -import { Link, Outlet, RouterProvider, createRootRoute, createRoute, createRouter } from '@tanstack/solid-router'; +import { + Link, + Outlet, + RouterProvider, + createRootRoute, + createRoute, + createRouter, + redirect, +} from '@tanstack/solid-router'; import * as Sentry from '@sentry/solid'; import { tanstackRouterBrowserTracingIntegration } from '@sentry/solid/tanstackrouter'; import { render } from 'solid-js/web'; @@ -24,6 +32,11 @@ const rootRoute = createRootRoute({ Post 2 +
  • + + Redirect + +

  • @@ -59,7 +72,15 @@ const postIdRoute = createRoute({ }, }); -const routeTree = rootRoute.addChildren([indexRoute, postsRoute.addChildren([postIdRoute])]); +const redirectRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'redirect', + beforeLoad: () => { + throw redirect({ to: '/posts/$postId', params: { postId: '1' }, replace: true }); + }, +}); + +const routeTree = rootRoute.addChildren([indexRoute, redirectRoute, postsRoute.addChildren([postIdRoute])]); const router = createRouter({ routeTree, diff --git a/dev-packages/e2e-tests/test-applications/solid-tanstack-router/tests/routing-instrumentation.test.ts b/dev-packages/e2e-tests/test-applications/solid-tanstack-router/tests/routing-instrumentation.test.ts index fd81eb0f1d45..9d835b847f9b 100644 --- a/dev-packages/e2e-tests/test-applications/solid-tanstack-router/tests/routing-instrumentation.test.ts +++ b/dev-packages/e2e-tests/test-applications/solid-tanstack-router/tests/routing-instrumentation.test.ts @@ -71,6 +71,122 @@ test('sends a navigation transaction with a parameterized URL', async ({ page }) }); }); +test('sends a pageload transaction named after the resolved route when a redirect is thrown on initial load', async ({ + page, +}) => { + const pageloadTxnPromise = waitForTransaction('solid-tanstack-router', async transactionEvent => { + return transactionEvent.contexts?.trace?.op === 'pageload' && transactionEvent.transaction === '/posts/$postId'; + }); + + await page.goto(`/redirect`); + + const pageloadTxn = await pageloadTxnPromise; + + expect(pageloadTxn).toMatchObject({ + contexts: { + trace: { + data: { + 'sentry.source': 'route', + 'sentry.origin': 'auto.pageload.solid.tanstack_router', + 'sentry.op': 'pageload', + 'url.path.parameter.postId': '1', + }, + op: 'pageload', + origin: 'auto.pageload.solid.tanstack_router', + }, + }, + transaction: '/posts/$postId', + transaction_info: { + source: 'route', + }, + }); +}); + +test('sends a navigation transaction when a redirect is thrown in beforeLoad', async ({ page }) => { + const pageloadTxnPromise = waitForTransaction('solid-tanstack-router', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + const navigationTxnPromise = waitForTransaction('solid-tanstack-router', async transactionEvent => { + return ( + !!transactionEvent?.transaction && + transactionEvent.transaction === '/posts/$postId' && + transactionEvent.contexts?.trace?.op === 'navigation' + ); + }); + + await page.goto(`/`); + await pageloadTxnPromise; + + await page.locator('#redirect-link').click(); + + const navigationTxn = await navigationTxnPromise; + + expect(navigationTxn).toMatchObject({ + contexts: { + trace: { + data: { + 'sentry.source': 'route', + 'sentry.origin': 'auto.navigation.solid.tanstack_router', + 'sentry.op': 'navigation', + 'url.path.parameter.postId': '1', + }, + op: 'navigation', + origin: 'auto.navigation.solid.tanstack_router', + }, + }, + transaction: '/posts/$postId', + transaction_info: { + source: 'route', + }, + }); +}); + +test('sends a navigation transaction for a normal navigation that happens after a redirect', async ({ page }) => { + const pageloadTxnPromise = waitForTransaction('solid-tanstack-router', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + await page.goto(`/`); + await pageloadTxnPromise; + + const redirectTxnPromise = waitForTransaction('solid-tanstack-router', async transactionEvent => { + return transactionEvent.contexts?.trace?.op === 'navigation' && transactionEvent.transaction === '/posts/$postId'; + }); + await page.locator('#redirect-link').click(); + await redirectTxnPromise; + + const navigationTxnPromise = waitForTransaction('solid-tanstack-router', async transactionEvent => { + return ( + transactionEvent.contexts?.trace?.op === 'navigation' && + transactionEvent.contexts?.trace?.data?.['url.path.parameter.postId'] === '2' + ); + }); + + await page.locator('#nav-link').click(); + + const navigationTxn = await navigationTxnPromise; + + expect(navigationTxn).toMatchObject({ + contexts: { + trace: { + data: { + 'sentry.source': 'route', + 'sentry.origin': 'auto.navigation.solid.tanstack_router', + 'sentry.op': 'navigation', + 'url.path.parameter.postId': '2', + }, + op: 'navigation', + origin: 'auto.navigation.solid.tanstack_router', + }, + }, + transaction: '/posts/$postId', + transaction_info: { + source: 'route', + }, + }); +}); + test('sends pageload transaction with web vitals measurements', async ({ page }) => { const transactionPromise = waitForTransaction('solid-tanstack-router', async transactionEvent => { return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; diff --git a/dev-packages/e2e-tests/test-applications/tanstack-router/src/main.tsx b/dev-packages/e2e-tests/test-applications/tanstack-router/src/main.tsx index 01d867f63c65..47d112136049 100644 --- a/dev-packages/e2e-tests/test-applications/tanstack-router/src/main.tsx +++ b/dev-packages/e2e-tests/test-applications/tanstack-router/src/main.tsx @@ -1,5 +1,13 @@ import * as Sentry from '@sentry/react'; -import { Link, Outlet, RouterProvider, createRootRoute, createRoute, createRouter } from '@tanstack/react-router'; +import { + Link, + Outlet, + RouterProvider, + createRootRoute, + createRoute, + createRouter, + redirect, +} from '@tanstack/react-router'; import { StrictMode } from 'react'; import ReactDOM from 'react-dom/client'; @@ -20,6 +28,11 @@ const rootRoute = createRootRoute({ Post 2 +
  • + + Redirect + +

  • @@ -61,7 +74,15 @@ const postIdRoute = createRoute({ }, }); -const routeTree = rootRoute.addChildren([indexRoute, postsRoute.addChildren([postIdRoute])]); +const redirectRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'redirect', + beforeLoad: () => { + throw redirect({ to: '/posts/$postId', params: { postId: '1' }, replace: true }); + }, +}); + +const routeTree = rootRoute.addChildren([indexRoute, redirectRoute, postsRoute.addChildren([postIdRoute])]); const router = createRouter({ routeTree }); diff --git a/dev-packages/e2e-tests/test-applications/tanstack-router/tests/routing-instrumentation.test.ts b/dev-packages/e2e-tests/test-applications/tanstack-router/tests/routing-instrumentation.test.ts index bfb7dde10865..ee752f4b7cac 100644 --- a/dev-packages/e2e-tests/test-applications/tanstack-router/tests/routing-instrumentation.test.ts +++ b/dev-packages/e2e-tests/test-applications/tanstack-router/tests/routing-instrumentation.test.ts @@ -117,3 +117,123 @@ test('sends a navigation transaction with a parameterized URL', async ({ page }) ]), }); }); + +test('sends a pageload transaction named after the resolved route when a redirect is thrown on initial load', async ({ + page, +}) => { + const pageloadTxnPromise = waitForTransaction('tanstack-router', async transactionEvent => { + return transactionEvent.contexts?.trace?.op === 'pageload' && transactionEvent.transaction === '/posts/$postId'; + }); + + // Visiting `/redirect` directly throws `redirect({ to: '/posts/$postId', params: { postId: '1' } })` + // in `beforeLoad` during the initial pageload, so the pageload span must be renamed to the target route. + await page.goto(`/redirect`); + + const pageloadTxn = await pageloadTxnPromise; + + expect(pageloadTxn).toMatchObject({ + contexts: { + trace: { + data: { + 'sentry.source': 'route', + 'sentry.origin': 'auto.pageload.react.tanstack_router', + 'sentry.op': 'pageload', + 'url.path.params.postId': '1', + }, + op: 'pageload', + origin: 'auto.pageload.react.tanstack_router', + }, + }, + transaction: '/posts/$postId', + transaction_info: { + source: 'route', + }, + }); +}); + +test('sends a navigation transaction when a redirect is thrown in beforeLoad', async ({ page }) => { + const pageloadTxnPromise = waitForTransaction('tanstack-router', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + const navigationTxnPromise = waitForTransaction('tanstack-router', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; + }); + + await page.goto(`/`); + await pageloadTxnPromise; + + await page.locator('#redirect-link').click(); + + const navigationTxn = await navigationTxnPromise; + + // The `/redirect` route throws `redirect({ to: '/posts/$postId', params: { postId: '1' } })` in + // `beforeLoad`, so the navigation span must be named after the resolved target route, not `/redirect`. + expect(navigationTxn).toMatchObject({ + contexts: { + trace: { + data: { + 'sentry.source': 'route', + 'sentry.origin': 'auto.navigation.react.tanstack_router', + 'sentry.op': 'navigation', + 'url.path.params.postId': '1', + }, + op: 'navigation', + origin: 'auto.navigation.react.tanstack_router', + }, + }, + transaction: '/posts/$postId', + transaction_info: { + source: 'route', + }, + }); +}); + +test('sends a navigation transaction for a normal navigation that happens after a redirect', async ({ page }) => { + const pageloadTxnPromise = waitForTransaction('tanstack-router', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + await page.goto(`/`); + await pageloadTxnPromise; + + // First trigger a redirect-driven navigation. Upstream (TanStack/router#3920) this leaves the + // router in a state where `onBeforeNavigate` never fires again, which previously killed all + // subsequent navigation spans. + const redirectTxnPromise = waitForTransaction('tanstack-router', async transactionEvent => { + return transactionEvent.contexts?.trace?.op === 'navigation' && transactionEvent.transaction === '/posts/$postId'; + }); + await page.locator('#redirect-link').click(); + await redirectTxnPromise; + + // Now a plain navigation must still produce a navigation span. + const navigationTxnPromise = waitForTransaction('tanstack-router', async transactionEvent => { + return ( + transactionEvent.contexts?.trace?.op === 'navigation' && + transactionEvent.contexts?.trace?.data?.['url.path.params.postId'] === '2' + ); + }); + + await page.locator('#nav-link').click(); + + const navigationTxn = await navigationTxnPromise; + + expect(navigationTxn).toMatchObject({ + contexts: { + trace: { + data: { + 'sentry.source': 'route', + 'sentry.origin': 'auto.navigation.react.tanstack_router', + 'sentry.op': 'navigation', + 'url.path.params.postId': '2', + }, + op: 'navigation', + origin: 'auto.navigation.react.tanstack_router', + }, + }, + transaction: '/posts/$postId', + transaction_info: { + source: 'route', + }, + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/vue-tanstack-router/src/App.vue b/dev-packages/e2e-tests/test-applications/vue-tanstack-router/src/App.vue index fa9ef37a3794..8adf1ff1881b 100644 --- a/dev-packages/e2e-tests/test-applications/vue-tanstack-router/src/App.vue +++ b/dev-packages/e2e-tests/test-applications/vue-tanstack-router/src/App.vue @@ -11,6 +11,9 @@
  • Post 2
  • +
  • + Redirect +

  • diff --git a/dev-packages/e2e-tests/test-applications/vue-tanstack-router/src/main.ts b/dev-packages/e2e-tests/test-applications/vue-tanstack-router/src/main.ts index 2b44a6297ca7..c17c2c81147d 100644 --- a/dev-packages/e2e-tests/test-applications/vue-tanstack-router/src/main.ts +++ b/dev-packages/e2e-tests/test-applications/vue-tanstack-router/src/main.ts @@ -1,5 +1,5 @@ import { createApp } from 'vue'; -import { RouterProvider, createRoute, createRootRoute, createRouter } from '@tanstack/vue-router'; +import { RouterProvider, createRoute, createRootRoute, createRouter, redirect } from '@tanstack/vue-router'; import * as Sentry from '@sentry/vue'; import { tanstackRouterBrowserTracingIntegration } from '@sentry/vue/tanstackrouter'; @@ -33,7 +33,15 @@ const postIdRoute = createRoute({ component: PostView, }); -const routeTree = rootRoute.addChildren([indexRoute, postsRoute.addChildren([postIdRoute])]); +const redirectRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'redirect', + beforeLoad: () => { + throw redirect({ to: '/posts/$postId', params: { postId: '1' }, replace: true }); + }, +}); + +const routeTree = rootRoute.addChildren([indexRoute, redirectRoute, postsRoute.addChildren([postIdRoute])]); const router = createRouter({ routeTree, diff --git a/dev-packages/e2e-tests/test-applications/vue-tanstack-router/tests/routing-instrumentation.test.ts b/dev-packages/e2e-tests/test-applications/vue-tanstack-router/tests/routing-instrumentation.test.ts index d6d9d4d9b0ba..2a47d5a67d62 100644 --- a/dev-packages/e2e-tests/test-applications/vue-tanstack-router/tests/routing-instrumentation.test.ts +++ b/dev-packages/e2e-tests/test-applications/vue-tanstack-router/tests/routing-instrumentation.test.ts @@ -112,3 +112,119 @@ test('sends a navigation transaction with a parameterized URL', async ({ page }) }, }); }); + +test('sends a pageload transaction named after the resolved route when a redirect is thrown on initial load', async ({ + page, +}) => { + const pageloadTxnPromise = waitForTransaction('vue-tanstack-router', async transactionEvent => { + return transactionEvent.contexts?.trace?.op === 'pageload' && transactionEvent.transaction === '/posts/$postId'; + }); + + await page.goto(`/redirect`); + + const pageloadTxn = await pageloadTxnPromise; + + expect(pageloadTxn).toMatchObject({ + contexts: { + trace: { + data: { + 'sentry.source': 'route', + 'sentry.origin': 'auto.pageload.vue.tanstack_router', + 'sentry.op': 'pageload', + 'url.path.parameter.postId': '1', + }, + op: 'pageload', + origin: 'auto.pageload.vue.tanstack_router', + }, + }, + transaction: '/posts/$postId', + transaction_info: { + source: 'route', + }, + }); +}); + +test('sends a navigation transaction when a redirect is thrown in beforeLoad', async ({ page }) => { + const pageloadTxnPromise = waitForTransaction('vue-tanstack-router', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + const navigationTxnPromise = waitForTransaction('vue-tanstack-router', async transactionEvent => { + return ( + !!transactionEvent?.transaction && + transactionEvent.transaction === '/posts/$postId' && + transactionEvent.contexts?.trace?.op === 'navigation' + ); + }); + + await page.goto(`/`); + await pageloadTxnPromise; + + await page.locator('#redirect-link').click(); + + const navigationTxn = await navigationTxnPromise; + + expect(navigationTxn).toMatchObject({ + contexts: { + trace: { + data: { + 'sentry.source': 'route', + 'sentry.origin': 'auto.navigation.vue.tanstack_router', + 'sentry.op': 'navigation', + 'url.path.parameter.postId': '1', + }, + op: 'navigation', + origin: 'auto.navigation.vue.tanstack_router', + }, + }, + transaction: '/posts/$postId', + transaction_info: { + source: 'route', + }, + }); +}); + +test('sends a navigation transaction for a normal navigation that happens after a redirect', async ({ page }) => { + const pageloadTxnPromise = waitForTransaction('vue-tanstack-router', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + await page.goto(`/`); + await pageloadTxnPromise; + + const redirectTxnPromise = waitForTransaction('vue-tanstack-router', async transactionEvent => { + return transactionEvent.contexts?.trace?.op === 'navigation' && transactionEvent.transaction === '/posts/$postId'; + }); + await page.locator('#redirect-link').click(); + await redirectTxnPromise; + + const navigationTxnPromise = waitForTransaction('vue-tanstack-router', async transactionEvent => { + return ( + transactionEvent.contexts?.trace?.op === 'navigation' && + transactionEvent.contexts?.trace?.data?.['url.path.parameter.postId'] === '2' + ); + }); + + await page.locator('#nav-link').click(); + + const navigationTxn = await navigationTxnPromise; + + expect(navigationTxn).toMatchObject({ + contexts: { + trace: { + data: { + 'sentry.source': 'route', + 'sentry.origin': 'auto.navigation.vue.tanstack_router', + 'sentry.op': 'navigation', + 'url.path.parameter.postId': '2', + }, + op: 'navigation', + origin: 'auto.navigation.vue.tanstack_router', + }, + }, + transaction: '/posts/$postId', + transaction_info: { + source: 'route', + }, + }); +}); diff --git a/packages/react/src/tanstackrouter.ts b/packages/react/src/tanstackrouter.ts index 54b24b3d8490..4473cb252512 100644 --- a/packages/react/src/tanstackrouter.ts +++ b/packages/react/src/tanstackrouter.ts @@ -40,20 +40,25 @@ export function tanstackRouterBrowserTracingIntegration( afterAllSetup(client) { browserTracingIntegrationInstance.afterAllSetup(client); + const resolveRouteMatch = (pathname: string, search: unknown): VendoredTanstackRouterRouteMatch | undefined => { + const matchedRoutes = castRouterInstance.matchRoutes(pathname, search as {}, { + preload: false, + throwOnError: false, + }); + const lastMatch = matchedRoutes[matchedRoutes.length - 1]; + // If we only match __root__, we ended up not matching any route at all, so + // we fall back to the pathname. + return lastMatch?.routeId !== '__root__' ? lastMatch : undefined; + }; + const initialWindowLocation = WINDOW.location; if (instrumentPageLoad && initialWindowLocation) { - const matchedRoutes = castRouterInstance.matchRoutes( + const routeMatch = resolveRouteMatch( initialWindowLocation.pathname, castRouterInstance.options.parseSearch(initialWindowLocation.search), - { preload: false, throwOnError: false }, ); - const lastMatch = matchedRoutes[matchedRoutes.length - 1]; - // If we only match __root__, we ended up not matching any route at all, so - // we fall back to the pathname. - const routeMatch = lastMatch?.routeId !== '__root__' ? lastMatch : undefined; - - startBrowserTracingPageLoadSpan(client, { + const pageloadSpan = startBrowserTracingPageLoadSpan(client, { name: routeMatch ? routeMatch.routeId : initialWindowLocation.pathname, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'pageload', @@ -62,62 +67,82 @@ export function tanstackRouterBrowserTracingIntegration( ...routeMatchToParamSpanAttributes(routeMatch), }, }); + + // A redirect thrown during the initial pageload leaves the span named after the pre-redirect + // route, so correct it to the resolved route once. + const unsubscribePageloadResolved = castRouterInstance.subscribe('onResolved', onResolvedArgs => { + unsubscribePageloadResolved(); + if (!pageloadSpan) { + return; + } + const resolvedMatch = resolveRouteMatch(onResolvedArgs.toLocation.pathname, onResolvedArgs.toLocation.search); + if (resolvedMatch && resolvedMatch.routeId !== routeMatch?.routeId) { + pageloadSpan.updateName(resolvedMatch.routeId); + pageloadSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); + pageloadSpan.setAttributes(routeMatchToParamSpanAttributes(resolvedMatch)); + } + }); } if (instrumentNavigation) { - // The onBeforeNavigate hook is called at the very beginning of a navigation and is only called once per navigation, even when the user is redirected - castRouterInstance.subscribe('onBeforeNavigate', onBeforeNavigateArgs => { - // onBeforeNavigate is called during pageloads. We can avoid creating navigation spans by: - // 1. Checking if there's no fromLocation (initial pageload) - // 2. Comparing the states of the to and from arguments + // Navigation is driven by `onBeforeLoad` (accurate start) + `onResolved` (final route), not + // `onBeforeNavigate`, which TanStack stops firing after any loader redirect (TanStack/router#3920). + // A redirect chain emits one `onBeforeLoad` per load but a single `onResolved`, so we start the + // span on the first `onBeforeLoad`, rename it on later ones, and clear it on `onResolved`. + let inFlightNavigationSpan: ReturnType | undefined; + + const applyRouteMatch = ( + span: NonNullable, + match: VendoredTanstackRouterRouteMatch | undefined, + fallbackName: string, + ): void => { + span.updateName(match ? match.routeId : fallbackName); + span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, match ? 'route' : 'url'); + span.setAttributes(routeMatchToParamSpanAttributes(match)); + }; + + castRouterInstance.subscribe('onBeforeLoad', onBeforeLoadArgs => { + // Skip the initial pageload (no fromLocation) and no-op reloads (same state). if ( - !onBeforeNavigateArgs.fromLocation || - onBeforeNavigateArgs.toLocation.state === onBeforeNavigateArgs.fromLocation.state + !onBeforeLoadArgs.fromLocation || + onBeforeLoadArgs.toLocation.state === onBeforeLoadArgs.fromLocation.state ) { return; } - const matchedRoutesOnBeforeNavigate = castRouterInstance.matchRoutes( - onBeforeNavigateArgs.toLocation.pathname, - onBeforeNavigateArgs.toLocation.search, - { preload: false, throwOnError: false }, + const routeMatch = resolveRouteMatch( + onBeforeLoadArgs.toLocation.pathname, + onBeforeLoadArgs.toLocation.search, ); + const fallbackName = WINDOW.location.pathname; - const onBeforeNavigateLastMatch = matchedRoutesOnBeforeNavigate[matchedRoutesOnBeforeNavigate.length - 1]; - const onBeforeNavigateRouteMatch = - onBeforeNavigateLastMatch?.routeId !== '__root__' ? onBeforeNavigateLastMatch : undefined; + if (inFlightNavigationSpan) { + // Redirect continuation within the same navigation: keep the span, update the target. + applyRouteMatch(inFlightNavigationSpan, routeMatch, fallbackName); + return; + } - const navigationLocation = WINDOW.location; - const navigationSpan = startBrowserTracingNavigationSpan(client, { - name: onBeforeNavigateRouteMatch ? onBeforeNavigateRouteMatch.routeId : navigationLocation.pathname, + inFlightNavigationSpan = startBrowserTracingNavigationSpan(client, { + name: routeMatch ? routeMatch.routeId : fallbackName, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.tanstack_router', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: onBeforeNavigateRouteMatch ? 'route' : 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: routeMatch ? 'route' : 'url', + ...routeMatchToParamSpanAttributes(routeMatch), }, }); + }); - // In case the user is redirected during navigation we want to update the span with the right value. - const unsubscribeOnResolved = castRouterInstance.subscribe('onResolved', onResolvedArgs => { - unsubscribeOnResolved(); - if (navigationSpan) { - const matchedRoutesOnResolved = castRouterInstance.matchRoutes( - onResolvedArgs.toLocation.pathname, - onResolvedArgs.toLocation.search, - { preload: false, throwOnError: false }, - ); - - const onResolvedLastMatch = matchedRoutesOnResolved[matchedRoutesOnResolved.length - 1]; - const onResolvedRouteMatch = - onResolvedLastMatch?.routeId !== '__root__' ? onResolvedLastMatch : undefined; - - if (onResolvedRouteMatch) { - navigationSpan.updateName(onResolvedRouteMatch.routeId); - navigationSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); - navigationSpan.setAttributes(routeMatchToParamSpanAttributes(onResolvedRouteMatch)); - } - } - }); + castRouterInstance.subscribe('onResolved', onResolvedArgs => { + const span = inFlightNavigationSpan; + inFlightNavigationSpan = undefined; + if (!span) { + return; + } + const resolvedMatch = resolveRouteMatch(onResolvedArgs.toLocation.pathname, onResolvedArgs.toLocation.search); + if (resolvedMatch) { + applyRouteMatch(span, resolvedMatch, WINDOW.location.pathname); + } }); } }, diff --git a/packages/react/src/vendor/tanstackrouter-types.ts b/packages/react/src/vendor/tanstackrouter-types.ts index d130d0c5f93b..aa5e70821c46 100644 --- a/packages/react/src/vendor/tanstackrouter-types.ts +++ b/packages/react/src/vendor/tanstackrouter-types.ts @@ -22,7 +22,7 @@ export interface VendoredTanstackRouter { }, ) => Array; subscribe( - eventType: 'onResolved' | 'onBeforeNavigate', + eventType: 'onResolved' | 'onBeforeNavigate' | 'onBeforeLoad', callback: (stateUpdate: { toLocation: VendoredTanstackRouterLocation; fromLocation?: VendoredTanstackRouterLocation; diff --git a/packages/solid/src/tanstackrouter.ts b/packages/solid/src/tanstackrouter.ts index c589430eb615..90ae70f5d187 100644 --- a/packages/solid/src/tanstackrouter.ts +++ b/packages/solid/src/tanstackrouter.ts @@ -39,20 +39,22 @@ export function tanstackRouterBrowserTracingIntegration( afterAllSetup(client) { browserTracingIntegrationInstance.afterAllSetup(client); + const resolveRouteMatch = (pathname: string, search: Record): RouteMatch | undefined => { + const matchedRoutes = router.matchRoutes(pathname, search, { preload: false, throwOnError: false }); + const lastMatch = matchedRoutes[matchedRoutes.length - 1]; + // If we only match __root__, we ended up not matching any route at all, so + // we fall back to the pathname. + return lastMatch?.routeId !== '__root__' ? lastMatch : undefined; + }; + const initialWindowLocation = WINDOW.location; if (instrumentPageLoad && initialWindowLocation) { - const matchedRoutes = router.matchRoutes( + const routeMatch = resolveRouteMatch( initialWindowLocation.pathname, router.options.parseSearch(initialWindowLocation.search), - { preload: false, throwOnError: false }, ); - const lastMatch = matchedRoutes[matchedRoutes.length - 1]; - // If we only match __root__, we ended up not matching any route at all, so - // we fall back to the pathname. - const routeMatch = lastMatch?.routeId !== '__root__' ? lastMatch : undefined; - - startBrowserTracingPageLoadSpan(client, { + const pageloadSpan = startBrowserTracingPageLoadSpan(client, { name: routeMatch ? routeMatch.routeId : initialWindowLocation.pathname, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'pageload', @@ -61,62 +63,82 @@ export function tanstackRouterBrowserTracingIntegration( ...routeMatchToParamSpanAttributes(routeMatch), }, }); + + // A redirect thrown during the initial pageload leaves the span named after the pre-redirect + // route, so correct it to the resolved route once. + const unsubscribePageloadResolved = router.subscribe('onResolved', onResolvedArgs => { + unsubscribePageloadResolved(); + if (!pageloadSpan) { + return; + } + const resolvedMatch = resolveRouteMatch(onResolvedArgs.toLocation.pathname, onResolvedArgs.toLocation.search); + if (resolvedMatch && resolvedMatch.routeId !== routeMatch?.routeId) { + pageloadSpan.updateName(resolvedMatch.routeId); + pageloadSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); + pageloadSpan.setAttributes(routeMatchToParamSpanAttributes(resolvedMatch)); + } + }); } if (instrumentNavigation) { - // The onBeforeNavigate hook is called at the very beginning of a navigation and is only called once per navigation, even when the user is redirected - router.subscribe('onBeforeNavigate', onBeforeNavigateArgs => { - // onBeforeNavigate is called during pageloads. We can avoid creating navigation spans by: - // 1. Checking if there's no fromLocation (initial pageload) - // 2. Comparing the states of the to and from arguments + // Navigation is driven by `onBeforeLoad` (accurate start) + `onResolved` (final route), not + // `onBeforeNavigate`, which TanStack stops firing after any loader redirect (TanStack/router#3920). + // A redirect chain emits one `onBeforeLoad` per load but a single `onResolved`, so we start the + // span on the first `onBeforeLoad`, rename it on later ones, and clear it on `onResolved`. + let inFlightNavigationSpan: ReturnType | undefined; + + const applyRouteMatch = ( + span: NonNullable, + match: RouteMatch | undefined, + fallbackName: string, + ): void => { + span.updateName(match ? match.routeId : fallbackName); + span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, match ? 'route' : 'url'); + span.setAttributes(routeMatchToParamSpanAttributes(match)); + }; + + router.subscribe('onBeforeLoad', onBeforeLoadArgs => { + // Skip the initial pageload (no fromLocation) and no-op reloads (same state). if ( - !onBeforeNavigateArgs.fromLocation || - onBeforeNavigateArgs.toLocation.state === onBeforeNavigateArgs.fromLocation.state + !onBeforeLoadArgs.fromLocation || + onBeforeLoadArgs.toLocation.state === onBeforeLoadArgs.fromLocation.state ) { return; } - const matchedRoutesOnBeforeNavigate = router.matchRoutes( - onBeforeNavigateArgs.toLocation.pathname, - onBeforeNavigateArgs.toLocation.search, - { preload: false, throwOnError: false }, + const routeMatch = resolveRouteMatch( + onBeforeLoadArgs.toLocation.pathname, + onBeforeLoadArgs.toLocation.search, ); + const fallbackName = WINDOW.location.pathname; - const onBeforeNavigateLastMatch = matchedRoutesOnBeforeNavigate[matchedRoutesOnBeforeNavigate.length - 1]; - const onBeforeNavigateRouteMatch = - onBeforeNavigateLastMatch?.routeId !== '__root__' ? onBeforeNavigateLastMatch : undefined; + if (inFlightNavigationSpan) { + // Redirect continuation within the same navigation: keep the span, update the target. + applyRouteMatch(inFlightNavigationSpan, routeMatch, fallbackName); + return; + } - const navigationLocation = WINDOW.location; - const navigationSpan = startBrowserTracingNavigationSpan(client, { - name: onBeforeNavigateRouteMatch ? onBeforeNavigateRouteMatch.routeId : navigationLocation.pathname, + inFlightNavigationSpan = startBrowserTracingNavigationSpan(client, { + name: routeMatch ? routeMatch.routeId : fallbackName, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.solid.tanstack_router', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: onBeforeNavigateRouteMatch ? 'route' : 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: routeMatch ? 'route' : 'url', + ...routeMatchToParamSpanAttributes(routeMatch), }, }); + }); - // In case the user is redirected during navigation we want to update the span with the right value. - const unsubscribeOnResolved = router.subscribe('onResolved', onResolvedArgs => { - unsubscribeOnResolved(); - if (navigationSpan) { - const matchedRoutesOnResolved = router.matchRoutes( - onResolvedArgs.toLocation.pathname, - onResolvedArgs.toLocation.search, - { preload: false, throwOnError: false }, - ); - - const onResolvedLastMatch = matchedRoutesOnResolved[matchedRoutesOnResolved.length - 1]; - const onResolvedRouteMatch = - onResolvedLastMatch?.routeId !== '__root__' ? onResolvedLastMatch : undefined; - - if (onResolvedRouteMatch) { - navigationSpan.updateName(onResolvedRouteMatch.routeId); - navigationSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); - navigationSpan.setAttributes(routeMatchToParamSpanAttributes(onResolvedRouteMatch)); - } - } - }); + router.subscribe('onResolved', onResolvedArgs => { + const span = inFlightNavigationSpan; + inFlightNavigationSpan = undefined; + if (!span) { + return; + } + const resolvedMatch = resolveRouteMatch(onResolvedArgs.toLocation.pathname, onResolvedArgs.toLocation.search); + if (resolvedMatch) { + applyRouteMatch(span, resolvedMatch, WINDOW.location.pathname); + } }); } }, diff --git a/packages/vue/src/tanstackrouter.ts b/packages/vue/src/tanstackrouter.ts index d46f2a8c40c6..6b2700db0bb2 100644 --- a/packages/vue/src/tanstackrouter.ts +++ b/packages/vue/src/tanstackrouter.ts @@ -14,6 +14,11 @@ import type { AnyRouter } from '@tanstack/vue-router'; type RouteMatch = ReturnType[number]; +interface TanstackRouterSubscribeArgs { + toLocation: { pathname: string; search: Record; state: unknown }; + fromLocation?: { state: unknown }; +} + /** * A custom browser tracing integration for TanStack Router. * @@ -39,20 +44,23 @@ export function tanstackRouterBrowserTracingIntegration( afterAllSetup(client) { browserTracingIntegrationInstance.afterAllSetup(client); + const resolveRouteMatch = (pathname: string, search: unknown): RouteMatch | undefined => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const matchedRoutes = router.matchRoutes(pathname, search as any, { preload: false, throwOnError: false }); + const lastMatch = matchedRoutes[matchedRoutes.length - 1]; + // If we only match __root__, we ended up not matching any route at all, so + // we fall back to the pathname. + return lastMatch?.routeId !== '__root__' ? lastMatch : undefined; + }; + const initialWindowLocation = WINDOW.location; if (instrumentPageLoad && initialWindowLocation) { - const matchedRoutes = router.matchRoutes( + const routeMatch = resolveRouteMatch( initialWindowLocation.pathname, router.options.parseSearch(initialWindowLocation.search), - { preload: false, throwOnError: false }, ); - const lastMatch = matchedRoutes[matchedRoutes.length - 1]; - // If we only match __root__, we ended up not matching any route at all, so - // we fall back to the pathname. - const routeMatch = lastMatch?.routeId !== '__root__' ? lastMatch : undefined; - - startBrowserTracingPageLoadSpan(client, { + const pageloadSpan = startBrowserTracingPageLoadSpan(client, { name: routeMatch ? routeMatch.routeId : initialWindowLocation.pathname, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'pageload', @@ -61,75 +69,83 @@ export function tanstackRouterBrowserTracingIntegration( ...routeMatchToParamSpanAttributes(routeMatch), }, }); + + // A redirect thrown during the initial pageload leaves the span named after the pre-redirect + // route, so correct it to the resolved route once. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const unsubscribePageloadResolved = router.subscribe('onResolved', (onResolvedArgs: any) => { + unsubscribePageloadResolved(); + if (!pageloadSpan) { + return; + } + const { toLocation } = onResolvedArgs as TanstackRouterSubscribeArgs; + const resolvedMatch = resolveRouteMatch(toLocation.pathname, toLocation.search); + if (resolvedMatch && resolvedMatch.routeId !== routeMatch?.routeId) { + pageloadSpan.updateName(resolvedMatch.routeId); + pageloadSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); + pageloadSpan.setAttributes(routeMatchToParamSpanAttributes(resolvedMatch)); + } + }); } if (instrumentNavigation) { - // The onBeforeNavigate hook is called at the very beginning of a navigation and is only called once per navigation, even when the user is redirected + // Navigation is driven by `onBeforeLoad` (accurate start) + `onResolved` (final route), not + // `onBeforeNavigate`, which TanStack stops firing after any loader redirect (TanStack/router#3920). + // A redirect chain emits one `onBeforeLoad` per load but a single `onResolved`, so we start the + // span on the first `onBeforeLoad`, rename it on later ones, and clear it on `onResolved`. + let inFlightNavigationSpan: ReturnType | undefined; + + const applyRouteMatch = ( + span: NonNullable, + match: RouteMatch | undefined, + fallbackName: string, + ): void => { + span.updateName(match ? match.routeId : fallbackName); + span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, match ? 'route' : 'url'); + span.setAttributes(routeMatchToParamSpanAttributes(match)); + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any - router.subscribe('onBeforeNavigate', (onBeforeNavigateArgs: any) => { - // onBeforeNavigate is called during pageloads. We can avoid creating navigation spans by: - // 1. Checking if there's no fromLocation (initial pageload) - // 2. Comparing the states of the to and from arguments - - if ( - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - !onBeforeNavigateArgs.fromLocation || - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - onBeforeNavigateArgs.toLocation.state === onBeforeNavigateArgs.fromLocation.state - ) { + router.subscribe('onBeforeLoad', (onBeforeLoadArgs: any) => { + const { toLocation, fromLocation } = onBeforeLoadArgs as TanstackRouterSubscribeArgs; + // Skip the initial pageload (no fromLocation) and no-op reloads (same state). + if (!fromLocation || toLocation.state === fromLocation.state) { + return; + } + + const routeMatch = resolveRouteMatch(toLocation.pathname, toLocation.search); + // In SSR/non-browser contexts, WINDOW.location may be undefined, so fall back to the router's location. + const fallbackName = WINDOW.location?.pathname || toLocation.pathname; + + if (inFlightNavigationSpan) { + // Redirect continuation within the same navigation: keep the span, update the target. + applyRouteMatch(inFlightNavigationSpan, routeMatch, fallbackName); return; } - const onResolvedMatchedRoutes = router.matchRoutes( - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - onBeforeNavigateArgs.toLocation.pathname, - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - onBeforeNavigateArgs.toLocation.search, - { preload: false, throwOnError: false }, - ); - - const onBeforeNavigateLastMatch = onResolvedMatchedRoutes[onResolvedMatchedRoutes.length - 1]; - const onBeforeNavigateRouteMatch = - onBeforeNavigateLastMatch?.routeId !== '__root__' ? onBeforeNavigateLastMatch : undefined; - - const navigationLocation = WINDOW.location; - const navigationSpan = startBrowserTracingNavigationSpan(client, { - name: onBeforeNavigateRouteMatch - ? onBeforeNavigateRouteMatch.routeId - : // In SSR/non-browser contexts, WINDOW.location may be undefined, so fall back to the router's location - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - navigationLocation?.pathname || onBeforeNavigateArgs.toLocation.pathname, + inFlightNavigationSpan = startBrowserTracingNavigationSpan(client, { + name: routeMatch ? routeMatch.routeId : fallbackName, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.vue.tanstack_router', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: onBeforeNavigateRouteMatch ? 'route' : 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: routeMatch ? 'route' : 'url', + ...routeMatchToParamSpanAttributes(routeMatch), }, }); + }); - // In case the user is redirected during navigation we want to update the span with the right value. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const unsubscribeOnResolved = router.subscribe('onResolved', (onResolvedArgs: any) => { - unsubscribeOnResolved(); - if (navigationSpan) { - const onResolvedMatchedRoutes = router.matchRoutes( - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - onResolvedArgs.toLocation.pathname, - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - onResolvedArgs.toLocation.search, - { preload: false, throwOnError: false }, - ); - - const onResolvedLastMatch = onResolvedMatchedRoutes[onResolvedMatchedRoutes.length - 1]; - const onResolvedRouteMatch = - onResolvedLastMatch?.routeId !== '__root__' ? onResolvedLastMatch : undefined; - - if (onResolvedRouteMatch) { - navigationSpan.updateName(onResolvedRouteMatch.routeId); - navigationSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); - navigationSpan.setAttributes(routeMatchToParamSpanAttributes(onResolvedRouteMatch)); - } - } - }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + router.subscribe('onResolved', (onResolvedArgs: any) => { + const span = inFlightNavigationSpan; + inFlightNavigationSpan = undefined; + if (!span) { + return; + } + const { toLocation } = onResolvedArgs as TanstackRouterSubscribeArgs; + const resolvedMatch = resolveRouteMatch(toLocation.pathname, toLocation.search); + if (resolvedMatch) { + applyRouteMatch(span, resolvedMatch, WINDOW.location?.pathname || toLocation.pathname); + } }); } }, diff --git a/packages/vue/test/tanstackrouter.test.ts b/packages/vue/test/tanstackrouter.test.ts index 7b23a4241799..d7ecb3c8ac61 100644 --- a/packages/vue/test/tanstackrouter.test.ts +++ b/packages/vue/test/tanstackrouter.test.ts @@ -118,24 +118,34 @@ describe('tanstackRouterBrowserTracingIntegration', () => { expect(startBrowserTracingPageLoadSpanSpy).not.toHaveBeenCalled(); }); + const getSubscribeCallback = (eventType: string): ((...args: any[]) => void) => + (mockRouter.subscribe as any).mock.calls.find( + (call: [string, (...args: any[]) => void]) => call[0] === eventType, + )?.[1]; + it('subscribes to router navigation events when instrumentNavigation is true', () => { const integration = tanstackRouterBrowserTracingIntegration(mockRouter, { instrumentNavigation: true, + instrumentPageLoad: false, }); integration.afterAllSetup(mockClient as any); - expect(mockRouter.subscribe).toHaveBeenCalledWith('onBeforeNavigate', expect.any(Function)); + // Navigation is driven by `onBeforeLoad` + `onResolved` rather than `onBeforeNavigate`, which + // TanStack suppresses after a redirect (upstream TanStack/router#3920). + expect(mockRouter.subscribe).toHaveBeenCalledWith('onBeforeLoad', expect.any(Function)); + expect(mockRouter.subscribe).toHaveBeenCalledWith('onResolved', expect.any(Function)); + expect(mockRouter.subscribe).not.toHaveBeenCalledWith('onBeforeNavigate', expect.any(Function)); }); it('does not subscribe to navigation events when instrumentNavigation is false', () => { const integration = tanstackRouterBrowserTracingIntegration(mockRouter, { instrumentNavigation: false, + instrumentPageLoad: false, }); integration.afterAllSetup(mockClient as any); - // Only pageload should have been called expect(mockRouter.subscribe).not.toHaveBeenCalled(); }); @@ -147,15 +157,11 @@ describe('tanstackRouterBrowserTracingIntegration', () => { integration.afterAllSetup(mockClient as any); - // Get the onBeforeNavigate callback - const onBeforeNavigateCallback = (mockRouter.subscribe as any).mock.calls.find( - (call: [string, (...args: any[]) => void]) => call[0] === 'onBeforeNavigate', - )?.[1]; - - expect(onBeforeNavigateCallback).toBeDefined(); + const onBeforeLoadCallback = getSubscribeCallback('onBeforeLoad'); + expect(onBeforeLoadCallback).toBeDefined(); // Simulate navigation - onBeforeNavigateCallback({ + onBeforeLoadCallback({ toLocation: { pathname: '/test/456', search: {}, @@ -178,6 +184,28 @@ describe('tanstackRouterBrowserTracingIntegration', () => { }); }); + it('skips navigation span creation on the initial pageload (no fromLocation)', () => { + const integration = tanstackRouterBrowserTracingIntegration(mockRouter, { + instrumentNavigation: true, + instrumentPageLoad: false, + }); + + integration.afterAllSetup(mockClient as any); + + const onBeforeLoadCallback = getSubscribeCallback('onBeforeLoad'); + + // The initial pageload emits `onBeforeLoad` without a `fromLocation`. + onBeforeLoadCallback({ + toLocation: { + pathname: '/test/456', + search: {}, + state: 'state-1', + }, + }); + + expect(startBrowserTracingNavigationSpanSpy).not.toHaveBeenCalled(); + }); + it('skips navigation span creation when state is the same', () => { const integration = tanstackRouterBrowserTracingIntegration(mockRouter, { instrumentNavigation: true, @@ -186,12 +214,10 @@ describe('tanstackRouterBrowserTracingIntegration', () => { integration.afterAllSetup(mockClient as any); - const onBeforeNavigateCallback = (mockRouter.subscribe as any).mock.calls.find( - (call: [string, (...args: any[]) => void]) => call[0] === 'onBeforeNavigate', - )?.[1]; + const onBeforeLoadCallback = getSubscribeCallback('onBeforeLoad'); - // Simulate navigation with same state (e.g., during pageload) - onBeforeNavigateCallback({ + // Simulate a no-op reload (same history state) + onBeforeLoadCallback({ toLocation: { pathname: '/test/456', search: {}, @@ -215,12 +241,10 @@ describe('tanstackRouterBrowserTracingIntegration', () => { integration.afterAllSetup(mockClient as any); - const onBeforeNavigateCallback = (mockRouter.subscribe as any).mock.calls.find( - (call: [string, (...args: any[]) => void]) => call[0] === 'onBeforeNavigate', - )?.[1]; + const onBeforeLoadCallback = getSubscribeCallback('onBeforeLoad'); // Simulate navigation - onBeforeNavigateCallback({ + onBeforeLoadCallback({ toLocation: { pathname: '/test/456', search: {}, @@ -233,11 +257,7 @@ describe('tanstackRouterBrowserTracingIntegration', () => { }, }); - // Get the onResolved callback that was registered - const onResolvedCallback = (mockRouter.subscribe as any).mock.calls.find( - (call: [string, (...args: any[]) => void]) => call[0] === 'onResolved', - )?.[1]; - + const onResolvedCallback = getSubscribeCallback('onResolved'); expect(onResolvedCallback).toBeDefined(); // Mock different matched routes for the redirect @@ -266,4 +286,31 @@ describe('tanstackRouterBrowserTracingIntegration', () => { 'params.id': '789', }); }); + + it('reuses the in-flight span across a redirect chain instead of starting a second span', () => { + const integration = tanstackRouterBrowserTracingIntegration(mockRouter, { + instrumentNavigation: true, + instrumentPageLoad: false, + }); + + integration.afterAllSetup(mockClient as any); + + const onBeforeLoadCallback = getSubscribeCallback('onBeforeLoad'); + + // First load of the navigation (the route that throws the redirect) + onBeforeLoadCallback({ + toLocation: { pathname: '/test/456', search: {}, state: 'state-1' }, + fromLocation: { pathname: '/test/123', search: {}, state: 'state-0' }, + }); + + // Redirect continuation: a second load within the same navigation + onBeforeLoadCallback({ + toLocation: { pathname: '/test/789', search: {}, state: 'state-2' }, + fromLocation: { pathname: '/test/456', search: {}, state: 'state-1' }, + }); + + // Only a single navigation span is started; the second load renames it. + expect(startBrowserTracingNavigationSpanSpy).toHaveBeenCalledTimes(1); + expect(mockNavigationSpan.updateName).toHaveBeenCalled(); + }); });