diff --git a/.changeset/inlineImage-maybeNot-crossOrigin.md b/.changeset/inlineImage-maybeNot-crossOrigin.md new file mode 100644 index 0000000000..071cd95c6f --- /dev/null +++ b/.changeset/inlineImage-maybeNot-crossOrigin.md @@ -0,0 +1,6 @@ +--- +"@amplitude/rrweb": patch +"@amplitude/rrweb-snapshot": patch +--- + +inlineImages: during snapshot avoid adding an event listener for inlining of same-origin images (async listener mutates the snapshot which can be problematic) diff --git a/.changeset/kind-kids-design.md b/.changeset/kind-kids-design.md new file mode 100644 index 0000000000..7423fa6ca7 --- /dev/null +++ b/.changeset/kind-kids-design.md @@ -0,0 +1,5 @@ +--- +"@amplitude/rrweb": patch +--- + +Optimize performance of isParentRemoved by converting it to an iterative procedure diff --git a/.changeset/proud-clocks-hope.md b/.changeset/proud-clocks-hope.md new file mode 100644 index 0000000000..3c66e91ae9 --- /dev/null +++ b/.changeset/proud-clocks-hope.md @@ -0,0 +1,5 @@ +--- +"@amplitude/rrweb-snapshot": patch +--- + +(when `recordCanvas: true`): ensure we use doc.createElement instead of document.createElement to allow use in non-browser e.g. jsdom environments diff --git a/.changeset/shadow-dom-unbusify.md b/.changeset/shadow-dom-unbusify.md new file mode 100644 index 0000000000..e6c9b45771 --- /dev/null +++ b/.changeset/shadow-dom-unbusify.md @@ -0,0 +1,5 @@ +--- +"@amplitude/rrweb": patch +--- + +Refactor to preclude the need for a continuous raf loop running in the background which is related to shadowDom diff --git a/packages/rrweb-snapshot/src/rebuild.ts b/packages/rrweb-snapshot/src/rebuild.ts index 7c6ed948e6..2cb554cbfa 100644 --- a/packages/rrweb-snapshot/src/rebuild.ts +++ b/packages/rrweb-snapshot/src/rebuild.ts @@ -294,7 +294,7 @@ function buildNode( const value = specialAttributes[name]; // handle internal attributes if (tagName === 'canvas' && name === 'rr_dataURL') { - const image = document.createElement('img'); + const image = doc.createElement('img'); image.onload = () => { const ctx = (node as HTMLCanvasElement).getContext('2d'); if (ctx) { diff --git a/packages/rrweb-snapshot/src/snapshot.ts b/packages/rrweb-snapshot/src/snapshot.ts index 81dc2133a0..6fbfff7bb6 100644 --- a/packages/rrweb-snapshot/src/snapshot.ts +++ b/packages/rrweb-snapshot/src/snapshot.ts @@ -726,7 +726,7 @@ function serializeElementNode( ); // create blank canvas of same dimensions - const blankCanvas = document.createElement('canvas'); + const blankCanvas = doc.createElement('canvas'); blankCanvas.width = (n as HTMLCanvasElement).width; blankCanvas.height = (n as HTMLCanvasElement).height; const blankCanvasDataURL = blankCanvas.toDataURL( @@ -747,8 +747,9 @@ function serializeElementNode( canvasCtx = canvasService.getContext('2d'); } const image = n as HTMLImageElement; - const oldValue = image.crossOrigin; - image.crossOrigin = 'anonymous'; + const imageSrc: string = + image.currentSrc || image.getAttribute('src') || ''; + const priorCrossOrigin = image.crossOrigin; const recordInlineImage = () => { image.removeEventListener('load', recordInlineImage); try { @@ -760,13 +761,23 @@ function serializeElementNode( dataURLOptions.quality, ); } catch (err) { - console.warn( - `Cannot inline img src=${image.currentSrc}! Error: ${err as string}`, - ); + if (image.crossOrigin !== 'anonymous') { + image.crossOrigin = 'anonymous'; + if (image.complete && image.naturalWidth !== 0) + recordInlineImage(); // too early due to image reload + else image.addEventListener('load', recordInlineImage); + return; + } else { + console.warn( + `Cannot inline img src=${imageSrc}! Error: ${err as string}`, + ); + } + } + if (image.crossOrigin === 'anonymous') { + priorCrossOrigin + ? (attributes.crossOrigin = priorCrossOrigin) + : image.removeAttribute('crossorigin'); } - oldValue - ? (attributes.crossOrigin = oldValue) - : image.removeAttribute('crossorigin'); }; // The image content may not have finished loading yet. if (image.complete && image.naturalWidth !== 0) recordInlineImage(); diff --git a/packages/rrweb-snapshot/test/__snapshots__/integration.test.ts.snap b/packages/rrweb-snapshot/test/__snapshots__/integration.test.ts.snap index 39c8c49ee1..e95a718645 100644 --- a/packages/rrweb-snapshot/test/__snapshots__/integration.test.ts.snap +++ b/packages/rrweb-snapshot/test/__snapshots__/integration.test.ts.snap @@ -338,6 +338,7 @@ exports[`integration tests [html file]: mask-text.html 1`] = ` exports[`integration tests [html file]: picture.html 1`] = ` " + diff --git a/packages/rrweb-snapshot/test/html/picture.html b/packages/rrweb-snapshot/test/html/picture.html index e005310b77..2401ca0c61 100644 --- a/packages/rrweb-snapshot/test/html/picture.html +++ b/packages/rrweb-snapshot/test/html/picture.html @@ -1,6 +1,7 @@ + diff --git a/packages/rrweb-snapshot/test/images/rrweb-favicon-20x20.png b/packages/rrweb-snapshot/test/images/rrweb-favicon-20x20.png new file mode 100644 index 0000000000..561f9060d7 Binary files /dev/null and b/packages/rrweb-snapshot/test/images/rrweb-favicon-20x20.png differ diff --git a/packages/rrweb-snapshot/test/integration.test.ts b/packages/rrweb-snapshot/test/integration.test.ts index dcc6a3ec0b..f212b0fd4c 100644 --- a/packages/rrweb-snapshot/test/integration.test.ts +++ b/packages/rrweb-snapshot/test/integration.test.ts @@ -6,7 +6,7 @@ import * as puppeteer from 'puppeteer'; import * as rollup from 'rollup'; import * as typescript from 'rollup-plugin-typescript2'; import * as assert from 'assert'; -import { waitForRAF } from './utils'; +import { waitForRAF, getServerURL } from './utils'; const _typescript = typescript as unknown as () => rollup.Plugin; @@ -209,12 +209,63 @@ iframe.contentDocument.querySelector('center').clientHeight inlineImages: true, inlineStylesheet: false })`); - await waitForRAF(page); - const snapshot = (await page.evaluate( - 'JSON.stringify(snapshot, null, 2);', - )) as string; - assert(snapshot.includes('"rr_dataURL"')); - assert(snapshot.includes('data:image/webp;base64,')); + // don't wait, as we want to ensure that the same-origin image can be inlined immediately + const bodyChildren = (await page.evaluate(` + snapshot.childNodes[0].childNodes[1].childNodes.filter((cn) => cn.type === 2); +`)) as any[]; + expect(bodyChildren[1]).toEqual( + expect.objectContaining({ + tagName: 'img', + attributes: { + src: expect.stringMatching(/images\/robot.png$/), + alt: 'This is a robot', + rr_dataURL: expect.stringMatching(/^data:image\/webp;base64,/), + }, + }), + ); + }); + + it('correctly saves cross-origin images offline', async () => { + const page: puppeteer.Page = await browser.newPage(); + + await page.goto('about:blank', { + waitUntil: 'load', + }); + await page.setContent( + ` + + + CORS restricted but has access-control-allow-origin: * + + +`, + { + waitUntil: 'load', + }, + ); + + await page.waitForSelector('img', { timeout: 1000 }); + await page.evaluate(`${code}var snapshot = rrweb.snapshot(document, { + dataURLOptions: { type: "image/webp", quality: 0.8 }, + inlineImages: true, + inlineStylesheet: false + })`); + await waitForRAF(page); // need a small wait, as after the crossOrigin="anonymous" change, the snapshot triggers a reload of the image (after which, the snapshot is mutated) + const bodyChildren = (await page.evaluate(` + snapshot.childNodes[0].childNodes[1].childNodes.filter((cn) => cn.type === 2); +`)) as any[]; + expect(bodyChildren[0]).toEqual( + expect.objectContaining({ + tagName: 'img', + attributes: { + src: getServerURL(server) + '/images/rrweb-favicon-20x20.png', + alt: 'CORS restricted but has access-control-allow-origin: *', + rr_dataURL: expect.stringMatching(/^data:image\/webp;base64,/), + }, + }), + ); }); it('correctly saves blob:images offline', async () => { diff --git a/packages/rrweb-snapshot/test/snapshot.test.ts b/packages/rrweb-snapshot/test/snapshot.test.ts index aa4bb428ee..7c930c3929 100644 --- a/packages/rrweb-snapshot/test/snapshot.test.ts +++ b/packages/rrweb-snapshot/test/snapshot.test.ts @@ -7,6 +7,7 @@ import { serializeNodeWithId, _isBlockedElement, } from '../src/snapshot'; +import snapshot from '../src/snapshot'; import { serializedNodeWithId, elementNode } from '../src/types'; import { Mirror } from '../src/utils'; @@ -257,3 +258,27 @@ describe('form', () => { expect(sel?.childNodes).toEqual([]); // shouldn't be stored in childNodes while in transit }); }); + +describe('jsdom snapshot', () => { + const render = (html: string): Document => { + document.write(html); + return document; + }; + + it("doesn't rely on global browser objects", () => { + // this test is incomplete in terms of coverage, + // but the idea being that we are checking that all features use the + // passed-in `doc` object rather than the global `document` + // (which is only present in browsers) + // in any case, supporting jsdom is not a primary goal + + const doc = render(`

Hello world

`); + const sn = snapshot(doc, { + // JSDOM Error: Not implemented: HTMLCanvasElement.prototype.toDataURL (without installing the canvas npm package) + //recordCanvas: true, + }); + expect(sn).toMatchObject({ + type: 0, + }); + }); +}); diff --git a/packages/rrweb-snapshot/test/utils.ts b/packages/rrweb-snapshot/test/utils.ts index 43d4484bb4..631f8640a6 100644 --- a/packages/rrweb-snapshot/test/utils.ts +++ b/packages/rrweb-snapshot/test/utils.ts @@ -1,4 +1,5 @@ import * as puppeteer from 'puppeteer'; +import * as http from 'http'; export async function waitForRAF(page: puppeteer.Page) { return await page.evaluate(() => { @@ -9,3 +10,12 @@ export async function waitForRAF(page: puppeteer.Page) { }); }); } + +export function getServerURL(server: http.Server): string { + const address = server.address(); + if (address && typeof address !== 'string') { + return `http://localhost:${address.port}`; + } else { + return `${address}`; + } +} diff --git a/packages/rrweb/src/record/index.ts b/packages/rrweb/src/record/index.ts index 66b7fcfb3e..9a10ecedfc 100644 --- a/packages/rrweb/src/record/index.ts +++ b/packages/rrweb/src/record/index.ts @@ -125,6 +125,11 @@ function record( if (inEmittingFrame && !emit) { throw new Error('emit function is required'); } + if (!inEmittingFrame && !passEmitsToParent) { + return () => { + /* no-op since in this case we don't need to record anything from this frame in particular */ + }; + } // move departed options to new options if (mousemoveWait !== undefined && sampling.mousemove === undefined) { sampling.mousemove = mousemoveWait; diff --git a/packages/rrweb/src/record/mutation.ts b/packages/rrweb/src/record/mutation.ts index 860221e22f..3f5ce5b607 100644 --- a/packages/rrweb/src/record/mutation.ts +++ b/packages/rrweb/src/record/mutation.ts @@ -802,15 +802,15 @@ function _isParentRemoved( n: Node, mirror: Mirror, ): boolean { - const { parentNode } = n; - if (!parentNode) { - return false; - } - const parentId = mirror.getId(parentNode); - if (removes.some((r) => r.id === parentId)) { - return true; + let node: ParentNode | null = n.parentNode; + while (node) { + const parentId = mirror.getId(node); + if (removes.some((r) => r.id === parentId)) { + return true; + } + node = node.parentNode; } - return _isParentRemoved(removes, parentNode, mirror); + return false; } function isAncestorInSet(set: Set, n: Node): boolean { diff --git a/packages/rrweb/src/record/processed-node-manager.ts b/packages/rrweb/src/record/processed-node-manager.ts index b5d6c4b679..c5c3490dab 100644 --- a/packages/rrweb/src/record/processed-node-manager.ts +++ b/packages/rrweb/src/record/processed-node-manager.ts @@ -5,19 +5,8 @@ import type MutationBuffer from './mutation'; */ export default class ProcessedNodeManager { private nodeMap: WeakMap> = new WeakMap(); - // Whether to continue RAF loop. - private loop = true; - constructor() { - this.periodicallyClear(); - } - - private periodicallyClear() { - requestAnimationFrame(() => { - this.clear(); - if (this.loop) this.periodicallyClear(); - }); - } + private active = false; public inOtherBuffer(node: Node, thisBuffer: MutationBuffer) { const buffers = this.nodeMap.get(node); @@ -27,15 +16,17 @@ export default class ProcessedNodeManager { } public add(node: Node, buffer: MutationBuffer) { + if (!this.active) { + this.active = true; + requestAnimationFrame(() => { + this.nodeMap = new WeakMap(); + this.active = false; + }); + } this.nodeMap.set(node, (this.nodeMap.get(node) || new Set()).add(buffer)); } - private clear() { - this.nodeMap = new WeakMap(); - } - public destroy() { - // Stop the RAF loop. - this.loop = false; + // cleanup no longer needed } } diff --git a/packages/rrweb/test/__snapshots__/integration.test.ts.snap b/packages/rrweb/test/__snapshots__/integration.test.ts.snap index f349bd2669..1572b675b6 100644 --- a/packages/rrweb/test/__snapshots__/integration.test.ts.snap +++ b/packages/rrweb/test/__snapshots__/integration.test.ts.snap @@ -12777,40 +12777,6 @@ exports[`record integration tests should record images inside iframe with blob u } ] } - }, - { - \\"type\\": 3, - \\"data\\": { - \\"source\\": 0, - \\"texts\\": [], - \\"attributes\\": [ - { - \\"id\\": 41, - \\"attributes\\": { - \\"crossorigin\\": \\"anonymous\\" - } - } - ], - \\"removes\\": [], - \\"adds\\": [] - } - }, - { - \\"type\\": 3, - \\"data\\": { - \\"source\\": 0, - \\"texts\\": [], - \\"attributes\\": [ - { - \\"id\\": 41, - \\"attributes\\": { - \\"crossorigin\\": null - } - } - ], - \\"removes\\": [], - \\"adds\\": [] - } } ]" `; @@ -13245,40 +13211,6 @@ exports[`record integration tests should record images inside iframe with blob u } ] } - }, - { - \\"type\\": 3, - \\"data\\": { - \\"source\\": 0, - \\"texts\\": [], - \\"attributes\\": [ - { - \\"id\\": 47, - \\"attributes\\": { - \\"crossorigin\\": \\"anonymous\\" - } - } - ], - \\"removes\\": [], - \\"adds\\": [] - } - }, - { - \\"type\\": 3, - \\"data\\": { - \\"source\\": 0, - \\"texts\\": [], - \\"attributes\\": [ - { - \\"id\\": 47, - \\"attributes\\": { - \\"crossorigin\\": null - } - } - ], - \\"removes\\": [], - \\"adds\\": [] - } } ]" `; @@ -13486,40 +13418,6 @@ exports[`record integration tests should record images with blob url 1`] = ` } ] } - }, - { - \\"type\\": 3, - \\"data\\": { - \\"source\\": 0, - \\"texts\\": [], - \\"attributes\\": [ - { - \\"id\\": 24, - \\"attributes\\": { - \\"crossorigin\\": \\"anonymous\\" - } - } - ], - \\"removes\\": [], - \\"adds\\": [] - } - }, - { - \\"type\\": 3, - \\"data\\": { - \\"source\\": 0, - \\"texts\\": [], - \\"attributes\\": [ - { - \\"id\\": 24, - \\"attributes\\": { - \\"crossorigin\\": null - } - } - ], - \\"removes\\": [], - \\"adds\\": [] - } } ]" `; diff --git a/packages/rrweb/test/benchmark/dom-mutation.test.ts b/packages/rrweb/test/benchmark/dom-mutation.test.ts index 4bf3109e2c..33bfb2566d 100644 --- a/packages/rrweb/test/benchmark/dom-mutation.test.ts +++ b/packages/rrweb/test/benchmark/dom-mutation.test.ts @@ -18,6 +18,12 @@ const suites: Array< // eval: 'document.querySelector("button").click()', // times: 10, // }, + { + title: 'create 1000x 1 DOM nodes with deeply nested children', + html: 'benchmark-dom-mutation-deep-nested.html', + eval: 'window.workload()', + times: 10, + }, { title: 'create 1000x10 DOM nodes', html: 'benchmark-dom-mutation.html', diff --git a/packages/rrweb/test/html/benchmark-dom-mutation-deep-nested.html b/packages/rrweb/test/html/benchmark-dom-mutation-deep-nested.html new file mode 100644 index 0000000000..fd0a4258b2 --- /dev/null +++ b/packages/rrweb/test/html/benchmark-dom-mutation-deep-nested.html @@ -0,0 +1,31 @@ + + + +