Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/rum-react/.npmignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@
/src/**/*.spec.ts
/src/**/*.specHelper.ts
!/react-router-v[6-7]/*
!/nextjs/*
7 changes: 7 additions & 0 deletions packages/rum-react/nextjs/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"name": "@datadog/browser-rum-react/nextjs",
"private": true,
"main": "../cjs/entries/nextjs.js",
"module": "../esm/entries/nextjs.js",
"types": "../cjs/entries/nextjs.d.ts"
}
4 changes: 4 additions & 0 deletions packages/rum-react/nextjs/typedoc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"$schema": "https://typedoc.org/schema.json",
"entryPoints": ["../src/entries/nextjs.ts"]
}
5 changes: 5 additions & 0 deletions packages/rum-react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"@datadog/browser-rum-core": "6.28.0"
},
"peerDependencies": {
"next": ">=13",
"react": "18 || 19",
"react-router": "6 || 7",
"react-router-dom": "6 || 7"
Expand All @@ -25,6 +26,9 @@
"@datadog/browser-rum-slim": {
"optional": true
},
"next": {
"optional": true
},
"react": {
"optional": true
},
Expand All @@ -38,6 +42,7 @@
"devDependencies": {
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"next": "15.2.2",
Copy link
Contributor

@BeltranBulbarellaDD BeltranBulbarellaDD Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question:: So we will support next >= 15.2.2. Shouldn't we try to support sooner versions too?
Suggestion:: For example support version >= 13?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, that's just the version we use inside our integration. As it is defined above in the peerDependencies section, this approach supports all the versions >= 13

"react": "19.2.4",
"react-dom": "19.2.4",
"react-router": "7.13.0",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { computeNextViewName } from './computeNextViewName'

describe('computeNextViewName', () => {
// prettier-ignore
const cases = [
// [pathname, params, expectedViewName]

// Static routes (no params)
['/about', {}, '/about'],
['/search', {}, '/search'],
['/', {}, '/'],

// Single dynamic segment
['/users/123', { id: '123' }, '/users/:id'],

// Multiple dynamic segments
['/users/123/posts/456', { userId: '123', postId: '456' }, '/users/:userId/posts/:postId'],

// Param value appears multiple times (FR-4)
['/a/123/b/123', { id: '123' }, '/a/:id/b/:id'],

// Catch-all segments (FR-3) — string array params
['/docs/a/b/c', { slug: ['a', 'b', 'c'] }, '/docs/:slug'],

// Catch-all from root
['/docs/getting-started', { slug: ['docs', 'getting-started'] }, '/:slug'],

// Segment-aware replacement (does NOT replace inside segment names)
['/abc/a/def', { id: 'a' }, '/abc/:id/def'],

// Undefined param value (optional catch-all with no match) — skipped
['/foo', { opt: undefined }, '/foo'],

// Empty string param value — skipped
['/foo', { empty: '' }, '/foo'],

// Catch-all with single segment
['/blog/hello', { slug: ['hello'] }, '/blog/:slug'],

// Mixed dynamic + catch-all
['/users/42/files/a/b', { id: '42', path: ['a', 'b'] }, '/users/:id/files/:path'],
] as const

cases.forEach(([pathname, params, expectedViewName]) => {
it(`returns "${expectedViewName}" for pathname "${pathname}" and params ${JSON.stringify(params)}`, () => {
expect(computeNextViewName(pathname, params as any)).toBe(expectedViewName)
})
})
})
70 changes: 70 additions & 0 deletions packages/rum-react/src/domain/nextjs/computeNextViewName.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import type { NextParams } from './types'

/**
* Reconstructs the parameterized route pattern from a Next.js pathname and params object.
*
* Uses segment-aware replacement to avoid matching param values as substrings of other
* path segments. For example, `{ id: 'a' }` with `/abc/a/def` correctly produces
* `/abc/:id/def` rather than `/:idbc/:id/def`.
*
* Processing order:
* 1. Array (catch-all) params are processed before string params, so the full contiguous
* segment sequence can be matched before individual segments are replaced.
* 2. String params are sorted by descending value length as a safety measure for edge cases
* where two params share values that are substrings of each other.
*/
export function computeNextViewName(pathname: string, params: NextParams): string {
const segments = pathname.split('/')

// Sort entries: arrays first, then strings sorted by descending length.
// This ensures catch-all params are matched before individual segments,
// and longer values are matched before shorter ones to avoid partial replacements.
const entries = Object.entries(params).sort((a, b) => {
const aIsArray = Array.isArray(a[1])
const bIsArray = Array.isArray(b[1])
if (aIsArray !== bIsArray) {
return aIsArray ? -1 : 1
}
// For strings, sort by descending value length
if (!aIsArray && !bIsArray) {
return String(b[1] ?? '').length - String(a[1] ?? '').length
}
return 0
})

for (const [paramName, paramValue] of entries) {
if (paramValue === undefined || paramValue === '') {
continue
}

if (Array.isArray(paramValue)) {
// Catch-all: find the first contiguous sequence of segments matching the array
if (paramValue.length === 0) {
continue
}
for (let i = 0; i <= segments.length - paramValue.length; i++) {
let matches = true
for (let j = 0; j < paramValue.length; j++) {
if (segments[i + j] !== paramValue[j]) {
matches = false
break
}
}
if (matches) {
// Replace the contiguous run with a single :paramName segment
segments.splice(i, paramValue.length, `:${paramName}`)
break // Only replace the first occurrence for catch-all
}
}
} else {
// String: replace all matching segments
for (let i = 0; i < segments.length; i++) {
if (segments[i] === paramValue) {
segments[i] = `:${paramName}`
}
}
}
}

return segments.join('/')
}
144 changes: 144 additions & 0 deletions packages/rum-react/src/domain/nextjs/datadogRumProvider.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import React, { act } from 'react'
import { display } from '@datadog/browser-core'
import { usePathname, useParams } from 'next/navigation'
import { replaceMockable } from '../../../../core/test'
import { appendComponent } from '../../../test/appendComponent'
import { initializeReactPlugin } from '../../../test/initializeReactPlugin'
import { DatadogRumProvider } from './datadogRumProvider'
import type { NextParams } from './types'

