Skip to content

[RUM-15238] [FIX] RUM events associated to previous view#1220

Open
marco-saia-datadog wants to merge 3 commits intodevelopfrom
marcosaia/RUM-15238/fix-events-mis-association
Open

[RUM-15238] [FIX] RUM events associated to previous view#1220
marco-saia-datadog wants to merge 3 commits intodevelopfrom
marcosaia/RUM-15238/fix-events-mis-association

Conversation

@marco-saia-datadog
Copy link
Member

@marco-saia-datadog marco-saia-datadog commented Mar 23, 2026

What does this PR do?

Introduces a NavigationBuffer that queues RUM events during in-flight navigation and flushes them once the new view is confirmed.

Motivation

RUM events (resources, actions) fired inside a screen's useEffect during a navigation transition were being attributed to the previous view instead of the new one. This happened because the onStateChange callback — which triggers DdRum.startView — fires after the new screen mounts and its effects run, leaving a window where events have no active view to attach to.

How it works

  1. __unsafe_action__ listener (startNavigation) — fires synchronously when any navigation action is dispatched, before the new screen mounts. Starts buffering all incoming RUM events and records navigationStartTime.
  2. prepareEndNavigation — called just before DdRum.startView. Stops accepting new events (so startView itself passes through immediately) but keeps the queue intact.
  3. flush — called after startView resolves. Drains queued events to the now-active view.
  4. endNavigation (stop + drain) — used as a fail-safe on timeout (500 ms), teardown, background state, and any path where startView is skipped.

DdRum.startView is also backdated to navigationStartTime so the view's start timestamp reflects when the user triggered navigation, not when onStateChange fired.

New option: useNavigationBuffer

Added to NavigationTrackingOptions (default true). Set to false to bypass the buffer entirely if it causes issues.

DdRumReactNavigationTracking.startTrackingViews(ref, {
    useNavigationBuffer: false
});

Back-to-back navigation race condition (in 5c925f3)

A second __unsafe_action__ can fire before flush() resolves from the first navigation (e.g. rapid taps). Without a fix, nav-2 events would land in the queue being flushed and be attributed to view 1.

prepareEndNavigation() now snapshots callbackQueue into _pendingFlushQueue and resets the live queue to []. flush() drains only the snapshot, so any events enqueued by a concurrent nav-2 are isolated and will be flushed in their own cycle. endNavigation() and drain() call drainAllQueues() which drains both, so nothing leaks on teardown/timeout

Key files

File What changed
core/.../Buffer/NavigationBuffer.ts New DatadogBuffer decorator implementing the buffer lifecycle
core/.../Buffer/BufferSingleton.ts onInitialization now installs NavigationBuffer wrapping PassThroughBuffer as the active SDK buffer
react-navigation/.../DdRumReactNavigationTracking.tsx Wires __unsafe_action__ listener; calls prepareEndNavigation/flush around startView; adds useNavigationBuffer option
core/src/index.tsx BufferSingleton removed from public exports — react-navigation accesses it via getGlobalInstance using the shared globalThis key

Additional Notes

  • NavigationBuffer is accessed from react-navigation via getGlobalInstance('com.datadog.reactnative.buffer_singleton', ...) — no cross-package import. The key is the same one BufferSingleton registers under. A comment in both files marks this coupling.
  • The 500 ms safety timeout (NAVIGATION_BUFFER_TIMEOUT_MS) guarantees the buffer is always drained even if onStateChange never fires (e.g. navigation cancelled).
  • All existing tests pass; new tests cover the buffer lifecycle and the __unsafe_action__ wiring.
  • Debug LOG calls are temporary and marked TODO: remove before shipping - I will remove them after some further testing on my side, and before merging the PR

Review checklist (to be filled by reviewers)

  • Feature or bugfix MUST have appropriate tests
  • Make sure you discussed the feature or bugfix with the maintaining team in an Issue
  • Make sure each commit and the PR mention the Issue number (cf the CONTRIBUTING doc)
  • If this PR is auto-generated, please make sure also to manually update the code related to the change

