Skip to content

Commit 1f1ed28

Browse files
[skip ci] Merge branch into staging-16
2 parents 62c3c1b + a7c318d commit 1f1ed28

26 files changed

Lines changed: 2209 additions & 36 deletions

LICENSE-3rdparty.csv

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ dev,@jsdevtools/coverage-istanbul-loader,MIT,Copyright (c) 2015 James Messinger
2222
dev,@ngtools/webpack,MIT,Copyright (c) 2017 Google LLC
2323
dev,@playwright/test,Apache-2.0,Copyright Microsoft Corporation
2424
dev,@swc/core,Apache-2.0,Copyright (c) SWC Contributors
25+
dev,@tanstack/react-router,MIT,Copyright (c) 2021-present Tanner Linsley
2526
dev,@types/chrome,MIT,Copyright Microsoft Corporation
2627
dev,@types/busboy,MIT,Copyright Microsoft Corporation
2728
dev,@types/cors,MIT,Copyright Microsoft Corporation

packages/core/src/tools/experimentalFeatures.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ export enum ExperimentalFeature {
2222
USE_CHANGE_RECORDS = 'use_change_records',
2323
USE_INCREMENTAL_CHANGE_RECORDS = 'use_incremental_change_records',
2424
TOO_MANY_REQUESTS_INVESTIGATION = 'too_many_requests_investigation',
25-
COMPOSED_PATH_SELECTOR = 'composed_path_selector',
2625
TRACK_RESOURCE_HEADERS = 'track_resource_headers',
2726
}
2827

