🐛 Prevent duplicate Next.js RUM views from discarded renders - #4940
🐛 Prevent duplicate Next.js RUM views from discarded renders#4940BeltranBulbarellaDD wants to merge 6 commits into
Conversation
🎉 All green!🧪 All tests passed 🎯 Code Coverage (details) 🔗 Commit SHA: 31857be | Docs | View more details | Give us feedback! |
Bundles Sizes Evolution
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 31857be6e6
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| previousPathname.current = pathname | ||
| startNextjsView(computeViewNameFromParams(pathname, params)) | ||
| } | ||
| setNextjsViewName(viewName, pathname) |
There was a problem hiding this comment.
Defer view-name mutation until the render commits
issue: setNextjsViewName() is still called during React's render phase, even though it invokes publicApi.setViewName() and mutates the module-level currentAppRouterPathname. If React abandons a route render, those changes are not rolled back, so the active RUM view can temporarily receive the abandoned route's normalized name and the next transition can compare against an uncommitted pathname. Invoke this from an effect keyed by viewName and pathname so only committed renders update SDK state.
Useful? React with 👍 / 👎.
| if (navigationUrl.origin === window.location.origin && navigationUrl.pathname !== currentAppRouterPathname) { | ||
| startNextjsView(navigationUrl.pathname, navigationUrl.href) |
There was a problem hiding this comment.
Deduplicate repeated transition callbacks before commit
issue: When onRouterTransitionStart() is invoked more than once for the same destination before a slow route commits—for example, two consecutive router.push('/slow') calls—currentAppRouterPathname still contains the previous committed route, so every invocation passes this condition and calls startView(), producing duplicate RUM view IDs for one destination. Track the pending pathname separately, or otherwise suppress repeated callbacks for the same in-flight target while still allowing redirects to a different target.
Useful? React with 👍 / 👎.
Motivation
Fixes #4931.
DatadogAppRouterpreviously calledstartNextjsView()during the render phase, guarded only by auseRef. When React discards a render before commit (common on App Router initial mount), the ref resets on the new fiber but thestartView()side effect cannot be undone — "The guard rolls back. The side effect does not." BecausenextjsPluginsetstrackViewsManually = true, each call creates RUM view.This PR splits view creation from view naming:
startView()runs outside React — on plugin init (initial load) and via Next.jsonRouterTransitionStart(client navigations, before React renders).setViewName()runs after commit — inDatadogAppRouter, to normalize dynamic segments (e.g./user/42→/user/[id]).View creation flow (click to expand)
flowchart TD A["instrumentation-client<br/>datadogRum.init() + export onRouterTransitionStart"] --> B["nextjsPlugin.onInit()"] B --> C["trackViewsManually = true"] C --> D["currentAppRouterPathname = location.pathname"] D --> E["startNextjsView(pathname, href)"] E --> F["V1: initial_load<br/>name = concrete pathname<br/>single view.id ✅"] F --> S{"Scenario"} S --> N1 S --> R1 S --> I1 subgraph N["1. Normal navigation (App Router)"] N1["Next.js: onRouterTransitionStart('/user/999?admin=true')<br/>⏱ BEFORE React renders"] --> N2{"pathname '/user/999' ≠<br/>currentAppRouterPathname?"} N2 -->|Yes| N3["startNextjsView()<br/>V2: route_change<br/>name='/user/999', full url"] N2 -->|No| NSkip["Skip — query/hash only or same committed route"] N3 --> N4["React renders /user/999"] N4 --> N5["React commits"] N5 --> N6["DatadogAppRouter: useLayoutEffect"] N6 --> N7["computeViewNameFromParams → '/user/[id]'"] N7 --> N8["setNextjsViewName('/user/[id]', '/user/999')<br/>currentAppRouterPathname = '/user/999'"] N8 --> N9["Same V2 ✅<br/>setViewName() — name normalization only"] end subgraph R["2. Route change with discarded render (post-init)"] R1["onRouterTransitionStart('/target')"] --> R2["startNextjsView()<br/>V2: route_change<br/>name='/target'"] R2 --> R3["React render #1<br/>render discarded (suspend / retry)"] R3 --> R4["React render #N …"] R4 --> R5["React commits (last attempt)"] R5 --> R6["useLayoutEffect → setNextjsViewName()"] R6 --> R7["Same V2 ✅<br/>DatadogAppRouter no longer calls startView()"] R2 -. "if navigation never commits" .-> R8["⚠️ Known limitation:<br/>V2 already exists, no rollback"] end subgraph I["3. Bug #4931 — initial mount with discarded renders"] direction TB I1["Page reload<br/>NO onRouterTransitionStart"] subgraph OLD["❌ Old code (issue #4931)"] direction TB O1["DatadogAppRouter render #1"] O1 --> O2["useRef = null → startView() during render ⚠️"] O2 --> O3["React discards the render<br/>('The guard rolls back. The side effect does not.')"] O3 --> O4["Render #2 — new fiber, useRef reset"] O4 --> O5["startView() again"] O5 --> O6["…6 renders, 1 commit → 6 startView() calls"] O6 --> O7["Result: duplicate views<br/>distinct view.id 💸"] end subgraph NEW["✅ Current code (PR #4940)"] direction TB Nw1["onInit already created V1 (single startView)"] Nw1 --> Nw2["DatadogAppRouter render #1…#N<br/>no side effects during render"] Nw2 --> Nw3["Only the committed render<br/>runs useLayoutEffect"] Nw3 --> Nw4["setNextjsViewName() — renames V1<br/>does NOT create new views"] Nw4 --> Nw5["Result: single V1 ✅"] end I1 --> OLD I1 --> NEW end style OLD fill:#fff0f0,stroke:#cc0000 style NEW fill:#f0fff0,stroke:#008800 style O7 fill:#ffcccc style Nw5 fill:#ccffccKnown limitation: if
onRouterTransitionStartfires but the navigation never commits, the view is already started and there is no rollback. This is separate from #4931 (duplicate views on initial mount).Test instructions
E2E
Key scenarios to verify:
should not create views from discarded renders?discard-nextjs-render=true→ exactly 1 view (initial_load)should start a slow navigation view before the route commitsshould track redirect views/redirect→/user/123produces expectedroute_changeviews/user/[id]), parallel routes unaffectedManual repro (issue #4931)
<DatadogAppRouter />in root layout (see issue comment).Checklist