Skip to content

Commit bcac1db

Browse files
mydeaclaude
andauthored
fix(react-router): Do not re-write origin on router state changes (#21056)
## Summary Closes #20784. Closes #20772. The router state-change subscription in `hydratedRouter.ts` was unconditionally re-writing `sentry.origin` on whatever root span was active at the time. Two side effects: 1. **The flaky CI failure** — for static routes (where the parameterized path equals the URL pathname), the `pathname === rootSpanName` guard didn't filter out the still-active pageload span, so the pageload got tagged with `auto.navigation.react_router` and produced transactions with `op=pageload` but `origin=auto.navigation.react_router`. Dynamic routes hid the bug because the parameterized name no longer equalled the pathname, short-circuiting the block. 2. **An instrumentation-API regression** — when the navigation span was created via `createClientInstrumentation`, its origin is `auto.navigation.react_router.instrumentation_api`. The subscribe callback's re-write was stripping the `.instrumentation_api` suffix. The origin is correctly set once at span creation (in `maybeCreateNavigationTransaction` / instrumentation API) or once in `trySubscribe` for the pageload. The subscribe callback only needs to update `name` + `source`. Drop the origin write entirely. Added a regression test asserting the pageload origin is preserved when the subscribe callback fires while the pageload is still the active root, and tightened the existing navigation assertion to check the attribute payload rather than just that `setAttributes` was called. Also corrected the `react-router-7-framework-instrumentation` navigation e2e tests, which were asserting the legacy origin only because of the same bug — React Router 7 actually invokes the instrumentation API navigate hook on the client, so the navigation spans genuinely carry the `.instrumentation_api` origin (with the legacy subscribe callback still doing parameterization). --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 97feb2c commit bcac1db

3 files changed

Lines changed: 62 additions & 31 deletions

File tree

dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/navigation.client.test.ts

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,22 @@ import { expect, test } from '@playwright/test';
22
import { waitForTransaction } from '@sentry-internal/test-utils';
33
import { APP_NAME } from '../constants';
44

5-
// Known React Router limitation: HydratedRouter doesn't invoke instrumentation API
6-
// hooks on the client-side in Framework Mode. Server-side instrumentation works.
5+
// When `useInstrumentationAPI: true` is set and the instrumentations array is passed to
6+
// HydratedRouter, React Router invokes the navigate hook on the client and the navigation span
7+
// is created via the instrumentation API (origin: `auto.navigation.react_router.instrumentation_api`).
8+
// The legacy `instrumentHydratedRouter()` subscribe callback still runs and updates the span
9+
// name to its parameterized form (so `sentry.source` ends up as `route`).
10+
//
711
// See: https://github.com/remix-run/react-router/discussions/13749
8-
// The legacy HydratedRouter instrumentation provides fallback navigation tracking.
912

10-
test.describe('client - navigation fallback to legacy instrumentation', () => {
11-
test('should send navigation transaction via legacy HydratedRouter instrumentation', async ({ page }) => {
13+
test.describe('client - hybrid navigation (instrumentation API span + legacy parameterization)', () => {
14+
test('should create navigation span via instrumentation API and parameterize via legacy subscribe', async ({
15+
page,
16+
}) => {
1217
// First load the performance page
1318
await page.goto(`/performance`);
1419
await page.waitForTimeout(1000);
1520

16-
// Wait for the navigation transaction (from legacy instrumentation)
1721
const navigationTxPromise = waitForTransaction(APP_NAME, async transactionEvent => {
1822
return (
1923
transactionEvent.transaction === '/performance/ssr' && transactionEvent.contexts?.trace?.op === 'navigation'
@@ -25,13 +29,17 @@ test.describe('client - navigation fallback to legacy instrumentation', () => {
2529

2630
const transaction = await navigationTxPromise;
2731

28-
// Navigation should work via legacy HydratedRouter instrumentation
29-
// (not instrumentation_api since that doesn't work in Framework Mode)
3032
expect(transaction).toMatchObject({
3133
contexts: {
3234
trace: {
3335
op: 'navigation',
34-
origin: 'auto.navigation.react_router', // Legacy origin, not instrumentation_api
36+
origin: 'auto.navigation.react_router.instrumentation_api',
37+
data: {
38+
'sentry.source': 'route',
39+
'sentry.op': 'navigation',
40+
'sentry.origin': 'auto.navigation.react_router.instrumentation_api',
41+
'navigation.type': 'router.navigate',
42+
},
3543
},
3644
},
3745
transaction: '/performance/ssr',
@@ -58,7 +66,7 @@ test.describe('client - navigation fallback to legacy instrumentation', () => {
5866
contexts: {
5967
trace: {
6068
op: 'navigation',
61-
origin: 'auto.navigation.react_router',
69+
origin: 'auto.navigation.react_router.instrumentation_api',
6270
data: {
6371
'sentry.source': 'route',
6472
},
@@ -89,7 +97,7 @@ test.describe('client - navigation fallback to legacy instrumentation', () => {
8997
contexts: {
9098
trace: {
9199
op: 'navigation',
92-
origin: 'auto.navigation.react_router',
100+
origin: 'auto.navigation.react_router.instrumentation_api',
93101
},
94102
},
95103
transaction: '/performance/ssr',
@@ -109,7 +117,7 @@ test.describe('client - navigation fallback to legacy instrumentation', () => {
109117
contexts: {
110118
trace: {
111119
op: 'navigation',
112-
origin: 'auto.navigation.react_router',
120+
origin: 'auto.navigation.react_router.instrumentation_api',
113121
},
114122
},
115123
transaction: '/performance',

packages/react-router/src/client/hydratedRouter.ts

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -66,28 +66,27 @@ export function instrumentHydratedRouter(): void {
6666
};
6767
}
6868

69-
// Subscribe to router state changes to update navigation transactions with parameterized routes
69+
// Subscribe to router state changes to update navigation transactions (and any pageload
70+
// whose route info only became available after `trySubscribe`, e.g. lazy routes) with the
71+
// parameterized route.
7072
router.subscribe(newState => {
71-
const navigationSpan = getActiveRootSpan();
73+
const rootSpan = getActiveRootSpan();
7274

73-
if (!navigationSpan) {
75+
if (!rootSpan) {
7476
return;
7577
}
7678

77-
const navigationSpanName = spanToJSON(navigationSpan).description;
78-
const parameterizedNavRoute = getParameterizedRoute(newState);
79+
const rootSpanName = spanToJSON(rootSpan).description;
80+
const parameterizedRoute = getParameterizedRoute(newState);
7981

8082
if (
81-
navigationSpanName &&
83+
rootSpanName &&
8284
newState.navigation.state === 'idle' && // navigation has completed
83-
// this event is for the currently active navigation
84-
normalizePathname(newState.location.pathname) === normalizePathname(navigationSpanName)
85+
// this event is for the currently active root span
86+
normalizePathname(newState.location.pathname) === normalizePathname(rootSpanName)
8587
) {
86-
navigationSpan.updateName(parameterizedNavRoute);
87-
navigationSpan.setAttributes({
88-
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route',
89-
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react_router',
90-
});
88+
rootSpan.updateName(parameterizedRoute);
89+
rootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route');
9190
}
9291
});
9392
return true;

packages/react-router/test/client/hydratedRouter.test.ts

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import * as browser from '@sentry/browser';
22
import * as core from '@sentry/core';
33
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
44
import { instrumentHydratedRouter } from '../../src/client/hydratedRouter';
5+
import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/core';
56

67
vi.mock('@sentry/core', async () => {
78
const actual = await vi.importActual<any>('@sentry/core');
@@ -42,14 +43,15 @@ describe('instrumentHydratedRouter', () => {
4243
};
4344
(globalThis as any).__reactRouterDataRouter = mockRouter;
4445

45-
mockPageloadSpan = { updateName: vi.fn(), setAttributes: vi.fn() };
46-
mockNavigationSpan = { updateName: vi.fn(), setAttributes: vi.fn() };
46+
mockPageloadSpan = { updateName: vi.fn(), setAttributes: vi.fn(), setAttribute: vi.fn() };
47+
mockNavigationSpan = { updateName: vi.fn(), setAttributes: vi.fn(), setAttribute: vi.fn() };
4748

4849
(core.getActiveSpan as any).mockReturnValue(mockPageloadSpan);
4950
(core.getRootSpan as any).mockImplementation((span: any) => span);
50-
(core.spanToJSON as any).mockImplementation((_span: any) => ({
51+
(core.spanToJSON as any).mockImplementation((span: any) => ({
5152
description: '/foo/bar',
52-
op: 'pageload',
53+
// Distinguish so the subscribe callback can branch on op (pageload vs. navigation).
54+
op: span === mockNavigationSpan ? 'navigation' : 'pageload',
5355
}));
5456
(core.getClient as any).mockReturnValue({});
5557
(browser.startBrowserTracingNavigationSpan as any).mockReturnValue(mockNavigationSpan);
@@ -91,8 +93,30 @@ describe('instrumentHydratedRouter', () => {
9193
// After navigation, the active span should be the navigation span
9294
(core.getActiveSpan as any).mockReturnValue(mockNavigationSpan);
9395
callback(newState);
94-
expect(mockNavigationSpan.updateName).toHaveBeenCalled();
95-
expect(mockNavigationSpan.setAttributes).toHaveBeenCalled();
96+
expect(mockNavigationSpan.updateName).toHaveBeenCalledWith('/foo/:id');
97+
expect(mockNavigationSpan.setAttribute).toHaveBeenCalledWith(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route');
98+
});
99+
100+
it('does not overwrite pageload origin when the pageload is still active', () => {
101+
// Regression test for #20784: a static-route pageload (where pathname == rootSpanName) was
102+
// being tagged with `origin: auto.navigation.react_router` because the subscribe callback
103+
// re-wrote origin unconditionally, even when the active root span was still the pageload.
104+
instrumentHydratedRouter();
105+
const callback = mockRouter.subscribe.mock.calls[0][0];
106+
const newState = {
107+
location: { pathname: '/foo/bar' },
108+
matches: [{ route: { path: '/foo/:id' } }],
109+
navigation: { state: 'idle' },
110+
};
111+
// Active root span is still the pageload (no navigation has happened yet).
112+
(core.getActiveSpan as any).mockReturnValue(mockPageloadSpan);
113+
callback(newState);
114+
// Subscribe callback must not touch the navigation span, and must not write `origin` on the
115+
// pageload — only `source` via the single-attribute setter. The pageload origin was already
116+
// set by trySubscribe.
117+
expect(mockNavigationSpan.setAttribute).not.toHaveBeenCalled();
118+
expect(mockNavigationSpan.setAttributes).not.toHaveBeenCalled();
119+
expect(mockPageloadSpan.setAttribute).toHaveBeenLastCalledWith(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route');
96120
});
97121

98122
it('does not update navigation transaction on state change to loading', () => {

0 commit comments

Comments
 (0)