From 50785be503a20799c3030e08a8cbf59e91b5d830 Mon Sep 17 00:00:00 2001 From: Justin Halsall Date: Thu, 31 Mar 2022 12:08:42 +0200 Subject: [PATCH 01/12] Move ids to weakmap --- packages/rrweb-snapshot/src/snapshot.ts | 33 +++++++++++++------ packages/rrweb-snapshot/src/types.ts | 4 ++- packages/rrweb-snapshot/typings/snapshot.d.ts | 5 +-- packages/rrweb-snapshot/typings/types.d.ts | 3 +- 4 files changed, 31 insertions(+), 14 deletions(-) diff --git a/packages/rrweb-snapshot/src/snapshot.ts b/packages/rrweb-snapshot/src/snapshot.ts index ae7f05f01c..486c77ee66 100644 --- a/packages/rrweb-snapshot/src/snapshot.ts +++ b/packages/rrweb-snapshot/src/snapshot.ts @@ -5,6 +5,7 @@ import { attributes, INode, idNodeMap, + nodeIdMap, MaskInputOptions, SlimDOMOptions, DataURLOptions, @@ -377,6 +378,7 @@ function serializeNode( n: Node, options: { doc: Document; + nodeIdMap: nodeIdMap; blockClass: string | RegExp; blockSelector: string | null; maskTextClass: string | RegExp; @@ -393,6 +395,7 @@ function serializeNode( ): serializedNode | false { const { doc, + nodeIdMap, blockClass, blockSelector, maskTextClass, @@ -408,8 +411,8 @@ function serializeNode( } = options; // Only record root id when document object is not the base document let rootId: number | undefined; - if (((doc as unknown) as INode).__sn) { - const docId = ((doc as unknown) as INode).__sn.id; + const docId = nodeIdMap.get(n); + if (docId) { rootId = docId === 1 ? undefined : docId; } switch (n.nodeType) { @@ -784,7 +787,8 @@ export function serializeNodeWithId( n: Node | INode, options: { doc: Document; - map: idNodeMap; + map: idNodeMap; // DEPRECATED + nodeIdMap: nodeIdMap; blockClass: string | RegExp; blockSelector: string | null; maskTextClass: string | RegExp; @@ -808,6 +812,7 @@ export function serializeNodeWithId( const { doc, map, + nodeIdMap, blockClass, blockSelector, maskTextClass, @@ -829,6 +834,7 @@ export function serializeNodeWithId( let { preserveWhiteSpace = true } = options; const _serializedNode = serializeNode(n, { doc, + nodeIdMap, blockClass, blockSelector, maskTextClass, @@ -848,10 +854,9 @@ export function serializeNodeWithId( return null; } - let id; - // Try to reuse the previous id - if ('__sn' in n) { - id = n.__sn.id; + let id: number | undefined = nodeIdMap.get(n); + if (id) { + // Reuse the previous id } else if ( slimDOMExcluded(_serializedNode, slimDOMOptions) || (!preserveWhiteSpace && @@ -868,7 +873,11 @@ export function serializeNodeWithId( if (id === IGNORED_NODE) { return null; // slimDOM } + nodeIdMap.set(n, id); + + // TODO: map is deprecated, please clean me up map[id] = n as INode; + if (onSerialize) { onSerialize(n as INode); } @@ -895,6 +904,7 @@ export function serializeNodeWithId( const bypassOptions = { doc, map, + nodeIdMap, blockClass, blockSelector, maskTextClass, @@ -948,6 +958,7 @@ export function serializeNodeWithId( const serializedIframeNode = serializeNodeWithId(iframeDoc, { doc: iframeDoc, map, + nodeIdMap, blockClass, blockSelector, maskTextClass, @@ -1001,7 +1012,7 @@ function snapshot( iframeLoadTimeout?: number; keepIframeSrcFn?: KeepIframeSrcFn; }, -): [serializedNodeWithId | null, idNodeMap] { +): [serializedNodeWithId | null, nodeIdMap] { const { blockClass = 'rr-block', blockSelector = null, @@ -1022,6 +1033,7 @@ function snapshot( keepIframeSrcFn = () => false, } = options || {}; const idNodeMap: idNodeMap = {}; + const nodeIdMap: nodeIdMap = new WeakMap(); const maskInputOptions: MaskInputOptions = maskAllInputs === true ? { @@ -1068,7 +1080,8 @@ function snapshot( return [ serializeNodeWithId(n, { doc: n, - map: idNodeMap, + map: idNodeMap, // DEPRECATED + nodeIdMap, blockClass, blockSelector, maskTextClass, @@ -1088,7 +1101,7 @@ function snapshot( iframeLoadTimeout, keepIframeSrcFn, }), - idNodeMap, + nodeIdMap, ]; } diff --git a/packages/rrweb-snapshot/src/types.ts b/packages/rrweb-snapshot/src/types.ts index eedd49252f..812c6d17c2 100644 --- a/packages/rrweb-snapshot/src/types.ts +++ b/packages/rrweb-snapshot/src/types.ts @@ -68,7 +68,7 @@ export type tagMap = { }; export interface INode extends Node { - __sn: serializedNodeWithId; + __sn: serializedNodeWithId | serializedNode; } export interface ICanvas extends HTMLCanvasElement { @@ -79,6 +79,8 @@ export type idNodeMap = { [key: number]: INode; }; +export type nodeIdMap = WeakMap; + export type MaskInputOptions = Partial<{ color: boolean; date: boolean; diff --git a/packages/rrweb-snapshot/typings/snapshot.d.ts b/packages/rrweb-snapshot/typings/snapshot.d.ts index b970f751eb..24c138f3df 100644 --- a/packages/rrweb-snapshot/typings/snapshot.d.ts +++ b/packages/rrweb-snapshot/typings/snapshot.d.ts @@ -1,4 +1,4 @@ -import { serializedNodeWithId, INode, idNodeMap, MaskInputOptions, SlimDOMOptions, DataURLOptions, MaskTextFn, MaskInputFn, KeepIframeSrcFn } from './types'; +import { serializedNodeWithId, INode, idNodeMap, nodeIdMap, MaskInputOptions, SlimDOMOptions, DataURLOptions, MaskTextFn, MaskInputFn, KeepIframeSrcFn } from './types'; export declare const IGNORED_NODE = -2; export declare function absoluteToStylesheet(cssText: string | null, href: string): string; export declare function absoluteToDoc(doc: Document, attributeValue: string): string; @@ -8,6 +8,7 @@ export declare function needMaskingText(node: Node | null, maskTextClass: string export declare function serializeNodeWithId(n: Node | INode, options: { doc: Document; map: idNodeMap; + nodeIdMap: nodeIdMap; blockClass: string | RegExp; blockSelector: string | null; maskTextClass: string | RegExp; @@ -45,7 +46,7 @@ declare function snapshot(n: Document, options?: { onIframeLoad?: (iframeINode: INode, node: serializedNodeWithId) => unknown; iframeLoadTimeout?: number; keepIframeSrcFn?: KeepIframeSrcFn; -}): [serializedNodeWithId | null, idNodeMap]; +}): [serializedNodeWithId | null, nodeIdMap]; export declare function visitSnapshot(node: serializedNodeWithId, onVisit: (node: serializedNodeWithId) => unknown): void; export declare function cleanupSnapshot(): void; export default snapshot; diff --git a/packages/rrweb-snapshot/typings/types.d.ts b/packages/rrweb-snapshot/typings/types.d.ts index a8ccc0d305..086aab40ec 100644 --- a/packages/rrweb-snapshot/typings/types.d.ts +++ b/packages/rrweb-snapshot/typings/types.d.ts @@ -53,7 +53,7 @@ export declare type tagMap = { [key: string]: string; }; export interface INode extends Node { - __sn: serializedNodeWithId; + __sn: serializedNodeWithId | serializedNode; } export interface ICanvas extends HTMLCanvasElement { __context: string; @@ -61,6 +61,7 @@ export interface ICanvas extends HTMLCanvasElement { export declare type idNodeMap = { [key: number]: INode; }; +export declare type nodeIdMap = WeakMap; export declare type MaskInputOptions = Partial<{ color: boolean; date: boolean; From 39bf03ed83d3b40396a8ad6c0541662d73c89e56 Mon Sep 17 00:00:00 2001 From: Justin Halsall Date: Thu, 31 Mar 2022 14:41:54 +0200 Subject: [PATCH 02/12] Fix typo --- 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 486c77ee66..f0fe7b3032 100644 --- a/packages/rrweb-snapshot/src/snapshot.ts +++ b/packages/rrweb-snapshot/src/snapshot.ts @@ -411,7 +411,7 @@ function serializeNode( } = options; // Only record root id when document object is not the base document let rootId: number | undefined; - const docId = nodeIdMap.get(n); + const docId = nodeIdMap.get(doc); if (docId) { rootId = docId === 1 ? undefined : docId; } From c6199f55029119b09117fe2b47d2bc14f417b43e Mon Sep 17 00:00:00 2001 From: Justin Halsall Date: Mon, 4 Apr 2022 18:05:23 +0200 Subject: [PATCH 03/12] Move from INode to storing serialized data in mirror --- packages/rrweb-snapshot/README.md | 2 +- packages/rrweb-snapshot/src/rebuild.ts | 67 +++++----- packages/rrweb-snapshot/src/snapshot.ts | 114 ++++++++---------- packages/rrweb-snapshot/src/types.ts | 9 +- packages/rrweb-snapshot/src/utils.ts | 69 ++++++++++- packages/rrweb-snapshot/tsconfig.json | 1 + packages/rrweb-snapshot/typings/rebuild.d.ts | 16 +-- packages/rrweb-snapshot/typings/snapshot.d.ts | 19 +-- packages/rrweb-snapshot/typings/types.d.ts | 7 +- packages/rrweb-snapshot/typings/utils.d.ts | 19 ++- packages/rrweb/src/record/iframe-manager.ts | 10 +- packages/rrweb/src/record/index.ts | 22 ++-- packages/rrweb/src/record/mutation.ts | 73 ++++++----- packages/rrweb/src/record/observer.ts | 28 ++--- .../rrweb/src/record/observers/canvas/2d.ts | 5 +- .../record/observers/canvas/canvas-manager.ts | 5 +- .../src/record/observers/canvas/canvas.ts | 4 +- .../src/record/observers/canvas/webgl.ts | 7 +- .../rrweb/src/record/shadow-dom-manager.ts | 2 +- packages/rrweb/src/replay/index.ts | 102 ++++++++-------- packages/rrweb/src/replay/virtual-styles.ts | 6 +- packages/rrweb/src/types.ts | 12 +- packages/rrweb/src/utils.ts | 82 +++---------- .../rrweb/typings/record/iframe-manager.d.ts | 4 +- packages/rrweb/typings/record/index.d.ts | 3 +- .../typings/record/observers/canvas/2d.d.ts | 3 +- .../observers/canvas/canvas-manager.d.ts | 3 +- .../record/observers/canvas/webgl.d.ts | 3 +- .../typings/record/shadow-dom-manager.d.ts | 6 +- packages/rrweb/typings/replay/index.d.ts | 4 +- .../rrweb/typings/replay/virtual-styles.d.ts | 3 +- packages/rrweb/typings/types.d.ts | 13 +- packages/rrweb/typings/utils.d.ts | 17 +-- 33 files changed, 393 insertions(+), 347 deletions(-) diff --git a/packages/rrweb-snapshot/README.md b/packages/rrweb-snapshot/README.md index 934f903483..596a52b70d 100644 --- a/packages/rrweb-snapshot/README.md +++ b/packages/rrweb-snapshot/README.md @@ -37,4 +37,4 @@ There are several things will be done during rebuild: #### buildNodeWithSN -`buildNodeWithSN` will build DOM from serialized node and store serialized information in `__sn` property. +`buildNodeWithSN` will build DOM from serialized node and store serialized information in the `mirror.getMeta(node)`. diff --git a/packages/rrweb-snapshot/src/rebuild.ts b/packages/rrweb-snapshot/src/rebuild.ts index 847cdd0698..491836f176 100644 --- a/packages/rrweb-snapshot/src/rebuild.ts +++ b/packages/rrweb-snapshot/src/rebuild.ts @@ -4,11 +4,9 @@ import { NodeType, tagMap, elementNode, - idNodeMap, - INode, BuildCache, } from './types'; -import { isElement } from './utils'; +import { isElement, Mirror } from './utils'; const tagMap: tagMap = { script: 'noscript', @@ -215,7 +213,10 @@ function buildNode( n.attributes.rr_dataURL ) { // backup original img srcset - node.setAttribute('rrweb-original-srcset', n.attributes.srcset as string); + node.setAttribute( + 'rrweb-original-srcset', + n.attributes.srcset as string, + ); } else { node.setAttribute(name, value); } @@ -307,16 +308,16 @@ export function buildNodeWithSN( n: serializedNodeWithId, options: { doc: Document; - map: idNodeMap; + mirror: Mirror; skipChild?: boolean; hackCss: boolean; - afterAppend?: (n: INode) => unknown; + afterAppend?: (n: Node) => unknown; cache: BuildCache; }, -): INode | null { +): Node | null { const { doc, - map, + mirror, skipChild = false, hackCss = true, afterAppend, @@ -328,7 +329,7 @@ export function buildNodeWithSN( } if (n.rootId) { console.assert( - ((map[n.rootId] as unknown) as Document) === doc, + ((mirror.getNode(n.rootId) as unknown) as Document) === doc, 'Target document should has the same root id.', ); } @@ -362,8 +363,7 @@ export function buildNodeWithSN( node = doc; } - (node as INode).__sn = n; - map[n.id] = node as INode; + mirror.add(node, n.id, n); if ( (n.type === NodeType.Document || n.type === NodeType.Element) && @@ -372,7 +372,7 @@ export function buildNodeWithSN( for (const childN of n.childNodes) { const childNode = buildNodeWithSN(childN, { doc, - map, + mirror, skipChild: false, hackCss, afterAppend, @@ -394,24 +394,24 @@ export function buildNodeWithSN( } } - return node as INode; + return node as Node; } -function visit(idNodeMap: idNodeMap, onVisit: (node: INode) => void) { - function walk(node: INode) { +function visit(mirror: Mirror, onVisit: (node: Node) => void) { + function walk(node: Node) { onVisit(node); } - for (const key in idNodeMap) { - if (idNodeMap[key]) { - walk(idNodeMap[key]); + for (const id of mirror.getIds()) { + if (mirror.has(id)) { + walk(mirror.getNode(id)!); } } } -function handleScroll(node: INode) { - const n = node.__sn; - if (n.type !== NodeType.Element) { +function handleScroll(node: Node, mirror: Mirror) { + const n = mirror.getMeta(node); + if (n?.type !== NodeType.Element) { return; } const el = (node as Node) as HTMLElement; @@ -433,29 +433,36 @@ function rebuild( n: serializedNodeWithId, options: { doc: Document; - onVisit?: (node: INode) => unknown; + onVisit?: (node: Node) => unknown; hackCss?: boolean; - afterAppend?: (n: INode) => unknown; + afterAppend?: (n: Node) => unknown; cache: BuildCache; + mirror: Mirror; }, -): [Node | null, idNodeMap] { - const { doc, onVisit, hackCss = true, afterAppend, cache } = options; - const idNodeMap: idNodeMap = {}; +): Node | null { + const { + doc, + onVisit, + hackCss = true, + afterAppend, + cache, + mirror = new Mirror(), + } = options; const node = buildNodeWithSN(n, { doc, - map: idNodeMap, + mirror, skipChild: false, hackCss, afterAppend, cache, }); - visit(idNodeMap, (visitedNode) => { + visit(mirror, (visitedNode) => { if (onVisit) { onVisit(visitedNode); } - handleScroll(visitedNode); + handleScroll(visitedNode, mirror); }); - return [node, idNodeMap]; + return node; } export default rebuild; diff --git a/packages/rrweb-snapshot/src/snapshot.ts b/packages/rrweb-snapshot/src/snapshot.ts index f0fe7b3032..78e1522064 100644 --- a/packages/rrweb-snapshot/src/snapshot.ts +++ b/packages/rrweb-snapshot/src/snapshot.ts @@ -3,9 +3,6 @@ import { serializedNodeWithId, NodeType, attributes, - INode, - idNodeMap, - nodeIdMap, MaskInputOptions, SlimDOMOptions, DataURLOptions, @@ -15,6 +12,7 @@ import { ICanvas, } from './types'; import { + Mirror, is2DCanvasBlank, isElement, isShadowRoot, @@ -378,7 +376,7 @@ function serializeNode( n: Node, options: { doc: Document; - nodeIdMap: nodeIdMap; + mirror: Mirror; blockClass: string | RegExp; blockSelector: string | null; maskTextClass: string | RegExp; @@ -395,7 +393,7 @@ function serializeNode( ): serializedNode | false { const { doc, - nodeIdMap, + mirror, blockClass, blockSelector, maskTextClass, @@ -411,8 +409,8 @@ function serializeNode( } = options; // Only record root id when document object is not the base document let rootId: number | undefined; - const docId = nodeIdMap.get(doc); - if (docId) { + if (mirror.getMeta(doc)) { + const docId = mirror.getId(doc); rootId = docId === 1 ? undefined : docId; } switch (n.nodeType) { @@ -784,11 +782,10 @@ function slimDOMExcluded( } export function serializeNodeWithId( - n: Node | INode, + n: Node, options: { doc: Document; - map: idNodeMap; // DEPRECATED - nodeIdMap: nodeIdMap; + mirror: Mirror; blockClass: string | RegExp; blockSelector: string | null; maskTextClass: string | RegExp; @@ -804,15 +801,17 @@ export function serializeNodeWithId( inlineImages?: boolean; recordCanvas?: boolean; preserveWhiteSpace?: boolean; - onSerialize?: (n: INode) => unknown; - onIframeLoad?: (iframeINode: INode, node: serializedNodeWithId) => unknown; + onSerialize?: (n: Node) => unknown; + onIframeLoad?: ( + iframeNode: HTMLIFrameElement, + node: serializedNodeWithId, + ) => unknown; iframeLoadTimeout?: number; }, ): serializedNodeWithId | null { const { doc, - map, - nodeIdMap, + mirror, blockClass, blockSelector, maskTextClass, @@ -834,7 +833,7 @@ export function serializeNodeWithId( let { preserveWhiteSpace = true } = options; const _serializedNode = serializeNode(n, { doc, - nodeIdMap, + mirror, blockClass, blockSelector, maskTextClass, @@ -854,9 +853,10 @@ export function serializeNodeWithId( return null; } - let id: number | undefined = nodeIdMap.get(n); - if (id) { + let id: number | undefined; + if (mirror.hasNode(n)) { // Reuse the previous id + id = mirror.getId(n); } else if ( slimDOMExcluded(_serializedNode, slimDOMOptions) || (!preserveWhiteSpace && @@ -868,18 +868,16 @@ export function serializeNodeWithId( } else { id = genId(); } - const serializedNode = Object.assign(_serializedNode, { id }); - (n as INode).__sn = serializedNode; if (id === IGNORED_NODE) { return null; // slimDOM } - nodeIdMap.set(n, id); - // TODO: map is deprecated, please clean me up - map[id] = n as INode; + mirror.add(n, id, _serializedNode); + + const serializedNode = Object.assign(_serializedNode, { id }); if (onSerialize) { - onSerialize(n as INode); + onSerialize(n); } let recordChild = !skipChild; if (serializedNode.type === NodeType.Element) { @@ -895,16 +893,15 @@ export function serializeNodeWithId( ) { if ( slimDOMOptions.headWhitespace && - _serializedNode.type === NodeType.Element && - _serializedNode.tagName === 'head' + serializedNode.type === NodeType.Element && + serializedNode.tagName === 'head' // would impede performance: || getComputedStyle(n)['white-space'] === 'normal' ) { preserveWhiteSpace = false; } const bypassOptions = { doc, - map, - nodeIdMap, + mirror, blockClass, blockSelector, maskTextClass, @@ -957,8 +954,7 @@ export function serializeNodeWithId( if (iframeDoc && onIframeLoad) { const serializedIframeNode = serializeNodeWithId(iframeDoc, { doc: iframeDoc, - map, - nodeIdMap, + mirror, blockClass, blockSelector, maskTextClass, @@ -980,7 +976,7 @@ export function serializeNodeWithId( }); if (serializedIframeNode) { - onIframeLoad(n as INode, serializedIframeNode); + onIframeLoad(n as HTMLIFrameElement, serializedIframeNode); } } }, @@ -994,6 +990,7 @@ export function serializeNodeWithId( function snapshot( n: Document, options?: { + mirror?: Mirror; blockClass?: string | RegExp; blockSelector?: string | null; maskTextClass?: string | RegExp; @@ -1007,13 +1004,14 @@ function snapshot( inlineImages?: boolean; recordCanvas?: boolean; preserveWhiteSpace?: boolean; - onSerialize?: (n: INode) => unknown; - onIframeLoad?: (iframeINode: INode, node: serializedNodeWithId) => unknown; + onSerialize?: (n: Node) => unknown; + onIframeLoad?: (iframeNode: Node, node: serializedNodeWithId) => unknown; iframeLoadTimeout?: number; keepIframeSrcFn?: KeepIframeSrcFn; }, -): [serializedNodeWithId | null, nodeIdMap] { +): serializedNodeWithId | null { const { + mirror = new Mirror(), blockClass = 'rr-block', blockSelector = null, maskTextClass = 'rr-mask', @@ -1032,8 +1030,6 @@ function snapshot( iframeLoadTimeout, keepIframeSrcFn = () => false, } = options || {}; - const idNodeMap: idNodeMap = {}; - const nodeIdMap: nodeIdMap = new WeakMap(); const maskInputOptions: MaskInputOptions = maskAllInputs === true ? { @@ -1077,32 +1073,28 @@ function snapshot( : slimDOM === false ? {} : slimDOM; - return [ - serializeNodeWithId(n, { - doc: n, - map: idNodeMap, // DEPRECATED - nodeIdMap, - blockClass, - blockSelector, - maskTextClass, - maskTextSelector, - skipChild: false, - inlineStylesheet, - maskInputOptions, - maskTextFn, - maskInputFn, - slimDOMOptions, - dataURLOptions, - inlineImages, - recordCanvas, - preserveWhiteSpace, - onSerialize, - onIframeLoad, - iframeLoadTimeout, - keepIframeSrcFn, - }), - nodeIdMap, - ]; + return serializeNodeWithId(n, { + doc: n, + mirror, + blockClass, + blockSelector, + maskTextClass, + maskTextSelector, + skipChild: false, + inlineStylesheet, + maskInputOptions, + maskTextFn, + maskInputFn, + slimDOMOptions, + dataURLOptions, + inlineImages, + recordCanvas, + preserveWhiteSpace, + onSerialize, + onIframeLoad, + iframeLoadTimeout, + keepIframeSrcFn, + }); } export function visitSnapshot( diff --git a/packages/rrweb-snapshot/src/types.ts b/packages/rrweb-snapshot/src/types.ts index 812c6d17c2..3ea1d9784f 100644 --- a/packages/rrweb-snapshot/src/types.ts +++ b/packages/rrweb-snapshot/src/types.ts @@ -67,20 +67,21 @@ export type tagMap = { [key: string]: string; }; +// @deprecated export interface INode extends Node { - __sn: serializedNodeWithId | serializedNode; + __sn: serializedNodeWithId; } export interface ICanvas extends HTMLCanvasElement { __context: string; } -export type idNodeMap = { - [key: number]: INode; -}; +export type idNodeMap = Map; export type nodeIdMap = WeakMap; +export type nodeMetaMap = WeakMap; + export type MaskInputOptions = Partial<{ color: boolean; date: boolean; diff --git a/packages/rrweb-snapshot/src/utils.ts b/packages/rrweb-snapshot/src/utils.ts index 7f84809929..315d5b8f1e 100644 --- a/packages/rrweb-snapshot/src/utils.ts +++ b/packages/rrweb-snapshot/src/utils.ts @@ -1,6 +1,13 @@ -import { INode, MaskInputFn, MaskInputOptions } from './types'; +import { + idNodeMap, + MaskInputFn, + MaskInputOptions, + nodeIdMap, + nodeMetaMap, + serializedNode, +} from './types'; -export function isElement(n: Node | INode): n is Element { +export function isElement(n: Node): n is Element { return n.nodeType === n.ELEMENT_NODE; } @@ -9,6 +16,64 @@ export function isShadowRoot(n: Node): n is ShadowRoot { return Boolean(host && host.shadowRoot && host.shadowRoot === n); } +export class Mirror { + private idNodeMap: idNodeMap = new Map(); + private nodeIdMap: nodeIdMap = new WeakMap(); + private nodeMetaMap: nodeMetaMap = new WeakMap(); + + getId(n: Node | undefined | null): number { + if (!n) return -1; + + const id = this.nodeIdMap.get(n); + + // if n is not a serialized Node, use -1 as its id. + return id ?? -1; + } + + getNode(id: number): Node | null { + return this.idNodeMap.get(id) || null; + } + + getIds(): IterableIterator { + return this.idNodeMap.keys(); + } + + getMeta(n: Node): serializedNode | null { + return this.nodeMetaMap.get(n) || null; + } + + // removes the node from idNodeMap + // doesn't remove the node from nodeMetaMap + removeNodeFromMap(n: Node) { + const id = this.getId(n); + this.idNodeMap.delete(id); + + if (n.childNodes) { + n.childNodes.forEach((childNode) => this.removeNodeFromMap(childNode)); + } + } + has(id: number): boolean { + return this.idNodeMap.has(id); + } + hasNode(node: Node): boolean { + return this.nodeIdMap.has(node); + } + add(n: Node, id: number, meta?: serializedNode) { + this.idNodeMap.set(id, n); + this.nodeIdMap.set(n, id); + if (meta) this.nodeMetaMap.set(n, meta); + } + reset() { + this.idNodeMap = new Map(); + this.nodeIdMap = new WeakMap(); + this.nodeMetaMap = new WeakMap(); + } +} + +export function createMirror(): Mirror { + return new Mirror(); +} + export function maskInputValue({ maskInputOptions, tagName, diff --git a/packages/rrweb-snapshot/tsconfig.json b/packages/rrweb-snapshot/tsconfig.json index 2b6d703290..78414e1412 100644 --- a/packages/rrweb-snapshot/tsconfig.json +++ b/packages/rrweb-snapshot/tsconfig.json @@ -6,6 +6,7 @@ "strictNullChecks": true, "removeComments": true, "preserveConstEnums": true, + "target": "es6", "rootDir": "src", "outDir": "build", "lib": ["es6", "dom"] diff --git a/packages/rrweb-snapshot/typings/rebuild.d.ts b/packages/rrweb-snapshot/typings/rebuild.d.ts index 64a564cfd7..894149e2bb 100644 --- a/packages/rrweb-snapshot/typings/rebuild.d.ts +++ b/packages/rrweb-snapshot/typings/rebuild.d.ts @@ -1,19 +1,21 @@ -import { serializedNodeWithId, idNodeMap, INode, BuildCache } from './types'; +import { serializedNodeWithId, BuildCache } from './types'; +import { Mirror } from './utils'; export declare function addHoverClass(cssText: string, cache: BuildCache): string; export declare function createCache(): BuildCache; export declare function buildNodeWithSN(n: serializedNodeWithId, options: { doc: Document; - map: idNodeMap; + mirror: Mirror; skipChild?: boolean; hackCss: boolean; - afterAppend?: (n: INode) => unknown; + afterAppend?: (n: Node) => unknown; cache: BuildCache; -}): INode | null; +}): Node | null; declare function rebuild(n: serializedNodeWithId, options: { doc: Document; - onVisit?: (node: INode) => unknown; + onVisit?: (node: Node) => unknown; hackCss?: boolean; - afterAppend?: (n: INode) => unknown; + afterAppend?: (n: Node) => unknown; cache: BuildCache; -}): [Node | null, idNodeMap]; + mirror: Mirror; +}): Node | null; export default rebuild; diff --git a/packages/rrweb-snapshot/typings/snapshot.d.ts b/packages/rrweb-snapshot/typings/snapshot.d.ts index 24c138f3df..1facce0f7f 100644 --- a/packages/rrweb-snapshot/typings/snapshot.d.ts +++ b/packages/rrweb-snapshot/typings/snapshot.d.ts @@ -1,14 +1,14 @@ -import { serializedNodeWithId, INode, idNodeMap, nodeIdMap, MaskInputOptions, SlimDOMOptions, DataURLOptions, MaskTextFn, MaskInputFn, KeepIframeSrcFn } from './types'; +import { serializedNodeWithId, MaskInputOptions, SlimDOMOptions, DataURLOptions, MaskTextFn, MaskInputFn, KeepIframeSrcFn } from './types'; +import { Mirror } from './utils'; export declare const IGNORED_NODE = -2; export declare function absoluteToStylesheet(cssText: string | null, href: string): string; export declare function absoluteToDoc(doc: Document, attributeValue: string): string; export declare function transformAttribute(doc: Document, tagName: string, name: string, value: string): string; export declare function _isBlockedElement(element: HTMLElement, blockClass: string | RegExp, blockSelector: string | null): boolean; export declare function needMaskingText(node: Node | null, maskTextClass: string | RegExp, maskTextSelector: string | null): boolean; -export declare function serializeNodeWithId(n: Node | INode, options: { +export declare function serializeNodeWithId(n: Node, options: { doc: Document; - map: idNodeMap; - nodeIdMap: nodeIdMap; + mirror: Mirror; blockClass: string | RegExp; blockSelector: string | null; maskTextClass: string | RegExp; @@ -24,11 +24,12 @@ export declare function serializeNodeWithId(n: Node | INode, options: { inlineImages?: boolean; recordCanvas?: boolean; preserveWhiteSpace?: boolean; - onSerialize?: (n: INode) => unknown; - onIframeLoad?: (iframeINode: INode, node: serializedNodeWithId) => unknown; + onSerialize?: (n: Node) => unknown; + onIframeLoad?: (iframeNode: HTMLIFrameElement, node: serializedNodeWithId) => unknown; iframeLoadTimeout?: number; }): serializedNodeWithId | null; declare function snapshot(n: Document, options?: { + mirror?: Mirror; blockClass?: string | RegExp; blockSelector?: string | null; maskTextClass?: string | RegExp; @@ -42,11 +43,11 @@ declare function snapshot(n: Document, options?: { inlineImages?: boolean; recordCanvas?: boolean; preserveWhiteSpace?: boolean; - onSerialize?: (n: INode) => unknown; - onIframeLoad?: (iframeINode: INode, node: serializedNodeWithId) => unknown; + onSerialize?: (n: Node) => unknown; + onIframeLoad?: (iframeNode: Node, node: serializedNodeWithId) => unknown; iframeLoadTimeout?: number; keepIframeSrcFn?: KeepIframeSrcFn; -}): [serializedNodeWithId | null, nodeIdMap]; +}): serializedNodeWithId | null; export declare function visitSnapshot(node: serializedNodeWithId, onVisit: (node: serializedNodeWithId) => unknown): void; export declare function cleanupSnapshot(): void; export default snapshot; diff --git a/packages/rrweb-snapshot/typings/types.d.ts b/packages/rrweb-snapshot/typings/types.d.ts index 086aab40ec..8fef47ae34 100644 --- a/packages/rrweb-snapshot/typings/types.d.ts +++ b/packages/rrweb-snapshot/typings/types.d.ts @@ -53,15 +53,14 @@ export declare type tagMap = { [key: string]: string; }; export interface INode extends Node { - __sn: serializedNodeWithId | serializedNode; + __sn: serializedNodeWithId; } export interface ICanvas extends HTMLCanvasElement { __context: string; } -export declare type idNodeMap = { - [key: number]: INode; -}; +export declare type idNodeMap = Map; export declare type nodeIdMap = WeakMap; +export declare type nodeMetaMap = WeakMap; export declare type MaskInputOptions = Partial<{ color: boolean; date: boolean; diff --git a/packages/rrweb-snapshot/typings/utils.d.ts b/packages/rrweb-snapshot/typings/utils.d.ts index 6572ab8279..d9dc6d61ca 100644 --- a/packages/rrweb-snapshot/typings/utils.d.ts +++ b/packages/rrweb-snapshot/typings/utils.d.ts @@ -1,6 +1,21 @@ -import { INode, MaskInputFn, MaskInputOptions } from './types'; -export declare function isElement(n: Node | INode): n is Element; +import { MaskInputFn, MaskInputOptions, serializedNode } from './types'; +export declare function isElement(n: Node): n is Element; export declare function isShadowRoot(n: Node): n is ShadowRoot; +export declare class Mirror { + private idNodeMap; + private nodeIdMap; + private nodeMetaMap; + getId(n: Node | undefined | null): number; + getNode(id: number): Node | null; + getIds(): IterableIterator; + getMeta(n: Node): serializedNode | null; + removeNodeFromMap(n: Node): void; + has(id: number): boolean; + hasNode(node: Node): boolean; + add(n: Node, id: number, meta?: serializedNode): void; + reset(): void; +} +export declare function createMirror(): Mirror; export declare function maskInputValue({ maskInputOptions, tagName, type, value, maskInputFn, }: { maskInputOptions: MaskInputOptions; tagName: string; diff --git a/packages/rrweb/src/record/iframe-manager.ts b/packages/rrweb/src/record/iframe-manager.ts index 1825c1786f..7c28758685 100644 --- a/packages/rrweb/src/record/iframe-manager.ts +++ b/packages/rrweb/src/record/iframe-manager.ts @@ -1,4 +1,4 @@ -import { serializedNodeWithId, INode } from 'rrweb-snapshot'; +import { Mirror, serializedNodeWithId } from 'rrweb-snapshot'; import { mutationCallBack } from '../types'; export class IframeManager { @@ -18,11 +18,15 @@ export class IframeManager { this.loadListener = cb; } - public attachIframe(iframeEl: INode, childSn: serializedNodeWithId) { + public attachIframe( + iframeEl: Node, + childSn: serializedNodeWithId, + mirror: Mirror, + ) { this.mutationCb({ adds: [ { - parentId: iframeEl.__sn.id, + parentId: mirror.getId(iframeEl), nextId: null, node: childSn, }, diff --git a/packages/rrweb/src/record/index.ts b/packages/rrweb/src/record/index.ts index 8c748a8214..4d53994f10 100644 --- a/packages/rrweb/src/record/index.ts +++ b/packages/rrweb/src/record/index.ts @@ -1,13 +1,17 @@ -import { snapshot, MaskInputOptions, SlimDOMOptions } from 'rrweb-snapshot'; +import { + snapshot, + MaskInputOptions, + SlimDOMOptions, + Mirror, +} from 'rrweb-snapshot'; import { initObservers, mutationBuffers } from './observer'; import { on, getWindowWidth, getWindowHeight, polyfill, - isIframeINode, hasShadowRoot, - createMirror, + isSerializedIframe, } from '../utils'; import { EventType, @@ -35,7 +39,7 @@ let wrappedEmit!: (e: eventWithTime, isCheckout?: boolean) => void; let takeFullSnapshot!: (isCheckout?: boolean) => void; -const mirror = createMirror(); +const mirror = new Mirror(); function record( options: recordOptions = {}, ): listenerHandler | undefined { @@ -252,7 +256,8 @@ function record( ); mutationBuffers.forEach((buf) => buf.lock()); // don't allow any mirror modifications during snapshotting - const [node, idNodeMap] = snapshot(document, { + const node = snapshot(document, { + mirror, blockClass, blockSelector, maskTextClass, @@ -264,15 +269,15 @@ function record( recordCanvas, inlineImages, onSerialize: (n) => { - if (isIframeINode(n)) { - iframeManager.addIframe(n); + if (isSerializedIframe(n, mirror)) { + iframeManager.addIframe(n as HTMLIFrameElement); } if (hasShadowRoot(n)) { shadowDomManager.addShadowRoot(n.shadowRoot, document); } }, onIframeLoad: (iframe, childSn) => { - iframeManager.attachIframe(iframe, childSn); + iframeManager.attachIframe(iframe, childSn, mirror); shadowDomManager.observeAttachShadow( (iframe as Node) as HTMLIFrameElement, ); @@ -284,7 +289,6 @@ function record( return console.warn('Failed to snapshot the document'); } - mirror.map = idNodeMap; wrappedEmit( wrapEvent({ type: EventType.FullSnapshot, diff --git a/packages/rrweb/src/record/mutation.ts b/packages/rrweb/src/record/mutation.ts index 1e4a8fbe07..7b8cd716ea 100644 --- a/packages/rrweb/src/record/mutation.ts +++ b/packages/rrweb/src/record/mutation.ts @@ -1,11 +1,11 @@ import { - INode, serializeNodeWithId, transformAttribute, IGNORED_NODE, isShadowRoot, needMaskingText, maskInputValue, + Mirror, } from 'rrweb-snapshot'; import { mutationRecord, @@ -13,7 +13,6 @@ import { attributeCursor, removedNodeMutation, addedNodeMutation, - Mirror, styleAttributeValue, observerParam, MutationBufferParam, @@ -23,8 +22,8 @@ import { isBlocked, isAncestorRemoved, isIgnored, - isIframeINode, hasShadowRoot, + isSerializedIframe, } from '../utils'; type DoubleLinkedListNode = { @@ -39,6 +38,7 @@ type NodeInLinkedList = Node & { function isNodeInLinkedList(n: Node | NodeInLinkedList): n is NodeInLinkedList { return '__ln' in n; } + class DoubleLinkedList { public length = 0; public head: DoubleLinkedListNode | null = null; @@ -117,9 +117,6 @@ class DoubleLinkedList { } const moveKey = (id: number, parentId: number) => `${id}@${parentId}`; -function isINode(n: Node | INode): n is INode { - return '__sn' in n; -} /** * controls behaviour of a MutationObserver @@ -255,7 +252,7 @@ export default class MutationBuffer { let nextId: number | null = IGNORED_NODE; // slimDOM: ignored while (nextId === IGNORED_NODE) { ns = ns && ns.nextSibling; - nextId = ns && this.mirror.getId((ns as unknown) as INode); + nextId = ns && this.mirror.getId(ns); } return nextId; }; @@ -277,15 +274,15 @@ export default class MutationBuffer { return; } const parentId = isShadowRoot(n.parentNode) - ? this.mirror.getId((shadowHost as unknown) as INode) - : this.mirror.getId((n.parentNode as Node) as INode); + ? this.mirror.getId(shadowHost) + : this.mirror.getId(n.parentNode); const nextId = getNextId(n); if (parentId === -1 || nextId === -1) { return addList.addNode(n); } let sn = serializeNodeWithId(n, { doc: this.doc, - map: this.mirror.map, + mirror: this.mirror, blockClass: this.blockClass, blockSelector: this.blockSelector, maskTextClass: this.maskTextClass, @@ -299,7 +296,7 @@ export default class MutationBuffer { recordCanvas: this.recordCanvas, inlineImages: this.inlineImages, onSerialize: (currentN) => { - if (isIframeINode(currentN)) { + if (isSerializedIframe(currentN, this.mirror)) { this.iframeManager.addIframe(currentN); } if (hasShadowRoot(n)) { @@ -307,10 +304,8 @@ export default class MutationBuffer { } }, onIframeLoad: (iframe, childSn) => { - this.iframeManager.attachIframe(iframe, childSn); - this.shadowDomManager.observeAttachShadow( - (iframe as Node) as HTMLIFrameElement, - ); + this.iframeManager.attachIframe(iframe, childSn, this.mirror); + this.shadowDomManager.observeAttachShadow(iframe); }, }); if (sn) { @@ -323,7 +318,7 @@ export default class MutationBuffer { }; while (this.mapRemoves.length) { - this.mirror.removeNodeFromMap(this.mapRemoves.shift() as INode); + this.mirror.removeNodeFromMap(this.mapRemoves.shift()!); } for (const n of this.movedSet) { @@ -353,9 +348,7 @@ export default class MutationBuffer { while (addList.length) { let node: DoubleLinkedListNode | null = null; if (candidate) { - const parentId = this.mirror.getId( - (candidate.value.parentNode as Node) as INode, - ); + const parentId = this.mirror.getId(candidate.value.parentNode); const nextId = getNextId(candidate.value); if (parentId !== -1 && nextId !== -1) { node = candidate; @@ -366,9 +359,7 @@ export default class MutationBuffer { const _node = addList.get(index)!; // ensure _node is defined before attempting to find value if (_node) { - const parentId = this.mirror.getId( - (_node.value.parentNode as Node) as INode, - ); + const parentId = this.mirror.getId(_node.value.parentNode); const nextId = getNextId(_node.value); if (parentId !== -1 && nextId !== -1) { node = _node; @@ -396,14 +387,14 @@ export default class MutationBuffer { const payload = { texts: this.texts .map((text) => ({ - id: this.mirror.getId(text.node as INode), + id: this.mirror.getId(text.node), value: text.value, })) // text mutation's id was not in the mirror map means the target node has been removed .filter((text) => this.mirror.has(text.id)), attributes: this.attributes .map((attribute) => ({ - id: this.mirror.getId(attribute.node as INode), + id: this.mirror.getId(attribute.node), attributes: attribute.attributes, })) // attribute mutation's id was not in the mirror map means the target node has been removed @@ -434,7 +425,7 @@ export default class MutationBuffer { }; private processMutation = (m: mutationRecord) => { - if (isIgnored(m.target)) { + if (isIgnored(m.target, this.mirror)) { return; } switch (m.type) { @@ -528,11 +519,14 @@ export default class MutationBuffer { case 'childList': { m.addedNodes.forEach((n) => this.genAdds(n, m.target)); m.removedNodes.forEach((n) => { - const nodeId = this.mirror.getId(n as INode); + const nodeId = this.mirror.getId(n); const parentId = isShadowRoot(m.target) - ? this.mirror.getId((m.target.host as unknown) as INode) - : this.mirror.getId(m.target as INode); - if (isBlocked(m.target, this.blockClass) || isIgnored(n)) { + ? this.mirror.getId(m.target.host) + : this.mirror.getId(m.target); + if ( + isBlocked(m.target, this.blockClass) || + isIgnored(n, this.mirror) + ) { return; } // removed node has not been serialized yet, just remove it from the Set @@ -547,7 +541,7 @@ export default class MutationBuffer { * newly added node will be serialized without child nodes. * TODO: verify this */ - } else if (isAncestorRemoved(m.target as INode, this.mirror)) { + } else if (isAncestorRemoved(m.target, this.mirror)) { /** * If parent id was not in the mirror map any more, it * means the parent node has already been removed. So @@ -575,22 +569,23 @@ export default class MutationBuffer { } }; - private genAdds = (n: Node | INode, target?: Node | INode) => { + private genAdds = (n: Node, target?: Node) => { // parent was blocked, so we can ignore this node if (target && isBlocked(target, this.blockClass)) { return; } - if (isINode(n)) { - if (isIgnored(n)) { + + if (this.mirror.getMeta(n)) { + if (isIgnored(n, this.mirror)) { return; } this.movedSet.add(n); let targetId: number | null = null; - if (target && isINode(target)) { - targetId = target.__sn.id; + if (target && this.mirror.getMeta(target)) { + targetId = this.mirror.getId(target); } - if (targetId) { - this.movedMap[moveKey(n.__sn.id, targetId)] = true; + if (targetId && targetId !== -1) { + this.movedMap[moveKey(this.mirror.getId(n), targetId)] = true; } } else { this.addedSet.add(n); @@ -600,7 +595,7 @@ export default class MutationBuffer { // if this node is blocked `serializeNode` will turn it into a placeholder element // but we have to remove it's children otherwise they will be added as placeholders too if (!isBlocked(n, this.blockClass)) - n.childNodes.forEach((childN) => this.genAdds(childN)); + (n as Node).childNodes.forEach((childN) => this.genAdds(childN)); }; } @@ -624,7 +619,7 @@ function isParentRemoved( if (!parentNode) { return false; } - const parentId = mirror.getId((parentNode as Node) as INode); + const parentId = mirror.getId(parentNode); if (removes.some((r) => r.id === parentId)) { return true; } diff --git a/packages/rrweb/src/record/observer.ts b/packages/rrweb/src/record/observer.ts index a735c93e3c..11c899763a 100644 --- a/packages/rrweb/src/record/observer.ts +++ b/packages/rrweb/src/record/observer.ts @@ -1,4 +1,4 @@ -import { INode, MaskInputOptions, maskInputValue } from 'rrweb-snapshot'; +import { MaskInputOptions, maskInputValue } from 'rrweb-snapshot'; import { FontFaceSet } from 'css-font-loading-module'; import { throttle, @@ -173,7 +173,7 @@ function initMoveObserver({ positions.push({ x: clientX, y: clientY, - id: mirror.getId(target as INode), + id: mirror.getId(target as Node), timeOffset: Date.now() - timeBaseline, }); // it is possible DragEvent is undefined even on devices @@ -228,7 +228,7 @@ function initMouseInteractionObserver({ if (!e) { return; } - const id = mirror.getId(target as INode); + const id = mirror.getId(target); const { clientX, clientY } = e; mouseInteractionCb({ type: MouseInteractions[eventKey], @@ -270,7 +270,7 @@ export function initScrollObserver({ if (!target || isBlocked(target as Node, blockClass)) { return; } - const id = mirror.getId(target as INode); + const id = mirror.getId(target as Node); if (target === doc) { const scrollEl = (doc.scrollingElement || doc.documentElement)!; scrollCb({ @@ -408,7 +408,7 @@ function initInputObserver({ lastInputValue.isChecked !== v.isChecked ) { lastInputValueMap.set(target, v); - const id = mirror.getId(target as INode); + const id = mirror.getId(target as Node); inputCb({ ...v, id, @@ -497,7 +497,7 @@ function initStyleSheetObserver( rule: string, index?: number, ) { - const id = mirror.getId(this.ownerNode as INode); + const id = mirror.getId(this.ownerNode); if (id !== -1) { styleSheetRuleCb({ id, @@ -509,7 +509,7 @@ function initStyleSheetObserver( const deleteRule = win.CSSStyleSheet.prototype.deleteRule; win.CSSStyleSheet.prototype.deleteRule = function (index: number) { - const id = mirror.getId(this.ownerNode as INode); + const id = mirror.getId(this.ownerNode); if (id !== -1) { styleSheetRuleCb({ id, @@ -554,7 +554,7 @@ function initStyleSheetObserver( }; type.prototype.insertRule = function (rule: string, index?: number) { - const id = mirror.getId(this.parentStyleSheet.ownerNode as INode); + const id = mirror.getId(this.parentStyleSheet.ownerNode); if (id !== -1) { styleSheetRuleCb({ id, @@ -573,7 +573,7 @@ function initStyleSheetObserver( }; type.prototype.deleteRule = function (index: number) { - const id = mirror.getId(this.parentStyleSheet.ownerNode as INode); + const id = mirror.getId(this.parentStyleSheet.ownerNode); if (id !== -1) { styleSheetRuleCb({ id, @@ -605,9 +605,7 @@ function initStyleDeclarationObserver( value: string, priority: string, ) { - const id = mirror.getId( - (this.parentRule?.parentStyleSheet?.ownerNode as unknown) as INode, - ); + const id = mirror.getId(this.parentRule?.parentStyleSheet?.ownerNode); if (id !== -1) { styleDeclarationCb({ id, @@ -627,9 +625,7 @@ function initStyleDeclarationObserver( this: CSSStyleDeclaration, property: string, ) { - const id = mirror.getId( - (this.parentRule?.parentStyleSheet?.ownerNode as unknown) as INode, - ); + const id = mirror.getId(this.parentRule?.parentStyleSheet?.ownerNode); if (id !== -1) { styleDeclarationCb({ id, @@ -663,7 +659,7 @@ function initMediaInteractionObserver({ const { currentTime, volume, muted } = target as HTMLMediaElement; mediaInteractionCb({ type, - id: mirror.getId(target as INode), + id: mirror.getId(target as Node), currentTime, volume, muted, diff --git a/packages/rrweb/src/record/observers/canvas/2d.ts b/packages/rrweb/src/record/observers/canvas/2d.ts index dd63469b1e..6e7049af82 100644 --- a/packages/rrweb/src/record/observers/canvas/2d.ts +++ b/packages/rrweb/src/record/observers/canvas/2d.ts @@ -1,11 +1,10 @@ -import { INode } from 'rrweb-snapshot'; +import { Mirror } from 'rrweb-snapshot'; import { blockClass, CanvasContext, canvasManagerMutationCallback, IWindow, listenerHandler, - Mirror, } from '../../../types'; import { hookSetter, isBlocked, patch } from '../../../utils'; @@ -36,7 +35,7 @@ export default function initCanvas2DMutationObserver( this: CanvasRenderingContext2D, ...args: Array ) { - if (!isBlocked((this.canvas as unknown) as INode, blockClass)) { + if (!isBlocked(this.canvas, blockClass)) { // Using setTimeout as getImageData + JSON.stringify can be heavy // and we'd rather not block the main thread setTimeout(() => { diff --git a/packages/rrweb/src/record/observers/canvas/canvas-manager.ts b/packages/rrweb/src/record/observers/canvas/canvas-manager.ts index a266e5fb73..007419b37d 100644 --- a/packages/rrweb/src/record/observers/canvas/canvas-manager.ts +++ b/packages/rrweb/src/record/observers/canvas/canvas-manager.ts @@ -1,4 +1,4 @@ -import { INode } from 'rrweb-snapshot'; +import { Mirror } from 'rrweb-snapshot'; import { blockClass, canvasManagerMutationCallback, @@ -7,7 +7,6 @@ import { canvasMutationWithType, IWindow, listenerHandler, - Mirror, } from '../../../types'; import initCanvas2DMutationObserver from './2d'; import initCanvasContextObserver from './canvas'; @@ -126,7 +125,7 @@ export class CanvasManager { flushPendingCanvasMutations() { this.pendingCanvasMutations.forEach( (values: canvasMutationCommand[], canvas: HTMLCanvasElement) => { - const id = this.mirror.getId((canvas as unknown) as INode); + const id = this.mirror.getId(canvas); this.flushPendingCanvasMutationFor(canvas, id); }, ); diff --git a/packages/rrweb/src/record/observers/canvas/canvas.ts b/packages/rrweb/src/record/observers/canvas/canvas.ts index 437af7d5f3..ff42c7a05d 100644 --- a/packages/rrweb/src/record/observers/canvas/canvas.ts +++ b/packages/rrweb/src/record/observers/canvas/canvas.ts @@ -1,4 +1,4 @@ -import { INode, ICanvas } from 'rrweb-snapshot'; +import { ICanvas } from 'rrweb-snapshot'; import { blockClass, IWindow, listenerHandler } from '../../../types'; import { isBlocked, patch } from '../../../utils'; @@ -17,7 +17,7 @@ export default function initCanvasContextObserver( contextType: string, ...args: Array ) { - if (!isBlocked((this as unknown) as INode, blockClass)) { + if (!isBlocked(this, blockClass)) { if (!('__context' in this)) (this as ICanvas).__context = contextType; } diff --git a/packages/rrweb/src/record/observers/canvas/webgl.ts b/packages/rrweb/src/record/observers/canvas/webgl.ts index 45b03928f3..1ebe2e19da 100644 --- a/packages/rrweb/src/record/observers/canvas/webgl.ts +++ b/packages/rrweb/src/record/observers/canvas/webgl.ts @@ -1,4 +1,4 @@ -import { INode } from 'rrweb-snapshot'; +import { Mirror } from 'rrweb-snapshot'; import { blockClass, CanvasContext, @@ -6,7 +6,6 @@ import { canvasMutationWithType, IWindow, listenerHandler, - Mirror, } from '../../../types'; import { hookSetter, isBlocked, patch } from '../../../utils'; import { saveWebGLVar, serializeArgs } from './serialize-args'; @@ -32,8 +31,8 @@ function patchGLPrototype( return function (this: typeof prototype, ...args: Array) { const result = original.apply(this, args); saveWebGLVar(result, win, prototype); - if (!isBlocked((this.canvas as unknown) as INode, blockClass)) { - const id = mirror.getId((this.canvas as unknown) as INode); + if (!isBlocked(this.canvas, blockClass)) { + const id = mirror.getId(this.canvas); const recordArgs = serializeArgs([...args], win, prototype); const mutation: canvasMutationWithType = { diff --git a/packages/rrweb/src/record/shadow-dom-manager.ts b/packages/rrweb/src/record/shadow-dom-manager.ts index e0ad26f33c..1bc50d0f2e 100644 --- a/packages/rrweb/src/record/shadow-dom-manager.ts +++ b/packages/rrweb/src/record/shadow-dom-manager.ts @@ -1,12 +1,12 @@ import { mutationCallBack, - Mirror, scrollCallback, MutationBufferParam, SamplingStrategy, } from '../types'; import { initMutationObserver, initScrollObserver } from './observer'; import { patch } from '../utils'; +import { Mirror } from 'rrweb-snapshot'; type BypassOptions = Omit< MutationBufferParam, diff --git a/packages/rrweb/src/replay/index.ts b/packages/rrweb/src/replay/index.ts index df567b3ae1..e7cd811bac 100644 --- a/packages/rrweb/src/replay/index.ts +++ b/packages/rrweb/src/replay/index.ts @@ -1,10 +1,11 @@ import { rebuild, buildNodeWithSN, - INode, NodeType, BuildCache, createCache, + Mirror, + createMirror, } from 'rrweb-snapshot'; import * as mittProxy from 'mitt'; import { polyfill as smoothscrollPolyfill } from './smoothscroll'; @@ -33,7 +34,6 @@ import { scrollData, inputData, canvasMutationData, - Mirror, ElementState, styleAttributeValue, styleValueWithPriority, @@ -43,15 +43,14 @@ import { textMutation, } from '../types'; import { - createMirror, polyfill, TreeIndex, queueToResolveTrees, iterateResolveTree, AppendedIframe, - isIframeINode, getBaseDimension, hasShadowRoot, + isSerializedIframe, } from '../utils'; import getInjectStyleRules from './styles/inject-style'; import './styles/style.css'; @@ -115,8 +114,8 @@ export class Replayer { private legacy_missingNodeRetryMap: missingNodeMap = {}; private treeIndex!: TreeIndex; - private fragmentParentMap!: Map; - private elementStateMap!: Map; + private fragmentParentMap!: Map; + private elementStateMap!: Map; // Hold the list of CSSRules for in-memory state restoration private virtualStyleRulesMap!: VirtualStyleRulesMap; @@ -167,8 +166,8 @@ export class Replayer { this.setupDom(); this.treeIndex = new TreeIndex(); - this.fragmentParentMap = new Map(); - this.elementStateMap = new Map(); + this.fragmentParentMap = new Map(); + this.elementStateMap = new Map(); this.virtualStyleRulesMap = new Map(); this.emitter.on(ReplayerEvents.Flush, () => { @@ -657,13 +656,14 @@ export class Replayer { } this.legacy_missingNodeRetryMap = {}; const collected: AppendedIframe[] = []; - this.mirror.map = rebuild(event.data.node, { + rebuild(event.data.node, { doc: this.iframe.contentDocument, afterAppend: (builtNode) => { this.collectIframeAndAttachDocument(collected, builtNode); }, cache: this.cache, - })[1]; + mirror: this.mirror, + }); for (const { mutationInQueue, builtNode } of collected) { this.attachDocumentToIframe(mutationInQueue, builtNode); this.newDocumentQueue = this.newDocumentQueue.filter( @@ -715,8 +715,8 @@ export class Replayer { let parent = iframeEl.parentNode; while (parent) { // The parent of iframeEl is virtual parent and we need to mount it on the dom. - if (this.fragmentParentMap.has((parent as unknown) as INode)) { - const frag = (parent as unknown) as INode; + if (this.fragmentParentMap.has(parent)) { + const frag = parent; const realParent = this.fragmentParentMap.get(frag)!; this.restoreRealParent(frag, realParent); break; @@ -726,14 +726,15 @@ export class Replayer { } buildNodeWithSN(mutation.node, { doc: iframeEl.contentDocument!, - map: this.mirror.map, + mirror: this.mirror, hackCss: true, skipChild: false, afterAppend: (builtNode) => { this.collectIframeAndAttachDocument(collected, builtNode); + const sn = this.mirror.getMeta(builtNode); if ( - builtNode.__sn.type === NodeType.Element && - builtNode.__sn.tagName.toUpperCase() === 'HTML' + sn?.type === NodeType.Element && + sn?.tagName.toUpperCase() === 'HTML' ) { const { documentElement, head } = iframeEl.contentDocument!; this.insertStyleRules(documentElement, head); @@ -751,11 +752,11 @@ export class Replayer { private collectIframeAndAttachDocument( collected: AppendedIframe[], - builtNode: INode, + builtNode: Node, ) { - if (isIframeINode(builtNode)) { + if (isSerializedIframe(builtNode, this.mirror)) { const mutationInQueue = this.newDocumentQueue.find( - (m) => m.parentId === builtNode.__sn.id, + (m) => m.parentId === this.mirror.getId(builtNode), ); if (mutationInQueue) { collected.push({ mutationInQueue, builtNode }); @@ -905,7 +906,7 @@ export class Replayer { d.adds.forEach((m) => this.treeIndex.add(m)); d.texts.forEach((m) => { const target = this.mirror.getNode(m.id); - const parent = (target?.parentNode as unknown) as INode | null; + const parent = (target?.parentNode as unknown) as Node | null; // remove any style rules that pending // for stylesheets where the contents get replaced if (parent && this.virtualStyleRulesMap.has(parent)) @@ -1117,7 +1118,7 @@ export class Replayer { } const styleEl = (target as Node) as HTMLStyleElement; - const parent = (target.parentNode as unknown) as INode; + const parent = target.parentNode!; const usingVirtualParent = this.fragmentParentMap.has(parent); /** @@ -1219,7 +1220,7 @@ export class Replayer { } const styleEl = (target as Node) as HTMLStyleElement; - const parent = (target.parentNode as unknown) as INode; + const parent = target.parentNode!; const usingVirtualParent = this.fragmentParentMap.has(parent); const styleSheet = usingVirtualParent ? null : styleEl.sheet; @@ -1318,7 +1319,7 @@ export class Replayer { if (this.virtualStyleRulesMap.has(target)) { this.virtualStyleRulesMap.delete(target); } - let parent: INode | null | ShadowRoot = this.mirror.getNode( + let parent: Node | null | ShadowRoot = this.mirror.getNode( mutation.parentId, ); if (!parent) { @@ -1331,8 +1332,9 @@ export class Replayer { this.mirror.removeNodeFromMap(target); if (parent) { let realTarget = null; - const realParent = - '__sn' in parent ? this.fragmentParentMap.get(parent) : undefined; + const realParent = this.mirror.getMeta(parent) + ? this.fragmentParentMap.get(parent) + : undefined; if (realParent && realParent.contains(target)) { parent = realParent; } else if (this.fragmentParentMap.has(target)) { @@ -1391,7 +1393,7 @@ export class Replayer { if (!this.iframe.contentDocument) { return console.warn('Looks like your replayer has been destroyed.'); } - let parent: INode | null | ShadowRoot = this.mirror.getNode( + let parent: Node | null | ShadowRoot = this.mirror.getNode( mutation.parentId, ); if (!parent) { @@ -1415,17 +1417,17 @@ export class Replayer { ((parent as unknown) as HTMLElement).getElementsByTagName?.('iframe') .length > 0; /** - * Why !isIframeINode(parent)? If parent element is an iframe, iframe document can't be appended to virtual parent. + * Why !isSerializedIframe(parent)? If parent element is an iframe, iframe document can't be appended to virtual parent. * Why !hasIframeChild? If we move iframe elements from dom to fragment document, we will lose the contentDocument of iframe. So we need to disable the virtual dom optimization if a parent node contains iframe elements. */ if ( useVirtualParent && parentInDocument && - !isIframeINode(parent) && + !isSerializedIframe(parent, this.mirror) && !hasIframeChild ) { - const virtualParent = (document.createDocumentFragment() as unknown) as INode; - this.mirror.map[mutation.parentId] = virtualParent; + const virtualParent = document.createDocumentFragment(); + this.mirror.add(virtualParent, mutation.parentId); this.fragmentParentMap.set(virtualParent, parent); // store the state, like scroll position, of child nodes before they are unmounted from dom @@ -1464,17 +1466,17 @@ export class Replayer { const targetDoc = mutation.node.rootId ? this.mirror.getNode(mutation.node.rootId) : this.iframe.contentDocument; - if (isIframeINode(parent)) { + if (isSerializedIframe(parent, this.mirror)) { this.attachDocumentToIframe(mutation, parent); return; } const target = buildNodeWithSN(mutation.node, { doc: targetDoc as Document, - map: this.mirror.map, + mirror: this.mirror, skipChild: true, hackCss: true, cache: this.cache, - }) as INode; + }) as Node; // legacy data, we should not have -1 siblings any more if (mutation.previousId === -1 || mutation.nextId === -1) { @@ -1485,10 +1487,11 @@ export class Replayer { return; } + const parentSn = this.mirror.getMeta(parent); if ( - '__sn' in parent && - parent.__sn.type === NodeType.Element && - parent.__sn.tagName === 'textarea' && + parentSn && + parentSn.type === NodeType.Element && + parentSn.tagName === 'textarea' && mutation.node.type === NodeType.Text ) { // https://github.com/rrweb-io/rrweb/issues/745 @@ -1521,9 +1524,10 @@ export class Replayer { parent.appendChild(target); } - if (isIframeINode(target)) { + if (isSerializedIframe(target, this.mirror)) { + const targetId = this.mirror.getId(target); const mutationInQueue = this.newDocumentQueue.find( - (m) => m.parentId === target.__sn.id, + (m) => m.parentId === targetId, ); if (mutationInQueue) { this.attachDocumentToIframe(mutationInQueue, target); @@ -1654,13 +1658,14 @@ export class Replayer { if (!target) { return this.debugNodeNotFound(d, d.id); } + const sn = this.mirror.getMeta(target); if ((target as Node) === this.iframe.contentDocument) { this.iframe.contentWindow!.scrollTo({ top: d.y, left: d.x, behavior: isSync ? 'auto' : 'smooth', }); - } else if (target.__sn.type === NodeType.Document) { + } else if (sn?.type === NodeType.Document) { // nest iframe content document ((target as unknown) as Document).defaultView!.scrollTo({ top: d.y, @@ -1835,15 +1840,18 @@ export class Replayer { * @param frag fragment document, the virtual parent * @param parent real parent element */ - private restoreRealParent(frag: INode, parent: INode) { - this.mirror.map[parent.__sn.id] = parent; + private restoreRealParent(frag: Node, parent: Node) { + const id = this.mirror.getId(frag); + const parentSn = this.mirror.getMeta(parent); + this.mirror.add(parent, id); + /** * If we have already set value attribute on textarea, * then we could not apply text content as default value any more. */ if ( - parent.__sn.type === NodeType.Element && - parent.__sn.tagName === 'textarea' && + parentSn?.type === NodeType.Element && + parentSn?.tagName === 'textarea' && frag.textContent ) { ((parent as unknown) as HTMLTextAreaElement).value = frag.textContent; @@ -1858,7 +1866,7 @@ export class Replayer { * the state should be restored in the handler of event ReplayerEvents.Flush * e.g. browser would lose scroll position after the process that we add children of parent node to Fragment Document as virtual dom */ - private storeState(parent: INode) { + private storeState(parent: Node) { if (parent) { if (parent.nodeType === parent.ELEMENT_NODE) { const parentElement = (parent as unknown) as HTMLElement; @@ -1875,7 +1883,7 @@ export class Replayer { ); const children = parentElement.children; for (const child of Array.from(children)) { - this.storeState((child as unknown) as INode); + this.storeState(child); } } } @@ -1885,7 +1893,7 @@ export class Replayer { * restore the state of elements recursively, which was stored before elements were unmounted from dom in virtual parent mode * this function corresponds to function storeState */ - private restoreState(parent: INode) { + private restoreState(parent: Node) { if (parent.nodeType === parent.ELEMENT_NODE) { const parentElement = (parent as unknown) as HTMLElement; if (this.elementStateMap.has(parent)) { @@ -1899,12 +1907,12 @@ export class Replayer { } const children = parentElement.children; for (const child of Array.from(children)) { - this.restoreState((child as unknown) as INode); + this.restoreState(child); } } } - private restoreNodeSheet(node: INode) { + private restoreNodeSheet(node: Node) { const storedRules = this.virtualStyleRulesMap.get(node); if (node.nodeName !== 'STYLE') { return; diff --git a/packages/rrweb/src/replay/virtual-styles.ts b/packages/rrweb/src/replay/virtual-styles.ts index f850df27c9..ca13344af2 100644 --- a/packages/rrweb/src/replay/virtual-styles.ts +++ b/packages/rrweb/src/replay/virtual-styles.ts @@ -1,5 +1,3 @@ -import { INode } from 'rrweb-snapshot'; - export enum StyleRuleType { Insert, Remove, @@ -37,7 +35,7 @@ type RemovePropertyRule = { export type VirtualStyleRules = Array< InsertRule | RemoveRule | SnapshotRule | SetPropertyRule | RemovePropertyRule >; -export type VirtualStyleRulesMap = Map; +export type VirtualStyleRulesMap = Map; export function getNestedRule( rules: CSSRuleList, @@ -173,7 +171,7 @@ export function storeCSSRules( const cssTexts = Array.from( (parentElement as HTMLStyleElement).sheet?.cssRules || [], ).map((rule) => rule.cssText); - virtualStyleRulesMap.set((parentElement as unknown) as INode, [ + virtualStyleRulesMap.set(parentElement, [ { type: StyleRuleType.Snapshot, cssTexts, diff --git a/packages/rrweb/src/types.ts b/packages/rrweb/src/types.ts index dea29982dc..acf02173aa 100644 --- a/packages/rrweb/src/types.ts +++ b/packages/rrweb/src/types.ts @@ -1,6 +1,6 @@ import { serializedNodeWithId, - idNodeMap, + Mirror, INode, MaskInputOptions, SlimDOMOptions, @@ -571,11 +571,13 @@ export type DocumentDimension = { absoluteScale: number; }; -export type Mirror = { - map: idNodeMap; - getId: (n: INode) => number; +export type DeprecatedMirror = { + map: { + [key: number]: INode; + }; + getId: (n: Node) => number; getNode: (id: number) => INode | null; - removeNodeFromMap: (n: INode) => void; + removeNodeFromMap: (n: Node) => void; has: (id: number) => boolean; reset: () => void; }; diff --git a/packages/rrweb/src/utils.ts b/packages/rrweb/src/utils.ts index 91376444ef..ebb212162b 100644 --- a/packages/rrweb/src/utils.ts +++ b/packages/rrweb/src/utils.ts @@ -1,5 +1,4 @@ import { - Mirror, throttleOptions, listenerHandler, hookResetter, @@ -14,14 +13,9 @@ import { inputData, DocumentDimension, IWindow, + DeprecatedMirror, } from './types'; -import { - INode, - IGNORED_NODE, - serializedNodeWithId, - NodeType, - isShadowRoot, -} from 'rrweb-snapshot'; +import { Mirror, IGNORED_NODE, isShadowRoot } from 'rrweb-snapshot'; export function on( type: string, @@ -33,38 +27,6 @@ export function on( return () => target.removeEventListener(type, fn, options); } -export function createMirror(): Mirror { - return { - map: {}, - getId(n) { - // if n is not a serialized INode, use -1 as its id. - if (!n || !n.__sn) { - return -1; - } - return n.__sn.id; - }, - getNode(id) { - return this.map[id] || null; - }, - // TODO: use a weakmap to get rid of manually memory management - removeNodeFromMap(n) { - const id = n.__sn && n.__sn.id; - delete this.map[id]; - if (n.childNodes) { - n.childNodes.forEach((child) => - this.removeNodeFromMap((child as Node) as INode), - ); - } - }, - has(id) { - return this.map.hasOwnProperty(id); - }, - reset() { - this.map = {}; - }, - }; -} - // https://github.com/rrweb-io/rrweb/pull/407 const DEPARTED_MIRROR_ACCESS_WARNING = 'Please stop import mirror directly. Instead of that,' + @@ -72,7 +34,7 @@ const DEPARTED_MIRROR_ACCESS_WARNING = 'now you can use replayer.getMirror() to access the mirror instance of a replayer,' + '\r\n' + 'or you can use record.mirror to access the mirror instance during recording.'; -export let _mirror: Mirror = { +export let _mirror: DeprecatedMirror = { map: {}, getId() { console.error(DEPARTED_MIRROR_ACCESS_WARNING); @@ -251,16 +213,13 @@ export function isBlocked(node: Node | null, blockClass: blockClass): boolean { return isBlocked(node.parentNode, blockClass); } -export function isIgnored(n: Node | INode): boolean { - if ('__sn' in n) { - return (n as INode).__sn.id === IGNORED_NODE; - } +export function isIgnored(n: Node, mirror: Mirror): boolean { // The main part of the slimDOM check happens in // rrweb-snapshot::serializeNodeWithId - return false; + return mirror.getId(n) === IGNORED_NODE; } -export function isAncestorRemoved(target: INode, mirror: Mirror): boolean { +export function isAncestorRemoved(target: Node, mirror: Mirror): boolean { if (isShadowRoot(target)) { return false; } @@ -278,7 +237,7 @@ export function isAncestorRemoved(target: INode, mirror: Mirror): boolean { if (!target.parentNode) { return true; } - return isAncestorRemoved((target.parentNode as unknown) as INode, mirror); + return isAncestorRemoved(target.parentNode, mirror); } export function isTouchEvent( @@ -325,6 +284,7 @@ export type TreeNode = { texts: textMutation[]; attributes: attributeMutation[]; }; + export class TreeIndex { public tree!: Record; @@ -363,12 +323,12 @@ export class TreeIndex { const treeNode = this.indexes.get(mutation.id); const deepRemoveFromMirror = (id: number) => { + if (id === -1) return; + this.removeIdSet.add(id); const node = mirror.getNode(id); node?.childNodes.forEach((childNode) => { - if ('__sn' in childNode) { - deepRemoveFromMirror(((childNode as unknown) as INode).__sn.id); - } + deepRemoveFromMirror(mirror.getId(childNode)); }); }; const deepRemoveFromTreeIndex = (node: TreeNode) => { @@ -576,24 +536,16 @@ export function iterateResolveTree( } } -type HTMLIFrameINode = HTMLIFrameElement & { - __sn: serializedNodeWithId; -}; export type AppendedIframe = { mutationInQueue: addedNodeMutation; - builtNode: HTMLIFrameINode; + builtNode: HTMLIFrameElement; }; -export function isIframeINode( - node: INode | ShadowRoot, -): node is HTMLIFrameINode { - if ('__sn' in node) { - return ( - node.__sn.type === NodeType.Element && node.__sn.tagName === 'iframe' - ); - } - // node can be document fragment when using the virtual parent feature - return false; +export function isSerializedIframe( + n: Node, + mirror: Mirror, +): n is HTMLIFrameElement { + return Boolean(n.nodeName === 'IFRAME' && mirror.getMeta(n)); } export function getBaseDimension( diff --git a/packages/rrweb/typings/record/iframe-manager.d.ts b/packages/rrweb/typings/record/iframe-manager.d.ts index 4300a7abce..d610401213 100644 --- a/packages/rrweb/typings/record/iframe-manager.d.ts +++ b/packages/rrweb/typings/record/iframe-manager.d.ts @@ -1,4 +1,4 @@ -import { serializedNodeWithId, INode } from 'rrweb-snapshot'; +import { Mirror, serializedNodeWithId } from 'rrweb-snapshot'; import { mutationCallBack } from '../types'; export declare class IframeManager { private iframes; @@ -9,5 +9,5 @@ export declare class IframeManager { }); addIframe(iframeEl: HTMLIFrameElement): void; addLoadListener(cb: (iframeEl: HTMLIFrameElement) => unknown): void; - attachIframe(iframeEl: INode, childSn: serializedNodeWithId): void; + attachIframe(iframeEl: Node, childSn: serializedNodeWithId, mirror: Mirror): void; } diff --git a/packages/rrweb/typings/record/index.d.ts b/packages/rrweb/typings/record/index.d.ts index c4f541c887..9e141a4ea6 100644 --- a/packages/rrweb/typings/record/index.d.ts +++ b/packages/rrweb/typings/record/index.d.ts @@ -1,9 +1,10 @@ +import { Mirror } from 'rrweb-snapshot'; import { eventWithTime, recordOptions, listenerHandler } from '../types'; declare function record(options?: recordOptions): listenerHandler | undefined; declare namespace record { var addCustomEvent: (tag: string, payload: T) => void; var freezePage: () => void; var takeFullSnapshot: (isCheckout?: boolean | undefined) => void; - var mirror: import("../types").Mirror; + var mirror: Mirror; } export default record; diff --git a/packages/rrweb/typings/record/observers/canvas/2d.d.ts b/packages/rrweb/typings/record/observers/canvas/2d.d.ts index cb7b7f2e94..fee00970e9 100644 --- a/packages/rrweb/typings/record/observers/canvas/2d.d.ts +++ b/packages/rrweb/typings/record/observers/canvas/2d.d.ts @@ -1,2 +1,3 @@ -import { blockClass, canvasManagerMutationCallback, IWindow, listenerHandler, Mirror } from '../../../types'; +import { Mirror } from 'rrweb-snapshot'; +import { blockClass, canvasManagerMutationCallback, IWindow, listenerHandler } from '../../../types'; export default function initCanvas2DMutationObserver(cb: canvasManagerMutationCallback, win: IWindow, blockClass: blockClass, mirror: Mirror): listenerHandler; diff --git a/packages/rrweb/typings/record/observers/canvas/canvas-manager.d.ts b/packages/rrweb/typings/record/observers/canvas/canvas-manager.d.ts index 2a3eaf3461..94a913e002 100644 --- a/packages/rrweb/typings/record/observers/canvas/canvas-manager.d.ts +++ b/packages/rrweb/typings/record/observers/canvas/canvas-manager.d.ts @@ -1,4 +1,5 @@ -import { blockClass, canvasMutationCallback, IWindow, Mirror } from '../../../types'; +import { Mirror } from 'rrweb-snapshot'; +import { blockClass, canvasMutationCallback, IWindow } from '../../../types'; export declare type RafStamps = { latestId: number; invokeId: number | null; diff --git a/packages/rrweb/typings/record/observers/canvas/webgl.d.ts b/packages/rrweb/typings/record/observers/canvas/webgl.d.ts index 0f446770a5..7f866cd8e0 100644 --- a/packages/rrweb/typings/record/observers/canvas/webgl.d.ts +++ b/packages/rrweb/typings/record/observers/canvas/webgl.d.ts @@ -1,2 +1,3 @@ -import { blockClass, canvasManagerMutationCallback, IWindow, listenerHandler, Mirror } from '../../../types'; +import { Mirror } from 'rrweb-snapshot'; +import { blockClass, canvasManagerMutationCallback, IWindow, listenerHandler } from '../../../types'; export default function initCanvasWebGLMutationObserver(cb: canvasManagerMutationCallback, win: IWindow, blockClass: blockClass, mirror: Mirror): listenerHandler; diff --git a/packages/rrweb/typings/record/shadow-dom-manager.d.ts b/packages/rrweb/typings/record/shadow-dom-manager.d.ts index aab8669e5b..7f973a93d8 100644 --- a/packages/rrweb/typings/record/shadow-dom-manager.d.ts +++ b/packages/rrweb/typings/record/shadow-dom-manager.d.ts @@ -1,4 +1,5 @@ -import { mutationCallBack, Mirror, scrollCallback, MutationBufferParam, SamplingStrategy } from '../types'; +import { mutationCallBack, scrollCallback, MutationBufferParam, SamplingStrategy } from '../types'; +import { Mirror } from 'rrweb-snapshot'; declare type BypassOptions = Omit & { sampling: SamplingStrategy; }; @@ -7,6 +8,7 @@ export declare class ShadowDomManager { private scrollCb; private bypassOptions; private mirror; + private restorePatches; constructor(options: { mutationCb: mutationCallBack; scrollCb: scrollCallback; @@ -14,5 +16,7 @@ export declare class ShadowDomManager { mirror: Mirror; }); addShadowRoot(shadowRoot: ShadowRoot, doc: Document): void; + observeAttachShadow(iframeElement: HTMLIFrameElement): void; + reset(): void; } export {}; diff --git a/packages/rrweb/typings/replay/index.d.ts b/packages/rrweb/typings/replay/index.d.ts index 05e89b38be..3f0cc85d62 100644 --- a/packages/rrweb/typings/replay/index.d.ts +++ b/packages/rrweb/typings/replay/index.d.ts @@ -1,6 +1,7 @@ +import { Mirror } from 'rrweb-snapshot'; import { Timer } from './timer'; import { createPlayerService, createSpeedService } from './machine'; -import { eventWithTime, playerConfig, playerMetaData, Handler, Mirror } from '../types'; +import { eventWithTime, playerConfig, playerMetaData, Handler } from '../types'; import './styles/style.css'; export declare class Replayer { wrapper: HTMLDivElement; @@ -59,6 +60,7 @@ export declare class Replayer { private applyMutation; private applyScroll; private applyInput; + private applyText; private legacy_resolveMissingNode; private moveAndHover; private drawMouseTail; diff --git a/packages/rrweb/typings/replay/virtual-styles.d.ts b/packages/rrweb/typings/replay/virtual-styles.d.ts index 11ebf52d1b..ad37eed73c 100644 --- a/packages/rrweb/typings/replay/virtual-styles.d.ts +++ b/packages/rrweb/typings/replay/virtual-styles.d.ts @@ -1,4 +1,3 @@ -import { INode } from 'rrweb-snapshot'; export declare enum StyleRuleType { Insert = 0, Remove = 1, @@ -32,7 +31,7 @@ declare type RemovePropertyRule = { property: string; }; export declare type VirtualStyleRules = Array; -export declare type VirtualStyleRulesMap = Map; +export declare type VirtualStyleRulesMap = Map; export declare function getNestedRule(rules: CSSRuleList, position: number[]): CSSGroupingRule; export declare function getPositionsAndIndex(nestedIndex: number[]): { positions: number[]; diff --git a/packages/rrweb/typings/types.d.ts b/packages/rrweb/typings/types.d.ts index 87c1a9c814..913853afcf 100644 --- a/packages/rrweb/typings/types.d.ts +++ b/packages/rrweb/typings/types.d.ts @@ -1,4 +1,4 @@ -import { serializedNodeWithId, idNodeMap, INode, MaskInputOptions, SlimDOMOptions, MaskInputFn, MaskTextFn } from 'rrweb-snapshot'; +import { serializedNodeWithId, Mirror, INode, MaskInputOptions, SlimDOMOptions, MaskInputFn, MaskTextFn } from 'rrweb-snapshot'; import { PackFn, UnpackFn } from './packer/base'; import { IframeManager } from './record/iframe-manager'; import { ShadowDomManager } from './record/shadow-dom-manager'; @@ -401,11 +401,13 @@ export declare type DocumentDimension = { relativeScale: number; absoluteScale: number; }; -export declare type Mirror = { - map: idNodeMap; - getId: (n: INode) => number; +export declare type DeprecatedMirror = { + map: { + [key: number]: INode; + }; + getId: (n: Node) => number; getNode: (id: number) => INode | null; - removeNodeFromMap: (n: INode) => void; + removeNodeFromMap: (n: Node) => void; has: (id: number) => boolean; reset: () => void; }; @@ -494,4 +496,5 @@ declare global { } } export declare type IWindow = Window & typeof globalThis; +export declare type Optional = Pick, K> & Omit; export {}; diff --git a/packages/rrweb/typings/utils.d.ts b/packages/rrweb/typings/utils.d.ts index bdd5d741d2..cbd9d30e9d 100644 --- a/packages/rrweb/typings/utils.d.ts +++ b/packages/rrweb/typings/utils.d.ts @@ -1,8 +1,7 @@ -import { Mirror, throttleOptions, listenerHandler, hookResetter, blockClass, addedNodeMutation, removedNodeMutation, textMutation, attributeMutation, mutationData, scrollData, inputData, DocumentDimension, IWindow } from './types'; -import { INode, serializedNodeWithId } from 'rrweb-snapshot'; +import { throttleOptions, listenerHandler, hookResetter, blockClass, addedNodeMutation, removedNodeMutation, textMutation, attributeMutation, mutationData, scrollData, inputData, DocumentDimension, IWindow, DeprecatedMirror } from './types'; +import { Mirror } from 'rrweb-snapshot'; export declare function on(type: string, fn: EventListenerOrEventListenerObject, target?: Document | IWindow): listenerHandler; -export declare function createMirror(): Mirror; -export declare let _mirror: Mirror; +export declare let _mirror: DeprecatedMirror; export declare function throttle(func: (arg: T) => void, wait: number, options?: throttleOptions): (arg: T) => void; export declare function hookSetter(target: T, key: string | number | symbol, d: PropertyDescriptor, isRevoked?: boolean, win?: Window & typeof globalThis): hookResetter; export declare function patch(source: { @@ -11,8 +10,8 @@ export declare function patch(source: { export declare function getWindowHeight(): number; export declare function getWindowWidth(): number; export declare function isBlocked(node: Node | null, blockClass: blockClass): boolean; -export declare function isIgnored(n: Node | INode): boolean; -export declare function isAncestorRemoved(target: INode, mirror: Mirror): boolean; +export declare function isIgnored(n: Node, mirror: Mirror): boolean; +export declare function isAncestorRemoved(target: Node, mirror: Mirror): boolean; export declare function isTouchEvent(event: MouseEvent | TouchEvent): event is TouchEvent; export declare function polyfill(win?: Window & typeof globalThis): void; export declare type TreeNode = { @@ -54,14 +53,10 @@ declare type ResolveTree = { }; export declare function queueToResolveTrees(queue: addedNodeMutation[]): ResolveTree[]; export declare function iterateResolveTree(tree: ResolveTree, cb: (mutation: addedNodeMutation) => unknown): void; -declare type HTMLIFrameINode = HTMLIFrameElement & { - __sn: serializedNodeWithId; -}; export declare type AppendedIframe = { mutationInQueue: addedNodeMutation; - builtNode: HTMLIFrameINode; + builtNode: HTMLIFrameElement; }; -export declare function isIframeINode(node: INode | ShadowRoot): node is HTMLIFrameINode; export declare function getBaseDimension(node: Node, rootIframe: Node): DocumentDimension; export declare function hasShadowRoot(n: T): n is T & { shadowRoot: ShadowRoot; From 4b30d1fc64aa6a42b28b27c32858247a26431d1b Mon Sep 17 00:00:00 2001 From: Justin Halsall Date: Tue, 5 Apr 2022 08:26:45 +0200 Subject: [PATCH 04/12] Update packages/rrweb-snapshot/src/rebuild.ts Co-authored-by: Yun Feng --- packages/rrweb-snapshot/src/rebuild.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/rrweb-snapshot/src/rebuild.ts b/packages/rrweb-snapshot/src/rebuild.ts index 491836f176..f6ded5b951 100644 --- a/packages/rrweb-snapshot/src/rebuild.ts +++ b/packages/rrweb-snapshot/src/rebuild.ts @@ -394,7 +394,7 @@ export function buildNodeWithSN( } } - return node as Node; + return node; } function visit(mirror: Mirror, onVisit: (node: Node) => void) { From fa5dfebede6183ad46d0477d8c785cefa8ba7230 Mon Sep 17 00:00:00 2001 From: Justin Halsall Date: Tue, 5 Apr 2022 16:03:56 +0200 Subject: [PATCH 05/12] Remove unnessisary `as Node` typecastings Fixes: https://github.com/rrweb-io/rrweb/pull/868#discussion_r842240758 --- packages/rrweb-snapshot/src/rebuild.ts | 2 +- packages/rrweb-snapshot/src/snapshot.ts | 5 +- packages/rrweb-snapshot/typings/snapshot.d.ts | 2 +- packages/rrweb/src/record/index.ts | 4 +- packages/rrweb/src/record/observer.ts | 2 +- packages/rrweb/src/replay/index.ts | 52 +++++++++---------- packages/rrweb/typings/utils.d.ts | 1 + 7 files changed, 35 insertions(+), 33 deletions(-) diff --git a/packages/rrweb-snapshot/src/rebuild.ts b/packages/rrweb-snapshot/src/rebuild.ts index f6ded5b951..5e6bf69911 100644 --- a/packages/rrweb-snapshot/src/rebuild.ts +++ b/packages/rrweb-snapshot/src/rebuild.ts @@ -414,7 +414,7 @@ function handleScroll(node: Node, mirror: Mirror) { if (n?.type !== NodeType.Element) { return; } - const el = (node as Node) as HTMLElement; + const el = node as HTMLElement; for (const name in n.attributes) { if (!(n.attributes.hasOwnProperty(name) && name.startsWith('rr_'))) { continue; diff --git a/packages/rrweb-snapshot/src/snapshot.ts b/packages/rrweb-snapshot/src/snapshot.ts index 78e1522064..538ebf75fb 100644 --- a/packages/rrweb-snapshot/src/snapshot.ts +++ b/packages/rrweb-snapshot/src/snapshot.ts @@ -1005,7 +1005,10 @@ function snapshot( recordCanvas?: boolean; preserveWhiteSpace?: boolean; onSerialize?: (n: Node) => unknown; - onIframeLoad?: (iframeNode: Node, node: serializedNodeWithId) => unknown; + onIframeLoad?: ( + iframeNode: HTMLIFrameElement, + node: serializedNodeWithId, + ) => unknown; iframeLoadTimeout?: number; keepIframeSrcFn?: KeepIframeSrcFn; }, diff --git a/packages/rrweb-snapshot/typings/snapshot.d.ts b/packages/rrweb-snapshot/typings/snapshot.d.ts index 1facce0f7f..0cdca439df 100644 --- a/packages/rrweb-snapshot/typings/snapshot.d.ts +++ b/packages/rrweb-snapshot/typings/snapshot.d.ts @@ -44,7 +44,7 @@ declare function snapshot(n: Document, options?: { recordCanvas?: boolean; preserveWhiteSpace?: boolean; onSerialize?: (n: Node) => unknown; - onIframeLoad?: (iframeNode: Node, node: serializedNodeWithId) => unknown; + onIframeLoad?: (iframeNode: HTMLIFrameElement, node: serializedNodeWithId) => unknown; iframeLoadTimeout?: number; keepIframeSrcFn?: KeepIframeSrcFn; }): serializedNodeWithId | null; diff --git a/packages/rrweb/src/record/index.ts b/packages/rrweb/src/record/index.ts index 4d53994f10..23e0c2d4a8 100644 --- a/packages/rrweb/src/record/index.ts +++ b/packages/rrweb/src/record/index.ts @@ -278,9 +278,7 @@ function record( }, onIframeLoad: (iframe, childSn) => { iframeManager.attachIframe(iframe, childSn, mirror); - shadowDomManager.observeAttachShadow( - (iframe as Node) as HTMLIFrameElement, - ); + shadowDomManager.observeAttachShadow(iframe); }, keepIframeSrcFn, }); diff --git a/packages/rrweb/src/record/observer.ts b/packages/rrweb/src/record/observer.ts index 11c899763a..5504377853 100644 --- a/packages/rrweb/src/record/observer.ts +++ b/packages/rrweb/src/record/observer.ts @@ -221,7 +221,7 @@ function initMouseInteractionObserver({ const getHandler = (eventKey: keyof typeof MouseInteractions) => { return (event: MouseEvent | TouchEvent) => { const target = getEventTarget(event) as Node; - if (isBlocked(target as Node, blockClass)) { + if (isBlocked(target, blockClass)) { return; } const e = isTouchEvent(event) ? event.changedTouches[0] : event; diff --git a/packages/rrweb/src/replay/index.ts b/packages/rrweb/src/replay/index.ts index e7cd811bac..73336a770c 100644 --- a/packages/rrweb/src/replay/index.ts +++ b/packages/rrweb/src/replay/index.ts @@ -906,7 +906,7 @@ export class Replayer { d.adds.forEach((m) => this.treeIndex.add(m)); d.texts.forEach((m) => { const target = this.mirror.getNode(m.id); - const parent = (target?.parentNode as unknown) as Node | null; + const parent = target?.parentNode; // remove any style rules that pending // for stylesheets where the contents get replaced if (parent && this.virtualStyleRulesMap.has(parent)) @@ -974,13 +974,13 @@ export class Replayer { const { triggerFocus } = this.config; switch (d.type) { case MouseInteractions.Blur: - if ('blur' in ((target as Node) as HTMLElement)) { - ((target as Node) as HTMLElement).blur(); + if ('blur' in (target as HTMLElement)) { + (target as HTMLElement).blur(); } break; case MouseInteractions.Focus: - if (triggerFocus && ((target as Node) as HTMLElement).focus) { - ((target as Node) as HTMLElement).focus({ + if (triggerFocus && (target as HTMLElement).focus) { + (target as HTMLElement).focus({ preventScroll: true, }); } @@ -1081,7 +1081,7 @@ export class Replayer { if (!target) { return this.debugNodeNotFound(d, d.id); } - const mediaEl = (target as Node) as HTMLMediaElement; + const mediaEl = target as HTMLMediaElement; try { if (d.currentTime) { mediaEl.currentTime = d.currentTime; @@ -1117,7 +1117,7 @@ export class Replayer { return this.debugNodeNotFound(d, d.id); } - const styleEl = (target as Node) as HTMLStyleElement; + const styleEl = target as HTMLStyleElement; const parent = target.parentNode!; const usingVirtualParent = this.fragmentParentMap.has(parent); @@ -1219,7 +1219,7 @@ export class Replayer { return this.debugNodeNotFound(d, d.id); } - const styleEl = (target as Node) as HTMLStyleElement; + const styleEl = target as HTMLStyleElement; const parent = target.parentNode!; const usingVirtualParent = this.fragmentParentMap.has(parent); @@ -1375,7 +1375,7 @@ export class Replayer { const nextNotInDOM = (mutation: addedNodeMutation) => { let next: Node | null = null; if (mutation.nextId) { - next = this.mirror.getNode(mutation.nextId) as Node; + next = this.mirror.getNode(mutation.nextId); } // next not present at this moment if ( @@ -1442,18 +1442,18 @@ export class Replayer { if (mutation.node.isShadow) { // If the parent is attached a shadow dom after it's created, it won't have a shadow root. if (!hasShadowRoot(parent)) { - ((parent as Node) as HTMLElement).attachShadow({ mode: 'open' }); - parent = ((parent as Node) as HTMLElement).shadowRoot!; + (parent as HTMLElement).attachShadow({ mode: 'open' }); + parent = (parent as HTMLElement).shadowRoot!; } else parent = parent.shadowRoot; } let previous: Node | null = null; let next: Node | null = null; if (mutation.previousId) { - previous = this.mirror.getNode(mutation.previousId) as Node; + previous = this.mirror.getNode(mutation.previousId); } if (mutation.nextId) { - next = this.mirror.getNode(mutation.nextId) as Node; + next = this.mirror.getNode(mutation.nextId); } if (nextNotInDOM(mutation)) { return queue.push(mutation); @@ -1476,7 +1476,7 @@ export class Replayer { skipChild: true, hackCss: true, cache: this.cache, - }) as Node; + })!; // legacy data, we should not have -1 siblings any more if (mutation.previousId === -1 || mutation.nextId === -1) { @@ -1615,10 +1615,10 @@ export class Replayer { if (typeof attributeName === 'string') { const value = mutation.attributes[attributeName]; if (value === null) { - ((target as Node) as Element).removeAttribute(attributeName); + (target as Element).removeAttribute(attributeName); } else if (typeof value === 'string') { try { - ((target as Node) as Element).setAttribute(attributeName, value); + (target as Element).setAttribute(attributeName, value); } catch (error) { if (this.config.showWarning) { console.warn( @@ -1629,7 +1629,7 @@ export class Replayer { } } else if (attributeName === 'style') { let styleValues = value as styleAttributeValue; - const targetEl = (target as Node) as HTMLElement; + const targetEl = target as HTMLElement; for (var s in styleValues) { if (styleValues[s] === false) { targetEl.style.removeProperty(s); @@ -1659,7 +1659,7 @@ export class Replayer { return this.debugNodeNotFound(d, d.id); } const sn = this.mirror.getMeta(target); - if ((target as Node) === this.iframe.contentDocument) { + if (target === this.iframe.contentDocument) { this.iframe.contentWindow!.scrollTo({ top: d.y, left: d.x, @@ -1674,8 +1674,8 @@ export class Replayer { }); } else { try { - ((target as Node) as Element).scrollTop = d.y; - ((target as Node) as Element).scrollLeft = d.x; + (target as Element).scrollTop = d.y; + (target as Element).scrollLeft = d.x; } catch (error) { /** * Seldomly we may found scroll target was removed before @@ -1691,8 +1691,8 @@ export class Replayer { return this.debugNodeNotFound(d, d.id); } try { - ((target as Node) as HTMLInputElement).checked = d.isChecked; - ((target as Node) as HTMLInputElement).value = d.text; + (target as HTMLInputElement).checked = d.isChecked; + (target as HTMLInputElement).value = d.text; } catch (error) { // for safe } @@ -1704,7 +1704,7 @@ export class Replayer { return this.debugNodeNotFound(mutation, d.id); } try { - ((target as Node) as HTMLElement).textContent = d.value; + (target as HTMLElement).textContent = d.value; } catch (error) { // for safe } @@ -1725,7 +1725,7 @@ export class Replayer { delete map[mutation.node.id]; delete this.legacy_missingNodeRetryMap[mutation.node.id]; if (mutation.previousId || mutation.nextId) { - this.legacy_resolveMissingNode(map, parent, node as Node, mutation); + this.legacy_resolveMissingNode(map, parent, node, mutation); } } if (nextInMap) { @@ -1734,7 +1734,7 @@ export class Replayer { delete map[mutation.node.id]; delete this.legacy_missingNodeRetryMap[mutation.node.id]; if (mutation.previousId || mutation.nextId) { - this.legacy_resolveMissingNode(map, parent, node as Node, mutation); + this.legacy_resolveMissingNode(map, parent, node, mutation); } } } @@ -1760,7 +1760,7 @@ export class Replayer { if (!isSync) { this.drawMouseTail({ x: _x, y: _y }); } - this.hoverElements((target as Node) as Element); + this.hoverElements(target as Element); } private drawMouseTail(position: { x: number; y: number }) { diff --git a/packages/rrweb/typings/utils.d.ts b/packages/rrweb/typings/utils.d.ts index cbd9d30e9d..5e5a26478b 100644 --- a/packages/rrweb/typings/utils.d.ts +++ b/packages/rrweb/typings/utils.d.ts @@ -57,6 +57,7 @@ export declare type AppendedIframe = { mutationInQueue: addedNodeMutation; builtNode: HTMLIFrameElement; }; +export declare function isSerializedIframe(n: Node, mirror: Mirror): n is HTMLIFrameElement; export declare function getBaseDimension(node: Node, rootIframe: Node): DocumentDimension; export declare function hasShadowRoot(n: T): n is T & { shadowRoot: ShadowRoot; From 5d357c584cb31e5ec5c3a94cd325f2867d8a19b6 Mon Sep 17 00:00:00 2001 From: Justin Halsall Date: Tue, 5 Apr 2022 16:09:57 +0200 Subject: [PATCH 06/12] Remove unnessisary `as unknown as ...` --- packages/rrweb/src/record/iframe-manager.ts | 4 ++-- packages/rrweb/src/replay/canvas/2d.ts | 2 +- packages/rrweb/src/replay/index.ts | 13 ++++++------- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/packages/rrweb/src/record/iframe-manager.ts b/packages/rrweb/src/record/iframe-manager.ts index 7c28758685..e4331788d4 100644 --- a/packages/rrweb/src/record/iframe-manager.ts +++ b/packages/rrweb/src/record/iframe-manager.ts @@ -19,7 +19,7 @@ export class IframeManager { } public attachIframe( - iframeEl: Node, + iframeEl: HTMLIFrameElement, childSn: serializedNodeWithId, mirror: Mirror, ) { @@ -36,6 +36,6 @@ export class IframeManager { attributes: [], isAttachIframe: true, }); - this.loadListener?.((iframeEl as unknown) as HTMLIFrameElement); + this.loadListener?.(iframeEl); } } diff --git a/packages/rrweb/src/replay/canvas/2d.ts b/packages/rrweb/src/replay/canvas/2d.ts index b9fde639b3..feeb94d8c2 100644 --- a/packages/rrweb/src/replay/canvas/2d.ts +++ b/packages/rrweb/src/replay/canvas/2d.ts @@ -15,7 +15,7 @@ export default function canvasMutation({ errorHandler: Replayer['warnCanvasMutationFailed']; }): void { try { - const ctx = ((target as unknown) as HTMLCanvasElement).getContext('2d')!; + const ctx = target.getContext('2d')!; if (mutation.setter) { // skip some read-only type checks diff --git a/packages/rrweb/src/replay/index.ts b/packages/rrweb/src/replay/index.ts index 73336a770c..70fa6cb664 100644 --- a/packages/rrweb/src/replay/index.ts +++ b/packages/rrweb/src/replay/index.ts @@ -1280,7 +1280,7 @@ export class Replayer { canvasMutation({ event: e, mutation: d, - target: (target as unknown) as HTMLCanvasElement, + target: target as HTMLCanvasElement, imageMap: this.imageMap, errorHandler: this.warnCanvasMutationFailed.bind(this), }); @@ -1414,8 +1414,7 @@ export class Replayer { } const hasIframeChild = - ((parent as unknown) as HTMLElement).getElementsByTagName?.('iframe') - .length > 0; + (parent as HTMLElement).getElementsByTagName?.('iframe').length > 0; /** * Why !isSerializedIframe(parent)? If parent element is an iframe, iframe document can't be appended to virtual parent. * Why !hasIframeChild? If we move iframe elements from dom to fragment document, we will lose the contentDocument of iframe. So we need to disable the virtual dom optimization if a parent node contains iframe elements. @@ -1854,7 +1853,7 @@ export class Replayer { parentSn?.tagName === 'textarea' && frag.textContent ) { - ((parent as unknown) as HTMLTextAreaElement).value = frag.textContent; + (parent as HTMLTextAreaElement).value = frag.textContent; } parent.appendChild(frag); // restore state of elements after they are mounted @@ -1869,7 +1868,7 @@ export class Replayer { private storeState(parent: Node) { if (parent) { if (parent.nodeType === parent.ELEMENT_NODE) { - const parentElement = (parent as unknown) as HTMLElement; + const parentElement = parent as HTMLElement; if (parentElement.scrollLeft || parentElement.scrollTop) { // store scroll position state this.elementStateMap.set(parent, { @@ -1895,7 +1894,7 @@ export class Replayer { */ private restoreState(parent: Node) { if (parent.nodeType === parent.ELEMENT_NODE) { - const parentElement = (parent as unknown) as HTMLElement; + const parentElement = parent as HTMLElement; if (this.elementStateMap.has(parent)) { const storedState = this.elementStateMap.get(parent)!; // restore scroll position @@ -1922,7 +1921,7 @@ export class Replayer { return; } - const styleNode = (node as unknown) as HTMLStyleElement; + const styleNode = node as HTMLStyleElement; applyVirtualStyleRulesToNode(storedRules, styleNode); } From f1a62c5d9f35d5d8e862bf6d8987f6cc1c2b8e4e Mon Sep 17 00:00:00 2001 From: Justin Halsall Date: Tue, 5 Apr 2022 16:12:45 +0200 Subject: [PATCH 07/12] Remove unnessisary `as unknown as ...` --- packages/rrweb-snapshot/src/css.ts | 2 +- packages/rrweb-snapshot/src/rebuild.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/rrweb-snapshot/src/css.ts b/packages/rrweb-snapshot/src/css.ts index 950ec0b4c1..c0b4001fd4 100644 --- a/packages/rrweb-snapshot/src/css.ts +++ b/packages/rrweb-snapshot/src/css.ts @@ -892,7 +892,7 @@ function addParent(obj: Stylesheet, parent?: Stylesheet) { addParent(v, childParent); }); } else if (value && typeof value === 'object') { - addParent((value as unknown) as Stylesheet, childParent); + addParent(value as Stylesheet, childParent); } } diff --git a/packages/rrweb-snapshot/src/rebuild.ts b/packages/rrweb-snapshot/src/rebuild.ts index 5e6bf69911..d86cfca49a 100644 --- a/packages/rrweb-snapshot/src/rebuild.ts +++ b/packages/rrweb-snapshot/src/rebuild.ts @@ -329,7 +329,7 @@ export function buildNodeWithSN( } if (n.rootId) { console.assert( - ((mirror.getNode(n.rootId) as unknown) as Document) === doc, + (mirror.getNode(n.rootId) as Document) === doc, 'Target document should has the same root id.', ); } From 8ce3861e2a9117c315f306a7a4e2e50ef84f39cb Mon Sep 17 00:00:00 2001 From: Justin Halsall Date: Tue, 5 Apr 2022 16:29:53 +0200 Subject: [PATCH 08/12] Reset mirror when recording starts Solves: https://github.com/rrweb-io/rrweb/pull/868#discussion_r842249599 --- packages/rrweb/src/record/index.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/rrweb/src/record/index.ts b/packages/rrweb/src/record/index.ts index 23e0c2d4a8..3b6d910ceb 100644 --- a/packages/rrweb/src/record/index.ts +++ b/packages/rrweb/src/record/index.ts @@ -2,7 +2,7 @@ import { snapshot, MaskInputOptions, SlimDOMOptions, - Mirror, + createMirror, } from 'rrweb-snapshot'; import { initObservers, mutationBuffers } from './observer'; import { @@ -39,7 +39,7 @@ let wrappedEmit!: (e: eventWithTime, isCheckout?: boolean) => void; let takeFullSnapshot!: (isCheckout?: boolean) => void; -const mirror = new Mirror(); +const mirror = createMirror(); function record( options: recordOptions = {}, ): listenerHandler | undefined { @@ -78,6 +78,9 @@ function record( sampling.mousemove = mousemoveWait; } + // reset mirror in case `record` this was called earlier + mirror.reset(); + const maskInputOptions: MaskInputOptions = maskAllInputs === true ? { From 4e0c85c14a74c56c40e0bf7e7c299b290f5d77e6 Mon Sep 17 00:00:00 2001 From: Justin Halsall Date: Tue, 5 Apr 2022 17:38:36 +0200 Subject: [PATCH 09/12] API has changed for snapshot, change test to reflect that --- packages/rrweb-snapshot/test/integration.test.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/rrweb-snapshot/test/integration.test.ts b/packages/rrweb-snapshot/test/integration.test.ts index 0d5af440b6..0d70c4faad 100644 --- a/packages/rrweb-snapshot/test/integration.test.ts +++ b/packages/rrweb-snapshot/test/integration.test.ts @@ -131,7 +131,7 @@ describe('integration tests', function (this: ISuite) { const rebuildHtml = ( await page.evaluate(`${code} const x = new XMLSerializer(); - const [snap] = rrweb.snapshot(document); + const snap = rrweb.snapshot(document); let out = x.serializeToString(rrweb.rebuild(snap, { doc: document })[0]); if (document.querySelector('html').getAttribute('xmlns') !== 'http://www.w3.org/1999/xhtml') { // this is just an artefact of serializeToString @@ -168,7 +168,7 @@ describe('integration tests', function (this: ISuite) { `pre-check: images will be rendered ~326px high in BackCompat mode, and ~588px in CSS1Compat mode; getting: ${renderedHeight}px`, ); const rebuildRenderedHeight = await page.evaluate(`${code} -const [snap] = rrweb.snapshot(document); +const snap = rrweb.snapshot(document); const iframe = document.createElement('iframe'); iframe.setAttribute('width', document.body.clientWidth) iframe.setAttribute('height', document.body.clientHeight) @@ -205,9 +205,7 @@ iframe.contentDocument.querySelector('center').clientHeight inlineStylesheet: false })`); await page.waitFor(100); - const snapshot = await page.evaluate( - 'JSON.stringify(snapshot[0], null, 2);', - ); + const snapshot = await page.evaluate('JSON.stringify(snapshot, null, 2);'); assert(snapshot.includes('"rr_dataURL"')); assert(snapshot.includes('data:image/webp;base64,')); }); @@ -253,7 +251,7 @@ describe('iframe integration tests', function (this: ISuite) { }); const snapshotResult = JSON.stringify( await page.evaluate(`${code}; - rrweb.snapshot(document)[0]; + rrweb.snapshot(document); `), null, 2, @@ -302,7 +300,7 @@ describe('shadow DOM integration tests', function (this: ISuite) { }); const snapshotResult = JSON.stringify( await page.evaluate(`${code}; - rrweb.snapshot(document)[0]; + rrweb.snapshot(document); `), null, 2, From 07de96d586a75cc915ff3d187282ebac7ae77e4f Mon Sep 17 00:00:00 2001 From: Justin Halsall Date: Tue, 5 Apr 2022 17:39:33 +0200 Subject: [PATCH 10/12] Allow for es5 compatibility --- packages/rrweb-snapshot/src/utils.ts | 4 ++-- packages/rrweb-snapshot/tsconfig.json | 1 - packages/rrweb-snapshot/typings/utils.d.ts | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/rrweb-snapshot/src/utils.ts b/packages/rrweb-snapshot/src/utils.ts index 315d5b8f1e..74d0f750a7 100644 --- a/packages/rrweb-snapshot/src/utils.ts +++ b/packages/rrweb-snapshot/src/utils.ts @@ -34,8 +34,8 @@ export class Mirror { return this.idNodeMap.get(id) || null; } - getIds(): IterableIterator { - return this.idNodeMap.keys(); + getIds(): number[] { + return Array.from(this.idNodeMap.keys()); } getMeta(n: Node): serializedNode | null { diff --git a/packages/rrweb-snapshot/tsconfig.json b/packages/rrweb-snapshot/tsconfig.json index 78414e1412..2b6d703290 100644 --- a/packages/rrweb-snapshot/tsconfig.json +++ b/packages/rrweb-snapshot/tsconfig.json @@ -6,7 +6,6 @@ "strictNullChecks": true, "removeComments": true, "preserveConstEnums": true, - "target": "es6", "rootDir": "src", "outDir": "build", "lib": ["es6", "dom"] diff --git a/packages/rrweb-snapshot/typings/utils.d.ts b/packages/rrweb-snapshot/typings/utils.d.ts index d9dc6d61ca..2cc7e6a05d 100644 --- a/packages/rrweb-snapshot/typings/utils.d.ts +++ b/packages/rrweb-snapshot/typings/utils.d.ts @@ -7,7 +7,7 @@ export declare class Mirror { private nodeMetaMap; getId(n: Node | undefined | null): number; getNode(id: number): Node | null; - getIds(): IterableIterator; + getIds(): number[]; getMeta(n: Node): serializedNode | null; removeNodeFromMap(n: Node): void; has(id: number): boolean; From b5f4642dde10e3d12414c9c117cd3649773f68ba Mon Sep 17 00:00:00 2001 From: Yun Feng Date: Wed, 6 Apr 2022 13:03:22 +1000 Subject: [PATCH 11/12] Remove unnessisary as unknown as ... and change test to reflect the API change --- packages/rrweb-snapshot/test/integration.test.ts | 2 +- packages/rrweb/src/replay/index.ts | 2 +- packages/rrweb/typings/record/iframe-manager.d.ts | 2 +- packages/rrweb/typings/record/index.d.ts | 3 +-- 4 files changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/rrweb-snapshot/test/integration.test.ts b/packages/rrweb-snapshot/test/integration.test.ts index 0d70c4faad..c34bfefcc1 100644 --- a/packages/rrweb-snapshot/test/integration.test.ts +++ b/packages/rrweb-snapshot/test/integration.test.ts @@ -132,7 +132,7 @@ describe('integration tests', function (this: ISuite) { await page.evaluate(`${code} const x = new XMLSerializer(); const snap = rrweb.snapshot(document); - let out = x.serializeToString(rrweb.rebuild(snap, { doc: document })[0]); + let out = x.serializeToString(rrweb.rebuild(snap, { doc: document })); if (document.querySelector('html').getAttribute('xmlns') !== 'http://www.w3.org/1999/xhtml') { // this is just an artefact of serializeToString out = out.replace(' xmlns=\"http://www.w3.org/1999/xhtml\"', ''); diff --git a/packages/rrweb/src/replay/index.ts b/packages/rrweb/src/replay/index.ts index 70fa6cb664..9fa736af7c 100644 --- a/packages/rrweb/src/replay/index.ts +++ b/packages/rrweb/src/replay/index.ts @@ -1666,7 +1666,7 @@ export class Replayer { }); } else if (sn?.type === NodeType.Document) { // nest iframe content document - ((target as unknown) as Document).defaultView!.scrollTo({ + (target as Document).defaultView!.scrollTo({ top: d.y, left: d.x, behavior: isSync ? 'auto' : 'smooth', diff --git a/packages/rrweb/typings/record/iframe-manager.d.ts b/packages/rrweb/typings/record/iframe-manager.d.ts index d610401213..9fbf136cc4 100644 --- a/packages/rrweb/typings/record/iframe-manager.d.ts +++ b/packages/rrweb/typings/record/iframe-manager.d.ts @@ -9,5 +9,5 @@ export declare class IframeManager { }); addIframe(iframeEl: HTMLIFrameElement): void; addLoadListener(cb: (iframeEl: HTMLIFrameElement) => unknown): void; - attachIframe(iframeEl: Node, childSn: serializedNodeWithId, mirror: Mirror): void; + attachIframe(iframeEl: HTMLIFrameElement, childSn: serializedNodeWithId, mirror: Mirror): void; } diff --git a/packages/rrweb/typings/record/index.d.ts b/packages/rrweb/typings/record/index.d.ts index 9e141a4ea6..f997da1f6f 100644 --- a/packages/rrweb/typings/record/index.d.ts +++ b/packages/rrweb/typings/record/index.d.ts @@ -1,10 +1,9 @@ -import { Mirror } from 'rrweb-snapshot'; import { eventWithTime, recordOptions, listenerHandler } from '../types'; declare function record(options?: recordOptions): listenerHandler | undefined; declare namespace record { var addCustomEvent: (tag: string, payload: T) => void; var freezePage: () => void; var takeFullSnapshot: (isCheckout?: boolean | undefined) => void; - var mirror: Mirror; + var mirror: import("rrweb-snapshot").Mirror; } export default record; From c44ddd86419afcd0de97d565cc15318c8322c3a4 Mon Sep 17 00:00:00 2001 From: Justin Halsall Date: Wed, 6 Apr 2022 13:27:28 +0200 Subject: [PATCH 12/12] Refactor mirror to remove `nodeIdMap` Fixes: https://github.com/rrweb-io/rrweb/pull/868#discussion_r842732696 --- packages/rrweb-snapshot/src/rebuild.ts | 2 +- packages/rrweb-snapshot/src/snapshot.ts | 4 ++-- packages/rrweb-snapshot/src/types.ts | 4 +--- packages/rrweb-snapshot/src/utils.ts | 24 +++++++++++++--------- packages/rrweb-snapshot/typings/types.d.ts | 3 +-- packages/rrweb-snapshot/typings/utils.d.ts | 8 ++++---- packages/rrweb/src/replay/index.ts | 4 ++-- 7 files changed, 25 insertions(+), 24 deletions(-) diff --git a/packages/rrweb-snapshot/src/rebuild.ts b/packages/rrweb-snapshot/src/rebuild.ts index d86cfca49a..cf818fc7e3 100644 --- a/packages/rrweb-snapshot/src/rebuild.ts +++ b/packages/rrweb-snapshot/src/rebuild.ts @@ -363,7 +363,7 @@ export function buildNodeWithSN( node = doc; } - mirror.add(node, n.id, n); + mirror.add(node, n); if ( (n.type === NodeType.Document || n.type === NodeType.Element) && diff --git a/packages/rrweb-snapshot/src/snapshot.ts b/packages/rrweb-snapshot/src/snapshot.ts index 24742e7c37..9ccb67b52d 100644 --- a/packages/rrweb-snapshot/src/snapshot.ts +++ b/packages/rrweb-snapshot/src/snapshot.ts @@ -877,10 +877,10 @@ export function serializeNodeWithId( return null; // slimDOM } - mirror.add(n, id, _serializedNode); - const serializedNode = Object.assign(_serializedNode, { id }); + mirror.add(n, serializedNode); + if (onSerialize) { onSerialize(n); } diff --git a/packages/rrweb-snapshot/src/types.ts b/packages/rrweb-snapshot/src/types.ts index 3ea1d9784f..89b4e1ac12 100644 --- a/packages/rrweb-snapshot/src/types.ts +++ b/packages/rrweb-snapshot/src/types.ts @@ -78,9 +78,7 @@ export interface ICanvas extends HTMLCanvasElement { export type idNodeMap = Map; -export type nodeIdMap = WeakMap; - -export type nodeMetaMap = WeakMap; +export type nodeMetaMap = WeakMap; export type MaskInputOptions = Partial<{ color: boolean; diff --git a/packages/rrweb-snapshot/src/utils.ts b/packages/rrweb-snapshot/src/utils.ts index 74d0f750a7..5eba92f806 100644 --- a/packages/rrweb-snapshot/src/utils.ts +++ b/packages/rrweb-snapshot/src/utils.ts @@ -2,9 +2,8 @@ import { idNodeMap, MaskInputFn, MaskInputOptions, - nodeIdMap, nodeMetaMap, - serializedNode, + serializedNodeWithId, } from './types'; export function isElement(n: Node): n is Element { @@ -18,13 +17,12 @@ export function isShadowRoot(n: Node): n is ShadowRoot { export class Mirror { private idNodeMap: idNodeMap = new Map(); - private nodeIdMap: nodeIdMap = new WeakMap(); private nodeMetaMap: nodeMetaMap = new WeakMap(); getId(n: Node | undefined | null): number { if (!n) return -1; - const id = this.nodeIdMap.get(n); + const id = this.getMeta(n)?.id; // if n is not a serialized Node, use -1 as its id. return id ?? -1; @@ -38,7 +36,7 @@ export class Mirror { return Array.from(this.idNodeMap.keys()); } - getMeta(n: Node): serializedNode | null { + getMeta(n: Node): serializedNodeWithId | null { return this.nodeMetaMap.get(n) || null; } @@ -55,17 +53,23 @@ export class Mirror { has(id: number): boolean { return this.idNodeMap.has(id); } + hasNode(node: Node): boolean { - return this.nodeIdMap.has(node); + return this.nodeMetaMap.has(node); + } + + add(n: Node, meta: serializedNodeWithId) { + const id = meta.id; + this.idNodeMap.set(id, n); + this.nodeMetaMap.set(n, meta); } - add(n: Node, id: number, meta?: serializedNode) { + + replace(id: number, n: Node) { this.idNodeMap.set(id, n); - this.nodeIdMap.set(n, id); - if (meta) this.nodeMetaMap.set(n, meta); } + reset() { this.idNodeMap = new Map(); - this.nodeIdMap = new WeakMap(); this.nodeMetaMap = new WeakMap(); } } diff --git a/packages/rrweb-snapshot/typings/types.d.ts b/packages/rrweb-snapshot/typings/types.d.ts index 8fef47ae34..9ffd0ea8ec 100644 --- a/packages/rrweb-snapshot/typings/types.d.ts +++ b/packages/rrweb-snapshot/typings/types.d.ts @@ -59,8 +59,7 @@ export interface ICanvas extends HTMLCanvasElement { __context: string; } export declare type idNodeMap = Map; -export declare type nodeIdMap = WeakMap; -export declare type nodeMetaMap = WeakMap; +export declare type nodeMetaMap = WeakMap; export declare type MaskInputOptions = Partial<{ color: boolean; date: boolean; diff --git a/packages/rrweb-snapshot/typings/utils.d.ts b/packages/rrweb-snapshot/typings/utils.d.ts index 2cc7e6a05d..c3bf88f824 100644 --- a/packages/rrweb-snapshot/typings/utils.d.ts +++ b/packages/rrweb-snapshot/typings/utils.d.ts @@ -1,18 +1,18 @@ -import { MaskInputFn, MaskInputOptions, serializedNode } from './types'; +import { MaskInputFn, MaskInputOptions, serializedNodeWithId } from './types'; export declare function isElement(n: Node): n is Element; export declare function isShadowRoot(n: Node): n is ShadowRoot; export declare class Mirror { private idNodeMap; - private nodeIdMap; private nodeMetaMap; getId(n: Node | undefined | null): number; getNode(id: number): Node | null; getIds(): number[]; - getMeta(n: Node): serializedNode | null; + getMeta(n: Node): serializedNodeWithId | null; removeNodeFromMap(n: Node): void; has(id: number): boolean; hasNode(node: Node): boolean; - add(n: Node, id: number, meta?: serializedNode): void; + add(n: Node, meta: serializedNodeWithId): void; + replace(id: number, n: Node): void; reset(): void; } export declare function createMirror(): Mirror; diff --git a/packages/rrweb/src/replay/index.ts b/packages/rrweb/src/replay/index.ts index 9fa736af7c..d7027a14bf 100644 --- a/packages/rrweb/src/replay/index.ts +++ b/packages/rrweb/src/replay/index.ts @@ -1426,7 +1426,7 @@ export class Replayer { !hasIframeChild ) { const virtualParent = document.createDocumentFragment(); - this.mirror.add(virtualParent, mutation.parentId); + this.mirror.replace(mutation.parentId, virtualParent); this.fragmentParentMap.set(virtualParent, parent); // store the state, like scroll position, of child nodes before they are unmounted from dom @@ -1842,7 +1842,7 @@ export class Replayer { private restoreRealParent(frag: Node, parent: Node) { const id = this.mirror.getId(frag); const parentSn = this.mirror.getMeta(parent); - this.mirror.add(parent, id); + this.mirror.replace(id, parent); /** * If we have already set value attribute on textarea,