Skip to content

Commit 4a7c056

Browse files
authored
fix(react-router): Set correct transaction name when navigating with object argument (#19590)
When navigate() or <Link> is called with an object `to` prop (e.g. { pathname: '/items/123' }), the transaction name currently is set to `[object Object]`. This adds a `resolveNavigateArg` helper that extracts the pathname from object arguments: - If we get a string or number we use it directly as transaction name - If we get an object with a pathname property we use that - If we get an object without a pathname property we stay on the current page so we try to grab the current path as transaction name, fallback to `/` Closes #19580
1 parent 003e894 commit 4a7c056

File tree

7 files changed

+134
-3
lines changed

7 files changed

+134
-3
lines changed

dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/performance/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ export default function PerformancePage() {
77
<nav>
88
<Link to="/performance/ssr">SSR Page</Link>
99
<Link to="/performance/with/sentry">With Param Page</Link>
10+
<Link to={{ pathname: '/performance/with/object-nav', search: '?foo=bar' }}>Object Navigate</Link>
11+
<Link to={{ search: '?query=test' }}>Search Only Navigate</Link>
1012
<Link to="/performance/server-loader">Server Loader</Link>
1113
</nav>
1214
</div>

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

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,56 @@ test.describe('client - navigation performance', () => {
5454
});
5555
});
5656

