From 8473ed4bb5364e3f160986b82b01420c1ea58cf4 Mon Sep 17 00:00:00 2001 From: Justin Ratner Date: Thu, 9 Oct 2025 17:00:44 -0700 Subject: [PATCH 1/6] Fix race condition in canvas --- packages/rrdom/src/diff.ts | 33 +++++-- packages/rrdom/test/diff.test.ts | 158 +++++++++++++++++++++++++++++++ 2 files changed, 183 insertions(+), 8 deletions(-) diff --git a/packages/rrdom/src/diff.ts b/packages/rrdom/src/diff.ts index 5d9ba7cea5..ad71e593fe 100644 --- a/packages/rrdom/src/diff.ts +++ b/packages/rrdom/src/diff.ts @@ -255,17 +255,34 @@ function diffAfterUpdatingChildren( } case 'CANVAS': { const rrCanvasElement = newTree as RRCanvasElement; - // This canvas element is created with initial data in an iframe element. https://github.com/rrweb-io/rrweb/pull/944 + + // Treat rr_dataURL as the first mutation to avoid race conditions if (rrCanvasElement.rr_dataURL !== null) { - const image = document.createElement('img'); - image.onload = () => { - const ctx = (oldElement as HTMLCanvasElement).getContext('2d'); - if (ctx) { - ctx.drawImage(image, 0, 0, image.width, image.height); - } + // Create a synthetic canvas mutation for the initial dataURL + const syntheticMutation: canvasMutationData = { + source: 9, // IncrementalSource.CanvasMutation + id: replayer.mirror.getId(oldTree), + type: 0, // CanvasContext['2D'] + commands: [{ + property: 'drawImage', + args: [rrCanvasElement.rr_dataURL, 0, 0, (oldTree as HTMLCanvasElement).width, (oldTree as HTMLCanvasElement).height], + setter: false + } as any] }; - image.src = rrCanvasElement.rr_dataURL; + + // Apply the synthetic mutation synchronously + replayer.applyCanvas( + { + timestamp: 0, + type: 3, // EventType.IncrementalSnapshot + data: syntheticMutation + } as canvasEventWithTime, + syntheticMutation, + oldTree as HTMLCanvasElement, + ); } + + // Apply all regular mutations rrCanvasElement.canvasMutations.forEach((canvasMutation) => replayer.applyCanvas( canvasMutation.event, diff --git a/packages/rrdom/test/diff.test.ts b/packages/rrdom/test/diff.test.ts index 0b13edb49c..8984a0271f 100644 --- a/packages/rrdom/test/diff.test.ts +++ b/packages/rrdom/test/diff.test.ts @@ -248,6 +248,164 @@ describe('diff algorithm for rrdom', () => { expect(replayer.applyCanvas).toHaveBeenCalledTimes(MutationNumber); }); + it('should handle canvas with rr_dataURL and mutations without race condition', async () => { + const element = document.createElement('canvas'); + element.width = 200; + element.height = 100; + + const rrDocument = new RRDocument(); + const rrCanvas = rrDocument.createElement('canvas'); + const sn = Object.assign({}, elementSn, { tagName: 'canvas' }); + rrDocument.mirror.add(rrCanvas, sn); + + // Set up initial canvas state with rr_dataURL + const testDataURL = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=='; + rrCanvas.rr_dataURL = testDataURL; + + // Add subsequent canvas mutations + const clearRectMutation = { + source: IncrementalSource.CanvasMutation, + id: 0, + type: 0, + commands: [{ + property: 'clearRect', + args: [0, 0, 200, 100] + }], + } as canvasMutationData; + + const drawImageMutation = { + source: IncrementalSource.CanvasMutation, + id: 0, + type: 0, + commands: [{ + property: 'drawImage', + args: [testDataURL, 10, 10, 50, 50] + }], + } as canvasMutationData; + + rrCanvas.canvasMutations.push({ + event: { + timestamp: 1000, + type: EventType.IncrementalSnapshot, + data: clearRectMutation, + }, + mutation: clearRectMutation, + }); + + rrCanvas.canvasMutations.push({ + event: { + timestamp: 2000, + type: EventType.IncrementalSnapshot, + data: drawImageMutation, + }, + mutation: drawImageMutation, + }); + + // Mock the canvas context and applyCanvas method + const mockContext = { + drawImage: vi.fn(), + clearRect: vi.fn(), + }; + vi.spyOn(element, 'getContext').mockReturnValue(mockContext as any); + + const applyCanvasCalls: Array<{ event: any; mutation: any; target: any }> = []; + replayer.applyCanvas = vi.fn((event, mutation, target) => { + applyCanvasCalls.push({ event, mutation, target }); + }); + + // Execute the diff + diff(element, rrCanvas, replayer); + + // Verify that applyCanvas was called 3 times: + // 1. Synthetic mutation for rr_dataURL + // 2. clearRect mutation + // 3. drawImage mutation + expect(replayer.applyCanvas).toHaveBeenCalledTimes(3); + + // Verify the synthetic mutation for rr_dataURL was called first + const firstCall = applyCanvasCalls[0]; + expect(firstCall.event.timestamp).toBe(0); + expect(firstCall.event.type).toBe(EventType.IncrementalSnapshot); + expect(firstCall.mutation.source).toBe(IncrementalSource.CanvasMutation); + expect(firstCall.mutation.commands[0].property).toBe('drawImage'); + expect(firstCall.mutation.commands[0].args).toEqual([testDataURL, 0, 0, 300, 150]); + + // Verify subsequent mutations were called in order + const secondCall = applyCanvasCalls[1]; + expect(secondCall.event.timestamp).toBe(1000); + expect(secondCall.mutation.commands[0].property).toBe('clearRect'); + + const thirdCall = applyCanvasCalls[2]; + expect(thirdCall.event.timestamp).toBe(2000); + expect(thirdCall.mutation.commands[0].property).toBe('drawImage'); + }); + + it('should prevent race condition by applying rr_dataURL synchronously before mutations', () => { + const element = document.createElement('canvas'); + element.width = 100; + element.height = 100; + + const rrDocument = new RRDocument(); + const rrCanvas = rrDocument.createElement('canvas'); + const sn = Object.assign({}, elementSn, { tagName: 'canvas' }); + rrDocument.mirror.add(rrCanvas, sn); + + // Set up canvas with rr_dataURL and mutations + const testDataURL = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=='; + rrCanvas.rr_dataURL = testDataURL; + + // Add a mutation that would conflict with async rr_dataURL loading + const conflictingMutation = { + source: IncrementalSource.CanvasMutation, + id: 0, + type: 0, + commands: [{ + property: 'clearRect', + args: [0, 0, 100, 100] + }], + } as canvasMutationData; + + rrCanvas.canvasMutations.push({ + event: { + timestamp: 100, + type: EventType.IncrementalSnapshot, + data: conflictingMutation, + }, + mutation: conflictingMutation, + }); + + // Track the order of operations + const operationOrder: string[] = []; + + // Mock applyCanvas to track when it's called + replayer.applyCanvas = vi.fn((event, mutation, target) => { + if (event.timestamp === 0) { + operationOrder.push('rr_dataURL_synthetic_mutation'); + } else if (('commands' in mutation ? mutation.commands[0] : mutation).property === 'clearRect') { + operationOrder.push('clearRect_mutation'); + } + }); + + // Execute the diff + diff(element, rrCanvas, replayer); + + // Verify that rr_dataURL synthetic mutation is applied FIRST + // This prevents the race condition where async image loading could overwrite mutations + expect(operationOrder).toEqual(['rr_dataURL_synthetic_mutation', 'clearRect_mutation']); + + // Verify both operations were called + expect(replayer.applyCanvas).toHaveBeenCalledTimes(2); + + // Verify the synthetic mutation has the correct structure + const firstCall = (replayer.applyCanvas as any).mock.calls[0]; + const [event, mutation] = firstCall; + + expect(event.timestamp).toBe(0); + expect(mutation.source).toBe(IncrementalSource.CanvasMutation); + expect(mutation.commands[0].property).toBe('drawImage'); + expect(mutation.commands[0].args).toEqual([testDataURL, 0, 0, 300, 150]); + }); + it('should diff a media element', async () => { // mock the HTMLMediaElement of jsdom let paused = true; From 2a3c793dd7fbbc790248027212b0882779c1edaa Mon Sep 17 00:00:00 2001 From: Justin Ratner Date: Thu, 9 Oct 2025 17:05:46 -0700 Subject: [PATCH 2/6] Use appropriate enums instead of hardcoded values --- packages/rrdom/src/diff.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/rrdom/src/diff.ts b/packages/rrdom/src/diff.ts index ad71e593fe..46944ecb4f 100644 --- a/packages/rrdom/src/diff.ts +++ b/packages/rrdom/src/diff.ts @@ -9,6 +9,11 @@ import type { styleDeclarationData, styleSheetRuleData, } from '@rrweb/types'; +import { + IncrementalSource, + EventType, + CanvasContext, +} from '@rrweb/types'; import type { IRRCDATASection, IRRComment, @@ -260,9 +265,9 @@ function diffAfterUpdatingChildren( if (rrCanvasElement.rr_dataURL !== null) { // Create a synthetic canvas mutation for the initial dataURL const syntheticMutation: canvasMutationData = { - source: 9, // IncrementalSource.CanvasMutation + source: IncrementalSource.CanvasMutation, id: replayer.mirror.getId(oldTree), - type: 0, // CanvasContext['2D'] + type: CanvasContext['2D'], commands: [{ property: 'drawImage', args: [rrCanvasElement.rr_dataURL, 0, 0, (oldTree as HTMLCanvasElement).width, (oldTree as HTMLCanvasElement).height], @@ -274,7 +279,7 @@ function diffAfterUpdatingChildren( replayer.applyCanvas( { timestamp: 0, - type: 3, // EventType.IncrementalSnapshot + type: EventType.IncrementalSnapshot, data: syntheticMutation } as canvasEventWithTime, syntheticMutation, From f965b322f055f468153d227b4b9c418d946993b8 Mon Sep 17 00:00:00 2001 From: Justin Ratner Date: Fri, 10 Oct 2025 09:23:32 -0700 Subject: [PATCH 3/6] Fix test issue --- packages/rrdom/test/diff.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/rrdom/test/diff.test.ts b/packages/rrdom/test/diff.test.ts index 8984a0271f..18920a81ba 100644 --- a/packages/rrdom/test/diff.test.ts +++ b/packages/rrdom/test/diff.test.ts @@ -279,7 +279,7 @@ describe('diff algorithm for rrdom', () => { type: 0, commands: [{ property: 'drawImage', - args: [testDataURL, 10, 10, 50, 50] + args: [testDataURL, 0, 0] }], } as canvasMutationData; @@ -342,8 +342,8 @@ describe('diff algorithm for rrdom', () => { it('should prevent race condition by applying rr_dataURL synchronously before mutations', () => { const element = document.createElement('canvas'); - element.width = 100; - element.height = 100; + element.width = 300; + element.height = 150; const rrDocument = new RRDocument(); const rrCanvas = rrDocument.createElement('canvas'); @@ -361,7 +361,7 @@ describe('diff algorithm for rrdom', () => { type: 0, commands: [{ property: 'clearRect', - args: [0, 0, 100, 100] + args: [0, 0, 300, 150] }], } as canvasMutationData; From 6958223100420c222bd6f89d431b887c79645996 Mon Sep 17 00:00:00 2001 From: Justin Ratner Date: Fri, 10 Oct 2025 09:29:51 -0700 Subject: [PATCH 4/6] Fix lint and formatting issues --- packages/rrdom/src/diff.ts | 31 +++++---- packages/rrdom/test/diff.test.ts | 108 +++++++++++++++++++------------ 2 files changed, 83 insertions(+), 56 deletions(-) diff --git a/packages/rrdom/src/diff.ts b/packages/rrdom/src/diff.ts index 46944ecb4f..1b39bbd509 100644 --- a/packages/rrdom/src/diff.ts +++ b/packages/rrdom/src/diff.ts @@ -9,11 +9,7 @@ import type { styleDeclarationData, styleSheetRuleData, } from '@rrweb/types'; -import { - IncrementalSource, - EventType, - CanvasContext, -} from '@rrweb/types'; +import { IncrementalSource, EventType, CanvasContext } from '@rrweb/types'; import type { IRRCDATASection, IRRComment, @@ -260,7 +256,7 @@ function diffAfterUpdatingChildren( } case 'CANVAS': { const rrCanvasElement = newTree as RRCanvasElement; - + // Treat rr_dataURL as the first mutation to avoid race conditions if (rrCanvasElement.rr_dataURL !== null) { // Create a synthetic canvas mutation for the initial dataURL @@ -268,25 +264,32 @@ function diffAfterUpdatingChildren( source: IncrementalSource.CanvasMutation, id: replayer.mirror.getId(oldTree), type: CanvasContext['2D'], - commands: [{ - property: 'drawImage', - args: [rrCanvasElement.rr_dataURL, 0, 0, (oldTree as HTMLCanvasElement).width, (oldTree as HTMLCanvasElement).height], - setter: false - } as any] + commands: [ + { + property: 'drawImage', + args: [ + rrCanvasElement.rr_dataURL, + 0, + 0, + (oldTree as HTMLCanvasElement).width, + (oldTree as HTMLCanvasElement).height, + ], + }, + ], }; - + // Apply the synthetic mutation synchronously replayer.applyCanvas( { timestamp: 0, type: EventType.IncrementalSnapshot, - data: syntheticMutation + data: syntheticMutation, } as canvasEventWithTime, syntheticMutation, oldTree as HTMLCanvasElement, ); } - + // Apply all regular mutations rrCanvasElement.canvasMutations.forEach((canvasMutation) => replayer.applyCanvas( diff --git a/packages/rrdom/test/diff.test.ts b/packages/rrdom/test/diff.test.ts index 18920a81ba..eb36d33532 100644 --- a/packages/rrdom/test/diff.test.ts +++ b/packages/rrdom/test/diff.test.ts @@ -252,37 +252,42 @@ describe('diff algorithm for rrdom', () => { const element = document.createElement('canvas'); element.width = 200; element.height = 100; - + const rrDocument = new RRDocument(); const rrCanvas = rrDocument.createElement('canvas'); const sn = Object.assign({}, elementSn, { tagName: 'canvas' }); rrDocument.mirror.add(rrCanvas, sn); - + // Set up initial canvas state with rr_dataURL - const testDataURL = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=='; + const testDataURL = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=='; rrCanvas.rr_dataURL = testDataURL; - + // Add subsequent canvas mutations const clearRectMutation = { source: IncrementalSource.CanvasMutation, id: 0, type: 0, - commands: [{ - property: 'clearRect', - args: [0, 0, 200, 100] - }], + commands: [ + { + property: 'clearRect', + args: [0, 0, 200, 100], + }, + ], } as canvasMutationData; - + const drawImageMutation = { source: IncrementalSource.CanvasMutation, id: 0, type: 0, - commands: [{ - property: 'drawImage', - args: [testDataURL, 0, 0] - }], + commands: [ + { + property: 'drawImage', + args: [testDataURL, 0, 0], + }, + ], } as canvasMutationData; - + rrCanvas.canvasMutations.push({ event: { timestamp: 1000, @@ -291,7 +296,7 @@ describe('diff algorithm for rrdom', () => { }, mutation: clearRectMutation, }); - + rrCanvas.canvasMutations.push({ event: { timestamp: 2000, @@ -300,41 +305,51 @@ describe('diff algorithm for rrdom', () => { }, mutation: drawImageMutation, }); - + // Mock the canvas context and applyCanvas method const mockContext = { drawImage: vi.fn(), clearRect: vi.fn(), }; vi.spyOn(element, 'getContext').mockReturnValue(mockContext as any); - - const applyCanvasCalls: Array<{ event: any; mutation: any; target: any }> = []; + + const applyCanvasCalls: Array<{ + event: any; + mutation: any; + target: any; + }> = []; replayer.applyCanvas = vi.fn((event, mutation, target) => { applyCanvasCalls.push({ event, mutation, target }); }); - + // Execute the diff diff(element, rrCanvas, replayer); - + // Verify that applyCanvas was called 3 times: // 1. Synthetic mutation for rr_dataURL // 2. clearRect mutation // 3. drawImage mutation expect(replayer.applyCanvas).toHaveBeenCalledTimes(3); - + // Verify the synthetic mutation for rr_dataURL was called first const firstCall = applyCanvasCalls[0]; expect(firstCall.event.timestamp).toBe(0); expect(firstCall.event.type).toBe(EventType.IncrementalSnapshot); expect(firstCall.mutation.source).toBe(IncrementalSource.CanvasMutation); expect(firstCall.mutation.commands[0].property).toBe('drawImage'); - expect(firstCall.mutation.commands[0].args).toEqual([testDataURL, 0, 0, 300, 150]); - + expect(firstCall.mutation.commands[0].args).toEqual([ + testDataURL, + 0, + 0, + 300, + 150, + ]); + // Verify subsequent mutations were called in order const secondCall = applyCanvasCalls[1]; expect(secondCall.event.timestamp).toBe(1000); expect(secondCall.mutation.commands[0].property).toBe('clearRect'); - + const thirdCall = applyCanvasCalls[2]; expect(thirdCall.event.timestamp).toBe(2000); expect(thirdCall.mutation.commands[0].property).toBe('drawImage'); @@ -344,27 +359,30 @@ describe('diff algorithm for rrdom', () => { const element = document.createElement('canvas'); element.width = 300; element.height = 150; - + const rrDocument = new RRDocument(); const rrCanvas = rrDocument.createElement('canvas'); const sn = Object.assign({}, elementSn, { tagName: 'canvas' }); rrDocument.mirror.add(rrCanvas, sn); - + // Set up canvas with rr_dataURL and mutations - const testDataURL = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=='; + const testDataURL = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=='; rrCanvas.rr_dataURL = testDataURL; - + // Add a mutation that would conflict with async rr_dataURL loading const conflictingMutation = { source: IncrementalSource.CanvasMutation, id: 0, type: 0, - commands: [{ - property: 'clearRect', - args: [0, 0, 300, 150] - }], + commands: [ + { + property: 'clearRect', + args: [0, 0, 300, 150], + }, + ], } as canvasMutationData; - + rrCanvas.canvasMutations.push({ event: { timestamp: 100, @@ -373,33 +391,39 @@ describe('diff algorithm for rrdom', () => { }, mutation: conflictingMutation, }); - + // Track the order of operations const operationOrder: string[] = []; - + // Mock applyCanvas to track when it's called replayer.applyCanvas = vi.fn((event, mutation, target) => { if (event.timestamp === 0) { operationOrder.push('rr_dataURL_synthetic_mutation'); - } else if (('commands' in mutation ? mutation.commands[0] : mutation).property === 'clearRect') { + } else if ( + ('commands' in mutation ? mutation.commands[0] : mutation) + .property === 'clearRect' + ) { operationOrder.push('clearRect_mutation'); } }); - + // Execute the diff diff(element, rrCanvas, replayer); - + // Verify that rr_dataURL synthetic mutation is applied FIRST // This prevents the race condition where async image loading could overwrite mutations - expect(operationOrder).toEqual(['rr_dataURL_synthetic_mutation', 'clearRect_mutation']); - + expect(operationOrder).toEqual([ + 'rr_dataURL_synthetic_mutation', + 'clearRect_mutation', + ]); + // Verify both operations were called expect(replayer.applyCanvas).toHaveBeenCalledTimes(2); - + // Verify the synthetic mutation has the correct structure const firstCall = (replayer.applyCanvas as any).mock.calls[0]; const [event, mutation] = firstCall; - + expect(event.timestamp).toBe(0); expect(mutation.source).toBe(IncrementalSource.CanvasMutation); expect(mutation.commands[0].property).toBe('drawImage'); From 1d30f5f9ea84b706709332c54408af68d3faafb7 Mon Sep 17 00:00:00 2001 From: Justin Ratner Date: Mon, 17 Nov 2025 13:33:08 -0800 Subject: [PATCH 5/6] Fix size issue --- packages/rrdom/src/diff.ts | 8 +------- packages/rrdom/test/diff.test.ts | 4 +--- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/packages/rrdom/src/diff.ts b/packages/rrdom/src/diff.ts index 1b39bbd509..26e502fe36 100644 --- a/packages/rrdom/src/diff.ts +++ b/packages/rrdom/src/diff.ts @@ -267,13 +267,7 @@ function diffAfterUpdatingChildren( commands: [ { property: 'drawImage', - args: [ - rrCanvasElement.rr_dataURL, - 0, - 0, - (oldTree as HTMLCanvasElement).width, - (oldTree as HTMLCanvasElement).height, - ], + args: [rrCanvasElement.rr_dataURL, 0, 0], }, ], }; diff --git a/packages/rrdom/test/diff.test.ts b/packages/rrdom/test/diff.test.ts index eb36d33532..aa1614e894 100644 --- a/packages/rrdom/test/diff.test.ts +++ b/packages/rrdom/test/diff.test.ts @@ -341,8 +341,6 @@ describe('diff algorithm for rrdom', () => { testDataURL, 0, 0, - 300, - 150, ]); // Verify subsequent mutations were called in order @@ -427,7 +425,7 @@ describe('diff algorithm for rrdom', () => { expect(event.timestamp).toBe(0); expect(mutation.source).toBe(IncrementalSource.CanvasMutation); expect(mutation.commands[0].property).toBe('drawImage'); - expect(mutation.commands[0].args).toEqual([testDataURL, 0, 0, 300, 150]); + expect(mutation.commands[0].args).toEqual([testDataURL, 0, 0]); }); it('should diff a media element', async () => { From ab971ac85c86079a29c85447df14af547b6b3a27 Mon Sep 17 00:00:00 2001 From: Justin Ratner Date: Tue, 18 Nov 2025 10:03:31 -0800 Subject: [PATCH 6/6] Fix prettier error --- packages/rrdom/test/diff.test.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/rrdom/test/diff.test.ts b/packages/rrdom/test/diff.test.ts index aa1614e894..e8e6a74fc1 100644 --- a/packages/rrdom/test/diff.test.ts +++ b/packages/rrdom/test/diff.test.ts @@ -337,11 +337,7 @@ describe('diff algorithm for rrdom', () => { expect(firstCall.event.type).toBe(EventType.IncrementalSnapshot); expect(firstCall.mutation.source).toBe(IncrementalSource.CanvasMutation); expect(firstCall.mutation.commands[0].property).toBe('drawImage'); - expect(firstCall.mutation.commands[0].args).toEqual([ - testDataURL, - 0, - 0, - ]); + expect(firstCall.mutation.commands[0].args).toEqual([testDataURL, 0, 0]); // Verify subsequent mutations were called in order const secondCall = applyCanvasCalls[1];