diff --git a/packages/browser-rum/src/domain/record/canvas/canvasManager.spec.ts b/packages/browser-rum/src/domain/record/canvas/canvasManager.spec.ts new file mode 100644 index 0000000000..2d49271abd --- /dev/null +++ b/packages/browser-rum/src/domain/record/canvas/canvasManager.spec.ts @@ -0,0 +1,74 @@ +import { registerCleanupTask } from '@datadog/browser-core/test' +import { createCanvasManager } from './canvasManager' + +describe('CanvasManager', () => { + it('tracks whether a canvas is dirty', () => { + const canvasManager = createCanvasManager() + const canvas = appendCanvas() + + expect(canvasManager.isCanvasDirty(canvas)).toBeFalse() + + canvasManager.markCanvasDirty(canvas) + expect(canvasManager.isCanvasDirty(canvas)).toBeTrue() + + canvasManager.markCanvasClean(canvas) + expect(canvasManager.isCanvasDirty(canvas)).toBeFalse() + + canvasManager.markCanvasDirty(canvas) + expect(canvasManager.isCanvasDirty(canvas)).toBeTrue() + }) + + it('tracks canvases independently', () => { + const canvasManager = createCanvasManager() + const dirtyCanvas = appendCanvas() + const cleanCanvas = appendCanvas() + + canvasManager.markCanvasDirty(dirtyCanvas) + + expect(canvasManager.isCanvasDirty(dirtyCanvas)).toBeTrue() + expect(canvasManager.isCanvasDirty(cleanCanvas)).toBeFalse() + }) + + it('returns connected dirty canvases', () => { + const canvasManager = createCanvasManager() + const canvas = appendCanvas() + + canvasManager.markCanvasDirty(canvas) + + expect(canvasManager.getDirtyCanvases()).toEqual([canvas]) + + canvasManager.markCanvasClean(canvas) + expect(canvasManager.getDirtyCanvases()).toEqual([]) + }) + + it('does not retain detached canvases', () => { + const canvasManager = createCanvasManager() + const canvas = document.createElement('canvas') + + canvasManager.markCanvasDirty(canvas) + + expect(canvasManager.getDirtyCanvases()).toEqual([]) + + document.body.appendChild(canvas) + registerCleanupTask(() => canvas.remove()) + expect(canvasManager.getDirtyCanvases()).toEqual([]) + }) + + it('clears dirty canvases', () => { + const canvasManager = createCanvasManager() + const canvas = appendCanvas() + canvasManager.markCanvasDirty(canvas) + + canvasManager.clearDirtyCanvases() + + expect(canvasManager.getDirtyCanvases()).toEqual([]) + expect(canvasManager.isCanvasDirty(canvas)).toBeFalse() + }) +}) + +function appendCanvas(): HTMLCanvasElement { + const canvas = document.createElement('canvas') + document.body.appendChild(canvas) + registerCleanupTask(() => canvas.remove()) + return canvas +} diff --git a/packages/browser-rum/src/domain/record/canvas/canvasManager.ts b/packages/browser-rum/src/domain/record/canvas/canvasManager.ts new file mode 100644 index 0000000000..da21bd0c02 --- /dev/null +++ b/packages/browser-rum/src/domain/record/canvas/canvasManager.ts @@ -0,0 +1,35 @@ +export interface CanvasManager { + clearDirtyCanvases: () => void + getDirtyCanvases: () => HTMLCanvasElement[] + isCanvasDirty: (canvas: HTMLCanvasElement) => boolean + markCanvasClean: (canvas: HTMLCanvasElement) => void + markCanvasDirty: (canvas: HTMLCanvasElement) => void +} + +export function createCanvasManager(): CanvasManager { + const dirtyCanvases = new Set() + + return { + clearDirtyCanvases: () => dirtyCanvases.clear(), + getDirtyCanvases: () => { + const connectedCanvases: HTMLCanvasElement[] = [] + + dirtyCanvases.forEach((canvas) => { + if (canvas.isConnected) { + connectedCanvases.push(canvas) + } else { + dirtyCanvases.delete(canvas) + } + }) + + return connectedCanvases + }, + isCanvasDirty: (canvas) => dirtyCanvases.has(canvas), + markCanvasClean: (canvas) => dirtyCanvases.delete(canvas), + markCanvasDirty: (canvas) => { + if (canvas.isConnected) { + dirtyCanvases.add(canvas) + } + }, + } +} diff --git a/packages/browser-rum/src/domain/record/canvas/canvasUtils.spec.ts b/packages/browser-rum/src/domain/record/canvas/canvasUtils.spec.ts new file mode 100644 index 0000000000..e3b8635ce1 --- /dev/null +++ b/packages/browser-rum/src/domain/record/canvas/canvasUtils.spec.ts @@ -0,0 +1,15 @@ +import { isCanvasElement, isCanvasSizeAttribute } from './canvasUtils' + +describe('canvasUtils', () => { + it('identifies only canvas elements', () => { + expect(isCanvasElement(document.createElement('canvas'))).toBeTrue() + expect(isCanvasElement(document.createElement('div'))).toBeFalse() + expect(isCanvasElement(document.createTextNode('canvas'))).toBeFalse() + }) + + it('identifies canvas size attributes', () => { + expect(isCanvasSizeAttribute('width')).toBeTrue() + expect(isCanvasSizeAttribute('HEIGHT')).toBeTrue() + expect(isCanvasSizeAttribute('class')).toBeFalse() + }) +}) diff --git a/packages/browser-rum/src/domain/record/canvas/canvasUtils.ts b/packages/browser-rum/src/domain/record/canvas/canvasUtils.ts new file mode 100644 index 0000000000..d44b8e15d3 --- /dev/null +++ b/packages/browser-rum/src/domain/record/canvas/canvasUtils.ts @@ -0,0 +1,11 @@ +import { isElementNode } from '@datadog/browser-rum-core' + +const CANVAS_SIZE_ATTRIBUTES = ['width', 'height'] + +export function isCanvasElement(node: Node): node is HTMLCanvasElement { + return isElementNode(node) && node.tagName === 'CANVAS' +} + +export function isCanvasSizeAttribute(attributeName: string): boolean { + return CANVAS_SIZE_ATTRIBUTES.includes(attributeName.toLowerCase()) +} diff --git a/packages/browser-rum/src/domain/record/internalApi.ts b/packages/browser-rum/src/domain/record/internalApi.ts index a62bdde92e..ced9abd5bb 100644 --- a/packages/browser-rum/src/domain/record/internalApi.ts +++ b/packages/browser-rum/src/domain/record/internalApi.ts @@ -10,6 +10,7 @@ import { createElementsScrollPositions } from './elementsScrollPositions' import type { EmitRecordCallback } from './record.types' import type { SerializationTransaction } from './serialization' import { createRootInsertionCursor, SerializationKind, serializeInTransaction, serializeNode } from './serialization' +import { createCanvasManager } from './canvas/canvasManager' /** * Take a full snapshot of the document, generating the same records that the browser SDK @@ -74,6 +75,7 @@ export function takeNodeSnapshot( function createTemporaryRecordingScope(configuration?: Partial): RecordingScope { return createRecordingScope( + createCanvasManager(), { defaultPrivacyLevel: NodePrivacyLevel.ALLOW, ...configuration, diff --git a/packages/browser-rum/src/domain/record/record.spec.ts b/packages/browser-rum/src/domain/record/record.spec.ts index 3c30e044ba..8fc38fc4a9 100644 --- a/packages/browser-rum/src/domain/record/record.spec.ts +++ b/packages/browser-rum/src/domain/record/record.spec.ts @@ -73,6 +73,38 @@ describe('record', () => { ]) }) + describe('canvas mutation tracking', () => { + it('instruments canvas drawing when canvas recording is enabled', () => { + const originalFillRect = Object.getOwnPropertyDescriptor(CanvasRenderingContext2D.prototype, 'fillRect')!.value + + startRecording({ sessionReplayCanvasRecording: { enable: true, maxFramesPerSecond: 1 } }) + + expect(Object.getOwnPropertyDescriptor(CanvasRenderingContext2D.prototype, 'fillRect')!.value).not.toBe( + originalFillRect + ) + }) + + it('does not instrument canvas drawing when canvas recording is disabled', () => { + const originalFillRect = Object.getOwnPropertyDescriptor(CanvasRenderingContext2D.prototype, 'fillRect')!.value + + startRecording() + + expect(Object.getOwnPropertyDescriptor(CanvasRenderingContext2D.prototype, 'fillRect')!.value).toBe( + originalFillRect + ) + }) + + it('does not instrument canvas drawing when the maximum frame rate is zero', () => { + const originalFillRect = Object.getOwnPropertyDescriptor(CanvasRenderingContext2D.prototype, 'fillRect')!.value + + startRecording({ sessionReplayCanvasRecording: { enable: true, maxFramesPerSecond: 0 } }) + + expect(Object.getOwnPropertyDescriptor(CanvasRenderingContext2D.prototype, 'fillRect')!.value).toBe( + originalFillRect + ) + }) + }) + it('flushes pending mutation records before taking a full snapshot', async () => { startRecording() @@ -374,12 +406,12 @@ describe('record', () => { }) }) - function startRecording() { + function startRecording(configuration: Partial = {}) { lifeCycle = new LifeCycle() recordApi = record({ emitRecord: emitSpy, emitStats: noop, - configuration: { defaultPrivacyLevel: DefaultPrivacyLevel.ALLOW } as RumConfiguration, + configuration: { defaultPrivacyLevel: DefaultPrivacyLevel.ALLOW, ...configuration } as RumConfiguration, lifeCycle, viewHistory: { findView: () => ({ id: FAKE_VIEW_ID, startClocks: {} }), diff --git a/packages/browser-rum/src/domain/record/record.ts b/packages/browser-rum/src/domain/record/record.ts index f550a9128b..960eff80dc 100644 --- a/packages/browser-rum/src/domain/record/record.ts +++ b/packages/browser-rum/src/domain/record/record.ts @@ -15,6 +15,7 @@ import { trackViewEnd, trackViewportResize, trackVisualViewportResize, + trackCanvasContent, } from './trackers' import { createElementsScrollPositions } from './elementsScrollPositions' import type { ShadowRootsController } from './shadowRootsController' @@ -22,6 +23,7 @@ import { initShadowRootsController } from './shadowRootsController' import { startFullSnapshots } from './startFullSnapshots' import type { EmitRecordCallback, EmitStatsCallback } from './record.types' import { createRecordingScope } from './recordingScope' +import { createCanvasManager } from './canvas/canvasManager' export interface RecordOptions { emitRecord: EmitRecordCallback @@ -51,8 +53,14 @@ export function record(options: RecordOptions): RecordAPI { replayStats.addRecord(view.id) } + const canvasManager = createCanvasManager() const shadowRootsController = initShadowRootsController(processRecord, emitStats) - const scope = createRecordingScope(configuration, createElementsScrollPositions(), shadowRootsController) + const scope = createRecordingScope( + canvasManager, + configuration, + createElementsScrollPositions(), + shadowRootsController + ) const { stop: stopFullSnapshots } = startFullSnapshots(lifeCycle, processRecord, emitStats, flushMutations, scope) @@ -74,6 +82,7 @@ export function record(options: RecordOptions): RecordAPI { trackFocus(processRecord), trackVisualViewportResize(processRecord), trackViewEnd(lifeCycle, processRecord, flushMutations), + trackCanvasContent(scope), ] return { diff --git a/packages/browser-rum/src/domain/record/recordingScope.ts b/packages/browser-rum/src/domain/record/recordingScope.ts index 14cbce729b..4a479ec9bb 100644 --- a/packages/browser-rum/src/domain/record/recordingScope.ts +++ b/packages/browser-rum/src/domain/record/recordingScope.ts @@ -4,6 +4,7 @@ import type { ElementsScrollPositions } from './elementsScrollPositions' import { createEventIds, createNodeIds, createStringIds, createStyleSheetIds } from './itemIds' import type { EventIds, NodeIds, StringIds, StyleSheetIds } from './itemIds' import type { ShadowRootsController } from './shadowRootsController' +import type { CanvasManager } from './canvas/canvasManager' /** * State associated with a stream of session replay records. When a new stream of records @@ -14,6 +15,7 @@ import type { ShadowRootsController } from './shadowRootsController' export interface RecordingScope { resetIds(): void + canvasManager: CanvasManager configuration: RumConfiguration elementsScrollPositions: ElementsScrollPositions eventIds: EventIds @@ -24,6 +26,7 @@ export interface RecordingScope { } export function createRecordingScope( + canvasManager: CanvasManager, configuration: RumConfiguration, elementsScrollPositions: ElementsScrollPositions, shadowRootsController: ShadowRootsController @@ -41,6 +44,7 @@ export function createRecordingScope( scope.styleSheetIds.clear() }, + canvasManager, configuration, elementsScrollPositions, eventIds, diff --git a/packages/browser-rum/src/domain/record/serialization/serializeMutations.ts b/packages/browser-rum/src/domain/record/serialization/serializeMutations.ts index d430e14c33..8f7ea2ab70 100644 --- a/packages/browser-rum/src/domain/record/serialization/serializeMutations.ts +++ b/packages/browser-rum/src/domain/record/serialization/serializeMutations.ts @@ -12,6 +12,7 @@ import type { AttributeChange } from '../../../types' import type { RecordingScope } from '../recordingScope' import type { EmitRecordCallback, EmitStatsCallback } from '../record.types' import type { NodeId, NodeIds } from '../itemIds' +import { isCanvasElement, isCanvasSizeAttribute } from '../canvas/canvasUtils' import type { SerializationTransaction } from './serializationTransaction' import { SerializationKind, serializeInTransaction } from './serializationTransaction' import { serializeNode } from './serializeNode' @@ -111,6 +112,10 @@ function processRemovedNodes(nodes: Set, transaction: SerializationTransac } forNodeAndDescendants(node, (node: Node) => { + if (isCanvasElement(node)) { + transaction.scope.canvasManager.markCanvasClean(node) + } + if (isNodeShadowHost(node)) { transaction.scope.shadowRootsController.removeShadowRoot(node.shadowRoot) } @@ -257,6 +262,10 @@ function processAttributeMutations( continue // No change since the last snapshot. } + if (isCanvasElement(node) && isCanvasSizeAttribute(attributeName)) { + transaction.scope.canvasManager.markCanvasDirty(node) + } + if (attributeName === 'value') { const attributeValue = getElementInputValue(node, privacyLevel) if (attributeValue !== undefined) { diff --git a/packages/browser-rum/src/domain/record/serialization/serializeNode.node.spec.ts b/packages/browser-rum/src/domain/record/serialization/serializeNode.node.spec.ts index 009aa497ea..650abf616c 100644 --- a/packages/browser-rum/src/domain/record/serialization/serializeNode.node.spec.ts +++ b/packages/browser-rum/src/domain/record/serialization/serializeNode.node.spec.ts @@ -5,6 +5,7 @@ import { ChangeType, PlaybackState } from '../../../types' import type { RecordingScope } from '../recordingScope' import type { ScrollPositions } from '../elementsScrollPositions' import { serializeHtml } from '../test/serializeHtml.specHelper' +import { createRecordingScopeForTesting } from '../test/recordingScope.specHelper' import { SerializationKind } from './serializationTransaction' describe('serializeNode for DOM nodes', () => { @@ -118,6 +119,19 @@ describe('serializeNode for DOM nodes', () => { ], ]) }) + + it('marks nested canvases dirty when their subtree is serialized', async () => { + const scope = createRecordingScopeForTesting() + + await serializeHtml('
', { + scope, + after: (target) => { + expect(scope.canvasManager.getDirtyCanvases()).toEqual( + Array.from((target as Element).querySelectorAll('canvas')) + ) + }, + }) + }) }) describe('for SVG elements', () => { diff --git a/packages/browser-rum/src/domain/record/serialization/serializeNode.ts b/packages/browser-rum/src/domain/record/serialization/serializeNode.ts index 66cd43c4df..c0d91c82bf 100644 --- a/packages/browser-rum/src/domain/record/serialization/serializeNode.ts +++ b/packages/browser-rum/src/domain/record/serialization/serializeNode.ts @@ -13,6 +13,7 @@ import { } from '@datadog/browser-rum-core' import { MediaInteractionType } from '../../../types' import type { NodeId, StyleSheetId } from '../itemIds' +import { isCanvasElement } from '../canvas/canvasUtils' import type { InsertionCursor } from './insertionCursor' import type { SerializationTransaction } from './serializationTransaction' import { serializeDOMAttributes, serializeVirtualAttributes } from './serializeAttributes' @@ -138,6 +139,10 @@ function serializeElementNode( const domAttributes = Object.entries(serializeDOMAttributes(element, privacyLevel, transaction)) transaction.addNode(insertionPoint, encodedElementName(element), ...domAttributes) + if (isCanvasElement(element)) { + transaction.scope.canvasManager.markCanvasDirty(element) + } + const { _cssText: cssText, rr_mediaState: mediaState, diff --git a/packages/browser-rum/src/domain/record/test/recordingScope.specHelper.ts b/packages/browser-rum/src/domain/record/test/recordingScope.specHelper.ts index 91b39c9c2c..13e5c34974 100644 --- a/packages/browser-rum/src/domain/record/test/recordingScope.specHelper.ts +++ b/packages/browser-rum/src/domain/record/test/recordingScope.specHelper.ts @@ -3,7 +3,9 @@ import { noop } from '@datadog/browser-core' import { createElementsScrollPositions } from '../elementsScrollPositions' import type { RecordingScope } from '../recordingScope' import { createRecordingScope } from '../recordingScope' +import type { CanvasManager } from '../canvas/canvasManager' import type { AddShadowRootCallBack, RemoveShadowRootCallBack } from '../shadowRootsController' +import { createCanvasManager } from '../canvas/canvasManager' import { DEFAULT_CONFIGURATION } from './rumConfiguration.specHelper' import { DEFAULT_SHADOW_ROOT_CONTROLLER } from './shadowRootsController.specHelper' @@ -11,12 +13,15 @@ export function createRecordingScopeForTesting({ configuration, addShadowRoot, removeShadowRoot, + canvasManager = createCanvasManager(), }: { configuration?: Partial addShadowRoot?: AddShadowRootCallBack removeShadowRoot?: RemoveShadowRootCallBack + canvasManager?: CanvasManager } = {}): RecordingScope { return createRecordingScope( + canvasManager, { ...DEFAULT_CONFIGURATION, ...configuration, diff --git a/packages/browser-rum/src/domain/record/trackers/index.ts b/packages/browser-rum/src/domain/record/trackers/index.ts index 7ccc9fcbab..e2e4786cd8 100644 --- a/packages/browser-rum/src/domain/record/trackers/index.ts +++ b/packages/browser-rum/src/domain/record/trackers/index.ts @@ -8,4 +8,5 @@ export { trackFocus } from './trackFocus' export { trackViewEnd } from './trackViewEnd' export { trackInput } from './trackInput' export { trackMutation } from './trackMutation' +export { trackCanvasContent } from './trackCanvasContent' export type { Tracker } from './tracker.types' diff --git a/packages/browser-rum/src/domain/record/trackers/trackCanvasContent.spec.ts b/packages/browser-rum/src/domain/record/trackers/trackCanvasContent.spec.ts new file mode 100644 index 0000000000..84d73786ff --- /dev/null +++ b/packages/browser-rum/src/domain/record/trackers/trackCanvasContent.spec.ts @@ -0,0 +1,116 @@ +import { registerCleanupTask } from '@datadog/browser-core/test' +import type { CanvasManager } from '../canvas/canvasManager' +import { createCanvasManager } from '../canvas/canvasManager' +import { createRecordingScopeForTesting } from '../test/recordingScope.specHelper' +import type { Tracker } from './tracker.types' +import { trackCanvasContent } from './trackCanvasContent' + +describe('trackCanvasContent', () => { + let canvas: HTMLCanvasElement + let context: CanvasRenderingContext2D + let markCanvasDirtySpy: jasmine.Spy<(canvas: HTMLCanvasElement) => void> + let canvasManager: CanvasManager + let tracker: Tracker | undefined + + beforeEach(() => { + canvas = document.createElement('canvas') + context = canvas.getContext('2d')! + markCanvasDirtySpy = jasmine.createSpy() + canvasManager = { ...createCanvasManager(), markCanvasDirty: markCanvasDirtySpy } + + registerCleanupTask(() => tracker?.stop()) + }) + + function startTracking(enable = true, maxFramesPerSecond = 1): Tracker { + const scope = createRecordingScopeForTesting({ + canvasManager, + configuration: { sessionReplayCanvasRecording: { enable, maxFramesPerSecond } }, + }) + scope.nodeIds.getOrInsert(canvas) + tracker = trackCanvasContent(scope) + return tracker + } + + it('marks the canvas dirty after drawing operations', () => { + startTracking() + const imageData = context.createImageData(1, 1) + const drawingOperations: Array<{ method: string; draw: () => void }> = [ + { method: 'clearRect', draw: () => context.clearRect(0, 0, 1, 1) }, + { method: 'fillRect', draw: () => context.fillRect(0, 0, 1, 1) }, + { method: 'strokeRect', draw: () => context.strokeRect(0, 0, 1, 1) }, + { method: 'fill', draw: () => context.fill() }, + { method: 'stroke', draw: () => context.stroke() }, + { method: 'fillText', draw: () => context.fillText('foo', 0, 0) }, + { method: 'strokeText', draw: () => context.strokeText('foo', 0, 0) }, + { method: 'drawImage', draw: () => context.drawImage(canvas, 0, 0) }, + { method: 'putImageData', draw: () => context.putImageData(imageData, 0, 0) }, + { method: 'drawFocusIfNeeded', draw: () => context.drawFocusIfNeeded(canvas) }, + { method: 'reset', draw: () => context.reset() }, + ] + + // Skip unsuported APIs per browser version. + drawingOperations + .filter( + ({ method }) => + typeof CanvasRenderingContext2D.prototype[method as keyof CanvasRenderingContext2D] === 'function' + ) + .forEach(({ draw }) => { + markCanvasDirtySpy.calls.reset() + draw() + expect(markCanvasDirtySpy).toHaveBeenCalledOnceWith(canvas) + }) + }) + + it('does not mark the canvas dirty for non-drawing operations', () => { + startTracking() + + context.beginPath() + context.moveTo(0, 0) + context.lineTo(1, 1) + + expect(markCanvasDirtySpy).not.toHaveBeenCalled() + }) + + it('does not mark an unserialized canvas dirty', () => { + const scope = createRecordingScopeForTesting({ + canvasManager, + configuration: { sessionReplayCanvasRecording: { enable: true, maxFramesPerSecond: 1 } }, + }) + tracker = trackCanvasContent(scope) + + context.fillRect(0, 0, 1, 1) + + expect(markCanvasDirtySpy).not.toHaveBeenCalled() + }) + + it('does not mark the canvas dirty when a drawing operation throws', () => { + startTracking() + + expect(() => context.putImageData(null as unknown as ImageData, 0, 0)).toThrow() + expect(markCanvasDirtySpy).not.toHaveBeenCalled() + }) + + it('restores the original behavior when stopped', () => { + startTracking().stop() + + context.fillRect(0, 0, 1, 1) + + expect(markCanvasDirtySpy).not.toHaveBeenCalled() + }) + + it('does not track canvas content when canvas recording is disabled', () => { + startTracking(false) + + context.fillRect(0, 0, 1, 1) + + expect(markCanvasDirtySpy).not.toHaveBeenCalled() + }) + + it('does not track canvas content when the maximum frame rate is zero', () => { + startTracking(true, 0) + + context.fillRect(0, 0, 1, 1) + + expect(markCanvasDirtySpy).not.toHaveBeenCalled() + }) +}) diff --git a/packages/browser-rum/src/domain/record/trackers/trackCanvasContent.ts b/packages/browser-rum/src/domain/record/trackers/trackCanvasContent.ts new file mode 100644 index 0000000000..822bd56555 --- /dev/null +++ b/packages/browser-rum/src/domain/record/trackers/trackCanvasContent.ts @@ -0,0 +1,47 @@ +import { instrumentMethod, noop } from '@datadog/browser-core' +import type { RecordingScope } from '../recordingScope' +import type { Tracker } from './tracker.types' + +export type MarkCanvasDirty = (canvas: HTMLCanvasElement) => void + +const CANVAS_2D_DRAWING_METHODS = [ + 'clearRect', + 'fillRect', + 'strokeRect', + 'fill', + 'stroke', + 'fillText', + 'strokeText', + 'drawImage', + 'putImageData', + 'drawFocusIfNeeded', + 'reset', +] as const + +export function trackCanvasContent(scope: RecordingScope): Tracker { + if ( + !scope.configuration.sessionReplayCanvasRecording?.enable || + scope.configuration.sessionReplayCanvasRecording.maxFramesPerSecond === 0 || + typeof CanvasRenderingContext2D === 'undefined' + ) { + return { stop: noop } + } + + const instrumentationStoppers: Tracker[] = [] + + CANVAS_2D_DRAWING_METHODS.forEach((method) => { + instrumentationStoppers.push( + instrumentMethod(CanvasRenderingContext2D.prototype, method, ({ target: context, onPostCall }) => { + onPostCall(() => { + if (scope.nodeIds.get(context.canvas) !== undefined) { + scope.canvasManager.markCanvasDirty(context.canvas) + } + }) + }) + ) + }) + + return { + stop: () => instrumentationStoppers.forEach((stopper) => stopper.stop()), + } +} diff --git a/packages/browser-rum/src/domain/record/trackers/trackMutation.spec.ts b/packages/browser-rum/src/domain/record/trackers/trackMutation.spec.ts index 0184f10218..a924c2abb5 100644 --- a/packages/browser-rum/src/domain/record/trackers/trackMutation.spec.ts +++ b/packages/browser-rum/src/domain/record/trackers/trackMutation.spec.ts @@ -10,6 +10,7 @@ import { import type { BrowserChangeRecord, BrowserFullSnapshotChangeRecord, BrowserRecord } from '../../../types' import { ChangeType } from '../../../types' import type { RecordingScope } from '../recordingScope' +import { createCanvasManager } from '../canvas/canvasManager' import type { AddShadowRootCallBack, RemoveShadowRootCallBack } from '../shadowRootsController' import type { ChangeDecoder, SerializationStats } from '../serialization' import { aggregateSerializationStats, createSerializationStats } from '../serialization' @@ -439,6 +440,25 @@ describe('trackMutation', () => { expect(removeShadowRootSpy.calls.argsFor(1)[0]).toBe(childShadowRoot) }) }) + + it('removes a canvas from dirty canvases when it is removed', async () => { + const canvasManager = createCanvasManager() + const scope = createRecordingScopeForTesting({ canvasManager }) + let canvas!: HTMLCanvasElement + + await recordMutationOf( + '', + (sandbox) => { + canvas = sandbox as HTMLCanvasElement + expect(canvasManager.isCanvasDirty(canvas)).toBeTrue() + + canvas.remove() + }, + { scope } + ) + + expect(canvasManager.isCanvasDirty(canvas)).toBeFalse() + }) }) describe('characterData mutations', () => { @@ -548,6 +568,42 @@ describe('trackMutation', () => { ) expect(mutation?.data).toEqual([[ChangeType.Attribute, [0, ['data-foo', '***']]]]) }) + + it('marks a canvas dirty when a size attribute changes', async () => { + const canvasManager = createCanvasManager() + const scope = createRecordingScopeForTesting({ canvasManager }) + let canvas!: HTMLCanvasElement + + await recordMutationOf( + '', + (sandbox) => { + canvas = sandbox as HTMLCanvasElement + canvasManager.markCanvasClean(canvas) + canvas.setAttribute('width', '101') + }, + { scope } + ) + + expect(canvasManager.isCanvasDirty(canvas)).toBeTrue() + }) + + it('does not mark a canvas dirty when an unrelated attribute changes', async () => { + const canvasManager = createCanvasManager() + const scope = createRecordingScopeForTesting({ canvasManager }) + let canvas!: HTMLCanvasElement + + await recordMutationOf( + '', + (sandbox) => { + canvas = sandbox as HTMLCanvasElement + canvasManager.markCanvasClean(canvas) + canvas.setAttribute('class', 'foo') + }, + { scope } + ) + + expect(canvasManager.isCanvasDirty(canvas)).toBeFalse() + }) }) describe('ignored nodes', () => {