57+
test('should create navigation transaction when navigating with object `to` prop', async ({ page }) => {
58+
const txPromise = waitForTransaction(APP_NAME, async transactionEvent => {
59+
return transactionEvent.transaction === '/performance/with/:param';
60+
});
61+
62+
await page.goto(`/performance`); // pageload
63+
await page.waitForTimeout(1000); // give it a sec before navigation
64+
await page.getByRole('link', { name: 'Object Navigate' }).click(); // navigation with object to
65+
66+
const transaction = await txPromise;
67+
68+
expect(transaction).toMatchObject({
69+
contexts: {
70+
trace: {
71+
op: 'navigation',
72+
origin: 'auto.navigation.react_router',
73+
data: {
74+
'sentry.source': 'route',
75+
},
76+
},
77+
},
78+
transaction: '/performance/with/:param',
79+
type: 'transaction',
80+
transaction_info: { source: 'route' },
81+
});
82+
});
83+
84+
test('should create navigation transaction when navigating with search-only object `to` prop', async ({ page }) => {
85+
const txPromise = waitForTransaction(APP_NAME, async transactionEvent => {
86+
return transactionEvent.transaction === '/performance' && transactionEvent.contexts?.trace?.op === 'navigation';
87+
});
88+
89+
await page.goto(`/performance`); // pageload
90+
await page.waitForTimeout(1000); // give it a sec before navigation
91+
await page.getByRole('link', { name: 'Search Only Navigate' }).click(); // navigation with search-only object to
92+
93+
const transaction = await txPromise;
94+
95+
expect(transaction).toMatchObject({
96+
contexts: {
97+
trace: {
98+
op: 'navigation',
99+
origin: 'auto.navigation.react_router',
100+
},
101+
},
102+
transaction: '/performance',
103+
type: 'transaction',
104+
});
105+
});
106+
57107
test('should update navigation transaction for dynamic routes', async ({ page }) => {
58108
const txPromise = waitForTransaction(APP_NAME, async transactionEvent => {
59109
return transactionEvent.transaction === '/performance/with/:param';

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
import { DEBUG_BUILD } from '../common/debug-build';
1414
import type { ClientInstrumentation, InstrumentableRoute, InstrumentableRouter } from '../common/types';
1515
import { captureInstrumentationError, getPathFromRequest, getPattern, normalizeRoutePath } from '../common/utils';
16+
import { resolveNavigateArg } from './utils';
1617

1718
const WINDOW = GLOBAL_OBJ as typeof GLOBAL_OBJ & Window;
1819

@@ -164,9 +165,9 @@ export function createSentryClientInstrumentation(
164165
return;
165166
}
166167

167-
// Handle string navigations (e.g., navigate('/about'))
168+
// Handle string/object navigations (e.g., navigate('/about') or navigate({ pathname: '/about' }))
168169
const client = getClient();
169-
const toPath = String(info.to);
170+
const toPath = resolveNavigateArg(info.to);
170171
let navigationSpan;
171172

172173
if (client) {

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
import type { DataRouter, RouterState } from 'react-router';
1515
import { DEBUG_BUILD } from '../common/debug-build';
1616
import { isClientInstrumentationApiUsed } from './createClientInstrumentation';
17+
import { resolveNavigateArg } from './utils';
1718

1819
const GLOBAL_OBJ_WITH_DATA_ROUTER = GLOBAL_OBJ as typeof GLOBAL_OBJ & {
1920
__reactRouterDataRouter?: DataRouter;
@@ -59,7 +60,7 @@ export function instrumentHydratedRouter(): void {
5960
router.navigate = function sentryPatchedNavigate(...args) {
6061
// Skip if instrumentation API is enabled (it handles navigation spans itself)
6162
if (!isClientInstrumentationApiUsed()) {
62-
maybeCreateNavigationTransaction(String(args[0]) || '<unknown route>', 'url');
63+
maybeCreateNavigationTransaction(resolveNavigateArg(args[0]) || '<unknown route>', 'url');
6364
}
6465
return originalNav(...args);
6566
};
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { GLOBAL_OBJ } from '@sentry/core';
2+
3+
/**
4+
* Resolves a navigate argument to a pathname string.
5+
*
6+
* React Router's navigate() accepts a string, number, or a To object ({ pathname, search, hash }).
7+
* All fields in the To object are optional (Partial<Path>), so we need to detect object args
8+
* to avoid "[object Object]" transaction names.
9+
*/
10+
export function resolveNavigateArg(target: unknown): string {
11+
if (typeof target !== 'object' || target === null) {
12+
// string or number
13+
return String(target);
14+
}
15+
16+
// Object `to` with pathname
17+
const pathname = (target as Record<string, unknown>).pathname;
18+
if (typeof pathname === 'string') {
19+
return pathname || '/';
20+
}
21+
22+
// Object `to` without pathname - navigation stays on current path
23+
return (GLOBAL_OBJ as typeof GLOBAL_OBJ & Window).location?.pathname || '/';
24+
}

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

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,37 @@ describe('createSentryClientInstrumentation', () => {
100100
expect(mockCallNavigate).toHaveBeenCalled();
101101
});
102102

103+
it('should create navigation span with correct name when `to` is an object', async () => {
104+
const mockCallNavigate = vi.fn().mockResolvedValue({ status: 'success', error: undefined });
105+
const mockInstrument = vi.fn();
106+
const mockClient = {};
107+
108+
(core.getClient as any).mockReturnValue(mockClient);
109+
110+
const instrumentation = createSentryClientInstrumentation();
111+
instrumentation.router?.({ instrument: mockInstrument });
112+
113+
expect(mockInstrument).toHaveBeenCalled();
114+
const hooks = mockInstrument.mock.calls[0]![0];
115+
116+
// Call the navigate hook with an object `to` (pathname + search)
117+
await hooks.navigate(mockCallNavigate, {
118+
currentUrl: '/home',
119+
to: { pathname: '/items/123', search: '?foo=bar' },
120+
});
121+
122+
expect(browser.startBrowserTracingNavigationSpan).toHaveBeenCalledWith(mockClient, {
123+
name: '/items/123',
124+
attributes: expect.objectContaining({
125+
'sentry.source': 'url',
126+
'sentry.op': 'navigation',
127+
'sentry.origin': 'auto.navigation.react_router.instrumentation_api',
128+
'navigation.type': 'router.navigate',
129+
}),
130+
});
131+
expect(mockCallNavigate).toHaveBeenCalled();
132+
});
133+
103134
it('should instrument router fetch with spans', async () => {
104135
const mockCallFetch = vi.fn().mockResolvedValue({ status: 'success', error: undefined });
105136
const mockInstrument = vi.fn();

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,28 @@ describe('instrumentHydratedRouter', () => {
127127
delete (globalThis as any).__sentryReactRouterClientInstrumentationUsed;
128128
});
129129

130+
it('creates navigation transaction with correct name when navigate is called with an object `to`', () => {
131+
instrumentHydratedRouter();
132+
mockRouter.navigate({ pathname: '/items/123', search: '?foo=bar' });
133+
expect(browser.startBrowserTracingNavigationSpan).toHaveBeenCalledWith(
134+
expect.anything(),
135+
expect.objectContaining({
136+
name: '/items/123',
137+
}),
138+
);
139+
});
140+
141+
it('creates navigation transaction with correct name when navigate is called with a number', () => {
142+
instrumentHydratedRouter();
143+
mockRouter.navigate(-1);
144+
expect(browser.startBrowserTracingNavigationSpan).toHaveBeenCalledWith(
145+
expect.anything(),
146+
expect.objectContaining({
147+
name: '-1',
148+
}),
149+
);
150+
});
151+
130152
it('creates navigation span when client instrumentation API is not enabled', () => {
131153
// Ensure the flag is not set (default state - instrumentation API not used)
132154
delete (globalThis as any).__sentryReactRouterClientInstrumentationUsed;

0 commit comments

Comments
 (0)