From f3b402e7b687ac4dcc3c2c8e93ea48d24186635b Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Tue, 3 Feb 2026 10:21:02 +0100 Subject: [PATCH 01/20] Added Next.js App Router integration --- packages/rum-react/nextjs/package.json | 7 + packages/rum-react/package.json | 5 + .../domain/nextjs/datadogRumProvider.spec.tsx | 62 +++ .../src/domain/nextjs/datadogRumProvider.tsx | 43 ++ packages/rum-react/src/domain/nextjs/index.ts | 5 + .../src/domain/nextjs/initDatadogRum.ts | 64 +++ packages/rum-react/src/domain/nextjs/types.ts | 25 + .../src/domain/nextjs/viewTracking.spec.tsx | 122 ++++ .../src/domain/nextjs/viewTracking.tsx | 50 ++ .../rum-react/src/domain/reactPlugin.spec.ts | 9 +- packages/rum-react/src/domain/reactPlugin.ts | 11 +- packages/rum-react/src/entries/nextjs.ts | 46 ++ yarn.lock | 521 +++++++++++++++++- 13 files changed, 963 insertions(+), 7 deletions(-) create mode 100644 packages/rum-react/nextjs/package.json create mode 100644 packages/rum-react/src/domain/nextjs/datadogRumProvider.spec.tsx create mode 100644 packages/rum-react/src/domain/nextjs/datadogRumProvider.tsx create mode 100644 packages/rum-react/src/domain/nextjs/index.ts create mode 100644 packages/rum-react/src/domain/nextjs/initDatadogRum.ts create mode 100644 packages/rum-react/src/domain/nextjs/types.ts create mode 100644 packages/rum-react/src/domain/nextjs/viewTracking.spec.tsx create mode 100644 packages/rum-react/src/domain/nextjs/viewTracking.tsx create mode 100644 packages/rum-react/src/entries/nextjs.ts diff --git a/packages/rum-react/nextjs/package.json b/packages/rum-react/nextjs/package.json new file mode 100644 index 0000000000..38b6e4229f --- /dev/null +++ b/packages/rum-react/nextjs/package.json @@ -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" +} diff --git a/packages/rum-react/package.json b/packages/rum-react/package.json index d188d06206..63c9773c1e 100644 --- a/packages/rum-react/package.json +++ b/packages/rum-react/package.json @@ -14,6 +14,7 @@ "@datadog/browser-rum-core": "6.26.0" }, "peerDependencies": { + "next": ">=13", "react": "18 || 19", "react-router": "6 || 7", "react-router-dom": "6 || 7" @@ -25,6 +26,9 @@ "@datadog/browser-rum-slim": { "optional": true }, + "next": { + "optional": true + }, "react": { "optional": true }, @@ -38,6 +42,7 @@ "devDependencies": { "@types/react": "19.2.8", "@types/react-dom": "19.2.3", + "next": "16.1.6", "react": "19.2.3", "react-dom": "19.2.3", "react-router": "7.12.0", diff --git a/packages/rum-react/src/domain/nextjs/datadogRumProvider.spec.tsx b/packages/rum-react/src/domain/nextjs/datadogRumProvider.spec.tsx new file mode 100644 index 0000000000..c1a1651300 --- /dev/null +++ b/packages/rum-react/src/domain/nextjs/datadogRumProvider.spec.tsx @@ -0,0 +1,62 @@ +import React from 'react' +import { appendComponent } from '../../../test/appendComponent' +import { initializeReactPlugin } from '../../../test/initializeReactPlugin' +import { DatadogRumProvider } from './datadogRumProvider' + +describe('DatadogRumProvider', () => { + let startViewSpy: jasmine.Spy<(name?: string | object) => void> + let usePathnameSpy: jasmine.Spy<() => string> + + beforeEach(() => { + startViewSpy = jasmine.createSpy() + initializeReactPlugin({ + configuration: { + nextjs: true, + }, + publicApi: { + startView: startViewSpy, + }, + }) + + usePathnameSpy = jasmine.createSpy('usePathname').and.returnValue('/') + }) + + it('renders children correctly', () => { + const container = appendComponent( + +
Test Content
+
+ ) + + const child = container.querySelector('[data-testid="test-child"]') + expect(child).not.toBeNull() + expect(child!.textContent).toBe('Test Content') + expect(child!.parentElement).toBe(container) + }) + + it('calls usePathnameTracker', () => { + usePathnameSpy.and.returnValue('/product/123') + + appendComponent( + +
Content
+
+ ) + + expect(startViewSpy).toHaveBeenCalledOnceWith('/product/:id') + }) + + it('renders multiple children', () => { + const container = appendComponent( + +
Child 1
+
Child 2
+
Child 3
+
+ ) + + expect(container.querySelector('[data-testid="child-1"]')!.textContent).toBe('Child 1') + expect(container.querySelector('[data-testid="child-2"]')!.textContent).toBe('Child 2') + expect(container.querySelector('[data-testid="child-3"]')!.textContent).toBe('Child 3') + }) +}) diff --git a/packages/rum-react/src/domain/nextjs/datadogRumProvider.tsx b/packages/rum-react/src/domain/nextjs/datadogRumProvider.tsx new file mode 100644 index 0000000000..a3c3dc52e3 --- /dev/null +++ b/packages/rum-react/src/domain/nextjs/datadogRumProvider.tsx @@ -0,0 +1,43 @@ +'use client' + +import React, { type ReactNode } from 'react' +import { usePathname as nextUsePathname } from 'next/navigation' +import { usePathnameTracker } from './viewTracking' + +export interface DatadogRumProviderProps { + /** + * The children components to render. + */ + + children: ReactNode + + /** + * @internal - For dependency injection in tests. + */ + usePathname?: () => string +} + +/** + * Provider component for Next.js App Router that automatically tracks navigation. + * Wrap your application with this component to enable automatic view tracking. + * + * @example + * ```tsx + * // app/layout.tsx + * import { DatadogRumProvider } from '@datadog/browser-rum-react/nextjs' + * + * export default function RootLayout({ children }) { + * return ( + * + * + * {children} + * + * + * ) + * } + * ``` + */ +export function DatadogRumProvider({ children, usePathname = nextUsePathname }: DatadogRumProviderProps) { + usePathnameTracker(usePathname) + return <>{children} +} diff --git a/packages/rum-react/src/domain/nextjs/index.ts b/packages/rum-react/src/domain/nextjs/index.ts new file mode 100644 index 0000000000..e4106d8112 --- /dev/null +++ b/packages/rum-react/src/domain/nextjs/index.ts @@ -0,0 +1,5 @@ +export { DatadogRumProvider } from './datadogRumProvider' +export type { DatadogRumProviderProps } from './datadogRumProvider' +export { usePathnameTracker, startNextjsView, normalizeViewName } from './viewTracking' +// export { initDatadogRum } from './initDatadogRum' +export type { NextjsRumConfig } from './types' diff --git a/packages/rum-react/src/domain/nextjs/initDatadogRum.ts b/packages/rum-react/src/domain/nextjs/initDatadogRum.ts new file mode 100644 index 0000000000..db1e8ceb82 --- /dev/null +++ b/packages/rum-react/src/domain/nextjs/initDatadogRum.ts @@ -0,0 +1,64 @@ +// Work in progress, not tested yet. + +// import type { RumPublicApi } from '@datadog/browser-rum-core' +// import { addEventListener } from '@datadog/browser-core' +// import { reactPlugin } from '../reactPlugin' +// import type { NextjsRumConfig } from './types' + +// /** +// * Helper for Next.js instrumentation file (instrumentation-client.ts). +// * Initializes RUM with Next.js integration enabled and optional early error capture. +// * +// * @param config - Configuration for Next.js RUM integration +// * @param datadogRum - The datadogRum instance from @datadog/browser-rum +// * @example +// * ```ts +// * // instrumentation-client.ts +// * import { datadogRum } from '@datadog/browser-rum' +// * import { initDatadogRum } from '@datadog/browser-rum-react/nextjs' +// * +// * export function register() { +// * initDatadogRum( +// * { +// * datadogConfig: { +// * applicationId: '', +// * clientToken: '', +// * site: 'datadoghq.com', +// * }, +// * nextjsConfig: { +// * captureEarlyErrors: true, +// * } +// * }, +// * datadogRum +// * ) +// * } +// * ``` +// */ +// export function initDatadogRum(config: NextjsRumConfig, datadogRum: RumPublicApi): void { +// if (typeof window === 'undefined') { +// // Server-side guard - RUM only runs in the browser +// return +// } + +// const { datadogConfig, nextjsConfig } = config + +// // Initialize RUM with the reactPlugin configured for Next.js +// const nextjsPlugin = reactPlugin({ nextjs: true }) +// const existingPlugins = (datadogConfig.plugins || []) as Array + +// datadogRum.init({ +// ...datadogConfig, +// plugins: [nextjsPlugin].concat(existingPlugins), +// }) + +// // Optional: Set up early error capture +// if (nextjsConfig?.captureEarlyErrors) { +// addEventListener({}, window, 'error', (event) => { +// datadogRum.addError(event.error) +// }) + +// addEventListener({}, window, 'unhandledrejection', (event: PromiseRejectionEvent) => { +// datadogRum.addError(event.reason) +// }) +// } +// } diff --git a/packages/rum-react/src/domain/nextjs/types.ts b/packages/rum-react/src/domain/nextjs/types.ts new file mode 100644 index 0000000000..4e4b6fdfa5 --- /dev/null +++ b/packages/rum-react/src/domain/nextjs/types.ts @@ -0,0 +1,25 @@ +import type { RumInitConfiguration } from '@datadog/browser-rum-core' + +/** + * Configuration for Next.js instrumentation file pattern. + */ +export interface NextjsRumConfig { + /** + * Datadog RUM initialization configuration. + */ + datadogConfig: RumInitConfiguration + + /** + * Next.js-specific configuration options. + */ + + nextjsConfig?: { + /** + * Whether to capture early errors that occur before the app fully initializes. + * + * @default false + */ + + captureEarlyErrors?: boolean + } +} diff --git a/packages/rum-react/src/domain/nextjs/viewTracking.spec.tsx b/packages/rum-react/src/domain/nextjs/viewTracking.spec.tsx new file mode 100644 index 0000000000..b7b43dbd07 --- /dev/null +++ b/packages/rum-react/src/domain/nextjs/viewTracking.spec.tsx @@ -0,0 +1,122 @@ +import React, { act, useState } from 'react' +import { display } from '@datadog/browser-core' +import { appendComponent } from '../../../test/appendComponent' +import { initializeReactPlugin } from '../../../test/initializeReactPlugin' +import { startNextjsView, usePathnameTracker } from './viewTracking' + +describe('startNextjsView', () => { + let startViewSpy: jasmine.Spy<(name?: string | object) => void> + + beforeEach(() => { + startViewSpy = jasmine.createSpy() + initializeReactPlugin({ + configuration: { + nextjs: true, + }, + publicApi: { + startView: startViewSpy, + }, + }) + }) + ;[ + ['/product/123', '/product/:id'], + ['/user/abc12345-1234-1234-1234-123456789012', '/user/:uuid'], + ['/about', '/about'], + ['/', '/'], + ].forEach(([pathname, normalizedPathname]) => { + it(`creates a new view with the normalized pathname ${normalizedPathname}`, () => { + startNextjsView(pathname) + + expect(startViewSpy).toHaveBeenCalledOnceWith(normalizedPathname) + }) + }) + + it('warns when nextjs configuration is missing', () => { + const localStartViewSpy = jasmine.createSpy() + const warnSpy = spyOn(display, 'warn') + initializeReactPlugin({ + configuration: {}, + publicApi: { + startView: localStartViewSpy, + }, + }) + + startNextjsView('/product/123') + + expect(warnSpy).toHaveBeenCalledOnceWith( + '`nextjs: true` is missing from the react plugin configuration, the view will not be tracked.' + ) + expect(localStartViewSpy).not.toHaveBeenCalled() + }) + + it('does not create a view when nextjs flag is false', () => { + const localStartViewSpy = jasmine.createSpy() + const warnSpy = spyOn(display, 'warn') + initializeReactPlugin({ + configuration: { + nextjs: false, + }, + publicApi: { + startView: localStartViewSpy, + }, + }) + + startNextjsView('/product/123') + + expect(warnSpy).toHaveBeenCalled() + expect(localStartViewSpy).not.toHaveBeenCalled() + }) +}) + +describe('usePathnameTracker', () => { + let startViewSpy: jasmine.Spy<(name?: string | object) => void> + let usePathnameSpy: jasmine.Spy<() => string> + + beforeEach(() => { + startViewSpy = jasmine.createSpy() + initializeReactPlugin({ + configuration: { + nextjs: true, + }, + publicApi: { + startView: startViewSpy, + }, + }) + + usePathnameSpy = jasmine.createSpy('usePathname').and.returnValue('/') + }) + + function TestComponent() { + usePathnameTracker(usePathnameSpy) + return null + } + + it('calls startNextjsView on mount', () => { + usePathnameSpy.and.returnValue('/product/123') + + appendComponent() + + expect(startViewSpy).toHaveBeenCalledOnceWith('/product/:id') + }) + + it('does not create duplicate views on re-render with same pathname', () => { + usePathnameSpy.and.returnValue('/product/123') + + function ReRenderingComponent() { + const [, setCounter] = useState(0) + usePathnameTracker(usePathnameSpy) + + return + } + + const container = appendComponent() + expect(startViewSpy).toHaveBeenCalledTimes(1) + + const button = container.querySelector('button') as HTMLButtonElement + act(() => { + button.click() + }) + + expect(startViewSpy).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/rum-react/src/domain/nextjs/viewTracking.tsx b/packages/rum-react/src/domain/nextjs/viewTracking.tsx new file mode 100644 index 0000000000..6ceffe763b --- /dev/null +++ b/packages/rum-react/src/domain/nextjs/viewTracking.tsx @@ -0,0 +1,50 @@ +import { useRef, useEffect } from 'react' +import { usePathname as nextUsePathname } from 'next/navigation' +import { display } from '@datadog/browser-core' +import { onRumInit } from '../reactPlugin' + +/** + * Normalizes the pathname to use route patterns (e.g., /product/123 -> /product/:id). + */ +export function normalizeViewName(pathname: string): string { + return ( + pathname + // Replace UUID segments first (more specific pattern) + // Matches: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx (8-4-4-4-12 hex digits) + // Match complete UUIDs followed by /, ?, #, or end of string + .replace(/\/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}(?=\/|[?#]|$)/gi, '/:uuid') + // Replace numeric segments (match complete numeric path segments) + // Followed by /, ?, #, or end of string (not hyphens or other characters) + .replace(/\/\d+(?=\/|[?#]|$)/g, '/:id') + ) +} + +/** + * Starts a new RUM view. + */ +export function startNextjsView(pathname: string) { + onRumInit((configuration, rumPublicApi) => { + if (!configuration.nextjs) { + display.warn('`nextjs: true` is missing from the react plugin configuration, the view will not be tracked.') + return + } + + const viewName = normalizeViewName(pathname) + rumPublicApi.startView(viewName) + }) +} + +/** + * Tracks navigation changes and starts a new RUM view for each new pathname. + */ +export function usePathnameTracker(usePathname = nextUsePathname) { + const pathname = usePathname() + const pathnameRef = useRef(null) + + useEffect(() => { + if (pathnameRef.current !== pathname) { + pathnameRef.current = pathname + startNextjsView(pathname) + } + }, [pathname]) +} diff --git a/packages/rum-react/src/domain/reactPlugin.spec.ts b/packages/rum-react/src/domain/reactPlugin.spec.ts index 2540c79186..6f0fdd573e 100644 --- a/packages/rum-react/src/domain/reactPlugin.spec.ts +++ b/packages/rum-react/src/domain/reactPlugin.spec.ts @@ -69,6 +69,13 @@ describe('reactPlugin', () => { const pluginConfiguration = { router: true } const plugin = reactPlugin(pluginConfiguration) - expect(plugin.getConfigurationTelemetry()).toEqual({ router: true }) + expect(plugin.getConfigurationTelemetry()).toEqual({ router: true, nextjs: false }) + }) + + it('returns the configuration telemetry when nextjs is true', () => { + const pluginConfiguration = { nextjs: true } + const plugin = reactPlugin(pluginConfiguration) + + expect(plugin.getConfigurationTelemetry()).toEqual({ router: false, nextjs: true }) }) }) diff --git a/packages/rum-react/src/domain/reactPlugin.ts b/packages/rum-react/src/domain/reactPlugin.ts index 946e733ff7..479a896f91 100644 --- a/packages/rum-react/src/domain/reactPlugin.ts +++ b/packages/rum-react/src/domain/reactPlugin.ts @@ -23,6 +23,13 @@ export interface ReactPluginConfiguration { * ``` */ router?: boolean + + /** + * Enable Next.js App Router integration. Make sure to use the DatadogRumProvider from + * {@link @datadog/browser-rum-react/nextjs! | @datadog/browser-rum-react/nextjs} + * to enable automatic view tracking. + */ + nextjs?: boolean } /** @@ -61,7 +68,7 @@ export function reactPlugin(configuration: ReactPluginConfiguration = {}): React for (const subscriber of onRumInitSubscribers) { subscriber(globalConfiguration, globalPublicApi) } - if (configuration.router) { + if (configuration.router || configuration.nextjs) { initConfiguration.trackViewsManually = true } }, @@ -74,7 +81,7 @@ export function reactPlugin(configuration: ReactPluginConfiguration = {}): React } }, getConfigurationTelemetry() { - return { router: !!configuration.router } + return { router: !!configuration.router, nextjs: !!configuration.nextjs } }, } satisfies RumPlugin } diff --git a/packages/rum-react/src/entries/nextjs.ts b/packages/rum-react/src/entries/nextjs.ts new file mode 100644 index 0000000000..af881d84c3 --- /dev/null +++ b/packages/rum-react/src/entries/nextjs.ts @@ -0,0 +1,46 @@ +/** + * Next.js App Router integration. + * + * @packageDocumentation + * @example + * ```tsx + * // app/layout.tsx + * import { datadogRum } from '@datadog/browser-rum' + * import { reactPlugin } from '@datadog/browser-rum-react' + * import { DatadogRumProvider } from '@datadog/browser-rum-react/nextjs' + * + * datadogRum.init({ + * applicationId: '', + * clientToken: '', + * plugins: [reactPlugin({ nextjs: true })], + * // ... + * }) + * + * export default function RootLayout({ children }) { + * return ( + * + * + * {children} + * + * + * ) + * } + * ``` + */ + +// Export Next.js-specific functionality +export { + DatadogRumProvider, + usePathnameTracker, + // initDatadogRum, + startNextjsView, +} from '../domain/nextjs' +export type { DatadogRumProviderProps, NextjsRumConfig } from '../domain/nextjs' + +// Re-export shared functionality from main package +export { ErrorBoundary, addReactError } from '../domain/error' +export type { ErrorBoundaryProps, ErrorBoundaryFallback } from '../domain/error' +export type { ReactPluginConfiguration, ReactPlugin } from '../domain/reactPlugin' +export { reactPlugin } from '../domain/reactPlugin' +// eslint-disable-next-line camelcase +export { UNSTABLE_ReactComponentTracker } from '../domain/performance' diff --git a/yarn.lock b/yarn.lock index 5fb112aef6..abe7bfe03b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -330,12 +330,14 @@ __metadata: "@datadog/browser-rum-core": "npm:6.26.0" "@types/react": "npm:19.2.8" "@types/react-dom": "npm:19.2.3" + next: "npm:16.1.6" react: "npm:19.2.3" react-dom: "npm:19.2.3" react-router: "npm:7.12.0" react-router-dom: "npm:7.12.0" react-router-dom-6: "npm:react-router-dom@6.30.3" peerDependencies: + next: ">=13" react: 18 || 19 react-router: 6 || 7 react-router-dom: 6 || 7 @@ -344,6 +346,8 @@ __metadata: optional: true "@datadog/browser-rum-slim": optional: true + next: + optional: true react: optional: true react-router: @@ -471,6 +475,15 @@ __metadata: languageName: node linkType: hard +"@emnapi/runtime@npm:^1.7.0": + version: 1.8.1 + resolution: "@emnapi/runtime@npm:1.8.1" + dependencies: + tslib: "npm:^2.4.0" + checksum: 10c0/f4929d75e37aafb24da77d2f58816761fe3f826aad2e37fa6d4421dac9060cbd5098eea1ac3c9ecc4526b89deb58153852fa432f87021dc57863f2ff726d713f + languageName: node + linkType: hard + "@emnapi/wasi-threads@npm:1.1.0": version: 1.1.0 resolution: "@emnapi/wasi-threads@npm:1.1.0" @@ -873,6 +886,233 @@ __metadata: languageName: node linkType: hard +"@img/colour@npm:^1.0.0": + version: 1.0.0 + resolution: "@img/colour@npm:1.0.0" + checksum: 10c0/02261719c1e0d7aa5a2d585981954f2ac126f0c432400aa1a01b925aa2c41417b7695da8544ee04fd29eba7ecea8eaf9b8bef06f19dc8faba78f94eeac40667d + languageName: node + linkType: hard + +"@img/sharp-darwin-arm64@npm:0.34.5": + version: 0.34.5 + resolution: "@img/sharp-darwin-arm64@npm:0.34.5" + dependencies: + "@img/sharp-libvips-darwin-arm64": "npm:1.2.4" + dependenciesMeta: + "@img/sharp-libvips-darwin-arm64": + optional: true + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@img/sharp-darwin-x64@npm:0.34.5": + version: 0.34.5 + resolution: "@img/sharp-darwin-x64@npm:0.34.5" + dependencies: + "@img/sharp-libvips-darwin-x64": "npm:1.2.4" + dependenciesMeta: + "@img/sharp-libvips-darwin-x64": + optional: true + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@img/sharp-libvips-darwin-arm64@npm:1.2.4": + version: 1.2.4 + resolution: "@img/sharp-libvips-darwin-arm64@npm:1.2.4" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@img/sharp-libvips-darwin-x64@npm:1.2.4": + version: 1.2.4 + resolution: "@img/sharp-libvips-darwin-x64@npm:1.2.4" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@img/sharp-libvips-linux-arm64@npm:1.2.4": + version: 1.2.4 + resolution: "@img/sharp-libvips-linux-arm64@npm:1.2.4" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@img/sharp-libvips-linux-arm@npm:1.2.4": + version: 1.2.4 + resolution: "@img/sharp-libvips-linux-arm@npm:1.2.4" + conditions: os=linux & cpu=arm & libc=glibc + languageName: node + linkType: hard + +"@img/sharp-libvips-linux-ppc64@npm:1.2.4": + version: 1.2.4 + resolution: "@img/sharp-libvips-linux-ppc64@npm:1.2.4" + conditions: os=linux & cpu=ppc64 & libc=glibc + languageName: node + linkType: hard + +"@img/sharp-libvips-linux-riscv64@npm:1.2.4": + version: 1.2.4 + resolution: "@img/sharp-libvips-linux-riscv64@npm:1.2.4" + conditions: os=linux & cpu=riscv64 & libc=glibc + languageName: node + linkType: hard + +"@img/sharp-libvips-linux-s390x@npm:1.2.4": + version: 1.2.4 + resolution: "@img/sharp-libvips-linux-s390x@npm:1.2.4" + conditions: os=linux & cpu=s390x & libc=glibc + languageName: node + linkType: hard + +"@img/sharp-libvips-linux-x64@npm:1.2.4": + version: 1.2.4 + resolution: "@img/sharp-libvips-linux-x64@npm:1.2.4" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@img/sharp-libvips-linuxmusl-arm64@npm:1.2.4": + version: 1.2.4 + resolution: "@img/sharp-libvips-linuxmusl-arm64@npm:1.2.4" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@img/sharp-libvips-linuxmusl-x64@npm:1.2.4": + version: 1.2.4 + resolution: "@img/sharp-libvips-linuxmusl-x64@npm:1.2.4" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@img/sharp-linux-arm64@npm:0.34.5": + version: 0.34.5 + resolution: "@img/sharp-linux-arm64@npm:0.34.5" + dependencies: + "@img/sharp-libvips-linux-arm64": "npm:1.2.4" + dependenciesMeta: + "@img/sharp-libvips-linux-arm64": + optional: true + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@img/sharp-linux-arm@npm:0.34.5": + version: 0.34.5 + resolution: "@img/sharp-linux-arm@npm:0.34.5" + dependencies: + "@img/sharp-libvips-linux-arm": "npm:1.2.4" + dependenciesMeta: + "@img/sharp-libvips-linux-arm": + optional: true + conditions: os=linux & cpu=arm & libc=glibc + languageName: node + linkType: hard + +"@img/sharp-linux-ppc64@npm:0.34.5": + version: 0.34.5 + resolution: "@img/sharp-linux-ppc64@npm:0.34.5" + dependencies: + "@img/sharp-libvips-linux-ppc64": "npm:1.2.4" + dependenciesMeta: + "@img/sharp-libvips-linux-ppc64": + optional: true + conditions: os=linux & cpu=ppc64 & libc=glibc + languageName: node + linkType: hard + +"@img/sharp-linux-riscv64@npm:0.34.5": + version: 0.34.5 + resolution: "@img/sharp-linux-riscv64@npm:0.34.5" + dependencies: + "@img/sharp-libvips-linux-riscv64": "npm:1.2.4" + dependenciesMeta: + "@img/sharp-libvips-linux-riscv64": + optional: true + conditions: os=linux & cpu=riscv64 & libc=glibc + languageName: node + linkType: hard + +"@img/sharp-linux-s390x@npm:0.34.5": + version: 0.34.5 + resolution: "@img/sharp-linux-s390x@npm:0.34.5" + dependencies: + "@img/sharp-libvips-linux-s390x": "npm:1.2.4" + dependenciesMeta: + "@img/sharp-libvips-linux-s390x": + optional: true + conditions: os=linux & cpu=s390x & libc=glibc + languageName: node + linkType: hard + +"@img/sharp-linux-x64@npm:0.34.5": + version: 0.34.5 + resolution: "@img/sharp-linux-x64@npm:0.34.5" + dependencies: + "@img/sharp-libvips-linux-x64": "npm:1.2.4" + dependenciesMeta: + "@img/sharp-libvips-linux-x64": + optional: true + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@img/sharp-linuxmusl-arm64@npm:0.34.5": + version: 0.34.5 + resolution: "@img/sharp-linuxmusl-arm64@npm:0.34.5" + dependencies: + "@img/sharp-libvips-linuxmusl-arm64": "npm:1.2.4" + dependenciesMeta: + "@img/sharp-libvips-linuxmusl-arm64": + optional: true + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@img/sharp-linuxmusl-x64@npm:0.34.5": + version: 0.34.5 + resolution: "@img/sharp-linuxmusl-x64@npm:0.34.5" + dependencies: + "@img/sharp-libvips-linuxmusl-x64": "npm:1.2.4" + dependenciesMeta: + "@img/sharp-libvips-linuxmusl-x64": + optional: true + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@img/sharp-wasm32@npm:0.34.5": + version: 0.34.5 + resolution: "@img/sharp-wasm32@npm:0.34.5" + dependencies: + "@emnapi/runtime": "npm:^1.7.0" + conditions: cpu=wasm32 + languageName: node + linkType: hard + +"@img/sharp-win32-arm64@npm:0.34.5": + version: 0.34.5 + resolution: "@img/sharp-win32-arm64@npm:0.34.5" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@img/sharp-win32-ia32@npm:0.34.5": + version: 0.34.5 + resolution: "@img/sharp-win32-ia32@npm:0.34.5" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + +"@img/sharp-win32-x64@npm:0.34.5": + version: 0.34.5 + resolution: "@img/sharp-win32-x64@npm:0.34.5" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@inquirer/ansi@npm:^1.0.0, @inquirer/ansi@npm:^1.0.2": version: 1.0.2 resolution: "@inquirer/ansi@npm:1.0.2" @@ -1463,6 +1703,69 @@ __metadata: languageName: node linkType: hard +"@next/env@npm:16.1.6": + version: 16.1.6 + resolution: "@next/env@npm:16.1.6" + checksum: 10c0/ed7023edb94b9b2e5da3f9c99d08b614da9757c1edd0ecec792fce4d336b4f0c64db1a84955e07cfbd848b9e61c4118fff28f4098cd7b0a7f97814a90565ebe6 + languageName: node + linkType: hard + +"@next/swc-darwin-arm64@npm:16.1.6": + version: 16.1.6 + resolution: "@next/swc-darwin-arm64@npm:16.1.6" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@next/swc-darwin-x64@npm:16.1.6": + version: 16.1.6 + resolution: "@next/swc-darwin-x64@npm:16.1.6" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@next/swc-linux-arm64-gnu@npm:16.1.6": + version: 16.1.6 + resolution: "@next/swc-linux-arm64-gnu@npm:16.1.6" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@next/swc-linux-arm64-musl@npm:16.1.6": + version: 16.1.6 + resolution: "@next/swc-linux-arm64-musl@npm:16.1.6" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@next/swc-linux-x64-gnu@npm:16.1.6": + version: 16.1.6 + resolution: "@next/swc-linux-x64-gnu@npm:16.1.6" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@next/swc-linux-x64-musl@npm:16.1.6": + version: 16.1.6 + resolution: "@next/swc-linux-x64-musl@npm:16.1.6" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@next/swc-win32-arm64-msvc@npm:16.1.6": + version: 16.1.6 + resolution: "@next/swc-win32-arm64-msvc@npm:16.1.6" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@next/swc-win32-x64-msvc@npm:16.1.6": + version: 16.1.6 + resolution: "@next/swc-win32-x64-msvc@npm:16.1.6" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@nodelib/fs.scandir@npm:2.1.5": version: 2.1.5 resolution: "@nodelib/fs.scandir@npm:2.1.5" @@ -2488,6 +2791,15 @@ __metadata: languageName: node linkType: hard +"@swc/helpers@npm:0.5.15": + version: 0.5.15 + resolution: "@swc/helpers@npm:0.5.15" + dependencies: + tslib: "npm:^2.8.0" + checksum: 10c0/33002f74f6f885f04c132960835fdfc474186983ea567606db62e86acd0680ca82f34647e8e610f4e1e422d1c16fce729dde22cd3b797ab1fd9061a825dabca4 + languageName: node + linkType: hard + "@swc/types@npm:^0.1.25": version: 0.1.25 resolution: "@swc/types@npm:0.1.25" @@ -4077,6 +4389,15 @@ __metadata: languageName: node linkType: hard +"baseline-browser-mapping@npm:^2.8.3": + version: 2.9.19 + resolution: "baseline-browser-mapping@npm:2.9.19" + bin: + baseline-browser-mapping: dist/cli.js + checksum: 10c0/569928db78bcd081953d7db79e4243a59a579a34b4ae1806b9b42d3b7f84e5bc40e6e82ae4fa06e7bef8291bf747b33b3f9ef5d3c6e1e420cb129d9295536129 + languageName: node + linkType: hard + "baseline-browser-mapping@npm:^2.9.0": version: 2.9.7 resolution: "baseline-browser-mapping@npm:2.9.7" @@ -4556,6 +4877,13 @@ __metadata: languageName: node linkType: hard +"caniuse-lite@npm:^1.0.30001579": + version: 1.0.30001767 + resolution: "caniuse-lite@npm:1.0.30001767" + checksum: 10c0/37067c6d2b26623f81494a1f206adbff2b80baed3318ba430684e428bd7d81be889f39db8ef081501d1db5f7dd5d15972892f173eb59c9f3bb780e0b38e6610a + languageName: node + linkType: hard + "caniuse-lite@npm:^1.0.30001759": version: 1.0.30001760 resolution: "caniuse-lite@npm:1.0.30001760" @@ -4802,6 +5130,13 @@ __metadata: languageName: node linkType: hard +"client-only@npm:0.0.1": + version: 0.0.1 + resolution: "client-only@npm:0.0.1" + checksum: 10c0/9d6cfd0c19e1c96a434605added99dff48482152af791ec4172fb912a71cff9027ff174efd8cdb2160cc7f377543e0537ffc462d4f279bc4701de3f2a3c4b358 + languageName: node + linkType: hard + "cliui@npm:^7.0.2": version: 7.0.4 resolution: "cliui@npm:7.0.4" @@ -5632,6 +5967,13 @@ __metadata: languageName: node linkType: hard +"detect-libc@npm:^2.1.2": + version: 2.1.2 + resolution: "detect-libc@npm:2.1.2" + checksum: 10c0/acc675c29a5649fa1fb6e255f993b8ee829e510b6b56b0910666949c80c364738833417d0edb5f90e4e46be17228b0f2b66a010513984e18b15deeeac49369c4 + languageName: node + linkType: hard + "detect-node-es@npm:^1.1.0": version: 1.1.0 resolution: "detect-node-es@npm:1.1.0" @@ -10408,7 +10750,7 @@ __metadata: languageName: node linkType: hard -"nanoid@npm:^3.3.11": +"nanoid@npm:^3.3.11, nanoid@npm:^3.3.6": version: 3.3.11 resolution: "nanoid@npm:3.3.11" bin: @@ -10461,6 +10803,66 @@ __metadata: languageName: node linkType: hard +"next@npm:16.1.6": + version: 16.1.6 + resolution: "next@npm:16.1.6" + dependencies: + "@next/env": "npm:16.1.6" + "@next/swc-darwin-arm64": "npm:16.1.6" + "@next/swc-darwin-x64": "npm:16.1.6" + "@next/swc-linux-arm64-gnu": "npm:16.1.6" + "@next/swc-linux-arm64-musl": "npm:16.1.6" + "@next/swc-linux-x64-gnu": "npm:16.1.6" + "@next/swc-linux-x64-musl": "npm:16.1.6" + "@next/swc-win32-arm64-msvc": "npm:16.1.6" + "@next/swc-win32-x64-msvc": "npm:16.1.6" + "@swc/helpers": "npm:0.5.15" + baseline-browser-mapping: "npm:^2.8.3" + caniuse-lite: "npm:^1.0.30001579" + postcss: "npm:8.4.31" + sharp: "npm:^0.34.4" + styled-jsx: "npm:5.1.6" + peerDependencies: + "@opentelemetry/api": ^1.1.0 + "@playwright/test": ^1.51.1 + babel-plugin-react-compiler: "*" + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + sass: ^1.3.0 + dependenciesMeta: + "@next/swc-darwin-arm64": + optional: true + "@next/swc-darwin-x64": + optional: true + "@next/swc-linux-arm64-gnu": + optional: true + "@next/swc-linux-arm64-musl": + optional: true + "@next/swc-linux-x64-gnu": + optional: true + "@next/swc-linux-x64-musl": + optional: true + "@next/swc-win32-arm64-msvc": + optional: true + "@next/swc-win32-x64-msvc": + optional: true + sharp: + optional: true + peerDependenciesMeta: + "@opentelemetry/api": + optional: true + "@playwright/test": + optional: true + babel-plugin-react-compiler: + optional: true + sass: + optional: true + bin: + next: dist/bin/next + checksum: 10c0/543766bf879bb5a5d454dc18cb302953270a92efba1d01dd028ea83c64b69573ce7d6e6c3759ecbaabec0a84131b0237263c24d1ccd7c8a97205e776dcd34e0b + languageName: node + linkType: hard + "no-case@npm:^3.0.4": version: 3.0.4 resolution: "no-case@npm:3.0.4" @@ -11597,7 +11999,7 @@ __metadata: languageName: node linkType: hard -"picocolors@npm:^1.1.1": +"picocolors@npm:^1.0.0, picocolors@npm:^1.1.1": version: 1.1.1 resolution: "picocolors@npm:1.1.1" checksum: 10c0/e2e3e8170ab9d7c7421969adaa7e1b31434f789afb9b3f115f6b96d91945041ac3ceb02e9ec6fe6510ff036bcc0bf91e69a1772edc0b707e12b19c0f2d6bcf58 @@ -11772,6 +12174,17 @@ __metadata: languageName: node linkType: hard +"postcss@npm:8.4.31": + version: 8.4.31 + resolution: "postcss@npm:8.4.31" + dependencies: + nanoid: "npm:^3.3.6" + picocolors: "npm:^1.0.0" + source-map-js: "npm:^1.0.2" + checksum: 10c0/748b82e6e5fc34034dcf2ae88ea3d11fd09f69b6c50ecdd3b4a875cfc7cdca435c958b211e2cb52355422ab6fccb7d8f2f2923161d7a1b281029e4a913d59acf + languageName: node + linkType: hard + "postcss@npm:^8.5.6": version: 8.5.6 resolution: "postcss@npm:8.5.6" @@ -13148,6 +13561,90 @@ __metadata: languageName: node linkType: hard +"sharp@npm:^0.34.4": + version: 0.34.5 + resolution: "sharp@npm:0.34.5" + dependencies: + "@img/colour": "npm:^1.0.0" + "@img/sharp-darwin-arm64": "npm:0.34.5" + "@img/sharp-darwin-x64": "npm:0.34.5" + "@img/sharp-libvips-darwin-arm64": "npm:1.2.4" + "@img/sharp-libvips-darwin-x64": "npm:1.2.4" + "@img/sharp-libvips-linux-arm": "npm:1.2.4" + "@img/sharp-libvips-linux-arm64": "npm:1.2.4" + "@img/sharp-libvips-linux-ppc64": "npm:1.2.4" + "@img/sharp-libvips-linux-riscv64": "npm:1.2.4" + "@img/sharp-libvips-linux-s390x": "npm:1.2.4" + "@img/sharp-libvips-linux-x64": "npm:1.2.4" + "@img/sharp-libvips-linuxmusl-arm64": "npm:1.2.4" + "@img/sharp-libvips-linuxmusl-x64": "npm:1.2.4" + "@img/sharp-linux-arm": "npm:0.34.5" + "@img/sharp-linux-arm64": "npm:0.34.5" + "@img/sharp-linux-ppc64": "npm:0.34.5" + "@img/sharp-linux-riscv64": "npm:0.34.5" + "@img/sharp-linux-s390x": "npm:0.34.5" + "@img/sharp-linux-x64": "npm:0.34.5" + "@img/sharp-linuxmusl-arm64": "npm:0.34.5" + "@img/sharp-linuxmusl-x64": "npm:0.34.5" + "@img/sharp-wasm32": "npm:0.34.5" + "@img/sharp-win32-arm64": "npm:0.34.5" + "@img/sharp-win32-ia32": "npm:0.34.5" + "@img/sharp-win32-x64": "npm:0.34.5" + detect-libc: "npm:^2.1.2" + semver: "npm:^7.7.3" + dependenciesMeta: + "@img/sharp-darwin-arm64": + optional: true + "@img/sharp-darwin-x64": + optional: true + "@img/sharp-libvips-darwin-arm64": + optional: true + "@img/sharp-libvips-darwin-x64": + optional: true + "@img/sharp-libvips-linux-arm": + optional: true + "@img/sharp-libvips-linux-arm64": + optional: true + "@img/sharp-libvips-linux-ppc64": + optional: true + "@img/sharp-libvips-linux-riscv64": + optional: true + "@img/sharp-libvips-linux-s390x": + optional: true + "@img/sharp-libvips-linux-x64": + optional: true + "@img/sharp-libvips-linuxmusl-arm64": + optional: true + "@img/sharp-libvips-linuxmusl-x64": + optional: true + "@img/sharp-linux-arm": + optional: true + "@img/sharp-linux-arm64": + optional: true + "@img/sharp-linux-ppc64": + optional: true + "@img/sharp-linux-riscv64": + optional: true + "@img/sharp-linux-s390x": + optional: true + "@img/sharp-linux-x64": + optional: true + "@img/sharp-linuxmusl-arm64": + optional: true + "@img/sharp-linuxmusl-x64": + optional: true + "@img/sharp-wasm32": + optional: true + "@img/sharp-win32-arm64": + optional: true + "@img/sharp-win32-ia32": + optional: true + "@img/sharp-win32-x64": + optional: true + checksum: 10c0/fd79e29df0597a7d5704b8461c51f944ead91a5243691697be6e8243b966402beda53ddc6f0a53b96ea3cb8221f0b244aa588114d3ebf8734fb4aefd41ab802f + languageName: node + linkType: hard + "shebang-command@npm:^2.0.0": version: 2.0.0 resolution: "shebang-command@npm:2.0.0" @@ -13369,7 +13866,7 @@ __metadata: languageName: node linkType: hard -"source-map-js@npm:^1.2.0, source-map-js@npm:^1.2.1": +"source-map-js@npm:^1.0.2, source-map-js@npm:^1.2.0, source-map-js@npm:^1.2.1": version: 1.2.1 resolution: "source-map-js@npm:1.2.1" checksum: 10c0/7bda1fc4c197e3c6ff17de1b8b2c20e60af81b63a52cb32ec5a5d67a20a7d42651e2cb34ebe93833c5a2a084377e17455854fee3e21e7925c64a51b6a52b0faf @@ -13786,6 +14283,22 @@ __metadata: languageName: node linkType: hard +"styled-jsx@npm:5.1.6": + version: 5.1.6 + resolution: "styled-jsx@npm:5.1.6" + dependencies: + client-only: "npm:0.0.1" + peerDependencies: + react: ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + peerDependenciesMeta: + "@babel/core": + optional: true + babel-plugin-macros: + optional: true + checksum: 10c0/ace50e7ea5ae5ae6a3b65a50994c51fca6ae7df9c7ecfd0104c36be0b4b3a9c5c1a2374d16e2a11e256d0b20be6d47256d768ecb4f91ab390f60752a075780f5 + languageName: node + linkType: hard + "supports-color@npm:^7.1.0": version: 7.2.0 resolution: "supports-color@npm:7.2.0" @@ -14166,7 +14679,7 @@ __metadata: languageName: node linkType: hard -"tslib@npm:^2.0.0, tslib@npm:^2.0.1, tslib@npm:^2.0.3, tslib@npm:^2.1.0, tslib@npm:^2.3.0, tslib@npm:^2.4.0": +"tslib@npm:^2.0.0, tslib@npm:^2.0.1, tslib@npm:^2.0.3, tslib@npm:^2.1.0, tslib@npm:^2.3.0, tslib@npm:^2.4.0, tslib@npm:^2.8.0": version: 2.8.1 resolution: "tslib@npm:2.8.1" checksum: 10c0/9c4759110a19c53f992d9aae23aac5ced636e99887b51b9e61def52611732872ff7668757d4e4c61f19691e36f4da981cd9485e869b4a7408d689f6bf1f14e62 From c49fccf441a0059d69b18b944279b4c61ff4d1f3 Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Tue, 3 Feb 2026 14:11:48 +0100 Subject: [PATCH 02/20] Add plan and next steps for Next.js App Router integration --- packages/rum-react/NEXTJS_NEXTSTEPS.md | 350 +++++++++++++++++++++ packages/rum-react/NEXTJS_PLAN.md | 404 +++++++++++++++++++++++++ 2 files changed, 754 insertions(+) create mode 100644 packages/rum-react/NEXTJS_NEXTSTEPS.md create mode 100644 packages/rum-react/NEXTJS_PLAN.md diff --git a/packages/rum-react/NEXTJS_NEXTSTEPS.md b/packages/rum-react/NEXTJS_NEXTSTEPS.md new file mode 100644 index 0000000000..788f389b69 --- /dev/null +++ b/packages/rum-react/NEXTJS_NEXTSTEPS.md @@ -0,0 +1,350 @@ +# Next.js Integration - Next Steps + +## Current Status + +✅ **Phase 1 & 2 Complete**: Core implementation finished + +- All source files created and tested +- TypeScript compilation successful +- Unit tests passing (27 tests) +- Linting passes +- Build artifacts generated + +## Remaining Work + +### Phase 3: E2E Testing + +Create a comprehensive test application and automated E2E tests. + +#### 3.1 Create Test Application + +**Location**: `test/apps/nextjs-app-router/` + +**Structure**: + +``` +test/apps/nextjs-app-router/ +├── app/ +│ ├── layout.tsx # With DatadogRumProvider +│ ├── page.tsx # Home page +│ ├── about/page.tsx # Static route +│ ├── product/[id]/page.tsx # Numeric dynamic route +│ ├── user/[uuid]/page.tsx # UUID dynamic route +│ ├── blog/[...slug]/page.tsx # Catch-all route +│ ├── error.tsx # Error boundary +│ └── components/ +│ └── datadog-provider.tsx # Client component wrapper +├── next.config.js +├── package.json +├── tsconfig.json +└── README.md +``` + +#### 3.2 Playwright E2E Tests + +**Location**: `test/e2e/scenarios/nextjs-app-router.scenario.ts` + +**Test Cases**: + +1. ✅ **Initial Load**: Verify view created on first page load +2. ✅ **Static Navigation**: Navigate from `/` to `/about`, verify new view +3. ✅ **Dynamic Routes - Numeric**: Navigate to `/product/123`, verify view name is `/product/:id` +4. ✅ **Dynamic Routes - UUID**: Navigate to `/user/abc-123-def`, verify view name is `/user/:uuid` +5. ✅ **Dynamic Routes - Multiple**: Test `/orders/456/items/789` → `/orders/:id/items/:id` +6. ✅ **Catch-all Routes**: Navigate to `/blog/2024/01/post`, verify tracking +7. ✅ **Browser Navigation**: Test back/forward buttons +8. ✅ **Error Tracking**: Trigger error, verify captured in Datadog +9. ✅ **View Name Uniqueness**: Different products create same view name pattern +10. ✅ **Query Parameters**: Verify `/product/123?sort=asc` tracked correctly +11. ✅ **Hash Fragments**: Verify `/product/123#reviews` tracked correctly +12. ✅ **SSR/Hydration**: Ensure no hydration mismatches + +**Commands**: + +```bash +# Setup (one time) +yarn test:e2e:init + +# Run Next.js tests +yarn test:e2e -g "nextjs" +``` + +### Phase 4: Documentation + +Update package documentation with comprehensive examples. + +#### 4.1 Update README + +**Location**: `packages/rum-react/README.md` + +**Sections to Add**: + +1. **Next.js App Router Integration** (new section) + - Quick start guide + - Installation instructions + - Basic setup example + - Both initialization patterns + - View name normalization explanation + +2. **Advanced Usage** + - Error boundary integration + - Component performance tracking + - Instrumentation file pattern + - Custom view names (if needed) + +3. **Migration Guide** + - From manual integration + - From Pages Router (if applicable) + +4. **Troubleshooting** + - Common issues + - Configuration checklist + - Debugging tips + +#### 4.2 API Documentation + +Ensure TypeDoc generates proper documentation: + +- Add `packages/rum-react/nextjs/typedoc.json` +- Verify JSDoc comments are complete +- Generate and review docs: `yarn docs:serve` + +#### 4.3 Code Examples + +Create example repository or folder: + +``` +examples/nextjs-app-router/ +├── basic/ # Basic DatadogRumProvider setup +├── instrumentation/ # Using instrumentation-client.ts +├── error-handling/ # Error boundary examples +└── performance-tracking/ # Component tracking examples +``` + +### Phase 5: Manual Testing + +Test with real Next.js applications across different versions. + +#### 5.1 Version Compatibility Testing + +Test with: + +- ✅ **Next.js 13.x** (App Router introduced) +- ✅ **Next.js 14.x** (Current stable) +- ✅ **Next.js 15.x** (Latest) + +Test with: + +- ✅ **React 18.x** +- ✅ **React 19.x** + +#### 5.2 Manual Test Checklist + +- [ ] Install package in fresh Next.js 13+ app +- [ ] Import from `@datadog/browser-rum-react/nextjs` works +- [ ] Add `DatadogRumProvider` to root layout +- [ ] Initialize with `reactPlugin({ nextjs: true })` +- [ ] Navigate between pages, verify views in Datadog RUM UI +- [ ] Check view names match expected patterns (e.g., `/product/:id`) +- [ ] Trigger errors, verify captured in Datadog +- [ ] Add `UNSTABLE_ReactComponentTracker`, verify performance metrics +- [ ] Test SSR (check for hydration errors in console) +- [ ] Test with TypeScript strict mode +- [ ] Verify bundle size impact + +#### 5.3 Integration Testing + +- [ ] Test with common Next.js middleware patterns +- [ ] Test with internationalization (i18n) +- [ ] Test with authentication flows +- [ ] Test with API routes +- [ ] Test with server actions +- [ ] Verify no memory leaks on navigation + +### Phase 6: Release Preparation + +#### 6.1 Changelog + +Add entry to `CHANGELOG.md`: + +```markdown +## [6.27.0] - YYYY-MM-DD + +### Added + +- Next.js App Router integration (`@datadog/browser-rum-react/nextjs`) + - Automatic route tracking with view name normalization + - Support for dynamic routes (numeric IDs and UUIDs) + - DatadogRumProvider component for easy setup + - initDatadogRum helper for instrumentation file pattern + - Compatible with Next.js 13, 14, 15 +``` + +#### 6.2 Migration Guide + +Document for users migrating from manual Next.js integration. + +#### 6.3 Announcement + +Prepare announcement for: + +- Datadog blog post +- GitHub release notes +- Documentation site + +## Optional Enhancements + +These features can be added in future versions based on user feedback. + +### Optional 1: `trackInitialLoad` Option + +Add back the ability to skip initial load tracking for advanced use cases. + +**Use Case**: Users who want to manually create the initial view with custom context. + +**Implementation**: + +```typescript +export interface UsePathnameTrackerOptions { + /** + * Whether to track the initial page load. + * + * @default true + */ + trackInitialLoad?: boolean +} + +export interface DatadogRumProviderProps { + children: ReactNode + + /** + * Whether to track the initial page load. + * + * @default true + */ + trackInitialLoad?: boolean +} +``` + +**Example Usage**: + +```typescript +// instrumentation-client.ts - runs very early +export function register() { + initDatadogRum(config, datadogRum) + + // Manually create initial view with custom data + datadogRum.startView({ + name: '/home', + context: { experimentVariant: 'A' } + }) +} + +// app/layout.tsx +export default function RootLayout({ children }) { + return ( + + + {/* Skip initial load since we handled it manually above */} + + {children} + + + + ) +} +``` + +**Priority**: Low (only add if users request it) + +### Optional 2: Custom View Name Transformer + +Allow users to customize view name normalization. + +**Use Case**: Users with custom dynamic route patterns not covered by default normalization. + +**Implementation**: + +```typescript +export interface NextjsPluginConfiguration { + /** + * Custom function to normalize view names. + * If not provided, uses default normalization (numeric IDs and UUIDs). + */ + normalizeViewName?: (pathname: string) => string +} + +// Usage +reactPlugin({ + nextjs: true, + normalizeViewName: (pathname) => { + // Custom logic + return pathname.replace(/\/sku-\d+/g, '/:sku') + }, +}) +``` + +**Priority**: Low (most users should be fine with default behavior) + +### Optional 3: Pages Router Support + +Extend to support Next.js Pages Router (not just App Router). + +**Implementation**: Similar pattern but using `next/router` instead of `next/navigation`. + +**Priority**: Medium (if users request it, but App Router is the future) + +### Optional 4: Middleware Integration + +Provide helper for tracking in Next.js middleware. + +**Use Case**: Track server-side redirects, authentication checks, etc. + +**Priority**: Low (most tracking is client-side) + +### Optional 5: TypedRoutes Support + +Integration with Next.js experimental typed routes feature. + +**Priority**: Low (experimental feature, wait for stability) + +## Questions to Resolve + +1. **Should we provide a Next.js plugin/template?** + - Create a `create-next-app` template with Datadog pre-configured + - Or a Next.js plugin that auto-configures the integration + +2. **Should we support Pages Router?** + - Current implementation is App Router only + - Pages Router uses different APIs (`next/router` vs `next/navigation`) + +3. **How should we handle incremental static regeneration (ISR)?** + - View tracking might behave differently + - Need to test and document + +4. **Should view names be configurable?** + - Current: automatic normalization (not configurable) + - Alternative: allow custom patterns via configuration + +5. **How should we handle middleware tracking?** + - Currently focused on client-side tracking + - Server-side tracking might need different approach + +## Timeline Estimate + +- **Phase 3 (E2E Testing)**: 3-5 days +- **Phase 4 (Documentation)**: 2-3 days +- **Phase 5 (Manual Testing)**: 2-3 days +- **Phase 6 (Release Prep)**: 1 day + +**Total**: ~2 weeks for complete release-ready state + +## Success Metrics + +After release, track: + +- Adoption rate (npm downloads) +- GitHub issues related to Next.js integration +- User feedback and feature requests +- Performance impact reports +- Documentation clarity (support tickets) diff --git a/packages/rum-react/NEXTJS_PLAN.md b/packages/rum-react/NEXTJS_PLAN.md new file mode 100644 index 0000000000..92760d2c7c --- /dev/null +++ b/packages/rum-react/NEXTJS_PLAN.md @@ -0,0 +1,404 @@ +# Next.js App Router Integration Plan + +## Overview + +Create a Next.js App Router integration as a new entry point within `@datadog/browser-rum-react` package (following the react-router-v6/v7 pattern). This integration will provide automatic route tracking, error boundary support, and component performance tracking for Next.js 13+ applications. + +**Package**: `@datadog/browser-rum-react/nextjs` + +## Architecture Decisions + +### 1. Package Structure + +- **Extend rum-react** with `/nextjs` entry point (not a separate package) +- Maximizes code reuse for ErrorBoundary and UNSTABLE_ReactComponentTracker +- Follows established pattern of react-router-v6/v7 entry points + +### 2. Initialization Patterns + +Support **both** patterns to accommodate different use cases: + +- **DatadogRumProvider component** (primary/recommended): For use in app/layout.tsx +- **Instrumentation file approach**: For early initialization using instrumentation-client.ts + +### 3. View Naming Strategy + +- **Automatic pattern detection**: Like React Router, view names use patterns not actual values +- Built-in transformation: `/product/123` → `/product/:id`, `/user/abc-123-def` → `/user/:uuid` +- Works automatically with no configuration required (matches React Router behavior) + +### 4. Plugin Configuration + +- Add `nextjs: boolean` flag to `ReactPluginConfiguration` +- When enabled, sets `trackViewsManually: true` automatically + +## Components & Exports + +### Main Exports (from `@datadog/browser-rum-react/nextjs`) + +**Initialization:** + +- `DatadogRumProvider` - Component wrapper for automatic route tracking +- `initDatadogRum(config)` - Helper for instrumentation file usage + +**Error Tracking:** + +- `ErrorBoundary` - Reused from rum-react +- `addReactError` - Reused from rum-react + +**Performance:** + +- `UNSTABLE_ReactComponentTracker` - Reused from rum-react + +**Manual Control (Advanced):** + +- `usePathnameTracker()` - Hook for custom tracking logic (rare use case) + +**Types:** + +- `NextjsRumConfig` - Configuration interface for instrumentation file +- `DatadogRumProviderProps` - Provider component props + +## Implementation Files + +### New Files Created + +#### Core Integration + +``` +packages/rum-react/src/domain/nextjs/ +├── index.ts # Barrel export +├── types.ts # TypeScript interfaces +├── datadogRumProvider.tsx # Main provider component +├── datadogRumProvider.spec.tsx # Provider tests +├── usePathnameTracker.ts # Route change detection hook +├── usePathnameTracker.spec.ts # Hook tests +├── startNextjsView.ts # View creation logic +├── startNextjsView.spec.ts # View creation tests +├── normalizeViewName.ts # Internal view name normalization +├── normalizeViewName.spec.ts # Normalization tests +└── initDatadogRum.ts # Instrumentation file helper +``` + +#### Entry Point + +``` +packages/rum-react/src/entries/ +└── nextjs.ts # Main entry point + +packages/rum-react/nextjs/ +├── package.json # Points to ../esm/entries/nextjs.js +└── typedoc.json # Documentation config +``` + +### Files Modified + +**packages/rum-react/src/domain/reactPlugin.ts** + +- Add `nextjs?: boolean` to `ReactPluginConfiguration` interface +- Set `trackViewsManually: true` when `nextjs: true` + +**packages/rum-react/package.json** + +- Add `"next": ">=13"` to `peerDependencies` +- Mark as optional in `peerDependenciesMeta` + +**packages/rum-react/README.md** + +- Add Next.js App Router section with usage examples + +## Technical Implementation Details + +### 1. DatadogRumProvider Component + +```typescript +// src/domain/nextjs/datadogRumProvider.tsx +'use client' + +export function DatadogRumProvider({ children }: DatadogRumProviderProps) { + usePathnameTracker() + return <>{children} +} +``` + +**Key features:** + +- Must be client component (`'use client'` directive) +- Uses `usePathnameTracker` internally +- Automatically normalizes dynamic segments (no configuration needed) +- Transparent wrapper (no DOM nodes) +- Always tracks initial page load (matches React Router behavior) + +### 2. usePathnameTracker Hook + +```typescript +// src/domain/nextjs/usePathnameTracker.ts +import { usePathname } from 'next/navigation' +import { useRef, useEffect } from 'react' + +export function usePathnameTracker() { + const pathname = usePathname() + const pathnameRef = useRef(null) + + useEffect(() => { + if (pathnameRef.current !== pathname) { + pathnameRef.current = pathname + startNextjsView(pathname) + } + }, [pathname]) +} +``` + +**Implementation notes:** + +- Uses Next.js `usePathname()` hook +- `useRef` to avoid unnecessary re-renders +- `useEffect` to ensure client-side only execution +- Tracks initial page load (consistent with React Router) +- Automatically normalizes view names (no config needed) + +### 3. startNextjsView Function + +```typescript +// src/domain/nextjs/startNextjsView.ts +export function startNextjsView(pathname: string) { + onRumInit((configuration, rumPublicApi) => { + if (!configuration.nextjs) { + display.warn('`nextjs: true` is missing from the react plugin configuration, ' + 'the view will not be tracked.') + return + } + + const viewName = normalizeViewName(pathname) + rumPublicApi.startView(viewName) + }) +} +``` + +**Key behaviors:** + +- Uses `onRumInit` subscription pattern (from reactPlugin) +- Checks `configuration.nextjs` flag +- Applies automatic view name normalization +- Calls `rumPublicApi.startView()` with normalized name + +### 4. View Name Normalization (Internal) + +```typescript +// src/domain/nextjs/normalizeViewName.ts + +/** + * Internal function that automatically normalizes pathnames to route patterns. + * Mimics React Router behavior where view names use placeholders. + * + * Examples: + * /product/123 -> /product/:id + * /user/abc-123-def-456 -> /user/:uuid + * /orders/456/items/789 -> /orders/:id/items/:id + */ +export function normalizeViewName(pathname: string): string { + return ( + pathname + // Replace UUID segments first (more specific pattern) + .replace(/\/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}(?=\/|[?#]|$)/gi, '/:uuid') + // Replace numeric segments + .replace(/\/\d+(?=\/|[?#]|$)/g, '/:id') + ) +} +``` + +**Note**: This function is internal and not exported. It automatically applies pattern detection to match React Router's behavior of showing route patterns rather than actual values. + +### 5. Instrumentation File Helper + +```typescript +// src/domain/nextjs/initDatadogRum.ts + +/** + * Helper for Next.js instrumentation-client.ts file. + * Initializes RUM and sets up global error tracking. + */ +export function initDatadogRum(config: NextjsRumConfig, datadogRum: RumPublicApi): void { + if (typeof window === 'undefined') { + // Server-side guard + return + } + + const { datadogConfig, nextjsConfig } = config + const nextjsPlugin = reactPlugin({ nextjs: true }) + const existingPlugins = (datadogConfig.plugins || []) as Array + + datadogRum.init({ + ...datadogConfig, + plugins: [nextjsPlugin].concat(existingPlugins), + }) + + // Optional: Set up early error capture + if (nextjsConfig?.captureEarlyErrors) { + addEventListener({}, window, 'error', (event) => { + datadogRum.addError(event.error) + }) + + addEventListener({}, window, 'unhandledrejection', (event: PromiseRejectionEvent) => { + datadogRum.addError(event.reason) + }) + } +} +``` + +## Usage Patterns + +### Pattern 1: DatadogRumProvider (Recommended) + +```typescript +// app/components/datadog-provider.tsx +'use client' +import { DatadogRumProvider } from '@datadog/browser-rum-react/nextjs' + +export function DatadogProvider({ children }) { + return {children} +} + +// app/layout.tsx +import { datadogRum } from '@datadog/browser-rum' +import { reactPlugin } from '@datadog/browser-rum-react' +import { DatadogProvider } from './components/datadog-provider' + +datadogRum.init({ + applicationId: '', + clientToken: '', + site: 'datadoghq.com', + plugins: [reactPlugin({ nextjs: true })], +}) + +export default function RootLayout({ children }) { + return ( + + + {children} + + + ) +} +``` + +### Pattern 2: Instrumentation File (Advanced) + +```typescript +// instrumentation-client.ts +import { initDatadogRum } from '@datadog/browser-rum-react/nextjs' + +export function register() { + initDatadogRum({ + datadogConfig: { + applicationId: '', + clientToken: '', + site: 'datadoghq.com', + }, + nextjsConfig: { + captureEarlyErrors: true, + } + }) +} + +// app/components/router-tracker.tsx +'use client' +import { usePathnameTracker } from '@datadog/browser-rum-react/nextjs' + +export function RouterTracker() { + usePathnameTracker() + return null +} + +// app/layout.tsx +import { RouterTracker } from './components/router-tracker' + +export default function RootLayout({ children }) { + return ( + + + + {children} + + + ) +} +``` + +## Error Tracking Integration + +### With Next.js error.js + +```typescript +// app/error.tsx +'use client' +import { useEffect } from 'react' +import { addReactError } from '@datadog/browser-rum-react/nextjs' + +export default function Error({ error, reset }) { + useEffect(() => { + addReactError(error, { componentStack: '' }) + }, [error]) + + return ( +
+