packages/rum-core/src/domain/action/trackClickActions.spec.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ describe('trackClickActions', () => {
135135
selector: '#button',
136136
width: 100,
137137
height: 100,
138-
composedPathSelector: undefined,
138+
composedPathSelector: jasmine.any(String),
139139
},
140140
position: { x: 50, y: 50 },
141141
events: [domEvent],
@@ -702,7 +702,6 @@ describe('trackClickActions', () => {
702702

703703
describe('when composed path selector is enabled', () => {
704704
it('should return a composed_path_selector', () => {
705-
addExperimentalFeatures([ExperimentalFeature.COMPOSED_PATH_SELECTOR])
706705
startClickActionsTracking()
707706
emulateClick({
708707
target: button,

packages/rum-core/src/domain/action/trackClickActions.ts

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,5 @@
11
import type { Duration, ClocksState, TimeStamp } from '@datadog/browser-core'
2-
import {
3-
timeStampNow,
4-
Observable,
5-
timeStampToClocks,
6-
relativeToClocks,
7-
generateUUID,
8-
isExperimentalFeatureEnabled,
9-
ExperimentalFeature,
10-
} from '@datadog/browser-core'
2+
import { timeStampNow, Observable, timeStampToClocks, relativeToClocks, generateUUID } from '@datadog/browser-core'
113
import { isNodeShadowHost } from '../../browser/htmlDomUtils'
124
import type { FrustrationType } from '../../rawRumEvent.types'
135
import { ActionType } from '../../rawRumEvent.types'
@@ -246,10 +238,7 @@ function computeClickActionBase(
246238
const rect = target.getBoundingClientRect()
247239
const selector = getSelectorFromElement(target, configuration.actionNameAttribute)
248240

249-
const composedPathSelector =
250-
isExperimentalFeatureEnabled(ExperimentalFeature.COMPOSED_PATH_SELECTOR) && typeof event.composedPath === 'function'
251-
? getComposedPathSelector(event.composedPath(), configuration.actionNameAttribute)
252-
: undefined
241+
const composedPathSelector = getComposedPathSelector(event.composedPath(), configuration.actionNameAttribute)
253242

254243
if (selector) {
255244
updateInteractionSelector(event.timeStamp, selector)

packages/rum-react/package.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"internal",
1313
"react-router-v6",
1414
"react-router-v7",
15+
"tanstack-router",
1516
"!src/**/*.spec.*",
1617
"!src/**/*.specHelper.*"
1718
],
@@ -24,6 +25,7 @@
2425
"@datadog/browser-rum-core": "6.32.0"
2526
},
2627
"peerDependencies": {
28+
"@tanstack/react-router": ">=1.64.0 <2",
2729
"react": "18 || 19",
2830
"react-router": "6 || 7",
2931
"react-router-dom": "6 || 7"
@@ -35,6 +37,9 @@
3537
"@datadog/browser-rum-slim": {
3638
"optional": true
3739
},
40+
"@tanstack/react-router": {
41+
"optional": true
42+
},
3843
"react": {
3944
"optional": true
4045
},
@@ -46,6 +51,7 @@
4651
}
4752
},
4853
"devDependencies": {
54+
"@tanstack/react-router": "1.168.7",
4955
"@types/react": "19.2.14",
5056
"@types/react-dom": "19.2.3",
5157
"react": "19.2.4",

packages/rum-react/src/domain/reactPlugin.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,10 @@ const onRumStartSubscribers: StartSubscriber[] = []
1616
*/
1717
export interface ReactPluginConfiguration {
1818
/**
19-
* Enable react-router integration. Make sure to use functions from
20-
* {@link @datadog/browser-rum-react/react-router-v6! | @datadog/browser-rum-react/react-router-v6} or
21-
* {@link @datadog/browser-rum-react/react-router-v7! | @datadog/browser-rum-react/react-router-v7}
19+
* Enable router integration. Make sure to use functions from
20+
* {@link @datadog/browser-rum-react/react-router-v6! | @datadog/browser-rum-react/react-router-v6},
21+
* {@link @datadog/browser-rum-react/react-router-v7! | @datadog/browser-rum-react/react-router-v7}, or
22+
* {@link @datadog/browser-rum-react/tanstack-router! | @datadog/browser-rum-react/tanstack-router}
2223
* to create the router.
2324
* ```
2425
*/
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { display } from '@datadog/browser-core'
2+
import { initializeReactPlugin } from '../../../test/initializeReactPlugin'
3+
import { startTanStackRouterView, computeViewName } from './startTanStackRouterView'
4+
import type { AnyTanStackRouteMatch } from './types'
5+
6+
describe('startTanStackRouterView', () => {
7+
it('creates a new view with the computed view name', () => {
8+
const startViewSpy = jasmine.createSpy()
9+
initializeReactPlugin({
10+
configuration: {
11+
router: true,
12+
},
13+
publicApi: {
14+
startView: startViewSpy,
15+
},
16+
})
17+
18+
startTanStackRouterView([
19+
{ fullPath: '/', pathname: '/', params: {} },
20+
{ fullPath: '/users/$userId', pathname: '/users/1', params: { userId: '1' } },
21+
])
22+
23+
expect(startViewSpy).toHaveBeenCalledOnceWith('/users/$userId')
24+
})
25+
26+
it('displays a warning if the router integration is not enabled', () => {
27+
const displayWarnSpy = spyOn(display, 'warn')
28+
initializeReactPlugin({
29+
configuration: {},
30+
})
31+
32+
startTanStackRouterView([])
33+
expect(displayWarnSpy).toHaveBeenCalledOnceWith(
34+
'`router: true` is missing from the react plugin configuration, the view will not be tracked.'
35+
)
36+
})
37+
38+
describe('computeViewName', () => {
39+
it('returns an empty string if there is no route match', () => {
40+
expect(computeViewName([])).toBe('')
41+
})
42+
43+
// prettier-ignore
44+
const cases = [
45+
// route paths, path, expected view name
46+
47+
// Simple paths
48+
['/foo', '/foo', '/foo'],
49+
['/foo > /', '/foo', '/foo'],
50+
['/foo > bar', '/foo/bar', '/foo/bar'],
51+
['/foo > bar > $p', '/foo/bar/1', '/foo/bar/$p'],
52+
['$p', '/foo', '/$p'],
53+
['/foo/$p', '/foo/bar', '/foo/$p'],
54+
['/foo > $p', '/foo/bar', '/foo/$p'],
55+
['/$a/$b', '/foo/bar', '/$a/$b'],
56+
['/$a > $b', '/foo/bar', '/$a/$b'],
57+
58+
// Splats — TanStack uses "$" for catch-all segments, substituted with actual path
59+
['$', '/foo/1', '/foo/1'],
60+
['$', '/', '/'],
61+
['/foo/$', '/foo/1', '/foo/1'],
62+
['/foo > $', '/foo/1', '/foo/1'],
63+
['/foo/$p > $', '/foo/bar/baz', '/foo/$p/baz'],
64+
['/$p > $', '/foo/bar/1', '/$p/bar/1'],
65+
] as const
66+
67+
cases.forEach(([routePaths, path, expectedViewName]) => {
68+
it(`returns "${expectedViewName}" for route "${path}" and config "${routePaths}"`, () => {
69+
const router = buildRouter(routePaths, path)
70+
71+
expect(computeViewName(router.state.matches)).toEqual(expectedViewName)
72+
})
73+
})
74+
75+
it('keeps the splat pattern when _splat param is not available', () => {
76+
expect(
77+
computeViewName([
78+
{ fullPath: '/', pathname: '/', params: {} },
79+
{ fullPath: '/files/$', pathname: '/files/', params: {} },
80+
])
81+
).toBe('/files/$')
82+
})
83+
})
84+
})
85+
86+
/**
87+
* Build a mock router that mimics TanStack Router's resolved state for a given route config and
88+
* path. The routePaths string uses ' > ' to delimit nested route segments (e.g. '/foo > bar > $p').
89+
*/
90+
function buildRouter(routePaths: string, path: string) {
91+
const segments = routePaths.split(' > ')
92+
let fullPath = ''
93+
for (const segment of segments) {
94+
if (segment === '/') {
95+
fullPath += '/'
96+
} else if (segment.startsWith('/')) {
97+
fullPath += segment
98+
} else {
99+
fullPath += `/${segment}`
100+
}
101+
}
102+
103+
const params: Record<string, string> = {}
104+
const templateParts = fullPath.split('/')
105+
const pathParts = path.split('/')
106+
107+
let pathIdx = 0
108+
for (let i = 0; i < templateParts.length; i++) {
109+
const tpl = templateParts[i]
110+
if (tpl === '$') {
111+
params._splat = pathParts.slice(pathIdx).join('/')
112+
break
113+
} else if (tpl.startsWith('$')) {
114+
params[tpl.slice(1)] = pathParts[pathIdx] || ''
115+
}
116+
pathIdx++
117+
}
118+
119+
return {
120+
state: {
121+
matches: [{ fullPath, pathname: path, params }] as AnyTanStackRouteMatch[],
122+
},
123+
}
124+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { display } from '@datadog/browser-core'
2+
import { onRumInit } from '../reactPlugin'
3+
import type { AnyTanStackRouteMatch } from './types'
4+
5+
export function startTanStackRouterView(routeMatches: AnyTanStackRouteMatch[]) {
6+
onRumInit((configuration, rumPublicApi) => {
7+
if (!configuration.router) {
8+
display.warn('`router: true` is missing from the react plugin configuration, the view will not be tracked.')
9+
return
10+
}
11+
rumPublicApi.startView(computeViewName(routeMatches))
12+
})
13+
}
14+
15+
export function computeViewName(routeMatches: AnyTanStackRouteMatch[]) {
16+
if (routeMatches.length === 0) {
17+
return ''
18+
}
19+
20+
// TanStack Router provides `fullPath` on each match, which is the route template
21+
// with param placeholders (e.g., "/posts/$postId"). The last match is the most
22+
// specific route, and its fullPath is the complete view name.
23+
const lastMatch = routeMatches[routeMatches.length - 1]
24+
let viewName = lastMatch.fullPath
25+
26+
// Handle splat routes: TanStack uses bare "$" for catch-all segments.
27+
// Replace the splat placeholder with the actual matched path for better readability,
28+
// consistent with how the React Router integration handles "*" splats.
29+
viewName = substitutePathSplats(viewName, lastMatch.params)
30+
31+
// Remove trailing slash (e.g. "/posts/" → "/posts") happening when the last match is an index route
32+
if (viewName.endsWith('/')) {
33+
viewName = viewName.slice(0, -1)
34+
}
35+
36+
return viewName || '/'
37+
}
38+
39+
/**
40+
* TanStack Router uses a bare "$" for catch-all (splat) segments.
41+
* Example: "/files/$" with params._splat = "path/to/file" → "/files/path/to/file"
42+
*
43+
* Keeping the "$" in the view name isn't helpful as it hides information.
44+
* We replace it with the actual matched path, consistent with React Router's "*" handling.
45+
*/
46+
function substitutePathSplats(path: string, params: Record<string, string | undefined>): string {
47+
if (!path.endsWith('/$') || params._splat === undefined) {
48+
return path
49+
}
50+
51+
return path.replace(/\/\$$/, `/${params._splat}`)
52+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
// Those types are used by our instrumentation functions to make them work with
2+
// TanStack Router without directly importing its types. This avoids version coupling
3+
// and ensures compatibility across TanStack Router v1 releases.
4+
//
5+
// Those types should be:
6+
// * compatible with all @tanstack/react-router v1 versions we support
7+
// * include the minimal set of attributes used by our instrumentation functions.
8+
9+
export interface AnyTanStackRouteMatch {
10+
fullPath: string
11+
pathname: string
12+
params: Record<string, string | undefined>
13+
}
14+
15+
export interface AnyTanStackNavigationEvent {
16+
type: string
17+
pathChanged: boolean
18+
toLocation: { pathname: string }
19+
}
20+
21+
export interface AnyTanStackRouterInstance {
22+
state: {
23+
location: { pathname: string }
24+
matches: AnyTanStackRouteMatch[]
25+
}
26+
subscribe: (eventType: 'onLoad', fn: (event: AnyTanStackNavigationEvent) => void) => () => void
27+
}
28+
29+
export type AnyTanStackCreateRouter = (options: any) => AnyTanStackRouterInstance

0 commit comments

Comments
 (0)