From b8c143f91bcebaaf97b6b2c87ee55cf6df63c38e Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Tue, 6 Oct 2026 12:15:56 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20Deduplicate=20Next.js=20views=20?= =?UTF-8?q?across=20discarded=20mount=20renders?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/nextJSRouter/datadogAppRouter.tsx | 9 +---- .../src/domain/nextjsPlugin.spec.ts | 40 +++++++++++++++++++ .../src/domain/nextjsPlugin.ts | 12 ++++++ test/apps/nextjs/app/hydration-marker.tsx | 11 +++++ test/apps/nextjs/app/layout.tsx | 2 + .../apps/nextjs/app/lazy-hydration-marker.tsx | 13 ++++++ .../scenario/plugins/nextjsPlugin.scenario.ts | 30 ++++++++++++++ 7 files changed, 110 insertions(+), 7 deletions(-) create mode 100644 test/apps/nextjs/app/hydration-marker.tsx create mode 100644 test/apps/nextjs/app/lazy-hydration-marker.tsx diff --git a/packages/browser-rum-nextjs/src/domain/nextJSRouter/datadogAppRouter.tsx b/packages/browser-rum-nextjs/src/domain/nextJSRouter/datadogAppRouter.tsx index 4fba7e7911..e6954680db 100644 --- a/packages/browser-rum-nextjs/src/domain/nextJSRouter/datadogAppRouter.tsx +++ b/packages/browser-rum-nextjs/src/domain/nextJSRouter/datadogAppRouter.tsx @@ -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)(null) - if (previousPathname.current !== pathname) { - previousPathname.current = pathname - startNextjsView(computeViewNameFromParams(pathname, params)) - } + startAppRouterView(pathname, computeViewNameFromParams(pathname, params)) return null } diff --git a/packages/browser-rum-nextjs/src/domain/nextjsPlugin.spec.ts b/packages/browser-rum-nextjs/src/domain/nextjsPlugin.spec.ts index c2929b5d1b..3d7b3b207f 100644 --- a/packages/browser-rum-nextjs/src/domain/nextjsPlugin.spec.ts +++ b/packages/browser-rum-nextjs/src/domain/nextjsPlugin.spec.ts @@ -5,6 +5,7 @@ import { appendElement } from '../../../browser-rum-core/test' import { nextjsPlugin, startNextjsView, + startAppRouterView, onRumInit, onRumStart, onRouterTransitionStart, @@ -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() diff --git a/packages/browser-rum-nextjs/src/domain/nextjsPlugin.ts b/packages/browser-rum-nextjs/src/domain/nextjsPlugin.ts index 61ccdb4a5a..039b62db41 100644 --- a/packages/browser-rum-nextjs/src/domain/nextjsPlugin.ts +++ b/packages/browser-rum-nextjs/src/domain/nextjsPlugin.ts @@ -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[] = [] @@ -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 @@ -91,5 +102,6 @@ export function resetNextjsPlugin() { onRumInitSubscribers.length = 0 onRumStartSubscribers.length = 0 lastNavigationUrl = undefined + lastStartedAppRouterPathname = undefined routerType = undefined } diff --git a/test/apps/nextjs/app/hydration-marker.tsx b/test/apps/nextjs/app/hydration-marker.tsx new file mode 100644 index 0000000000..5ea474ba5f --- /dev/null +++ b/test/apps/nextjs/app/hydration-marker.tsx @@ -0,0 +1,11 @@ +'use client' + +import { useEffect } from 'react' + +export default function HydrationMarker() { + useEffect(() => { + performance.mark('root-hydration-marker') + }, []) + + return null +} diff --git a/test/apps/nextjs/app/layout.tsx b/test/apps/nextjs/app/layout.tsx index 9733204698..5efc9d4ff3 100644 --- a/test/apps/nextjs/app/layout.tsx +++ b/test/apps/nextjs/app/layout.tsx @@ -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 ( +