From dc5baf617150b8334db10589173ceadfb3c16d4f Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Wed, 7 Jan 2026 14:26:09 +0100 Subject: [PATCH] fix(replay): Ensure replays contain canvas rendering when resumed after inactivity Replays of apps that use canvas elements that are resumed after a long period of inactivity (for example when navigating away and back to a tab after 5 minutes) were previously broken. Replays contained all DOM elements, including the canvas, but the canvas would not have any of its rendering captured. This happens because before resuming from inactivity, `getCanvasManager` creates a new `CanvasManager` that is then passed to a promise resolve function that was already resolved beforehand. That leads to the new canvas manager not actually being used when returning from inactivity and thus having all rendering attempted to be captured from the previous canvas manager instead of the new one. For backwards compatibility, I kept the promise based approach around and added a second storage variable for the canvas manager. I attempted to create integration tests but was not able to reproduce this issue in an integration test so I opted for just a basic unit test. I did reproduce this issue in a sample app locally and captured two replays: 1) The [first replay](https://sentry-sdks.sentry.io/explore/replays/26cd46702dc448148c0c887edaa10aec/?playlistEnd=2026-01-07T13%3A05%3A52&playlistStart=2026-01-07T12%3A05%3A52&project=4507937458552832&query=&referrer=replayList) uses our CDN bundles and shows canvas rendering captured at first but missing towards the end of the replay. 2) The [second replay](https://sentry-sdks.sentry.io/explore/replays/765c4b98474242b0a0e690e16b59ab7f/?playlistEnd=2026-01-07T13%3A13%3A23&playlistStart=2026-01-07T12%3A13%3A23&project=4507937458552832&query=&referrer=replayList) uses bundles built from this PR and shows canvas rendering continues towards the end of the replay. Closes: #18682 --- packages/replay-canvas/src/canvas.ts | 8 ++++++- packages/replay-canvas/test/canvas.test.ts | 28 ++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/packages/replay-canvas/src/canvas.ts b/packages/replay-canvas/src/canvas.ts index 7861572b190f..0ed2b49d237e 100644 --- a/packages/replay-canvas/src/canvas.ts +++ b/packages/replay-canvas/src/canvas.ts @@ -77,6 +77,7 @@ export const _replayCanvasIntegration = ((options: Partial ] as [number, number], }; + let currentCanvasManager: CanvasManager | undefined; let canvasManagerResolve: (value: CanvasManager) => void; const _canvasManager: Promise = new Promise(resolve => (canvasManagerResolve = resolve)); @@ -104,14 +105,19 @@ export const _replayCanvasIntegration = ((options: Partial } }, }); + + currentCanvasManager = manager; + + // Resolve promise on first call for backward compatibility canvasManagerResolve(manager); + return manager; }, ...(CANVAS_QUALITY[quality || 'medium'] || CANVAS_QUALITY.medium), }; }, async snapshot(canvasElement?: HTMLCanvasElement, options?: SnapshotOptions) { - const canvasManager = await _canvasManager; + const canvasManager = currentCanvasManager || (await _canvasManager); canvasManager.snapshot(canvasElement, options); }, diff --git a/packages/replay-canvas/test/canvas.test.ts b/packages/replay-canvas/test/canvas.test.ts index 1acfeab69d21..ee51c91ce47a 100644 --- a/packages/replay-canvas/test/canvas.test.ts +++ b/packages/replay-canvas/test/canvas.test.ts @@ -103,3 +103,31 @@ it('has correct types', () => { const res2 = rc.snapshot(document.createElement('canvas')); expect(res2).toBeInstanceOf(Promise); }); + +it('tracks current canvas manager across multiple getCanvasManager calls', async () => { + const rc = _replayCanvasIntegration({ enableManualSnapshot: true }); + const options = rc.getOptions(); + + // First call - simulates initial recording session + // @ts-expect-error don't care about the normal options we need to call this with + options.getCanvasManager({}); + expect(CanvasManager).toHaveBeenCalledTimes(1); + + const mockManager1 = vi.mocked(CanvasManager).mock.results[0].value; + mockManager1.snapshot = vi.fn(); + + // Second call - simulates session refresh after inactivity or max age + // @ts-expect-error don't care about the normal options we need to call this with + options.getCanvasManager({}); + expect(CanvasManager).toHaveBeenCalledTimes(2); + + const mockManager2 = vi.mocked(CanvasManager).mock.results[1].value; + mockManager2.snapshot = vi.fn(); + + void rc.snapshot(); + + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(mockManager1.snapshot).toHaveBeenCalledTimes(0); + expect(mockManager2.snapshot).toHaveBeenCalledTimes(1); +});