diff --git a/static/app/components/replays/canvasReplayerPlugin.tsx b/static/app/components/replays/canvasReplayerPlugin.tsx index 669b0f29f26465..06a352c527d2d6 100644 --- a/static/app/components/replays/canvasReplayerPlugin.tsx +++ b/static/app/components/replays/canvasReplayerPlugin.tsx @@ -10,6 +10,7 @@ import { type ReplayPlugin, } from '@sentry-internal/rrweb'; import type {CanvasArg} from '@sentry-internal/rrweb-types'; +import debounce from 'lodash/debounce'; import {deserializeCanvasArg} from './deserializeCanvasArgs'; @@ -25,6 +26,8 @@ function isCanvasMutationEvent(e: eventWithTime): e is CanvasEventWithTime { ); } +class InvalidCanvasNodeError extends Error {} + /** * Find the lowest matching index for event */ @@ -79,6 +82,11 @@ export function CanvasReplayerPlugin(events: eventWithTime[]): ReplayPlugin { // if a deserialization of an event is in progress so that it can be skipped if so. const preloadQueue = new Set(); const eventsToPrune: eventWithTime[] = []; + // In the case where replay is not started and user seeks, `handler` can be + // called before the DOM is fully built. This means that nodes do not yet + // exist in DOM mirror. We need to replay these events when `onBuild` is + // called. + const handleQueue = new Map(); // This is a pointer to the index of the next event that will need to be // preloaded. Most of the time the recording plays sequentially, so we do not @@ -193,6 +201,74 @@ export function CanvasReplayerPlugin(events: eventWithTime[]): ReplayPlugin { } } + const debouncedProcessQueuedEvents = debounce( + function () { + Array.from(handleQueue.entries()).forEach(async ([id, [e, replayer]]) => { + try { + await processEvent(e, {replayer}); + handleQueue.delete(id); + } catch (err) { + if (!(err instanceof InvalidCanvasNodeError)) { + Sentry.captureException(err); + } + } + }); + }, + 250, + {maxWait: 1000} + ); + + /** + * In the case where mirror DOM is built, we only want to process the most + * recent sync event, otherwise the playback will look like it's playing if + * we process all events. + */ + function processEventSync(e: CanvasEventWithTime, {replayer}: {replayer: Replayer}) { + // We want to only process the most recent sync event + handleQueue.set(e.data.id, [e, replayer]); + debouncedProcessQueuedEvents(); + } + + /** + * Processes canvas mutation events + */ + async function processEvent(e: CanvasEventWithTime, {replayer}: {replayer: Replayer}) { + preload(e); + + const source = replayer.getMirror().getNode(e.data.id); + const target = + canvases.get(e.data.id) || + (source && cloneCanvas(e.data.id, source as HTMLCanvasElement)); + + if (!target) { + throw new InvalidCanvasNodeError('No canvas found for id'); + } + + await canvasMutation({ + event: e, + mutation: e.data, + target, + imageMap, + canvasEventMap, + errorHandler: (err: unknown) => { + if (err instanceof Error) { + Sentry.captureException(err); + } else { + Sentry.metrics.increment('replay.canvas_player.error_canvas_mutation'); + } + }, + }); + + const img = containers.get(e.data.id); + if (img) { + img.src = target.toDataURL(); + img.style.maxWidth = '100%'; + img.style.maxHeight = '100%'; + } + + prune(e); + } + preload(); return { @@ -212,69 +288,65 @@ export function CanvasReplayerPlugin(events: eventWithTime[]): ReplayPlugin { (node as HTMLCanvasElement).appendChild(el); containers.set(id, el); } + + // See comments at definition of `handleQueue` + const queueItem = handleQueue.get(id); + handleQueue.delete(id); + if (!queueItem) { + return; + } + const [event, replayer] = queueItem; + try { + processEvent(event, {replayer}); + } catch (err) { + Sentry.captureException(err); + } }, /** * Mutate canvas outside of iframe, then export the canvas as an image, and * draw inside of the image el inside of replay canvas. */ - handler: async ( - e: eventWithTime, - isSync: boolean, - {replayer}: {replayer: Replayer} - ) => { + handler: (e: eventWithTime, isSync: boolean, {replayer}: {replayer: Replayer}) => { + const isCanvas = isCanvasMutationEvent(e); + // isSync = true means it is fast forwarding vs playing // nothing to do when fast forwarding since canvas mutations for us are // image snapshots and do not depend on past events - if (isSync || !isCanvasMutationEvent(e)) { - if (isSync) { - // Set this to -1 to indicate that we will need to search - // `canvasMutationEvents` for starting point of preloading - // - // Only do this when isSync is true, meaning there was a seek - nextPreloadIndex = -1; + if (isSync) { + // Set this to -1 to indicate that we will need to search + // `canvasMutationEvents` for starting point of preloading + // + // Only do this when isSync is true, meaning there was a seek, since we + // don't know where next index is + nextPreloadIndex = -1; + + if (isCanvas) { + processEventSync(e, {replayer}); } + prune(e); return; } - preload(e); - - const source = replayer.getMirror().getNode(e.data.id); - const target = - canvases.get(e.data.id) || - (source && cloneCanvas(e.data.id, source as HTMLCanvasElement)); + if (!isCanvas) { + // Otherwise, not `isSync` and not canvas, only need to prune + prune(e); - // No canvas found for id... this isn't reliably reproducible and not - // exactly sure why it flakes. Saving as metric to keep an eye on it. - if (!target) { - Sentry.metrics.increment('replay.canvas_player.no_canvas_id'); return; } - await canvasMutation({ - event: e, - mutation: e.data, - target, - imageMap, - canvasEventMap, - errorHandler: (err: unknown) => { - if (err instanceof Error) { - Sentry.captureException(err); - } else { - Sentry.metrics.increment('replay.canvas_player.error_canvas_mutation'); - } - }, - }); + try { + processEvent(e, {replayer}); + } catch (err) { + if (err instanceof InvalidCanvasNodeError) { + // This can throw if mirror DOM is not ready + Sentry.metrics.increment('replay.canvas_player.no_canvas_id'); + return; + } - const img = containers.get(e.data.id); - if (img) { - img.src = target.toDataURL(); - img.style.maxWidth = '100%'; - img.style.maxHeight = '100%'; + Sentry.captureException(err); } - - prune(e); }, }; }