Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 33 additions & 8 deletions packages/rrdom/src/diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
styleDeclarationData,
styleSheetRuleData,
} from '@rrweb/types';
import { IncrementalSource, EventType, CanvasContext } from '@rrweb/types';
import type {
IRRCDATASection,
IRRComment,
Expand Down Expand Up @@ -255,17 +256,41 @@ 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: 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,
],
},
],
};
image.src = rrCanvasElement.rr_dataURL;

// Apply the synthetic mutation synchronously
replayer.applyCanvas(
{
timestamp: 0,
type: EventType.IncrementalSnapshot,
data: syntheticMutation,
} as canvasEventWithTime,
syntheticMutation,
oldTree as HTMLCanvasElement,
);
}

// Apply all regular mutations
rrCanvasElement.canvasMutations.forEach((canvasMutation) =>
replayer.applyCanvas(
canvasMutation.event,
Expand Down
182 changes: 182 additions & 0 deletions packages/rrdom/test/diff.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,188 @@ 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, 0, 0],
},
],
} 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 = 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==';
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],
},
],
} 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]);
Copy link

Copilot AI Oct 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The expected dimensions [300, 150] don't match the canvas dimensions set in the test (100x100). This should use the actual canvas dimensions for consistency.

Suggested change
expect(mutation.commands[0].args).toEqual([testDataURL, 0, 0, 300, 150]);
expect(mutation.commands[0].args).toEqual([testDataURL, 0, 0, canvas.width, canvas.height]);

Copilot uses AI. Check for mistakes.
});

it('should diff a media element', async () => {
// mock the HTMLMediaElement of jsdom
let paused = true;
Expand Down
Loading