diff --git a/.changeset/canvas-layout-dimensions.md b/.changeset/canvas-layout-dimensions.md new file mode 100644 index 0000000000..4dc85a4fe1 --- /dev/null +++ b/.changeset/canvas-layout-dimensions.md @@ -0,0 +1,6 @@ +--- +"rrweb-snapshot": patch +"rrweb": patch +--- + +Fix problem where without `recordCanvas: true`, a canvas element is replayed without dimensions, potentially affecting layout diff --git a/packages/rrweb-snapshot/package.json b/packages/rrweb-snapshot/package.json index 8d4a082f30..83d5ab7961 100644 --- a/packages/rrweb-snapshot/package.json +++ b/packages/rrweb-snapshot/package.json @@ -56,6 +56,7 @@ }, "homepage": "https://github.com/rrweb-io/rrweb/tree/master/packages/rrweb-snapshot#readme", "devDependencies": { + "@types/jest-image-snapshot": "^6.1.0", "@rrweb/types": "^2.0.0-alpha.20", "@rrweb/utils": "^2.0.0-alpha.20", "@types/jsdom": "^20.0.0", @@ -67,6 +68,7 @@ "typescript": "^5.4.5", "vite": "^6.0.1", "vite-plugin-dts": "^3.9.1", + "jest-image-snapshot": "^6.2.0", "vitest": "^1.4.0" }, "dependencies": { diff --git a/packages/rrweb-snapshot/src/rebuild.ts b/packages/rrweb-snapshot/src/rebuild.ts index 0ee9b0f983..ef8c1f68e9 100644 --- a/packages/rrweb-snapshot/src/rebuild.ts +++ b/packages/rrweb-snapshot/src/rebuild.ts @@ -467,6 +467,9 @@ function buildNode( if (name === 'rr_width') { (node as HTMLElement).style.setProperty('width', value.toString()); + if (tagName === 'canvas') { + (node as HTMLElement).style.setProperty('display', 'block'); + } } else if (name === 'rr_height') { (node as HTMLElement).style.setProperty('height', value.toString()); } else if ( diff --git a/packages/rrweb-snapshot/src/snapshot.ts b/packages/rrweb-snapshot/src/snapshot.ts index b5426a1751..badff9d465 100644 --- a/packages/rrweb-snapshot/src/snapshot.ts +++ b/packages/rrweb-snapshot/src/snapshot.ts @@ -655,8 +655,8 @@ function serializeElementNode( } // canvas image data - if (tagName === 'canvas' && recordCanvas) { - if ((n as ICanvas).__context === '2d') { + if (tagName === 'canvas') { + if (recordCanvas && (n as ICanvas).__context === '2d') { // only record this on 2d canvas if (!is2DCanvasBlank(n as HTMLCanvasElement)) { attributes.rr_dataURL = (n as HTMLCanvasElement).toDataURL( @@ -664,7 +664,7 @@ function serializeElementNode( dataURLOptions.quality, ); } - } else if (!('__context' in n)) { + } else if (recordCanvas && !('__context' in n)) { // context is unknown, better not call getContext to trigger it const canvasDataURL = (n as HTMLCanvasElement).toDataURL( dataURLOptions.type, @@ -684,6 +684,14 @@ function serializeElementNode( if (canvasDataURL !== blankCanvasDataURL) { attributes.rr_dataURL = canvasDataURL; } + } else { + // explicitly record dimensions in case the unrecorded canvas affects layout; + // normally with UNSAFE_replayCanvas during replay the `canvas.getContext()` + // initialization will force browser to commit to the canvas's intrisic size + // as layout size + const { width, height } = n.getBoundingClientRect(); + attributes.rr_width = `${width}px`; + attributes.rr_height = `${height}px`; } } // save image offline diff --git a/packages/rrweb-snapshot/test/__image_snapshots__/canvas-layout-rebuilt.png b/packages/rrweb-snapshot/test/__image_snapshots__/canvas-layout-rebuilt.png new file mode 100644 index 0000000000..1e2f5a2843 Binary files /dev/null and b/packages/rrweb-snapshot/test/__image_snapshots__/canvas-layout-rebuilt.png differ diff --git a/packages/rrweb-snapshot/test/__snapshots__/integration.test.ts.snap b/packages/rrweb-snapshot/test/__snapshots__/integration.test.ts.snap index d3f4a0e44c..50b6d158bc 100644 --- a/packages/rrweb-snapshot/test/__snapshots__/integration.test.ts.snap +++ b/packages/rrweb-snapshot/test/__snapshots__/integration.test.ts.snap @@ -306,6 +306,15 @@ exports[`integration tests > [html file]: block-element.html 1`] = ` " `; +exports[`integration tests > [html file]: canvas-layout.html 1`] = ` +" +
+ \\"This + +
+ " +`; + exports[`integration tests > [html file]: compat-mode.html 1`] = ` " Compat Mode; image resizing diff --git a/packages/rrweb-snapshot/test/html/canvas-layout.html b/packages/rrweb-snapshot/test/html/canvas-layout.html new file mode 100644 index 0000000000..68b427c893 --- /dev/null +++ b/packages/rrweb-snapshot/test/html/canvas-layout.html @@ -0,0 +1,8 @@ + + +
+ This is a robot + +
+ + diff --git a/packages/rrweb-snapshot/test/integration.test.ts b/packages/rrweb-snapshot/test/integration.test.ts index 2e11859a2f..d64e3df29e 100644 --- a/packages/rrweb-snapshot/test/integration.test.ts +++ b/packages/rrweb-snapshot/test/integration.test.ts @@ -14,6 +14,7 @@ import { vi, } from 'vitest'; +import { toMatchImageSnapshot } from 'jest-image-snapshot'; import { getServerURL, waitForRAF } from './utils'; const htmlFolder = path.join(__dirname, 'html'); @@ -560,3 +561,61 @@ describe('shadow DOM integration tests', function (this: ISuite) { await assertSnapshot(snapshotResult); }); }); + +describe('snapshot/rebuild image tests', function (this: ISuite) { + expect.extend({ toMatchImageSnapshot }); + vi.setConfig({ testTimeout: 30_000 }); + let server: ISuite['server']; + let serverURL: ISuite['serverURL']; + let browser: ISuite['browser']; + let code: ISuite['code']; + + beforeAll(async () => { + server = await startServer(); + serverURL = getServerURL(server); + browser = await puppeteer.launch({ + args: ['--no-sandbox', '--disable-setuid-sandbox'], + }); + code = fs.readFileSync( + path.resolve(__dirname, '../dist/rrweb-snapshot.umd.cjs'), + 'utf-8', + ); + }); + + afterAll(async () => { + await browser.close(); + await server.close(); + }); + + it('canvas dimensions are preserved after rebuild', async () => { + const page: puppeteer.Page = await browser.newPage(); + page.on('console', (msg) => console.log(msg.text())); + + // background-grey should extend to full rhs if
is dimensioned properly + await page.goto(`${serverURL}/html/canvas-layout.html`, { + waitUntil: 'load', + }); + await waitForRAF(page); + + await page.evaluate(`${code} + const snap = rrwebSnapshot.snapshot(document); + const { iframe, node } = rrwebSnapshot.rebuildIntoSandboxedIframe( + snap, { + root: document.body, + } + ); + iframe.id = 'rebuild-iframe'; + iframe.setAttribute('width', document.body.clientWidth); + iframe.setAttribute('height', document.body.clientHeight); + `); + await waitForRAF(page); + + const iframeElement = await page.$('#rebuild-iframe'); + const rebuildImage = await iframeElement!.screenshot(); + expect(rebuildImage).toMatchImageSnapshot({ + customSnapshotIdentifier: 'canvas-layout-rebuilt', + failureThreshold: 0.05, + failureThresholdType: 'percent', + }); + }); +}); diff --git a/packages/rrweb/test/__snapshots__/integration.test.ts.snap b/packages/rrweb/test/__snapshots__/integration.test.ts.snap index 738f2fe8e2..c199012c9a 100644 --- a/packages/rrweb/test/__snapshots__/integration.test.ts.snap +++ b/packages/rrweb/test/__snapshots__/integration.test.ts.snap @@ -698,7 +698,10 @@ exports[`record integration tests > can mask character data mutations 1`] = ` { \\"type\\": 2, \\"tagName\\": \\"canvas\\", - \\"attributes\\": {}, + \\"attributes\\": { + \\"rr_width\\": \\"300px\\", + \\"rr_height\\": \\"150px\\" + }, \\"childNodes\\": [], \\"id\\": 15 }, @@ -916,7 +919,10 @@ exports[`record integration tests > can mask character data mutations with regex { \\"type\\": 2, \\"tagName\\": \\"canvas\\", - \\"attributes\\": {}, + \\"attributes\\": { + \\"rr_width\\": \\"300px\\", + \\"rr_height\\": \\"150px\\" + }, \\"childNodes\\": [], \\"id\\": 15 }, @@ -1803,7 +1809,10 @@ exports[`record integration tests > can record attribute mutation 1`] = ` { \\"type\\": 2, \\"tagName\\": \\"canvas\\", - \\"attributes\\": {}, + \\"attributes\\": { + \\"rr_width\\": \\"300px\\", + \\"rr_height\\": \\"150px\\" + }, \\"childNodes\\": [], \\"id\\": 15 }, @@ -1974,7 +1983,10 @@ exports[`record integration tests > can record character data muatations 1`] = ` { \\"type\\": 2, \\"tagName\\": \\"canvas\\", - \\"attributes\\": {}, + \\"attributes\\": { + \\"rr_width\\": \\"300px\\", + \\"rr_height\\": \\"150px\\" + }, \\"childNodes\\": [], \\"id\\": 15 }, @@ -2152,7 +2164,10 @@ exports[`record integration tests > can record childList mutations 1`] = ` { \\"type\\": 2, \\"tagName\\": \\"canvas\\", - \\"attributes\\": {}, + \\"attributes\\": { + \\"rr_width\\": \\"300px\\", + \\"rr_height\\": \\"150px\\" + }, \\"childNodes\\": [], \\"id\\": 15 }, @@ -5470,7 +5485,10 @@ exports[`record integration tests > handles null attribute values 1`] = ` { \\"type\\": 2, \\"tagName\\": \\"canvas\\", - \\"attributes\\": {}, + \\"attributes\\": { + \\"rr_width\\": \\"300px\\", + \\"rr_height\\": \\"150px\\" + }, \\"childNodes\\": [], \\"id\\": 15 }, @@ -17415,7 +17433,10 @@ exports[`record integration tests > will serialize node before record 1`] = ` { \\"type\\": 2, \\"tagName\\": \\"canvas\\", - \\"attributes\\": {}, + \\"attributes\\": { + \\"rr_width\\": \\"300px\\", + \\"rr_height\\": \\"150px\\" + }, \\"childNodes\\": [], \\"id\\": 15 }, diff --git a/packages/rrweb/test/record/__snapshots__/cross-origin-iframes.test.ts.snap b/packages/rrweb/test/record/__snapshots__/cross-origin-iframes.test.ts.snap index 8cd9fce959..f986d78ce1 100644 --- a/packages/rrweb/test/record/__snapshots__/cross-origin-iframes.test.ts.snap +++ b/packages/rrweb/test/record/__snapshots__/cross-origin-iframes.test.ts.snap @@ -4873,7 +4873,10 @@ exports[`cross origin iframes > move-node.html > should record canvas elements 1 \\"node\\": { \\"type\\": 2, \\"tagName\\": \\"canvas\\", - \\"attributes\\": {}, + \\"attributes\\": { + \\"rr_width\\": \\"300px\\", + \\"rr_height\\": \\"150px\\" + }, \\"childNodes\\": [], \\"rootId\\": 11, \\"id\\": 33 diff --git a/packages/rrweb/test/record/__snapshots__/webgl.test.ts.snap b/packages/rrweb/test/record/__snapshots__/webgl.test.ts.snap index 8a9b0c1fc2..2b4609374e 100644 --- a/packages/rrweb/test/record/__snapshots__/webgl.test.ts.snap +++ b/packages/rrweb/test/record/__snapshots__/webgl.test.ts.snap @@ -467,7 +467,10 @@ exports[`record webgl > will record changes to a canvas element before the canva \\"node\\": { \\"type\\": 2, \\"tagName\\": \\"canvas\\", - \\"attributes\\": {}, + \\"attributes\\": { + \\"rr_width\\": \\"300px\\", + \\"rr_height\\": \\"150px\\" + }, \\"childNodes\\": [], \\"id\\": 11 } @@ -611,7 +614,10 @@ exports[`record webgl > will record changes to a canvas element before the canva \\"node\\": { \\"type\\": 2, \\"tagName\\": \\"canvas\\", - \\"attributes\\": {}, + \\"attributes\\": { + \\"rr_width\\": \\"300px\\", + \\"rr_height\\": \\"150px\\" + }, \\"childNodes\\": [], \\"id\\": 11 }