@marco-saia-datadog marco-saia-datadog force-pushed the marcosaia/RUM-15238/fix-events-mis-association branch from e08ad8f to 37adee8 Compare March 23, 2026 15:34
@marco-saia-datadog marco-saia-datadog marked this pull request as ready for review March 23, 2026 17:09
@marco-saia-datadog marco-saia-datadog requested a review from a team as a code owner March 23, 2026 17:09
Copilot AI review requested due to automatic review settings March 23, 2026 17:09
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds a navigation-transition buffering mechanism so RUM events emitted during in-flight navigation are attributed to the new view rather than the previous one.

Changes:

  • Introduces NavigationBuffer in core and wires it into BufferSingleton after SDK initialization.
  • Updates React Navigation RUM tracking to start buffering on __unsafe_action__, then prepareEndNavigation() before DdRum.startView and flush() after it settles; adds useNavigationBuffer option.
  • Adds/updates tests covering buffer lifecycle, teardown, and back-to-back navigation behavior.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
packages/react-navigation/src/rum/instrumentation/DdRumReactNavigationTracking.tsx Wires navigation lifecycle to the buffer and backdates startView using navigationStartTime; adds useNavigationBuffer.
packages/react-navigation/src/tests/rum/instrumentation/DdRumReactNavigationTracking.test.tsx Adds integration tests for the buffer wiring and updates RUM mocks to return promises.
packages/core/src/sdk/DatadogProvider/Buffer/NavigationBuffer.ts Implements the buffering decorator with startNavigation / prepareEndNavigation / flush / endNavigation and a safety timeout.
packages/core/src/sdk/DatadogProvider/Buffer/BufferSingleton.ts Installs NavigationBuffer(PassThroughBuffer) as the active buffer on initialization and exposes getNavigationBuffer().
packages/core/src/sdk/DatadogProvider/Buffer/tests/NavigationBuffer.test.ts Adds unit tests for buffering, draining, timeouts, and the two-phase flush pattern.
packages/core/src/sdk/DatadogProvider/Buffer/tests/BufferSingleton.test.ts Verifies NavigationBuffer wiring through BufferSingleton.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +31 to +32
this.navigationBuffer = new NavigationBuffer(new PassThroughBuffer());
this.bufferInstance = this.navigationBuffer;
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

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

BufferSingleton.onInitialization() replaces the global SDK buffer with NavigationBuffer, which means all buffered native calls (logs, traces, etc.) will be delayed during navigation, not just RUM events. If the intention is to buffer only RUM events, consider scoping the buffer to RUM’s buffering layer instead of the shared BufferSingleton, or explicitly document/guard which callbacks should be buffered.

Suggested change
this.navigationBuffer = new NavigationBuffer(new PassThroughBuffer());
this.bufferInstance = this.navigationBuffer;
if (!this.navigationBuffer) {
this.navigationBuffer = new NavigationBuffer(new PassThroughBuffer());
}

Copilot uses AI. Check for mistakes.
this.bufferInstance = this.navigationBuffer;
};

