Skip to content

Commit 303ebbe

Browse files
authored
fix(replay): Fix blank canvas when viewing replay (#97503)
This fixes a bug where we can get a blank canvas image when navigating directly to a timestamp. This happens because when we load replay at a timestamp, we can have multiple isSync canvas mutation events that get queued up and debounced before the document is built (`onBuild`). This means that in `onBuild`, we call `processEvent` before the debounced processor is called. When we finally run the debounced function, it will mistakenly clean-up the canvas that was just processed in `onBuild`.
1 parent 5f90443 commit 303ebbe

File tree

2 files changed

+247
-3
lines changed

2 files changed

+247
-3
lines changed
Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
import {CanvasReplayerPlugin} from 'sentry/components/replays/canvasReplayerPlugin';
2+
3+
// Mock rrweb pieces used by the plugin
4+
jest.mock('@sentry-internal/rrweb', () => {
5+
return {
6+
// Keep enums minimal; values only need to be comparable
7+
EventType: {IncrementalSnapshot: 3},
8+
IncrementalSource: {CanvasMutation: 6},
9+
canvasMutation: jest.fn(() => Promise.resolve()),
10+
};
11+
});
12+
13+
// debounce doesn't work with fake timers
14+
jest.mock('lodash/debounce', () =>
15+
jest.fn().mockImplementation((callback, timeout) => {
16+
let timeoutId: ReturnType<typeof setTimeout> | null = null;
17+
const debounced = jest.fn((...args) => {
18+
if (timeoutId) clearTimeout(timeoutId);
19+
timeoutId = setTimeout(() => callback(...args), timeout);
20+
});
21+
22+
const cancel = jest.fn(() => {
23+
if (timeoutId) clearTimeout(timeoutId);
24+
});
25+
26+
const flush = jest.fn(() => {
27+
if (timeoutId) clearTimeout(timeoutId);
28+
callback();
29+
});
30+
31+
// @ts-expect-error mock lodash debounce
32+
debounced.cancel = cancel;
33+
// @ts-expect-error mock lodash debounce
34+
debounced.flush = flush;
35+
return debounced;
36+
})
37+
);
38+
39+
import {
40+
canvasMutation,
41+
EventType,
42+
IncrementalSource,
43+
Replayer,
44+
} from '@sentry-internal/rrweb';
45+
46+
type EventWithTime = {
47+
data: any;
48+
timestamp: number;
49+
type: number;
50+
};
51+
52+
jest.useFakeTimers();
53+
54+
// Ensure canvas.toDataURL exists under JSDOM
55+
beforeAll(() => {
56+
jest
57+
.spyOn(HTMLCanvasElement.prototype, 'toDataURL')
58+
.mockReturnValue('data:image/png;base64,TEST');
59+
});
60+
61+
afterAll(() => {
62+
jest.restoreAllMocks();
63+
});
64+
65+
function createCanvasNode(): HTMLCanvasElement {
66+
const canvas = document.createElement('canvas');
67+
// Give it size to avoid edge cases
68+
canvas.width = 10;
69+
canvas.height = 10;
70+
return canvas;
71+
}
72+
73+
function createCanvasEvent(id: number, timestamp: number): EventWithTime {
74+
return {
75+
type: EventType.IncrementalSnapshot,
76+
timestamp,
77+
data: {
78+
id,
79+
source: IncrementalSource.CanvasMutation,
80+
// Minimal shape; the plugin defers to mocked canvasMutation
81+
commands: [],
82+
},
83+
};
84+
}
85+
86+
function createReplayer(getNodeImpl: (id: number) => Node | null) {
87+
return {
88+
getMirror() {
89+
return {
90+
getNode: getNodeImpl,
91+
};
92+
},
93+
} as Replayer;
94+
}
95+
96+
describe('CanvasReplayerPlugin', () => {
97+
beforeEach(() => {
98+
jest.clearAllTimers();
99+
(canvasMutation as jest.Mock).mockClear();
100+
});
101+
102+
it('does not clear current canvas snapshot when flushing queued sync events before processing a canvas event', async () => {
103+
const id = 1;
104+
const canvasNode = createCanvasNode();
105+
106+
// Replayer mirror returns our canvas node for this id
107+
const replayer = createReplayer(requestedId =>
108+
requestedId === id ? canvasNode : null
109+
);
110+
111+
// Create plugin instance; the initial events list can be empty for this test
112+
const plugin = CanvasReplayerPlugin([]);
113+
114+
// Build the node and ensure an <img> container is appended
115+
plugin.onBuild!(canvasNode, {id, replayer});
116+
const img = canvasNode.querySelector('img')!;
117+
expect(img).toBeTruthy();
118+
expect(img.src).toBe('');
119+
120+
// First, process a normal canvas event to seed the snapshot and canvases map
121+
const e0 = createCanvasEvent(id, 1000);
122+
plugin.handler!(e0, false, {replayer});
123+
124+
// Allow async microtasks to run for processEvent
125+
jest.runAllTimers();
126+
await Promise.resolve();
127+
128+
expect(canvasMutation).toHaveBeenCalledTimes(1);
129+
expect(img.src).toContain('data:image/png;base64');
130+
131+
// Now simulate a seek: queue two sync events for the same canvas id
132+
const e1 = createCanvasEvent(id, 2000);
133+
const e2 = createCanvasEvent(id, 3000);
134+
plugin.handler!(e1, true, {replayer});
135+
plugin.handler!(e2, true, {replayer});
136+
137+
// While debounced queue has not auto-flushed yet, a new canvas event arrives
138+
const e3 = createCanvasEvent(id, 3100);
139+
plugin.handler!(e3, false, {replayer});
140+
141+
// processEvent flushes the debounced queue; wait for async processing
142+
jest.runAllTimers();
143+
await Promise.resolve();
144+
145+
// We should have processed the queued latest sync event and the realtime event
146+
expect((canvasMutation as jest.Mock).mock.calls.length).toBeGreaterThanOrEqual(2);
147+
148+
// Critically, the current canvas snapshot should not be cleared
149+
expect(img.src).toContain('data:image/png;base64');
150+
});
151+
152+
it('does not clear sync canvas snapshots when loading a replay at specific timestamp', async () => {
153+
const id1 = 1;
154+
const id2 = 2;
155+
const id3 = 3;
156+
const canvasNode1 = createCanvasNode();
157+
const canvasNode2 = createCanvasNode();
158+
const canvasNode3 = createCanvasNode();
159+
160+
// Replayer mirror returns our canvas node for this id
161+
const replayer = createReplayer(requestedId =>
162+
requestedId === id1
163+
? canvasNode1
164+
: requestedId === id2
165+
? canvasNode2
166+
: requestedId === id3
167+
? canvasNode3
168+
: null
169+
);
170+
171+
// Create plugin instance; the initial events list can be empty for this test
172+
const plugin = CanvasReplayerPlugin([]);
173+
174+
// player first processes sync events
175+
const e0 = createCanvasEvent(1, 1000);
176+
const e1 = createCanvasEvent(2, 2000);
177+
const e2 = createCanvasEvent(3, 3000);
178+
plugin.handler!(e0, true, {replayer});
179+
plugin.handler!(e1, true, {replayer});
180+
plugin.handler!(e2, true, {replayer});
181+
182+
// Then build
183+
plugin.onBuild!(canvasNode3, {id: id3, replayer});
184+
// Note, onBuild clears the event from `handleQueue`, so canvas mutation only gets called once, via onBuild -> processEvent
185+
186+
expect(canvasMutation).toHaveBeenCalledTimes(3);
187+
jest.runAllTimers();
188+
await Promise.resolve();
189+
const img = canvasNode3.querySelector('img')!;
190+
expect(img.src).toContain('data:image/png;base64');
191+
});
192+
193+
it('clears snapshots for canvases that are not queued when seeking', async () => {
194+
const id1 = 10;
195+
const id2 = 11;
196+
const canvas1 = createCanvasNode();
197+
const canvas2 = createCanvasNode();
198+
199+
const replayer = createReplayer(requestedId => {
200+
if (requestedId === id1) return canvas1;
201+
if (requestedId === id2) return canvas2;
202+
return null;
203+
});
204+
205+
const plugin = CanvasReplayerPlugin([]);
206+
207+
// Build both canvases and create snapshots for both
208+
plugin.onBuild!(canvas1, {id: id1, replayer});
209+
plugin.onBuild!(canvas2, {id: id2, replayer});
210+
const img1 = canvas1.querySelector('img')!;
211+
const img2 = canvas2.querySelector('img')!;
212+
213+
const e1 = createCanvasEvent(id1, 1000);
214+
const e2 = createCanvasEvent(id2, 1100);
215+
plugin.handler!(e1, false, {replayer});
216+
plugin.handler!(e2, false, {replayer});
217+
218+
jest.runAllTimers();
219+
await Promise.resolve();
220+
expect(img1.src).toContain('data:image/png;base64');
221+
expect(img2.src).toContain('data:image/png;base64');
222+
223+
// Seek to a point where only id1 has a queued canvas event
224+
const e1Seek = createCanvasEvent(id1, 2000);
225+
plugin.handler!(e1Seek, true, {replayer});
226+
227+
// Trigger processing by a new realtime canvas event (could be id1 again)
228+
const e1Realtime = createCanvasEvent(id1, 2100);
229+
plugin.handler!(e1Realtime, false, {replayer});
230+
231+
jest.runAllTimers();
232+
await Promise.resolve();
233+
234+
// Since id2 was not queued during the seek, its snapshot should be cleared by the plugin
235+
expect(img1.src).toContain('data:image/png;base64');
236+
// it should be empty but it's
237+
expect(img2.src).toBe('data:,');
238+
});
239+
});

static/app/components/replays/canvasReplayerPlugin.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -232,9 +232,9 @@ export function CanvasReplayerPlugin(events: eventWithTime[]): ReplayPlugin {
232232
// event for all canvas mutation events before the current replay time
233233
const debouncedProcessQueuedEvents = debounce(
234234
function processQueuedEvents() {
235-
const canvasIds = Array.from(canvases.keys());
236235
const queuedEventIds = Array.from(handleQueue.keys());
237236
const queuedEventIdsSet = new Set(queuedEventIds);
237+
const canvasIds = Array.from(canvases.keys());
238238
const unusedCanvases = canvasIds.filter(id => !queuedEventIdsSet.has(id));
239239

240240
// Compare the canvas ids from canvas mutation events against existing
@@ -243,7 +243,8 @@ export function CanvasReplayerPlugin(events: eventWithTime[]): ReplayPlugin {
243243
unusedCanvases.forEach(id => {
244244
const el = containers.get(id);
245245
if (el) {
246-
el.src = '';
246+
// this is valid URL for a blank image
247+
el.src = 'data:,';
247248
}
248249
});
249250

@@ -332,7 +333,8 @@ export function CanvasReplayerPlugin(events: eventWithTime[]): ReplayPlugin {
332333

333334
if (node.nodeName === 'CANVAS' && node.nodeType === 1) {
334335
// Add new image container that will be written to
335-
const el = containers.get(id) || document.createElement('img');
336+
const ownerDoc = (node as Element).ownerDocument || document;
337+
const el = containers.get(id) || ownerDoc.createElement('img');
336338
(node as HTMLCanvasElement).appendChild(el);
337339
containers.set(id, el);
338340
}
@@ -343,6 +345,9 @@ export function CanvasReplayerPlugin(events: eventWithTime[]): ReplayPlugin {
343345
if (!queueItem) {
344346
return;
345347
}
348+
// Ensure that queued calls from `processEventSync` (e.g. when you seek into the middle of the replay) is called before continue to process the current event.
349+
// Otherwise, if it runs after `processEvent`, `processQueuedEvents` will incorrectly clear the canvas assuming that the last queued event == current event.
350+
debouncedProcessQueuedEvents.flush();
346351
const [event, replayer] = queueItem;
347352
processEvent(event, {replayer}).catch(handleProcessEventError);
348353
},

0 commit comments

Comments
 (0)