diff --git a/package.json b/package.json index 2322557a..81ab58b1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@highlight-run/rrweb", - "version": "1.1.6", + "version": "1.1.7", "description": "record and replay the web", "scripts": { "test": "npm run bundle:browser && cross-env TS_NODE_CACHE=false TS_NODE_FILES=true mocha -r ts-node/register -r ignore-styles -r jsdom-global/register test/**.test.ts", diff --git a/src/record/index.ts b/src/record/index.ts index bda9aaa3..a097b756 100644 --- a/src/record/index.ts +++ b/src/record/index.ts @@ -18,9 +18,11 @@ import { listenerHandler, mutationCallbackParam, scrollCallback, + canvasMutationParam, } from '../types'; import { IframeManager } from './iframe-manager'; import { ShadowDomManager } from './shadow-dom-manager'; +import { CanvasManager } from './observers/canvas/canvas-manager'; function wrapEvent(e: event): eventWithTime { return { @@ -180,11 +182,29 @@ function record( }, }), ); + const wrappedCanvasMutationEmit = (p: canvasMutationParam) => + wrappedEmit( + wrapEvent({ + type: EventType.IncrementalSnapshot, + data: { + source: IncrementalSource.CanvasMutation, + ...p, + }, + }), + ); const iframeManager = new IframeManager({ mutationCb: wrappedMutationEmit, }); + const canvasManager = new CanvasManager({ + recordCanvas, + mutationCb: wrappedCanvasMutationEmit, + win: window, + blockClass, + mirror, + }); + const shadowDomManager = new ShadowDomManager({ mutationCb: wrappedMutationEmit, scrollCb: wrappedScrollEmit, @@ -201,6 +221,7 @@ function record( sampling, slimDOMOptions, iframeManager, + canvasManager, enableStrictPrivacy, }, mirror, @@ -365,16 +386,7 @@ function record( }, }), ), - canvasMutationCb: (p) => - wrappedEmit( - wrapEvent({ - type: EventType.IncrementalSnapshot, - data: { - source: IncrementalSource.CanvasMutation, - ...p, - }, - }), - ), + canvasMutationCb: wrappedCanvasMutationEmit, fontCb: (p) => wrappedEmit( wrapEvent({ @@ -403,6 +415,7 @@ function record( mirror, iframeManager, shadowDomManager, + canvasManager, plugins: plugins?.map((p) => ({ observer: p.observer, diff --git a/src/record/mutation.ts b/src/record/mutation.ts index 925ff433..11daa85d 100644 --- a/src/record/mutation.ts +++ b/src/record/mutation.ts @@ -31,6 +31,7 @@ import { hasShadowRoot, } from '../utils'; import { IframeManager } from './iframe-manager'; +import { CanvasManager } from './observers/canvas/canvas-manager'; import { ShadowDomManager } from './shadow-dom-manager'; type DoubleLinkedListNode = { @@ -179,6 +180,7 @@ export default class MutationBuffer { private mirror: Mirror; private iframeManager: IframeManager; private shadowDomManager: ShadowDomManager; + private canvasManager: CanvasManager; public init( cb: mutationCallBack, @@ -196,6 +198,7 @@ export default class MutationBuffer { mirror: Mirror, iframeManager: IframeManager, shadowDomManager: ShadowDomManager, + canvasManager: CanvasManager, enableStrictPrivacy: boolean, ) { this.blockClass = blockClass; @@ -214,14 +217,17 @@ export default class MutationBuffer { this.mirror = mirror; this.iframeManager = iframeManager; this.shadowDomManager = shadowDomManager; + this.canvasManager = canvasManager; } public freeze() { this.frozen = true; + this.canvasManager.freeze(); } public unfreeze() { this.frozen = false; + this.canvasManager.unfreeze(); this.emit(); } @@ -231,16 +237,22 @@ export default class MutationBuffer { public lock() { this.locked = true; + this.canvasManager.lock(); } public unlock() { this.locked = false; + this.canvasManager.unlock(); this.emit(); } + public reset() { + this.canvasManager.reset(); + } + public processMutations = (mutations: mutationRecord[]) => { - mutations.forEach(this.processMutation); - this.emit(); + mutations.forEach(this.processMutation); // adds mutations to the buffer + this.emit(); // clears buffer if not locked/frozen }; public emit = () => { diff --git a/src/record/observer.ts b/src/record/observer.ts index 645f6cc6..b312f2fe 100644 --- a/src/record/observer.ts +++ b/src/record/observer.ts @@ -53,6 +53,7 @@ import { ShadowDomManager } from './shadow-dom-manager'; import initCanvasContextObserver from './observers/canvas/canvas'; import initCanvas2DMutationObserver from './observers/canvas/2d'; import initCanvasWebGLMutationObserver from './observers/canvas/webgl'; +import { CanvasManager } from './observers/canvas/canvas-manager'; type WindowWithStoredMutationObserver = IWindow & { __rrMutationObserver?: MutationObserver; @@ -105,6 +106,7 @@ export function initMutationObserver( mirror: Mirror, iframeManager: IframeManager, shadowDomManager: ShadowDomManager, + canvasManager: CanvasManager, rootEl: Node, enableStrictPrivacy: boolean, ): MutationObserver { @@ -127,6 +129,7 @@ export function initMutationObserver( mirror, iframeManager, shadowDomManager, + canvasManager, enableStrictPrivacy, ); let mutationObserverCtor = @@ -375,8 +378,14 @@ function initInputObserver( userTriggeredOnInput: boolean, ): listenerHandler { function eventHandler(event: Event) { - const target = getEventTarget(event); + let target = getEventTarget(event); const userTriggered = event.isTrusted; + /** + * If a site changes the value 'selected' of an option element, the value of its parent element, usually a select element, will be changed as well. + * We can treat this change as a value change of the select element the current target belongs to. + */ + if (target && (target as Element).tagName === 'OPTION') + target = (target as Element).parentElement; if ( !target || !(target as Element).tagName || @@ -467,6 +476,7 @@ function initInputObserver( [HTMLTextAreaElement.prototype, 'value'], // Some UI library use selectedIndex to set select value [HTMLSelectElement.prototype, 'selectedIndex'], + [HTMLOptionElement.prototype, 'selected'], ]; if (propertyDescriptor && propertyDescriptor.set) { handlers.push( @@ -690,56 +700,34 @@ function initMediaInteractionObserver( mediaInteractionCb: mediaInteractionCallback, blockClass: blockClass, mirror: Mirror, + sampling: SamplingStrategy, ): listenerHandler { - const handler = (type: MediaInteractions) => (event: Event) => { - const target = getEventTarget(event); - if (!target || isBlocked(target as Node, blockClass)) { - return; - } - mediaInteractionCb({ - type, - id: mirror.getId(target as INode), - currentTime: (target as HTMLMediaElement).currentTime, - }); - }; + const handler = (type: MediaInteractions) => + throttle((event: Event) => { + const target = getEventTarget(event); + if (!target || isBlocked(target as Node, blockClass)) { + return; + } + const { currentTime, volume, muted } = target as HTMLMediaElement; + mediaInteractionCb({ + type, + id: mirror.getId(target as INode), + currentTime, + volume, + muted, + }); + }, sampling.media || 500); const handlers = [ on('play', handler(MediaInteractions.Play)), on('pause', handler(MediaInteractions.Pause)), on('seeked', handler(MediaInteractions.Seeked)), + on('volumechange', handler(MediaInteractions.VolumeChange)), ]; return () => { handlers.forEach((h) => h()); }; } -function initCanvasMutationObserver( - cb: canvasMutationCallback, - win: IWindow, - blockClass: blockClass, - mirror: Mirror, -): listenerHandler { - const canvasContextReset = initCanvasContextObserver(win, blockClass); - const canvas2DReset = initCanvas2DMutationObserver( - cb, - win, - blockClass, - mirror, - ); - - const canvasWebGL1and2Reset = initCanvasWebGLMutationObserver( - cb, - win, - blockClass, - mirror, - ); - - return () => { - canvasContextReset(); - canvas2DReset(); - canvasWebGL1and2Reset(); - }; -} - function initFontObserver(cb: fontCallback, doc: Document): listenerHandler { const win = doc.defaultView as IWindow; if (!win) { @@ -900,6 +888,7 @@ export function initObservers( o.mirror, o.iframeManager, o.shadowDomManager, + o.canvasManager, o.doc, o.enableStrictPrivacy, ); @@ -939,6 +928,7 @@ export function initObservers( o.mediaInteractionCb, o.blockClass, o.mirror, + o.sampling, ); const styleSheetObserver = initStyleSheetObserver( @@ -951,14 +941,6 @@ export function initObservers( currentWindow, o.mirror, ); - const canvasMutationObserver = o.recordCanvas - ? initCanvasMutationObserver( - o.canvasMutationCb, - currentWindow, - o.blockClass, - o.mirror, - ) - : () => {}; const fontObserver = o.collectFonts ? initFontObserver(o.fontCb, o.doc) : () => {}; @@ -971,6 +953,7 @@ export function initObservers( } return () => { + mutationBuffers.forEach((b) => b.reset()); mutationObserver.disconnect(); mousemoveHandler(); mouseInteractionHandler(); @@ -980,7 +963,6 @@ export function initObservers( mediaInteractionHandler(); styleSheetObserver(); styleDeclarationObserver(); - canvasMutationObserver(); fontObserver(); pluginHandlers.forEach((h) => h()); }; diff --git a/src/record/observers/canvas/2d.ts b/src/record/observers/canvas/2d.ts index 613ec618..f0a46819 100644 --- a/src/record/observers/canvas/2d.ts +++ b/src/record/observers/canvas/2d.ts @@ -2,15 +2,14 @@ import { INode } from '../../../snapshot'; import { blockClass, CanvasContext, - canvasMutationCallback, + canvasManagerMutationCallback, IWindow, listenerHandler, Mirror, } from '../../../types'; import { hookSetter, isBlocked, patch } from '../../../utils'; - export default function initCanvas2DMutationObserver( - cb: canvasMutationCallback, + cb: canvasManagerMutationCallback, win: IWindow, blockClass: blockClass, mirror: Mirror, @@ -37,6 +36,8 @@ export default function initCanvas2DMutationObserver( ...args: Array ) { if (!isBlocked((this.canvas as unknown) as INode, blockClass)) { + // Using setTimeout as getImageData + JSON.stringify can be heavy + // and we'd rather not block the main thread setTimeout(() => { const recordArgs = [...args]; if (prop === 'drawImage') { @@ -56,8 +57,7 @@ export default function initCanvas2DMutationObserver( recordArgs[0] = JSON.stringify(pix); } } - cb({ - id: mirror.getId((this.canvas as unknown) as INode), + cb(this.canvas, { type: CanvasContext['2D'], property: prop, args: recordArgs, @@ -75,8 +75,7 @@ export default function initCanvas2DMutationObserver( prop, { set(v) { - cb({ - id: mirror.getId((this.canvas as unknown) as INode), + cb(this.canvas, { type: CanvasContext['2D'], property: prop, args: [v], diff --git a/src/record/observers/canvas/canvas-manager.ts b/src/record/observers/canvas/canvas-manager.ts new file mode 100644 index 00000000..5c40307f --- /dev/null +++ b/src/record/observers/canvas/canvas-manager.ts @@ -0,0 +1,154 @@ +import { INode } from '../../../snapshot'; +import { + blockClass, + canvasManagerMutationCallback, + canvasMutationCallback, + canvasMutationCommand, + canvasMutationWithType, + IWindow, + listenerHandler, + Mirror, +} from '../../../types'; +import initCanvas2DMutationObserver from './2d'; +import initCanvasContextObserver from './canvas'; +import initCanvasWebGLMutationObserver from './webgl'; + +export type RafStamps = { latestId: number; invokeId: number | null }; + +type pendingCanvasMutationsMap = Map< + HTMLCanvasElement, + canvasMutationWithType[] +>; + +export class CanvasManager { + private pendingCanvasMutations: pendingCanvasMutationsMap = new Map(); + private rafStamps: RafStamps = { latestId: 0, invokeId: null }; + private mirror: Mirror; + + private mutationCb: canvasMutationCallback; + private resetObservers: listenerHandler; + private frozen: boolean = false; + private locked: boolean = false; + + public reset() { + this.pendingCanvasMutations.clear(); + this.resetObservers(); + } + + public freeze() { + this.frozen = true; + } + + public unfreeze() { + this.frozen = false; + } + + public lock() { + this.locked = true; + } + + public unlock() { + this.locked = false; + } + + constructor(options: { + recordCanvas: boolean | number; + mutationCb: canvasMutationCallback; + win: IWindow; + blockClass: blockClass; + mirror: Mirror; + }) { + this.mutationCb = options.mutationCb; + this.mirror = options.mirror; + + if (options.recordCanvas === true) + this.initCanvasMutationObserver(options.win, options.blockClass); + } + + private processMutation: canvasManagerMutationCallback = function ( + target, + mutation, + ) { + const newFrame = + this.rafStamps.invokeId && + this.rafStamps.latestId !== this.rafStamps.invokeId; + if (newFrame || !this.rafStamps.invokeId) + this.rafStamps.invokeId = this.rafStamps.latestId; + + if (!this.pendingCanvasMutations.has(target)) { + this.pendingCanvasMutations.set(target, []); + } + + this.pendingCanvasMutations.get(target)!.push(mutation); + }; + + private initCanvasMutationObserver( + win: IWindow, + blockClass: blockClass, + ): void { + this.startRAFTimestamping(); + this.startPendingCanvasMutationFlusher(); + + const canvasContextReset = initCanvasContextObserver(win, blockClass); + const canvas2DReset = initCanvas2DMutationObserver( + this.processMutation.bind(this), + win, + blockClass, + this.mirror, + ); + + const canvasWebGL1and2Reset = initCanvasWebGLMutationObserver( + this.processMutation.bind(this), + win, + blockClass, + this.mirror, + ); + + this.resetObservers = () => { + canvasContextReset(); + canvas2DReset(); + canvasWebGL1and2Reset(); + }; + } + + private startPendingCanvasMutationFlusher() { + requestAnimationFrame(() => this.flushPendingCanvasMutations()); + } + + private startRAFTimestamping() { + const setLatestRAFTimestamp = (timestamp: DOMHighResTimeStamp) => { + this.rafStamps.latestId = timestamp; + requestAnimationFrame(setLatestRAFTimestamp); + }; + requestAnimationFrame(setLatestRAFTimestamp); + } + + flushPendingCanvasMutations() { + this.pendingCanvasMutations.forEach( + (values: canvasMutationCommand[], canvas: HTMLCanvasElement) => { + const id = this.mirror.getId((canvas as unknown) as INode); + this.flushPendingCanvasMutationFor(canvas, id); + }, + ); + requestAnimationFrame(() => this.flushPendingCanvasMutations()); + } + + flushPendingCanvasMutationFor(canvas: HTMLCanvasElement, id: number) { + if (this.frozen || this.locked) { + return; + } + + const valuesWithType = this.pendingCanvasMutations.get(canvas); + if (!valuesWithType || id === -1) return; + + const values = valuesWithType.map((value) => { + const { type, ...rest } = value; + return rest; + }); + const { type } = valuesWithType[0]; + + this.mutationCb({ id, type, commands: values }); + + this.pendingCanvasMutations.delete(canvas); + } +} diff --git a/src/record/observers/canvas/webgl.ts b/src/record/observers/canvas/webgl.ts index 77ca34ca..ef3a5ee8 100644 --- a/src/record/observers/canvas/webgl.ts +++ b/src/record/observers/canvas/webgl.ts @@ -2,8 +2,8 @@ import { INode } from '../../../snapshot'; import { blockClass, CanvasContext, - canvasMutationCallback, - canvasMutationCommand, + canvasManagerMutationCallback, + canvasMutationWithType, IWindow, listenerHandler, Mirror, @@ -11,57 +11,12 @@ import { import { hookSetter, isBlocked, patch } from '../../../utils'; import { saveWebGLVar, serializeArgs } from './serialize-args'; -type canvasMutationWithType = { type: CanvasContext } & canvasMutationCommand; -type pendingCanvasMutationsMap = Map< - HTMLCanvasElement, - canvasMutationWithType[] ->; -type RafStamps = { latestId: number; invokeId: number | null }; - -function flushPendingCanvasMutations( - pendingCanvasMutations: pendingCanvasMutationsMap, - cb: canvasMutationCallback, - mirror: Mirror, -) { - pendingCanvasMutations.forEach( - (values: canvasMutationCommand[], canvas: HTMLCanvasElement) => { - const id = mirror.getId((canvas as unknown) as INode); - flushPendingCanvasMutationFor(canvas, pendingCanvasMutations, id, cb); - }, - ); - requestAnimationFrame(() => - flushPendingCanvasMutations(pendingCanvasMutations, cb, mirror), - ); -} - -function flushPendingCanvasMutationFor( - canvas: HTMLCanvasElement, - pendingCanvasMutations: pendingCanvasMutationsMap, - id: number, - cb: canvasMutationCallback, -) { - const valuesWithType = pendingCanvasMutations.get(canvas); - if (!valuesWithType || id === -1) return; - - const values = valuesWithType.map((value) => { - const { type, ...rest } = value; - return rest; - }); - const { type } = valuesWithType[0]; - - cb({ id, type, commands: values }); - - pendingCanvasMutations.delete(canvas); -} - function patchGLPrototype( prototype: WebGLRenderingContext | WebGL2RenderingContext, type: CanvasContext, - cb: canvasMutationCallback, + cb: canvasManagerMutationCallback, blockClass: blockClass, mirror: Mirror, - pendingCanvasMutations: pendingCanvasMutationsMap, - rafStamps: RafStamps, win: IWindow, ): listenerHandler[] { const handlers: listenerHandler[] = []; @@ -75,11 +30,6 @@ function patchGLPrototype( } const restoreHandler = patch(prototype, prop, function (original) { return function (this: typeof prototype, ...args: Array) { - const newFrame = - rafStamps.invokeId && rafStamps.latestId !== rafStamps.invokeId; - if (newFrame || !rafStamps.invokeId) - rafStamps.invokeId = rafStamps.latestId; - const result = original.apply(this, args); saveWebGLVar(result, win, prototype); if (!isBlocked((this.canvas as unknown) as INode, blockClass)) { @@ -91,15 +41,8 @@ function patchGLPrototype( property: prop, args: recordArgs, }; - - if (!pendingCanvasMutations.has(this.canvas as HTMLCanvasElement)) { - pendingCanvasMutations.set(this.canvas as HTMLCanvasElement, []); - } - - pendingCanvasMutations - // FIXME! THIS COULD MAYBE BE AN OFFSCREEN CANVAS - .get(this.canvas as HTMLCanvasElement)! - .push(mutation); + // TODO: this could potentially also be an OffscreenCanvas as well as HTMLCanvasElement + cb(this.canvas as HTMLCanvasElement, mutation); } return result; @@ -109,8 +52,8 @@ function patchGLPrototype( } catch { const hookHandler = hookSetter(prototype, prop, { set(v) { - cb({ - id: mirror.getId((this.canvas as unknown) as INode), + // TODO: this could potentially also be an OffscreenCanvas as well as HTMLCanvasElement + cb(this.canvas as HTMLCanvasElement, { type, property: prop, args: [v], @@ -126,29 +69,12 @@ function patchGLPrototype( } export default function initCanvasWebGLMutationObserver( - cb: canvasMutationCallback, + cb: canvasManagerMutationCallback, win: IWindow, blockClass: blockClass, mirror: Mirror, ): listenerHandler { const handlers: listenerHandler[] = []; - const pendingCanvasMutations: pendingCanvasMutationsMap = new Map(); - - const rafStamps: RafStamps = { - latestId: 0, - invokeId: null, - }; - - const setLatestRAFTimestamp = (timestamp: DOMHighResTimeStamp) => { - rafStamps.latestId = timestamp; - requestAnimationFrame(setLatestRAFTimestamp); - }; - requestAnimationFrame(setLatestRAFTimestamp); - - // TODO: replace me - requestAnimationFrame(() => - flushPendingCanvasMutations(pendingCanvasMutations, cb, mirror), - ); handlers.push( ...patchGLPrototype( @@ -157,8 +83,6 @@ export default function initCanvasWebGLMutationObserver( cb, blockClass, mirror, - pendingCanvasMutations, - rafStamps, win, ), ); @@ -171,15 +95,12 @@ export default function initCanvasWebGLMutationObserver( cb, blockClass, mirror, - pendingCanvasMutations, - rafStamps, win, ), ); } return () => { - pendingCanvasMutations.clear(); handlers.forEach((h) => h()); }; } diff --git a/src/record/shadow-dom-manager.ts b/src/record/shadow-dom-manager.ts index 5b2b43cc..052909ff 100644 --- a/src/record/shadow-dom-manager.ts +++ b/src/record/shadow-dom-manager.ts @@ -14,6 +14,7 @@ import { } from '../snapshot'; import { IframeManager } from './iframe-manager'; import { initMutationObserver, initScrollObserver } from './observer'; +import { CanvasManager } from './observers/canvas/canvas-manager'; type BypassOptions = { blockClass: blockClass; @@ -28,6 +29,7 @@ type BypassOptions = { sampling: SamplingStrategy; slimDOMOptions: SlimDOMOptions; iframeManager: IframeManager; + canvasManager: CanvasManager; enableStrictPrivacy: boolean; }; @@ -66,6 +68,7 @@ export class ShadowDomManager { this.mirror, this.bypassOptions.iframeManager, this, + this.bypassOptions.canvasManager, shadowRoot, this.bypassOptions.enableStrictPrivacy, ); diff --git a/src/replay/index.ts b/src/replay/index.ts index 53c26591..a4056edd 100644 --- a/src/replay/index.ts +++ b/src/replay/index.ts @@ -193,7 +193,7 @@ export class Replayer { this.virtualStyleRulesMap.clear(); for (const d of scrollMap.values()) { - this.applyScroll(d); + this.applyScroll(d, true); } for (const d of inputMap.values()) { this.applyInput(d); @@ -733,6 +733,8 @@ export class Replayer { finish(); } } + + this.emitter.emit(ReplayerEvents.EventCast, event); }; return wrappedCastFn; } @@ -798,10 +800,6 @@ export class Replayer { this.newDocumentQueue = this.newDocumentQueue.filter( (m) => m !== mutationInQueue, ); - if (builtNode.contentDocument) { - const { documentElement, head } = builtNode.contentDocument; - this.insertStyleRules(documentElement, head); - } } const { documentElement, head } = this.iframe.contentDocument; this.insertStyleRules(documentElement, head); @@ -864,6 +862,13 @@ export class Replayer { skipChild: false, afterAppend: (builtNode) => { this.collectIframeAndAttachDocument(collected, builtNode); + if ( + builtNode.__sn.type === NodeType.Element && + builtNode.__sn.tagName.toUpperCase() === 'HTML' + ) { + const { documentElement, head } = iframeEl.contentDocument!; + this.insertStyleRules(documentElement, head); + } }, cache: this.cache, }); @@ -872,10 +877,6 @@ export class Replayer { this.newDocumentQueue = this.newDocumentQueue.filter( (m) => m !== mutationInQueue, ); - if (builtNode.contentDocument) { - const { documentElement, head } = builtNode.contentDocument; - this.insertStyleRules(documentElement, head); - } } } @@ -1170,7 +1171,7 @@ export class Replayer { this.treeIndex.scroll(d); break; } - this.applyScroll(d); + this.applyScroll(d, false); break; } case IncrementalSource.ViewportResize: @@ -1206,6 +1207,12 @@ export class Replayer { if (d.currentTime) { mediaEl.currentTime = d.currentTime; } + if (d.volume) { + mediaEl.volume = d.volume; + } + if (d.muted) { + mediaEl.muted = d.muted; + } if (d.type === MediaInteractions.Pause) { mediaEl.pause(); } @@ -1642,10 +1649,6 @@ export class Replayer { (m) => m !== mutationInQueue, ); } - if (target.contentDocument) { - const { documentElement, head } = target.contentDocument; - this.insertStyleRules(documentElement, head); - } } if (mutation.previousId || mutation.nextId) { @@ -1758,7 +1761,13 @@ export class Replayer { }); } - private applyScroll(d: scrollData) { + /** + * Apply the scroll data on real elements. + * If the replayer is in sync mode, smooth scroll behavior should be disabled. + * @param d the scroll data + * @param isSync whether the replayer is in sync mode(fast-forward) + */ + private applyScroll(d: scrollData, isSync: boolean) { const target = this.mirror.getNode(d.id); if (!target) { return this.debugNodeNotFound(d, d.id); @@ -1767,14 +1776,14 @@ export class Replayer { this.iframe.contentWindow!.scrollTo({ top: d.y, left: d.x, - behavior: 'smooth', + behavior: isSync ? 'auto' : 'smooth', }); } else if (target.__sn.type === NodeType.Document) { // nest iframe content document ((target as unknown) as Document).defaultView!.scrollTo({ top: d.y, left: d.x, - behavior: 'smooth', + behavior: isSync ? 'auto' : 'smooth', }); } else { try { diff --git a/src/replay/machine.ts b/src/replay/machine.ts index 4816fcbb..8c5ca33f 100644 --- a/src/replay/machine.ts +++ b/src/replay/machine.ts @@ -205,7 +205,6 @@ export function createPlayerService( actions.push({ doAction: () => { castFn(); - emitter.emit(ReplayerEvents.EventCast, event); }, delay: event.delay!, }); @@ -270,7 +269,6 @@ export function createPlayerService( timer.addAction({ doAction: () => { castFn(); - emitter.emit(ReplayerEvents.EventCast, event); }, delay: event.delay!, }); diff --git a/src/snapshot/snapshot.ts b/src/snapshot/snapshot.ts index 58359440..33a096b4 100644 --- a/src/snapshot/snapshot.ts +++ b/src/snapshot/snapshot.ts @@ -12,7 +12,12 @@ import { KeepIframeSrcFn, ICanvas, } from './types'; -import { isElement, isShadowRoot, maskInputValue } from './utils'; +import { + is2DCanvasBlank, + isElement, + isShadowRoot, + maskInputValue, +} from './utils'; let _id = 1; const tagNameRegex = new RegExp('[^a-z0-9-_:]'); @@ -54,9 +59,7 @@ function getCssRuleString(rule: CSSRule): string { if (isCSSImportRule(rule)) { try { cssStringified = getCssRulesString(rule.styleSheet) || cssStringified; - } catch { - // ignore - } + } catch {} } return cssStringified; } @@ -495,7 +498,7 @@ function serializeNode( } } if (tagName === 'option') { - if ((n as HTMLOptionElement).selected) { + if ((n as HTMLOptionElement).selected && !maskInputOptions['select']) { attributes.selected = true; } else { // ignore the html attribute (which corresponds to DOM (n as HTMLOptionElement).defaultSelected) @@ -504,22 +507,26 @@ function serializeNode( } } // canvas image data - if ( - tagName === 'canvas' && - recordCanvas && - (!('__context' in n) || (n as ICanvas).__context === '2d') // only record this on 2d canvas - ) { - const canvasDataURL = (n as HTMLCanvasElement).toDataURL(); + if (tagName === 'canvas' && recordCanvas) { + if ((n as ICanvas).__context === '2d') { + // only record this on 2d canvas + if (!is2DCanvasBlank(n as HTMLCanvasElement)) { + attributes.rr_dataURL = (n as HTMLCanvasElement).toDataURL(); + } + } else if (!('__context' in n)) { + // context is unknown, better not call getContext to trigger it + const canvasDataURL = (n as HTMLCanvasElement).toDataURL(); - // create blank canvas of same dimensions - const blankCanvas = document.createElement('canvas'); - blankCanvas.width = (n as HTMLCanvasElement).width; - blankCanvas.height = (n as HTMLCanvasElement).height; - const blankCanvasDataURL = blankCanvas.toDataURL(); + // create blank canvas of same dimensions + const blankCanvas = document.createElement('canvas'); + blankCanvas.width = (n as HTMLCanvasElement).width; + blankCanvas.height = (n as HTMLCanvasElement).height; + const blankCanvasDataURL = blankCanvas.toDataURL(); - // no need to save dataURL if it's the same as blank canvas - if (canvasDataURL !== blankCanvasDataURL) { - attributes.rr_dataURL = (n as HTMLCanvasElement).toDataURL(); + // no need to save dataURL if it's the same as blank canvas + if (canvasDataURL !== blankCanvasDataURL) { + attributes.rr_dataURL = canvasDataURL; + } } } // media elements diff --git a/src/snapshot/utils.ts b/src/snapshot/utils.ts index ed5c5f75..2b059765 100644 --- a/src/snapshot/utils.ts +++ b/src/snapshot/utils.ts @@ -35,3 +35,40 @@ export function maskInputValue({ } return text; } + +const ORIGINAL_ATTRIBUTE_NAME = '__rrweb_original__'; +type PatchedGetImageData = { + [ORIGINAL_ATTRIBUTE_NAME]: CanvasImageData['getImageData']; +} & CanvasImageData['getImageData']; + +export function is2DCanvasBlank(canvas: HTMLCanvasElement): boolean { + const ctx = canvas.getContext('2d'); + if (!ctx) return true; + + const chunkSize = 50; + + // get chunks of the canvas and check if it is blank + for (let x = 0; x < canvas.width; x += chunkSize) { + for (let y = 0; y < canvas.height; y += chunkSize) { + const getImageData = ctx.getImageData as PatchedGetImageData; + const originalGetImageData = + ORIGINAL_ATTRIBUTE_NAME in getImageData + ? getImageData[ORIGINAL_ATTRIBUTE_NAME] + : getImageData; + // by getting the canvas in chunks we avoid an expensive + // `getImageData` call that retrieves everything + // even if we can already tell from the first chunk(s) that + // the canvas isn't blank + const pixelBuffer = new Uint32Array( + originalGetImageData( + x, + y, + Math.min(chunkSize, canvas.width - x), + Math.min(chunkSize, canvas.height - y), + ).data.buffer, + ); + if (pixelBuffer.some((pixel) => pixel !== 0)) return false; + } + } + return true; +} diff --git a/src/types.ts b/src/types.ts index b81a957e..4f161d94 100644 --- a/src/types.ts +++ b/src/types.ts @@ -12,6 +12,7 @@ import { FontFaceDescriptors } from 'css-font-loading-module'; import { IframeManager } from './record/iframe-manager'; import { ShadowDomManager } from './record/shadow-dom-manager'; import type { Replayer } from './replay'; +import { CanvasManager } from './record/observers/canvas/canvas-manager'; export enum EventType { DomContentLoaded, @@ -199,6 +200,10 @@ export type SamplingStrategy = Partial<{ * number is the throttle threshold of recording scroll */ scroll: number; + /** + * number is the throttle threshold of recording media interactions + */ + media: number; /** * 'all' will record all the input events * 'last' will only record the last input value while input a sequence of chars @@ -270,6 +275,7 @@ export type observerParam = { mirror: Mirror; iframeManager: IframeManager; shadowDomManager: ShadowDomManager; + canvasManager: CanvasManager; plugins: Array<{ observer: Function; callback: Function; @@ -468,8 +474,6 @@ export type styleDeclarationParam = { export type styleDeclarationCallback = (s: styleDeclarationParam) => void; -export type canvasMutationCallback = (p: canvasMutationParam) => void; - export type canvasMutationCommand = { property: string; args: Array; @@ -487,6 +491,17 @@ export type canvasMutationParam = type: CanvasContext; } & canvasMutationCommand); +export type canvasMutationWithType = { + type: CanvasContext; +} & canvasMutationCommand; + +export type canvasMutationCallback = (p: canvasMutationParam) => void; + +export type canvasManagerMutationCallback = ( + target: HTMLCanvasElement, + p: canvasMutationWithType, +) => void; + export type fontParam = { family: string; fontSource: string; @@ -520,12 +535,15 @@ export const enum MediaInteractions { Play, Pause, Seeked, + VolumeChange, } export type mediaInteractionParam = { type: MediaInteractions; id: number; currentTime?: number; + volume?: number; + muted?: boolean; }; export type mediaInteractionCallback = (p: mediaInteractionParam) => void;