From eebddffc032de5a1ed3cc4805c69c395134558d1 Mon Sep 17 00:00:00 2001 From: "roman.gaignault" Date: Tue, 23 Jul 2024 13:58:06 +0200 Subject: [PATCH 1/4] On view change, take the full snapshot asynchronously --- .../core/src/tools/experimentalFeatures.ts | 1 + .../test/emulate/mockRequestIdleCallback.ts | 39 +++++ packages/core/test/index.ts | 1 + .../src/browser/requestIdleCallBack.spec.ts | 33 +++++ .../rum/src/browser/requestIdleCallback.ts | 19 +++ packages/rum/src/domain/record/index.ts | 1 + .../rum/src/domain/record/mutationBatch.ts | 20 +-- packages/rum/src/domain/record/record.spec.ts | 24 +++- packages/rum/src/domain/record/record.ts | 20 ++- .../domain/record/startFullSnapshots.spec.ts | 134 ++++++++++++------ .../src/domain/record/startFullSnapshots.ts | 36 +++-- 11 files changed, 250 insertions(+), 78 deletions(-) create mode 100644 packages/core/test/emulate/mockRequestIdleCallback.ts create mode 100644 packages/rum/src/browser/requestIdleCallBack.spec.ts create mode 100644 packages/rum/src/browser/requestIdleCallback.ts 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..5a3dd68ca6 --- /dev/null +++ b/packages/core/test/emulate/mockRequestIdleCallback.ts @@ -0,0 +1,39 @@ +import { registerCleanupTask } from '../registerCleanupTask' + +let requestIdleCallbackSpy: jasmine.Spy +let cancelIdleCallbackSpy: jasmine.Spy + +export function mockRequestIdleCallback() { + const callbacks = new Map void>() + + function addCallback(callback: (...params: any[]) => any) { + const id = Math.random() + 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..0691132aaa --- /dev/null +++ b/packages/rum/src/browser/requestIdleCallBack.spec.ts @@ -0,0 +1,33 @@ +import { requestIdleCallback } from './requestIdleCallback' + +describe('requestIdleCallback', () => { + let requestAnimationFrameSpy: jasmine.Spy + let cancelAnimationFrameSpy: jasmine.Spy + let callback: jasmine.Spy + const originalRequestIdleCallback = window.requestIdleCallback + const originalCancelIdleCallback = window.cancelIdleCallback + + beforeEach(() => { + requestAnimationFrameSpy = spyOn(window, 'requestAnimationFrame').and.callFake((cb) => { + cb(0) + return 123 + }) + cancelAnimationFrameSpy = spyOn(window, 'cancelAnimationFrame') + callback = jasmine.createSpy('callback') + }) + + afterEach(() => { + window.requestIdleCallback = originalRequestIdleCallback + window.cancelIdleCallback = originalCancelIdleCallback + }) + + it('should use requestAnimationFrame when requestIdleCallback is not defined', () => { + window.requestIdleCallback = undefined as any + window.cancelIdleCallback = undefined as any + + const cancel = requestIdleCallback(callback) + expect(requestAnimationFrameSpy).toHaveBeenCalled() + cancel() + expect(cancelAnimationFrameSpy).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..e5aaad68d7 100644 --- a/packages/rum/src/domain/record/startFullSnapshots.spec.ts +++ b/packages/rum/src/domain/record/startFullSnapshots.spec.ts @@ -1,54 +1,100 @@ -import type { RumConfiguration, ViewCreatedEvent } from '@datadog/browser-rum-core' -import { LifeCycle, LifeCycleEventType } from '@datadog/browser-rum-core' -import type { TimeStamp } from '@datadog/browser-core' -import { isIE, noop } from '@datadog/browser-core' +import { LifeCycleEventType, getScrollX, getScrollY, getViewportDimension } from '@datadog/browser-rum-core' +import type { RumConfiguration, LifeCycle } from '@datadog/browser-rum-core' +import { timeStampNow, isExperimentalFeatureEnabled, ExperimentalFeature } from '@datadog/browser-core' +import { requestIdleCallback } from '../../browser/requestIdleCallback' import type { BrowserRecord } from '../../types' -import { startFullSnapshots } from './startFullSnapshots' -import { createElementsScrollPositions } from './elementsScrollPositions' +import { RecordType } from '../../types' +import type { ElementsScrollPositions } from './elementsScrollPositions' import type { ShadowRootsController } from './shadowRootsController' +import { SerializationContextStatus, serializeDocument } from './serialization' +import { getVisualViewport } from './viewports' -describe('startFullSnapshots', () => { - const viewStartClock = { relative: 1, timeStamp: 1 as TimeStamp } - let lifeCycle: LifeCycle - let fullSnapshotCallback: jasmine.Spy<(records: BrowserRecord[]) => void> - - beforeEach(() => { - if (isIE()) { - pending('IE not supported') +export function startFullSnapshots( + elementsScrollPositions: ElementsScrollPositions, + shadowRootsController: ShadowRootsController, + lifeCycle: LifeCycle, + configuration: RumConfiguration, + flushMutations: () => void, + fullSnapshotPendingCallback: () => void, + fullSnapshotReadyCallback: (records: BrowserRecord[]) => void +) { + const takeFullSnapshot = ( + timestamp = timeStampNow(), + serializationContext = { + status: SerializationContextStatus.INITIAL_FULL_SNAPSHOT, + elementsScrollPositions, + shadowRootsController, } + ) => { + const { width, height } = getViewportDimension() + const records: BrowserRecord[] = [ + { + data: { + height, + href: window.location.href, + width, + }, + type: RecordType.Meta, + timestamp, + }, + { + data: { + has_focus: document.hasFocus(), + }, + type: RecordType.Focus, + timestamp, + }, + { + data: { + node: serializeDocument(document, configuration, serializationContext), + initialOffset: { + left: getScrollX(), + top: getScrollY(), + }, + }, + type: RecordType.FullSnapshot, + timestamp, + }, + ] - lifeCycle = new LifeCycle() - fullSnapshotCallback = jasmine.createSpy() - startFullSnapshots( - createElementsScrollPositions(), - {} as ShadowRootsController, - lifeCycle, - {} as RumConfiguration, - noop, - fullSnapshotCallback - ) - }) + if (window.visualViewport) { + records.push({ + data: getVisualViewport(window.visualViewport), + type: RecordType.VisualViewport, + timestamp, + }) + } + return records + } - it('takes a full snapshot when startFullSnapshots is called', () => { - expect(fullSnapshotCallback).toHaveBeenCalledTimes(1) - }) + fullSnapshotReadyCallback(takeFullSnapshot()) - it('takes a full snapshot when the view changes', () => { - lifeCycle.notify(LifeCycleEventType.VIEW_CREATED, { - startClocks: viewStartClock, - } as Partial as any) + let cancelIdleCallback: (() => void) | undefined + const { unsubscribe } = lifeCycle.subscribe(LifeCycleEventType.VIEW_CREATED, (view) => { + flushMutations() + function takeSubsequentFullSnapshot() { + flushMutations() + fullSnapshotReadyCallback( + takeFullSnapshot(view.startClocks.timeStamp, { + shadowRootsController, + status: SerializationContextStatus.SUBSEQUENT_FULL_SNAPSHOT, + elementsScrollPositions, + }) + ) + } - expect(fullSnapshotCallback).toHaveBeenCalledTimes(2) + if (isExperimentalFeatureEnabled(ExperimentalFeature.ASYNC_FULL_SNAPSHOT)) { + if (cancelIdleCallback) { + cancelIdleCallback() + } + fullSnapshotPendingCallback() + cancelIdleCallback = requestIdleCallback(takeSubsequentFullSnapshot) + } else { + takeSubsequentFullSnapshot() + } }) - it('full snapshot related records should have the view change date', () => { - lifeCycle.notify(LifeCycleEventType.VIEW_CREATED, { - startClocks: viewStartClock, - } as Partial as any) - - const records = fullSnapshotCallback.calls.mostRecent().args[0] - expect(records[0].timestamp).toEqual(1) - expect(records[1].timestamp).toEqual(1) - expect(records[2].timestamp).toEqual(1) - }) -}) + return { + stop: unsubscribe, + } +} diff --git a/packages/rum/src/domain/record/startFullSnapshots.ts b/packages/rum/src/domain/record/startFullSnapshots.ts index b7e409cc75..e5aaad68d7 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,17 +67,31 @@ 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 { From 479736c96056d81feea75bb9ed094ce6f4906c8c Mon Sep 17 00:00:00 2001 From: "roman.gaignault" Date: Tue, 23 Jul 2024 14:04:08 +0200 Subject: [PATCH 2/4] startfullsnapshot unit test --- .../domain/record/startFullSnapshots.spec.ts | 174 ++++++++---------- 1 file changed, 80 insertions(+), 94 deletions(-) diff --git a/packages/rum/src/domain/record/startFullSnapshots.spec.ts b/packages/rum/src/domain/record/startFullSnapshots.spec.ts index e5aaad68d7..66e37f90cc 100644 --- a/packages/rum/src/domain/record/startFullSnapshots.spec.ts +++ b/packages/rum/src/domain/record/startFullSnapshots.spec.ts @@ -1,100 +1,86 @@ -import { LifeCycleEventType, getScrollX, getScrollY, getViewportDimension } from '@datadog/browser-rum-core' -import type { RumConfiguration, LifeCycle } from '@datadog/browser-rum-core' -import { timeStampNow, isExperimentalFeatureEnabled, ExperimentalFeature } from '@datadog/browser-core' -import { requestIdleCallback } from '../../browser/requestIdleCallback' +import type { RumConfiguration, ViewCreatedEvent } from '@datadog/browser-rum-core' +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 { RecordType } from '../../types' -import type { ElementsScrollPositions } from './elementsScrollPositions' +import { ExperimentalFeature } from '../../../../core/src/tools/experimentalFeatures' +import { startFullSnapshots } from './startFullSnapshots' +import { createElementsScrollPositions } from './elementsScrollPositions' import type { ShadowRootsController } from './shadowRootsController' -import { SerializationContextStatus, serializeDocument } from './serialization' -import { getVisualViewport } from './viewports' - -export function startFullSnapshots( - elementsScrollPositions: ElementsScrollPositions, - shadowRootsController: ShadowRootsController, - lifeCycle: LifeCycle, - configuration: RumConfiguration, - flushMutations: () => void, - fullSnapshotPendingCallback: () => void, - fullSnapshotReadyCallback: (records: BrowserRecord[]) => void -) { - const takeFullSnapshot = ( - timestamp = timeStampNow(), - serializationContext = { - status: SerializationContextStatus.INITIAL_FULL_SNAPSHOT, - elementsScrollPositions, - shadowRootsController, - } - ) => { - const { width, height } = getViewportDimension() - const records: BrowserRecord[] = [ - { - data: { - height, - href: window.location.href, - width, - }, - type: RecordType.Meta, - timestamp, - }, - { - data: { - has_focus: document.hasFocus(), - }, - type: RecordType.Focus, - timestamp, - }, - { - data: { - node: serializeDocument(document, configuration, serializationContext), - initialOffset: { - left: getScrollX(), - top: getScrollY(), - }, - }, - type: RecordType.FullSnapshot, - timestamp, - }, - ] - - if (window.visualViewport) { - records.push({ - data: getVisualViewport(window.visualViewport), - type: RecordType.VisualViewport, - timestamp, - }) - } - return records - } - - fullSnapshotReadyCallback(takeFullSnapshot()) - - let cancelIdleCallback: (() => void) | undefined - const { unsubscribe } = lifeCycle.subscribe(LifeCycleEventType.VIEW_CREATED, (view) => { - flushMutations() - 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() +describe('startFullSnapshots', () => { + const viewStartClock = { relative: 1, timeStamp: 1 as TimeStamp } + let lifeCycle: LifeCycle + let fullSnapshotPendingCallback: jasmine.Spy<() => void> + let fullSnapshotReadyCallback: jasmine.Spy<(records: BrowserRecord[]) => void> + + beforeEach(() => { + if (isIE()) { + pending('IE not supported') } + + lifeCycle = new LifeCycle() + mockExperimentalFeatures([ExperimentalFeature.ASYNC_FULL_SNAPSHOT]) + fullSnapshotPendingCallback = jasmine.createSpy('fullSnapshotPendingCallback') + fullSnapshotReadyCallback = jasmine.createSpy('fullSnapshotReadyCallback') + + startFullSnapshots( + createElementsScrollPositions(), + {} as ShadowRootsController, + lifeCycle, + {} as RumConfiguration, + noop, + fullSnapshotPendingCallback, + fullSnapshotReadyCallback + ) + }) + + it('takes a full snapshot when startFullSnapshots is called', () => { + 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) }) - return { - stop: unsubscribe, - } -} + 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) + + 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) + + 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) + }) +}) From 0e188c041f480640b31fa141b04bc186525b3f5f Mon Sep 17 00:00:00 2001 From: "roman.gaignault" Date: Wed, 24 Jul 2024 00:34:37 +0200 Subject: [PATCH 3/4] set a counter for id mockrequestIdleCallBack add CancelIdleCallback to return --- packages/core/test/emulate/mockRequestIdleCallback.ts | 4 +++- packages/rum/src/domain/record/startFullSnapshots.ts | 5 ++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/core/test/emulate/mockRequestIdleCallback.ts b/packages/core/test/emulate/mockRequestIdleCallback.ts index 5a3dd68ca6..d0f8e7ce8d 100644 --- a/packages/core/test/emulate/mockRequestIdleCallback.ts +++ b/packages/core/test/emulate/mockRequestIdleCallback.ts @@ -6,8 +6,10 @@ let cancelIdleCallbackSpy: jasmine.Spy export function mockRequestIdleCallback() { const callbacks = new Map void>() + let idCounter = 0 + function addCallback(callback: (...params: any[]) => any) { - const id = Math.random() + const id = ++idCounter callbacks.set(id, callback) return id } diff --git a/packages/rum/src/domain/record/startFullSnapshots.ts b/packages/rum/src/domain/record/startFullSnapshots.ts index e5aaad68d7..8ca054df9d 100644 --- a/packages/rum/src/domain/record/startFullSnapshots.ts +++ b/packages/rum/src/domain/record/startFullSnapshots.ts @@ -95,6 +95,9 @@ export function startFullSnapshots( }) return { - stop: unsubscribe, + stop: () => { + unsubscribe() + cancelIdleCallback?.() + }, } } From fd4b76cb33024f8629d429907df8448061ff0d27 Mon Sep 17 00:00:00 2001 From: "roman.gaignault" Date: Wed, 24 Jul 2024 14:36:55 +0200 Subject: [PATCH 4/4] update requestIdleCallBack unit test --- .../src/browser/requestIdleCallBack.spec.ts | 45 ++++++++++++------- 1 file changed, 30 insertions(+), 15 deletions(-) diff --git a/packages/rum/src/browser/requestIdleCallBack.spec.ts b/packages/rum/src/browser/requestIdleCallBack.spec.ts index 0691132aaa..015067a044 100644 --- a/packages/rum/src/browser/requestIdleCallBack.spec.ts +++ b/packages/rum/src/browser/requestIdleCallBack.spec.ts @@ -1,33 +1,48 @@ import { requestIdleCallback } from './requestIdleCallback' describe('requestIdleCallback', () => { - let requestAnimationFrameSpy: jasmine.Spy - let cancelAnimationFrameSpy: jasmine.Spy let callback: jasmine.Spy const originalRequestIdleCallback = window.requestIdleCallback - const originalCancelIdleCallback = window.cancelIdleCallback beforeEach(() => { - requestAnimationFrameSpy = spyOn(window, 'requestAnimationFrame').and.callFake((cb) => { - cb(0) - return 123 - }) - cancelAnimationFrameSpy = spyOn(window, 'cancelAnimationFrame') callback = jasmine.createSpy('callback') }) afterEach(() => { - window.requestIdleCallback = originalRequestIdleCallback - window.cancelIdleCallback = originalCancelIdleCallback + if (originalRequestIdleCallback) { + window.requestIdleCallback = originalRequestIdleCallback + } }) - it('should use requestAnimationFrame when requestIdleCallback is not defined', () => { - window.requestIdleCallback = undefined as any - window.cancelIdleCallback = undefined as any + 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(requestAnimationFrameSpy).toHaveBeenCalled() + expect(window.requestAnimationFrame).toHaveBeenCalled() cancel() - expect(cancelAnimationFrameSpy).toHaveBeenCalledWith(123) + expect(window.cancelAnimationFrame).toHaveBeenCalledWith(123) }) })