Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,23 +8,32 @@ import { getGlobalInstance } from '../../../utils/singletonUtils';

import { BoundedBuffer } from './BoundedBuffer';
import type { DatadogBuffer } from './DatadogBuffer';
import { NavigationBuffer } from './NavigationBuffer';
import { PassThroughBuffer } from './PassThroughBuffer';

// IMPORTANT: Keep this key aligned with the react-navigation package
const BUFFER_SINGLETON_MODULE = 'com.datadog.reactnative.buffer_singleton';

class _BufferSingleton {
private bufferInstance: DatadogBuffer = new BoundedBuffer();
private navigationBuffer: NavigationBuffer | null = null;

getInstance = (): DatadogBuffer => {
return BufferSingleton.bufferInstance;
};

getNavigationBuffer = (): NavigationBuffer | null => {
return this.navigationBuffer;
};

onInitialization = () => {
this.bufferInstance.drain();
this.bufferInstance = new PassThroughBuffer();
this.navigationBuffer = new NavigationBuffer(new PassThroughBuffer());
this.bufferInstance = this.navigationBuffer;
Comment on lines +31 to +32
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.
};

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.
this.navigationBuffer = null;
BufferSingleton.bufferInstance = new BoundedBuffer();
};
}
Expand Down
204 changes: 204 additions & 0 deletions packages/core/src/sdk/DatadogProvider/Buffer/NavigationBuffer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
/*
* Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0.
* This product includes software developed at Datadog (https://www.datadoghq.com/).
* Copyright 2016-Present Datadog, Inc.
*/

import { DatadogBuffer } from './DatadogBuffer';

/**
* Safety timeout (ms): auto-drains the buffer if `onStateChange` never fires
* after a navigation dispatch.
*/
export const NAVIGATION_BUFFER_TIMEOUT_MS = 500;

/**
* An internal `DatadogBuffer` decorator that queues RUM events during a
* navigation transition and flushes them once the new view is confirmed.
*
* **IMPORTANT**
* Any changes to the public methods of this class must be reflected in
* the interface definition of the react-navigation package.
*
* **Lifecycle**
* 1. `startNavigation()` — called when a navigation action is dispatched
* (via the `__unsafe_action__` listener). Starts buffering all incoming
* RUM events and records `navigationStartTime` so the view-start can be
* backdated to the moment the user triggered the navigation.
* A safety timeout (`NAVIGATION_BUFFER_TIMEOUT_MS`) automatically calls
* `endNavigation()` if the state-change callback never fires.
* 2. `prepareEndNavigation()` — called just before `DdRum.startView()`.
* Stops accepting new events into the queue (so `startView` itself passes
* through immediately) but keeps the queue intact and preserves
* `navigationStartTime` for the caller to read.
* 3. `flush()` — called after `startView()` resolves. Drains queued events
* to the inner buffer so they are attributed to the new view.
* 4. `endNavigation()` — stop-and-drain shortcut used by the safety timeout,
* teardown (`stopTrackingViews`), and any path where no `startView` fires
* (background state, predicate returning false, undefined route, etc.).
*
* **Integration point**
* `BufferSingleton.onInitialization()` installs a `NavigationBuffer` wrapping
* a `PassThroughBuffer` as the active SDK buffer. The react-navigation package
* accesses it via `getGlobalInstance` using the shared
* `'com.datadog.reactnative.buffer_singleton'` key — no public export needed.
*
* @internal
*/
export class NavigationBuffer extends DatadogBuffer {
private innerBuffer: DatadogBuffer;
private isNavigating = false;
private callbackQueue: Array<() => void> = [];
// Snapshot of callbackQueue taken at prepareEndNavigation() time.
// Kept separate so that a back-to-back navigation that calls startNavigation()
// before flush() runs cannot mix its events into the pending flush for the
// previous view. flush() drains only this snapshot; new events from the
// subsequent navigation accumulate in the fresh callbackQueue.
private _pendingFlushQueue: Array<() => void> = [];
private timeoutId: ReturnType<typeof setTimeout> | null = null;
private _navigationStartTime: number | null = null;

/**
* The timestamp (ms since epoch) captured when startNavigation() was called.
* Use this as the timestampMs for DdRum.startView() so the view start reflects
* when the user initiated navigation, not when onStateChange fired.
* Null when no navigation is in progress.
*/
get navigationStartTime(): number | null {
return this._navigationStartTime;
}

constructor(innerBuffer: DatadogBuffer) {
super();
this.innerBuffer = innerBuffer;
}

addCallback = (callback: () => Promise<void>): Promise<void> => {
if (!this.isNavigating) {
return this.innerBuffer.addCallback(callback);
}
this.callbackQueue.push(() => {
this.innerBuffer.addCallback(callback);
});
return Promise.resolve();
};

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);
});
});
Comment on lines +86 to +96
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.
};

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);
});
});
Comment on lines +99 to +110
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.
};

drain = (): void => {
this.drainAllQueues();
this.innerBuffer.drain();
};

