Skip to content

Commit

Permalink
Merge pull request #39 from amplitude/rrweb-sync-alpha-15
Browse files Browse the repository at this point in the history
rrweb sync alpha 15 (pre-refactor)
  • Loading branch information
jxiwang authored Oct 17, 2024
2 parents a7e0986 + a622c56 commit c306c80
Show file tree
Hide file tree
Showing 18 changed files with 196 additions and 145 deletions.
6 changes: 6 additions & 0 deletions .changeset/inlineImage-maybeNot-crossOrigin.md
Original file line number Diff line number Diff line change
@@ -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)
5 changes: 5 additions & 0 deletions .changeset/kind-kids-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@amplitude/rrweb": patch
---

Optimize performance of isParentRemoved by converting it to an iterative procedure
5 changes: 5 additions & 0 deletions .changeset/proud-clocks-hope.md
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions .changeset/shadow-dom-unbusify.md
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion packages/rrweb-snapshot/src/rebuild.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
29 changes: 20 additions & 9 deletions packages/rrweb-snapshot/src/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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') || '<unknown-src>';
const priorCrossOrigin = image.crossOrigin;
const recordInlineImage = () => {
image.removeEventListener('load', recordInlineImage);
try {
Expand All @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,7 @@ exports[`integration tests [html file]: mask-text.html 1`] = `
exports[`integration tests [html file]: picture.html 1`] = `
"<html xmlns=\\"http://www.w3.org/1999/xhtml\\"><head></head><body>
<picture>
<!-- these are 404 - not sure if that's intentional -->
<source type=\\"image/webp\\" srcset=\\"http://localhost:3030/assets/img/characters/robot.webp\\" />
<img src=\\"http://localhost:3030/assets/img/characters/robot.png\\" />
</picture>
Expand Down
1 change: 1 addition & 0 deletions packages/rrweb-snapshot/test/html/picture.html
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<html xmlns="http://www.w3.org/1999/xhtml">
<body>
<picture>
<!-- these are 404 - not sure if that's intentional -->
<source type="image/webp" srcset="assets/img/characters/robot.webp" />
<img src="assets/img/characters/robot.png" />
</picture>
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
65 changes: 58 additions & 7 deletions packages/rrweb-snapshot/test/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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(
`
<html xmlns="http://www.w3.org/1999/xhtml">
<body>
<img src="${getServerURL(
server,
)}/images/rrweb-favicon-20x20.png" alt="CORS restricted but has access-control-allow-origin: *" />
</body>
</html>
`,
{
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 () => {
Expand Down
25 changes: 25 additions & 0 deletions packages/rrweb-snapshot/test/snapshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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(`<!DOCTYPE html><p>Hello world</p><canvas></canvas>`);
const sn = snapshot(doc, {
// JSDOM Error: Not implemented: HTMLCanvasElement.prototype.toDataURL (without installing the canvas npm package)
//recordCanvas: true,
});
expect(sn).toMatchObject({
type: 0,
});
});
});
10 changes: 10 additions & 0 deletions packages/rrweb-snapshot/test/utils.ts
Original file line number Diff line number Diff line change
@@ -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(() => {
Expand All @@ -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}`;
}
}
5 changes: 5 additions & 0 deletions packages/rrweb/src/record/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,11 @@ function record<T = eventWithTime>(
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;
Expand Down
16 changes: 8 additions & 8 deletions packages/rrweb/src/record/mutation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Node>, n: Node): boolean {
Expand Down
27 changes: 9 additions & 18 deletions packages/rrweb/src/record/processed-node-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,8 @@ import type MutationBuffer from './mutation';
*/
export default class ProcessedNodeManager {
private nodeMap: WeakMap<Node, Set<MutationBuffer>> = 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);
Expand All @@ -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
}
}
Loading

0 comments on commit c306c80

Please sign in to comment.