Skip to content

🐛 Prevent duplicate Next.js RUM views from discarded renders - #4940

Open
BeltranBulbarellaDD wants to merge 6 commits into
mainfrom
beltran.bulbarella/next_js_render_issue
Open

🐛 Prevent duplicate Next.js RUM views from discarded renders#4940
BeltranBulbarellaDD wants to merge 6 commits into
mainfrom
beltran.bulbarella/next_js_render_issue

Conversation

@BeltranBulbarellaDD

@BeltranBulbarellaDD BeltranBulbarellaDD commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Motivation

Fixes #4931.

DatadogAppRouter previously called startNextjsView() during the render phase, guarded only by a useRef. When React discards a render before commit (common on App Router initial mount), the ref resets on the new fiber but the startView() side effect cannot be undone — "The guard rolls back. The side effect does not." Because nextjsPlugin sets trackViewsManually = 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.js onRouterTransitionStart (client navigations, before React renders).
  • setViewName() runs after commit — in DatadogAppRouter, 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:#ccffcc
Loading

Known limitation: if onRouterTransitionStart fires 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:

Test What it validates
should not create views from discarded renders Reload with ?discard-nextjs-render=true → exactly 1 view (initial_load)
should start a slow navigation view before the route commits View starts at navigation time, not after the slow route commits
should track redirect views /redirect/user/123 produces expected route_change views
Base router tests Dynamic route normalization (/user/[id]), parallel routes unaffected

Manual repro (issue #4931)

  1. Add a suspend probe next to <DatadogAppRouter /> in root layout (see issue comment).
  2. Reload the page — old code: N console render attempts → N RUM views; new code: N render attempts → 1 RUM view.

Checklist

  • Tested locally
  • Tested on staging
  • Added unit tests for this change.
  • Added e2e/integration tests for this change.
  • Updated documentation and/or relevant AGENTS.md file

@datadog-datadog-prod-us1

datadog-datadog-prod-us1 Bot commented Aug 12, 2026

Copy link
Copy Markdown

Tests

🎉 All green!

🧪 All tests passed
❄️ No new flaky tests detected

🎯 Code Coverage (details)
Patch Coverage: 76.92%
Overall Coverage: 77.02% (+0.00%)

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: 31857be | Docs | View more details | Give us feedback!

@BeltranBulbarellaDD BeltranBulbarellaDD changed the title test nextjs rendering issue 🐛 Prevent duplicate Next.js RUM views from discarded renders Aug 17, 2026
@BeltranBulbarellaDD
BeltranBulbarellaDD marked this pull request as ready for review August 19, 2026 15:16
@BeltranBulbarellaDD
BeltranBulbarellaDD requested a review from a team as a code owner August 19, 2026 15:16
Comment thread packages/browser-rum-nextjs/src/domain/nextJSRouter/useStartNextjsView.ts Outdated
@sbarrio
sbarrio requested a review from bdibon August 20, 2026 07:06
@BeltranBulbarellaDD
BeltranBulbarellaDD marked this pull request as draft August 27, 2026 09:49
@BeltranBulbarellaDD
BeltranBulbarellaDD removed the request for review from bdibon August 27, 2026 09:49
@cit-pr-commenter-54b7da

cit-pr-commenter-54b7da Bot commented Aug 27, 2026

Copy link
Copy Markdown

Bundles Sizes Evolution

📦 Bundle Name Base Size Local Size 𝚫 𝚫% Status
Rum 181.63 KiB 181.63 KiB 0 B 0.00%
Rum Profiler 8.43 KiB 8.43 KiB 0 B 0.00%
Rum Recorder 25.32 KiB 25.32 KiB 0 B 0.00%
Logs 57.93 KiB 57.93 KiB 0 B 0.00%
Rum Salesforce N/A 139.69 KiB N/A N/A N/A
Rum Slim 139.68 KiB 139.68 KiB 0 B 0.00%
Worker 22.96 KiB 22.96 KiB 0 B 0.00%
Rum Shopify N/A 205.99 KiB N/A N/A N/A
Rum-shopify Profiler N/A 8.43 KiB N/A N/A N/A
Rum-shopify Recorder N/A 3.74 KiB N/A N/A N/A

@BeltranBulbarellaDD
BeltranBulbarellaDD marked this pull request as ready for review August 28, 2026 14:11

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment on lines +87 to +88
if (navigationUrl.origin === window.location.origin && navigationUrl.pathname !== currentAppRouterPathname) {
startNextjsView(navigationUrl.pathname, navigationUrl.href)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

🐛 Router components create duplicate RUM views: render-phase startView() guarded only by useRef

2 participants