-
Notifications
You must be signed in to change notification settings - Fork 190
⚗️ Add canvas change detection for Session Replay #4949
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
c0c0524
3b02647
b76c1ca
e013ae7
0e9b67a
dc6b3d5
b056d28
07aed32
b97a735
faf302e
aab401f
5b3a6ad
bb0700a
4c65728
7336322
fe5e50c
feca8f2
697ea42
71b0ba1
e9e087d
9413573
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<HTMLCanvasElement>() | ||
|
|
||
| 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) | ||
| } | ||
| }, | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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() | ||
| }) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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()) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<Node>, transaction: SerializationTransac | |
| } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please don't remove these lines; we should never be tracking any information about nodes that don't have node ids. You probably felt the need to do this because in |
||
|
|
||
| forNodeAndDescendants(node, (node: Node) => { | ||
| if (isCanvasElement(node)) { | ||
| transaction.scope.canvasManager.markCanvasClean(node) | ||
| } | ||
|
Comment on lines
+115
to
+117
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
issue: When a canvas is appended, drawn into, and removed before the mutation batch is processed, drawing adds it to Useful? React with 👍 / 👎.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think this is either stale or wrong; it looks to me like |
||
|
|
||
| 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) | ||
|
BeltranBulbarellaDD marked this conversation as resolved.
|
||
| } | ||
|
|
||
| if (attributeName === 'value') { | ||
| const attributeValue = getElementInputValue(node, privacyLevel) | ||
| if (attributeValue !== undefined) { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) | ||
| } | ||
|
Comment on lines
+142
to
+144
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
issue: When Useful? React with 👍 / 👎.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is on purpose. See |
||
|
|
||
| const { | ||
| _cssText: cssText, | ||
| rr_mediaState: mediaState, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
issue: When an enabled recording observes canvas churn (for example, charts that create, draw, and remove canvases), this
Setkeeps every removed dirty canvas and its backing bitmap reachable. Removal mutations never delete canvases, and no production code in this commit callsgetDirtyCanvases()orclearDirtyCanvases(), so the pruning inside the getter never runs and memory grows for the lifetime of the recording; detached canvases need weak bookkeeping or cleanup when they are removed.Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is accurate, though the headline ("avoid strongly retaining...") points in the wrong direction, I think. You need to iterate the contents of this set, it seems, so strongly retaining is necessary. However, you should have a way to remove canvases from the set of dirty canvas, and you should perform this removal in
trackMutation.tswhen canvas elements are removed from the DOM.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah I think I get what you mean but I think that's scope for the next PR. Something like this?