diff --git a/packages/browser-rum-nextjs/src/domain/nextJSRouter/datadogAppRouter.tsx b/packages/browser-rum-nextjs/src/domain/nextJSRouter/datadogAppRouter.tsx index 50bc025ead..af8bc3349a 100644 --- a/packages/browser-rum-nextjs/src/domain/nextJSRouter/datadogAppRouter.tsx +++ b/packages/browser-rum-nextjs/src/domain/nextJSRouter/datadogAppRouter.tsx @@ -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)(null) - - if (previousPathname.current !== pathname) { - previousPathname.current = pathname - startNextjsView(computeViewNameFromParams(pathname, params)) - } + useStartNextjsView(pathname, computeViewNameFromParams(pathname, params)) return null } diff --git a/packages/browser-rum-nextjs/src/domain/nextJSRouter/datadogPagesRouter.tsx b/packages/browser-rum-nextjs/src/domain/nextJSRouter/datadogPagesRouter.tsx index e320dfe951..e017016ae5 100644 --- a/packages/browser-rum-nextjs/src/domain/nextJSRouter/datadogPagesRouter.tsx +++ b/packages/browser-rum-nextjs/src/domain/nextJSRouter/datadogPagesRouter.tsx @@ -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)(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 } diff --git a/packages/browser-rum-nextjs/src/domain/nextJSRouter/useStartNextjsView.spec.tsx b/packages/browser-rum-nextjs/src/domain/nextJSRouter/useStartNextjsView.spec.tsx new file mode 100644 index 0000000000..d7744113e3 --- /dev/null +++ b/packages/browser-rum-nextjs/src/domain/nextJSRouter/useStartNextjsView.spec.tsx @@ -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( + + + + ) + + 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() + 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(null) + setPath = setCurrentPath + useStartNextjsView(path, '/user/[id]') + return null + } + + appendComponent() + expect(startViewSpy).not.toHaveBeenCalled() + + act(() => { + setPath('/user/42') + }) + + expect(startViewSpy).toHaveBeenCalledOnceWith({ name: '/user/[id]', url: undefined }) + }) +}) diff --git a/packages/browser-rum-nextjs/src/domain/nextJSRouter/useStartNextjsView.ts b/packages/browser-rum-nextjs/src/domain/nextJSRouter/useStartNextjsView.ts new file mode 100644 index 0000000000..e13c4a0e8a --- /dev/null +++ b/packages/browser-rum-nextjs/src/domain/nextJSRouter/useStartNextjsView.ts @@ -0,0 +1,13 @@ +import { useLayoutEffect, useRef } from 'react' +import { startNextjsView } from '../nextjsPlugin' + +export function useStartNextjsView(path: string | null, viewName: string) { + const previousPath = useRef(null) + + useLayoutEffect(() => { + if (path !== null && previousPath.current !== path) { + previousPath.current = path + startNextjsView(viewName) + } + }, [path, viewName]) +} diff --git a/test/apps/nextjs/app/discardedRenderProbe.tsx b/test/apps/nextjs/app/discardedRenderProbe.tsx new file mode 100644 index 0000000000..772b8f9190 --- /dev/null +++ b/test/apps/nextjs/app/discardedRenderProbe.tsx @@ -0,0 +1,21 @@ +'use client' + +let renderAttempt = 0 +let suspendPromise: Promise | 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