-
Notifications
You must be signed in to change notification settings - Fork 170
[NextJS] Nextjs app router integration (AI optimistic) #4234
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
bd942c0
54637c9
3acc527
5ea0e10
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,3 +5,4 @@ | |
| /src/**/*.spec.ts | ||
| /src/**/*.specHelper.ts | ||
| !/react-router-v[6-7]/* | ||
| !/nextjs/* | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| { | ||
| "name": "@datadog/browser-rum-react/nextjs", | ||
| "private": true, | ||
| "main": "../cjs/entries/nextjs.js", | ||
| "module": "../esm/entries/nextjs.js", | ||
| "types": "../cjs/entries/nextjs.d.ts" | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| { | ||
| "$schema": "https://typedoc.org/schema.json", | ||
| "entryPoints": ["../src/entries/nextjs.ts"] | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| import { computeNextViewName } from './computeNextViewName' | ||
|
|
||
| describe('computeNextViewName', () => { | ||
| // prettier-ignore | ||
| const cases = [ | ||
| // [pathname, params, expectedViewName] | ||
|
|
||
| // Static routes (no params) | ||
| ['/about', {}, '/about'], | ||
| ['/search', {}, '/search'], | ||
| ['/', {}, '/'], | ||
|
|
||
| // Single dynamic segment | ||
| ['/users/123', { id: '123' }, '/users/:id'], | ||
|
|
||
| // Multiple dynamic segments | ||
| ['/users/123/posts/456', { userId: '123', postId: '456' }, '/users/:userId/posts/:postId'], | ||
|
|
||
| // Param value appears multiple times (FR-4) | ||
| ['/a/123/b/123', { id: '123' }, '/a/:id/b/:id'], | ||
|
|
||
| // Catch-all segments (FR-3) — string array params | ||
| ['/docs/a/b/c', { slug: ['a', 'b', 'c'] }, '/docs/:slug'], | ||
|
|
||
| // Catch-all from root | ||
| ['/docs/getting-started', { slug: ['docs', 'getting-started'] }, '/:slug'], | ||
|
|
||
| // Segment-aware replacement (does NOT replace inside segment names) | ||
| ['/abc/a/def', { id: 'a' }, '/abc/:id/def'], | ||
|
|
||
| // Undefined param value (optional catch-all with no match) — skipped | ||
| ['/foo', { opt: undefined }, '/foo'], | ||
|
|
||
| // Empty string param value — skipped | ||
| ['/foo', { empty: '' }, '/foo'], | ||
|
|
||
| // Catch-all with single segment | ||
| ['/blog/hello', { slug: ['hello'] }, '/blog/:slug'], | ||
|
|
||
| // Mixed dynamic + catch-all | ||
| ['/users/42/files/a/b', { id: '42', path: ['a', 'b'] }, '/users/:id/files/:path'], | ||
| ] as const | ||
|
|
||
| cases.forEach(([pathname, params, expectedViewName]) => { | ||
| it(`returns "${expectedViewName}" for pathname "${pathname}" and params ${JSON.stringify(params)}`, () => { | ||
| expect(computeNextViewName(pathname, params as any)).toBe(expectedViewName) | ||
| }) | ||
| }) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,70 @@ | ||
| import type { NextParams } from './types' | ||
|
|
||
| /** | ||
| * Reconstructs the parameterized route pattern from a Next.js pathname and params object. | ||
| * | ||
| * Uses segment-aware replacement to avoid matching param values as substrings of other | ||
| * path segments. For example, `{ id: 'a' }` with `/abc/a/def` correctly produces | ||
| * `/abc/:id/def` rather than `/:idbc/:id/def`. | ||
| * | ||
| * Processing order: | ||
| * 1. Array (catch-all) params are processed before string params, so the full contiguous | ||
| * segment sequence can be matched before individual segments are replaced. | ||
| * 2. String params are sorted by descending value length as a safety measure for edge cases | ||
| * where two params share values that are substrings of each other. | ||
| */ | ||
| export function computeNextViewName(pathname: string, params: NextParams): string { | ||
| const segments = pathname.split('/') | ||
|
|
||
| // Sort entries: arrays first, then strings sorted by descending length. | ||
| // This ensures catch-all params are matched before individual segments, | ||
| // and longer values are matched before shorter ones to avoid partial replacements. | ||
| const entries = Object.entries(params).sort((a, b) => { | ||
| const aIsArray = Array.isArray(a[1]) | ||
| const bIsArray = Array.isArray(b[1]) | ||
| if (aIsArray !== bIsArray) { | ||
| return aIsArray ? -1 : 1 | ||
| } | ||
| // For strings, sort by descending value length | ||
| if (!aIsArray && !bIsArray) { | ||
| return String(b[1] ?? '').length - String(a[1] ?? '').length | ||
| } | ||
| return 0 | ||
| }) | ||
|
|
||
| for (const [paramName, paramValue] of entries) { | ||
| if (paramValue === undefined || paramValue === '') { | ||
| continue | ||
| } | ||
|
|
||
| if (Array.isArray(paramValue)) { | ||
| // Catch-all: find the first contiguous sequence of segments matching the array | ||
| if (paramValue.length === 0) { | ||
| continue | ||
| } | ||
| for (let i = 0; i <= segments.length - paramValue.length; i++) { | ||
| let matches = true | ||
| for (let j = 0; j < paramValue.length; j++) { | ||
| if (segments[i + j] !== paramValue[j]) { | ||
| matches = false | ||
| break | ||
| } | ||
| } | ||
| if (matches) { | ||
| // Replace the contiguous run with a single :paramName segment | ||
| segments.splice(i, paramValue.length, `:${paramName}`) | ||
| break // Only replace the first occurrence for catch-all | ||
| } | ||
| } | ||
| } else { | ||
| // String: replace all matching segments | ||
| for (let i = 0; i < segments.length; i++) { | ||
| if (segments[i] === paramValue) { | ||
| segments[i] = `:${paramName}` | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| return segments.join('/') | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,144 @@ | ||
| import React, { act } from 'react' | ||
| import { display } from '@datadog/browser-core' | ||
| import { usePathname, useParams } from 'next/navigation' | ||
| import { replaceMockable } from '../../../../core/test' | ||
| import { appendComponent } from '../../../test/appendComponent' | ||
| import { initializeReactPlugin } from '../../../test/initializeReactPlugin' | ||
| import { DatadogRumProvider } from './datadogRumProvider' | ||
| import type { NextParams } from './types' | ||
|
|
||
| describe('DatadogRumProvider', () => { | ||
| let mockPathname: string | ||
| let mockParams: NextParams | ||
| let startViewSpy: jasmine.Spy | ||
|
|
||
| beforeEach(() => { | ||
| mockPathname = '/' | ||
| mockParams = {} | ||
|
|
||
| replaceMockable(usePathname, (() => mockPathname) as typeof usePathname) | ||
| replaceMockable(useParams, (() => mockParams) as typeof useParams) | ||
|
|
||
| startViewSpy = jasmine.createSpy() | ||
| initializeReactPlugin({ | ||
| configuration: { nextAppRouter: true }, | ||
| publicApi: { startView: startViewSpy }, | ||
| }) | ||
| }) | ||
|
|
||
| it('starts a view on initial mount', () => { | ||
| mockPathname = '/users/123' | ||
| mockParams = { id: '123' } | ||
|
|
||
| appendComponent(<DatadogRumProvider>content</DatadogRumProvider>) | ||
|
|
||
| expect(startViewSpy).toHaveBeenCalledOnceWith('/users/:id') | ||
| }) | ||
|
|
||
| it('renders children without wrapper DOM elements', () => { | ||
| const container = appendComponent( | ||
| <DatadogRumProvider> | ||
| <span>hello</span> | ||
| </DatadogRumProvider> | ||
| ) | ||
|
|
||
| expect(container.innerHTML).toBe('<span>hello</span>') | ||
| }) | ||
|
|
||
| it('does not start a new view on re-render with same pathname', () => { | ||
| mockPathname = '/about' | ||
|
|
||
| let forceUpdate: () => void | ||
|
|
||
| function App() { | ||
| const [, setState] = React.useState(0) | ||
| forceUpdate = () => setState((s) => s + 1) | ||
| return <DatadogRumProvider>content</DatadogRumProvider> | ||
| } | ||
|
|
||
| appendComponent(<App />) | ||
| expect(startViewSpy).toHaveBeenCalledTimes(1) | ||
|
|
||
| act(() => { | ||
| forceUpdate!() | ||
| }) | ||
|
|
||
| expect(startViewSpy).toHaveBeenCalledTimes(1) | ||
| }) | ||
|
|
||
| it('starts a new view when the view name changes', () => { | ||
| mockPathname = '/users/123' | ||
| mockParams = { id: '123' } | ||
|
|
||
| let forceUpdate: () => void | ||
|
|
||
| function App() { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm not sure I love the idea of doing a component in a unit test. I would prefer to put it in a e2e test and here test behaviour. |
||
| const [, setState] = React.useState(0) | ||
| forceUpdate = () => setState((s) => s + 1) | ||
| return <DatadogRumProvider>content</DatadogRumProvider> | ||
| } | ||
|
|
||
| appendComponent(<App />) | ||
| startViewSpy.calls.reset() | ||
|
|
||
| // Navigate to a different route pattern | ||
| mockPathname = '/about' | ||
| mockParams = {} | ||
|
|
||
| act(() => { | ||
| forceUpdate!() | ||
| }) | ||
|
|
||
| expect(startViewSpy).toHaveBeenCalledOnceWith('/about') | ||
| }) | ||
|
|
||
| it('does not start a new view when navigating to a different instance of the same route', () => { | ||
| mockPathname = '/users/123' | ||
| mockParams = { id: '123' } | ||
|
|
||
| let forceUpdate: () => void | ||
|
|
||
| function App() { | ||
| const [, setState] = React.useState(0) | ||
| forceUpdate = () => setState((s) => s + 1) | ||
| return <DatadogRumProvider>content</DatadogRumProvider> | ||
| } | ||
|
|
||
| appendComponent(<App />) | ||
| expect(startViewSpy).toHaveBeenCalledOnceWith('/users/:id') | ||
| startViewSpy.calls.reset() | ||
|
|
||
| // Navigate to a different user — same route pattern /users/:id | ||
| mockPathname = '/users/456' | ||
| mockParams = { id: '456' } | ||
|
|
||
| act(() => { | ||
| forceUpdate!() | ||
| }) | ||
|
|
||
| expect(startViewSpy).not.toHaveBeenCalled() | ||
| }) | ||
|
|
||
| it('starts a view with raw pathname for static routes', () => { | ||
| mockPathname = '/about' | ||
| mockParams = {} | ||
|
|
||
| appendComponent(<DatadogRumProvider>content</DatadogRumProvider>) | ||
|
|
||
| expect(startViewSpy).toHaveBeenCalledOnceWith('/about') | ||
| }) | ||
|
|
||
| it('warns when nextAppRouter config is missing', () => { | ||
| const displayWarnSpy = spyOn(display, 'warn') | ||
| initializeReactPlugin({ | ||
| configuration: {}, | ||
| publicApi: { startView: startViewSpy }, | ||
| }) | ||
|
|
||
| mockPathname = '/about' | ||
|
|
||
| appendComponent(<DatadogRumProvider>content</DatadogRumProvider>) | ||
|
|
||
| expect(displayWarnSpy).toHaveBeenCalledOnceWith(jasmine.stringContaining('`nextAppRouter: true` is missing')) | ||
| }) | ||
| }) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| import React, { useRef, type ReactNode } from 'react' | ||
| import { mockable } from '@datadog/browser-core' | ||
| import { usePathname, useParams } from 'next/navigation' | ||
| import { startNextjsView } from './startNextjsView' | ||
| import { computeNextViewName } from './computeNextViewName' | ||
|
|
||
| export function DatadogRumProvider({ children }: { children: ReactNode }) { | ||
| const pathname = mockable(usePathname)() | ||
| const params = mockable(useParams)() | ||
| const viewNameRef = useRef<string | null>(null) | ||
|
|
||
| const viewName = computeNextViewName(pathname, params ?? {}) | ||
|
|
||
| if (viewNameRef.current !== viewName) { | ||
| viewNameRef.current = viewName | ||
| startNextjsView(viewName) | ||
| } | ||
|
|
||
| return <>{children}</> | ||
| } | ||
|
|
||
| DatadogRumProvider.displayName = 'DatadogRumProvider' | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Question: What is
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's just for the React Browser Extension to be able to display the component name in the React Virtual DOM tree. This is optional but nice to have |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| export { computeNextViewName } from './computeNextViewName' | ||
| export { startNextjsView } from './startNextjsView' | ||
| export { DatadogRumProvider } from './datadogRumProvider' | ||
| export type { NextParams } from './types' |
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Question:: So we will support next >= 15.2.2. Shouldn't we try to support sooner versions too?
Suggestion:: For example support version >= 13?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No, that's just the version we use inside our integration. As it is defined above in the peerDependencies section, this approach supports all the versions >= 13