Something went wrong!

+ +
+ ) +} +``` + +### With Datadog ErrorBoundary + +```typescript +// app/layout.tsx or page-level +import { ErrorBoundary } from '@datadog/browser-rum-react/nextjs' + +function ErrorFallback({ error, resetError }) { + return ( +
+

Error: {error.message}

+ +
+ ) +} + +export default function Layout({ children }) { + return ( + + {children} + + ) +} +``` + +## Performance Tracking + +```typescript +// app/dashboard/page.tsx +'use client' +import { UNSTABLE_ReactComponentTracker } from '@datadog/browser-rum-react/nextjs' +import { DashboardWidget } from './components/widget' + +export default function DashboardPage() { + return ( + + + + ) +} +``` + +## Success Criteria + +- ✅ View tracking works for Next.js App Router navigation +- ✅ Dynamic routes normalized with pattern detection +- ✅ Error tracking integrates with Next.js error boundaries +- ✅ Component performance tracking works client-side +- ✅ Both initialization patterns supported and documented +- ✅ >90% test coverage for new code +- ✅ Zero TypeScript errors +- ✅ Works with Next.js 13, 14, 15 +- ✅ Clear documentation with multiple examples +- ✅ <5 minute setup time for developers +- ✅ Aligns with React Router behavior (always tracks initial load) From 4d3dcaa047ee24fbb50b0c05a2232c9d657d06c6 Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Thu, 5 Feb 2026 12:21:09 +0100 Subject: [PATCH 03/20] Add next to npmignore to fix duplicate package error --- packages/rum-react/.npmignore | 1 + .../rum-react/src/domain/nextjs/viewTracking.tsx | 13 +++---------- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/packages/rum-react/.npmignore b/packages/rum-react/.npmignore index 4049b9b2d8..c239794535 100644 --- a/packages/rum-react/.npmignore +++ b/packages/rum-react/.npmignore @@ -5,3 +5,4 @@ /src/**/*.spec.ts /src/**/*.specHelper.ts !/react-router-v[6-7]/* +!/nextjs/* diff --git a/packages/rum-react/src/domain/nextjs/viewTracking.tsx b/packages/rum-react/src/domain/nextjs/viewTracking.tsx index 6ceffe763b..aca0f8215f 100644 --- a/packages/rum-react/src/domain/nextjs/viewTracking.tsx +++ b/packages/rum-react/src/domain/nextjs/viewTracking.tsx @@ -7,16 +7,9 @@ import { onRumInit } from '../reactPlugin' * Normalizes the pathname to use route patterns (e.g., /product/123 -> /product/:id). */ export function normalizeViewName(pathname: string): string { - return ( - pathname - // Replace UUID segments first (more specific pattern) - // Matches: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx (8-4-4-4-12 hex digits) - // Match complete UUIDs followed by /, ?, #, or end of string - .replace(/\/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}(?=\/|[?#]|$)/gi, '/:uuid') - // Replace numeric segments (match complete numeric path segments) - // Followed by /, ?, #, or end of string (not hyphens or other characters) - .replace(/\/\d+(?=\/|[?#]|$)/g, '/:id') - ) + return pathname + .replace(/\/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}(?=\/|[?#]|$)/gi, '/:uuid') + .replace(/\/\d+(?=\/|[?#]|$)/g, '/:id') } /** From ba77231346b928eadc2e38d04465fbde69d2be09 Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Thu, 5 Feb 2026 13:38:34 +0100 Subject: [PATCH 04/20] Add test app for nextjs app router integration --- .prettierignore | 1 + eslint.config.mjs | 1 + test/apps/nextjs-app-router/.gitignore | 3 + test/apps/nextjs-app-router/README.md | 46 ++ .../apps/nextjs-app-router/app/ddprovider.tsx | 21 + .../nextjs-app-router/app/error-test/page.tsx | 61 ++ test/apps/nextjs-app-router/app/layout.tsx | 24 + test/apps/nextjs-app-router/app/page.tsx | 23 + .../nextjs-app-router/app/tracked/page.tsx | 16 + .../nextjs-app-router/app/user/[id]/page.tsx | 13 + test/apps/nextjs-app-router/next-env.d.ts | 6 + test/apps/nextjs-app-router/next.config.js | 4 + test/apps/nextjs-app-router/package.json | 34 + test/apps/nextjs-app-router/tsconfig.json | 27 + test/apps/nextjs-app-router/yarn.lock | 715 ++++++++++++++++++ 15 files changed, 995 insertions(+) create mode 100644 test/apps/nextjs-app-router/.gitignore create mode 100644 test/apps/nextjs-app-router/README.md create mode 100644 test/apps/nextjs-app-router/app/ddprovider.tsx create mode 100644 test/apps/nextjs-app-router/app/error-test/page.tsx create mode 100644 test/apps/nextjs-app-router/app/layout.tsx create mode 100644 test/apps/nextjs-app-router/app/page.tsx create mode 100644 test/apps/nextjs-app-router/app/tracked/page.tsx create mode 100644 test/apps/nextjs-app-router/app/user/[id]/page.tsx create mode 100644 test/apps/nextjs-app-router/next-env.d.ts create mode 100644 test/apps/nextjs-app-router/next.config.js create mode 100644 test/apps/nextjs-app-router/package.json create mode 100644 test/apps/nextjs-app-router/tsconfig.json create mode 100644 test/apps/nextjs-app-router/yarn.lock diff --git a/.prettierignore b/.prettierignore index cd4dd6cc00..d1ee84bc43 100644 --- a/.prettierignore +++ b/.prettierignore @@ -9,3 +9,4 @@ yarn.lock /docs /developer-extension/.output /developer-extension/.wxt +/test/apps/nextjs-app-router/.next diff --git a/eslint.config.mjs b/eslint.config.mjs index 0c04bbf93a..8ec65cc36a 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -35,6 +35,7 @@ export default tseslint.config( 'docs', 'developer-extension/.wxt', 'developer-extension/.output', + 'test/apps/nextjs-app-router', ], }, diff --git a/test/apps/nextjs-app-router/.gitignore b/test/apps/nextjs-app-router/.gitignore new file mode 100644 index 0000000000..20e9477154 --- /dev/null +++ b/test/apps/nextjs-app-router/.gitignore @@ -0,0 +1,3 @@ +.next +node_modules +.yarn/* \ No newline at end of file diff --git a/test/apps/nextjs-app-router/README.md b/test/apps/nextjs-app-router/README.md new file mode 100644 index 0000000000..feab2a5ce0 --- /dev/null +++ b/test/apps/nextjs-app-router/README.md @@ -0,0 +1,46 @@ +# Next.js App Router Test App + +Test application for the `@datadog/browser-rum-react/nextjs` integration. + +## Rebuilding After Changes + +When you make changes to the SDK packages, run these commands: + +### One-liner (from repo root) + +```bash +cd packages/rum-react && yarn build && yarn pack --out package.tgz && cd ../../test/apps/nextjs-app-router && rm -rf node_modules && yarn cache clean --all && yarn install && yarn dev +``` + +### Step by step + +```bash +# 1. Build and pack rum-react (from repo root) +cd packages/rum-react +yarn build +yarn pack --out package.tgz + +# 2. Reinstall in test app +cd ../../test/apps/nextjs-app-router +rm -rf node_modules +yarn cache clean --all +yarn install + +# 3. Start dev server +yarn dev +``` + +App available at http://localhost:3000 + +## Important Notes + +- **Always clear cache**: Yarn caches `.tgz` files by hash. Run `yarn cache clean --all` before reinstalling. +- **Remove node_modules**: Required for a clean install after repacking. +- **Clear Next.js cache**: If you see stale code, run `rm -rf .next` + +## Test Routes + +- `/` - Home page +- `/user/42` - Dynamic route (normalizes to `/user/:id`) +- `/tracked` - Component tracking demo +- `/error-test` - Error boundary testing diff --git a/test/apps/nextjs-app-router/app/ddprovider.tsx b/test/apps/nextjs-app-router/app/ddprovider.tsx new file mode 100644 index 0000000000..1c4b9b301a --- /dev/null +++ b/test/apps/nextjs-app-router/app/ddprovider.tsx @@ -0,0 +1,21 @@ +'use client' + +import { DatadogRumProvider } from '@datadog/browser-rum-react/nextjs' +import { datadogRum } from '@datadog/browser-rum' +import { reactPlugin } from '@datadog/browser-rum-react' + +datadogRum.init({ + applicationId: 'a81f40b8-e9bd-4805-9b66-4e4edc529a14', + clientToken: 'pubfe2e138a54296da76dd66f6b0b5f3d98', + site: 'datad0g.com', + service: 'nextjs-app-router-test', + env: 'development', + sessionSampleRate: 100, + sessionReplaySampleRate: 100, + defaultPrivacyLevel: 'allow', + plugins: [reactPlugin({ nextjs: true })], +}) + +export function DatadogProvider({ children }: { children: React.ReactNode }) { + return {children} +} diff --git a/test/apps/nextjs-app-router/app/error-test/page.tsx b/test/apps/nextjs-app-router/app/error-test/page.tsx new file mode 100644 index 0000000000..d4e877bf09 --- /dev/null +++ b/test/apps/nextjs-app-router/app/error-test/page.tsx @@ -0,0 +1,61 @@ +'use client' + +import { useState } from 'react' +import Link from 'next/link' +import { ErrorBoundary } from '@datadog/browser-rum-react' + +function ComponentWithErrorButton() { + const [shouldError, setShouldError] = useState(false) + + if (shouldError) { + throw new Error('Error triggered by button click') + } + + return ( +
+