reset = () => {
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

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

reset() nulls out navigationBuffer but doesn’t actively cancel any outstanding navigation timeout on the existing instance. In tests (or any future reuse), that timer can still fire and execute callbacks after reset. Consider calling this.navigationBuffer?.endNavigation() (or a dedicated dispose) before dropping the reference.

Suggested change
reset = () => {
reset = () => {
this.navigationBuffer?.endNavigation();

Copilot uses AI. Check for mistakes.
Comment on lines 489 to 492
const route = this.registeredContainer?.getCurrentRoute();

const newRouteStateEvent = this.navigationTimeline?.addNewRouteEvent(
this.previousRouteKey,
route?.key
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

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

The resolveNavigationStateChangeListener has an early-return when route === undefined a few lines below, but it doesn’t call NavigationBuffer.endNavigation(). With the new buffering, that means events can remain queued until the timeout fires. Consider draining the buffer before returning when the route can’t be resolved.

Copilot uses AI. Check for mistakes.
Comment on lines +398 to +399
// Capture the navigation start timestamp BEFORE prepareEndNavigation
// clears it, so we can backdate the view start to when navigation began.
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

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

The comment says prepareEndNavigation “clears” the navigation start timestamp, but NavigationBuffer.prepareEndNavigation() intentionally keeps _navigationStartTime until flush(). Either update the comment here or adjust the buffer lifecycle so the code and documentation stay aligned.

Suggested change
// Capture the navigation start timestamp BEFORE prepareEndNavigation
// clears it, so we can backdate the view start to when navigation began.
// Capture the navigation start timestamp before marking the navigation
// as ended, so we can backdate the view start to when navigation began.

Copilot uses AI. Check for mistakes.
Comment on lines +919 to +921
// endNavigation called synchronously since view is not tracked
// (startNavigation may not be called here because navigation.navigate()
// uses a screen-level dispatch that may bypass the container ref's patched dispatch)
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

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

Another spot referencing the old “patched dispatch” approach (this scenario is now about whether __unsafe_action__ fires for screen-level navigation). Consider updating this comment so it matches the current buffering trigger mechanism.

Suggested change
// endNavigation called synchronously since view is not tracked
// (startNavigation may not be called here because navigation.navigate()
// uses a screen-level dispatch that may bypass the container ref's patched dispatch)
// endNavigation is called synchronously since the view is not tracked.
// Here navigation.navigate() triggers screen-level navigation (via
// __unsafe_action__/navigation events), and we verify that the buffer
// still ends the navigation even if startNavigation is not invoked.

Copilot uses AI. Check for mistakes.
Comment on lines +165 to +172
flush = (): void => {
this._navigationStartTime = null;
const toFlush = this._pendingFlushQueue;
this._pendingFlushQueue = [];
for (const callback of toFlush) {
callback();
}
};
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

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

flush() always sets _navigationStartTime = null. In a back-to-back navigation scenario where nav-2 calls startNavigation() before nav-1 flush() runs, nav-1’s flush will clear nav-2’s start timestamp, so view-2 can no longer be backdated. Consider snapshotting the start time in prepareEndNavigation() and only clearing the timestamp associated with the queue being flushed (or only clearing when no new navigation is active).

Copilot uses AI. Check for mistakes.
Comment on lines +864 to +868
// startNavigation is triggered by the patched dispatch on the
// navigation container ref. Screen-level navigation.navigate()
// uses an internal dispatch path that may not hit the container
// ref's dispatch. This test verifies the dispatch patch by
// calling dispatch directly on the container ref.
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

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

These test comments still refer to a “patched dispatch” implementation, but the production code now relies on the __unsafe_action__ listener to call startNavigation(). Updating the wording will avoid confusion about what behavior is actually being exercised.

Suggested change
// startNavigation is triggered by the patched dispatch on the
// navigation container ref. Screen-level navigation.navigate()
// uses an internal dispatch path that may not hit the container
// ref's dispatch. This test verifies the dispatch patch by
// calling dispatch directly on the container ref.
// startNavigation is triggered by the navigation container's
// __unsafe_action__ listener when an action is dispatched. Screen-level
// navigation.navigate() goes through the same event wiring, but may not
// call dispatch directly on the container ref. This test verifies the
// __unsafe_action__-based wiring by dispatching directly on the container ref.

Copilot uses AI. Check for mistakes.
Comment on lines +86 to +96
addCallbackReturningId = (
callback: () => Promise<string>
): Promise<string> => {
if (!this.isNavigating) {
return this.innerBuffer.addCallbackReturningId(callback);
}
return new Promise<string>(resolve => {
this.callbackQueue.push(() => {
this.innerBuffer.addCallbackReturningId(callback).then(resolve);
});
});
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

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

When buffering, addCallbackReturningId only wires the inner promise to resolve (no reject/catch). If the underlying native call rejects, the returned promise will never settle (hang), potentially deadlocking callers awaiting an ID (e.g., tracing spans). Capture reject too and forward both resolve/reject from the inner buffer call, and consider guarding against synchronous throws as well.

Copilot uses AI. Check for mistakes.
Comment on lines +99 to +110
addCallbackWithId = (
callback: (id: string) => Promise<void>,
id: string
): Promise<void> => {
if (!this.isNavigating) {
return this.innerBuffer.addCallbackWithId(callback, id);
}
return new Promise<void>(resolve => {
this.callbackQueue.push(() => {
this.innerBuffer.addCallbackWithId(callback, id).then(resolve);
});
});
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

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

Similarly, addCallbackWithId creates a promise that only ever resolves. If the inner addCallbackWithId rejects, the outer promise never settles. Propagate rejections (or resolve with a safe fallback) so callers don't hang during buffered navigation windows.

Copilot uses AI. Check for mistakes.
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.

2 participants