diff --git a/.changeset/inlineImage-maybeNot-crossOrigin.md b/.changeset/inlineImage-maybeNot-crossOrigin.md new file mode 100644 index 0000000000..89eb7bb340 --- /dev/null +++ b/.changeset/inlineImage-maybeNot-crossOrigin.md @@ -0,0 +1,6 @@ +--- +"rrweb": patch +"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/packages/rrweb-snapshot/src/snapshot.ts b/packages/rrweb-snapshot/src/snapshot.ts index 81dc2133a0..5a2eaa745b 100644 --- a/packages/rrweb-snapshot/src/snapshot.ts +++ b/packages/rrweb-snapshot/src/snapshot.ts @@ -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/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/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\\": [] - } } ]" `;