Click button to trigger error

+ +
+ ) +} + +export default function ErrorTestPage() { + const throwAsyncError = async () => { + await new Promise((resolve) => setTimeout(resolve, 100)) + throw new Error('Test asynchronous error') + } + + const throwUnhandledRejection = () => { + Promise.reject(new Error('Test unhandled promise rejection')) + } + + return ( +
+ ← Back to Home +

Error Testing

+ +
+

Error Boundary (Datadog RUM)

+ ( +
+

Something went wrong

+

{error.message}

+ +
+ )} + > + +
+
+ +
+

Other Error Types

+ + +
+
+ ) +} diff --git a/test/apps/nextjs-app-router/app/layout.tsx b/test/apps/nextjs-app-router/app/layout.tsx new file mode 100644 index 0000000000..62d9de480f --- /dev/null +++ b/test/apps/nextjs-app-router/app/layout.tsx @@ -0,0 +1,24 @@ +import type { Metadata } from 'next' +import { DatadogProvider } from './ddprovider' + +export const metadata: Metadata = { + title: 'Next.js App Router Test', + description: 'Test app for Datadog RUM Next.js integration', +} + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + + +
{children}
+
+ + + ) +} diff --git a/test/apps/nextjs-app-router/app/page.tsx b/test/apps/nextjs-app-router/app/page.tsx new file mode 100644 index 0000000000..22699faf36 --- /dev/null +++ b/test/apps/nextjs-app-router/app/page.tsx @@ -0,0 +1,23 @@ +import Link from 'next/link' + +export default function HomePage() { + return ( +
+

Home

+
    +
  • + Go to User 42 +
  • +
  • + Go to User 123 +
  • +
  • + Go to Tracked Component +
  • +
  • + Go to Error Test +
  • +
+
+ ) +} diff --git a/test/apps/nextjs-app-router/app/tracked/page.tsx b/test/apps/nextjs-app-router/app/tracked/page.tsx new file mode 100644 index 0000000000..12401a8b48 --- /dev/null +++ b/test/apps/nextjs-app-router/app/tracked/page.tsx @@ -0,0 +1,16 @@ +'use client' + +import Link from 'next/link' +import { UNSTABLE_ReactComponentTracker as ReactComponentTracker } from '@datadog/browser-rum-react' + +export default function TrackedPage() { + return ( +
+ ← Back to Home + +

Component Tracker

+

This component is tracked for performance metrics.

+
+
+ ) +} diff --git a/test/apps/nextjs-app-router/app/user/[id]/page.tsx b/test/apps/nextjs-app-router/app/user/[id]/page.tsx new file mode 100644 index 0000000000..5a496c9ba4 --- /dev/null +++ b/test/apps/nextjs-app-router/app/user/[id]/page.tsx @@ -0,0 +1,13 @@ +import Link from 'next/link' + +export default async function UserPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params + + return ( +
+ ← Back to Home +

User {id}

+

This is a dynamic route testing view name normalization.

+
+ ) +} diff --git a/test/apps/nextjs-app-router/next-env.d.ts b/test/apps/nextjs-app-router/next-env.d.ts new file mode 100644 index 0000000000..c4b7818fbb --- /dev/null +++ b/test/apps/nextjs-app-router/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +import "./.next/dev/types/routes.d.ts"; + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/test/apps/nextjs-app-router/next.config.js b/test/apps/nextjs-app-router/next.config.js new file mode 100644 index 0000000000..767719fc4f --- /dev/null +++ b/test/apps/nextjs-app-router/next.config.js @@ -0,0 +1,4 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = {} + +module.exports = nextConfig diff --git a/test/apps/nextjs-app-router/package.json b/test/apps/nextjs-app-router/package.json new file mode 100644 index 0000000000..b342c95c88 --- /dev/null +++ b/test/apps/nextjs-app-router/package.json @@ -0,0 +1,34 @@ +{ + "name": "nextjs-app-router", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev --port 3000", + "build": "next build", + "start": "next start --port 3000" + }, + "dependencies": { + "@datadog/browser-rum": "file:../../../packages/rum/package.tgz", + "@datadog/browser-rum-react": "file:../../../packages/rum-react/package.tgz", + "next": "16.1.6", + "react": "19.2.3", + "react-dom": "19.2.3" + }, + "resolutions": { + "@datadog/browser-rum-core": "file:../../../packages/rum-core/package.tgz", + "@datadog/browser-core": "file:../../../packages/core/package.tgz", + "@datadog/browser-rum": "file:../../../packages/rum/package.tgz", + "@datadog/browser-rum-react": "file:../../../packages/rum-react/package.tgz", + "@datadog/browser-rum-slim": "file:../../../packages/rum-slim/package.tgz", + "@datadog/browser-worker": "file:../../../packages/worker/package.tgz" + }, + "devDependencies": { + "@types/node": "22.16.0", + "@types/react": "19.2.8", + "@types/react-dom": "19.2.3", + "typescript": "5.9.3" + }, + "volta": { + "extends": "../../../package.json" + } +} diff --git a/test/apps/nextjs-app-router/tsconfig.json b/test/apps/nextjs-app-router/tsconfig.json new file mode 100644 index 0000000000..f22e40d712 --- /dev/null +++ b/test/apps/nextjs-app-router/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./*"] + }, + "target": "ES2017" + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/test/apps/nextjs-app-router/yarn.lock b/test/apps/nextjs-app-router/yarn.lock new file mode 100644 index 0000000000..f1ea97bef1 --- /dev/null +++ b/test/apps/nextjs-app-router/yarn.lock @@ -0,0 +1,715 @@ +# This file is generated by running "yarn install" inside your project. +# Manual changes might be lost - proceed with caution! + +__metadata: + version: 8 + cacheKey: 10c0 + +"@datadog/browser-core@file:../../../packages/core/package.tgz::locator=nextjs-app-router%40workspace%3A.": + version: 6.26.0 + resolution: "@datadog/browser-core@file:../../../packages/core/package.tgz#../../../packages/core/package.tgz::hash=187b55&locator=nextjs-app-router%40workspace%3A." + checksum: 10c0/19ac48eba81c641b6c6598e2a3d9d0533ff1f422d26f563ed984888c8ab488e73127f2877942fc0816eec3fb2e91eca0fadf97f8ecaf9af914b7ea0591a96c68 + languageName: node + linkType: hard + +"@datadog/browser-rum-core@file:../../../packages/rum-core/package.tgz::locator=nextjs-app-router%40workspace%3A.": + version: 6.26.0 + resolution: "@datadog/browser-rum-core@file:../../../packages/rum-core/package.tgz#../../../packages/rum-core/package.tgz::hash=ab8181&locator=nextjs-app-router%40workspace%3A." + dependencies: + "@datadog/browser-core": "npm:6.26.0" + checksum: 10c0/24d0d5207c6df687bf9caae795a8598136c3788daa96bb4c321b30de9838b126dfec418df3cdbbbcf3fa2608b47e552581dfe3d0c1134811c98c306891b343d7 + languageName: node + linkType: hard + +"@datadog/browser-rum-react@file:../../../packages/rum-react/package.tgz::locator=nextjs-app-router%40workspace%3A.": + version: 6.26.0 + resolution: "@datadog/browser-rum-react@file:../../../packages/rum-react/package.tgz#../../../packages/rum-react/package.tgz::hash=f71519&locator=nextjs-app-router%40workspace%3A." + dependencies: + "@datadog/browser-core": "npm:6.26.0" + "@datadog/browser-rum-core": "npm:6.26.0" + peerDependencies: + next: ">=13" + react: 18 || 19 + react-router: 6 || 7 + react-router-dom: 6 || 7 + peerDependenciesMeta: + "@datadog/browser-rum": + optional: true + "@datadog/browser-rum-slim": + optional: true + next: + optional: true + react: + optional: true + react-router: + optional: true + react-router-dom: + optional: true + checksum: 10c0/c94598ac8926ba88d9f6a06bb347ee42b8e2615372a54c6a8405dfa9c8bb93d77ded7d51433bd804882d9814c357c4f5003dabf4a022eddf6e91bc39d7723a57 + languageName: node + linkType: hard + +"@datadog/browser-rum@file:../../../packages/rum/package.tgz::locator=nextjs-app-router%40workspace%3A.": + version: 6.26.0 + resolution: "@datadog/browser-rum@file:../../../packages/rum/package.tgz#../../../packages/rum/package.tgz::hash=8b8895&locator=nextjs-app-router%40workspace%3A." + dependencies: + "@datadog/browser-core": "npm:6.26.0" + "@datadog/browser-rum-core": "npm:6.26.0" + peerDependencies: + "@datadog/browser-logs": 6.26.0 + peerDependenciesMeta: + "@datadog/browser-logs": + optional: true + checksum: 10c0/2f8f6fa898bb2d9a6ea3efe35025c09b9e865ecf3fe7bab983f0e8df6d296778662fb4da8df0d396b37e01b723cf38af03443dd3982323bef12d3658b9e8be42 + languageName: node + linkType: hard + +"@emnapi/runtime@npm:^1.7.0": + version: 1.8.1 + resolution: "@emnapi/runtime@npm:1.8.1" + dependencies: + tslib: "npm:^2.4.0" + checksum: 10c0/f4929d75e37aafb24da77d2f58816761fe3f826aad2e37fa6d4421dac9060cbd5098eea1ac3c9ecc4526b89deb58153852fa432f87021dc57863f2ff726d713f + languageName: node + linkType: hard + +"@img/colour@npm:^1.0.0": + version: 1.0.0 + resolution: "@img/colour@npm:1.0.0" + checksum: 10c0/02261719c1e0d7aa5a2d585981954f2ac126f0c432400aa1a01b925aa2c41417b7695da8544ee04fd29eba7ecea8eaf9b8bef06f19dc8faba78f94eeac40667d + languageName: node + linkType: hard + +"@img/sharp-darwin-arm64@npm:0.34.5": + version: 0.34.5 + resolution: "@img/sharp-darwin-arm64@npm:0.34.5" + dependencies: + "@img/sharp-libvips-darwin-arm64": "npm:1.2.4" + dependenciesMeta: + "@img/sharp-libvips-darwin-arm64": + optional: true + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@img/sharp-darwin-x64@npm:0.34.5": + version: 0.34.5 + resolution: "@img/sharp-darwin-x64@npm:0.34.5" + dependencies: + "@img/sharp-libvips-darwin-x64": "npm:1.2.4" + dependenciesMeta: + "@img/sharp-libvips-darwin-x64": + optional: true + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@img/sharp-libvips-darwin-arm64@npm:1.2.4": + version: 1.2.4 + resolution: "@img/sharp-libvips-darwin-arm64@npm:1.2.4" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@img/sharp-libvips-darwin-x64@npm:1.2.4": + version: 1.2.4 + resolution: "@img/sharp-libvips-darwin-x64@npm:1.2.4" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@img/sharp-libvips-linux-arm64@npm:1.2.4": + version: 1.2.4 + resolution: "@img/sharp-libvips-linux-arm64@npm:1.2.4" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@img/sharp-libvips-linux-arm@npm:1.2.4": + version: 1.2.4 + resolution: "@img/sharp-libvips-linux-arm@npm:1.2.4" + conditions: os=linux & cpu=arm & libc=glibc + languageName: node + linkType: hard + +"@img/sharp-libvips-linux-ppc64@npm:1.2.4": + version: 1.2.4 + resolution: "@img/sharp-libvips-linux-ppc64@npm:1.2.4" + conditions: os=linux & cpu=ppc64 & libc=glibc + languageName: node + linkType: hard + +"@img/sharp-libvips-linux-riscv64@npm:1.2.4": + version: 1.2.4 + resolution: "@img/sharp-libvips-linux-riscv64@npm:1.2.4" + conditions: os=linux & cpu=riscv64 & libc=glibc + languageName: node + linkType: hard + +"@img/sharp-libvips-linux-s390x@npm:1.2.4": + version: 1.2.4 + resolution: "@img/sharp-libvips-linux-s390x@npm:1.2.4" + conditions: os=linux & cpu=s390x & libc=glibc + languageName: node + linkType: hard + +"@img/sharp-libvips-linux-x64@npm:1.2.4": + version: 1.2.4 + resolution: "@img/sharp-libvips-linux-x64@npm:1.2.4" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@img/sharp-libvips-linuxmusl-arm64@npm:1.2.4": + version: 1.2.4 + resolution: "@img/sharp-libvips-linuxmusl-arm64@npm:1.2.4" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@img/sharp-libvips-linuxmusl-x64@npm:1.2.4": + version: 1.2.4 + resolution: "@img/sharp-libvips-linuxmusl-x64@npm:1.2.4" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@img/sharp-linux-arm64@npm:0.34.5": + version: 0.34.5 + resolution: "@img/sharp-linux-arm64@npm:0.34.5" + dependencies: + "@img/sharp-libvips-linux-arm64": "npm:1.2.4" + dependenciesMeta: + "@img/sharp-libvips-linux-arm64": + optional: true + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@img/sharp-linux-arm@npm:0.34.5": + version: 0.34.5 + resolution: "@img/sharp-linux-arm@npm:0.34.5" + dependencies: + "@img/sharp-libvips-linux-arm": "npm:1.2.4" + dependenciesMeta: + "@img/sharp-libvips-linux-arm": + optional: true + conditions: os=linux & cpu=arm & libc=glibc + languageName: node + linkType: hard + +"@img/sharp-linux-ppc64@npm:0.34.5": + version: 0.34.5 + resolution: "@img/sharp-linux-ppc64@npm:0.34.5" + dependencies: + "@img/sharp-libvips-linux-ppc64": "npm:1.2.4" + dependenciesMeta: + "@img/sharp-libvips-linux-ppc64": + optional: true + conditions: os=linux & cpu=ppc64 & libc=glibc + languageName: node + linkType: hard + +"@img/sharp-linux-riscv64@npm:0.34.5": + version: 0.34.5 + resolution: "@img/sharp-linux-riscv64@npm:0.34.5" + dependencies: + "@img/sharp-libvips-linux-riscv64": "npm:1.2.4" + dependenciesMeta: + "@img/sharp-libvips-linux-riscv64": + optional: true + conditions: os=linux & cpu=riscv64 & libc=glibc + languageName: node + linkType: hard + +"@img/sharp-linux-s390x@npm:0.34.5": + version: 0.34.5 + resolution: "@img/sharp-linux-s390x@npm:0.34.5" + dependencies: + "@img/sharp-libvips-linux-s390x": "npm:1.2.4" + dependenciesMeta: + "@img/sharp-libvips-linux-s390x": + optional: true + conditions: os=linux & cpu=s390x & libc=glibc + languageName: node + linkType: hard + +"@img/sharp-linux-x64@npm:0.34.5": + version: 0.34.5 + resolution: "@img/sharp-linux-x64@npm:0.34.5" + dependencies: + "@img/sharp-libvips-linux-x64": "npm:1.2.4" + dependenciesMeta: + "@img/sharp-libvips-linux-x64": + optional: true + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@img/sharp-linuxmusl-arm64@npm:0.34.5": + version: 0.34.5 + resolution: "@img/sharp-linuxmusl-arm64@npm:0.34.5" + dependencies: + "@img/sharp-libvips-linuxmusl-arm64": "npm:1.2.4" + dependenciesMeta: + "@img/sharp-libvips-linuxmusl-arm64": + optional: true + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@img/sharp-linuxmusl-x64@npm:0.34.5": + version: 0.34.5 + resolution: "@img/sharp-linuxmusl-x64@npm:0.34.5" + dependencies: + "@img/sharp-libvips-linuxmusl-x64": "npm:1.2.4" + dependenciesMeta: + "@img/sharp-libvips-linuxmusl-x64": + optional: true + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@img/sharp-wasm32@npm:0.34.5": + version: 0.34.5 + resolution: "@img/sharp-wasm32@npm:0.34.5" + dependencies: + "@emnapi/runtime": "npm:^1.7.0" + conditions: cpu=wasm32 + languageName: node + linkType: hard + +"@img/sharp-win32-arm64@npm:0.34.5": + version: 0.34.5 + resolution: "@img/sharp-win32-arm64@npm:0.34.5" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@img/sharp-win32-ia32@npm:0.34.5": + version: 0.34.5 + resolution: "@img/sharp-win32-ia32@npm:0.34.5" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + +"@img/sharp-win32-x64@npm:0.34.5": + version: 0.34.5 + resolution: "@img/sharp-win32-x64@npm:0.34.5" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@next/env@npm:16.1.6": + version: 16.1.6 + resolution: "@next/env@npm:16.1.6" + checksum: 10c0/ed7023edb94b9b2e5da3f9c99d08b614da9757c1edd0ecec792fce4d336b4f0c64db1a84955e07cfbd848b9e61c4118fff28f4098cd7b0a7f97814a90565ebe6 + languageName: node + linkType: hard + +"@next/swc-darwin-arm64@npm:16.1.6": + version: 16.1.6 + resolution: "@next/swc-darwin-arm64@npm:16.1.6" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@next/swc-darwin-x64@npm:16.1.6": + version: 16.1.6 + resolution: "@next/swc-darwin-x64@npm:16.1.6" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@next/swc-linux-arm64-gnu@npm:16.1.6": + version: 16.1.6 + resolution: "@next/swc-linux-arm64-gnu@npm:16.1.6" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@next/swc-linux-arm64-musl@npm:16.1.6": + version: 16.1.6 + resolution: "@next/swc-linux-arm64-musl@npm:16.1.6" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@next/swc-linux-x64-gnu@npm:16.1.6": + version: 16.1.6 + resolution: "@next/swc-linux-x64-gnu@npm:16.1.6" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@next/swc-linux-x64-musl@npm:16.1.6": + version: 16.1.6 + resolution: "@next/swc-linux-x64-musl@npm:16.1.6" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@next/swc-win32-arm64-msvc@npm:16.1.6": + version: 16.1.6 + resolution: "@next/swc-win32-arm64-msvc@npm:16.1.6" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@next/swc-win32-x64-msvc@npm:16.1.6": + version: 16.1.6 + resolution: "@next/swc-win32-x64-msvc@npm:16.1.6" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@swc/helpers@npm:0.5.15": + version: 0.5.15 + resolution: "@swc/helpers@npm:0.5.15" + dependencies: + tslib: "npm:^2.8.0" + checksum: 10c0/33002f74f6f885f04c132960835fdfc474186983ea567606db62e86acd0680ca82f34647e8e610f4e1e422d1c16fce729dde22cd3b797ab1fd9061a825dabca4 + languageName: node + linkType: hard + +"@types/node@npm:22.16.0": + version: 22.16.0 + resolution: "@types/node@npm:22.16.0" + dependencies: + undici-types: "npm:~6.21.0" + checksum: 10c0/6219b521062f6c38d4d85ebd25807bd7f2bc703a5acba24e2c6716938d9d6cefd6fafd7b5156f61580eb58a0d82e8921751b778655675389631d813e5f261c03 + languageName: node + linkType: hard + +"@types/react-dom@npm:19.2.3": + version: 19.2.3 + resolution: "@types/react-dom@npm:19.2.3" + peerDependencies: + "@types/react": ^19.2.0 + checksum: 10c0/b486ebe0f4e2fb35e2e108df1d8fc0927ca5d6002d5771e8a739de11239fe62d0e207c50886185253c99eb9dedfeeb956ea7429e5ba17f6693c7acb4c02f8cd1 + languageName: node + linkType: hard + +"@types/react@npm:19.2.8": + version: 19.2.8 + resolution: "@types/react@npm:19.2.8" + dependencies: + csstype: "npm:^3.2.2" + checksum: 10c0/832834998c4ee971fca72ecf1eb95dc924ad3931a2112c687a4dae498aabd115c5fa4db09186853e34a646226b0223808c8f867df03d17601168f9cf119448de + languageName: node + linkType: hard + +"baseline-browser-mapping@npm:^2.8.3": + version: 2.9.19 + resolution: "baseline-browser-mapping@npm:2.9.19" + bin: + baseline-browser-mapping: dist/cli.js + checksum: 10c0/569928db78bcd081953d7db79e4243a59a579a34b4ae1806b9b42d3b7f84e5bc40e6e82ae4fa06e7bef8291bf747b33b3f9ef5d3c6e1e420cb129d9295536129 + languageName: node + linkType: hard + +"caniuse-lite@npm:^1.0.30001579": + version: 1.0.30001768 + resolution: "caniuse-lite@npm:1.0.30001768" + checksum: 10c0/16808cb39f9563098deab6d45bcd0642a79fc5ace8dbcea8106b008b179820353e3ec089ed7e54f1f3c8bb84f2c2835b451f308212d8f36c2b7942f879e91955 + languageName: node + linkType: hard + +"client-only@npm:0.0.1": + version: 0.0.1 + resolution: "client-only@npm:0.0.1" + checksum: 10c0/9d6cfd0c19e1c96a434605added99dff48482152af791ec4172fb912a71cff9027ff174efd8cdb2160cc7f377543e0537ffc462d4f279bc4701de3f2a3c4b358 + languageName: node + linkType: hard + +"csstype@npm:^3.2.2": + version: 3.2.3 + resolution: "csstype@npm:3.2.3" + checksum: 10c0/cd29c51e70fa822f1cecd8641a1445bed7063697469d35633b516e60fe8c1bde04b08f6c5b6022136bb669b64c63d4173af54864510fbb4ee23281801841a3ce + languageName: node + linkType: hard + +"detect-libc@npm:^2.1.2": + version: 2.1.2 + resolution: "detect-libc@npm:2.1.2" + checksum: 10c0/acc675c29a5649fa1fb6e255f993b8ee829e510b6b56b0910666949c80c364738833417d0edb5f90e4e46be17228b0f2b66a010513984e18b15deeeac49369c4 + languageName: node + linkType: hard + +"nanoid@npm:^3.3.6": + version: 3.3.11 + resolution: "nanoid@npm:3.3.11" + bin: + nanoid: bin/nanoid.cjs + checksum: 10c0/40e7f70b3d15f725ca072dfc4f74e81fcf1fbb02e491cf58ac0c79093adc9b0a73b152bcde57df4b79cd097e13023d7504acb38404a4da7bc1cd8e887b82fe0b + languageName: node + linkType: hard + +"next@npm:16.1.6": + version: 16.1.6 + resolution: "next@npm:16.1.6" + dependencies: + "@next/env": "npm:16.1.6" + "@next/swc-darwin-arm64": "npm:16.1.6" + "@next/swc-darwin-x64": "npm:16.1.6" + "@next/swc-linux-arm64-gnu": "npm:16.1.6" + "@next/swc-linux-arm64-musl": "npm:16.1.6" + "@next/swc-linux-x64-gnu": "npm:16.1.6" + "@next/swc-linux-x64-musl": "npm:16.1.6" + "@next/swc-win32-arm64-msvc": "npm:16.1.6" + "@next/swc-win32-x64-msvc": "npm:16.1.6" + "@swc/helpers": "npm:0.5.15" + baseline-browser-mapping: "npm:^2.8.3" + caniuse-lite: "npm:^1.0.30001579" + postcss: "npm:8.4.31" + sharp: "npm:^0.34.4" + styled-jsx: "npm:5.1.6" + peerDependencies: + "@opentelemetry/api": ^1.1.0 + "@playwright/test": ^1.51.1 + babel-plugin-react-compiler: "*" + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + sass: ^1.3.0 + dependenciesMeta: + "@next/swc-darwin-arm64": + optional: true + "@next/swc-darwin-x64": + optional: true + "@next/swc-linux-arm64-gnu": + optional: true + "@next/swc-linux-arm64-musl": + optional: true + "@next/swc-linux-x64-gnu": + optional: true + "@next/swc-linux-x64-musl": + optional: true + "@next/swc-win32-arm64-msvc": + optional: true + "@next/swc-win32-x64-msvc": + optional: true + sharp: + optional: true + peerDependenciesMeta: + "@opentelemetry/api": + optional: true + "@playwright/test": + optional: true + babel-plugin-react-compiler: + optional: true + sass: + optional: true + bin: + next: dist/bin/next + checksum: 10c0/543766bf879bb5a5d454dc18cb302953270a92efba1d01dd028ea83c64b69573ce7d6e6c3759ecbaabec0a84131b0237263c24d1ccd7c8a97205e776dcd34e0b + languageName: node + linkType: hard + +"nextjs-app-router@workspace:.": + version: 0.0.0-use.local + resolution: "nextjs-app-router@workspace:." + dependencies: + "@datadog/browser-rum": "file:../../../packages/rum/package.tgz" + "@datadog/browser-rum-react": "file:../../../packages/rum-react/package.tgz" + "@types/node": "npm:22.16.0" + "@types/react": "npm:19.2.8" + "@types/react-dom": "npm:19.2.3" + next: "npm:16.1.6" + react: "npm:19.2.3" + react-dom: "npm:19.2.3" + typescript: "npm:5.9.3" + languageName: unknown + linkType: soft + +"picocolors@npm:^1.0.0": + version: 1.1.1 + resolution: "picocolors@npm:1.1.1" + checksum: 10c0/e2e3e8170ab9d7c7421969adaa7e1b31434f789afb9b3f115f6b96d91945041ac3ceb02e9ec6fe6510ff036bcc0bf91e69a1772edc0b707e12b19c0f2d6bcf58 + languageName: node + linkType: hard + +"postcss@npm:8.4.31": + version: 8.4.31 + resolution: "postcss@npm:8.4.31" + dependencies: + nanoid: "npm:^3.3.6" + picocolors: "npm:^1.0.0" + source-map-js: "npm:^1.0.2" + checksum: 10c0/748b82e6e5fc34034dcf2ae88ea3d11fd09f69b6c50ecdd3b4a875cfc7cdca435c958b211e2cb52355422ab6fccb7d8f2f2923161d7a1b281029e4a913d59acf + languageName: node + linkType: hard + +"react-dom@npm:19.2.3": + version: 19.2.3 + resolution: "react-dom@npm:19.2.3" + dependencies: + scheduler: "npm:^0.27.0" + peerDependencies: + react: ^19.2.3 + checksum: 10c0/dc43f7ede06f46f3acc16ee83107c925530de9b91d1d0b3824583814746ff4c498ea64fd65cd83aba363205268adff52e2827c582634ae7b15069deaeabc4892 + languageName: node + linkType: hard + +"react@npm:19.2.3": + version: 19.2.3 + resolution: "react@npm:19.2.3" + checksum: 10c0/094220b3ba3a76c1b668f972ace1dd15509b157aead1b40391d1c8e657e720c201d9719537375eff08f5e0514748c0319063392a6f000e31303aafc4471f1436 + languageName: node + linkType: hard + +"scheduler@npm:^0.27.0": + version: 0.27.0 + resolution: "scheduler@npm:0.27.0" + checksum: 10c0/4f03048cb05a3c8fddc45813052251eca00688f413a3cee236d984a161da28db28ba71bd11e7a3dd02f7af84ab28d39fb311431d3b3772fed557945beb00c452 + languageName: node + linkType: hard + +"semver@npm:^7.7.3": + version: 7.7.3 + resolution: "semver@npm:7.7.3" + bin: + semver: bin/semver.js + checksum: 10c0/4afe5c986567db82f44c8c6faef8fe9df2a9b1d98098fc1721f57c696c4c21cebd572f297fc21002f81889492345b8470473bc6f4aff5fb032a6ea59ea2bc45e + languageName: node + linkType: hard + +"sharp@npm:^0.34.4": + version: 0.34.5 + resolution: "sharp@npm:0.34.5" + dependencies: + "@img/colour": "npm:^1.0.0" + "@img/sharp-darwin-arm64": "npm:0.34.5" + "@img/sharp-darwin-x64": "npm:0.34.5" + "@img/sharp-libvips-darwin-arm64": "npm:1.2.4" + "@img/sharp-libvips-darwin-x64": "npm:1.2.4" + "@img/sharp-libvips-linux-arm": "npm:1.2.4" + "@img/sharp-libvips-linux-arm64": "npm:1.2.4" + "@img/sharp-libvips-linux-ppc64": "npm:1.2.4" + "@img/sharp-libvips-linux-riscv64": "npm:1.2.4" + "@img/sharp-libvips-linux-s390x": "npm:1.2.4" + "@img/sharp-libvips-linux-x64": "npm:1.2.4" + "@img/sharp-libvips-linuxmusl-arm64": "npm:1.2.4" + "@img/sharp-libvips-linuxmusl-x64": "npm:1.2.4" + "@img/sharp-linux-arm": "npm:0.34.5" + "@img/sharp-linux-arm64": "npm:0.34.5" + "@img/sharp-linux-ppc64": "npm:0.34.5" + "@img/sharp-linux-riscv64": "npm:0.34.5" + "@img/sharp-linux-s390x": "npm:0.34.5" + "@img/sharp-linux-x64": "npm:0.34.5" + "@img/sharp-linuxmusl-arm64": "npm:0.34.5" + "@img/sharp-linuxmusl-x64": "npm:0.34.5" + "@img/sharp-wasm32": "npm:0.34.5" + "@img/sharp-win32-arm64": "npm:0.34.5" + "@img/sharp-win32-ia32": "npm:0.34.5" + "@img/sharp-win32-x64": "npm:0.34.5" + detect-libc: "npm:^2.1.2" + semver: "npm:^7.7.3" + dependenciesMeta: + "@img/sharp-darwin-arm64": + optional: true + "@img/sharp-darwin-x64": + optional: true + "@img/sharp-libvips-darwin-arm64": + optional: true + "@img/sharp-libvips-darwin-x64": + optional: true + "@img/sharp-libvips-linux-arm": + optional: true + "@img/sharp-libvips-linux-arm64": + optional: true + "@img/sharp-libvips-linux-ppc64": + optional: true + "@img/sharp-libvips-linux-riscv64": + optional: true + "@img/sharp-libvips-linux-s390x": + optional: true + "@img/sharp-libvips-linux-x64": + optional: true + "@img/sharp-libvips-linuxmusl-arm64": + optional: true + "@img/sharp-libvips-linuxmusl-x64": + optional: true + "@img/sharp-linux-arm": + optional: true + "@img/sharp-linux-arm64": + optional: true + "@img/sharp-linux-ppc64": + optional: true + "@img/sharp-linux-riscv64": + optional: true + "@img/sharp-linux-s390x": + optional: true + "@img/sharp-linux-x64": + optional: true + "@img/sharp-linuxmusl-arm64": + optional: true + "@img/sharp-linuxmusl-x64": + optional: true + "@img/sharp-wasm32": + optional: true + "@img/sharp-win32-arm64": + optional: true + "@img/sharp-win32-ia32": + optional: true + "@img/sharp-win32-x64": + optional: true + checksum: 10c0/fd79e29df0597a7d5704b8461c51f944ead91a5243691697be6e8243b966402beda53ddc6f0a53b96ea3cb8221f0b244aa588114d3ebf8734fb4aefd41ab802f + languageName: node + linkType: hard + +"source-map-js@npm:^1.0.2": + version: 1.2.1 + resolution: "source-map-js@npm:1.2.1" + checksum: 10c0/7bda1fc4c197e3c6ff17de1b8b2c20e60af81b63a52cb32ec5a5d67a20a7d42651e2cb34ebe93833c5a2a084377e17455854fee3e21e7925c64a51b6a52b0faf + languageName: node + linkType: hard + +"styled-jsx@npm:5.1.6": + version: 5.1.6 + resolution: "styled-jsx@npm:5.1.6" + dependencies: + client-only: "npm:0.0.1" + peerDependencies: + react: ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + peerDependenciesMeta: + "@babel/core": + optional: true + babel-plugin-macros: + optional: true + checksum: 10c0/ace50e7ea5ae5ae6a3b65a50994c51fca6ae7df9c7ecfd0104c36be0b4b3a9c5c1a2374d16e2a11e256d0b20be6d47256d768ecb4f91ab390f60752a075780f5 + languageName: node + linkType: hard + +"tslib@npm:^2.4.0, tslib@npm:^2.8.0": + version: 2.8.1 + resolution: "tslib@npm:2.8.1" + checksum: 10c0/9c4759110a19c53f992d9aae23aac5ced636e99887b51b9e61def52611732872ff7668757d4e4c61f19691e36f4da981cd9485e869b4a7408d689f6bf1f14e62 + languageName: node + linkType: hard + +"typescript@npm:5.9.3": + version: 5.9.3 + resolution: "typescript@npm:5.9.3" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10c0/6bd7552ce39f97e711db5aa048f6f9995b53f1c52f7d8667c1abdc1700c68a76a308f579cd309ce6b53646deb4e9a1be7c813a93baaf0a28ccd536a30270e1c5 + languageName: node + linkType: hard + +"typescript@patch:typescript@npm%3A5.9.3#optional!builtin": + version: 5.9.3 + resolution: "typescript@patch:typescript@npm%3A5.9.3#optional!builtin::version=5.9.3&hash=5786d5" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10c0/ad09fdf7a756814dce65bc60c1657b40d44451346858eea230e10f2e95a289d9183b6e32e5c11e95acc0ccc214b4f36289dcad4bf1886b0adb84d711d336a430 + languageName: node + linkType: hard + +"undici-types@npm:~6.21.0": + version: 6.21.0 + resolution: "undici-types@npm:6.21.0" + checksum: 10c0/c01ed51829b10aa72fc3ce64b747f8e74ae9b60eafa19a7b46ef624403508a54c526ffab06a14a26b3120d055e1104d7abe7c9017e83ced038ea5cf52f8d5e04 + languageName: node + linkType: hard From 73ad35fbb915ec487fbfff6fb0256c06c0c909f4 Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Fri, 6 Feb 2026 11:12:28 +0100 Subject: [PATCH 05/20] Add e2e tests for Next.js App Router integration --- packages/rum-react/NEXTJS_NEXTSTEPS.md | 350 ------------------ test/apps/nextjs-app-router/README.md | 19 +- .../apps/nextjs-app-router/app/ddprovider.tsx | 25 +- test/e2e/lib/framework/createTest.ts | 58 ++- test/e2e/lib/framework/flushEvents.ts | 4 +- test/e2e/lib/framework/pageSetups.ts | 1 + test/e2e/lib/helpers/playwright.ts | 1 + test/e2e/playwright.base.config.ts | 25 +- test/e2e/scenario/nextjs.scenario.ts | 74 ++++ 9 files changed, 169 insertions(+), 388 deletions(-) delete mode 100644 packages/rum-react/NEXTJS_NEXTSTEPS.md create mode 100644 test/e2e/scenario/nextjs.scenario.ts diff --git a/packages/rum-react/NEXTJS_NEXTSTEPS.md b/packages/rum-react/NEXTJS_NEXTSTEPS.md deleted file mode 100644 index 788f389b69..0000000000 --- a/packages/rum-react/NEXTJS_NEXTSTEPS.md +++ /dev/null @@ -1,350 +0,0 @@ -# Next.js Integration - Next Steps - -## Current Status - -✅ **Phase 1 & 2 Complete**: Core implementation finished - -- All source files created and tested -- TypeScript compilation successful -- Unit tests passing (27 tests) -- Linting passes -- Build artifacts generated - -## Remaining Work - -### Phase 3: E2E Testing - -Create a comprehensive test application and automated E2E tests. - -#### 3.1 Create Test Application - -**Location**: `test/apps/nextjs-app-router/` - -**Structure**: - -``` -test/apps/nextjs-app-router/ -├── app/ -│ ├── layout.tsx # With DatadogRumProvider -│ ├── page.tsx # Home page -│ ├── about/page.tsx # Static route -│ ├── product/[id]/page.tsx # Numeric dynamic route -│ ├── user/[uuid]/page.tsx # UUID dynamic route -│ ├── blog/[...slug]/page.tsx # Catch-all route -│ ├── error.tsx # Error boundary -│ └── components/ -│ └── datadog-provider.tsx # Client component wrapper -├── next.config.js -├── package.json -├── tsconfig.json -└── README.md -``` - -#### 3.2 Playwright E2E Tests - -**Location**: `test/e2e/scenarios/nextjs-app-router.scenario.ts` - -**Test Cases**: - -1. ✅ **Initial Load**: Verify view created on first page load -2. ✅ **Static Navigation**: Navigate from `/` to `/about`, verify new view -3. ✅ **Dynamic Routes - Numeric**: Navigate to `/product/123`, verify view name is `/product/:id` -4. ✅ **Dynamic Routes - UUID**: Navigate to `/user/abc-123-def`, verify view name is `/user/:uuid` -5. ✅ **Dynamic Routes - Multiple**: Test `/orders/456/items/789` → `/orders/:id/items/:id` -6. ✅ **Catch-all Routes**: Navigate to `/blog/2024/01/post`, verify tracking -7. ✅ **Browser Navigation**: Test back/forward buttons -8. ✅ **Error Tracking**: Trigger error, verify captured in Datadog -9. ✅ **View Name Uniqueness**: Different products create same view name pattern -10. ✅ **Query Parameters**: Verify `/product/123?sort=asc` tracked correctly -11. ✅ **Hash Fragments**: Verify `/product/123#reviews` tracked correctly -12. ✅ **SSR/Hydration**: Ensure no hydration mismatches - -**Commands**: - -```bash -# Setup (one time) -yarn test:e2e:init - -# Run Next.js tests -yarn test:e2e -g "nextjs" -``` - -### Phase 4: Documentation - -Update package documentation with comprehensive examples. - -#### 4.1 Update README - -**Location**: `packages/rum-react/README.md` - -**Sections to Add**: - -1. **Next.js App Router Integration** (new section) - - Quick start guide - - Installation instructions - - Basic setup example - - Both initialization patterns - - View name normalization explanation - -2. **Advanced Usage** - - Error boundary integration - - Component performance tracking - - Instrumentation file pattern - - Custom view names (if needed) - -3. **Migration Guide** - - From manual integration - - From Pages Router (if applicable) - -4. **Troubleshooting** - - Common issues - - Configuration checklist - - Debugging tips - -#### 4.2 API Documentation - -Ensure TypeDoc generates proper documentation: - -- Add `packages/rum-react/nextjs/typedoc.json` -- Verify JSDoc comments are complete -- Generate and review docs: `yarn docs:serve` - -#### 4.3 Code Examples - -Create example repository or folder: - -``` -examples/nextjs-app-router/ -├── basic/ # Basic DatadogRumProvider setup -├── instrumentation/ # Using instrumentation-client.ts -├── error-handling/ # Error boundary examples -└── performance-tracking/ # Component tracking examples -``` - -### Phase 5: Manual Testing - -Test with real Next.js applications across different versions. - -#### 5.1 Version Compatibility Testing - -Test with: - -- ✅ **Next.js 13.x** (App Router introduced) -- ✅ **Next.js 14.x** (Current stable) -- ✅ **Next.js 15.x** (Latest) - -Test with: - -- ✅ **React 18.x** -- ✅ **React 19.x** - -#### 5.2 Manual Test Checklist - -- [ ] Install package in fresh Next.js 13+ app -- [ ] Import from `@datadog/browser-rum-react/nextjs` works -- [ ] Add `DatadogRumProvider` to root layout -- [ ] Initialize with `reactPlugin({ nextjs: true })` -- [ ] Navigate between pages, verify views in Datadog RUM UI -- [ ] Check view names match expected patterns (e.g., `/product/:id`) -- [ ] Trigger errors, verify captured in Datadog -- [ ] Add `UNSTABLE_ReactComponentTracker`, verify performance metrics -- [ ] Test SSR (check for hydration errors in console) -- [ ] Test with TypeScript strict mode -- [ ] Verify bundle size impact - -#### 5.3 Integration Testing - -- [ ] Test with common Next.js middleware patterns -- [ ] Test with internationalization (i18n) -- [ ] Test with authentication flows -- [ ] Test with API routes -- [ ] Test with server actions -- [ ] Verify no memory leaks on navigation - -### Phase 6: Release Preparation - -#### 6.1 Changelog - -Add entry to `CHANGELOG.md`: - -```markdown -## [6.27.0] - YYYY-MM-DD - -### Added - -- Next.js App Router integration (`@datadog/browser-rum-react/nextjs`) - - Automatic route tracking with view name normalization - - Support for dynamic routes (numeric IDs and UUIDs) - - DatadogRumProvider component for easy setup - - initDatadogRum helper for instrumentation file pattern - - Compatible with Next.js 13, 14, 15 -``` - -#### 6.2 Migration Guide - -Document for users migrating from manual Next.js integration. - -#### 6.3 Announcement - -Prepare announcement for: - -- Datadog blog post -- GitHub release notes -- Documentation site - -## Optional Enhancements - -These features can be added in future versions based on user feedback. - -### Optional 1: `trackInitialLoad` Option - -Add back the ability to skip initial load tracking for advanced use cases. - -**Use Case**: Users who want to manually create the initial view with custom context. - -**Implementation**: - -```typescript -export interface UsePathnameTrackerOptions { - /** - * Whether to track the initial page load. - * - * @default true - */ - trackInitialLoad?: boolean -} - -export interface DatadogRumProviderProps { - children: ReactNode - - /** - * Whether to track the initial page load. - * - * @default true - */ - trackInitialLoad?: boolean -} -``` - -**Example Usage**: - -```typescript -// instrumentation-client.ts - runs very early -export function register() { - initDatadogRum(config, datadogRum) - - // Manually create initial view with custom data - datadogRum.startView({ - name: '/home', - context: { experimentVariant: 'A' } - }) -} - -// app/layout.tsx -export default function RootLayout({ children }) { - return ( - - - {/* Skip initial load since we handled it manually above */} - - {children} - - - - ) -} -``` - -**Priority**: Low (only add if users request it) - -### Optional 2: Custom View Name Transformer - -Allow users to customize view name normalization. - -**Use Case**: Users with custom dynamic route patterns not covered by default normalization. - -**Implementation**: - -```typescript -export interface NextjsPluginConfiguration { - /** - * Custom function to normalize view names. - * If not provided, uses default normalization (numeric IDs and UUIDs). - */ - normalizeViewName?: (pathname: string) => string -} - -// Usage -reactPlugin({ - nextjs: true, - normalizeViewName: (pathname) => { - // Custom logic - return pathname.replace(/\/sku-\d+/g, '/:sku') - }, -}) -``` - -**Priority**: Low (most users should be fine with default behavior) - -### Optional 3: Pages Router Support - -Extend to support Next.js Pages Router (not just App Router). - -**Implementation**: Similar pattern but using `next/router` instead of `next/navigation`. - -**Priority**: Medium (if users request it, but App Router is the future) - -### Optional 4: Middleware Integration - -Provide helper for tracking in Next.js middleware. - -**Use Case**: Track server-side redirects, authentication checks, etc. - -**Priority**: Low (most tracking is client-side) - -### Optional 5: TypedRoutes Support - -Integration with Next.js experimental typed routes feature. - -**Priority**: Low (experimental feature, wait for stability) - -## Questions to Resolve - -1. **Should we provide a Next.js plugin/template?** - - Create a `create-next-app` template with Datadog pre-configured - - Or a Next.js plugin that auto-configures the integration - -2. **Should we support Pages Router?** - - Current implementation is App Router only - - Pages Router uses different APIs (`next/router` vs `next/navigation`) - -3. **How should we handle incremental static regeneration (ISR)?** - - View tracking might behave differently - - Need to test and document - -4. **Should view names be configurable?** - - Current: automatic normalization (not configurable) - - Alternative: allow custom patterns via configuration - -5. **How should we handle middleware tracking?** - - Currently focused on client-side tracking - - Server-side tracking might need different approach - -## Timeline Estimate - -- **Phase 3 (E2E Testing)**: 3-5 days -- **Phase 4 (Documentation)**: 2-3 days -- **Phase 5 (Manual Testing)**: 2-3 days -- **Phase 6 (Release Prep)**: 1 day - -**Total**: ~2 weeks for complete release-ready state - -## Success Metrics - -After release, track: - -- Adoption rate (npm downloads) -- GitHub issues related to Next.js integration -- User feedback and feature requests -- Performance impact reports -- Documentation clarity (support tickets) diff --git a/test/apps/nextjs-app-router/README.md b/test/apps/nextjs-app-router/README.md index feab2a5ce0..f0a5e9d7e6 100644 --- a/test/apps/nextjs-app-router/README.md +++ b/test/apps/nextjs-app-router/README.md @@ -32,15 +32,22 @@ yarn dev App available at http://localhost:3000 -## Important Notes - -- **Always clear cache**: Yarn caches `.tgz` files by hash. Run `yarn cache clean --all` before reinstalling. -- **Remove node_modules**: Required for a clean install after repacking. -- **Clear Next.js cache**: If you see stale code, run `rm -rf .next` - ## Test Routes - `/` - Home page - `/user/42` - Dynamic route (normalizes to `/user/:id`) - `/tracked` - Component tracking demo - `/error-test` - Error boundary testing + +## E2E Tests + +E2E tests are in `test/e2e/scenario/nextjs.scenario.ts`. + +### Running E2E Tests + +```bash +# From repo root - starts both dev servers automatically +yarn test:e2e -g "nextjs" +``` + +The Playwright config automatically starts the Next.js dev server on port 3000. diff --git a/test/apps/nextjs-app-router/app/ddprovider.tsx b/test/apps/nextjs-app-router/app/ddprovider.tsx index 1c4b9b301a..a23c91ab49 100644 --- a/test/apps/nextjs-app-router/app/ddprovider.tsx +++ b/test/apps/nextjs-app-router/app/ddprovider.tsx @@ -1,21 +1,22 @@ 'use client' +import { useEffect, type ReactNode } from 'react' import { DatadogRumProvider } from '@datadog/browser-rum-react/nextjs' import { datadogRum } from '@datadog/browser-rum' import { reactPlugin } from '@datadog/browser-rum-react' -datadogRum.init({ - applicationId: 'a81f40b8-e9bd-4805-9b66-4e4edc529a14', - clientToken: 'pubfe2e138a54296da76dd66f6b0b5f3d98', - site: 'datad0g.com', - service: 'nextjs-app-router-test', - env: 'development', - sessionSampleRate: 100, - sessionReplaySampleRate: 100, - defaultPrivacyLevel: 'allow', - plugins: [reactPlugin({ nextjs: true })], -}) +export function DatadogProvider({ children }: { children: ReactNode }) { + useEffect(() => { + const config = (window as any).RUM_CONFIGURATION + if (!config) { + return + } + + datadogRum.init({ + ...config, + plugins: [reactPlugin({ nextjs: true })], + }) + }, []) -export function DatadogProvider({ children }: { children: React.ReactNode }) { return {children} } diff --git a/test/e2e/lib/framework/createTest.ts b/test/e2e/lib/framework/createTest.ts index 25b32dc831..5041cdc6f8 100644 --- a/test/e2e/lib/framework/createTest.ts +++ b/test/e2e/lib/framework/createTest.ts @@ -9,6 +9,7 @@ import { BrowserLogsManager, deleteAllCookies, getBrowserName, sendXhr } from '. import { DEFAULT_LOGS_CONFIGURATION, DEFAULT_RUM_CONFIGURATION } from '../helpers/configuration' import { validateRumFormat } from '../helpers/validation' import type { BrowserConfiguration } from '../../../browsers.conf' +import { NEXTJS_APP_URL } from '../helpers/playwright' import { IntakeRegistry } from './intakeRegistry' import { flushEvents } from './flushEvents' import type { Servers } from './httpServers' @@ -43,6 +44,7 @@ interface TestContext { deleteAllCookies: () => Promise sendXhr: (url: string, headers?: string[][]) => Promise interactWithWorker: (cb: (worker: ServiceWorker) => void) => Promise + isNextjsApp: boolean } type TestRunner = (testContext: TestContext) => Promise | void @@ -64,6 +66,7 @@ class TestBuilder { } = {} private useServiceWorker: boolean = false private hostName?: string + private nextjsApp = false constructor(private title: string) {} @@ -112,6 +115,12 @@ class TestBuilder { return this } + withNextjsApp() { + this.nextjsApp = true + this.setups = [{ factory: () => '' }] + return this + } + withBasePath(newBasePath: string) { this.basePath = newBasePath return this @@ -194,6 +203,7 @@ class TestBuilder { testFixture: this.testFixture, extension: this.extension, hostName: this.hostName, + nextjsApp: this.nextjsApp, } if (this.alsoRunWithRumSlim) { @@ -269,11 +279,14 @@ function declareTest(title: string, setupOptions: SetupOptions, factory: SetupFa const testContext = createTestContext(servers, page, context, browserLogs, browserName, setupOptions) servers.intake.bindServerApp(createIntakeServerApp(testContext.intakeRegistry)) - const setup = factory(setupOptions, servers) - servers.base.bindServerApp(createMockServerApp(servers, setup, setupOptions.remoteConfiguration)) - servers.crossOrigin.bindServerApp(createMockServerApp(servers, setup)) + // Next.js runs on its own server, only set up mock server for other test apps + if (!setupOptions.nextjsApp) { + const setup = factory(setupOptions, servers) + servers.base.bindServerApp(createMockServerApp(servers, setup, setupOptions.remoteConfiguration)) + servers.crossOrigin.bindServerApp(createMockServerApp(servers, setup)) + } - await setUpTest(browserLogs, testContext) + await setUpTest(browserLogs, testContext, setupOptions, servers) try { await runner(testContext) @@ -290,12 +303,17 @@ function createTestContext( browserContext: BrowserContext, browserLogsManager: BrowserLogsManager, browserName: TestContext['browserName'], - { basePath, hostName }: SetupOptions + { basePath, hostName, nextjsApp }: SetupOptions ): TestContext { - const baseUrl = new URL(basePath, servers.base.origin) + let baseUrl: URL - if (hostName) { - baseUrl.hostname = hostName + if (nextjsApp) { + baseUrl = new URL(basePath, NEXTJS_APP_URL) + } else { + baseUrl = new URL(basePath, servers.base.origin) + if (hostName) { + baseUrl.hostname = hostName + } } return { @@ -305,6 +323,7 @@ function createTestContext( page, browserContext, browserName, + isNextjsApp: !!nextjsApp, withBrowserLogs: (cb: (logs: BrowserLog[]) => void) => { try { cb(browserLogsManager.get()) @@ -316,7 +335,7 @@ function createTestContext( await page.evaluate(`(${cb.toString()})(window.myServiceWorker.active)`) }, flushBrowserLogs: () => browserLogsManager.clear(), - flushEvents: () => flushEvents(page), + flushEvents: () => flushEvents(page, nextjsApp ? NEXTJS_APP_URL : undefined), deleteAllCookies: () => deleteAllCookies(browserContext), sendXhr: (url: string, headers?: string[][]) => sendXhr(page, url, headers), getExtensionId: async () => { @@ -331,7 +350,12 @@ function createTestContext( } } -async function setUpTest(browserLogsManager: BrowserLogsManager, { baseUrl, page, browserContext }: TestContext) { +async function setUpTest( + browserLogsManager: BrowserLogsManager, + { baseUrl, page, browserContext }: TestContext, + setupOptions: SetupOptions, + servers: Servers +) { browserContext.on('console', (msg) => { browserLogsManager.add({ level: msg.type() as BrowserLog['level'], @@ -350,6 +374,20 @@ async function setUpTest(browserLogsManager: BrowserLogsManager, { baseUrl, page }) }) + // For Next.js apps, inject RUM configuration before navigation + if (setupOptions.nextjsApp && setupOptions.rum) { + const rumConfig = { + ...setupOptions.rum, + proxy: servers.intake.origin, + } + await page.addInitScript( + ({ config }) => { + ;(window as any).RUM_CONFIGURATION = config + }, + { config: rumConfig } + ) + } + await page.goto(baseUrl) await waitForServersIdle() } diff --git a/test/e2e/lib/framework/flushEvents.ts b/test/e2e/lib/framework/flushEvents.ts index 7567d42116..1a30c26e4c 100644 --- a/test/e2e/lib/framework/flushEvents.ts +++ b/test/e2e/lib/framework/flushEvents.ts @@ -2,7 +2,7 @@ import type { Page } from '@playwright/test' import { getTestServers, waitForServersIdle } from './httpServers' import { waitForRequests } from './waitForRequests' -export async function flushEvents(page: Page) { +export async function flushEvents(page: Page, gotoUrl?: string) { await waitForRequests(page) const servers = await getTestServers() @@ -21,6 +21,6 @@ export async function flushEvents(page: Page) { // The issue mainly occurs with local e2e tests (not browserstack), because the network latency is // very low (same machine), so the request resolves very quickly. In real life conditions, this // issue is mitigated, because requests will likely take a few milliseconds to reach the server. - await page.goto(`${servers.base.origin}/ok?duration=200`) + await page.goto(gotoUrl ?? `${servers.base.origin}/ok?duration=200`) await waitForServersIdle() } diff --git a/test/e2e/lib/framework/pageSetups.ts b/test/e2e/lib/framework/pageSetups.ts index 3a47a8ac51..074d3b32a3 100644 --- a/test/e2e/lib/framework/pageSetups.ts +++ b/test/e2e/lib/framework/pageSetups.ts @@ -27,6 +27,7 @@ export interface SetupOptions { logsConfiguration?: LogsInitConfiguration } hostName?: string + nextjsApp?: boolean } export interface WorkerOptions { diff --git a/test/e2e/lib/helpers/playwright.ts b/test/e2e/lib/helpers/playwright.ts index a6affa5973..657eabdabf 100644 --- a/test/e2e/lib/helpers/playwright.ts +++ b/test/e2e/lib/helpers/playwright.ts @@ -4,6 +4,7 @@ import { getBuildInfos } from '../../../envUtils.ts' import packageJson from '../../../../package.json' with { type: 'json' } export const DEV_SERVER_BASE_URL = 'http://localhost:8080' +export const NEXTJS_APP_URL = 'http://localhost:3000' export function getPlaywrightConfigBrowserName(name: string): PlaywrightWorkerOptions['browserName'] { if (name.includes('firefox')) { diff --git a/test/e2e/playwright.base.config.ts b/test/e2e/playwright.base.config.ts index 9bc8fb1a0d..e0743b58cf 100644 --- a/test/e2e/playwright.base.config.ts +++ b/test/e2e/playwright.base.config.ts @@ -1,7 +1,7 @@ import path from 'path' import type { ReporterDescription, Config } from '@playwright/test' import { getTestReportDirectory } from '../envUtils' -import { DEV_SERVER_BASE_URL } from './lib/helpers/playwright' +import { DEV_SERVER_BASE_URL, NEXTJS_APP_URL } from './lib/helpers/playwright' const isCi = !!process.env.CI const isLocal = !isCi @@ -32,12 +32,21 @@ export const config: Config = { }, webServer: isLocal - ? { - stdout: 'pipe', - cwd: path.join(__dirname, '../..'), - command: 'yarn dev', - url: DEV_SERVER_BASE_URL, - reuseExistingServer: true, - } + ? [ + { + stdout: 'pipe', + cwd: path.join(__dirname, '../..'), + command: 'yarn dev', + url: DEV_SERVER_BASE_URL, + reuseExistingServer: true, + }, + { + stdout: 'pipe', + cwd: path.join(__dirname, '../apps/nextjs-app-router'), + command: 'yarn dev', + url: NEXTJS_APP_URL, + reuseExistingServer: true, + }, + ] : undefined, } diff --git a/test/e2e/scenario/nextjs.scenario.ts b/test/e2e/scenario/nextjs.scenario.ts new file mode 100644 index 0000000000..b788c3ea42 --- /dev/null +++ b/test/e2e/scenario/nextjs.scenario.ts @@ -0,0 +1,74 @@ +import { test, expect } from '@playwright/test' +import { createTest } from '../lib/framework' + +test.describe('nextjs app router', () => { + createTest('should normalize dynamic route to /user/:id') + .withRum() + .withNextjsApp() + .run(async ({ page, flushEvents, intakeRegistry }) => { + await page.click('text=Go to User 42') + await page.waitForURL('**/user/42') + + await page.click('text=Back to Home') + await page.waitForURL('**/localhost:3000/') + + await flushEvents() + + const viewEvents = intakeRegistry.rumViewEvents + expect(viewEvents.length).toBeGreaterThanOrEqual(2) + + const homeView = viewEvents.find((e) => e.view.name === '/') + expect(homeView).toBeDefined() + + const userView = viewEvents.find((e) => e.view.name === '/user/:id') + expect(userView).toBeDefined() + expect(userView?.view.loading_type).toBe('route_change') + }) + + createTest('should send a react component render vital event') + .withRum() + .withNextjsApp() + .run(async ({ page, flushEvents, intakeRegistry }) => { + await page.click('text=Go to Tracked Component') + await page.waitForURL('**/tracked') + + await page.click('text=Back to Home') + await page.waitForURL('**/localhost:3000/') + + await flushEvents() + + const vitalEvents = intakeRegistry.rumVitalEvents + expect(vitalEvents.length).toBeGreaterThan(0) + + const trackedVital = vitalEvents.find((e) => e.vital.description === 'TrackedPage') + expect(trackedVital).toBeDefined() + expect(trackedVital?.vital.duration).toEqual(expect.any(Number)) + }) + + createTest('should capture react error from error boundary') + .withRum() + .withNextjsApp() + .run(async ({ page, flushEvents, intakeRegistry, withBrowserLogs }) => { + await page.click('text=Go to Error Test') + await page.waitForURL('**/error-test') + await page.click('#error-button') + + await page.click('text=Back to Home') + await page.waitForURL('**/localhost:3000/') + + await flushEvents() + + const errorEvents = intakeRegistry.rumErrorEvents + expect(errorEvents.length).toBeGreaterThan(0) + + const boundaryError = errorEvents.find((e) => e.error.message?.includes('Error triggered by button')) + expect(boundaryError).toBeDefined() + expect(boundaryError?.error.source).toBe('custom') + expect(boundaryError?.context?.framework).toBe('react') + expect(boundaryError?.error.component_stack).toBeDefined() + + withBrowserLogs((browserLogs) => { + expect(browserLogs.length).toBeGreaterThan(0) + }) + }) +}) From 5fc1a431a578c6292449aa571ae7b81f3530a3a3 Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Fri, 6 Feb 2026 13:45:38 +0100 Subject: [PATCH 06/20] Remove unnecesary files. --- packages/rum-react/src/domain/nextjs/index.ts | 1 - .../src/domain/nextjs/initDatadogRum.ts | 64 ------------------- test/apps/nextjs-app-router/next-env.d.ts | 2 +- 3 files changed, 1 insertion(+), 66 deletions(-) delete mode 100644 packages/rum-react/src/domain/nextjs/initDatadogRum.ts diff --git a/packages/rum-react/src/domain/nextjs/index.ts b/packages/rum-react/src/domain/nextjs/index.ts index e4106d8112..0243d97db1 100644 --- a/packages/rum-react/src/domain/nextjs/index.ts +++ b/packages/rum-react/src/domain/nextjs/index.ts @@ -1,5 +1,4 @@ export { DatadogRumProvider } from './datadogRumProvider' export type { DatadogRumProviderProps } from './datadogRumProvider' export { usePathnameTracker, startNextjsView, normalizeViewName } from './viewTracking' -// export { initDatadogRum } from './initDatadogRum' export type { NextjsRumConfig } from './types' diff --git a/packages/rum-react/src/domain/nextjs/initDatadogRum.ts b/packages/rum-react/src/domain/nextjs/initDatadogRum.ts deleted file mode 100644 index db1e8ceb82..0000000000 --- a/packages/rum-react/src/domain/nextjs/initDatadogRum.ts +++ /dev/null @@ -1,64 +0,0 @@ -// Work in progress, not tested yet. - -// import type { RumPublicApi } from '@datadog/browser-rum-core' -// import { addEventListener } from '@datadog/browser-core' -// import { reactPlugin } from '../reactPlugin' -// import type { NextjsRumConfig } from './types' - -// /** -// * Helper for Next.js instrumentation file (instrumentation-client.ts). -// * Initializes RUM with Next.js integration enabled and optional early error capture. -// * -// * @param config - Configuration for Next.js RUM integration -// * @param datadogRum - The datadogRum instance from @datadog/browser-rum -// * @example -// * ```ts -// * // instrumentation-client.ts -// * import { datadogRum } from '@datadog/browser-rum' -// * import { initDatadogRum } from '@datadog/browser-rum-react/nextjs' -// * -// * export function register() { -// * initDatadogRum( -// * { -// * datadogConfig: { -// * applicationId: '', -// * clientToken: '', -// * site: 'datadoghq.com', -// * }, -// * nextjsConfig: { -// * captureEarlyErrors: true, -// * } -// * }, -// * datadogRum -// * ) -// * } -// * ``` -// */ -// export function initDatadogRum(config: NextjsRumConfig, datadogRum: RumPublicApi): void { -// if (typeof window === 'undefined') { -// // Server-side guard - RUM only runs in the browser -// return -// } - -// const { datadogConfig, nextjsConfig } = config - -// // Initialize RUM with the reactPlugin configured for Next.js -// const nextjsPlugin = reactPlugin({ nextjs: true }) -// const existingPlugins = (datadogConfig.plugins || []) as Array - -// datadogRum.init({ -// ...datadogConfig, -// plugins: [nextjsPlugin].concat(existingPlugins), -// }) - -// // Optional: Set up early error capture -// if (nextjsConfig?.captureEarlyErrors) { -// addEventListener({}, window, 'error', (event) => { -// datadogRum.addError(event.error) -// }) - -// addEventListener({}, window, 'unhandledrejection', (event: PromiseRejectionEvent) => { -// datadogRum.addError(event.reason) -// }) -// } -// } diff --git a/test/apps/nextjs-app-router/next-env.d.ts b/test/apps/nextjs-app-router/next-env.d.ts index c4b7818fbb..a3e4680c77 100644 --- a/test/apps/nextjs-app-router/next-env.d.ts +++ b/test/apps/nextjs-app-router/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import './.next/dev/types/routes.d.ts' // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. From 876d2abd74bde2dd3cf17b39497f0c2f80c21fe8 Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Fri, 6 Feb 2026 13:53:13 +0100 Subject: [PATCH 07/20] Add Next.js to 3rdparty license. --- LICENSE-3rdparty.csv | 1 + 1 file changed, 1 insertion(+) diff --git a/LICENSE-3rdparty.csv b/LICENSE-3rdparty.csv index a3ac2d3aa6..cc4f97c3a9 100644 --- a/LICENSE-3rdparty.csv +++ b/LICENSE-3rdparty.csv @@ -57,6 +57,7 @@ dev,karma-spec-reporter,MIT,Copyright 2015 Michael Lex dev,karma-webpack,MIT,Copyright JS Foundation and other contributors dev,lerna,MIT,Copyright 2015-present Lerna Contributors dev,minimatch,ISC,Copyright (c) Isaac Z. Schlueter and Contributors +dev,next,MIT,Copyright (c) 2025 Vercel, Inc. dev,node-forge,BSD,Copyright (c) 2010, Digital Bazaar, Inc. dev,pako,MIT,(C) 2014-2017 Vitaly Puzrin and Andrey Tupitsin dev,prettier,MIT,Copyright James Long and contributors From 77989b7330816cd33f53fafdbc14c156412dddfb Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Fri, 6 Feb 2026 14:05:10 +0100 Subject: [PATCH 08/20] Fix yarn.lock. --- yarn.lock | 22 ++-------------------- 1 file changed, 2 insertions(+), 20 deletions(-) diff --git a/yarn.lock b/yarn.lock index 0f73d62969..efd73ba2bf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -466,16 +466,7 @@ __metadata: languageName: node linkType: hard -"@emnapi/runtime@npm:^1.1.0, @emnapi/runtime@npm:^1.4.3": - version: 1.8.1 - resolution: "@emnapi/runtime@npm:1.8.1" - dependencies: - tslib: "npm:^2.4.0" - checksum: 10c0/f4929d75e37aafb24da77d2f58816761fe3f826aad2e37fa6d4421dac9060cbd5098eea1ac3c9ecc4526b89deb58153852fa432f87021dc57863f2ff726d713f - languageName: node - linkType: hard - -"@emnapi/runtime@npm:^1.7.0": +"@emnapi/runtime@npm:^1.1.0, @emnapi/runtime@npm:^1.4.3, @emnapi/runtime@npm:^1.7.0": version: 1.8.1 resolution: "@emnapi/runtime@npm:1.8.1" dependencies: @@ -4590,16 +4581,7 @@ __metadata: languageName: node linkType: hard -"baseline-browser-mapping@npm:^2.8.3": - version: 2.9.19 - resolution: "baseline-browser-mapping@npm:2.9.19" - bin: - baseline-browser-mapping: dist/cli.js - checksum: 10c0/569928db78bcd081953d7db79e4243a59a579a34b4ae1806b9b42d3b7f84e5bc40e6e82ae4fa06e7bef8291bf747b33b3f9ef5d3c6e1e420cb129d9295536129 - languageName: node - linkType: hard - -"baseline-browser-mapping@npm:^2.9.0": +"baseline-browser-mapping@npm:^2.8.3, baseline-browser-mapping@npm:^2.9.0": version: 2.9.19 resolution: "baseline-browser-mapping@npm:2.9.19" bin: From 129042141b677329375bda8c10b522587d0442b8 Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Fri, 13 Feb 2026 16:02:50 +0100 Subject: [PATCH 09/20] Implement history tracking for loading time correct calculation --- .../domain/nextjs/datadogRumProvider.spec.tsx | 35 ++++++-- .../src/domain/nextjs/datadogRumProvider.tsx | 55 ++++++------- .../src/domain/nextjs/historyTracking.spec.ts | 79 +++++++++++++++++++ .../src/domain/nextjs/historyTracking.ts | 61 ++++++++++++++ packages/rum-react/src/domain/nextjs/index.ts | 3 +- packages/rum-react/src/domain/nextjs/types.ts | 25 ------ .../src/domain/nextjs/viewTracking.spec.tsx | 72 ++++------------- .../src/domain/nextjs/viewTracking.tsx | 21 +---- packages/rum-react/src/entries/nextjs.ts | 29 ++++--- .../apps/nextjs-app-router/app/ddprovider.tsx | 22 ------ test/apps/nextjs-app-router/app/layout.tsx | 6 +- test/apps/nextjs-app-router/app/providers.tsx | 33 ++++++++ test/apps/nextjs-app-router/yarn.lock | 16 ++-- test/e2e/scenario/nextjs.scenario.ts | 47 +++++++++++ 14 files changed, 319 insertions(+), 185 deletions(-) create mode 100644 packages/rum-react/src/domain/nextjs/historyTracking.spec.ts create mode 100644 packages/rum-react/src/domain/nextjs/historyTracking.ts delete mode 100644 packages/rum-react/src/domain/nextjs/types.ts delete mode 100644 test/apps/nextjs-app-router/app/ddprovider.tsx create mode 100644 test/apps/nextjs-app-router/app/providers.tsx diff --git a/packages/rum-react/src/domain/nextjs/datadogRumProvider.spec.tsx b/packages/rum-react/src/domain/nextjs/datadogRumProvider.spec.tsx index c1a1651300..8b8fb1ece5 100644 --- a/packages/rum-react/src/domain/nextjs/datadogRumProvider.spec.tsx +++ b/packages/rum-react/src/domain/nextjs/datadogRumProvider.spec.tsx @@ -1,11 +1,13 @@ import React from 'react' +import { registerCleanupTask } from '../../../../core/test' import { appendComponent } from '../../../test/appendComponent' import { initializeReactPlugin } from '../../../test/initializeReactPlugin' import { DatadogRumProvider } from './datadogRumProvider' describe('DatadogRumProvider', () => { let startViewSpy: jasmine.Spy<(name?: string | object) => void> - let usePathnameSpy: jasmine.Spy<() => string> + let originalPushState: History['pushState'] + let originalReplaceState: History['replaceState'] beforeEach(() => { startViewSpy = jasmine.createSpy() @@ -18,12 +20,18 @@ describe('DatadogRumProvider', () => { }, }) - usePathnameSpy = jasmine.createSpy('usePathname').and.returnValue('/') + originalPushState = history.pushState.bind(history) + originalReplaceState = history.replaceState.bind(history) + + registerCleanupTask(() => { + history.pushState = originalPushState + history.replaceState = originalReplaceState + }) }) it('renders children correctly', () => { const container = appendComponent( - +
Test Content
) @@ -34,21 +42,32 @@ describe('DatadogRumProvider', () => { expect(child!.parentElement).toBe(container) }) - it('calls usePathnameTracker', () => { - usePathnameSpy.and.returnValue('/product/123') + it('starts initial view on mount', () => { + appendComponent( + +
Content
+
+ ) + + expect(startViewSpy).toHaveBeenCalledWith(window.location.pathname) + }) + it('starts a new view on navigation', () => { appendComponent( - +
Content
) - expect(startViewSpy).toHaveBeenCalledOnceWith('/product/:id') + startViewSpy.calls.reset() + history.pushState({}, '', '/new-page') + + expect(startViewSpy).toHaveBeenCalledWith('/new-page') }) it('renders multiple children', () => { const container = appendComponent( - +
Child 1
Child 2
Child 3
diff --git a/packages/rum-react/src/domain/nextjs/datadogRumProvider.tsx b/packages/rum-react/src/domain/nextjs/datadogRumProvider.tsx index a3c3dc52e3..e50e2c27ee 100644 --- a/packages/rum-react/src/domain/nextjs/datadogRumProvider.tsx +++ b/packages/rum-react/src/domain/nextjs/datadogRumProvider.tsx @@ -1,43 +1,36 @@ 'use client' -import React, { type ReactNode } from 'react' -import { usePathname as nextUsePathname } from 'next/navigation' -import { usePathnameTracker } from './viewTracking' +import React, { type ReactNode, useEffect, useRef } from 'react' +import { setupHistoryTracking } from './historyTracking' +import { startNextjsView } from './viewTracking' export interface DatadogRumProviderProps { /** * The children components to render. */ - children: ReactNode - - /** - * @internal - For dependency injection in tests. - */ - usePathname?: () => string } -/** - * Provider component for Next.js App Router that automatically tracks navigation. - * Wrap your application with this component to enable automatic view tracking. - * - * @example - * ```tsx - * // app/layout.tsx - * import { DatadogRumProvider } from '@datadog/browser-rum-react/nextjs' - * - * export default function RootLayout({ children }) { - * return ( - * - * - * {children} - * - * - * ) - * } - * ``` - */ -export function DatadogRumProvider({ children, usePathname = nextUsePathname }: DatadogRumProviderProps) { - usePathnameTracker(usePathname) +export function DatadogRumProvider({ children }: DatadogRumProviderProps) { + const isSetupRef = useRef(false) + + useEffect(() => { + if (isSetupRef.current) { + return + } + isSetupRef.current = true + + startNextjsView(window.location.pathname) + + const cleanup = setupHistoryTracking((pathname) => { + startNextjsView(pathname) + }) + + return () => { + cleanup() + isSetupRef.current = false + } + }, []) + return <>{children} } diff --git a/packages/rum-react/src/domain/nextjs/historyTracking.spec.ts b/packages/rum-react/src/domain/nextjs/historyTracking.spec.ts new file mode 100644 index 0000000000..3f2ab8d8c6 --- /dev/null +++ b/packages/rum-react/src/domain/nextjs/historyTracking.spec.ts @@ -0,0 +1,79 @@ +import { registerCleanupTask } from '../../../../core/test' +import { setupHistoryTracking } from './historyTracking' + +describe('setupHistoryTracking', () => { + let onNavigationSpy: jasmine.Spy<(pathname: string) => void> + let cleanup: () => void + let originalPushState: History['pushState'] + let originalReplaceState: History['replaceState'] + + beforeEach(() => { + onNavigationSpy = jasmine.createSpy('onNavigation') + originalPushState = history.pushState.bind(history) + originalReplaceState = history.replaceState.bind(history) + + registerCleanupTask(() => { + if (cleanup) { + cleanup() + } + history.pushState = originalPushState + history.replaceState = originalReplaceState + }) + }) + ;[ + { method: 'pushState' as const, url: '/new-path', expected: '/new-path' }, + { method: 'replaceState' as const, url: '/replaced-path', expected: '/replaced-path' }, + { method: 'pushState' as const, url: '/user/42?tab=profile', expected: '/user/42' }, + ].forEach(({ method, url, expected }) => { + it(`calls callback with ${expected} when ${method} is called with ${url}`, () => { + cleanup = setupHistoryTracking(onNavigationSpy) + + history[method]({}, '', url) + + expect(onNavigationSpy).toHaveBeenCalledWith(expected) + }) + }) + + it('calls callback on popstate event', () => { + cleanup = setupHistoryTracking(onNavigationSpy) + + window.dispatchEvent(new PopStateEvent('popstate')) + + expect(onNavigationSpy).toHaveBeenCalledWith(window.location.pathname) + }) + + it('does not call callback when URL is null', () => { + cleanup = setupHistoryTracking(onNavigationSpy) + + history.pushState({ data: 'test' }, '') + + expect(onNavigationSpy).not.toHaveBeenCalled() + }) + ;[ + { name: 'pushState', trigger: () => history.pushState({}, '', '/after-cleanup') }, + { name: 'replaceState', trigger: () => history.replaceState({}, '', '/after-cleanup') }, + { name: 'popstate', trigger: () => window.dispatchEvent(new PopStateEvent('popstate')) }, + ].forEach(({ name, trigger }) => { + it(`does not call callback after cleanup when ${name} is triggered`, () => { + cleanup = setupHistoryTracking(onNavigationSpy) + cleanup() + + trigger() + + expect(onNavigationSpy).not.toHaveBeenCalled() + }) + }) + + it('tracks multiple navigations', () => { + cleanup = setupHistoryTracking(onNavigationSpy) + + history.pushState({}, '', '/page1') + history.pushState({}, '', '/page2') + history.replaceState({}, '', '/page3') + + expect(onNavigationSpy).toHaveBeenCalledTimes(3) + expect(onNavigationSpy.calls.argsFor(0)).toEqual(['/page1']) + expect(onNavigationSpy.calls.argsFor(1)).toEqual(['/page2']) + expect(onNavigationSpy.calls.argsFor(2)).toEqual(['/page3']) + }) +}) diff --git a/packages/rum-react/src/domain/nextjs/historyTracking.ts b/packages/rum-react/src/domain/nextjs/historyTracking.ts new file mode 100644 index 0000000000..6dd9af2d30 --- /dev/null +++ b/packages/rum-react/src/domain/nextjs/historyTracking.ts @@ -0,0 +1,61 @@ +/** + * History API interception for tracking client-side navigations. + * + * This module intercepts browser History API methods (pushState, replaceState) + * and the popstate event to detect navigation changes in SPAs like Next.js. + */ + +import { buildUrl, addEventListener, DOM_EVENT } from '@datadog/browser-core' + +type NavigationCallback = (pathname: string) => void + +/** + * Sets up History API interception to track client-side navigations. + * + * @param onNavigation - Callback invoked with the new pathname on each navigation + * @returns Cleanup function to remove interception and event listeners + */ +export function setupHistoryTracking(onNavigation: NavigationCallback): () => void { + const originalPushState = history.pushState.bind(history) + const originalReplaceState = history.replaceState.bind(history) + + function handleStateChange(_state: unknown, _unused: string, url?: string | URL | null) { + if (url) { + const pathname = buildUrl(String(url), window.location.href).pathname + onNavigation(pathname) + } + } + + function handlePopState() { + onNavigation(window.location.pathname) + } + + // Intercept pushState + history.pushState = function (...args: Parameters) { + const result = originalPushState(...args) + handleStateChange(...args) + return result + } + + // Intercept replaceState + history.replaceState = function (...args: Parameters) { + const result = originalReplaceState(...args) + handleStateChange(...args) + return result + } + + // Listen for back/forward navigation + const { stop: stopPopStateListener } = addEventListener( + { allowUntrustedEvents: true }, + window, + DOM_EVENT.POP_STATE, + handlePopState + ) + + // Return cleanup function + return () => { + history.pushState = originalPushState + history.replaceState = originalReplaceState + stopPopStateListener() + } +} diff --git a/packages/rum-react/src/domain/nextjs/index.ts b/packages/rum-react/src/domain/nextjs/index.ts index 0243d97db1..f6637edfe3 100644 --- a/packages/rum-react/src/domain/nextjs/index.ts +++ b/packages/rum-react/src/domain/nextjs/index.ts @@ -1,4 +1,3 @@ export { DatadogRumProvider } from './datadogRumProvider' export type { DatadogRumProviderProps } from './datadogRumProvider' -export { usePathnameTracker, startNextjsView, normalizeViewName } from './viewTracking' -export type { NextjsRumConfig } from './types' +export { normalizeViewName } from './viewTracking' diff --git a/packages/rum-react/src/domain/nextjs/types.ts b/packages/rum-react/src/domain/nextjs/types.ts deleted file mode 100644 index 4e4b6fdfa5..0000000000 --- a/packages/rum-react/src/domain/nextjs/types.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { RumInitConfiguration } from '@datadog/browser-rum-core' - -/** - * Configuration for Next.js instrumentation file pattern. - */ -export interface NextjsRumConfig { - /** - * Datadog RUM initialization configuration. - */ - datadogConfig: RumInitConfiguration - - /** - * Next.js-specific configuration options. - */ - - nextjsConfig?: { - /** - * Whether to capture early errors that occur before the app fully initializes. - * - * @default false - */ - - captureEarlyErrors?: boolean - } -} diff --git a/packages/rum-react/src/domain/nextjs/viewTracking.spec.tsx b/packages/rum-react/src/domain/nextjs/viewTracking.spec.tsx index b7b43dbd07..f5a4d500c4 100644 --- a/packages/rum-react/src/domain/nextjs/viewTracking.spec.tsx +++ b/packages/rum-react/src/domain/nextjs/viewTracking.spec.tsx @@ -1,8 +1,21 @@ -import React, { act, useState } from 'react' import { display } from '@datadog/browser-core' -import { appendComponent } from '../../../test/appendComponent' import { initializeReactPlugin } from '../../../test/initializeReactPlugin' -import { startNextjsView, usePathnameTracker } from './viewTracking' +import { startNextjsView, normalizeViewName } from './viewTracking' + +describe('normalizeViewName', () => { + ;[ + ['/product/123', '/product/:id'], + ['/user/abc12345-1234-1234-1234-123456789012', '/user/:uuid'], + ['/about', '/about'], + ['/', '/'], + ['/orders/456/items/789', '/orders/:id/items/:id'], + ['/user/ABC12345-1234-1234-1234-123456789012/profile', '/user/:uuid/profile'], + ].forEach(([pathname, expected]) => { + it(`normalizes ${pathname} to ${expected}`, () => { + expect(normalizeViewName(pathname)).toBe(expected) + }) + }) +}) describe('startNextjsView', () => { let startViewSpy: jasmine.Spy<(name?: string | object) => void> @@ -67,56 +80,3 @@ describe('startNextjsView', () => { expect(localStartViewSpy).not.toHaveBeenCalled() }) }) - -describe('usePathnameTracker', () => { - let startViewSpy: jasmine.Spy<(name?: string | object) => void> - let usePathnameSpy: jasmine.Spy<() => string> - - beforeEach(() => { - startViewSpy = jasmine.createSpy() - initializeReactPlugin({ - configuration: { - nextjs: true, - }, - publicApi: { - startView: startViewSpy, - }, - }) - - usePathnameSpy = jasmine.createSpy('usePathname').and.returnValue('/') - }) - - function TestComponent() { - usePathnameTracker(usePathnameSpy) - return null - } - - it('calls startNextjsView on mount', () => { - usePathnameSpy.and.returnValue('/product/123') - - appendComponent() - - expect(startViewSpy).toHaveBeenCalledOnceWith('/product/:id') - }) - - it('does not create duplicate views on re-render with same pathname', () => { - usePathnameSpy.and.returnValue('/product/123') - - function ReRenderingComponent() { - const [, setCounter] = useState(0) - usePathnameTracker(usePathnameSpy) - - return - } - - const container = appendComponent() - expect(startViewSpy).toHaveBeenCalledTimes(1) - - const button = container.querySelector('button') as HTMLButtonElement - act(() => { - button.click() - }) - - expect(startViewSpy).toHaveBeenCalledTimes(1) - }) -}) diff --git a/packages/rum-react/src/domain/nextjs/viewTracking.tsx b/packages/rum-react/src/domain/nextjs/viewTracking.tsx index aca0f8215f..3fc54d1c69 100644 --- a/packages/rum-react/src/domain/nextjs/viewTracking.tsx +++ b/packages/rum-react/src/domain/nextjs/viewTracking.tsx @@ -1,5 +1,3 @@ -import { useRef, useEffect } from 'react' -import { usePathname as nextUsePathname } from 'next/navigation' import { display } from '@datadog/browser-core' import { onRumInit } from '../reactPlugin' @@ -13,7 +11,9 @@ export function normalizeViewName(pathname: string): string { } /** - * Starts a new RUM view. + * Starts a new RUM view with the given pathname. + * + * @internal */ export function startNextjsView(pathname: string) { onRumInit((configuration, rumPublicApi) => { @@ -26,18 +26,3 @@ export function startNextjsView(pathname: string) { rumPublicApi.startView(viewName) }) } - -/** - * Tracks navigation changes and starts a new RUM view for each new pathname. - */ -export function usePathnameTracker(usePathname = nextUsePathname) { - const pathname = usePathname() - const pathnameRef = useRef(null) - - useEffect(() => { - if (pathnameRef.current !== pathname) { - pathnameRef.current = pathname - startNextjsView(pathname) - } - }, [pathname]) -} diff --git a/packages/rum-react/src/entries/nextjs.ts b/packages/rum-react/src/entries/nextjs.ts index af881d84c3..59a038f413 100644 --- a/packages/rum-react/src/entries/nextjs.ts +++ b/packages/rum-react/src/entries/nextjs.ts @@ -4,23 +4,33 @@ * @packageDocumentation * @example * ```tsx - * // app/layout.tsx + * // app/providers.tsx (client component) + * 'use client' + * import type { ReactNode } from 'react' * import { datadogRum } from '@datadog/browser-rum' - * import { reactPlugin } from '@datadog/browser-rum-react' - * import { DatadogRumProvider } from '@datadog/browser-rum-react/nextjs' + * import { reactPlugin, DatadogRumProvider } from '@datadog/browser-rum-react/nextjs' * * datadogRum.init({ * applicationId: '', * clientToken: '', + * site: '', * plugins: [reactPlugin({ nextjs: true })], - * // ... * }) * + * export function RumProvider({ children }: { children: ReactNode }) { + * return {children} + * } + * ``` + * @example + * ```tsx + * // app/layout.tsx (server component) + * import { RumProvider } from './providers' + * * export default function RootLayout({ children }) { * return ( * * - * {children} + * {children} * * * ) @@ -29,13 +39,8 @@ */ // Export Next.js-specific functionality -export { - DatadogRumProvider, - usePathnameTracker, - // initDatadogRum, - startNextjsView, -} from '../domain/nextjs' -export type { DatadogRumProviderProps, NextjsRumConfig } from '../domain/nextjs' +export { DatadogRumProvider } from '../domain/nextjs' +export type { DatadogRumProviderProps } from '../domain/nextjs' // Re-export shared functionality from main package export { ErrorBoundary, addReactError } from '../domain/error' diff --git a/test/apps/nextjs-app-router/app/ddprovider.tsx b/test/apps/nextjs-app-router/app/ddprovider.tsx deleted file mode 100644 index a23c91ab49..0000000000 --- a/test/apps/nextjs-app-router/app/ddprovider.tsx +++ /dev/null @@ -1,22 +0,0 @@ -'use client' - -import { useEffect, type ReactNode } from 'react' -import { DatadogRumProvider } from '@datadog/browser-rum-react/nextjs' -import { datadogRum } from '@datadog/browser-rum' -import { reactPlugin } from '@datadog/browser-rum-react' - -export function DatadogProvider({ children }: { children: ReactNode }) { - useEffect(() => { - const config = (window as any).RUM_CONFIGURATION - if (!config) { - return - } - - datadogRum.init({ - ...config, - plugins: [reactPlugin({ nextjs: true })], - }) - }, []) - - return {children} -} diff --git a/test/apps/nextjs-app-router/app/layout.tsx b/test/apps/nextjs-app-router/app/layout.tsx index 62d9de480f..e4a1d2e274 100644 --- a/test/apps/nextjs-app-router/app/layout.tsx +++ b/test/apps/nextjs-app-router/app/layout.tsx @@ -1,5 +1,5 @@ import type { Metadata } from 'next' -import { DatadogProvider } from './ddprovider' +import { RumProvider } from './providers' export const metadata: Metadata = { title: 'Next.js App Router Test', @@ -10,14 +10,14 @@ export default function RootLayout({ children }: { children: React.ReactNode }) return ( - +
{children}
-
+ ) diff --git a/test/apps/nextjs-app-router/app/providers.tsx b/test/apps/nextjs-app-router/app/providers.tsx new file mode 100644 index 0000000000..25954cb4a4 --- /dev/null +++ b/test/apps/nextjs-app-router/app/providers.tsx @@ -0,0 +1,33 @@ +'use client' + +import type { ReactNode } from 'react' +import { datadogRum } from '@datadog/browser-rum' +import { reactPlugin, DatadogRumProvider } from '@datadog/browser-rum-react/nextjs' + +// In E2E tests, RUM_CONFIGURATION is injected via Playwright's addInitScript +// if (typeof window !== 'undefined') { +// const config = (window as any).RUM_CONFIGURATION +// if (config) { +// datadogRum.init({ +// ...config, +// plugins: [reactPlugin({ nextjs: true }), ...(config.plugins || [])], +// }) +// } +// } + +datadogRum.init({ + applicationId: 'a81f40b8-e9bd-4805-9b66-4e4edc529a14', + clientToken: 'pubfe2e138a54296da76dd66f6b0b5f3d98', + site: 'datad0g.com', + service: "beltran's-app", + env: 'dev', + sessionSampleRate: 100, + sessionReplaySampleRate: 20, + trackBfcacheViews: true, + defaultPrivacyLevel: 'mask-user-input', + plugins: [reactPlugin({ nextjs: true })], +}) + +export function RumProvider({ children }: { children: ReactNode }) { + return {children} +} diff --git a/test/apps/nextjs-app-router/yarn.lock b/test/apps/nextjs-app-router/yarn.lock index f1ea97bef1..a2903e2314 100644 --- a/test/apps/nextjs-app-router/yarn.lock +++ b/test/apps/nextjs-app-router/yarn.lock @@ -7,23 +7,23 @@ __metadata: "@datadog/browser-core@file:../../../packages/core/package.tgz::locator=nextjs-app-router%40workspace%3A.": version: 6.26.0 - resolution: "@datadog/browser-core@file:../../../packages/core/package.tgz#../../../packages/core/package.tgz::hash=187b55&locator=nextjs-app-router%40workspace%3A." - checksum: 10c0/19ac48eba81c641b6c6598e2a3d9d0533ff1f422d26f563ed984888c8ab488e73127f2877942fc0816eec3fb2e91eca0fadf97f8ecaf9af914b7ea0591a96c68 + resolution: "@datadog/browser-core@file:../../../packages/core/package.tgz#../../../packages/core/package.tgz::hash=ea9131&locator=nextjs-app-router%40workspace%3A." + checksum: 10c0/ba5a8807673090631e74ff2863705acd7973bf61361cd0b39e32e9290bd2213ced13b81467dc7db5eca23d799b63164a443ef678c605212cf9f521cf643ec0f8 languageName: node linkType: hard "@datadog/browser-rum-core@file:../../../packages/rum-core/package.tgz::locator=nextjs-app-router%40workspace%3A.": version: 6.26.0 - resolution: "@datadog/browser-rum-core@file:../../../packages/rum-core/package.tgz#../../../packages/rum-core/package.tgz::hash=ab8181&locator=nextjs-app-router%40workspace%3A." + resolution: "@datadog/browser-rum-core@file:../../../packages/rum-core/package.tgz#../../../packages/rum-core/package.tgz::hash=76632d&locator=nextjs-app-router%40workspace%3A." dependencies: "@datadog/browser-core": "npm:6.26.0" - checksum: 10c0/24d0d5207c6df687bf9caae795a8598136c3788daa96bb4c321b30de9838b126dfec418df3cdbbbcf3fa2608b47e552581dfe3d0c1134811c98c306891b343d7 + checksum: 10c0/d94abf41063da0dd758f96c33d5cd8984545ca16f425185e8aee2fe0f96a5d3e74eb777a52c27c2d60657fb533af4381ea9dec9a989fedb3742926e613fb73b1 languageName: node linkType: hard "@datadog/browser-rum-react@file:../../../packages/rum-react/package.tgz::locator=nextjs-app-router%40workspace%3A.": version: 6.26.0 - resolution: "@datadog/browser-rum-react@file:../../../packages/rum-react/package.tgz#../../../packages/rum-react/package.tgz::hash=f71519&locator=nextjs-app-router%40workspace%3A." + resolution: "@datadog/browser-rum-react@file:../../../packages/rum-react/package.tgz#../../../packages/rum-react/package.tgz::hash=4d43e7&locator=nextjs-app-router%40workspace%3A." dependencies: "@datadog/browser-core": "npm:6.26.0" "@datadog/browser-rum-core": "npm:6.26.0" @@ -45,13 +45,13 @@ __metadata: optional: true react-router-dom: optional: true - checksum: 10c0/c94598ac8926ba88d9f6a06bb347ee42b8e2615372a54c6a8405dfa9c8bb93d77ded7d51433bd804882d9814c357c4f5003dabf4a022eddf6e91bc39d7723a57 + checksum: 10c0/b4985eaff23eeea495158bf6ce4521eff483c4dfe59a3e3d6b2801e95a6961c19034603883b26bf21cd98fe716a05462b3a5cf5f9cb1b6f8b3d3b8e9675af0ef languageName: node linkType: hard "@datadog/browser-rum@file:../../../packages/rum/package.tgz::locator=nextjs-app-router%40workspace%3A.": version: 6.26.0 - resolution: "@datadog/browser-rum@file:../../../packages/rum/package.tgz#../../../packages/rum/package.tgz::hash=8b8895&locator=nextjs-app-router%40workspace%3A." + resolution: "@datadog/browser-rum@file:../../../packages/rum/package.tgz#../../../packages/rum/package.tgz::hash=a251ae&locator=nextjs-app-router%40workspace%3A." dependencies: "@datadog/browser-core": "npm:6.26.0" "@datadog/browser-rum-core": "npm:6.26.0" @@ -60,7 +60,7 @@ __metadata: peerDependenciesMeta: "@datadog/browser-logs": optional: true - checksum: 10c0/2f8f6fa898bb2d9a6ea3efe35025c09b9e865ecf3fe7bab983f0e8df6d296778662fb4da8df0d396b37e01b723cf38af03443dd3982323bef12d3658b9e8be42 + checksum: 10c0/82268f16e68538f9909fba68389f10acf294fc45c943d16568addb03671ea843a18e34de2de15cb6c80c4fb67e78cdb1d2a79be53f017666bcec6db4790971bf languageName: node linkType: hard diff --git a/test/e2e/scenario/nextjs.scenario.ts b/test/e2e/scenario/nextjs.scenario.ts index b788c3ea42..4d0734fc9f 100644 --- a/test/e2e/scenario/nextjs.scenario.ts +++ b/test/e2e/scenario/nextjs.scenario.ts @@ -2,6 +2,20 @@ import { test, expect } from '@playwright/test' import { createTest } from '../lib/framework' test.describe('nextjs app router', () => { + createTest('should track initial view') + .withRum() + .withNextjsApp() + .run(async ({ page, flushEvents, intakeRegistry }) => { + await page.click('text=Go to User 42') + await page.waitForURL('**/user/42') + + await flushEvents() + + const viewEvents = intakeRegistry.rumViewEvents + const homeView = viewEvents.find((e) => e.view.name === '/' && e.view.loading_type === 'initial_load') + expect(homeView).toBeDefined() + }) + createTest('should normalize dynamic route to /user/:id') .withRum() .withNextjsApp() @@ -25,6 +39,39 @@ test.describe('nextjs app router', () => { expect(userView?.view.loading_type).toBe('route_change') }) + createTest('should track SPA navigation with loading_time') + .withRum() + .withNextjsApp() + .run(async ({ page, flushEvents, intakeRegistry }) => { + await page.click('text=Go to User 42') + await page.waitForURL('**/user/42') + + await flushEvents() + + const viewEvents = intakeRegistry.rumViewEvents + const userView = viewEvents.find((e) => e.view.name === '/user/:id' && e.view.loading_type === 'route_change') + expect(userView).toBeDefined() + expect(userView?.view.loading_time).toBeDefined() + expect(userView?.view.loading_time).toBeGreaterThan(0) + }) + + createTest('should track back navigation via popstate') + .withRum() + .withNextjsApp() + .run(async ({ page, flushEvents, intakeRegistry }) => { + await page.click('text=Go to User 42') + await page.waitForURL('**/user/42') + + await page.goBack() + await page.waitForURL('**/localhost:3000/') + + await flushEvents() + + const viewEvents = intakeRegistry.rumViewEvents + // Should have at least 3 views: initial /, /user/:id, and back to / + expect(viewEvents.filter((e) => e.view.name === '/').length).toBeGreaterThanOrEqual(2) + }) + createTest('should send a react component render vital event') .withRum() .withNextjsApp() From 9ed9c2cdfa1601b9c7b18881b70329a96168cb51 Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Fri, 13 Feb 2026 16:54:13 +0100 Subject: [PATCH 10/20] Remove version from nextjs-app-router package.json --- test/apps/nextjs-app-router/package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/test/apps/nextjs-app-router/package.json b/test/apps/nextjs-app-router/package.json index b342c95c88..4d34606854 100644 --- a/test/apps/nextjs-app-router/package.json +++ b/test/apps/nextjs-app-router/package.json @@ -1,6 +1,5 @@ { "name": "nextjs-app-router", - "version": "0.1.0", "private": true, "scripts": { "dev": "next dev --port 3000", From 473b35c4d1662cfe94f105ac8416bec0106c0f1b Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Mon, 16 Feb 2026 11:01:04 +0100 Subject: [PATCH 11/20] remove unneded files and comments --- packages/rum-react/NEXTJS_PLAN.md | 404 ------------------ .../src/domain/nextjs/historyTracking.ts | 7 - packages/rum-react/src/entries/nextjs.ts | 2 - test/apps/nextjs-app-router/README.md | 53 --- test/apps/nextjs-app-router/app/providers.tsx | 31 +- 5 files changed, 9 insertions(+), 488 deletions(-) delete mode 100644 packages/rum-react/NEXTJS_PLAN.md delete mode 100644 test/apps/nextjs-app-router/README.md diff --git a/packages/rum-react/NEXTJS_PLAN.md b/packages/rum-react/NEXTJS_PLAN.md deleted file mode 100644 index 92760d2c7c..0000000000 --- a/packages/rum-react/NEXTJS_PLAN.md +++ /dev/null @@ -1,404 +0,0 @@ -# Next.js App Router Integration Plan - -## Overview - -Create a Next.js App Router integration as a new entry point within `@datadog/browser-rum-react` package (following the react-router-v6/v7 pattern). This integration will provide automatic route tracking, error boundary support, and component performance tracking for Next.js 13+ applications. - -**Package**: `@datadog/browser-rum-react/nextjs` - -## Architecture Decisions - -### 1. Package Structure - -- **Extend rum-react** with `/nextjs` entry point (not a separate package) -- Maximizes code reuse for ErrorBoundary and UNSTABLE_ReactComponentTracker -- Follows established pattern of react-router-v6/v7 entry points - -### 2. Initialization Patterns - -Support **both** patterns to accommodate different use cases: - -- **DatadogRumProvider component** (primary/recommended): For use in app/layout.tsx -- **Instrumentation file approach**: For early initialization using instrumentation-client.ts - -### 3. View Naming Strategy - -- **Automatic pattern detection**: Like React Router, view names use patterns not actual values -- Built-in transformation: `/product/123` → `/product/:id`, `/user/abc-123-def` → `/user/:uuid` -- Works automatically with no configuration required (matches React Router behavior) - -### 4. Plugin Configuration - -- Add `nextjs: boolean` flag to `ReactPluginConfiguration` -- When enabled, sets `trackViewsManually: true` automatically - -## Components & Exports - -### Main Exports (from `@datadog/browser-rum-react/nextjs`) - -**Initialization:** - -- `DatadogRumProvider` - Component wrapper for automatic route tracking -- `initDatadogRum(config)` - Helper for instrumentation file usage - -**Error Tracking:** - -- `ErrorBoundary` - Reused from rum-react -- `addReactError` - Reused from rum-react - -**Performance:** - -- `UNSTABLE_ReactComponentTracker` - Reused from rum-react - -**Manual Control (Advanced):** - -- `usePathnameTracker()` - Hook for custom tracking logic (rare use case) - -**Types:** - -- `NextjsRumConfig` - Configuration interface for instrumentation file -- `DatadogRumProviderProps` - Provider component props - -## Implementation Files - -### New Files Created - -#### Core Integration - -``` -packages/rum-react/src/domain/nextjs/ -├── index.ts # Barrel export -├── types.ts # TypeScript interfaces -├── datadogRumProvider.tsx # Main provider component -├── datadogRumProvider.spec.tsx # Provider tests -├── usePathnameTracker.ts # Route change detection hook -├── usePathnameTracker.spec.ts # Hook tests -├── startNextjsView.ts # View creation logic -├── startNextjsView.spec.ts # View creation tests -├── normalizeViewName.ts # Internal view name normalization -├── normalizeViewName.spec.ts # Normalization tests -└── initDatadogRum.ts # Instrumentation file helper -``` - -#### Entry Point - -``` -packages/rum-react/src/entries/ -└── nextjs.ts # Main entry point - -packages/rum-react/nextjs/ -├── package.json # Points to ../esm/entries/nextjs.js -└── typedoc.json # Documentation config -``` - -### Files Modified - -**packages/rum-react/src/domain/reactPlugin.ts** - -- Add `nextjs?: boolean` to `ReactPluginConfiguration` interface -- Set `trackViewsManually: true` when `nextjs: true` - -**packages/rum-react/package.json** - -- Add `"next": ">=13"` to `peerDependencies` -- Mark as optional in `peerDependenciesMeta` - -**packages/rum-react/README.md** - -- Add Next.js App Router section with usage examples - -## Technical Implementation Details - -### 1. DatadogRumProvider Component - -```typescript -// src/domain/nextjs/datadogRumProvider.tsx -'use client' - -export function DatadogRumProvider({ children }: DatadogRumProviderProps) { - usePathnameTracker() - return <>{children} -} -``` - -**Key features:** - -- Must be client component (`'use client'` directive) -- Uses `usePathnameTracker` internally -- Automatically normalizes dynamic segments (no configuration needed) -- Transparent wrapper (no DOM nodes) -- Always tracks initial page load (matches React Router behavior) - -### 2. usePathnameTracker Hook - -```typescript -// src/domain/nextjs/usePathnameTracker.ts -import { usePathname } from 'next/navigation' -import { useRef, useEffect } from 'react' - -export function usePathnameTracker() { - const pathname = usePathname() - const pathnameRef = useRef(null) - - useEffect(() => { - if (pathnameRef.current !== pathname) { - pathnameRef.current = pathname - startNextjsView(pathname) - } - }, [pathname]) -} -``` - -**Implementation notes:** - -- Uses Next.js `usePathname()` hook -- `useRef` to avoid unnecessary re-renders -- `useEffect` to ensure client-side only execution -- Tracks initial page load (consistent with React Router) -- Automatically normalizes view names (no config needed) - -### 3. startNextjsView Function - -```typescript -// src/domain/nextjs/startNextjsView.ts -export function startNextjsView(pathname: string) { - onRumInit((configuration, rumPublicApi) => { - if (!configuration.nextjs) { - display.warn('`nextjs: true` is missing from the react plugin configuration, ' + 'the view will not be tracked.') - return - } - - const viewName = normalizeViewName(pathname) - rumPublicApi.startView(viewName) - }) -} -``` - -**Key behaviors:** - -- Uses `onRumInit` subscription pattern (from reactPlugin) -- Checks `configuration.nextjs` flag -- Applies automatic view name normalization -- Calls `rumPublicApi.startView()` with normalized name - -### 4. View Name Normalization (Internal) - -```typescript -// src/domain/nextjs/normalizeViewName.ts - -/** - * Internal function that automatically normalizes pathnames to route patterns. - * Mimics React Router behavior where view names use placeholders. - * - * Examples: - * /product/123 -> /product/:id - * /user/abc-123-def-456 -> /user/:uuid - * /orders/456/items/789 -> /orders/:id/items/:id - */ -export function normalizeViewName(pathname: string): string { - return ( - pathname - // Replace UUID segments first (more specific pattern) - .replace(/\/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}(?=\/|[?#]|$)/gi, '/:uuid') - // Replace numeric segments - .replace(/\/\d+(?=\/|[?#]|$)/g, '/:id') - ) -} -``` - -**Note**: This function is internal and not exported. It automatically applies pattern detection to match React Router's behavior of showing route patterns rather than actual values. - -### 5. Instrumentation File Helper - -```typescript -// src/domain/nextjs/initDatadogRum.ts - -/** - * Helper for Next.js instrumentation-client.ts file. - * Initializes RUM and sets up global error tracking. - */ -export function initDatadogRum(config: NextjsRumConfig, datadogRum: RumPublicApi): void { - if (typeof window === 'undefined') { - // Server-side guard - return - } - - const { datadogConfig, nextjsConfig } = config - const nextjsPlugin = reactPlugin({ nextjs: true }) - const existingPlugins = (datadogConfig.plugins || []) as Array - - datadogRum.init({ - ...datadogConfig, - plugins: [nextjsPlugin].concat(existingPlugins), - }) - - // Optional: Set up early error capture - if (nextjsConfig?.captureEarlyErrors) { - addEventListener({}, window, 'error', (event) => { - datadogRum.addError(event.error) - }) - - addEventListener({}, window, 'unhandledrejection', (event: PromiseRejectionEvent) => { - datadogRum.addError(event.reason) - }) - } -} -``` - -## Usage Patterns - -### Pattern 1: DatadogRumProvider (Recommended) - -```typescript -// app/components/datadog-provider.tsx -'use client' -import { DatadogRumProvider } from '@datadog/browser-rum-react/nextjs' - -export function DatadogProvider({ children }) { - return {children} -} - -// app/layout.tsx -import { datadogRum } from '@datadog/browser-rum' -import { reactPlugin } from '@datadog/browser-rum-react' -import { DatadogProvider } from './components/datadog-provider' - -datadogRum.init({ - applicationId: '', - clientToken: '', - site: 'datadoghq.com', - plugins: [reactPlugin({ nextjs: true })], -}) - -export default function RootLayout({ children }) { - return ( - - - {children} - - - ) -} -``` - -### Pattern 2: Instrumentation File (Advanced) - -```typescript -// instrumentation-client.ts -import { initDatadogRum } from '@datadog/browser-rum-react/nextjs' - -export function register() { - initDatadogRum({ - datadogConfig: { - applicationId: '', - clientToken: '', - site: 'datadoghq.com', - }, - nextjsConfig: { - captureEarlyErrors: true, - } - }) -} - -// app/components/router-tracker.tsx -'use client' -import { usePathnameTracker } from '@datadog/browser-rum-react/nextjs' - -export function RouterTracker() { - usePathnameTracker() - return null -} - -// app/layout.tsx -import { RouterTracker } from './components/router-tracker' - -export default function RootLayout({ children }) { - return ( - - - - {children} - - - ) -} -``` - -## Error Tracking Integration - -### With Next.js error.js - -```typescript -// app/error.tsx -'use client' -import { useEffect } from 'react' -import { addReactError } from '@datadog/browser-rum-react/nextjs' - -export default function Error({ error, reset }) { - useEffect(() => { - addReactError(error, { componentStack: '' }) - }, [error]) - - return ( -
-

Something went wrong!

- -
- ) -} -``` - -### With Datadog ErrorBoundary - -```typescript -// app/layout.tsx or page-level -import { ErrorBoundary } from '@datadog/browser-rum-react/nextjs' - -function ErrorFallback({ error, resetError }) { - return ( -
-

Error: {error.message}

- -
- ) -} - -export default function Layout({ children }) { - return ( - - {children} - - ) -} -``` - -## Performance Tracking - -```typescript -// app/dashboard/page.tsx -'use client' -import { UNSTABLE_ReactComponentTracker } from '@datadog/browser-rum-react/nextjs' -import { DashboardWidget } from './components/widget' - -export default function DashboardPage() { - return ( - - - - ) -} -``` - -## Success Criteria - -- ✅ View tracking works for Next.js App Router navigation -- ✅ Dynamic routes normalized with pattern detection -- ✅ Error tracking integrates with Next.js error boundaries -- ✅ Component performance tracking works client-side -- ✅ Both initialization patterns supported and documented -- ✅ >90% test coverage for new code -- ✅ Zero TypeScript errors -- ✅ Works with Next.js 13, 14, 15 -- ✅ Clear documentation with multiple examples -- ✅ <5 minute setup time for developers -- ✅ Aligns with React Router behavior (always tracks initial load) diff --git a/packages/rum-react/src/domain/nextjs/historyTracking.ts b/packages/rum-react/src/domain/nextjs/historyTracking.ts index 6dd9af2d30..0b26464071 100644 --- a/packages/rum-react/src/domain/nextjs/historyTracking.ts +++ b/packages/rum-react/src/domain/nextjs/historyTracking.ts @@ -1,10 +1,3 @@ -/** - * History API interception for tracking client-side navigations. - * - * This module intercepts browser History API methods (pushState, replaceState) - * and the popstate event to detect navigation changes in SPAs like Next.js. - */ - import { buildUrl, addEventListener, DOM_EVENT } from '@datadog/browser-core' type NavigationCallback = (pathname: string) => void diff --git a/packages/rum-react/src/entries/nextjs.ts b/packages/rum-react/src/entries/nextjs.ts index 59a038f413..d4b2d9a3a2 100644 --- a/packages/rum-react/src/entries/nextjs.ts +++ b/packages/rum-react/src/entries/nextjs.ts @@ -38,10 +38,8 @@ * ``` */ -// Export Next.js-specific functionality export { DatadogRumProvider } from '../domain/nextjs' export type { DatadogRumProviderProps } from '../domain/nextjs' - // Re-export shared functionality from main package export { ErrorBoundary, addReactError } from '../domain/error' export type { ErrorBoundaryProps, ErrorBoundaryFallback } from '../domain/error' diff --git a/test/apps/nextjs-app-router/README.md b/test/apps/nextjs-app-router/README.md deleted file mode 100644 index f0a5e9d7e6..0000000000 --- a/test/apps/nextjs-app-router/README.md +++ /dev/null @@ -1,53 +0,0 @@ -# Next.js App Router Test App - -Test application for the `@datadog/browser-rum-react/nextjs` integration. - -## Rebuilding After Changes - -When you make changes to the SDK packages, run these commands: - -### One-liner (from repo root) - -```bash -cd packages/rum-react && yarn build && yarn pack --out package.tgz && cd ../../test/apps/nextjs-app-router && rm -rf node_modules && yarn cache clean --all && yarn install && yarn dev -``` - -### Step by step - -```bash -# 1. Build and pack rum-react (from repo root) -cd packages/rum-react -yarn build -yarn pack --out package.tgz - -# 2. Reinstall in test app -cd ../../test/apps/nextjs-app-router -rm -rf node_modules -yarn cache clean --all -yarn install - -# 3. Start dev server -yarn dev -``` - -App available at http://localhost:3000 - -## Test Routes - -- `/` - Home page -- `/user/42` - Dynamic route (normalizes to `/user/:id`) -- `/tracked` - Component tracking demo -- `/error-test` - Error boundary testing - -## E2E Tests - -E2E tests are in `test/e2e/scenario/nextjs.scenario.ts`. - -### Running E2E Tests - -```bash -# From repo root - starts both dev servers automatically -yarn test:e2e -g "nextjs" -``` - -The Playwright config automatically starts the Next.js dev server on port 3000. diff --git a/test/apps/nextjs-app-router/app/providers.tsx b/test/apps/nextjs-app-router/app/providers.tsx index 25954cb4a4..7c9b9db9ff 100644 --- a/test/apps/nextjs-app-router/app/providers.tsx +++ b/test/apps/nextjs-app-router/app/providers.tsx @@ -5,28 +5,15 @@ import { datadogRum } from '@datadog/browser-rum' import { reactPlugin, DatadogRumProvider } from '@datadog/browser-rum-react/nextjs' // In E2E tests, RUM_CONFIGURATION is injected via Playwright's addInitScript -// if (typeof window !== 'undefined') { -// const config = (window as any).RUM_CONFIGURATION -// if (config) { -// datadogRum.init({ -// ...config, -// plugins: [reactPlugin({ nextjs: true }), ...(config.plugins || [])], -// }) -// } -// } - -datadogRum.init({ - applicationId: 'a81f40b8-e9bd-4805-9b66-4e4edc529a14', - clientToken: 'pubfe2e138a54296da76dd66f6b0b5f3d98', - site: 'datad0g.com', - service: "beltran's-app", - env: 'dev', - sessionSampleRate: 100, - sessionReplaySampleRate: 20, - trackBfcacheViews: true, - defaultPrivacyLevel: 'mask-user-input', - plugins: [reactPlugin({ nextjs: true })], -}) +if (typeof window !== 'undefined') { + const config = (window as any).RUM_CONFIGURATION + if (config) { + datadogRum.init({ + ...config, + plugins: [reactPlugin({ nextjs: true }), ...(config.plugins || [])], + }) + } +} export function RumProvider({ children }: { children: ReactNode }) { return {children} From c4f795b18d5f64df724083cef80b24c2a979445c Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Mon, 16 Feb 2026 11:51:59 +0100 Subject: [PATCH 12/20] Build Next.js app router test app, rename nextjs.scenario. --- scripts/build/build-test-apps.ts | 1 + .../scenario/{nextjs.scenario.ts => nextjsPlugin.scenario.ts} | 0 2 files changed, 1 insertion(+) rename test/e2e/scenario/{nextjs.scenario.ts => nextjsPlugin.scenario.ts} (100%) diff --git a/scripts/build/build-test-apps.ts b/scripts/build/build-test-apps.ts index 1af38f9102..80abd3e0ef 100644 --- a/scripts/build/build-test-apps.ts +++ b/scripts/build/build-test-apps.ts @@ -18,6 +18,7 @@ runMain(async () => { buildApp('test/apps/react-router-v6-app') buildApp('test/apps/react-heavy-spa') buildApp('test/apps/react-shopist-like') + buildApp('test/apps/nextjs-app-router') await buildReactRouterv7App() await buildExtensions() diff --git a/test/e2e/scenario/nextjs.scenario.ts b/test/e2e/scenario/nextjsPlugin.scenario.ts similarity index 100% rename from test/e2e/scenario/nextjs.scenario.ts rename to test/e2e/scenario/nextjsPlugin.scenario.ts From 0a62c79c12d1679f6ac6377a21fb620b9fa98cad Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Mon, 16 Feb 2026 13:29:44 +0100 Subject: [PATCH 13/20] Fix web server config. --- test/apps/nextjs-app-router/next.config.js | 6 +++- test/apps/nextjs-app-router/yarn.lock | 36 ++++++++++---------- test/e2e/playwright.base.config.ts | 38 ++++++++++++---------- 3 files changed, 43 insertions(+), 37 deletions(-) diff --git a/test/apps/nextjs-app-router/next.config.js b/test/apps/nextjs-app-router/next.config.js index 767719fc4f..c77754a91e 100644 --- a/test/apps/nextjs-app-router/next.config.js +++ b/test/apps/nextjs-app-router/next.config.js @@ -1,4 +1,8 @@ /** @type {import('next').NextConfig} */ -const nextConfig = {} +const nextConfig = { + turbopack: { + root: __dirname, + }, +} module.exports = nextConfig diff --git a/test/apps/nextjs-app-router/yarn.lock b/test/apps/nextjs-app-router/yarn.lock index a2903e2314..69899fb059 100644 --- a/test/apps/nextjs-app-router/yarn.lock +++ b/test/apps/nextjs-app-router/yarn.lock @@ -6,27 +6,27 @@ __metadata: cacheKey: 10c0 "@datadog/browser-core@file:../../../packages/core/package.tgz::locator=nextjs-app-router%40workspace%3A.": - version: 6.26.0 - resolution: "@datadog/browser-core@file:../../../packages/core/package.tgz#../../../packages/core/package.tgz::hash=ea9131&locator=nextjs-app-router%40workspace%3A." - checksum: 10c0/ba5a8807673090631e74ff2863705acd7973bf61361cd0b39e32e9290bd2213ced13b81467dc7db5eca23d799b63164a443ef678c605212cf9f521cf643ec0f8 + version: 6.27.1 + resolution: "@datadog/browser-core@file:../../../packages/core/package.tgz#../../../packages/core/package.tgz::hash=1c42fb&locator=nextjs-app-router%40workspace%3A." + checksum: 10c0/87975b5069b59e9e0400e78a9db676137f3e52fae8fb3146c128527c760837c553960340505027b78c19ceeb9693fb79d70a8289781f2810be3bb22142c30ff9 languageName: node linkType: hard "@datadog/browser-rum-core@file:../../../packages/rum-core/package.tgz::locator=nextjs-app-router%40workspace%3A.": - version: 6.26.0 - resolution: "@datadog/browser-rum-core@file:../../../packages/rum-core/package.tgz#../../../packages/rum-core/package.tgz::hash=76632d&locator=nextjs-app-router%40workspace%3A." + version: 6.27.1 + resolution: "@datadog/browser-rum-core@file:../../../packages/rum-core/package.tgz#../../../packages/rum-core/package.tgz::hash=7c1f38&locator=nextjs-app-router%40workspace%3A." dependencies: - "@datadog/browser-core": "npm:6.26.0" - checksum: 10c0/d94abf41063da0dd758f96c33d5cd8984545ca16f425185e8aee2fe0f96a5d3e74eb777a52c27c2d60657fb533af4381ea9dec9a989fedb3742926e613fb73b1 + "@datadog/browser-core": "npm:6.27.1" + checksum: 10c0/b1516087d4082125ba9da1d0f7ae6f392d7e896bd5c932069d9af2fe617bc58508140065d7c69b54b896be6df26b55bce32c3a81e81d858bef8eaef4e854ca0c languageName: node linkType: hard "@datadog/browser-rum-react@file:../../../packages/rum-react/package.tgz::locator=nextjs-app-router%40workspace%3A.": - version: 6.26.0 - resolution: "@datadog/browser-rum-react@file:../../../packages/rum-react/package.tgz#../../../packages/rum-react/package.tgz::hash=4d43e7&locator=nextjs-app-router%40workspace%3A." + version: 6.27.1 + resolution: "@datadog/browser-rum-react@file:../../../packages/rum-react/package.tgz#../../../packages/rum-react/package.tgz::hash=01176f&locator=nextjs-app-router%40workspace%3A." dependencies: - "@datadog/browser-core": "npm:6.26.0" - "@datadog/browser-rum-core": "npm:6.26.0" + "@datadog/browser-core": "npm:6.27.1" + "@datadog/browser-rum-core": "npm:6.27.1" peerDependencies: next: ">=13" react: 18 || 19 @@ -45,22 +45,22 @@ __metadata: optional: true react-router-dom: optional: true - checksum: 10c0/b4985eaff23eeea495158bf6ce4521eff483c4dfe59a3e3d6b2801e95a6961c19034603883b26bf21cd98fe716a05462b3a5cf5f9cb1b6f8b3d3b8e9675af0ef + checksum: 10c0/88f9c4e95376c805df6c2d4344cfd2c41a8c891df1b4420a8e798e7069f5653dbba67853fc141ba1bf22e6d91ba0950cc2a4a48d5bb70146abc2004b277a5f9b languageName: node linkType: hard "@datadog/browser-rum@file:../../../packages/rum/package.tgz::locator=nextjs-app-router%40workspace%3A.": - version: 6.26.0 - resolution: "@datadog/browser-rum@file:../../../packages/rum/package.tgz#../../../packages/rum/package.tgz::hash=a251ae&locator=nextjs-app-router%40workspace%3A." + version: 6.27.1 + resolution: "@datadog/browser-rum@file:../../../packages/rum/package.tgz#../../../packages/rum/package.tgz::hash=fcaac5&locator=nextjs-app-router%40workspace%3A." dependencies: - "@datadog/browser-core": "npm:6.26.0" - "@datadog/browser-rum-core": "npm:6.26.0" + "@datadog/browser-core": "npm:6.27.1" + "@datadog/browser-rum-core": "npm:6.27.1" peerDependencies: - "@datadog/browser-logs": 6.26.0 + "@datadog/browser-logs": 6.27.1 peerDependenciesMeta: "@datadog/browser-logs": optional: true - checksum: 10c0/82268f16e68538f9909fba68389f10acf294fc45c943d16568addb03671ea843a18e34de2de15cb6c80c4fb67e78cdb1d2a79be53f017666bcec6db4790971bf + checksum: 10c0/1f4a18c17f069e10116a4eec745f80c3dd43ce2868cab3eefddc341e836f532b3b5d3c6c2ed5e547a96715fae24cf472969dd83207a7d970c2b7dad02c1c4d36 languageName: node linkType: hard diff --git a/test/e2e/playwright.base.config.ts b/test/e2e/playwright.base.config.ts index e0743b58cf..b7393fb63b 100644 --- a/test/e2e/playwright.base.config.ts +++ b/test/e2e/playwright.base.config.ts @@ -31,22 +31,24 @@ export const config: Config = { trace: isCi ? 'off' : 'retain-on-failure', }, - webServer: isLocal - ? [ - { - stdout: 'pipe', - cwd: path.join(__dirname, '../..'), - command: 'yarn dev', - url: DEV_SERVER_BASE_URL, - reuseExistingServer: true, - }, - { - stdout: 'pipe', - cwd: path.join(__dirname, '../apps/nextjs-app-router'), - command: 'yarn dev', - url: NEXTJS_APP_URL, - reuseExistingServer: true, - }, - ] - : undefined, + webServer: [ + ...(isLocal + ? [ + { + stdout: 'pipe' as const, + cwd: path.join(__dirname, '../..'), + command: 'yarn dev', + url: DEV_SERVER_BASE_URL, + reuseExistingServer: true, + }, + ] + : []), + { + stdout: 'pipe' as const, + cwd: path.join(__dirname, '../apps/nextjs-app-router'), + command: isLocal ? 'yarn dev' : 'yarn start', + url: NEXTJS_APP_URL, + reuseExistingServer: true, + }, + ], } From effabec08df8eab8f2bc395fa27704cf373fcd82 Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Mon, 16 Feb 2026 14:28:22 +0100 Subject: [PATCH 14/20] Use instrumentMethod from core for history tracking. --- .prettierignore | 1 + .../domain/nextjs/datadogRumProvider.spec.tsx | 11 ----- .../src/domain/nextjs/datadogRumProvider.tsx | 8 +--- .../src/domain/nextjs/historyTracking.spec.ts | 24 ++-------- .../src/domain/nextjs/historyTracking.ts | 44 ++++++++++--------- test/apps/nextjs-app-router/yarn.lock | 4 +- 6 files changed, 32 insertions(+), 60 deletions(-) diff --git a/.prettierignore b/.prettierignore index d1ee84bc43..29baea3bab 100644 --- a/.prettierignore +++ b/.prettierignore @@ -10,3 +10,4 @@ yarn.lock /developer-extension/.output /developer-extension/.wxt /test/apps/nextjs-app-router/.next +/test/apps/nextjs-app-router/next-env.d.ts diff --git a/packages/rum-react/src/domain/nextjs/datadogRumProvider.spec.tsx b/packages/rum-react/src/domain/nextjs/datadogRumProvider.spec.tsx index 8b8fb1ece5..7c5ba9bede 100644 --- a/packages/rum-react/src/domain/nextjs/datadogRumProvider.spec.tsx +++ b/packages/rum-react/src/domain/nextjs/datadogRumProvider.spec.tsx @@ -1,13 +1,10 @@ import React from 'react' -import { registerCleanupTask } from '../../../../core/test' import { appendComponent } from '../../../test/appendComponent' import { initializeReactPlugin } from '../../../test/initializeReactPlugin' import { DatadogRumProvider } from './datadogRumProvider' describe('DatadogRumProvider', () => { let startViewSpy: jasmine.Spy<(name?: string | object) => void> - let originalPushState: History['pushState'] - let originalReplaceState: History['replaceState'] beforeEach(() => { startViewSpy = jasmine.createSpy() @@ -19,14 +16,6 @@ describe('DatadogRumProvider', () => { startView: startViewSpy, }, }) - - originalPushState = history.pushState.bind(history) - originalReplaceState = history.replaceState.bind(history) - - registerCleanupTask(() => { - history.pushState = originalPushState - history.replaceState = originalReplaceState - }) }) it('renders children correctly', () => { diff --git a/packages/rum-react/src/domain/nextjs/datadogRumProvider.tsx b/packages/rum-react/src/domain/nextjs/datadogRumProvider.tsx index e50e2c27ee..747e767e9c 100644 --- a/packages/rum-react/src/domain/nextjs/datadogRumProvider.tsx +++ b/packages/rum-react/src/domain/nextjs/datadogRumProvider.tsx @@ -12,6 +12,7 @@ export interface DatadogRumProviderProps { } export function DatadogRumProvider({ children }: DatadogRumProviderProps) { + // This ref is needed because of React . const isSetupRef = useRef(false) useEffect(() => { @@ -22,14 +23,9 @@ export function DatadogRumProvider({ children }: DatadogRumProviderProps) { startNextjsView(window.location.pathname) - const cleanup = setupHistoryTracking((pathname) => { + setupHistoryTracking((pathname) => { startNextjsView(pathname) }) - - return () => { - cleanup() - isSetupRef.current = false - } }, []) return <>{children} diff --git a/packages/rum-react/src/domain/nextjs/historyTracking.spec.ts b/packages/rum-react/src/domain/nextjs/historyTracking.spec.ts index 3f2ab8d8c6..3390d13a30 100644 --- a/packages/rum-react/src/domain/nextjs/historyTracking.spec.ts +++ b/packages/rum-react/src/domain/nextjs/historyTracking.spec.ts @@ -3,21 +3,14 @@ import { setupHistoryTracking } from './historyTracking' describe('setupHistoryTracking', () => { let onNavigationSpy: jasmine.Spy<(pathname: string) => void> - let cleanup: () => void - let originalPushState: History['pushState'] - let originalReplaceState: History['replaceState'] + let stopHistoryTracking: () => void beforeEach(() => { onNavigationSpy = jasmine.createSpy('onNavigation') - originalPushState = history.pushState.bind(history) - originalReplaceState = history.replaceState.bind(history) + stopHistoryTracking = setupHistoryTracking(onNavigationSpy) registerCleanupTask(() => { - if (cleanup) { - cleanup() - } - history.pushState = originalPushState - history.replaceState = originalReplaceState + stopHistoryTracking() }) }) ;[ @@ -26,8 +19,6 @@ describe('setupHistoryTracking', () => { { method: 'pushState' as const, url: '/user/42?tab=profile', expected: '/user/42' }, ].forEach(({ method, url, expected }) => { it(`calls callback with ${expected} when ${method} is called with ${url}`, () => { - cleanup = setupHistoryTracking(onNavigationSpy) - history[method]({}, '', url) expect(onNavigationSpy).toHaveBeenCalledWith(expected) @@ -35,16 +26,12 @@ describe('setupHistoryTracking', () => { }) it('calls callback on popstate event', () => { - cleanup = setupHistoryTracking(onNavigationSpy) - window.dispatchEvent(new PopStateEvent('popstate')) expect(onNavigationSpy).toHaveBeenCalledWith(window.location.pathname) }) it('does not call callback when URL is null', () => { - cleanup = setupHistoryTracking(onNavigationSpy) - history.pushState({ data: 'test' }, '') expect(onNavigationSpy).not.toHaveBeenCalled() @@ -55,8 +42,7 @@ describe('setupHistoryTracking', () => { { name: 'popstate', trigger: () => window.dispatchEvent(new PopStateEvent('popstate')) }, ].forEach(({ name, trigger }) => { it(`does not call callback after cleanup when ${name} is triggered`, () => { - cleanup = setupHistoryTracking(onNavigationSpy) - cleanup() + stopHistoryTracking() trigger() @@ -65,8 +51,6 @@ describe('setupHistoryTracking', () => { }) it('tracks multiple navigations', () => { - cleanup = setupHistoryTracking(onNavigationSpy) - history.pushState({}, '', '/page1') history.pushState({}, '', '/page2') history.replaceState({}, '', '/page3') diff --git a/packages/rum-react/src/domain/nextjs/historyTracking.ts b/packages/rum-react/src/domain/nextjs/historyTracking.ts index 0b26464071..3f0db08c2b 100644 --- a/packages/rum-react/src/domain/nextjs/historyTracking.ts +++ b/packages/rum-react/src/domain/nextjs/historyTracking.ts @@ -1,17 +1,15 @@ -import { buildUrl, addEventListener, DOM_EVENT } from '@datadog/browser-core' +import { buildUrl, addEventListener, instrumentMethod, DOM_EVENT } from '@datadog/browser-core' type NavigationCallback = (pathname: string) => void /** * Sets up History API interception to track client-side navigations. + * This is needed so that we can track navigations and not have race conditions with the browser. * * @param onNavigation - Callback invoked with the new pathname on each navigation - * @returns Cleanup function to remove interception and event listeners + * @returns Object with a `stop` method to remove interception and event listeners */ export function setupHistoryTracking(onNavigation: NavigationCallback): () => void { - const originalPushState = history.pushState.bind(history) - const originalReplaceState = history.replaceState.bind(history) - function handleStateChange(_state: unknown, _unused: string, url?: string | URL | null) { if (url) { const pathname = buildUrl(String(url), window.location.href).pathname @@ -23,21 +21,22 @@ export function setupHistoryTracking(onNavigation: NavigationCallback): () => vo onNavigation(window.location.pathname) } - // Intercept pushState - history.pushState = function (...args: Parameters) { - const result = originalPushState(...args) - handleStateChange(...args) - return result - } + const { stop: stopInstrumentingPushState } = instrumentMethod( + getHistoryInstrumentationTarget('pushState'), + 'pushState', + ({ parameters, onPostCall }) => { + onPostCall(() => handleStateChange(...parameters)) + } + ) - // Intercept replaceState - history.replaceState = function (...args: Parameters) { - const result = originalReplaceState(...args) - handleStateChange(...args) - return result - } + const { stop: stopInstrumentingReplaceState } = instrumentMethod( + getHistoryInstrumentationTarget('replaceState'), + 'replaceState', + ({ parameters, onPostCall }) => { + onPostCall(() => handleStateChange(...parameters)) + } + ) - // Listen for back/forward navigation const { stop: stopPopStateListener } = addEventListener( { allowUntrustedEvents: true }, window, @@ -45,10 +44,13 @@ export function setupHistoryTracking(onNavigation: NavigationCallback): () => vo handlePopState ) - // Return cleanup function return () => { - history.pushState = originalPushState - history.replaceState = originalReplaceState + stopInstrumentingPushState() + stopInstrumentingReplaceState() stopPopStateListener() } } + +function getHistoryInstrumentationTarget(methodName: 'pushState' | 'replaceState') { + return Object.prototype.hasOwnProperty.call(history, methodName) ? history : History.prototype +} diff --git a/test/apps/nextjs-app-router/yarn.lock b/test/apps/nextjs-app-router/yarn.lock index 69899fb059..6834a7a896 100644 --- a/test/apps/nextjs-app-router/yarn.lock +++ b/test/apps/nextjs-app-router/yarn.lock @@ -23,7 +23,7 @@ __metadata: "@datadog/browser-rum-react@file:../../../packages/rum-react/package.tgz::locator=nextjs-app-router%40workspace%3A.": version: 6.27.1 - resolution: "@datadog/browser-rum-react@file:../../../packages/rum-react/package.tgz#../../../packages/rum-react/package.tgz::hash=01176f&locator=nextjs-app-router%40workspace%3A." + resolution: "@datadog/browser-rum-react@file:../../../packages/rum-react/package.tgz#../../../packages/rum-react/package.tgz::hash=0a2e77&locator=nextjs-app-router%40workspace%3A." dependencies: "@datadog/browser-core": "npm:6.27.1" "@datadog/browser-rum-core": "npm:6.27.1" @@ -45,7 +45,7 @@ __metadata: optional: true react-router-dom: optional: true - checksum: 10c0/88f9c4e95376c805df6c2d4344cfd2c41a8c891df1b4420a8e798e7069f5653dbba67853fc141ba1bf22e6d91ba0950cc2a4a48d5bb70146abc2004b277a5f9b + checksum: 10c0/e8f126e5e63ec4be06890e1d8d1a69c7c70d04fb7a55f56632cda3cc9270205041fdc714c133604e46dde612670aee7affc1d88ad926d0d4ef51bfb123d881f4 languageName: node linkType: hard From 27a938e23b8bde715f5ba92eb4300186fa9d1f5e Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Tue, 17 Feb 2026 13:40:42 +0100 Subject: [PATCH 15/20] Fix unit-bs by returning a cleanup and using initReactOldBrowsersSupport --- .../src/domain/nextjs/datadogRumProvider.spec.tsx | 2 ++ .../src/domain/nextjs/datadogRumProvider.tsx | 14 ++++---------- test/apps/nextjs-app-router/next-env.d.ts | 2 +- test/apps/nextjs-app-router/yarn.lock | 4 ++-- 4 files changed, 9 insertions(+), 13 deletions(-) diff --git a/packages/rum-react/src/domain/nextjs/datadogRumProvider.spec.tsx b/packages/rum-react/src/domain/nextjs/datadogRumProvider.spec.tsx index 7c5ba9bede..07dec70ff8 100644 --- a/packages/rum-react/src/domain/nextjs/datadogRumProvider.spec.tsx +++ b/packages/rum-react/src/domain/nextjs/datadogRumProvider.spec.tsx @@ -1,12 +1,14 @@ import React from 'react' import { appendComponent } from '../../../test/appendComponent' import { initializeReactPlugin } from '../../../test/initializeReactPlugin' +import { initReactOldBrowsersSupport } from '../../../test/reactOldBrowsersSupport' import { DatadogRumProvider } from './datadogRumProvider' describe('DatadogRumProvider', () => { let startViewSpy: jasmine.Spy<(name?: string | object) => void> beforeEach(() => { + initReactOldBrowsersSupport() startViewSpy = jasmine.createSpy() initializeReactPlugin({ configuration: { diff --git a/packages/rum-react/src/domain/nextjs/datadogRumProvider.tsx b/packages/rum-react/src/domain/nextjs/datadogRumProvider.tsx index 747e767e9c..e2212573ad 100644 --- a/packages/rum-react/src/domain/nextjs/datadogRumProvider.tsx +++ b/packages/rum-react/src/domain/nextjs/datadogRumProvider.tsx @@ -1,6 +1,6 @@ 'use client' -import React, { type ReactNode, useEffect, useRef } from 'react' +import React, { type ReactNode, useEffect } from 'react' import { setupHistoryTracking } from './historyTracking' import { startNextjsView } from './viewTracking' @@ -12,20 +12,14 @@ export interface DatadogRumProviderProps { } export function DatadogRumProvider({ children }: DatadogRumProviderProps) { - // This ref is needed because of React . - const isSetupRef = useRef(false) - useEffect(() => { - if (isSetupRef.current) { - return - } - isSetupRef.current = true - startNextjsView(window.location.pathname) - setupHistoryTracking((pathname) => { + const stopHistoryTracking = setupHistoryTracking((pathname) => { startNextjsView(pathname) }) + + return stopHistoryTracking }, []) return <>{children} diff --git a/test/apps/nextjs-app-router/next-env.d.ts b/test/apps/nextjs-app-router/next-env.d.ts index a3e4680c77..c4b7818fbb 100644 --- a/test/apps/nextjs-app-router/next-env.d.ts +++ b/test/apps/nextjs-app-router/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import './.next/dev/types/routes.d.ts' +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/test/apps/nextjs-app-router/yarn.lock b/test/apps/nextjs-app-router/yarn.lock index 6834a7a896..23edac97c2 100644 --- a/test/apps/nextjs-app-router/yarn.lock +++ b/test/apps/nextjs-app-router/yarn.lock @@ -23,7 +23,7 @@ __metadata: "@datadog/browser-rum-react@file:../../../packages/rum-react/package.tgz::locator=nextjs-app-router%40workspace%3A.": version: 6.27.1 - resolution: "@datadog/browser-rum-react@file:../../../packages/rum-react/package.tgz#../../../packages/rum-react/package.tgz::hash=0a2e77&locator=nextjs-app-router%40workspace%3A." + resolution: "@datadog/browser-rum-react@file:../../../packages/rum-react/package.tgz#../../../packages/rum-react/package.tgz::hash=9ccce1&locator=nextjs-app-router%40workspace%3A." dependencies: "@datadog/browser-core": "npm:6.27.1" "@datadog/browser-rum-core": "npm:6.27.1" @@ -45,7 +45,7 @@ __metadata: optional: true react-router-dom: optional: true - checksum: 10c0/e8f126e5e63ec4be06890e1d8d1a69c7c70d04fb7a55f56632cda3cc9270205041fdc714c133604e46dde612670aee7affc1d88ad926d0d4ef51bfb123d881f4 + checksum: 10c0/f318f6ce0a4b2fec8ba0c565d6791f02b2687d8af0889009a8a9e1da9c6fac9a9385a8433c9041ed38932eff4bacb37926bd959ba6610a1996164208f3352b3f languageName: node linkType: hard From 2340fb2072ab06f650ebac892ddf56b96fe34d72 Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Tue, 17 Feb 2026 14:14:32 +0100 Subject: [PATCH 16/20] Skip tearDownPassedTest for Next.js apps --- test/e2e/lib/framework/createTest.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/e2e/lib/framework/createTest.ts b/test/e2e/lib/framework/createTest.ts index f34ac5f8e3..318091a5dd 100644 --- a/test/e2e/lib/framework/createTest.ts +++ b/test/e2e/lib/framework/createTest.ts @@ -325,7 +325,9 @@ function declareTest(title: string, setupOptions: SetupOptions, factory: SetupFa try { await runner(testContext) - tearDownPassedTest(testContext) + if (!setupOptions.nextjsApp) { + tearDownPassedTest(testContext) + } } finally { await tearDownTest(testContext) } From 7e6cd10848ea752b6ef0d2a5843fe06ebed1b603 Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Tue, 17 Feb 2026 15:22:22 +0100 Subject: [PATCH 17/20] Replace historyTracking with usePathname and useParams --- .../domain/nextjs/datadogRumProvider.spec.tsx | 17 +++-- .../src/domain/nextjs/datadogRumProvider.tsx | 36 +++++++---- .../src/domain/nextjs/historyTracking.spec.ts | 63 ------------------- .../src/domain/nextjs/historyTracking.ts | 56 ----------------- packages/rum-react/src/domain/nextjs/index.ts | 2 +- .../src/domain/nextjs/viewTracking.spec.tsx | 62 +++++++++++------- .../src/domain/nextjs/viewTracking.tsx | 29 ++++++--- test/unit/karma.base.conf.js | 2 +- 8 files changed, 94 insertions(+), 173 deletions(-) delete mode 100644 packages/rum-react/src/domain/nextjs/historyTracking.spec.ts delete mode 100644 packages/rum-react/src/domain/nextjs/historyTracking.ts diff --git a/packages/rum-react/src/domain/nextjs/datadogRumProvider.spec.tsx b/packages/rum-react/src/domain/nextjs/datadogRumProvider.spec.tsx index 07dec70ff8..79eb11b171 100644 --- a/packages/rum-react/src/domain/nextjs/datadogRumProvider.spec.tsx +++ b/packages/rum-react/src/domain/nextjs/datadogRumProvider.spec.tsx @@ -22,7 +22,7 @@ describe('DatadogRumProvider', () => { it('renders children correctly', () => { const container = appendComponent( - +
Test Content
) @@ -35,30 +35,27 @@ describe('DatadogRumProvider', () => { it('starts initial view on mount', () => { appendComponent( - +
Content
) - expect(startViewSpy).toHaveBeenCalledWith(window.location.pathname) + expect(startViewSpy).toHaveBeenCalledWith('/home') }) - it('starts a new view on navigation', () => { + it('starts initial view with normalized name using params', () => { appendComponent( - +
Content
) - startViewSpy.calls.reset() - history.pushState({}, '', '/new-page') - - expect(startViewSpy).toHaveBeenCalledWith('/new-page') + expect(startViewSpy).toHaveBeenCalledWith('/user/:id') }) it('renders multiple children', () => { const container = appendComponent( - +
Child 1
Child 2
Child 3
diff --git a/packages/rum-react/src/domain/nextjs/datadogRumProvider.tsx b/packages/rum-react/src/domain/nextjs/datadogRumProvider.tsx index e2212573ad..64ccfb3e7c 100644 --- a/packages/rum-react/src/domain/nextjs/datadogRumProvider.tsx +++ b/packages/rum-react/src/domain/nextjs/datadogRumProvider.tsx @@ -1,26 +1,40 @@ 'use client' -import React, { type ReactNode, useEffect } from 'react' -import { setupHistoryTracking } from './historyTracking' -import { startNextjsView } from './viewTracking' +import React, { type ReactNode, useEffect, useRef } from 'react' +import { usePathname, useParams } from 'next/navigation' +import { computeViewName, startNextjsView } from './viewTracking' export interface DatadogRumProviderProps { /** * The children components to render. */ children: ReactNode + /** + * Override the current pathname for testing. + */ + pathname?: string + /** + * Override the current route params for testing. + */ + params?: Record } -export function DatadogRumProvider({ children }: DatadogRumProviderProps) { - useEffect(() => { - startNextjsView(window.location.pathname) +export function DatadogRumProvider({ children, pathname: pathnameProp, params: paramsProp }: DatadogRumProviderProps) { + const hookPathname = usePathname() + const hookParams = useParams() + const pathname = pathnameProp ?? hookPathname + const params = paramsProp ?? hookParams ?? {} + const previousPathnameRef = useRef(undefined) - const stopHistoryTracking = setupHistoryTracking((pathname) => { - startNextjsView(pathname) - }) + useEffect(() => { + if (previousPathnameRef.current === pathname) { + return + } + previousPathnameRef.current = pathname - return stopHistoryTracking - }, []) + const viewName = computeViewName(pathname, params) + startNextjsView(viewName) + }, [pathname, params]) return <>{children} } diff --git a/packages/rum-react/src/domain/nextjs/historyTracking.spec.ts b/packages/rum-react/src/domain/nextjs/historyTracking.spec.ts deleted file mode 100644 index 3390d13a30..0000000000 --- a/packages/rum-react/src/domain/nextjs/historyTracking.spec.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { registerCleanupTask } from '../../../../core/test' -import { setupHistoryTracking } from './historyTracking' - -describe('setupHistoryTracking', () => { - let onNavigationSpy: jasmine.Spy<(pathname: string) => void> - let stopHistoryTracking: () => void - - beforeEach(() => { - onNavigationSpy = jasmine.createSpy('onNavigation') - stopHistoryTracking = setupHistoryTracking(onNavigationSpy) - - registerCleanupTask(() => { - stopHistoryTracking() - }) - }) - ;[ - { method: 'pushState' as const, url: '/new-path', expected: '/new-path' }, - { method: 'replaceState' as const, url: '/replaced-path', expected: '/replaced-path' }, - { method: 'pushState' as const, url: '/user/42?tab=profile', expected: '/user/42' }, - ].forEach(({ method, url, expected }) => { - it(`calls callback with ${expected} when ${method} is called with ${url}`, () => { - history[method]({}, '', url) - - expect(onNavigationSpy).toHaveBeenCalledWith(expected) - }) - }) - - it('calls callback on popstate event', () => { - window.dispatchEvent(new PopStateEvent('popstate')) - - expect(onNavigationSpy).toHaveBeenCalledWith(window.location.pathname) - }) - - it('does not call callback when URL is null', () => { - history.pushState({ data: 'test' }, '') - - expect(onNavigationSpy).not.toHaveBeenCalled() - }) - ;[ - { name: 'pushState', trigger: () => history.pushState({}, '', '/after-cleanup') }, - { name: 'replaceState', trigger: () => history.replaceState({}, '', '/after-cleanup') }, - { name: 'popstate', trigger: () => window.dispatchEvent(new PopStateEvent('popstate')) }, - ].forEach(({ name, trigger }) => { - it(`does not call callback after cleanup when ${name} is triggered`, () => { - stopHistoryTracking() - - trigger() - - expect(onNavigationSpy).not.toHaveBeenCalled() - }) - }) - - it('tracks multiple navigations', () => { - history.pushState({}, '', '/page1') - history.pushState({}, '', '/page2') - history.replaceState({}, '', '/page3') - - expect(onNavigationSpy).toHaveBeenCalledTimes(3) - expect(onNavigationSpy.calls.argsFor(0)).toEqual(['/page1']) - expect(onNavigationSpy.calls.argsFor(1)).toEqual(['/page2']) - expect(onNavigationSpy.calls.argsFor(2)).toEqual(['/page3']) - }) -}) diff --git a/packages/rum-react/src/domain/nextjs/historyTracking.ts b/packages/rum-react/src/domain/nextjs/historyTracking.ts deleted file mode 100644 index 3f0db08c2b..0000000000 --- a/packages/rum-react/src/domain/nextjs/historyTracking.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { buildUrl, addEventListener, instrumentMethod, DOM_EVENT } from '@datadog/browser-core' - -type NavigationCallback = (pathname: string) => void - -/** - * Sets up History API interception to track client-side navigations. - * This is needed so that we can track navigations and not have race conditions with the browser. - * - * @param onNavigation - Callback invoked with the new pathname on each navigation - * @returns Object with a `stop` method to remove interception and event listeners - */ -export function setupHistoryTracking(onNavigation: NavigationCallback): () => void { - function handleStateChange(_state: unknown, _unused: string, url?: string | URL | null) { - if (url) { - const pathname = buildUrl(String(url), window.location.href).pathname - onNavigation(pathname) - } - } - - function handlePopState() { - onNavigation(window.location.pathname) - } - - const { stop: stopInstrumentingPushState } = instrumentMethod( - getHistoryInstrumentationTarget('pushState'), - 'pushState', - ({ parameters, onPostCall }) => { - onPostCall(() => handleStateChange(...parameters)) - } - ) - - const { stop: stopInstrumentingReplaceState } = instrumentMethod( - getHistoryInstrumentationTarget('replaceState'), - 'replaceState', - ({ parameters, onPostCall }) => { - onPostCall(() => handleStateChange(...parameters)) - } - ) - - const { stop: stopPopStateListener } = addEventListener( - { allowUntrustedEvents: true }, - window, - DOM_EVENT.POP_STATE, - handlePopState - ) - - return () => { - stopInstrumentingPushState() - stopInstrumentingReplaceState() - stopPopStateListener() - } -} - -function getHistoryInstrumentationTarget(methodName: 'pushState' | 'replaceState') { - return Object.prototype.hasOwnProperty.call(history, methodName) ? history : History.prototype -} diff --git a/packages/rum-react/src/domain/nextjs/index.ts b/packages/rum-react/src/domain/nextjs/index.ts index f6637edfe3..39e3e4e34a 100644 --- a/packages/rum-react/src/domain/nextjs/index.ts +++ b/packages/rum-react/src/domain/nextjs/index.ts @@ -1,3 +1,3 @@ export { DatadogRumProvider } from './datadogRumProvider' export type { DatadogRumProviderProps } from './datadogRumProvider' -export { normalizeViewName } from './viewTracking' +export { computeViewName } from './viewTracking' diff --git a/packages/rum-react/src/domain/nextjs/viewTracking.spec.tsx b/packages/rum-react/src/domain/nextjs/viewTracking.spec.tsx index f5a4d500c4..53c6d38f53 100644 --- a/packages/rum-react/src/domain/nextjs/viewTracking.spec.tsx +++ b/packages/rum-react/src/domain/nextjs/viewTracking.spec.tsx @@ -1,18 +1,40 @@ import { display } from '@datadog/browser-core' import { initializeReactPlugin } from '../../../test/initializeReactPlugin' -import { startNextjsView, normalizeViewName } from './viewTracking' +import { computeViewName, startNextjsView } from './viewTracking' -describe('normalizeViewName', () => { +describe('computeViewName', () => { ;[ - ['/product/123', '/product/:id'], - ['/user/abc12345-1234-1234-1234-123456789012', '/user/:uuid'], - ['/about', '/about'], - ['/', '/'], - ['/orders/456/items/789', '/orders/:id/items/:id'], - ['/user/ABC12345-1234-1234-1234-123456789012/profile', '/user/:uuid/profile'], - ].forEach(([pathname, expected]) => { - it(`normalizes ${pathname} to ${expected}`, () => { - expect(normalizeViewName(pathname)).toBe(expected) + { pathname: '/', params: {}, expected: '/' }, + { pathname: '/about', params: {}, expected: '/about' }, + { pathname: '/user/42', params: { id: '42' }, expected: '/user/:id' }, + { + pathname: '/orders/456/items/789', + params: { orderId: '456', itemId: '789' }, + expected: '/orders/:orderId/items/:itemId', + }, + { + pathname: '/user/abc12345-1234-1234-1234-123456789012', + params: { id: 'abc12345-1234-1234-1234-123456789012' }, + expected: '/user/:id', + }, + { + pathname: '/user/abc12345-1234-1234-1234-123456789012/profile', + params: { id: 'abc12345-1234-1234-1234-123456789012' }, + expected: '/user/:id/profile', + }, + { + pathname: '/docs/a/b/c', + params: { slug: ['a', 'b', 'c'] }, + expected: '/docs/:slug', + }, + { + pathname: '/blog/my-awesome-post', + params: { slug: 'my-awesome-post' }, + expected: '/blog/:slug', + }, + ].forEach(({ pathname, params, expected }) => { + it(`computes ${pathname} with params ${JSON.stringify(params)} to ${expected}`, () => { + expect(computeViewName(pathname, params)).toBe(expected) }) }) }) @@ -31,17 +53,11 @@ describe('startNextjsView', () => { }, }) }) - ;[ - ['/product/123', '/product/:id'], - ['/user/abc12345-1234-1234-1234-123456789012', '/user/:uuid'], - ['/about', '/about'], - ['/', '/'], - ].forEach(([pathname, normalizedPathname]) => { - it(`creates a new view with the normalized pathname ${normalizedPathname}`, () => { - startNextjsView(pathname) - expect(startViewSpy).toHaveBeenCalledOnceWith(normalizedPathname) - }) + it('creates a new view with the given view name', () => { + startNextjsView('/user/:id') + + expect(startViewSpy).toHaveBeenCalledOnceWith('/user/:id') }) it('warns when nextjs configuration is missing', () => { @@ -54,7 +70,7 @@ describe('startNextjsView', () => { }, }) - startNextjsView('/product/123') + startNextjsView('/product/:id') expect(warnSpy).toHaveBeenCalledOnceWith( '`nextjs: true` is missing from the react plugin configuration, the view will not be tracked.' @@ -74,7 +90,7 @@ describe('startNextjsView', () => { }, }) - startNextjsView('/product/123') + startNextjsView('/product/:id') expect(warnSpy).toHaveBeenCalled() expect(localStartViewSpy).not.toHaveBeenCalled() diff --git a/packages/rum-react/src/domain/nextjs/viewTracking.tsx b/packages/rum-react/src/domain/nextjs/viewTracking.tsx index 3fc54d1c69..f2a94276da 100644 --- a/packages/rum-react/src/domain/nextjs/viewTracking.tsx +++ b/packages/rum-react/src/domain/nextjs/viewTracking.tsx @@ -2,27 +2,40 @@ import { display } from '@datadog/browser-core' import { onRumInit } from '../reactPlugin' /** - * Normalizes the pathname to use route patterns (e.g., /product/123 -> /product/:id). + * Computes a view name by replacing dynamic parameter values in the pathname + * with their corresponding parameter names from Next.js's useParams(). + * + * @example + * computeViewName('/user/42', { id: '42' }) // => '/user/:id' + * computeViewName('/docs/a/b/c', { slug: ['a', 'b', 'c'] }) // => '/docs/:slug' */ -export function normalizeViewName(pathname: string): string { - return pathname - .replace(/\/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}(?=\/|[?#]|$)/gi, '/:uuid') - .replace(/\/\d+(?=\/|[?#]|$)/g, '/:id') +export function computeViewName(pathname: string, params: Record): string { + let viewName = pathname + for (const [key, value] of Object.entries(params)) { + if (value === undefined) { + continue + } + if (Array.isArray(value)) { + viewName = viewName.replace(value.join('/'), `:${key}`) + } else { + viewName = viewName.replace(value, `:${key}`) + } + } + return viewName } /** - * Starts a new RUM view with the given pathname. + * Starts a new RUM view with the given view name. * * @internal */ -export function startNextjsView(pathname: string) { +export function startNextjsView(viewName: string) { onRumInit((configuration, rumPublicApi) => { if (!configuration.nextjs) { display.warn('`nextjs: true` is missing from the react plugin configuration, the view will not be tracked.') return } - const viewName = normalizeViewName(pathname) rumPublicApi.startView(viewName) }) } diff --git a/test/unit/karma.base.conf.js b/test/unit/karma.base.conf.js index 480210acd5..5279a2886c 100644 --- a/test/unit/karma.base.conf.js +++ b/test/unit/karma.base.conf.js @@ -126,7 +126,7 @@ function overrideTsLoaderRule(module) { // We use swc-loader to transpile some dependencies that are using syntax not compatible with browsers we use for testing module.rules.push({ test: /\.m?js$/, - include: /node_modules\/(react|react-router-dom|react-dom|react-router|turbo-stream)/, + include: /node_modules\/(react|react-router-dom|react-dom|react-router|turbo-stream|next)/, use: { loader: 'swc-loader', options: { From cd1f691f8d5605227916705bfe16658e5ee5313a Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Tue, 17 Feb 2026 16:23:04 +0100 Subject: [PATCH 18/20] Rebuild packages --- test/apps/nextjs-app-router/next-env.d.ts | 2 +- test/apps/nextjs-app-router/yarn.lock | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/test/apps/nextjs-app-router/next-env.d.ts b/test/apps/nextjs-app-router/next-env.d.ts index c4b7818fbb..9edff1c7ca 100644 --- a/test/apps/nextjs-app-router/next-env.d.ts +++ b/test/apps/nextjs-app-router/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/test/apps/nextjs-app-router/yarn.lock b/test/apps/nextjs-app-router/yarn.lock index 23edac97c2..eecb6e3bab 100644 --- a/test/apps/nextjs-app-router/yarn.lock +++ b/test/apps/nextjs-app-router/yarn.lock @@ -7,23 +7,23 @@ __metadata: "@datadog/browser-core@file:../../../packages/core/package.tgz::locator=nextjs-app-router%40workspace%3A.": version: 6.27.1 - resolution: "@datadog/browser-core@file:../../../packages/core/package.tgz#../../../packages/core/package.tgz::hash=1c42fb&locator=nextjs-app-router%40workspace%3A." - checksum: 10c0/87975b5069b59e9e0400e78a9db676137f3e52fae8fb3146c128527c760837c553960340505027b78c19ceeb9693fb79d70a8289781f2810be3bb22142c30ff9 + resolution: "@datadog/browser-core@file:../../../packages/core/package.tgz#../../../packages/core/package.tgz::hash=236de9&locator=nextjs-app-router%40workspace%3A." + checksum: 10c0/7ebd0e1dff67c77cd0ee1667f3f80e35ef2eb33811d3f0ed83eb04a3b7f0621e29d101a9f3f38476b3304b61d8a0b4c4e70f4d0474cff8ef3c3c81f9c836a6f0 languageName: node linkType: hard "@datadog/browser-rum-core@file:../../../packages/rum-core/package.tgz::locator=nextjs-app-router%40workspace%3A.": version: 6.27.1 - resolution: "@datadog/browser-rum-core@file:../../../packages/rum-core/package.tgz#../../../packages/rum-core/package.tgz::hash=7c1f38&locator=nextjs-app-router%40workspace%3A." + resolution: "@datadog/browser-rum-core@file:../../../packages/rum-core/package.tgz#../../../packages/rum-core/package.tgz::hash=0ea591&locator=nextjs-app-router%40workspace%3A." dependencies: "@datadog/browser-core": "npm:6.27.1" - checksum: 10c0/b1516087d4082125ba9da1d0f7ae6f392d7e896bd5c932069d9af2fe617bc58508140065d7c69b54b896be6df26b55bce32c3a81e81d858bef8eaef4e854ca0c + checksum: 10c0/5d75b104db8b27e238134f397f6f167050e10f0a1f4580a00d1b25f8f0cebb6111c7df3df32e1e8761aa39c66c47eb43aa222f4ff0f8a29a7ec4b11859bd3557 languageName: node linkType: hard "@datadog/browser-rum-react@file:../../../packages/rum-react/package.tgz::locator=nextjs-app-router%40workspace%3A.": version: 6.27.1 - resolution: "@datadog/browser-rum-react@file:../../../packages/rum-react/package.tgz#../../../packages/rum-react/package.tgz::hash=9ccce1&locator=nextjs-app-router%40workspace%3A." + resolution: "@datadog/browser-rum-react@file:../../../packages/rum-react/package.tgz#../../../packages/rum-react/package.tgz::hash=5fd43f&locator=nextjs-app-router%40workspace%3A." dependencies: "@datadog/browser-core": "npm:6.27.1" "@datadog/browser-rum-core": "npm:6.27.1" @@ -45,13 +45,13 @@ __metadata: optional: true react-router-dom: optional: true - checksum: 10c0/f318f6ce0a4b2fec8ba0c565d6791f02b2687d8af0889009a8a9e1da9c6fac9a9385a8433c9041ed38932eff4bacb37926bd959ba6610a1996164208f3352b3f + checksum: 10c0/1b7fced93b394ce17beba346fddda4f869315b147690a109abef9b13ad6d06554d382782db27da3f74a5533fb98b76bb009e84384ba1538e203db3429d52f2d9 languageName: node linkType: hard "@datadog/browser-rum@file:../../../packages/rum/package.tgz::locator=nextjs-app-router%40workspace%3A.": version: 6.27.1 - resolution: "@datadog/browser-rum@file:../../../packages/rum/package.tgz#../../../packages/rum/package.tgz::hash=fcaac5&locator=nextjs-app-router%40workspace%3A." + resolution: "@datadog/browser-rum@file:../../../packages/rum/package.tgz#../../../packages/rum/package.tgz::hash=5b1f33&locator=nextjs-app-router%40workspace%3A." dependencies: "@datadog/browser-core": "npm:6.27.1" "@datadog/browser-rum-core": "npm:6.27.1" @@ -60,7 +60,7 @@ __metadata: peerDependenciesMeta: "@datadog/browser-logs": optional: true - checksum: 10c0/1f4a18c17f069e10116a4eec745f80c3dd43ce2868cab3eefddc341e836f532b3b5d3c6c2ed5e547a96715fae24cf472969dd83207a7d970c2b7dad02c1c4d36 + checksum: 10c0/083cdd3519ce1df8716a18067a564eb191adbb00ac2b3dec72d5ae9b34c0330922d2dcad2f696404a1c68ae3cf983a2da126e0ed4d7b63d008049b1b211693e8 languageName: node linkType: hard From d3e9871eadf972d0e37ccc988c257f188ad6deb3 Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Tue, 17 Feb 2026 17:14:16 +0100 Subject: [PATCH 19/20] Replace with mockable --- .../domain/nextjs/datadogRumProvider.spec.tsx | 22 +++++++++++++++---- .../src/domain/nextjs/datadogRumProvider.tsx | 17 ++++---------- test/apps/nextjs-app-router/yarn.lock | 4 ++-- yarn.lock | 6 ++--- 4 files changed, 27 insertions(+), 22 deletions(-) diff --git a/packages/rum-react/src/domain/nextjs/datadogRumProvider.spec.tsx b/packages/rum-react/src/domain/nextjs/datadogRumProvider.spec.tsx index 79eb11b171..aed01954d5 100644 --- a/packages/rum-react/src/domain/nextjs/datadogRumProvider.spec.tsx +++ b/packages/rum-react/src/domain/nextjs/datadogRumProvider.spec.tsx @@ -1,4 +1,6 @@ import React from 'react' +import { replaceMockable } from '@datadog/browser-core/test' +import { usePathname, useParams } from 'next/navigation' import { appendComponent } from '../../../test/appendComponent' import { initializeReactPlugin } from '../../../test/initializeReactPlugin' import { initReactOldBrowsersSupport } from '../../../test/reactOldBrowsersSupport' @@ -21,8 +23,11 @@ describe('DatadogRumProvider', () => { }) it('renders children correctly', () => { + replaceMockable(usePathname, () => '/') + replaceMockable(useParams, () => ({})) + const container = appendComponent( - +
Test Content
) @@ -34,8 +39,11 @@ describe('DatadogRumProvider', () => { }) it('starts initial view on mount', () => { + replaceMockable(usePathname, () => '/home') + replaceMockable(useParams, () => ({})) + appendComponent( - +
Content
) @@ -44,8 +52,11 @@ describe('DatadogRumProvider', () => { }) it('starts initial view with normalized name using params', () => { + replaceMockable(usePathname, () => '/user/42') + replaceMockable(useParams, () => ({ id: '42' })) + appendComponent( - +
Content
) @@ -54,8 +65,11 @@ describe('DatadogRumProvider', () => { }) it('renders multiple children', () => { + replaceMockable(usePathname, () => '/') + replaceMockable(useParams, () => ({})) + const container = appendComponent( - +
Child 1
Child 2
Child 3
diff --git a/packages/rum-react/src/domain/nextjs/datadogRumProvider.tsx b/packages/rum-react/src/domain/nextjs/datadogRumProvider.tsx index 64ccfb3e7c..4f6eda9880 100644 --- a/packages/rum-react/src/domain/nextjs/datadogRumProvider.tsx +++ b/packages/rum-react/src/domain/nextjs/datadogRumProvider.tsx @@ -2,6 +2,7 @@ import React, { type ReactNode, useEffect, useRef } from 'react' import { usePathname, useParams } from 'next/navigation' +import { mockable } from '@datadog/browser-core' import { computeViewName, startNextjsView } from './viewTracking' export interface DatadogRumProviderProps { @@ -9,21 +10,11 @@ export interface DatadogRumProviderProps { * The children components to render. */ children: ReactNode - /** - * Override the current pathname for testing. - */ - pathname?: string - /** - * Override the current route params for testing. - */ - params?: Record } -export function DatadogRumProvider({ children, pathname: pathnameProp, params: paramsProp }: DatadogRumProviderProps) { - const hookPathname = usePathname() - const hookParams = useParams() - const pathname = pathnameProp ?? hookPathname - const params = paramsProp ?? hookParams ?? {} +export function DatadogRumProvider({ children }: DatadogRumProviderProps) { + const pathname = mockable(usePathname)() + const params = mockable(useParams)() ?? {} const previousPathnameRef = useRef(undefined) useEffect(() => { diff --git a/test/apps/nextjs-app-router/yarn.lock b/test/apps/nextjs-app-router/yarn.lock index eecb6e3bab..3aad4a10e2 100644 --- a/test/apps/nextjs-app-router/yarn.lock +++ b/test/apps/nextjs-app-router/yarn.lock @@ -23,7 +23,7 @@ __metadata: "@datadog/browser-rum-react@file:../../../packages/rum-react/package.tgz::locator=nextjs-app-router%40workspace%3A.": version: 6.27.1 - resolution: "@datadog/browser-rum-react@file:../../../packages/rum-react/package.tgz#../../../packages/rum-react/package.tgz::hash=5fd43f&locator=nextjs-app-router%40workspace%3A." + resolution: "@datadog/browser-rum-react@file:../../../packages/rum-react/package.tgz#../../../packages/rum-react/package.tgz::hash=9fefa5&locator=nextjs-app-router%40workspace%3A." dependencies: "@datadog/browser-core": "npm:6.27.1" "@datadog/browser-rum-core": "npm:6.27.1" @@ -45,7 +45,7 @@ __metadata: optional: true react-router-dom: optional: true - checksum: 10c0/1b7fced93b394ce17beba346fddda4f869315b147690a109abef9b13ad6d06554d382782db27da3f74a5533fb98b76bb009e84384ba1538e203db3429d52f2d9 + checksum: 10c0/dc30c02d78ce3c02e90f175378e756df2d3fb6f8bc653ca5786718e73b66acd1a9c798acc045a664235d313198d43ee987115363e9dacd1e02762d685810698a languageName: node linkType: hard diff --git a/yarn.lock b/yarn.lock index 736e4d5fee..469d61d899 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5061,9 +5061,9 @@ __metadata: linkType: hard "caniuse-lite@npm:^1.0.30001579": - version: 1.0.30001767 - resolution: "caniuse-lite@npm:1.0.30001767" - checksum: 10c0/37067c6d2b26623f81494a1f206adbff2b80baed3318ba430684e428bd7d81be889f39db8ef081501d1db5f7dd5d15972892f173eb59c9f3bb780e0b38e6610a + version: 1.0.30001770 + resolution: "caniuse-lite@npm:1.0.30001770" + checksum: 10c0/02d15a8b723af65318cb4d888a52bb090076898da7b0de99e8676d537f8d1d2ae4797e81518e1e30cbfe84c33b048c322e8bfafc5b23cfee8defb0d2bf271149 languageName: node linkType: hard From 22fffbb920d9633172187f53cb8afedf4acdbdf1 Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Tue, 17 Feb 2026 17:23:29 +0100 Subject: [PATCH 20/20] Type typecheck issues because of https://github.com/microsoft/TypeScript/issues/43094 --- packages/core/src/tools/instrumentMethod.spec.ts | 2 +- packages/rum-core/src/domain/tracing/identifier.spec.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/src/tools/instrumentMethod.spec.ts b/packages/core/src/tools/instrumentMethod.spec.ts index 097f665bc4..37dc605b3d 100644 --- a/packages/core/src/tools/instrumentMethod.spec.ts +++ b/packages/core/src/tools/instrumentMethod.spec.ts @@ -266,7 +266,7 @@ describe('instrumentSetter', () => { it('does not use the Zone.js setTimeout function', () => { const zoneJsSetTimeoutSpy = jasmine.createSpy() - zoneJs.replaceProperty(window, 'setTimeout', zoneJsSetTimeoutSpy) + zoneJs.replaceProperty(window, 'setTimeout', zoneJsSetTimeoutSpy as unknown as typeof setTimeout) const object = {} as { foo: number } Object.defineProperty(object, 'foo', { set: noop, configurable: true }) diff --git a/packages/rum-core/src/domain/tracing/identifier.spec.ts b/packages/rum-core/src/domain/tracing/identifier.spec.ts index 6835e2cbc3..7194422728 100644 --- a/packages/rum-core/src/domain/tracing/identifier.spec.ts +++ b/packages/rum-core/src/domain/tracing/identifier.spec.ts @@ -38,7 +38,7 @@ describe('toPaddedHexadecimalString', () => { }) function mockRandomValues(cb: (buffer: Uint8Array) => void) { - spyOn(window.crypto, 'getRandomValues').and.callFake((bufferView) => { + spyOn(window.crypto, 'getRandomValues').and.callFake((bufferView: ArrayBufferView) => { cb(new Uint8Array(bufferView.buffer)) return bufferView })