Skip to content

Commit a3de582

Browse files
authored
Canvas recording: Preserve drawing buffer (#1273)
* Upgrade jest to 29 and puppeteer to 16 in rrweb * Apply formatting changes * Upgrade rrweb's puppeteer to v20 * Apply formatting changes * Canvas: Reduce flickering and capturing of empty canvas elements Turn on `preserveDrawingBuffer` by default for canvas FPS recording. Has some negative performance implications, but really helps when capturing canvas. * Apply formatting changes * Include all test image snapshots in ci * Apply formatting changes * Allow more flexibility when capturing hover * Apply formatting changes * Create tiny-chairs-build.md * Apply formatting changes * Update hover.test.ts * Apply formatting changes * Document snapshotFormat jest config * Freeze `yarn.lock` in ci for reproducible dependencies * Apply formatting changes * Apply formatting changes * Revert to old style of puppeteer evaluation script notation * Apply formatting changes * Make test less flaky * Apply formatting changes * Apply formatting changes * Make tests less flaky * Apply formatting changes * Make test more robust * Apply formatting changes * Apply formatting changes * Add debugging code for test * Apply formatting changes * Also test not ignored input * Apply formatting changes * Apply formatting changes * Apply formatting changes * escape ignoreSelector * Apply formatting changes * Apply formatting changes
1 parent 36da39d commit a3de582

File tree

20 files changed

+2103
-201
lines changed

20 files changed

+2103
-201
lines changed

.changeset/tiny-chairs-build.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'rrweb': patch
3+
---
4+
5+
Canvas FPS recording: override `preserveDrawingBuffer: true` on canvas creation.
6+
Canvas replay: fix flickering canvas elemenrs.
7+
Canvas FPS recording: fix bug that wipes webgl(2) canvas backgrounds while recording.

.github/workflows/ci-cd.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ jobs:
2121
node-version: lts/*
2222

2323
- name: Install Dependencies
24-
run: yarn
24+
run: yarn install --frozen-lockfile
2525

2626
- name: Build Project
2727
run: NODE_OPTIONS='--max-old-space-size=4096' yarn build:all
@@ -37,5 +37,5 @@ jobs:
3737
if: failure()
3838
with:
3939
name: image-diff
40-
path: packages/rrweb/test/e2e/__image_snapshots__/__diff_output__/*.png
40+
path: packages/rrweb/test/*/__image_snapshots__/__diff_output__/*.png
4141
if-no-files-found: ignore

.github/workflows/release.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,15 @@ jobs:
2121
node-version: lts/*
2222

2323
- name: Install Dependencies
24-
run: yarn
24+
run: yarn install --frozen-lockfile
2525

2626
- name: Create Release Pull Request or Publish to npm
2727
id: changesets
2828
uses: changesets/action@v1
2929
with:
3030
publish: yarn run release
3131
env:
32-
NODE_OPTIONS: "--max-old-space-size=4096"
32+
NODE_OPTIONS: '--max-old-space-size=4096'
3333
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
3434
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
3535

.github/workflows/style-check.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ jobs:
1818
node-version: 16
1919
cache: 'yarn'
2020
- name: Install Dependencies
21-
run: yarn
21+
run: yarn install --frozen-lockfile
2222
- name: Build Packages
2323
run: NODE_OPTIONS='--max-old-space-size=4096' yarn build:all
2424
- name: Eslint Check

packages/rrweb/jest.config.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,13 @@ export default {
66
moduleNameMapper: {
77
'\\.css$': 'identity-obj-proxy',
88
},
9+
/**
10+
* Keeps old (pre-jest 29) snapshot format
11+
* its a bit ugly and harder to read than the new format,
12+
* so we might want to remove this in its own PR
13+
*/
14+
snapshotFormat: {
15+
escapeString: true,
16+
printBasicPrototype: true,
17+
},
918
};

