Skip to content

Commit 0d0fd78

Browse files
authored
fix(replay): Ensure replays contain canvas rendering when resumed after inactivity (#18714)
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
1 parent 40ec7f8 commit 0d0fd78

File tree

2 files changed

+35
-1
lines changed

2 files changed

+35
-1
lines changed

packages/replay-canvas/src/canvas.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ export const _replayCanvasIntegration = ((options: Partial<ReplayCanvasOptions>
7777
] as [number, number],
7878
};
7979

80+
let currentCanvasManager: CanvasManager | undefined;
8081
let canvasManagerResolve: (value: CanvasManager) => void;
8182
const _canvasManager: Promise<CanvasManager> = new Promise(resolve => (canvasManagerResolve = resolve));
8283

@@ -104,14 +105,19 @@ export const _replayCanvasIntegration = ((options: Partial<ReplayCanvasOptions>
104105
}
105106
},
106107
});
108+
109+
currentCanvasManager = manager;
110+
111+
// Resolve promise on first call for backward compatibility
107112
canvasManagerResolve(manager);
113+
108114
return manager;
109115
},
110116
...(CANVAS_QUALITY[quality || 'medium'] || CANVAS_QUALITY.medium),
111117
};
112118
},
113119
async snapshot(canvasElement?: HTMLCanvasElement, options?: SnapshotOptions) {
114-
const canvasManager = await _canvasManager;
120+
const canvasManager = currentCanvasManager || (await _canvasManager);
115121

116122
canvasManager.snapshot(canvasElement, options);
117123
},

packages/replay-canvas/test/canvas.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,3 +103,31 @@ it('has correct types', () => {
103103
const res2 = rc.snapshot(document.createElement('canvas'));
104104
expect(res2).toBeInstanceOf(Promise);
105105
});
106+
107+
it('tracks current canvas manager across multiple getCanvasManager calls', async () => {
108+
const rc = _replayCanvasIntegration({ enableManualSnapshot: true });
109+
const options = rc.getOptions();
110+
111+
// First call - simulates initial recording session
112+
// @ts-expect-error don't care about the normal options we need to call this with
113+
options.getCanvasManager({});
114+
expect(CanvasManager).toHaveBeenCalledTimes(1);
115+
116+
const mockManager1 = vi.mocked(CanvasManager).mock.results[0].value;
117+
mockManager1.snapshot = vi.fn();
118+
119+
// Second call - simulates session refresh after inactivity or max age
120+
// @ts-expect-error don't care about the normal options we need to call this with
121+
options.getCanvasManager({});
122+
expect(CanvasManager).toHaveBeenCalledTimes(2);
123+
124+
const mockManager2 = vi.mocked(CanvasManager).mock.results[1].value;
125+
mockManager2.snapshot = vi.fn();
126+
127+
void rc.snapshot();
128+
129+
await new Promise(resolve => setTimeout(resolve, 0));
130+
131+
expect(mockManager1.snapshot).toHaveBeenCalledTimes(0);
132+
expect(mockManager2.snapshot).toHaveBeenCalledTimes(1);
133+
});

0 commit comments

Comments
 (0)