From 91148fd62b02cd05879e4c1487cef7ec9169a503 Mon Sep 17 00:00:00 2001 From: Colin Maxfield Date: Wed, 30 Aug 2023 14:08:54 -0400 Subject: [PATCH 01/15] Return early for child same origin frames If we have cross origin record turned on but we are in a child frame that has the same origin as its parent we end up in sort of an inefficient state. We will start the recording, record mutations, but then never actually emit them anywhere since inEmittingFrame and passEmitsToParent are both false. This is a waste of resources and we might as well just never start the recording. --- packages/rrweb/src/record/index.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/rrweb/src/record/index.ts b/packages/rrweb/src/record/index.ts index 3b4475cfc9..1e5178e7b0 100644 --- a/packages/rrweb/src/record/index.ts +++ b/packages/rrweb/src/record/index.ts @@ -115,6 +115,9 @@ 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; From 162988ddbab4a6e06dcb68e4862d180a2f2a4f6b Mon Sep 17 00:00:00 2001 From: colingm Date: Wed, 30 Aug 2023 18:12:34 +0000 Subject: [PATCH 02/15] Apply formatting changes --- packages/rrweb/src/record/index.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/rrweb/src/record/index.ts b/packages/rrweb/src/record/index.ts index 1e5178e7b0..e4dcfe7a77 100644 --- a/packages/rrweb/src/record/index.ts +++ b/packages/rrweb/src/record/index.ts @@ -116,7 +116,9 @@ function record( 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 */ }; + 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) { From 75d1be9f2ccd709da6712820e501ac6db4e05188 Mon Sep 17 00:00:00 2001 From: Michael Dellanoce Date: Wed, 20 Sep 2023 13:43:06 -0400 Subject: [PATCH 03/15] rrweb sends message to cross-origin iframe to force snapshot --- packages/rrweb/src/record/iframe-manager.ts | 15 +++++++++++++++ packages/rrweb/src/record/index.ts | 1 + 2 files changed, 16 insertions(+) diff --git a/packages/rrweb/src/record/iframe-manager.ts b/packages/rrweb/src/record/iframe-manager.ts index 26985cc49a..c40c16e47b 100644 --- a/packages/rrweb/src/record/iframe-manager.ts +++ b/packages/rrweb/src/record/iframe-manager.ts @@ -17,6 +17,7 @@ export class IframeManager { private mirror: Mirror; private mutationCb: mutationCallBack; private wrappedEmit: (e: eventWithTime, isCheckout?: boolean) => void; + private takeFullSnapshot: (isCheckout?: boolean) => void; private loadListener?: (iframeEl: HTMLIFrameElement) => unknown; private stylesheetManager: StylesheetManager; private recordCrossOriginIframes: boolean; @@ -27,9 +28,11 @@ export class IframeManager { stylesheetManager: StylesheetManager; recordCrossOriginIframes: boolean; wrappedEmit: (e: eventWithTime, isCheckout?: boolean) => void; + takeFullSnapshot: (isCheckout?: boolean) => void; }) { this.mutationCb = options.mutationCb; this.wrappedEmit = options.wrappedEmit; + this.takeFullSnapshot = options.takeFullSnapshot; this.stylesheetManager = options.stylesheetManager; this.recordCrossOriginIframes = options.recordCrossOriginIframes; this.crossOriginIframeStyleMirror = new CrossOriginIframeMirror( @@ -47,6 +50,13 @@ export class IframeManager { this.iframes.set(iframeEl, true); if (iframeEl.contentWindow) this.crossOriginIframeMap.set(iframeEl.contentWindow, iframeEl); + + if (!iframeEl.contentDocument && iframeEl.contentWindow) + iframeEl.contentWindow.postMessage({ + type: "rrweb", + origin: window.location.origin, + snapshot: true + }, "*"); } public addLoadListener(cb: (iframeEl: HTMLIFrameElement) => unknown) { @@ -94,6 +104,11 @@ export class IframeManager { const iframeSourceWindow = message.source; if (!iframeSourceWindow) return; + if (iframeSourceWindow == window.parent && window != window.parent && message.data.snapshot) { + this.takeFullSnapshot(); + return; + } + const iframeEl = this.crossOriginIframeMap.get(message.source); if (!iframeEl) return; diff --git a/packages/rrweb/src/record/index.ts b/packages/rrweb/src/record/index.ts index e4dcfe7a77..1b475c9339 100644 --- a/packages/rrweb/src/record/index.ts +++ b/packages/rrweb/src/record/index.ts @@ -296,6 +296,7 @@ function record( stylesheetManager: stylesheetManager, recordCrossOriginIframes, wrappedEmit, + takeFullSnapshot, }); /** From b04f9792394f56dccd1793e409787a36f7f6efce Mon Sep 17 00:00:00 2001 From: mdellanoce Date: Tue, 26 Sep 2023 13:56:35 +0000 Subject: [PATCH 04/15] Apply formatting changes --- packages/rrweb/src/record/iframe-manager.ts | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/packages/rrweb/src/record/iframe-manager.ts b/packages/rrweb/src/record/iframe-manager.ts index c40c16e47b..32a9070558 100644 --- a/packages/rrweb/src/record/iframe-manager.ts +++ b/packages/rrweb/src/record/iframe-manager.ts @@ -52,11 +52,14 @@ export class IframeManager { this.crossOriginIframeMap.set(iframeEl.contentWindow, iframeEl); if (!iframeEl.contentDocument && iframeEl.contentWindow) - iframeEl.contentWindow.postMessage({ - type: "rrweb", - origin: window.location.origin, - snapshot: true - }, "*"); + iframeEl.contentWindow.postMessage( + { + type: 'rrweb', + origin: window.location.origin, + snapshot: true, + }, + '*', + ); } public addLoadListener(cb: (iframeEl: HTMLIFrameElement) => unknown) { @@ -104,7 +107,11 @@ export class IframeManager { const iframeSourceWindow = message.source; if (!iframeSourceWindow) return; - if (iframeSourceWindow == window.parent && window != window.parent && message.data.snapshot) { + if ( + iframeSourceWindow == window.parent && + window != window.parent && + message.data.snapshot + ) { this.takeFullSnapshot(); return; } From 6f55c0c0d25ec1cf33c4121f3809b57aeca104c5 Mon Sep 17 00:00:00 2001 From: Michael Dellanoce Date: Fri, 17 Nov 2023 11:02:07 -0500 Subject: [PATCH 05/15] use native setTimeout,add/removeEventListener when zone.js is present --- .changeset/fair-seahorses-care.md | 6 ++++ packages/rrweb-snapshot/src/snapshot.ts | 7 +++-- packages/rrweb-snapshot/src/types.ts | 2 ++ packages/rrweb-snapshot/src/utils.ts | 29 +++++++++++++++++ packages/rrweb/src/record/observer.ts | 24 +++----------- .../rrweb/src/record/observers/canvas/2d.ts | 3 +- .../rrweb/src/record/shadow-dom-manager.ts | 4 +-- packages/rrweb/src/utils.ts | 31 +++++++++++++++---- packages/types/src/index.ts | 5 +-- 9 files changed, 77 insertions(+), 34 deletions(-) create mode 100644 .changeset/fair-seahorses-care.md diff --git a/.changeset/fair-seahorses-care.md b/.changeset/fair-seahorses-care.md new file mode 100644 index 0000000000..f8f0d9da6f --- /dev/null +++ b/.changeset/fair-seahorses-care.md @@ -0,0 +1,6 @@ +--- +'rrweb': patch +--- + +Use native setTimeout when zone.js is present +Use native add/removeEventListener when zone.js is present diff --git a/packages/rrweb-snapshot/src/snapshot.ts b/packages/rrweb-snapshot/src/snapshot.ts index 75fd863e0b..240f6bf811 100644 --- a/packages/rrweb-snapshot/src/snapshot.ts +++ b/packages/rrweb-snapshot/src/snapshot.ts @@ -24,6 +24,7 @@ import { getInputType, toLowerCase, extractFileExtension, + nativeSetTimeout } from './utils'; let _id = 1; @@ -362,7 +363,7 @@ function onceIframeLoaded( return; } if (readyState !== 'complete') { - const timer = setTimeout(() => { + const timer = nativeSetTimeout(() => { if (!fired) { listener(); fired = true; @@ -384,7 +385,7 @@ function onceIframeLoaded( ) { // iframe was already loaded, make sure we wait to trigger the listener // till _after_ the mutation that found this iframe has had time to process - setTimeout(listener, 0); + nativeSetTimeout(listener, 0); return iframeEl.addEventListener('load', listener); // keep listing for future loads } @@ -412,7 +413,7 @@ function onceStylesheetLoaded( if (styleSheetLoaded) return; - const timer = setTimeout(() => { + const timer = nativeSetTimeout(() => { if (!fired) { listener(); fired = true; diff --git a/packages/rrweb-snapshot/src/types.ts b/packages/rrweb-snapshot/src/types.ts index 90d31c171a..16871ea13c 100644 --- a/packages/rrweb-snapshot/src/types.ts +++ b/packages/rrweb-snapshot/src/types.ts @@ -163,3 +163,5 @@ export type KeepIframeSrcFn = (src: string) => boolean; export type BuildCache = { stylesWithHoverClass: Map; }; + +export type IWindow = Window & typeof globalThis; diff --git a/packages/rrweb-snapshot/src/utils.ts b/packages/rrweb-snapshot/src/utils.ts index 5ccc9082ed..25c8f9acbd 100644 --- a/packages/rrweb-snapshot/src/utils.ts +++ b/packages/rrweb-snapshot/src/utils.ts @@ -11,6 +11,7 @@ import { documentTypeNode, textNode, elementNode, + IWindow, } from './types'; export function isElement(n: Node): n is Element { @@ -30,6 +31,34 @@ export function isNativeShadowDom(shadowRoot: ShadowRoot) { return Object.prototype.toString.call(shadowRoot) === '[object ShadowRoot]'; } +type WindowWithAngularZone = IWindow & { + Zone?: { + __symbol__?: (key: keyof IWindow) => string; + }; + [key: string]: any; +}; + +export function getNative( + symbolName: keyof IWindow, + windowObj: IWindow = window, +): T { + const windowWithZone = windowObj as WindowWithAngularZone; + const angularZoneSymbol = windowWithZone?.Zone?.__symbol__?.(symbolName); + if (angularZoneSymbol) { + const zonelessImpl = windowWithZone[angularZoneSymbol] as T; + if (zonelessImpl) { + return zonelessImpl; + } + } + + return windowWithZone[symbolName] as T; +} + +export const nativeSetTimeout = + typeof window !== 'undefined' + ? getNative('setTimeout') + : global.setTimeout; + /** * Browsers sometimes destructively modify the css rules they receive. * This function tries to rectify the modifications the browser made to make it more cross platform compatible. diff --git a/packages/rrweb/src/record/observer.ts b/packages/rrweb/src/record/observer.ts index 0aa0f9856b..1841b255b4 100644 --- a/packages/rrweb/src/record/observer.ts +++ b/packages/rrweb/src/record/observer.ts @@ -4,6 +4,8 @@ import { Mirror, getInputType, toLowerCase, + getNative, + nativeSetTimeout, } from 'rrweb-snapshot'; import type { FontFaceSet } from 'css-font-loading-module'; import { @@ -54,11 +56,6 @@ import { callbackWrapper } from './error-handler'; type WindowWithStoredMutationObserver = IWindow & { __rrMutationObserver?: MutationObserver; }; -type WindowWithAngularZone = IWindow & { - Zone?: { - __symbol__?: (key: string) => string; - }; -}; export const mutationBuffers: MutationBuffer[] = []; @@ -93,7 +90,7 @@ export function initMutationObserver( // see mutation.ts for details mutationBuffer.init(options); let mutationObserverCtor = - window.MutationObserver || + getNative('MutationObserver') || /** * Some websites may disable MutationObserver by removing it from the window object. * If someone is using rrweb to build a browser extention or things like it, they @@ -103,19 +100,6 @@ export function initMutationObserver( * window.__rrMutationObserver = MutationObserver */ (window as WindowWithStoredMutationObserver).__rrMutationObserver; - const angularZoneSymbol = ( - window as WindowWithAngularZone - )?.Zone?.__symbol__?.('MutationObserver'); - if ( - angularZoneSymbol && - (window as unknown as Record)[ - angularZoneSymbol - ] - ) { - mutationObserverCtor = ( - window as unknown as Record - )[angularZoneSymbol]; - } const observer = new (mutationObserverCtor as new ( callback: MutationCallback, ) => MutationObserver)( @@ -1104,7 +1088,7 @@ function initFontObserver({ fontCb, doc }: observerParam): listenerHandler { 'add', function (original: (font: FontFace) => void) { return function (this: FontFaceSet, fontFace: FontFace) { - setTimeout( + nativeSetTimeout( callbackWrapper(() => { const p = fontMap.get(fontFace); if (p) { diff --git a/packages/rrweb/src/record/observers/canvas/2d.ts b/packages/rrweb/src/record/observers/canvas/2d.ts index b496b6b93a..f0c11e5832 100644 --- a/packages/rrweb/src/record/observers/canvas/2d.ts +++ b/packages/rrweb/src/record/observers/canvas/2d.ts @@ -1,4 +1,5 @@ import type { Mirror } from 'rrweb-snapshot'; +import { nativeSetTimeout } from 'rrweb-snapshot'; import { blockClass, CanvasContext, @@ -44,7 +45,7 @@ export default function initCanvas2DMutationObserver( if (!isBlocked(this.canvas, blockClass, blockSelector, true)) { // Using setTimeout as toDataURL can be heavy // and we'd rather not block the main thread - setTimeout(() => { + nativeSetTimeout(() => { const recordArgs = serializeArgs(args, win, this); cb(this.canvas, { type: CanvasContext['2D'], diff --git a/packages/rrweb/src/record/shadow-dom-manager.ts b/packages/rrweb/src/record/shadow-dom-manager.ts index 169c77216a..f27a41abc5 100644 --- a/packages/rrweb/src/record/shadow-dom-manager.ts +++ b/packages/rrweb/src/record/shadow-dom-manager.ts @@ -11,7 +11,7 @@ import { } from './observer'; import { patch, inDom } from '../utils'; import type { Mirror } from 'rrweb-snapshot'; -import { isNativeShadowDom } from 'rrweb-snapshot'; +import { isNativeShadowDom, nativeSetTimeout } from 'rrweb-snapshot'; type BypassOptions = Omit< MutationBufferParam, @@ -74,7 +74,7 @@ export class ShadowDomManager { }), ); // Defer this to avoid adoptedStyleSheet events being created before the full snapshot is created or attachShadow action is recorded. - setTimeout(() => { + nativeSetTimeout(() => { if ( shadowRoot.adoptedStyleSheets && shadowRoot.adoptedStyleSheets.length > 0 diff --git a/packages/rrweb/src/utils.ts b/packages/rrweb/src/utils.ts index f426689d2f..782fd3dd8e 100644 --- a/packages/rrweb/src/utils.ts +++ b/packages/rrweb/src/utils.ts @@ -10,17 +10,36 @@ import type { textMutation, } from '@rrweb/types'; import type { IMirror, Mirror } from 'rrweb-snapshot'; -import { isShadowRoot, IGNORED_NODE, classMatchesRegex } from 'rrweb-snapshot'; +import { + isShadowRoot, + IGNORED_NODE, + classMatchesRegex, + getNative, + nativeSetTimeout, +} from 'rrweb-snapshot'; import type { RRNode, RRIFrameElement } from 'rrdom'; +function getWindow(documentOrWindow: Document | IWindow): IWindow { + const defaultView = (documentOrWindow as Document).defaultView; + return (defaultView ? defaultView : documentOrWindow) as IWindow; +} + export function on( type: string, fn: EventListenerOrEventListenerObject, target: Document | IWindow = document, ): listenerHandler { + const windowObj = getWindow(target); + const nativeAddEventListener = getNative( + 'addEventListener', + windowObj, + ); + const nativeRemoveEventListener = getNative< + typeof window.removeEventListener + >('removeEventListener', windowObj); const options = { capture: true, passive: true }; - target.addEventListener(type, fn, options); - return () => target.removeEventListener(type, fn, options); + nativeAddEventListener.call(target, type, fn, options); + return () => nativeRemoveEventListener.call(target, type, fn, options); } // https://github.com/rrweb-io/rrweb/pull/407 @@ -70,7 +89,7 @@ export function throttle( wait: number, options: throttleOptions = {}, ) { - let timeout: ReturnType | null = null; + let timeout: ReturnType | number | null = null; let previous = 0; return function (...args: T[]) { const now = Date.now(); @@ -88,7 +107,7 @@ export function throttle( previous = now; func.apply(context, args); } else if (!timeout && options.trailing !== false) { - timeout = setTimeout(() => { + timeout = nativeSetTimeout(() => { previous = options.leading === false ? 0 : Date.now(); timeout = null; func.apply(context, args); @@ -113,7 +132,7 @@ export function hookSetter( : { set(value) { // put hooked setter into event loop to avoid of set latency - setTimeout(() => { + nativeSetTimeout(() => { d.set!.call(this, value); }, 0); if (original && original.set) { diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index c41d3e97ff..4425a73ce5 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -3,6 +3,7 @@ import type { Mirror, INode, DataURLOptions, + IWindow, } from 'rrweb-snapshot'; export enum EventType { @@ -678,8 +679,6 @@ declare global { } } -export type IWindow = Window & typeof globalThis; - export type Optional = Pick, K> & Omit; export type GetTypedKeys = TakeTypeHelper< @@ -694,3 +693,5 @@ export type TakeTypedKeyValues = Pick< Obj, TakeTypeHelper[keyof TakeTypeHelper] >; + +export { IWindow }; From 97f85be6d4fe93fcafee7279400153de67e9e5b1 Mon Sep 17 00:00:00 2001 From: Michael Dellanoce Date: Wed, 6 Dec 2023 11:28:15 -0500 Subject: [PATCH 06/15] optimize isParentRemoved for large remove lists --- .changeset/late-keys-whisper.md | 5 +++++ packages/rrweb/src/record/mutation.ts | 19 +++++++++++-------- 2 files changed, 16 insertions(+), 8 deletions(-) create mode 100644 .changeset/late-keys-whisper.md diff --git a/.changeset/late-keys-whisper.md b/.changeset/late-keys-whisper.md new file mode 100644 index 0000000000..daf97ae1dc --- /dev/null +++ b/.changeset/late-keys-whisper.md @@ -0,0 +1,5 @@ +--- +'rrweb': patch +--- + +Optimize isParentRemoved for large remove lists diff --git a/packages/rrweb/src/record/mutation.ts b/packages/rrweb/src/record/mutation.ts index 81592e8170..3f86cf2cc1 100644 --- a/packages/rrweb/src/record/mutation.ts +++ b/packages/rrweb/src/record/mutation.ts @@ -144,6 +144,7 @@ export default class MutationBuffer { private attributes: attributeCursor[] = []; private attributeMap = new WeakMap(); private removes: removedNodeMutation[] = []; + private removesMap = new Map(); private mapRemoves: Node[] = []; private movedMap: Record = {}; @@ -353,7 +354,7 @@ export default class MutationBuffer { for (const n of this.movedSet) { if ( - isParentRemoved(this.removes, n, this.mirror) && + isParentRemoved(this.removesMap, n, this.mirror) && !this.movedSet.has(n.parentNode!) ) { continue; @@ -364,7 +365,7 @@ export default class MutationBuffer { for (const n of this.addedSet) { if ( !isAncestorInSet(this.droppedSet, n) && - !isParentRemoved(this.removes, n, this.mirror) + !isParentRemoved(this.removesMap, n, this.mirror) ) { pushAdd(n); } else if (isAncestorInSet(this.movedSet, n)) { @@ -500,6 +501,7 @@ export default class MutationBuffer { this.attributes = []; this.attributeMap = new WeakMap(); this.removes = []; + this.removesMap = new Map(); this.addedSet = new Set(); this.movedSet = new Set(); this.droppedSet = new Set(); @@ -723,6 +725,7 @@ export default class MutationBuffer { ? true : undefined, }); + this.removesMap.set(nodeId, this.removes.length - 1); } this.mapRemoves.push(n); }); @@ -786,16 +789,16 @@ function deepDelete(addsSet: Set, n: Node) { } function isParentRemoved( - removes: removedNodeMutation[], + removesMap: Map, n: Node, mirror: Mirror, ): boolean { - if (removes.length === 0) return false; - return _isParentRemoved(removes, n, mirror); + if (removesMap.size === 0) return false; + return _isParentRemoved(removesMap, n, mirror); } function _isParentRemoved( - removes: removedNodeMutation[], + removesMap: Map, n: Node, mirror: Mirror, ): boolean { @@ -804,10 +807,10 @@ function _isParentRemoved( return false; } const parentId = mirror.getId(parentNode); - if (removes.some((r) => r.id === parentId)) { + if (removesMap.has(parentId)) { return true; } - return _isParentRemoved(removes, parentNode, mirror); + return _isParentRemoved(removesMap, parentNode, mirror); } function isAncestorInSet(set: Set, n: Node): boolean { From d526dc560e8ccd36275f785141c064e2b58a9217 Mon Sep 17 00:00:00 2001 From: Michael Dellanoce Date: Thu, 12 Jan 2023 12:54:17 -0500 Subject: [PATCH 07/15] apply text mask settings to inputs #1096 --- packages/rrweb-snapshot/src/snapshot.ts | 22 +- packages/rrweb-snapshot/src/utils.ts | 5 +- packages/rrweb/src/record/index.ts | 1 + packages/rrweb/src/record/mutation.ts | 8 + packages/rrweb/src/record/observer.ts | 14 +- .../__snapshots__/integration.test.ts.snap | 668 ++++++++++++++++++ packages/rrweb/test/integration.test.ts | 23 + packages/rrweb/test/utils.ts | 1 + 8 files changed, 738 insertions(+), 4 deletions(-) diff --git a/packages/rrweb-snapshot/src/snapshot.ts b/packages/rrweb-snapshot/src/snapshot.ts index 240f6bf811..bd6feb71eb 100644 --- a/packages/rrweb-snapshot/src/snapshot.ts +++ b/packages/rrweb-snapshot/src/snapshot.ts @@ -321,6 +321,7 @@ export function needMaskingText( ? (node as HTMLElement) : node.parentElement; if (el === null) return false; + if (maskTextSelector === '*') return true; if (typeof maskTextClass === 'string') { if (checkAncestors) { if (el.closest(`.${maskTextClass}`)) return true; @@ -503,11 +504,14 @@ function serializeNode( keepIframeSrcFn, newlyAddedElement, rootId, + needsMask, }); case n.TEXT_NODE: return serializeTextNode(n as Text, { needsMask, maskTextFn, + maskInputOptions, + maskInputFn, rootId, }); case n.CDATA_SECTION_NODE: @@ -538,16 +542,20 @@ function serializeTextNode( options: { needsMask: boolean | undefined; maskTextFn: MaskTextFn | undefined; + maskInputOptions: MaskInputOptions; + maskInputFn: MaskInputFn | undefined; rootId: number | undefined; }, ): serializedNode { - const { needsMask, maskTextFn, rootId } = options; + const { needsMask, maskTextFn, maskInputOptions, maskInputFn, 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; let textContent = n.textContent; const isStyle = parentTagName === 'STYLE' ? true : undefined; const isScript = parentTagName === 'SCRIPT' ? true : undefined; + const isTextarea = parentTagName === 'TEXTAREA' ? true : undefined; if (isStyle && textContent) { try { // try to read style sheet @@ -577,6 +585,11 @@ function serializeTextNode( ? maskTextFn(textContent, n.parentElement) : textContent.replace(/[\S]/g, '*'); } + if (isTextarea && textContent && maskInputOptions.textarea) { + textContent = maskInputFn + ? maskInputFn(textContent, n.parentNode as HTMLElement) + : textContent.replace(/[\S]/g, '*'); + } return { type: NodeType.Text, @@ -604,6 +617,7 @@ function serializeElementNode( */ newlyAddedElement?: boolean; rootId: number | undefined; + needsMask?: boolean; }, ): serializedNode | false { const { @@ -619,6 +633,7 @@ function serializeElementNode( keepIframeSrcFn, newlyAddedElement = false, rootId, + needsMask, } = options; const needBlock = _isBlockedElement(n, blockClass, blockSelector); const tagName = getValidTagName(n); @@ -675,6 +690,8 @@ function serializeElementNode( attributes.type !== 'button' && value ) { + const type = getInputType(n); + attributes.value = maskInputValue({ element: n, type: getInputType(n), @@ -682,6 +699,7 @@ function serializeElementNode( value, maskInputOptions, maskInputFn, + needsMask, }); } else if (checked) { attributes.checked = checked; @@ -1241,7 +1259,7 @@ function snapshot( inlineStylesheet?: boolean; maskAllInputs?: boolean | MaskInputOptions; maskTextFn?: MaskTextFn; - maskInputFn?: MaskTextFn; + maskInputFn?: MaskInputFn; slimDOM?: 'all' | boolean | SlimDOMOptions; dataURLOptions?: DataURLOptions; inlineImages?: boolean; diff --git a/packages/rrweb-snapshot/src/utils.ts b/packages/rrweb-snapshot/src/utils.ts index 25c8f9acbd..8671abdaaa 100644 --- a/packages/rrweb-snapshot/src/utils.ts +++ b/packages/rrweb-snapshot/src/utils.ts @@ -248,6 +248,7 @@ export function maskInputValue({ type, value, maskInputFn, + needsMask, }: { element: HTMLElement; maskInputOptions: MaskInputOptions; @@ -255,13 +256,15 @@ export function maskInputValue({ type: string | null; value: string | null; maskInputFn?: MaskInputFn; + needsMask?: boolean; }): string { let text = value || ''; const actualType = type && toLowerCase(type); if ( maskInputOptions[tagName.toLowerCase() as keyof MaskInputOptions] || - (actualType && maskInputOptions[actualType as keyof MaskInputOptions]) + (actualType && maskInputOptions[actualType as keyof MaskInputOptions]) || + needsMask ) { if (maskInputFn) { text = maskInputFn(text, element); diff --git a/packages/rrweb/src/record/index.ts b/packages/rrweb/src/record/index.ts index 1b475c9339..48d1afa348 100644 --- a/packages/rrweb/src/record/index.ts +++ b/packages/rrweb/src/record/index.ts @@ -381,6 +381,7 @@ function record( maskTextSelector, inlineStylesheet, maskAllInputs: maskInputOptions, + maskInputFn, maskTextFn, slimDOM: slimDOMOptions, dataURLOptions, diff --git a/packages/rrweb/src/record/mutation.ts b/packages/rrweb/src/record/mutation.ts index 3f86cf2cc1..29127ae9b5 100644 --- a/packages/rrweb/src/record/mutation.ts +++ b/packages/rrweb/src/record/mutation.ts @@ -565,6 +565,13 @@ export default class MutationBuffer { if (attributeName === 'value') { const type = getInputType(target); + const needsMask = needMaskingText( + m.target, + this.maskTextClass, + this.maskTextSelector, + true, + ); + value = maskInputValue({ element: target, maskInputOptions: this.maskInputOptions, @@ -572,6 +579,7 @@ export default class MutationBuffer { type, value, maskInputFn: this.maskInputFn, + needsMask, }); } if ( diff --git a/packages/rrweb/src/record/observer.ts b/packages/rrweb/src/record/observer.ts index 1841b255b4..07781162ed 100644 --- a/packages/rrweb/src/record/observer.ts +++ b/packages/rrweb/src/record/observer.ts @@ -6,6 +6,7 @@ import { toLowerCase, getNative, nativeSetTimeout, + needMaskingText, } from 'rrweb-snapshot'; import type { FontFaceSet } from 'css-font-loading-module'; import { @@ -404,6 +405,8 @@ function initInputObserver({ maskInputFn, sampling, userTriggeredOnInput, + maskTextClass, + maskTextSelector, }: observerParam): listenerHandler { function eventHandler(event: Event) { let target = getEventTarget(event) as HTMLElement | null; @@ -436,11 +439,19 @@ function initInputObserver({ let isChecked = false; const type: Lowercase = getInputType(target) || ''; + const needsMask = needMaskingText( + target as Node, + maskTextClass, + maskTextSelector, + true, + ); + if (type === 'radio' || type === 'checkbox') { isChecked = (target as HTMLInputElement).checked; } else if ( maskInputOptions[tagName.toLowerCase() as keyof MaskInputOptions] || - maskInputOptions[type as keyof MaskInputOptions] + maskInputOptions[type as keyof MaskInputOptions] || + needsMask ) { text = maskInputValue({ element: target, @@ -449,6 +460,7 @@ function initInputObserver({ type, value: text, maskInputFn, + needsMask, }); } cbWithDedup( diff --git a/packages/rrweb/test/__snapshots__/integration.test.ts.snap b/packages/rrweb/test/__snapshots__/integration.test.ts.snap index ae1aa7bf54..7b230846d1 100644 --- a/packages/rrweb/test/__snapshots__/integration.test.ts.snap +++ b/packages/rrweb/test/__snapshots__/integration.test.ts.snap @@ -4960,6 +4960,665 @@ exports[`record integration tests can use maskInputOptions to configure which ty ]" `; +exports[`record integration tests can use maskTextSelector to configure which inputs should be masked 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\\": { + \\"lang\\": \\"en\\" + }, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"head\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 5 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"meta\\", + \\"attributes\\": { + \\"charset\\": \\"UTF-8\\" + }, + \\"childNodes\\": [], + \\"id\\": 6 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 7 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"meta\\", + \\"attributes\\": { + \\"name\\": \\"viewport\\", + \\"content\\": \\"width=device-width, initial-scale=1.0\\" + }, + \\"childNodes\\": [], + \\"id\\": 8 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 9 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"meta\\", + \\"attributes\\": { + \\"http-equiv\\": \\"X-UA-Compatible\\", + \\"content\\": \\"ie=edge\\" + }, + \\"childNodes\\": [], + \\"id\\": 10 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 11 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"title\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"form fields\\", + \\"id\\": 13 + } + ], + \\"id\\": 12 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 14 + } + ], + \\"id\\": 4 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n\\\\n \\", + \\"id\\": 15 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"body\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 17 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"form\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 19 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"label\\", + \\"attributes\\": { + \\"for\\": \\"text\\" + }, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 21 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"input\\", + \\"attributes\\": { + \\"type\\": \\"text\\" + }, + \\"childNodes\\": [], + \\"id\\": 22 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 23 + } + ], + \\"id\\": 20 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 24 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"label\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 26 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"input\\", + \\"attributes\\": { + \\"type\\": \\"radio\\", + \\"name\\": \\"toggle\\", + \\"value\\": \\"on\\" + }, + \\"childNodes\\": [], + \\"id\\": 27 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 28 + } + ], + \\"id\\": 25 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 29 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"label\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 31 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"input\\", + \\"attributes\\": { + \\"type\\": \\"radio\\", + \\"name\\": \\"toggle\\", + \\"value\\": \\"off\\", + \\"checked\\": true + }, + \\"childNodes\\": [], + \\"id\\": 32 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 33 + } + ], + \\"id\\": 30 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 34 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"label\\", + \\"attributes\\": { + \\"for\\": \\"checkbox\\" + }, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 36 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"input\\", + \\"attributes\\": { + \\"type\\": \\"checkbox\\" + }, + \\"childNodes\\": [], + \\"id\\": 37 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 38 + } + ], + \\"id\\": 35 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 39 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"label\\", + \\"attributes\\": { + \\"for\\": \\"textarea\\" + }, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 41 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"textarea\\", + \\"attributes\\": { + \\"name\\": \\"\\", + \\"id\\": \\"\\", + \\"cols\\": \\"30\\", + \\"rows\\": \\"10\\", + \\"data-unmask-example\\": \\"true\\" + }, + \\"childNodes\\": [], + \\"id\\": 42 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 43 + } + ], + \\"id\\": 40 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 44 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"label\\", + \\"attributes\\": { + \\"for\\": \\"select\\" + }, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 46 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"select\\", + \\"attributes\\": { + \\"name\\": \\"\\", + \\"id\\": \\"\\", + \\"value\\": \\"1\\" + }, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 48 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"option\\", + \\"attributes\\": { + \\"value\\": \\"1\\", + \\"selected\\": true + }, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"1\\", + \\"id\\": 50 + } + ], + \\"id\\": 49 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 51 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"option\\", + \\"attributes\\": { + \\"value\\": \\"2\\" + }, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"2\\", + \\"id\\": 53 + } + ], + \\"id\\": 52 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 54 + } + ], + \\"id\\": 47 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 55 + } + ], + \\"id\\": 45 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 56 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"label\\", + \\"attributes\\": { + \\"for\\": \\"password\\" + }, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 58 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"input\\", + \\"attributes\\": { + \\"type\\": \\"password\\" + }, + \\"childNodes\\": [], + \\"id\\": 59 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 60 + } + ], + \\"id\\": 57 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 61 + } + ], + \\"id\\": 18 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\\\n \\", + \\"id\\": 62 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"script\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"SCRIPT_PLACEHOLDER\\", + \\"id\\": 64 + } + ], + \\"id\\": 63 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\\\n \\\\n\\\\n\\", + \\"id\\": 65 + } + ], + \\"id\\": 16 + } + ], + \\"id\\": 3 + } + ], + \\"id\\": 1 + }, + \\"initialOffset\\": { + \\"left\\": 0, + \\"top\\": 0 + } + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 2, + \\"type\\": 5, + \\"id\\": 22 + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 5, + \\"text\\": \\"**********\\", + \\"isChecked\\": false, + \\"id\\": 22 + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 2, + \\"type\\": 1, + \\"id\\": 27 + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 2, + \\"type\\": 6, + \\"id\\": 22 + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 2, + \\"type\\": 5, + \\"id\\": 27 + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 2, + \\"type\\": 0, + \\"id\\": 27 + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 2, + \\"type\\": 2, + \\"id\\": 27, + \\"pointerType\\": 0 + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 5, + \\"text\\": \\"on\\", + \\"isChecked\\": true, + \\"id\\": 27 + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 5, + \\"text\\": \\"off\\", + \\"isChecked\\": false, + \\"id\\": 32 + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 2, + \\"type\\": 1, + \\"id\\": 37 + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 2, + \\"type\\": 6, + \\"id\\": 27 + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 2, + \\"type\\": 5, + \\"id\\": 37 + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 2, + \\"type\\": 0, + \\"id\\": 37 + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 2, + \\"type\\": 2, + \\"id\\": 37, + \\"pointerType\\": 0 + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 5, + \\"text\\": \\"on\\", + \\"isChecked\\": true, + \\"id\\": 37 + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 2, + \\"type\\": 6, + \\"id\\": 37 + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 2, + \\"type\\": 5, + \\"id\\": 42 + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 5, + \\"text\\": \\"**********\\", + \\"isChecked\\": false, + \\"id\\": 42 + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 2, + \\"type\\": 6, + \\"id\\": 42 + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 2, + \\"type\\": 5, + \\"id\\": 59 + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 5, + \\"text\\": \\"**********\\", + \\"isChecked\\": false, + \\"id\\": 59 + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 5, + \\"text\\": \\"1\\", + \\"isChecked\\": false, + \\"id\\": 47 + } + } +]" +`; + exports[`record integration tests handles null attribute values 1`] = ` "[ { @@ -9785,6 +10444,15 @@ exports[`record integration tests should not record input values if dynamically \\"id\\": 21 } }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 3, + \\"id\\": 21, + \\"x\\": 20, + \\"y\\": 0 + } + }, { \\"type\\": 3, \\"data\\": { diff --git a/packages/rrweb/test/integration.test.ts b/packages/rrweb/test/integration.test.ts index 616b6e91cb..59656f8c4f 100644 --- a/packages/rrweb/test/integration.test.ts +++ b/packages/rrweb/test/integration.test.ts @@ -425,6 +425,29 @@ describe('record integration tests', function (this: ISuite) { assertSnapshot(snapshots); }); + it('can use maskTextSelector to configure which inputs should be masked', async () => { + const page: puppeteer.Page = await browser.newPage(); + await page.goto('about:blank'); + await page.setContent( + getHtml.call(this, 'form.html', { + maskTextSelector: 'input[type="text"],textarea', + maskInputFn: () => '*'.repeat(10), + }), + ); + + await page.type('input[type="text"]', 'test'); + await page.click('input[type="radio"]'); + await page.click('input[type="checkbox"]'); + await page.type('textarea', 'textarea test'); + await page.type('input[type="password"]', 'password'); + await page.select('select', '1'); + + const snapshots = (await page.evaluate( + 'window.snapshots', + )) as eventWithTime[]; + assertSnapshot(snapshots); + }); + it('should mask password value attribute with maskInputOptions', async () => { const page: puppeteer.Page = await browser.newPage(); await page.goto('about:blank'); diff --git a/packages/rrweb/test/utils.ts b/packages/rrweb/test/utils.ts index 6cd93281f9..91c1190c3b 100644 --- a/packages/rrweb/test/utils.ts +++ b/packages/rrweb/test/utils.ts @@ -692,6 +692,7 @@ export function generateRecordSnippet(options: recordOptions) { maskTextSelector: ${JSON.stringify(options.maskTextSelector)}, maskAllInputs: ${options.maskAllInputs}, maskInputOptions: ${JSON.stringify(options.maskAllInputs)}, + maskInputFn: ${options.maskInputFn}, userTriggeredOnInput: ${options.userTriggeredOnInput}, maskTextClass: ${options.maskTextClass}, maskTextFn: ${options.maskTextFn}, From 8d141248891320d4620938504093aaaa5bcf7d98 Mon Sep 17 00:00:00 2001 From: Michael Dellanoce Date: Fri, 8 Dec 2023 09:47:09 -0500 Subject: [PATCH 08/15] allow passing null to maskTextClass/blockClass --- .changeset/quick-cows-chew.md | 5 +++++ packages/rrweb-snapshot/src/snapshot.ts | 3 ++- 2 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 .changeset/quick-cows-chew.md diff --git a/.changeset/quick-cows-chew.md b/.changeset/quick-cows-chew.md new file mode 100644 index 0000000000..f06fba27b0 --- /dev/null +++ b/.changeset/quick-cows-chew.md @@ -0,0 +1,5 @@ +--- +'rrweb-snapshot': patch +--- + +allow passing null to maskTextClass/blockClass diff --git a/packages/rrweb-snapshot/src/snapshot.ts b/packages/rrweb-snapshot/src/snapshot.ts index bd6feb71eb..26b5c22250 100644 --- a/packages/rrweb-snapshot/src/snapshot.ts +++ b/packages/rrweb-snapshot/src/snapshot.ts @@ -270,7 +270,7 @@ export function _isBlockedElement( if (element.classList.contains(blockClass)) { return true; } - } else { + } else if (blockClass) { for (let eIndex = element.classList.length; eIndex--; ) { const className = element.classList[eIndex]; if (blockClass.test(className)) { @@ -294,6 +294,7 @@ export function classMatchesRegex( checkAncestors: boolean, ): boolean { if (!node) return false; + if (!regex) return false; if (node.nodeType !== node.ELEMENT_NODE) { if (!checkAncestors) return false; return classMatchesRegex(node.parentNode, regex, checkAncestors); From 3997dd1b4eb7f5f0fdd3160dd3c5d691686fe5b4 Mon Sep 17 00:00:00 2001 From: Michael Dellanoce Date: Wed, 24 Jan 2024 13:41:53 -0500 Subject: [PATCH 09/15] reorder addList on demand --- packages/rrweb/src/record/mutation.ts | 39 ++++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/packages/rrweb/src/record/mutation.ts b/packages/rrweb/src/record/mutation.ts index 29127ae9b5..982c564ad8 100644 --- a/packages/rrweb/src/record/mutation.ts +++ b/packages/rrweb/src/record/mutation.ts @@ -50,6 +50,7 @@ class DoubleLinkedList { public length = 0; public head: DoubleLinkedListNode | null = null; public tail: DoubleLinkedListNode | null = null; + public reordered = false; public get(position: number) { if (position >= this.length) { @@ -129,6 +130,34 @@ class DoubleLinkedList { } this.length--; } + + public needsReorder(_node: DoubleLinkedListNode) { + if (!this.reordered && + _node.value.previousSibling && + isNodeInLinkedList(_node.value.previousSibling) && + _node.previous && + _node.previous.value !== _node.value.previousSibling) { + return true; + } + return false; + } + + public reorder() { + if (this.reordered) return false; + let current = this.tail; + const head = this.head; + while (current) { + const prev = current.previous; + this.removeNode(current.value); + this.addNode(current.value); + if (current === head) { + break; + } + current = prev; + } + this.reordered = true; + return true; + } } const moveKey = (id: number, parentId: number) => `${id}@${parentId}`; @@ -395,9 +424,13 @@ export default class MutationBuffer { const parentId = this.mirror.getId(_node.value.parentNode); const nextId = getNextId(_node.value); - if (nextId === -1) continue; - // nextId !== -1 && parentId !== -1 - else if (parentId !== -1) { + if (nextId === -1) { + if (addList.needsReorder(_node) && addList.reorder()) { + tailNode = addList.tail; + } + continue; + } else if (parentId !== -1) { + // nextId !== -1 && parentId !== -1 node = _node; break; } From b844401a1b0f20e3e65597ab70210d778a068539 Mon Sep 17 00:00:00 2001 From: mdellanoce Date: Mon, 29 Jan 2024 15:56:32 +0000 Subject: [PATCH 10/15] Apply formatting changes --- packages/rrweb/src/record/mutation.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/rrweb/src/record/mutation.ts b/packages/rrweb/src/record/mutation.ts index 982c564ad8..59d05635c3 100644 --- a/packages/rrweb/src/record/mutation.ts +++ b/packages/rrweb/src/record/mutation.ts @@ -132,11 +132,13 @@ class DoubleLinkedList { } public needsReorder(_node: DoubleLinkedListNode) { - if (!this.reordered && + if ( + !this.reordered && _node.value.previousSibling && isNodeInLinkedList(_node.value.previousSibling) && _node.previous && - _node.previous.value !== _node.value.previousSibling) { + _node.previous.value !== _node.value.previousSibling + ) { return true; } return false; From edc42997ed5cd47caa71f56f4f849612b35cca05 Mon Sep 17 00:00:00 2001 From: Michael Dellanoce Date: Fri, 2 Feb 2024 15:51:28 -0500 Subject: [PATCH 11/15] avoid using 2nd argument of Array.from that prototype.js does not support --- packages/rrweb-snapshot/src/utils.ts | 8 +++++--- packages/rrweb/src/record/stylesheet-manager.ts | 13 +++++++++---- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/packages/rrweb-snapshot/src/utils.ts b/packages/rrweb-snapshot/src/utils.ts index 8671abdaaa..93ae75a9f9 100644 --- a/packages/rrweb-snapshot/src/utils.ts +++ b/packages/rrweb-snapshot/src/utils.ts @@ -125,10 +125,12 @@ export function escapeImportStatement(rule: CSSImportRule): string { export function stringifyStylesheet(s: CSSStyleSheet): string | null { try { const rules = s.rules || s.cssRules; + const stringifiedRules = [] as string[]; + for (let i = 0; i < rules.length; ++i) { + stringifiedRules.push(stringifyRule(rules[i])); + } return rules - ? fixBrowserCompatibilityIssuesInCSS( - Array.from(rules, stringifyRule).join(''), - ) + ? fixBrowserCompatibilityIssuesInCSS(stringifiedRules.join('')) : null; } catch (error) { return null; diff --git a/packages/rrweb/src/record/stylesheet-manager.ts b/packages/rrweb/src/record/stylesheet-manager.ts index 6e0a8077b4..b5bd3faecc 100644 --- a/packages/rrweb/src/record/stylesheet-manager.ts +++ b/packages/rrweb/src/record/stylesheet-manager.ts @@ -5,6 +5,7 @@ import type { adoptedStyleSheetParam, attributeMutation, mutationCallBack, + styleSheetAddRule, } from '@rrweb/types'; import { StyleSheetMirror } from '../utils'; @@ -61,12 +62,16 @@ export class StylesheetManager { let styleId; if (!this.styleMirror.has(sheet)) { styleId = this.styleMirror.add(sheet); + const rules = [] as styleSheetAddRule[]; + for (let i = 0; i < sheet.rules.length; ++i) { + rules.push({ + rule: stringifyRule(sheet.rules[i]), + index: i, + }); + } styles.push({ styleId, - rules: Array.from(sheet.rules || CSSRule, (r, index) => ({ - rule: stringifyRule(r), - index, - })), + rules, }); } else styleId = this.styleMirror.getId(sheet); adoptedStyleSheetData.styleIds.push(styleId); From 0407abad6200aa398a745783d48773d29e010246 Mon Sep 17 00:00:00 2001 From: Michael Dellanoce Date: Thu, 8 Feb 2024 10:51:58 -0500 Subject: [PATCH 12/15] toString for patched methods returns original toString --- packages/rrweb/src/utils.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/rrweb/src/utils.ts b/packages/rrweb/src/utils.ts index 782fd3dd8e..106097bf5e 100644 --- a/packages/rrweb/src/utils.ts +++ b/packages/rrweb/src/utils.ts @@ -163,6 +163,9 @@ export function patch( // Make sure it's a function first, as we need to attach an empty prototype for `defineProperties` to work // otherwise it'll throw "TypeError: Object.defineProperties called on non-object" if (typeof wrapped === 'function') { + wrapped.toString = function() { + return original.toString(); + }; // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment wrapped.prototype = wrapped.prototype || {}; Object.defineProperties(wrapped, { From 92595f2959bb2bb7042329132bd5073aa217ccd2 Mon Sep 17 00:00:00 2001 From: mdellanoce Date: Thu, 8 Feb 2024 15:53:42 +0000 Subject: [PATCH 13/15] Apply formatting changes --- packages/rrweb/src/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/rrweb/src/utils.ts b/packages/rrweb/src/utils.ts index 106097bf5e..bf31d8d253 100644 --- a/packages/rrweb/src/utils.ts +++ b/packages/rrweb/src/utils.ts @@ -163,7 +163,7 @@ export function patch( // Make sure it's a function first, as we need to attach an empty prototype for `defineProperties` to work // otherwise it'll throw "TypeError: Object.defineProperties called on non-object" if (typeof wrapped === 'function') { - wrapped.toString = function() { + wrapped.toString = function () { return original.toString(); }; // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment From 641b6f63369d47d40b12742dc0c24735851b6a42 Mon Sep 17 00:00:00 2001 From: guntherjh Date: Wed, 28 Feb 2024 18:15:02 +0000 Subject: [PATCH 14/15] Apply formatting changes --- packages/rrweb-snapshot/src/snapshot.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/rrweb-snapshot/src/snapshot.ts b/packages/rrweb-snapshot/src/snapshot.ts index 26b5c22250..e3e395c3e3 100644 --- a/packages/rrweb-snapshot/src/snapshot.ts +++ b/packages/rrweb-snapshot/src/snapshot.ts @@ -24,7 +24,7 @@ import { getInputType, toLowerCase, extractFileExtension, - nativeSetTimeout + nativeSetTimeout, } from './utils'; let _id = 1; From dd6050ccdc4ad83a576cfd6ca44bc62565200860 Mon Sep 17 00:00:00 2001 From: John Gunther Date: Thu, 7 Mar 2024 14:34:09 -0500 Subject: [PATCH 15/15] change name in packages/rrweb package.json from rrweb -> @pendo/rrweb --- packages/rrweb/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/rrweb/package.json b/packages/rrweb/package.json index 2315abfec8..432ff44884 100644 --- a/packages/rrweb/package.json +++ b/packages/rrweb/package.json @@ -1,5 +1,5 @@ { - "name": "rrweb", + "name": "@pendo/rrweb", "version": "2.0.0-alpha.11", "description": "record and replay the web", "scripts": {