Skip to content
Open
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,15 @@
'use client'

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

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))
}
startAppRouterView(pathname, computeViewNameFromParams(pathname, params))

return null
}
40 changes: 40 additions & 0 deletions packages/browser-rum-nextjs/src/domain/nextjsPlugin.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { appendElement } from '../../../browser-rum-core/test'
import {
nextjsPlugin,
startNextjsView,
startAppRouterView,
onRumInit,
onRumStart,
onRouterTransitionStart,
Expand Down Expand Up @@ -91,6 +92,45 @@ describe('nextjsPlugin', () => {
})
})

it('deduplicates successive App Router renders of the same pathname', () => {
const { startViewSpy } = initPlugin()

startAppRouterView('/user/42', '/user/[id]')
startAppRouterView('/user/42', '/user/[id]')

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

it('tracks different concrete pathnames and returning to a previous pathname', () => {
const { startViewSpy } = initPlugin()

startAppRouterView('/user/42', '/user/[id]')
startAppRouterView('/user/99', '/user/[id]')
startAppRouterView('/user/42', '/user/[id]')

expect(startViewSpy).toHaveBeenCalledTimes(3)
})

it('does not deduplicate an App Router render that happened before init', () => {
startAppRouterView('/user/42', '/user/[id]')
const { startViewSpy } = initPlugin()

startAppRouterView('/user/42', '/user/[id]')

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

it('clears App Router deduplication when the plugin resets', () => {
initPlugin()
startAppRouterView('/user/42', '/user/[id]')
resetNextjsPlugin()
const { startViewSpy } = initPlugin()

startAppRouterView('/user/42', '/user/[id]')

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

it('clears onRouterTransitionStart URL after startNextjsView consumes it', () => {
const { startViewSpy } = initPlugin()

Expand Down
12 changes: 12 additions & 0 deletions packages/browser-rum-nextjs/src/domain/nextjsPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ type StartSubscriber = (addError: StartRumResult['addError']) => void
let globalPublicApi: RumPublicApi | undefined
let globalAddError: StartRumResult['addError'] | undefined
let lastNavigationUrl: string | undefined
let lastStartedAppRouterPathname: string | undefined
let routerType: NextjsRouterType | undefined

const onRumInitSubscribers: InitSubscriber[] = []
Expand Down Expand Up @@ -55,6 +56,16 @@ function detectNextjsRouterType(): NextjsRouterType {
return document.getElementById('__NEXT_DATA__') ? 'pages-router' : 'app-router'
}

export function startAppRouterView(pathname: string, viewName: string) {
if (!globalPublicApi || pathname === lastStartedAppRouterPathname) {
return
}

// Keep the guard outside React so discarded mount renders do not reset it.
lastStartedAppRouterPathname = pathname
startNextjsView(viewName)
}

export function startNextjsView(viewName: string) {
if (globalPublicApi) {
// Use the URL captured by onRouterTransitionStart if available, since React renders before pushState updates window.location
Expand Down Expand Up @@ -91,5 +102,6 @@ export function resetNextjsPlugin() {
onRumInitSubscribers.length = 0
onRumStartSubscribers.length = 0
lastNavigationUrl = undefined
lastStartedAppRouterPathname = undefined
routerType = undefined
}
11 changes: 11 additions & 0 deletions test/apps/nextjs/app/hydration-marker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
'use client'

import { useEffect } from 'react'

export default function HydrationMarker() {
useEffect(() => {
performance.mark('root-hydration-marker')
}, [])

return null
}
2 changes: 2 additions & 0 deletions test/apps/nextjs/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { DatadogAppRouter } from '@datadog/browser-rum-nextjs'
import { LazyHydrationMarker } from './lazy-hydration-marker'

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 />
<LazyHydrationMarker />
<nav style={{ background: '#632ca6', padding: '1rem', marginBottom: '1rem' }}>
<a href="/" style={{ color: 'white', textDecoration: 'none' }}>
Home
Expand Down
13 changes: 13 additions & 0 deletions test/apps/nextjs/app/lazy-hydration-marker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
'use client'

import { lazy } from 'react'

// Keep a separate chunk so E2E tests can delay this root layout sibling.
const HydrationMarker = lazy(() => import('./hydration-marker'))

export function LazyHydrationMarker() {
if (typeof window !== 'undefined' && new URLSearchParams(window.location.search).has('delay-client-chunk')) {
return <HydrationMarker />
}
return null
}
30 changes: 30 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,36 @@ runBasePluginErrorTests(
)

test.describe('plugin: nextjs', () => {
createTest('should create only one view when a root layout client chunk is delayed')
.withRum()
.withBasePath('/?delay-client-chunk')
.withNextjsApp('app')
.run(async ({ page, baseUrl, flushEvents, intakeRegistry }) => {
await page.waitForFunction(() => performance.getEntriesByName('root-hydration-marker').length === 1)
await flushEvents()
intakeRegistry.empty()

let delayedChunks = 0
await page.route('**/_next/static/chunks/*.js', async (route) => {
const response = await route.fetch()
// Find the sibling's chunk without depending on its generated filename.
if ((await response.text()).includes('root-hydration-marker')) {
delayedChunks++
await new Promise((resolve) => setTimeout(resolve, 150))
}
await route.fulfill({ response })
})

await page.goto(baseUrl)
await page.waitForFunction(() => performance.getEntriesByName('root-hydration-marker').length === 1)
await flushEvents()

expect(delayedChunks).toBe(1)
const views = [...new Map(intakeRegistry.rumViewEvents.map((event) => [event.view.id, event.view])).values()]
expect([...new Set(views.map((view) => view.url))]).toEqual([baseUrl])
expect(views).toHaveLength(1)
})

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