packages/rrweb/package.json

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -53,30 +53,30 @@
5353
"@types/chai": "^4.1.6",
5454
"@types/dom-mediacapture-transform": "^0.1.3",
5555
"@types/inquirer": "^8.2.1",
56-
"@types/jest": "^27.4.1",
57-
"@types/jest-image-snapshot": "^5.1.0",
56+
"@types/jest": "^29.5.0",
57+
"@types/jest-image-snapshot": "^6.1.0",
5858
"@types/node": "^18.15.11",
5959
"@types/offscreencanvas": "^2019.6.4",
60-
"@types/puppeteer": "^5.4.4",
6160
"construct-style-sheets-polyfill": "^3.1.0",
6261
"cross-env": "^5.2.0",
6362
"esbuild": "^0.14.38",
6463
"fast-mhtml": "^1.1.9",
6564
"identity-obj-proxy": "^3.0.0",
6665
"ignore-styles": "^5.0.1",
6766
"inquirer": "^9.0.0",
68-
"jest": "^27.5.1",
69-
"jest-image-snapshot": "^5.2.0",
70-
"jest-snapshot": "^23.6.0",
71-
"puppeteer": "^11.0.0",
67+
"jest": "^29.6.0",
68+
"jest-environment-jsdom": "^29.6.0",
69+
"jest-image-snapshot": "^6.2.0",
70+
"jest-snapshot": "^29.6.2",
71+
"puppeteer": "^20.9.0",
7272
"rollup": "^2.68.0",
7373
"rollup-plugin-esbuild": "^4.9.1",
7474
"rollup-plugin-postcss": "^3.1.1",
7575
"rollup-plugin-rename-node-modules": "^1.3.1",
7676
"rollup-plugin-typescript2": "^0.31.2",
7777
"rollup-plugin-web-worker-loader": "^1.6.1",
7878
"simple-peer-light": "^9.10.0",
79-
"ts-jest": "^27.1.3",
79+
"ts-jest": "^29.1.1",
8080
"ts-node": "^10.9.1",
8181
"tslib": "^2.3.1"
8282
},