describe('DatadogRumProvider', () => {
let mockPathname: string
let mockParams: NextParams
let startViewSpy: jasmine.Spy

beforeEach(() => {
mockPathname = '/'
mockParams = {}

replaceMockable(usePathname, (() => mockPathname) as typeof usePathname)
replaceMockable(useParams, (() => mockParams) as typeof useParams)

startViewSpy = jasmine.createSpy()
initializeReactPlugin({
configuration: { nextAppRouter: true },
publicApi: { startView: startViewSpy },
})
})

it('starts a view on initial mount', () => {
mockPathname = '/users/123'
mockParams = { id: '123' }

appendComponent(<DatadogRumProvider>content</DatadogRumProvider>)

expect(startViewSpy).toHaveBeenCalledOnceWith('/users/:id')
})

it('renders children without wrapper DOM elements', () => {
const container = appendComponent(
<DatadogRumProvider>
<span>hello</span>
</DatadogRumProvider>
)

expect(container.innerHTML).toBe('<span>hello</span>')
})

it('does not start a new view on re-render with same pathname', () => {
mockPathname = '/about'

let forceUpdate: () => void

function App() {
const [, setState] = React.useState(0)
forceUpdate = () => setState((s) => s + 1)
return <DatadogRumProvider>content</DatadogRumProvider>
}

appendComponent(<App />)
expect(startViewSpy).toHaveBeenCalledTimes(1)

act(() => {
forceUpdate!()
})

expect(startViewSpy).toHaveBeenCalledTimes(1)
})

it('starts a new view when the view name changes', () => {
mockPathname = '/users/123'
mockParams = { id: '123' }

let forceUpdate: () => void

function App() {
Copy link
Contributor

@BeltranBulbarellaDD BeltranBulbarellaDD Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I love the idea of doing a component in a unit test. I would prefer to put it in a e2e test and here test behaviour.

const [, setState] = React.useState(0)
forceUpdate = () => setState((s) => s + 1)
return <DatadogRumProvider>content</DatadogRumProvider>
}

appendComponent(<App />)
startViewSpy.calls.reset()

// Navigate to a different route pattern
mockPathname = '/about'
mockParams = {}

act(() => {
forceUpdate!()
})

expect(startViewSpy).toHaveBeenCalledOnceWith('/about')
})

it('does not start a new view when navigating to a different instance of the same route', () => {
mockPathname = '/users/123'
mockParams = { id: '123' }

let forceUpdate: () => void

function App() {
const [, setState] = React.useState(0)
forceUpdate = () => setState((s) => s + 1)
return <DatadogRumProvider>content</DatadogRumProvider>
}

appendComponent(<App />)
expect(startViewSpy).toHaveBeenCalledOnceWith('/users/:id')
startViewSpy.calls.reset()

// Navigate to a different user — same route pattern /users/:id
mockPathname = '/users/456'
mockParams = { id: '456' }

act(() => {
forceUpdate!()
})

expect(startViewSpy).not.toHaveBeenCalled()
})

it('starts a view with raw pathname for static routes', () => {
mockPathname = '/about'
mockParams = {}

appendComponent(<DatadogRumProvider>content</DatadogRumProvider>)

expect(startViewSpy).toHaveBeenCalledOnceWith('/about')
})

it('warns when nextAppRouter config is missing', () => {
const displayWarnSpy = spyOn(display, 'warn')
initializeReactPlugin({
configuration: {},
publicApi: { startView: startViewSpy },
})

mockPathname = '/about'

appendComponent(<DatadogRumProvider>content</DatadogRumProvider>)

expect(displayWarnSpy).toHaveBeenCalledOnceWith(jasmine.stringContaining('`nextAppRouter: true` is missing'))
})
})
22 changes: 22 additions & 0 deletions packages/rum-react/src/domain/nextjs/datadogRumProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import React, { useRef, type ReactNode } from 'react'
import { mockable } from '@datadog/browser-core'
import { usePathname, useParams } from 'next/navigation'
import { startNextjsView } from './startNextjsView'
import { computeNextViewName } from './computeNextViewName'

export function DatadogRumProvider({ children }: { children: ReactNode }) {
const pathname = mockable(usePathname)()
const params = mockable(useParams)()
const viewNameRef = useRef<string | null>(null)

const viewName = computeNextViewName(pathname, params ?? {})

if (viewNameRef.current !== viewName) {
viewNameRef.current = viewName
startNextjsView(viewName)
}

return <>{children}</>
}

DatadogRumProvider.displayName = 'DatadogRumProvider'
Copy link
Contributor

@BeltranBulbarellaDD BeltranBulbarellaDD Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: What is displayName this for?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's just for the React Browser Extension to be able to display the component name in the React Virtual DOM tree. This is optional but nice to have

4 changes: 4 additions & 0 deletions packages/rum-react/src/domain/nextjs/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export { computeNextViewName } from './computeNextViewName'
export { startNextjsView } from './startNextjsView'
export { DatadogRumProvider } from './datadogRumProvider'
export type { NextParams } from './types'
Loading
Loading