startNavigation = (): void => {
const wasAlreadyNavigating = this.isNavigating;
if (this.timeoutId !== null) {
clearTimeout(this.timeoutId);
}
// Only capture the start time on the first navigation start; preserve it
// across rapid re-navigations so the timestamp reflects the original intent.
if (!wasAlreadyNavigating) {
this._navigationStartTime = Date.now();
}
this.isNavigating = true;
this.timeoutId = setTimeout(() => {
this.endNavigation();
}, NAVIGATION_BUFFER_TIMEOUT_MS);
};

/**
* Stop accepting new events into the buffer and cancel any pending timeout,
* WITHOUT draining the queue. Use this before calling DdRum.startView() so
* that startView() itself passes through immediately. Then call flush() after
* startView resolves to send queued events to the now-active view.
*
* Contrast with endNavigation(), which stops AND drains immediately (used by
* timeout auto-drain and teardown paths).
*/
prepareEndNavigation = (): void => {
if (this.timeoutId !== null) {
clearTimeout(this.timeoutId);
this.timeoutId = null;
}
this.isNavigating = false;
// Snapshot the current queue so flush() only drains events that belong
// to this navigation. Events arriving after this point (e.g. from a
// back-to-back navigation that re-enables buffering) go into the fresh
// callbackQueue and will not be included in the upcoming flush().
this._pendingFlushQueue = this.callbackQueue;
this.callbackQueue = [];
// Note: _navigationStartTime is intentionally kept until flush() so the
// caller can still read it after prepareEndNavigation() returns.
};

/**
* Drain the queued events to the inner buffer. Call this after startView()
* resolves to flush buffered events to the new view.
*
* Safe to call when the queue is empty (no-op).
*/
flush = (): void => {
this._navigationStartTime = null;
const toFlush = this._pendingFlushQueue;
this._pendingFlushQueue = [];
for (const callback of toFlush) {
callback();
}
};
Comment on lines +165 to +172
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.

/**
* Stop buffering and drain the queue immediately. Used by:
* - Timeout auto-drain (navigation never completed)
* - Teardown (stopTrackingViews)
*/
endNavigation = (): void => {
if (this.timeoutId !== null) {
clearTimeout(this.timeoutId);
this.timeoutId = null;
}
this.isNavigating = false;
this._navigationStartTime = null;
// Drain both queues: _pendingFlushQueue may be non-empty if a
// prepareEndNavigation() was followed by a back-to-back navigation
// before flush() ran.
this.drainAllQueues();
};

private drainAllQueues = (): void => {
const pendingFlush = this._pendingFlushQueue;
this._pendingFlushQueue = [];
for (const callback of pendingFlush) {
callback();
}
const queued = this.callbackQueue;
this.callbackQueue = [];
for (const callback of queued) {
callback();
}
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*/

import { BufferSingleton } from '../BufferSingleton';
import { NavigationBuffer } from '../NavigationBuffer';

const flushPromises = () =>
new Promise<void>(jest.requireActual('timers').setImmediate);
Expand Down Expand Up @@ -48,4 +49,54 @@ describe('BufferSingleton', () => {
expect(callbackWithId).toHaveBeenCalledWith('callbackId');
});
});

describe('NavigationBuffer wiring', () => {
afterEach(() => {
BufferSingleton.reset();
});

it('getNavigationBuffer returns null before initialization', () => {
expect(BufferSingleton.getNavigationBuffer()).toBeNull();
});

it('getNavigationBuffer returns NavigationBuffer after initialization', () => {
BufferSingleton.onInitialization();
const navBuffer = BufferSingleton.getNavigationBuffer();
expect(navBuffer).toBeInstanceOf(NavigationBuffer);
});

it('getInstance returns the NavigationBuffer after initialization', () => {
BufferSingleton.onInitialization();
const instance = BufferSingleton.getInstance();
expect(instance).toBeInstanceOf(NavigationBuffer);
});

it('NavigationBuffer passes through callbacks after initialization (not navigating)', async () => {
BufferSingleton.onInitialization();
const cb = jest.fn().mockResolvedValue(undefined);
BufferSingleton.getInstance().addCallback(cb);
expect(cb).toHaveBeenCalledTimes(1);
});

it('NavigationBuffer holds callbacks during navigation after initialization', async () => {
BufferSingleton.onInitialization();
const navBuffer = BufferSingleton.getNavigationBuffer()!;
const cb = jest.fn().mockResolvedValue(undefined);

navBuffer.startNavigation();
BufferSingleton.getInstance().addCallback(cb);
expect(cb).not.toHaveBeenCalled();

navBuffer.endNavigation();
expect(cb).toHaveBeenCalledTimes(1);
});

it('reset clears navigationBuffer reference', () => {
BufferSingleton.onInitialization();
expect(BufferSingleton.getNavigationBuffer()).not.toBeNull();

BufferSingleton.reset();
expect(BufferSingleton.getNavigationBuffer()).toBeNull();
});
});
});
Loading
Loading