Skip to content
Merged
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
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
  • Loading branch information
andreiborza committed Jan 7, 2026
commit dc5baf617150b8334db10589173ceadfb3c16d4f
8 changes: 7 additions & 1 deletion packages/replay-canvas/src/canvas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ export const _replayCanvasIntegration = ((options: Partial<ReplayCanvasOptions>
] as [number, number],
};

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

Expand Down Expand Up @@ -104,14 +105,19 @@ export const _replayCanvasIntegration = ((options: Partial<ReplayCanvasOptions>
}
},
});

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);
},
Expand Down
28 changes: 28 additions & 0 deletions packages/replay-canvas/test/canvas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Loading