From 0c6db642ff0d0bc85288ca52fc52355d093825f8 Mon Sep 17 00:00:00 2001 From: huangkairan <56213366+huangkairan@users.noreply.github.com> Date: Thu, 19 Oct 2023 22:10:21 -0500 Subject: [PATCH 01/28] =?UTF-8?q?=F0=9F=90=9E=20fix(web-extension):=20typo?= =?UTF-8?q?=20(#1307)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Yun Feng --- .changeset/tiny-candles-whisper.md | 5 +++++ packages/web-extension/src/utils/channel.ts | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 .changeset/tiny-candles-whisper.md diff --git a/.changeset/tiny-candles-whisper.md b/.changeset/tiny-candles-whisper.md new file mode 100644 index 0000000000..770cb86f51 --- /dev/null +++ b/.changeset/tiny-candles-whisper.md @@ -0,0 +1,5 @@ +--- +'@rrweb/web-extension': patch +--- + +🐞 fix(web-extension): typo diff --git a/packages/web-extension/src/utils/channel.ts b/packages/web-extension/src/utils/channel.ts index 4268811eac..1a8e9b2a82 100644 --- a/packages/web-extension/src/utils/channel.ts +++ b/packages/web-extension/src/utils/channel.ts @@ -33,7 +33,7 @@ class Channel { private emitter = mitt(); constructor() { /** - * Register massage listener. + * Register message listener. */ Browser.runtime.onMessage.addListener( ((message: string, sender: Runtime.MessageSender) => { From 53b18a954d09c487fc08e46d8aa4030500f43b86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=9F=B9=E8=80=81=E6=9D=BF?= Date: Wed, 1 Nov 2023 01:20:25 +0800 Subject: [PATCH 02/28] Pref: export eventWithTime (#1324) * export eventWithTime for consumption in typescript code --- .changeset/lemon-lamps-switch.md | 5 +++++ packages/rrweb/src/index.ts | 1 + 2 files changed, 6 insertions(+) create mode 100644 .changeset/lemon-lamps-switch.md diff --git a/.changeset/lemon-lamps-switch.md b/.changeset/lemon-lamps-switch.md new file mode 100644 index 0000000000..b325dfe252 --- /dev/null +++ b/.changeset/lemon-lamps-switch.md @@ -0,0 +1,5 @@ +--- +'rrweb': patch +--- + +export eventWithTime for consumption by typescript code diff --git a/packages/rrweb/src/index.ts b/packages/rrweb/src/index.ts index 67b83cc32a..7d09a62e50 100644 --- a/packages/rrweb/src/index.ts +++ b/packages/rrweb/src/index.ts @@ -8,6 +8,7 @@ export { IncrementalSource, MouseInteractions, ReplayerEvents, + type eventWithTime } from '@amplitude/rrweb-types'; export type { recordOptions } from './types'; From 8fa01a10f06f8afbe187837006326560ee095597 Mon Sep 17 00:00:00 2001 From: Justin Halsall Date: Fri, 3 Nov 2023 11:40:54 +0100 Subject: [PATCH 03/28] Fix linting issues (#1347) * Fix linting issues * Apply formatting changes --- packages/rrweb/src/record/observer.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/rrweb/src/record/observer.ts b/packages/rrweb/src/record/observer.ts index 3081238129..af6134bf05 100644 --- a/packages/rrweb/src/record/observer.ts +++ b/packages/rrweb/src/record/observer.ts @@ -1301,9 +1301,13 @@ export function initObservers( const inputHandler = initInputObserver(o); const mediaInteractionHandler = initMediaInteractionObserver(o); + // eslint-disable-next-line @typescript-eslint/no-empty-function let styleSheetObserver = () => {}; + // eslint-disable-next-line @typescript-eslint/no-empty-function let adoptedStyleSheetObserver = () => {}; + // eslint-disable-next-line @typescript-eslint/no-empty-function let styleDeclarationObserver = () => {}; + // eslint-disable-next-line @typescript-eslint/no-empty-function let fontObserver = () => {}; if (o.recordDOM) { styleSheetObserver = initStyleSheetObserver(o, { win: currentWindow }); From a5ef2a867154aed9cc49cdeb7ef1056095e264d1 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Fri, 3 Nov 2023 12:09:21 +0100 Subject: [PATCH 04/28] ref: Avoid unnecessary cloning of objects or arrays (#1340) --- .changeset/gold-apples-joke.md | 5 ++++ packages/rrweb/src/record/observer.ts | 28 +++++-------------- .../rrweb/src/record/observers/canvas/2d.ts | 2 +- .../record/observers/canvas/serialize-args.ts | 2 +- .../src/record/observers/canvas/webgl.ts | 2 +- 5 files changed, 15 insertions(+), 24 deletions(-) create mode 100644 .changeset/gold-apples-joke.md diff --git a/.changeset/gold-apples-joke.md b/.changeset/gold-apples-joke.md new file mode 100644 index 0000000000..4ad27974b8 --- /dev/null +++ b/.changeset/gold-apples-joke.md @@ -0,0 +1,5 @@ +--- +'rrweb': patch +--- + +ref: Avoid unnecessary cloning of objects or arrays diff --git a/packages/rrweb/src/record/observer.ts b/packages/rrweb/src/record/observer.ts index af6134bf05..2120a71df5 100644 --- a/packages/rrweb/src/record/observer.ts +++ b/packages/rrweb/src/record/observer.ts @@ -405,15 +405,6 @@ function initViewportResizeObserver( return on('resize', updateDimension, win); } -function wrapEventWithUserTriggeredFlag( - v: inputValue, - enable: boolean, -): inputValue { - const value = { ...v }; - if (!enable) delete value.userTriggered; - return value; -} - export const INPUT_TAGS = ['INPUT', 'TEXTAREA', 'SELECT']; const lastInputValueMap: WeakMap = new WeakMap(); function initInputObserver({ @@ -477,10 +468,9 @@ function initInputObserver({ } cbWithDedup( target, - callbackWrapper(wrapEventWithUserTriggeredFlag)( - { text, isChecked, userTriggered }, - userTriggeredOnInput, - ), + userTriggeredOnInput + ? { text, isChecked, userTriggered } + : { text, isChecked }, ); // if a radio was checked // the other radios with the same name attribute will be unchecked. @@ -490,16 +480,12 @@ function initInputObserver({ .querySelectorAll(`input[type="radio"][name="${name}"]`) .forEach((el) => { if (el !== target) { + const text = (el as HTMLInputElement).value; cbWithDedup( el, - callbackWrapper(wrapEventWithUserTriggeredFlag)( - { - text: (el as HTMLInputElement).value, - isChecked: !isChecked, - userTriggered: false, - }, - userTriggeredOnInput, - ), + userTriggeredOnInput + ? { text, isChecked: !isChecked, userTriggered: false } + : { text, isChecked: !isChecked }, ); } }); diff --git a/packages/rrweb/src/record/observers/canvas/2d.ts b/packages/rrweb/src/record/observers/canvas/2d.ts index 38c1e52c21..05228d8f7b 100644 --- a/packages/rrweb/src/record/observers/canvas/2d.ts +++ b/packages/rrweb/src/record/observers/canvas/2d.ts @@ -44,7 +44,7 @@ export default function initCanvas2DMutationObserver( // Using setTimeout as toDataURL can be heavy // and we'd rather not block the main thread setTimeout(() => { - const recordArgs = serializeArgs([...args], win, this); + const recordArgs = serializeArgs(args, win, this); cb(this.canvas, { type: CanvasContext['2D'], property: prop, diff --git a/packages/rrweb/src/record/observers/canvas/serialize-args.ts b/packages/rrweb/src/record/observers/canvas/serialize-args.ts index 10b97b8e24..19b5061b64 100644 --- a/packages/rrweb/src/record/observers/canvas/serialize-args.ts +++ b/packages/rrweb/src/record/observers/canvas/serialize-args.ts @@ -133,7 +133,7 @@ export const serializeArgs = ( win: IWindow, ctx: RenderingContext, ) => { - return [...args].map((arg) => serializeArg(arg, win, ctx)); + return args.map((arg) => serializeArg(arg, win, ctx)); }; export const isInstanceOfWebGLObject = ( diff --git a/packages/rrweb/src/record/observers/canvas/webgl.ts b/packages/rrweb/src/record/observers/canvas/webgl.ts index 6345f5a918..7bdd6e4e7f 100644 --- a/packages/rrweb/src/record/observers/canvas/webgl.ts +++ b/packages/rrweb/src/record/observers/canvas/webgl.ts @@ -53,7 +53,7 @@ function patchGLPrototype( 'tagName' in this.canvas && !isBlocked(this.canvas, blockClass, blockSelector, true) ) { - const recordArgs = serializeArgs([...args], win, this); + const recordArgs = serializeArgs(args, win, this); const mutation: canvasMutationWithType = { type, property: prop, From 2398dd242f85122ca9df4726342d53dfb0b8c37e Mon Sep 17 00:00:00 2001 From: Michael Dellanoce Date: Fri, 3 Nov 2023 18:03:59 -0400 Subject: [PATCH 05/28] perf(rrweb): attribute mutation optimization (#1343) --- .changeset/moody-dots-refuse.md | 5 +++++ packages/rrweb/src/record/mutation.ts | 7 ++++--- .../rrweb/test/benchmark/dom-mutation.test.ts | 6 ++++++ .../benchmark-dom-mutation-attributes.html | 21 +++++++++++++++++++ 4 files changed, 36 insertions(+), 3 deletions(-) create mode 100644 .changeset/moody-dots-refuse.md create mode 100644 packages/rrweb/test/html/benchmark-dom-mutation-attributes.html diff --git a/.changeset/moody-dots-refuse.md b/.changeset/moody-dots-refuse.md new file mode 100644 index 0000000000..32270da384 --- /dev/null +++ b/.changeset/moody-dots-refuse.md @@ -0,0 +1,5 @@ +--- +'rrweb': patch +--- + +use WeakMap for faster attributeCursor lookup while processing attribute mutations diff --git a/packages/rrweb/src/record/mutation.ts b/packages/rrweb/src/record/mutation.ts index 1bbe39354e..f419a08182 100644 --- a/packages/rrweb/src/record/mutation.ts +++ b/packages/rrweb/src/record/mutation.ts @@ -142,6 +142,7 @@ export default class MutationBuffer { private texts: textCursor[] = []; private attributes: attributeCursor[] = []; + private attributeMap = new WeakMap(); private removes: removedNodeMutation[] = []; private mapRemoves: Node[] = []; @@ -485,6 +486,7 @@ export default class MutationBuffer { // reset this.texts = []; this.attributes = []; + this.attributeMap = new WeakMap(); this.removes = []; this.addedSet = new Set(); this.movedSet = new Set(); @@ -546,9 +548,7 @@ export default class MutationBuffer { return; } - let item: attributeCursor | undefined = this.attributes.find( - (a) => a.node === m.target, - ); + let item = this.attributeMap.get(m.target); if ( target.tagName === 'IFRAME' && attributeName === 'src' && @@ -570,6 +570,7 @@ export default class MutationBuffer { _unchangedStyles: {}, }; this.attributes.push(item); + this.attributeMap.set(m.target, item); } // Keep this property on inputs that used to be password inputs diff --git a/packages/rrweb/test/benchmark/dom-mutation.test.ts b/packages/rrweb/test/benchmark/dom-mutation.test.ts index c1cb403e3f..4bf3109e2c 100644 --- a/packages/rrweb/test/benchmark/dom-mutation.test.ts +++ b/packages/rrweb/test/benchmark/dom-mutation.test.ts @@ -42,6 +42,12 @@ const suites: Array< eval: 'window.workload()', times: 5, }, + { + title: 'modify attributes on 10000 DOM nodes', + html: 'benchmark-dom-mutation-attributes.html', + eval: 'window.workload()', + times: 10, + }, ]; function avg(v: number[]): number { diff --git a/packages/rrweb/test/html/benchmark-dom-mutation-attributes.html b/packages/rrweb/test/html/benchmark-dom-mutation-attributes.html new file mode 100644 index 0000000000..3d00b26b4d --- /dev/null +++ b/packages/rrweb/test/html/benchmark-dom-mutation-attributes.html @@ -0,0 +1,21 @@ + + + + From 331e46b8170e03a597156ad4e1f1d7e67e0bb296 Mon Sep 17 00:00:00 2001 From: huangkairan <56213366+huangkairan@users.noreply.github.com> Date: Sat, 4 Nov 2023 06:10:00 +0800 Subject: [PATCH 06/28] fix(web-extension): beforeunload logic (#1330) --- .changeset/witty-kids-talk.md | 5 +++++ packages/web-extension/src/content/index.ts | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 .changeset/witty-kids-talk.md diff --git a/.changeset/witty-kids-talk.md b/.changeset/witty-kids-talk.md new file mode 100644 index 0000000000..4a9d4f000a --- /dev/null +++ b/.changeset/witty-kids-talk.md @@ -0,0 +1,5 @@ +--- +'@rrweb/web-extension': patch +--- + +🐞 fix(web-extension): beforeunload logic diff --git a/packages/web-extension/src/content/index.ts b/packages/web-extension/src/content/index.ts index 49f08055b0..a277518167 100644 --- a/packages/web-extension/src/content/index.ts +++ b/packages/web-extension/src/content/index.ts @@ -155,8 +155,8 @@ async function initMainPage() { // Before unload pages, cache the new events in the local storage. window.addEventListener('beforeunload', (event) => { + if (!newEvents.length) return; event.preventDefault(); - if (newEvents.length === 0) return; void Browser.storage.local.set({ [LocalDataKey.bufferedEvents]: bufferedEvents.concat(newEvents), }); From c7634783ac0b08427b32525efda2a00044a18abc Mon Sep 17 00:00:00 2001 From: Lukas Boehler Date: Fri, 3 Nov 2023 23:17:10 +0100 Subject: [PATCH 07/28] Added Gleap.io to "Who's using rrweb?" (#1332) Co-authored-by: Justin Halsall --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index d198499df9..cdcddce146 100644 --- a/README.md +++ b/README.md @@ -226,5 +226,10 @@ In addition to adding integration tests and unit tests, rrweb also provides a RE Intercept, Modify, Record & Replay HTTP Requests. + + + In-app bug reporting & customer feedback platform. + + From 3bfcc54c5940b99ce2a9dc1c735578bbf9c6286c Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Fri, 3 Nov 2023 19:53:49 -0230 Subject: [PATCH 08/28] fix(web-extension): Fix types in vite config (#1333) --- .changeset/mighty-bulldogs-begin.md | 5 +++++ packages/web-extension/vite.config.ts | 23 ++++++++++++++--------- 2 files changed, 19 insertions(+), 9 deletions(-) create mode 100644 .changeset/mighty-bulldogs-begin.md diff --git a/.changeset/mighty-bulldogs-begin.md b/.changeset/mighty-bulldogs-begin.md new file mode 100644 index 0000000000..b2623ab7c8 --- /dev/null +++ b/.changeset/mighty-bulldogs-begin.md @@ -0,0 +1,5 @@ +--- +'@rrweb/web-extension': patch +--- + +Update `vite.config.ts` to account for all potential entry types. diff --git a/packages/web-extension/vite.config.ts b/packages/web-extension/vite.config.ts index 95809cb727..76d2c631a3 100644 --- a/packages/web-extension/vite.config.ts +++ b/packages/web-extension/vite.config.ts @@ -1,9 +1,4 @@ -import { - defineConfig, - LibraryFormats, - LibraryOptions, - PluginOption, -} from 'vite'; +import { defineConfig, LibraryFormats, PluginOption } from 'vite'; import webExtension, { readJsonFile } from 'vite-plugin-web-extension'; import zip from 'vite-plugin-zip-pack'; import * as path from 'path'; @@ -17,9 +12,19 @@ function useSpecialFormat( return { name: 'use-special-format', config(config) { - const shouldUse = entriesToUse.includes( - (config.build?.lib as LibraryOptions)?.entry, - ); + // entry can be string | string[] | {[entryAlias: string]: string} + const entry = config.build?.lib && config.build.lib.entry; + let shouldUse = false; + + if (typeof entry === 'string') { + shouldUse = entriesToUse.includes(entry); + } else if (Array.isArray(entry)) { + shouldUse = entriesToUse.some((e) => entry.includes(e)); + } else if (entry && typeof entry === 'object') { + const entryKeys = Object.keys(entry); + shouldUse = entriesToUse.some((e) => entryKeys.includes(e)); + } + if (shouldUse) { config.build = config.build ?? {}; // @ts-expect-error: lib needs to be an object, forcing it. From b29bfcabe047a3112ecee9b86f950242c01269fa Mon Sep 17 00:00:00 2001 From: Eoghan Murray Date: Mon, 6 Nov 2023 10:10:07 +0000 Subject: [PATCH 09/28] Md create html document (#1321) * only call createHTMLDocument where it is needed * Perf: create the mutation document once as a 'singleton' as it can be reused --------- Co-authored-by: Michael Dellanoce --- packages/rrweb/src/record/mutation.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/packages/rrweb/src/record/mutation.ts b/packages/rrweb/src/record/mutation.ts index f419a08182..d0b99b5f52 100644 --- a/packages/rrweb/src/record/mutation.ts +++ b/packages/rrweb/src/record/mutation.ts @@ -190,6 +190,7 @@ export default class MutationBuffer { private shadowDomManager: observerParam['shadowDomManager']; private canvasManager: observerParam['canvasManager']; private processedNodeManager: observerParam['processedNodeManager']; + private unattachedDoc: HTMLDocument; public init(options: MutationBufferParam) { ( @@ -592,15 +593,17 @@ export default class MutationBuffer { value, ); if (attributeName === 'style') { - let unattachedDoc; - try { - // avoid upsetting original document from a Content Security point of view - unattachedDoc = document.implementation.createHTMLDocument(); - } catch (e) { - // fallback to more direct method - unattachedDoc = this.doc; + if (!this.unattachedDoc) { + try { + // avoid upsetting original document from a Content Security point of view + this.unattachedDoc = + document.implementation.createHTMLDocument(); + } catch (e) { + // fallback to more direct method + this.unattachedDoc = this.doc; + } } - const old = unattachedDoc.createElement('span'); + const old = this.unattachedDoc.createElement('span'); if (m.oldValue) { old.setAttribute('style', m.oldValue); } From a1d596254aa12bd85295f7c759ed28637cdffa04 Mon Sep 17 00:00:00 2001 From: Yun Feng Date: Wed, 8 Nov 2023 01:26:13 +1100 Subject: [PATCH 10/28] Feat: Add support for replaying :defined pseudo-class of custom elements (#1155) * Feat: Add support for replaying :defined pseudo-class of custom elements * add isCustom flag to serialized elements Applying Justin's review suggestion * fix code lint error * add custom element event * fix: tests (#1348) * Update packages/rrweb/src/record/observer.ts * Update packages/rrweb/src/record/observer.ts --------- Co-authored-by: Nafees Nehar Co-authored-by: Justin Halsall --- .changeset/fluffy-planes-retire.md | 5 ++ .changeset/smart-ears-refuse.md | 7 ++ packages/rrweb-snapshot/src/rebuild.ts | 12 +++ packages/rrweb-snapshot/src/snapshot.ts | 8 ++ packages/rrweb-snapshot/src/types.ts | 2 + .../__snapshots__/integration.test.ts.snap | 1 + packages/rrweb-snapshot/tsconfig.json | 1 + packages/rrweb/src/record/iframe-manager.ts | 1 + packages/rrweb/src/record/index.ts | 11 +++ packages/rrweb/src/record/observer.ts | 48 ++++++++++ packages/rrweb/src/types.ts | 2 + .../events/custom-element-define-class.ts | 89 +++++++++++++++++++ packages/rrweb/test/replayer.test.ts | 16 ++++ packages/types/src/index.ts | 17 +++- 14 files changed, 219 insertions(+), 1 deletion(-) create mode 100644 .changeset/fluffy-planes-retire.md create mode 100644 .changeset/smart-ears-refuse.md create mode 100644 packages/rrweb/test/events/custom-element-define-class.ts diff --git a/.changeset/fluffy-planes-retire.md b/.changeset/fluffy-planes-retire.md new file mode 100644 index 0000000000..41e9601704 --- /dev/null +++ b/.changeset/fluffy-planes-retire.md @@ -0,0 +1,5 @@ +--- +'rrweb': patch +--- + +Feat: Add support for replaying :defined pseudo-class of custom elements diff --git a/.changeset/smart-ears-refuse.md b/.changeset/smart-ears-refuse.md new file mode 100644 index 0000000000..0aaaabcf0f --- /dev/null +++ b/.changeset/smart-ears-refuse.md @@ -0,0 +1,7 @@ +--- +'rrweb-snapshot': patch +--- + +Feat: Add 'isCustom' flag to serialized elements. + +This flag is used to indicate whether the element is a custom element or not. This is useful for replaying the :defined pseudo-class of custom elements. diff --git a/packages/rrweb-snapshot/src/rebuild.ts b/packages/rrweb-snapshot/src/rebuild.ts index 6531cfe339..97faedf1da 100644 --- a/packages/rrweb-snapshot/src/rebuild.ts +++ b/packages/rrweb-snapshot/src/rebuild.ts @@ -170,6 +170,18 @@ function buildNode( if (n.isSVG) { node = doc.createElementNS('http://www.w3.org/2000/svg', tagName); } else { + if ( + // If the tag name is a custom element name + n.isCustom && + // If the browser supports custom elements + doc.defaultView?.customElements && + // If the custom element hasn't been defined yet + !doc.defaultView.customElements.get(n.tagName) + ) + doc.defaultView.customElements.define( + n.tagName, + class extends doc.defaultView.HTMLElement {}, + ); node = doc.createElement(tagName); } /** diff --git a/packages/rrweb-snapshot/src/snapshot.ts b/packages/rrweb-snapshot/src/snapshot.ts index 0963e6cfef..e6b25dc92b 100644 --- a/packages/rrweb-snapshot/src/snapshot.ts +++ b/packages/rrweb-snapshot/src/snapshot.ts @@ -801,6 +801,13 @@ function serializeElementNode( delete attributes.src; // prevent auto loading } + let isCustomElement: true | undefined; + try { + if (customElements.get(tagName)) isCustomElement = true; + } catch (e) { + // In case old browsers don't support customElements + } + return { type: NodeType.Element, tagName, @@ -809,6 +816,7 @@ function serializeElementNode( isSVG: isSVGElement(n as Element) || undefined, needBlock, rootId, + isCustom: isCustomElement, }; } diff --git a/packages/rrweb-snapshot/src/types.ts b/packages/rrweb-snapshot/src/types.ts index e573dfc1e0..90d31c171a 100644 --- a/packages/rrweb-snapshot/src/types.ts +++ b/packages/rrweb-snapshot/src/types.ts @@ -38,6 +38,8 @@ export type elementNode = { childNodes: serializedNodeWithId[]; isSVG?: true; needBlock?: boolean; + // This is a custom element or not. + isCustom?: true; }; export type textNode = { diff --git a/packages/rrweb-snapshot/test/__snapshots__/integration.test.ts.snap b/packages/rrweb-snapshot/test/__snapshots__/integration.test.ts.snap index a50f27cebe..77beb3be81 100644 --- a/packages/rrweb-snapshot/test/__snapshots__/integration.test.ts.snap +++ b/packages/rrweb-snapshot/test/__snapshots__/integration.test.ts.snap @@ -839,6 +839,7 @@ exports[`shadow DOM integration tests snapshot shadow DOM 1`] = ` \\"isShadow\\": true } ], + \\"isCustom\\": true, \\"id\\": 16, \\"isShadowHost\\": true }, diff --git a/packages/rrweb-snapshot/tsconfig.json b/packages/rrweb-snapshot/tsconfig.json index 2766ca4a82..45e8df8efd 100644 --- a/packages/rrweb-snapshot/tsconfig.json +++ b/packages/rrweb-snapshot/tsconfig.json @@ -3,6 +3,7 @@ "target": "ES6", "composite": true, "module": "ESNext", + "target": "ES6", "moduleResolution": "Node", "noImplicitAny": true, "strictNullChecks": true, diff --git a/packages/rrweb/src/record/iframe-manager.ts b/packages/rrweb/src/record/iframe-manager.ts index 00fbb42372..79818387ab 100644 --- a/packages/rrweb/src/record/iframe-manager.ts +++ b/packages/rrweb/src/record/iframe-manager.ts @@ -235,6 +235,7 @@ export class IframeManager { } } } + return false; } private replace>( diff --git a/packages/rrweb/src/record/index.ts b/packages/rrweb/src/record/index.ts index b09baf6139..f58d987a8b 100644 --- a/packages/rrweb/src/record/index.ts +++ b/packages/rrweb/src/record/index.ts @@ -528,6 +528,17 @@ function record( }), ); }, + customElementCb: (c) => { + wrappedEmit( + wrapEvent({ + type: EventType.IncrementalSnapshot, + data: { + source: IncrementalSource.CustomElement, + ...c, + }, + }), + ); + }, blockClass, ignoreClass, ignoreSelector, diff --git a/packages/rrweb/src/record/observer.ts b/packages/rrweb/src/record/observer.ts index 2120a71df5..6876c7c28a 100644 --- a/packages/rrweb/src/record/observer.ts +++ b/packages/rrweb/src/record/observer.ts @@ -31,6 +31,7 @@ import { styleDeclarationCallback, styleSheetRuleCallback, viewportResizeCallback, + customElementCallback } from '@amplitude/rrweb-types'; import type { FontFaceSet } from 'css-font-loading-module'; import type { MutationBufferParam, observerParam } from '../types'; @@ -1173,6 +1174,44 @@ function initSelectionObserver(param: observerParam): listenerHandler { return on('selectionchange', updateSelection); } +function initCustomElementObserver({ + doc, + customElementCb, +}: observerParam): listenerHandler { + const win = doc.defaultView as IWindow; + // eslint-disable-next-line @typescript-eslint/no-empty-function + if (!win || !win.customElements) return () => {}; + const restoreHandler = patch( + win.customElements, + 'define', + function ( + original: ( + name: string, + constructor: CustomElementConstructor, + options?: ElementDefinitionOptions, + ) => void, + ) { + return function ( + name: string, + constructor: CustomElementConstructor, + options?: ElementDefinitionOptions, + ) { + try { + customElementCb({ + define: { + name, + }, + }); + } catch (e) { + console.warn(`Custom element callback failed for ${name}`); + } + return original.apply(this, [name, constructor, options]); + }; + }, + ); + return restoreHandler; +} + function mergeHooks(o: observerParam, hooks: hooksParam) { const { mutationCb, @@ -1187,6 +1226,7 @@ function mergeHooks(o: observerParam, hooks: hooksParam) { canvasMutationCb, fontCb, selectionCb, + customElementCb, } = o; o.mutationCb = (...p: Arguments) => { if (hooks.mutation) { @@ -1260,6 +1300,12 @@ function mergeHooks(o: observerParam, hooks: hooksParam) { } selectionCb(...p); }; + o.customElementCb = (...c: Arguments) => { + if (hooks.customElement) { + hooks.customElement(...c); + } + customElementCb(...c); + }; } export function initObservers( @@ -1306,6 +1352,7 @@ export function initObservers( } } const selectionObserver = initSelectionObserver(o); + const customElementObserver = initCustomElementObserver(o); // plugins const pluginHandlers: listenerHandler[] = []; @@ -1329,6 +1376,7 @@ export function initObservers( styleDeclarationObserver(); fontObserver(); selectionObserver(); + customElementObserver(); pluginHandlers.forEach((h) => h()); }); } diff --git a/packages/rrweb/src/types.ts b/packages/rrweb/src/types.ts index 5c26322414..9fff957833 100644 --- a/packages/rrweb/src/types.ts +++ b/packages/rrweb/src/types.ts @@ -15,6 +15,7 @@ import type { addedNodeMutation, blockClass, canvasMutationCallback, + customElementCallback, eventWithTime, fontCallback, hooksParam, @@ -97,6 +98,7 @@ export type observerParam = { styleSheetRuleCb: styleSheetRuleCallback; styleDeclarationCb: styleDeclarationCallback; canvasMutationCb: canvasMutationCallback; + customElementCb: customElementCallback; fontCb: fontCallback; sampling: SamplingStrategy; recordDOM: boolean; diff --git a/packages/rrweb/test/events/custom-element-define-class.ts b/packages/rrweb/test/events/custom-element-define-class.ts new file mode 100644 index 0000000000..3f9bd9fa6b --- /dev/null +++ b/packages/rrweb/test/events/custom-element-define-class.ts @@ -0,0 +1,89 @@ +import { EventType } from '@rrweb/types'; +import type { eventWithTime } from '@rrweb/types'; + +const now = Date.now(); +const events: eventWithTime[] = [ + { + type: EventType.DomContentLoaded, + data: {}, + timestamp: now, + }, + { + type: EventType.Load, + data: {}, + timestamp: now + 100, + }, + { + type: EventType.Meta, + data: { + href: 'http://localhost', + width: 1000, + height: 800, + }, + timestamp: now + 100, + }, + // full snapshot: + { + data: { + node: { + id: 1, + type: 0, + childNodes: [ + { id: 2, name: 'html', type: 1, publicId: '', systemId: '' }, + { + id: 3, + type: 2, + tagName: 'html', + attributes: { lang: 'en' }, + childNodes: [ + { + id: 4, + type: 2, + tagName: 'head', + attributes: {}, + childNodes: [ + { + id: 5, + type: 2, + tagName: 'style', + childNodes: [ + { + id: 6, + type: 3, + isStyle: true, + // Set style of defined custom element to display: block + // Set undefined custom element to display: none + textContent: + 'custom-element:not(:defined) { display: none;} \n custom-element:defined { display: block; }', + }, + ], + }, + ], + }, + { + id: 7, + type: 2, + tagName: 'body', + attributes: {}, + childNodes: [ + { + id: 8, + type: 2, + tagName: 'custom-element', + childNodes: [], + isCustom: true, + }, + ], + }, + ], + }, + ], + }, + initialOffset: { top: 0, left: 0 }, + }, + type: EventType.FullSnapshot, + timestamp: now + 100, + }, +]; + +export default events; diff --git a/packages/rrweb/test/replayer.test.ts b/packages/rrweb/test/replayer.test.ts index cbf6ca75f2..8097189b8a 100644 --- a/packages/rrweb/test/replayer.test.ts +++ b/packages/rrweb/test/replayer.test.ts @@ -23,6 +23,7 @@ import { sampleStyleSheetRemoveEvents as stylesheetRemoveEvents, waitForRAF, } from './utils'; +import customElementDefineClass from './events/custom-element-define-class'; interface ISuite { code: string; @@ -1076,4 +1077,19 @@ describe('replayer', function () { ), ).toBe(':hover'); }); + + it('should replay styles with :define pseudo-class', async () => { + await page.evaluate(`events = ${JSON.stringify(customElementDefineClass)}`); + + const displayValue = await page.evaluate(` + const { Replayer } = rrweb; + const replayer = new Replayer(events); + replayer.pause(200); + const customElement = replayer.iframe.contentDocument.querySelector('custom-element'); + window.getComputedStyle(customElement).display; + `); + // If the custom element is not defined, the display value will be 'none'. + // If the custom element is defined, the display value will be 'block'. + expect(displayValue).toEqual('block'); + }); }); diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 85e0efd5c6..41eb069adb 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -83,6 +83,7 @@ export enum IncrementalSource { StyleDeclaration, Selection, AdoptedStyleSheet, + CustomElement, } export type mutationData = { @@ -142,6 +143,10 @@ export type adoptedStyleSheetData = { source: IncrementalSource.AdoptedStyleSheet; } & adoptedStyleSheetParam; +export type customElementData = { + source: IncrementalSource.CustomElement; +} & customElementParam; + export type incrementalData = | mutationData | mousemoveData @@ -155,7 +160,8 @@ export type incrementalData = | fontData | selectionData | styleDeclarationData - | adoptedStyleSheetData; + | adoptedStyleSheetData + | customElementData; export type event = | domContentLoadedEvent @@ -262,6 +268,7 @@ export type hooksParam = { canvasMutation?: canvasMutationCallback; font?: fontCallback; selection?: selectionCallback; + customElement?: customElementCallback; }; // https://dom.spec.whatwg.org/#interface-mutationrecord @@ -593,6 +600,14 @@ export type selectionParam = { export type selectionCallback = (p: selectionParam) => void; +export type customElementParam = { + define?: { + name: string; + }; +}; + +export type customElementCallback = (c: customElementParam) => void; + export type DeprecatedMirror = { map: { [key: number]: INode; From ba7f3d50e982d6d2e5c1dd4868a536db5d3572e9 Mon Sep 17 00:00:00 2001 From: Eoghan Murray Date: Fri, 24 Nov 2023 16:06:02 +0000 Subject: [PATCH 11/28] Masking: Avoid the repeated calls to `closest` when recursing through the DOM (#1349) * masking performance: avoid the repeated calls to `closest` when recursing through the DOM - needsMask===true means that an ancestor has tested positively for masking, and so this node and all descendents should be masked - needsMask===false means that no ancestors have tested positively for masking, we should check each node encountered - needsMask===undefined means that we don't know whether ancestors are masked or not (e.g. after a mutation) and should look up the tree * Add tests including an explicit characterData mutation tests * Further performance improvement: avoid calls to `el.matches` when on a leaf node, e.g. a `
` --------- Authored-by: eoghanmurray Based on initial PR #1338 by Alexey Babik --- .changeset/thin-vans-applaud.md | 6 + packages/rrweb-snapshot/src/snapshot.ts | 63 +++-- packages/rrweb/src/record/mutation.ts | 1 + .../__snapshots__/integration.test.ts.snap | 237 ++++++++++++++++++ packages/rrweb/test/integration.test.ts | 39 +++ packages/rrweb/test/utils.ts | 1 + 6 files changed, 323 insertions(+), 24 deletions(-) create mode 100644 .changeset/thin-vans-applaud.md diff --git a/.changeset/thin-vans-applaud.md b/.changeset/thin-vans-applaud.md new file mode 100644 index 0000000000..e5d8d32a63 --- /dev/null +++ b/.changeset/thin-vans-applaud.md @@ -0,0 +1,6 @@ +--- +'rrweb-snapshot': patch +'rrweb': patch +--- + +Snapshot performance when masking text: Avoid the repeated calls to `closest` when recursing through the DOM diff --git a/packages/rrweb-snapshot/src/snapshot.ts b/packages/rrweb-snapshot/src/snapshot.ts index e6b25dc92b..6d2f2ed2e8 100644 --- a/packages/rrweb-snapshot/src/snapshot.ts +++ b/packages/rrweb-snapshot/src/snapshot.ts @@ -310,6 +310,7 @@ export function needMaskingText( node: Node, maskTextClass: string | RegExp, maskTextSelector: string | null, + checkAncestors: boolean, ): boolean { try { const el: HTMLElement | null = @@ -317,17 +318,21 @@ export function needMaskingText( ? (node as HTMLElement) : node.parentElement; if (el === null) return false; - if (typeof maskTextClass === 'string') { - if (el.classList.contains(maskTextClass)) return true; - if (el.closest(`.${maskTextClass}`)) return true; + if (checkAncestors) { + if (el.closest(`.${maskTextClass}`)) return true; + } else { + if (el.classList.contains(maskTextClass)) return true; + } } else { - if (classMatchesRegex(el, maskTextClass, true)) return true; + if (classMatchesRegex(el, maskTextClass, checkAncestors)) return true; } - if (maskTextSelector) { - if (el.matches(maskTextSelector)) return true; - if (el.closest(maskTextSelector)) return true; + if (checkAncestors) { + if (el.closest(maskTextSelector)) return true; + } else { + if (el.matches(maskTextSelector)) return true; + } } } catch (e) { // @@ -426,8 +431,7 @@ function serializeNode( mirror: Mirror; blockClass: string | RegExp; blockSelector: string | null; - maskTextClass: string | RegExp; - maskTextSelector: string | null; + needsMask: boolean | undefined; inlineStylesheet: boolean; maskInputOptions: MaskInputOptions; maskTextFn: MaskTextFn | undefined; @@ -447,8 +451,7 @@ function serializeNode( mirror, blockClass, blockSelector, - maskTextClass, - maskTextSelector, + needsMask, inlineStylesheet, maskInputOptions = {}, maskTextFn, @@ -500,8 +503,7 @@ function serializeNode( }); case n.TEXT_NODE: return serializeTextNode(n as Text, { - maskTextClass, - maskTextSelector, + needsMask, maskTextFn, rootId, }); @@ -531,13 +533,12 @@ function getRootId(doc: Document, mirror: Mirror): number | undefined { function serializeTextNode( n: Text, options: { - maskTextClass: string | RegExp; - maskTextSelector: string | null; + needsMask: boolean | undefined; maskTextFn: MaskTextFn | undefined; rootId: number | undefined; }, ): serializedNode { - const { maskTextClass, maskTextSelector, maskTextFn, rootId } = options; + const { needsMask, maskTextFn, rootId } = options; // The parent node may not be a html element which has a tagName attribute. // So just let it be undefined which is ok in this use case. const parentTagName = n.parentNode && (n.parentNode as HTMLElement).tagName; @@ -568,12 +569,7 @@ function serializeTextNode( if (isScript) { textContent = 'SCRIPT_PLACEHOLDER'; } - if ( - !isStyle && - !isScript && - textContent && - needMaskingText(n, maskTextClass, maskTextSelector) - ) { + if (!isStyle && !isScript && textContent && needsMask) { textContent = maskTextFn ? maskTextFn(textContent, n.parentElement) : textContent.replace(/[\S]/g, '*'); @@ -935,6 +931,7 @@ export function serializeNodeWithId( inlineStylesheet: boolean; newlyAddedElement?: boolean; maskInputOptions?: MaskInputOptions; + needsMask?: boolean; maskTextFn: MaskTextFn | undefined; maskInputFn: MaskInputFn | undefined; slimDOMOptions: SlimDOMOptions; @@ -980,14 +977,29 @@ export function serializeNodeWithId( keepIframeSrcFn = () => false, newlyAddedElement = false, } = options; + let { needsMask } = options; let { preserveWhiteSpace = true } = options; + + if ( + !needsMask && + n.childNodes // we can avoid the check on leaf elements, as masking is applied to child text nodes only + ) { + // perf: if needsMask = true, children won't also need to check + const checkAncestors = needsMask === undefined; // if false, we've already checked ancestors + needsMask = needMaskingText( + n as Element, + maskTextClass, + maskTextSelector, + checkAncestors, + ); + } + const _serializedNode = serializeNode(n, { doc, mirror, blockClass, blockSelector, - maskTextClass, - maskTextSelector, + needsMask, inlineStylesheet, maskInputOptions, maskTextFn, @@ -1058,6 +1070,7 @@ export function serializeNodeWithId( mirror, blockClass, blockSelector, + needsMask, maskTextClass, maskTextSelector, skipChild, @@ -1118,6 +1131,7 @@ export function serializeNodeWithId( mirror, blockClass, blockSelector, + needsMask, maskTextClass, maskTextSelector, skipChild: false, @@ -1165,6 +1179,7 @@ export function serializeNodeWithId( mirror, blockClass, blockSelector, + needsMask, maskTextClass, maskTextSelector, skipChild: false, diff --git a/packages/rrweb/src/record/mutation.ts b/packages/rrweb/src/record/mutation.ts index d0b99b5f52..f2826ffefe 100644 --- a/packages/rrweb/src/record/mutation.ts +++ b/packages/rrweb/src/record/mutation.ts @@ -515,6 +515,7 @@ export default class MutationBuffer { m.target, this.maskTextClass, this.maskTextSelector, + true, // checkAncestors ) && value ? this.maskTextFn ? this.maskTextFn(value, closestElementOfNode(m.target)) diff --git a/packages/rrweb/test/__snapshots__/integration.test.ts.snap b/packages/rrweb/test/__snapshots__/integration.test.ts.snap index e320254111..e3fb552c7e 100644 --- a/packages/rrweb/test/__snapshots__/integration.test.ts.snap +++ b/packages/rrweb/test/__snapshots__/integration.test.ts.snap @@ -793,6 +793,243 @@ exports[`record integration tests can mask character data mutations 1`] = ` } ] } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"texts\\": [ + { + \\"id\\": 22, + \\"value\\": \\"****** *******\\" + } + ], + \\"attributes\\": [], + \\"removes\\": [], + \\"adds\\": [] + } + } +]" +`; + +exports[`record integration tests can mask character data mutations with regexp 1`] = ` +"[ + { + \\"type\\": 0, + \\"data\\": {} + }, + { + \\"type\\": 1, + \\"data\\": {} + }, + { + \\"type\\": 4, + \\"data\\": { + \\"href\\": \\"about:blank\\", + \\"width\\": 1920, + \\"height\\": 1080 + } + }, + { + \\"type\\": 2, + \\"data\\": { + \\"node\\": { + \\"type\\": 0, + \\"childNodes\\": [ + { + \\"type\\": 1, + \\"name\\": \\"html\\", + \\"publicId\\": \\"\\", + \\"systemId\\": \\"\\", + \\"id\\": 2 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"html\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"head\\", + \\"attributes\\": {}, + \\"childNodes\\": [], + \\"id\\": 4 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"body\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 6 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"p\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"mutation observer\\", + \\"id\\": 8 + } + ], + \\"id\\": 7 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 9 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"ul\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 11 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"li\\", + \\"attributes\\": {}, + \\"childNodes\\": [], + \\"id\\": 12 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 13 + } + ], + \\"id\\": 10 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 14 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"canvas\\", + \\"attributes\\": {}, + \\"childNodes\\": [], + \\"id\\": 15 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n\\\\n \\", + \\"id\\": 16 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"script\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"SCRIPT_PLACEHOLDER\\", + \\"id\\": 18 + } + ], + \\"id\\": 17 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\\\n \\\\n\\", + \\"id\\": 19 + } + ], + \\"id\\": 5 + } + ], + \\"id\\": 3 + } + ], + \\"id\\": 1 + }, + \\"initialOffset\\": { + \\"left\\": 0, + \\"top\\": 0 + } + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"texts\\": [], + \\"attributes\\": [ + { + \\"id\\": 10, + \\"attributes\\": { + \\"class\\": \\"custom-mask\\" + } + }, + { + \\"id\\": 7, + \\"attributes\\": { + \\"class\\": \\"custom-mask\\" + } + } + ], + \\"removes\\": [ + { + \\"parentId\\": 7, + \\"id\\": 8 + } + ], + \\"adds\\": [ + { + \\"parentId\\": 10, + \\"nextId\\": null, + \\"node\\": { + \\"type\\": 2, + \\"tagName\\": \\"li\\", + \\"attributes\\": {}, + \\"childNodes\\": [], + \\"id\\": 20 + } + }, + { + \\"parentId\\": 20, + \\"nextId\\": null, + \\"node\\": { + \\"type\\": 3, + \\"textContent\\": \\"*** **** ****\\", + \\"id\\": 21 + } + }, + { + \\"parentId\\": 7, + \\"nextId\\": null, + \\"node\\": { + \\"type\\": 3, + \\"textContent\\": \\"*******\\", + \\"id\\": 22 + } + } + ] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"texts\\": [ + { + \\"id\\": 21, + \\"value\\": \\"********** ****** ** ****** *** **** ****\\" + } + ], + \\"attributes\\": [], + \\"removes\\": [], + \\"adds\\": [] + } } ]" `; diff --git a/packages/rrweb/test/integration.test.ts b/packages/rrweb/test/integration.test.ts index cf2710d12e..2d5b0e7340 100644 --- a/packages/rrweb/test/integration.test.ts +++ b/packages/rrweb/test/integration.test.ts @@ -1207,6 +1207,45 @@ describe('record integration tests', function (this: ISuite) { p.innerText = 'mutated'; }); + await page.evaluate(() => { + // generate a characterData mutation; innerText doesn't do that + const p = document.querySelector('p') as HTMLParagraphElement; + (p.childNodes[0] as Text).insertData(0, 'doubly '); + }); + + const snapshots = (await page.evaluate( + 'window.snapshots', + )) as eventWithTime[]; + assertSnapshot(snapshots); + }); + + it('can mask character data mutations with regexp', async () => { + const page: puppeteer.Page = await browser.newPage(); + await page.goto('about:blank'); + await page.setContent( + getHtml.call(this, 'mutation-observer.html', { + maskTextClass: /custom/, + }), + ); + + await page.evaluate(() => { + const li = document.createElement('li'); + const ul = document.querySelector('ul') as HTMLUListElement; + const p = document.querySelector('p') as HTMLParagraphElement; + [ul, p].forEach((element) => { + element.className = 'custom-mask'; + }); + ul.appendChild(li); + li.innerText = 'new list item'; + p.innerText = 'mutated'; + }); + + await page.evaluate(() => { + // generate a characterData mutation; innerText doesn't do that + const li = document.querySelector('li:not(:empty)') as HTMLLIElement; + (li.childNodes[0] as Text).insertData(0, 'descendent should be masked '); + }); + const snapshots = (await page.evaluate( 'window.snapshots', )) as eventWithTime[]; diff --git a/packages/rrweb/test/utils.ts b/packages/rrweb/test/utils.ts index b8cabb2668..88b67f230f 100644 --- a/packages/rrweb/test/utils.ts +++ b/packages/rrweb/test/utils.ts @@ -693,6 +693,7 @@ export function generateRecordSnippet(options: recordOptions) { maskAllInputs: ${options.maskAllInputs}, maskInputOptions: ${JSON.stringify(options.maskAllInputs)}, userTriggeredOnInput: ${options.userTriggeredOnInput}, + maskTextClass: ${options.maskTextClass}, maskTextFn: ${options.maskTextFn}, maskInputFn: ${options.maskInputFn}, recordCanvas: ${options.recordCanvas}, From 1fa5c133617374e5e68be8b5d9e6393ad13dfaa6 Mon Sep 17 00:00:00 2001 From: Eoghan Murray Date: Fri, 1 Dec 2023 13:18:58 +0000 Subject: [PATCH 12/28] Fix serialization and mutation of +