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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,20 +1,14 @@
'use client'

import { useRef } from 'react'
import { usePathname, useParams } from 'next/navigation'
import { mockable } from '@datadog/browser-core'
import { startNextjsView } from '../nextjsPlugin'
import { computeViewNameFromParams } from './computeViewNameFromParams'
import { useStartNextjsView } from './useStartNextjsView'

export function DatadogAppRouter() {
const pathname = mockable(usePathname)()
const params = mockable(useParams)()
const previousPathname = mockable(useRef)<string | null>(null)

if (previousPathname.current !== pathname) {
previousPathname.current = pathname
startNextjsView(computeViewNameFromParams(pathname, params))
}
useStartNextjsView(pathname, computeViewNameFromParams(pathname, params))

return null
}
Original file line number Diff line number Diff line change
@@ -1,26 +1,17 @@
import { useRef } from 'react'
import { useRouter } from 'next/router'
import { mockable } from '@datadog/browser-core'
import { startNextjsView } from '../nextjsPlugin'
import { useStartNextjsView } from './useStartNextjsView'

export function DatadogPagesRouter() {
const router = mockable(useRouter)()
const previousPath = mockable(useRef)<string | null>(null)

if (!router.isReady) {
return null
}

// Extract the path portion of asPath (without query params or hash) to detect navigations.
const path = router.asPath.split(/[?#]/)[0]
const path = router.isReady ? router.asPath.split(/[?#]/)[0] : null

if (previousPath.current !== path) {
// router.pathname is the route pattern (e.g., "/user/[id]") — used as the view name
// router.asPath is the actual URL (e.g., "/user/42") — used to detect navigations between
// different concrete URLs of the same dynamic route (e.g., /user/42 → /user/43)
previousPath.current = path
startNextjsView(router.pathname)
}
// router.pathname is the route pattern (e.g., "/user/[id]") — used as the view name
// router.asPath is the actual URL (e.g., "/user/42") — used to detect navigations between
// different concrete URLs of the same dynamic route (e.g., /user/42 → /user/43)
useStartNextjsView(path, router.pathname)

return null
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import React, { act } from 'react'
import { appendComponent } from '../../../../browser-rum-react/test/appendComponent'
import { initReactOldBrowsersSupport } from '../../../../browser-rum-react/test/reactOldBrowsersSupport'
import { initializeNextjsPlugin } from '../../../test/initializeNextjsPlugin'
import { useStartNextjsView } from './useStartNextjsView'

describe('useStartNextjsView', () => {
beforeEach(() => {
initReactOldBrowsersSupport()
})

it('starts a single view when React renders the component twice in Strict Mode', () => {
const startViewSpy = jasmine.createSpy()
initializeNextjsPlugin({ publicApi: { startView: startViewSpy } })

function TestRouter() {
useStartNextjsView('/user/42', '/user/[id]')
return null
}

appendComponent(
<React.StrictMode>
<TestRouter />
</React.StrictMode>
)

expect(startViewSpy).toHaveBeenCalledOnceWith({ name: '/user/[id]', url: undefined })
})

it('starts a view when the path changes', () => {
const startViewSpy = jasmine.createSpy()
initializeNextjsPlugin({ publicApi: { startView: startViewSpy } })

let setRoute: (route: { path: string; viewName: string }) => void

function TestRouter() {
const [route, setCurrentRoute] = React.useState({ path: '/', viewName: '/' })
setRoute = setCurrentRoute
useStartNextjsView(route.path, route.viewName)
return null
}

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

act(() => {
setRoute({ path: '/user/42', viewName: '/user/[id]' })
})

expect(startViewSpy).toHaveBeenCalledOnceWith({ name: '/user/[id]', url: undefined })
})

it('does not start a view until the router is ready', () => {
const startViewSpy = jasmine.createSpy()
initializeNextjsPlugin({ publicApi: { startView: startViewSpy } })

let setPath: (path: string | null) => void

function TestRouter() {
const [path, setCurrentPath] = React.useState<string | null>(null)
setPath = setCurrentPath
useStartNextjsView(path, '/user/[id]')
return null
}

appendComponent(<TestRouter />)
expect(startViewSpy).not.toHaveBeenCalled()

act(() => {
setPath('/user/42')
})

expect(startViewSpy).toHaveBeenCalledOnceWith({ name: '/user/[id]', url: undefined })
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { useLayoutEffect, useRef } from 'react'
import { startNextjsView } from '../nextjsPlugin'

export function useStartNextjsView(path: string | null, viewName: string) {
const previousPath = useRef<string | null>(null)

useLayoutEffect(() => {
if (path !== null && previousPath.current !== path) {
previousPath.current = path
startNextjsView(viewName)
}
}, [path, viewName])
}
21 changes: 21 additions & 0 deletions test/apps/nextjs/app/discardedRenderProbe.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
'use client'

let renderAttempt = 0
let suspendPromise: Promise<void> | undefined

export function DiscardedRenderProbe() {
if (typeof window === 'undefined' || !new URLSearchParams(window.location.search).has('discard-nextjs-render')) {
return null
}

renderAttempt += 1

if (renderAttempt === 1) {
suspendPromise = new Promise((resolve) => {
setTimeout(resolve)
})
throw suspendPromise
}

return <span data-testid="discarded-render-probe-ready" hidden />
}
7 changes: 6 additions & 1 deletion test/apps/nextjs/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import { Suspense } from 'react'
import { DatadogAppRouter } from '@datadog/browser-rum-nextjs'
import { DiscardedRenderProbe } from './discardedRenderProbe'

export default function RootLayout({ children, sidebar }: { children: React.ReactNode; sidebar: React.ReactNode }) {
return (
<html lang="en">
<body style={{ fontFamily: 'system-ui, sans-serif', margin: 0 }}>
<DatadogAppRouter />
<Suspense fallback={null}>
<DatadogAppRouter />
<DiscardedRenderProbe />
</Suspense>
<nav style={{ background: '#632ca6', padding: '1rem', marginBottom: '1rem' }}>
<a href="/" style={{ color: 'white', textDecoration: 'none' }}>
Home
Expand Down
22 changes: 22 additions & 0 deletions test/e2e/scenario/plugins/nextjsPlugin.scenario.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,28 @@ runBasePluginErrorTests(
)

test.describe('plugin: nextjs', () => {
createTest('should not create a view for a discarded App Router render')
.withRum()
.withBasePath('/?discard-nextjs-render')
.withNextjsApp('app')
.run(async ({ page, flushEvents, intakeRegistry, withBrowserLogs }) => {
await page.waitForSelector('[data-testid="discarded-render-probe-ready"]', { state: 'attached' })

await flushEvents()

const homeViewEvents = intakeRegistry.rumViewEvents.filter((event) => event.view.name === '/')
expect(homeViewEvents.length).toBeGreaterThan(0)

const homeViewId = homeViewEvents[0].view.id
expect(homeViewEvents.every((event) => event.view.id === homeViewId)).toBe(true)

withBrowserLogs((logs) => {
const errors = logs.filter((log) => log.level === 'error')
expect(errors).toHaveLength(1)
expect(errors[0].message).toContain('Minified React error #418')
})
})

createTest('should not be affected by parallel routes')
.withRum()
.withNextjsApp('app')
Expand Down
Loading