diff --git a/packages/core/src/tools/experimentalFeatures.ts b/packages/core/src/tools/experimentalFeatures.ts index 6191d46327..470c4d0d06 100644 --- a/packages/core/src/tools/experimentalFeatures.ts +++ b/packages/core/src/tools/experimentalFeatures.ts @@ -19,6 +19,7 @@ export enum ExperimentalFeature { TOLERANT_RESOURCE_TIMINGS = 'tolerant_resource_timings', REMOTE_CONFIGURATION = 'remote_configuration', UPDATE_VIEW_NAME = 'update_view_name', + ASYNC_FULL_SNAPSHOT = 'async_full_snapshot', } const enabledExperimentalFeatures: Set = new Set() diff --git a/packages/core/test/emulate/mockRequestIdleCallback.ts b/packages/core/test/emulate/mockRequestIdleCallback.ts new file mode 100644 index 0000000000..d0f8e7ce8d --- /dev/null +++ b/packages/core/test/emulate/mockRequestIdleCallback.ts @@ -0,0 +1,41 @@ +import { registerCleanupTask } from '../registerCleanupTask' + +let requestIdleCallbackSpy: jasmine.Spy +let cancelIdleCallbackSpy: jasmine.Spy + +export function mockRequestIdleCallback() { + const callbacks = new Map void>() + + let idCounter = 0 + + function addCallback(callback: (...params: any[]) => any) { + const id = ++idCounter + callbacks.set(id, callback) + return id + } + + function removeCallback(id: number) { + callbacks.delete(id) + } + + if (!window.requestIdleCallback || !window.cancelIdleCallback) { + requestIdleCallbackSpy = spyOn(window, 'requestAnimationFrame').and.callFake(addCallback) + cancelIdleCallbackSpy = spyOn(window, 'cancelAnimationFrame').and.callFake(removeCallback) + } else { + requestIdleCallbackSpy = spyOn(window, 'requestIdleCallback').and.callFake(addCallback) + cancelIdleCallbackSpy = spyOn(window, 'cancelIdleCallback').and.callFake(removeCallback) + } + + registerCleanupTask(() => { + requestIdleCallbackSpy.calls.reset() + cancelIdleCallbackSpy.calls.reset() + callbacks.clear() + }) + + return { + triggerIdleCallbacks: () => { + callbacks.forEach((callback) => callback()) + }, + cancelIdleCallbackSpy, + } +} diff --git a/packages/core/test/index.ts b/packages/core/test/index.ts index 003646b827..1984451eab 100644 --- a/packages/core/test/index.ts +++ b/packages/core/test/index.ts @@ -7,6 +7,7 @@ export * from './interceptRequests' export * from './emulate/createNewEvent' export * from './emulate/mockLocation' export * from './emulate/mockClock' +export * from './emulate/mockRequestIdleCallback' export * from './emulate/mockReportingObserver' export * from './emulate/mockZoneJs' export * from './emulate/mockSyntheticsWorkerValues' diff --git a/packages/rum/src/browser/requestIdleCallBack.spec.ts b/packages/rum/src/browser/requestIdleCallBack.spec.ts new file mode 100644 index 0000000000..015067a044 --- /dev/null +++ b/packages/rum/src/browser/requestIdleCallBack.spec.ts @@ -0,0 +1,48 @@ +import { requestIdleCallback } from './requestIdleCallback' + +describe('requestIdleCallback', () => { + let callback: jasmine.Spy + const originalRequestIdleCallback = window.requestIdleCallback + + beforeEach(() => { + callback = jasmine.createSpy('callback') + }) + + afterEach(() => { + if (originalRequestIdleCallback) { + window.requestIdleCallback = originalRequestIdleCallback + } + }) + + it('should use requestIdleCallback when supported', () => { + if (!window.requestIdleCallback) { + pending('requestIdleCallback not supported') + } + spyOn(window, 'requestIdleCallback').and.callFake((cb) => { + cb({} as IdleDeadline) + return 123 + }) + spyOn(window, 'cancelIdleCallback') + + const cancel = requestIdleCallback(callback) + expect(window.requestIdleCallback).toHaveBeenCalled() + cancel() + expect(window.cancelIdleCallback).toHaveBeenCalledWith(123) + }) + + it('should use requestAnimationFrame when requestIdleCallback is not supported', () => { + if (window.requestIdleCallback) { + window.requestIdleCallback = undefined as any + } + spyOn(window, 'requestAnimationFrame').and.callFake((cb) => { + cb(1) + return 123 + }) + spyOn(window, 'cancelAnimationFrame') + + const cancel = requestIdleCallback(callback) + expect(window.requestAnimationFrame).toHaveBeenCalled() + cancel() + expect(window.cancelAnimationFrame).toHaveBeenCalledWith(123) + }) +}) diff --git a/packages/rum/src/browser/requestIdleCallback.ts b/packages/rum/src/browser/requestIdleCallback.ts new file mode 100644 index 0000000000..26e1080fc7 --- /dev/null +++ b/packages/rum/src/browser/requestIdleCallback.ts @@ -0,0 +1,19 @@ +import { monitor } from '@datadog/browser-core' + +/** + * Use 'requestIdleCallback' when available: it will throttle the mutation processing if the + * browser is busy rendering frames (ex: when frames are below 60fps). When not available, the + * fallback on 'requestAnimationFrame' will still ensure the mutations are processed after any + * browser rendering process (Layout, Recalculate Style, etc.), so we can serialize DOM nodes efficiently. + * + * Note: check both 'requestIdleCallback' and 'cancelIdleCallback' existence because some polyfills only implement 'requestIdleCallback'. + */ + +export function requestIdleCallback(callback: () => void, opts?: { timeout?: number }) { + if (window.requestIdleCallback && window.cancelIdleCallback) { + const id = window.requestIdleCallback(monitor(callback), opts) + return () => window.cancelIdleCallback(id) + } + const id = window.requestAnimationFrame(monitor(callback)) + return () => window.cancelAnimationFrame(id) +} diff --git a/packages/rum/src/domain/record/index.ts b/packages/rum/src/domain/record/index.ts index d4b434cff0..c57e05a5f5 100644 --- a/packages/rum/src/domain/record/index.ts +++ b/packages/rum/src/domain/record/index.ts @@ -2,3 +2,4 @@ export { record } from './record' export { serializeNodeWithId, serializeDocument, SerializationContextStatus } from './serialization' export { createElementsScrollPositions } from './elementsScrollPositions' export { ShadowRootsController } from './shadowRootsController' +export { startFullSnapshots } from './startFullSnapshots' diff --git a/packages/rum/src/domain/record/mutationBatch.ts b/packages/rum/src/domain/record/mutationBatch.ts index f21a159781..bf253384c6 100644 --- a/packages/rum/src/domain/record/mutationBatch.ts +++ b/packages/rum/src/domain/record/mutationBatch.ts @@ -1,4 +1,5 @@ -import { noop, monitor, throttle } from '@datadog/browser-core' +import { noop, throttle } from '@datadog/browser-core' +import { requestIdleCallback } from '../../browser/requestIdleCallback' import type { RumMutationRecord } from './trackers' /** @@ -45,20 +46,3 @@ export function createMutationBatch(processMutationBatch: (mutations: RumMutatio }, } } - -/** - * Use 'requestIdleCallback' when available: it will throttle the mutation processing if the - * browser is busy rendering frames (ex: when frames are below 60fps). When not available, the - * fallback on 'requestAnimationFrame' will still ensure the mutations are processed after any - * browser rendering process (Layout, Recalculate Style, etc.), so we can serialize DOM nodes efficiently. - * - * Note: check both 'requestIdleCallback' and 'cancelIdleCallback' existence because some polyfills only implement 'requestIdleCallback'. - */ -function requestIdleCallback(callback: () => void, opts?: { timeout?: number }) { - if (window.requestIdleCallback && window.cancelIdleCallback) { - const id = window.requestIdleCallback(monitor(callback), opts) - return () => window.cancelIdleCallback(id) - } - const id = window.requestAnimationFrame(monitor(callback)) - return () => window.cancelAnimationFrame(id) -} diff --git a/packages/rum/src/domain/record/record.spec.ts b/packages/rum/src/domain/record/record.spec.ts index 486a0b4346..b8a06136ef 100644 --- a/packages/rum/src/domain/record/record.spec.ts +++ b/packages/rum/src/domain/record/record.spec.ts @@ -2,7 +2,14 @@ import { DefaultPrivacyLevel, findLast, isIE } from '@datadog/browser-core' import type { RumConfiguration, ViewCreatedEvent } from '@datadog/browser-rum-core' import { LifeCycle, LifeCycleEventType } from '@datadog/browser-rum-core' import type { Clock } from '@datadog/browser-core/test' -import { createNewEvent, collectAsyncCalls, registerCleanupTask } from '@datadog/browser-core/test' +import { + createNewEvent, + collectAsyncCalls, + registerCleanupTask, + mockRequestIdleCallback, + mockExperimentalFeatures, +} from '@datadog/browser-core/test' +import { ExperimentalFeature } from '../../../../core/src/tools/experimentalFeatures' import { findElement, findFullSnapshot, findNode, recordsPerFullSnapshot } from '../../../test' import type { BrowserIncrementalSnapshotRecord, @@ -412,6 +419,21 @@ describe('record', () => { } }) + describe('it should not record when full snapshot is pending', () => { + it('ignores any record while a full snapshot is pending', () => { + mockExperimentalFeatures([ExperimentalFeature.ASYNC_FULL_SNAPSHOT]) + mockRequestIdleCallback() + startRecording() + newView() + + emitSpy.calls.reset() + + window.dispatchEvent(createNewEvent('focus')) + + expect(getEmittedRecords().find((record) => record.type === RecordType.Focus)).toBeUndefined() + }) + }) + describe('updates record replay stats', () => { it('when recording new records', () => { resetReplayStats() diff --git a/packages/rum/src/domain/record/record.ts b/packages/rum/src/domain/record/record.ts index f5b027d1ea..11ed55597e 100644 --- a/packages/rum/src/domain/record/record.ts +++ b/packages/rum/src/domain/record/record.ts @@ -42,11 +42,15 @@ export function record(options: RecordOptions): RecordAPI { throw new Error('emit function is required') } + let isFullSnapshotPending = false + const emitAndComputeStats = (record: BrowserRecord) => { - emit(record) - sendToExtension('record', { record }) - const view = options.viewContexts.findView()! - replayStats.addRecord(view.id) + if (!isFullSnapshotPending) { + emit(record) + sendToExtension('record', { record }) + const view = options.viewContexts.findView()! + replayStats.addRecord(view.id) + } } const elementsScrollPositions = createElementsScrollPositions() @@ -59,7 +63,13 @@ export function record(options: RecordOptions): RecordAPI { lifeCycle, configuration, flushMutations, - (records) => records.forEach((record) => emitAndComputeStats(record)) + () => { + isFullSnapshotPending = true + }, + (records) => { + isFullSnapshotPending = false + records.forEach((record) => emitAndComputeStats(record)) + } ) function flushMutations() { diff --git a/packages/rum/src/domain/record/startFullSnapshots.spec.ts b/packages/rum/src/domain/record/startFullSnapshots.spec.ts index a8026ed705..66e37f90cc 100644 --- a/packages/rum/src/domain/record/startFullSnapshots.spec.ts +++ b/packages/rum/src/domain/record/startFullSnapshots.spec.ts @@ -2,7 +2,9 @@ import type { RumConfiguration, ViewCreatedEvent } from '@datadog/browser-rum-co import { LifeCycle, LifeCycleEventType } from '@datadog/browser-rum-core' import type { TimeStamp } from '@datadog/browser-core' import { isIE, noop } from '@datadog/browser-core' +import { mockExperimentalFeatures, mockRequestIdleCallback } from '@datadog/browser-core/test' import type { BrowserRecord } from '../../types' +import { ExperimentalFeature } from '../../../../core/src/tools/experimentalFeatures' import { startFullSnapshots } from './startFullSnapshots' import { createElementsScrollPositions } from './elementsScrollPositions' import type { ShadowRootsController } from './shadowRootsController' @@ -10,7 +12,8 @@ import type { ShadowRootsController } from './shadowRootsController' describe('startFullSnapshots', () => { const viewStartClock = { relative: 1, timeStamp: 1 as TimeStamp } let lifeCycle: LifeCycle - let fullSnapshotCallback: jasmine.Spy<(records: BrowserRecord[]) => void> + let fullSnapshotPendingCallback: jasmine.Spy<() => void> + let fullSnapshotReadyCallback: jasmine.Spy<(records: BrowserRecord[]) => void> beforeEach(() => { if (isIE()) { @@ -18,35 +21,64 @@ describe('startFullSnapshots', () => { } lifeCycle = new LifeCycle() - fullSnapshotCallback = jasmine.createSpy() + mockExperimentalFeatures([ExperimentalFeature.ASYNC_FULL_SNAPSHOT]) + fullSnapshotPendingCallback = jasmine.createSpy('fullSnapshotPendingCallback') + fullSnapshotReadyCallback = jasmine.createSpy('fullSnapshotReadyCallback') + startFullSnapshots( createElementsScrollPositions(), {} as ShadowRootsController, lifeCycle, {} as RumConfiguration, noop, - fullSnapshotCallback + fullSnapshotPendingCallback, + fullSnapshotReadyCallback ) }) it('takes a full snapshot when startFullSnapshots is called', () => { - expect(fullSnapshotCallback).toHaveBeenCalledTimes(1) + expect(fullSnapshotReadyCallback).toHaveBeenCalledTimes(1) }) it('takes a full snapshot when the view changes', () => { + const { triggerIdleCallbacks } = mockRequestIdleCallback() + + lifeCycle.notify(LifeCycleEventType.VIEW_CREATED, { + startClocks: viewStartClock, + } as Partial as any) + + triggerIdleCallbacks() + + expect(fullSnapshotPendingCallback).toHaveBeenCalledTimes(1) + expect(fullSnapshotReadyCallback).toHaveBeenCalledTimes(2) + }) + + it('cancels the full snapshot if another view is created before it can it happens', () => { + const { triggerIdleCallbacks } = mockRequestIdleCallback() + + lifeCycle.notify(LifeCycleEventType.VIEW_CREATED, { + startClocks: viewStartClock, + } as Partial as any) + lifeCycle.notify(LifeCycleEventType.VIEW_CREATED, { startClocks: viewStartClock, } as Partial as any) - expect(fullSnapshotCallback).toHaveBeenCalledTimes(2) + triggerIdleCallbacks() + expect(fullSnapshotPendingCallback).toHaveBeenCalledTimes(2) + expect(fullSnapshotReadyCallback).toHaveBeenCalledTimes(2) }) it('full snapshot related records should have the view change date', () => { + const { triggerIdleCallbacks } = mockRequestIdleCallback() + lifeCycle.notify(LifeCycleEventType.VIEW_CREATED, { startClocks: viewStartClock, } as Partial as any) - const records = fullSnapshotCallback.calls.mostRecent().args[0] + triggerIdleCallbacks() + + const records = fullSnapshotReadyCallback.calls.mostRecent().args[0] expect(records[0].timestamp).toEqual(1) expect(records[1].timestamp).toEqual(1) expect(records[2].timestamp).toEqual(1) diff --git a/packages/rum/src/domain/record/startFullSnapshots.ts b/packages/rum/src/domain/record/startFullSnapshots.ts index b7e409cc75..8ca054df9d 100644 --- a/packages/rum/src/domain/record/startFullSnapshots.ts +++ b/packages/rum/src/domain/record/startFullSnapshots.ts @@ -1,6 +1,7 @@ import { LifeCycleEventType, getScrollX, getScrollY, getViewportDimension } from '@datadog/browser-rum-core' import type { RumConfiguration, LifeCycle } from '@datadog/browser-rum-core' -import { timeStampNow } from '@datadog/browser-core' +import { timeStampNow, isExperimentalFeatureEnabled, ExperimentalFeature } from '@datadog/browser-core' +import { requestIdleCallback } from '../../browser/requestIdleCallback' import type { BrowserRecord } from '../../types' import { RecordType } from '../../types' import type { ElementsScrollPositions } from './elementsScrollPositions' @@ -14,7 +15,8 @@ export function startFullSnapshots( lifeCycle: LifeCycle, configuration: RumConfiguration, flushMutations: () => void, - fullSnapshotCallback: (records: BrowserRecord[]) => void + fullSnapshotPendingCallback: () => void, + fullSnapshotReadyCallback: (records: BrowserRecord[]) => void ) { const takeFullSnapshot = ( timestamp = timeStampNow(), @@ -65,20 +67,37 @@ export function startFullSnapshots( return records } - fullSnapshotCallback(takeFullSnapshot()) + fullSnapshotReadyCallback(takeFullSnapshot()) + let cancelIdleCallback: (() => void) | undefined const { unsubscribe } = lifeCycle.subscribe(LifeCycleEventType.VIEW_CREATED, (view) => { flushMutations() - fullSnapshotCallback( - takeFullSnapshot(view.startClocks.timeStamp, { - shadowRootsController, - status: SerializationContextStatus.SUBSEQUENT_FULL_SNAPSHOT, - elementsScrollPositions, - }) - ) + function takeSubsequentFullSnapshot() { + flushMutations() + fullSnapshotReadyCallback( + takeFullSnapshot(view.startClocks.timeStamp, { + shadowRootsController, + status: SerializationContextStatus.SUBSEQUENT_FULL_SNAPSHOT, + elementsScrollPositions, + }) + ) + } + + if (isExperimentalFeatureEnabled(ExperimentalFeature.ASYNC_FULL_SNAPSHOT)) { + if (cancelIdleCallback) { + cancelIdleCallback() + } + fullSnapshotPendingCallback() + cancelIdleCallback = requestIdleCallback(takeSubsequentFullSnapshot) + } else { + takeSubsequentFullSnapshot() + } }) return { - stop: unsubscribe, + stop: () => { + unsubscribe() + cancelIdleCallback?.() + }, } }