packages/rrweb/src/record/observers/canvas/canvas-manager.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ export class CanvasManager {
114114
win,
115115
blockClass,
116116
blockSelector,
117+
true,
117118
);
118119
const snapshotInProgressMap: Map<number, boolean> = new Map();
119120
const worker =
@@ -198,9 +199,12 @@ export class CanvasManager {
198199
) {
199200
// Hack to load canvas back into memory so `createImageBitmap` can grab it's contents.
200201
// Context: https://twitter.com/Juice10/status/1499775271758704643
201-
// This hack might change the background color of the canvas in the unlikely event that
202+
// Preferably we set `preserveDrawingBuffer` to true, but that's not always possible,
203+
// especially when canvas is loaded before rrweb.
204+
// This hack can wipe the background color of the canvas in the (unlikely) event that
202205
// the canvas background was changed but clear was not called directly afterwards.
203-
context?.clear(context.COLOR_BUFFER_BIT);
206+
// Example of this hack having negative side effect: https://visgl.github.io/react-map-gl/examples/layers
207+
context.clear(context.COLOR_BUFFER_BIT);
204208
}
205209
}
206210
const bitmap = await createImageBitmap(canvas);
@@ -238,6 +242,7 @@ export class CanvasManager {
238242
win,
239243
blockClass,
240244
blockSelector,
245+
false,
241246
);
242247
const canvas2DReset = initCanvas2DMutationObserver(
243248
this.processMutation.bind(this),

packages/rrweb/src/record/observers/canvas/canvas.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,15 @@ import type { ICanvas } from 'rrweb-snapshot';
22
import type { blockClass, IWindow, listenerHandler } from '@rrweb/types';
33
import { isBlocked, patch } from '../../../utils';
44

5+
function getNormalizedContextName(contextType: string) {
6+
return contextType === 'experimental-webgl' ? 'webgl' : contextType;
7+
}
8+
59
export default function initCanvasContextObserver(
610
win: IWindow,
711
blockClass: blockClass,
812
blockSelector: string | null,
13+
setPreserveDrawingBufferToTrue: boolean,
914
): listenerHandler {
1015
const handlers: listenerHandler[] = [];
1116
try {
@@ -25,7 +30,24 @@ export default function initCanvasContextObserver(
2530
...args: Array<unknown>
2631
) {
2732
if (!isBlocked(this, blockClass, blockSelector, true)) {
28-
if (!('__context' in this)) this.__context = contextType;
33+
const ctxName = getNormalizedContextName(contextType);
34+
if (!('__context' in this)) this.__context = ctxName;
35+
36+
if (
37+
setPreserveDrawingBufferToTrue &&
38+
['webgl', 'webgl2'].includes(ctxName)
39+
) {
40+
if (args[0] && typeof args[0] === 'object') {
41+
const contextAttributes = args[0] as WebGLContextAttributes;
42+
if (!contextAttributes.preserveDrawingBuffer) {
43+
contextAttributes.preserveDrawingBuffer = true;
44+
}
45+
} else {
46+
args.splice(0, 1, {
47+
preserveDrawingBuffer: true,
48+
});
49+
}
50+
}
2951
}
3052
return original.apply(this, [contextType, ...args]);
3153
};

packages/rrweb/src/replay/canvas/2d.ts

Lines changed: 47 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -4,48 +4,63 @@ import { deserializeArg } from './deserialize-args';
44

55
export default async function canvasMutation({
66
event,
7-
mutation,
7+
mutations,
88
target,
99
imageMap,
1010
errorHandler,
1111
}: {
1212
event: Parameters<Replayer['applyIncremental']>[0];
13-
mutation: canvasMutationCommand;
13+
mutations: canvasMutationCommand[];
1414
target: HTMLCanvasElement;
1515
imageMap: Replayer['imageMap'];
1616
errorHandler: Replayer['warnCanvasMutationFailed'];
1717
}): Promise<void> {
18-
try {
19-
const ctx = target.getContext('2d')!;
18+
const ctx = target.getContext('2d');
2019

21-
if (mutation.setter) {
22-
// skip some read-only type checks
23-
(ctx as unknown as Record<string, unknown>)[mutation.property] =
24-
mutation.args[0];
25-
return;
26-
}
27-
const original = ctx[
28-
mutation.property as Exclude<keyof typeof ctx, 'canvas'>
29-
] as (ctx: CanvasRenderingContext2D, args: unknown[]) => void;
20+
if (!ctx) {
21+
errorHandler(mutations[0], new Error('Canvas context is null'));
22+
return;
23+
}
24+
25+
// step 1, deserialize args, they may be async
26+
const mutationArgsPromises = mutations.map(
27+
async (mutation: canvasMutationCommand): Promise<unknown[]> => {
28+
return Promise.all(mutation.args.map(deserializeArg(imageMap, ctx)));
29+
},
30+
);
31+
const args = await Promise.all(mutationArgsPromises);
32+
// step 2 apply all mutations
33+
args.forEach((args, index) => {
34+
const mutation = mutations[index];
35+
try {
36+
if (mutation.setter) {
37+
// skip some read-only type checks
38+
(ctx as unknown as Record<string, unknown>)[mutation.property] =
39+
mutation.args[0];
40+
return;
41+
}
42+
const original = ctx[
43+
mutation.property as Exclude<keyof typeof ctx, 'canvas'>
44+
] as (ctx: CanvasRenderingContext2D, args: unknown[]) => void;
3045

31-
/**
32-
* We have serialized the image source into base64 string during recording,
33-
* which has been preloaded before replay.
34-
* So we can get call drawImage SYNCHRONOUSLY which avoid some fragile cast.
35-
*/
36-
if (
37-
mutation.property === 'drawImage' &&
38-
typeof mutation.args[0] === 'string'
39-
) {
40-
imageMap.get(event);
41-
original.apply(ctx, mutation.args);
42-
} else {
43-
const args = await Promise.all(
44-
mutation.args.map(deserializeArg(imageMap, ctx)),
45-
);
46-
original.apply(ctx, args);
46+
/**
47+
* We have serialized the image source into base64 string during recording,
48+
* which has been preloaded before replay.
49+
* So we can get call drawImage SYNCHRONOUSLY which avoid some fragile cast.
50+
*/
51+
if (
52+
mutation.property === 'drawImage' &&
53+
typeof mutation.args[0] === 'string'
54+
) {
55+
imageMap.get(event);
56+
original.apply(ctx, mutation.args);
57+
} else {
58+
original.apply(ctx, args);
59+
}
60+
} catch (error) {
61+
errorHandler(mutation, error);
4762
}
48-
} catch (error) {
49-
errorHandler(mutation, error);
50-
}
63+
64+
return;
65+
});
5166
}

packages/rrweb/src/replay/canvas/index.ts

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -46,16 +46,13 @@ export default async function canvasMutation({
4646
return;
4747
}
4848
// default is '2d' for backwards compatibility (rrweb below 1.1.x)
49-
for (let i = 0; i < commands.length; i++) {
50-
const command = commands[i];
51-
await canvas2DMutation({
52-
event,
53-
mutation: command,
54-
target,
55-
imageMap,
56-
errorHandler,
57-
});
58-
}
49+
await canvas2DMutation({
50+
event,
51+
mutations: commands,
52+
target,
53+
imageMap,
54+
errorHandler,
55+
});
5956
} catch (error) {
6057
errorHandler(mutation, error);
6158
}

0 commit comments

Comments
 (0)