diff --git a/src/replay/index.ts b/src/replay/index.ts index a291f10e..c189da1b 100644 --- a/src/replay/index.ts +++ b/src/replay/index.ts @@ -1,4 +1,4 @@ -import { rebuild, buildNodeWithSN } from 'rrweb-snapshot'; +import { rebuild, buildNodeWithSN, INode, NodeType } from 'rrweb-snapshot'; import * as mittProxy from 'mitt'; import * as smoothscroll from 'smoothscroll-polyfill'; import { Timer } from './timer'; @@ -22,8 +22,11 @@ import { Emitter, MediaInteractions, metaEvent, + mutationData, + scrollData, + inputData, } from '../types'; -import { mirror, polyfill } from '../utils'; +import { mirror, polyfill, TreeIndex } from '../utils'; import getInjectStyleRules from './styles/inject-style'; import './styles/style.css'; @@ -71,6 +74,9 @@ export class Replayer { private service!: ReturnType; + private treeIndex!: TreeIndex; + private fragmentParentMap!: Map; + constructor( events: Array, config?: Partial, @@ -82,12 +88,42 @@ export class Replayer { this.handleResize = this.handleResize.bind(this); this.getCastFn = this.getCastFn.bind(this); - this.emitter.on('resize', this.handleResize as Handler); + this.emitter.on(ReplayerEvents.Resize, this.handleResize as Handler); smoothscroll.polyfill(); polyfill(); this.setupDom(); + this.treeIndex = new TreeIndex(); + this.fragmentParentMap = new Map(); + this.emitter.on(ReplayerEvents.Flush, () => { + const { scrollMap, inputMap } = this.treeIndex.flush(); + + for (const d of scrollMap.values()) { + this.applyScroll(d); + } + for (const d of inputMap.values()) { + this.applyInput(d); + } + + for (const [frag, parent] of this.fragmentParentMap.entries()) { + mirror.map[parent.__sn.id] = parent; + /** + * 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' && + frag.textContent + ) { + ((parent as unknown) as HTMLTextAreaElement).value = frag.textContent; + } + parent.appendChild(frag); + } + this.fragmentParentMap.clear(); + }); + this.service = createPlayerService( { events: events.map((e) => { @@ -402,130 +438,13 @@ export class Replayer { const { data: d } = e; switch (d.source) { case IncrementalSource.Mutation: { - d.removes.forEach((mutation) => { - const target = mirror.getNode(mutation.id); - if (!target) { - return this.warnNodeNotFound(d, mutation.id); - } - const parent = mirror.getNode(mutation.parentId); - if (!parent) { - return this.warnNodeNotFound(d, mutation.parentId); - } - // target may be removed with its parents before - mirror.removeNodeFromMap(target); - if (parent) { - parent.removeChild(target); - } - }); - - const legacy_missingNodeMap: missingNodeMap = { - ...this.legacy_missingNodeRetryMap, - }; - const queue: addedNodeMutation[] = []; - - const appendNode = (mutation: addedNodeMutation) => { - const parent = mirror.getNode(mutation.parentId); - if (!parent) { - return queue.push(mutation); - } - - let previous: Node | null = null; - let next: Node | null = null; - if (mutation.previousId) { - previous = mirror.getNode(mutation.previousId) as Node; - } - if (mutation.nextId) { - next = mirror.getNode(mutation.nextId) as Node; - } - // next not present at this moment - if (mutation.nextId !== null && mutation.nextId !== -1 && !next) { - return queue.push(mutation); - } - - const target = buildNodeWithSN( - mutation.node, - this.iframe.contentDocument!, - mirror.map, - true, - ) as Node; - - // legacy data, we should not have -1 siblings any more - if (mutation.previousId === -1 || mutation.nextId === -1) { - legacy_missingNodeMap[mutation.node.id] = { - node: target, - mutation, - }; - return; - } - - if ( - previous && - previous.nextSibling && - previous.nextSibling.parentNode - ) { - parent.insertBefore(target, previous.nextSibling); - } else if (next && next.parentNode) { - // making sure the parent contains the reference nodes - // before we insert target before next. - parent.contains(next) - ? parent.insertBefore(target, next) - : parent.insertBefore(target, null); - } else { - parent.appendChild(target); - } - - if (mutation.previousId || mutation.nextId) { - this.legacy_resolveMissingNode( - legacy_missingNodeMap, - parent, - target, - mutation, - ); - } - }; - - d.adds.forEach((mutation) => { - appendNode(mutation); - }); - - while (queue.length) { - if (queue.every((m) => !Boolean(mirror.getNode(m.parentId)))) { - return queue.forEach((m) => this.warnNodeNotFound(d, m.node.id)); - } - const mutation = queue.shift()!; - appendNode(mutation); + if (isSync) { + d.adds.forEach((m) => this.treeIndex.add(m)); + d.texts.forEach((m) => this.treeIndex.text(m)); + d.attributes.forEach((m) => this.treeIndex.attribute(m)); + d.removes.forEach((m) => this.treeIndex.remove(m)); } - - if (Object.keys(legacy_missingNodeMap).length) { - Object.assign(this.legacy_missingNodeRetryMap, legacy_missingNodeMap); - } - - d.texts.forEach((mutation) => { - const target = mirror.getNode(mutation.id); - if (!target) { - return this.warnNodeNotFound(d, mutation.id); - } - target.textContent = mutation.value; - }); - d.attributes.forEach((mutation) => { - const target = mirror.getNode(mutation.id); - if (!target) { - return this.warnNodeNotFound(d, mutation.id); - } - for (const attributeName in mutation.attributes) { - if (typeof attributeName === 'string') { - const value = mutation.attributes[attributeName]; - if (value !== null) { - ((target as Node) as Element).setAttribute( - attributeName, - value, - ); - } else { - ((target as Node) as Element).removeAttribute(attributeName); - } - } - } - }); + this.applyMutation(d, true); break; } case IncrementalSource.MouseMove: @@ -604,27 +523,11 @@ export class Replayer { if (d.id === -1) { break; } - const target = mirror.getNode(d.id); - if (!target) { - return this.debugNodeNotFound(d, d.id); - } - if ((target as Node) === this.iframe.contentDocument) { - this.iframe.contentWindow!.scrollTo({ - top: d.y, - left: d.x, - behavior: isSync ? 'auto' : 'smooth', - }); - } else { - try { - ((target as Node) as Element).scrollTop = d.y; - ((target as Node) as Element).scrollLeft = d.x; - } catch (error) { - /** - * Seldomly we may found scroll target was removed before - * its last scroll event. - */ - } + if (isSync) { + this.treeIndex.scroll(d); + break; } + this.applyScroll(d); break; } case IncrementalSource.ViewportResize: @@ -643,16 +546,11 @@ export class Replayer { if (d.id === -1) { break; } - const target = mirror.getNode(d.id); - if (!target) { - return this.debugNodeNotFound(d, d.id); - } - try { - ((target as Node) as HTMLInputElement).checked = d.isChecked; - ((target as Node) as HTMLInputElement).value = d.text; - } catch (error) { - // for safe + if (isSync) { + this.treeIndex.input(d); + break; } + this.applyInput(d); break; } case IncrementalSource.MediaInteraction: { @@ -712,6 +610,188 @@ export class Replayer { } } + private applyMutation(d: mutationData, useVirtualParent: boolean) { + d.removes.forEach((mutation) => { + const target = mirror.getNode(mutation.id); + if (!target) { + return this.warnNodeNotFound(d, mutation.id); + } + const parent = mirror.getNode(mutation.parentId); + if (!parent) { + return this.warnNodeNotFound(d, mutation.parentId); + } + // target may be removed with its parents before + mirror.removeNodeFromMap(target); + if (parent) { + const realParent = this.fragmentParentMap.get(parent); + if (realParent && realParent.contains(target)) { + realParent.removeChild(target); + } else { + parent.removeChild(target); + } + } + }); + + const legacy_missingNodeMap: missingNodeMap = { + ...this.legacy_missingNodeRetryMap, + }; + const queue: addedNodeMutation[] = []; + + const appendNode = (mutation: addedNodeMutation) => { + let parent = mirror.getNode(mutation.parentId); + if (!parent) { + return queue.push(mutation); + } + + const parentInDocument = this.iframe.contentDocument!.contains(parent); + if (useVirtualParent && parentInDocument) { + const virtualParent = (document.createDocumentFragment() as unknown) as INode; + mirror.map[mutation.parentId] = virtualParent; + this.fragmentParentMap.set(virtualParent, parent); + while (parent.firstChild) { + virtualParent.appendChild(parent.firstChild); + } + parent = virtualParent; + } + + let previous: Node | null = null; + let next: Node | null = null; + if (mutation.previousId) { + previous = mirror.getNode(mutation.previousId) as Node; + } + if (mutation.nextId) { + next = mirror.getNode(mutation.nextId) as Node; + } + // next not present at this moment + if (mutation.nextId !== null && mutation.nextId !== -1 && !next) { + return queue.push(mutation); + } + + const target = buildNodeWithSN( + mutation.node, + this.iframe.contentDocument!, + mirror.map, + true, + ) as Node; + + // legacy data, we should not have -1 siblings any more + if (mutation.previousId === -1 || mutation.nextId === -1) { + legacy_missingNodeMap[mutation.node.id] = { + node: target, + mutation, + }; + return; + } + + if (previous && previous.nextSibling && previous.nextSibling.parentNode) { + parent.insertBefore(target, previous.nextSibling); + } else if (next && next.parentNode) { + // making sure the parent contains the reference nodes + // before we insert target before next. + parent.contains(next) + ? parent.insertBefore(target, next) + : parent.insertBefore(target, null); + } else { + parent.appendChild(target); + } + + if (mutation.previousId || mutation.nextId) { + this.legacy_resolveMissingNode( + legacy_missingNodeMap, + parent, + target, + mutation, + ); + } + }; + + d.adds.forEach((mutation) => { + appendNode(mutation); + }); + + while (queue.length) { + if (queue.every((m) => !Boolean(mirror.getNode(m.parentId)))) { + return queue.forEach((m) => this.warnNodeNotFound(d, m.node.id)); + } + const mutation = queue.shift()!; + appendNode(mutation); + } + + if (Object.keys(legacy_missingNodeMap).length) { + Object.assign(this.legacy_missingNodeRetryMap, legacy_missingNodeMap); + } + + d.texts.forEach((mutation) => { + let target = mirror.getNode(mutation.id); + if (!target) { + return this.warnNodeNotFound(d, mutation.id); + } + /** + * apply text content to real parent directly + */ + if (this.fragmentParentMap.has(target)) { + target = this.fragmentParentMap.get(target)!; + } + target.textContent = mutation.value; + }); + d.attributes.forEach((mutation) => { + let target = mirror.getNode(mutation.id); + if (!target) { + return this.warnNodeNotFound(d, mutation.id); + } + if (this.fragmentParentMap.has(target)) { + target = this.fragmentParentMap.get(target)!; + } + for (const attributeName in mutation.attributes) { + if (typeof attributeName === 'string') { + const value = mutation.attributes[attributeName]; + if (value !== null) { + ((target as Node) as Element).setAttribute(attributeName, value); + } else { + ((target as Node) as Element).removeAttribute(attributeName); + } + } + } + }); + } + + private applyScroll(d: scrollData) { + const target = mirror.getNode(d.id); + if (!target) { + return this.debugNodeNotFound(d, d.id); + } + if ((target as Node) === this.iframe.contentDocument) { + this.iframe.contentWindow!.scrollTo({ + top: d.y, + left: d.x, + behavior: 'smooth', + }); + } else { + try { + ((target as Node) as Element).scrollTop = d.y; + ((target as Node) as Element).scrollLeft = d.x; + } catch (error) { + /** + * Seldomly we may found scroll target was removed before + * its last scroll event. + */ + } + } + } + + private applyInput(d: inputData) { + const target = mirror.getNode(d.id); + if (!target) { + return this.debugNodeNotFound(d, d.id); + } + try { + ((target as Node) as HTMLInputElement).checked = d.isChecked; + ((target as Node) as HTMLInputElement).value = d.text; + } catch (error) { + // for safe + } + } + private legacy_resolveMissingNode( map: missingNodeMap, parent: Node, diff --git a/src/replay/machine.ts b/src/replay/machine.ts index 24ea012a..af9cc852 100644 --- a/src/replay/machine.ts +++ b/src/replay/machine.ts @@ -8,6 +8,7 @@ import { Emitter, } from '../types'; import { Timer, getDelay } from './timer'; +import { needCastInSyncMode } from '../utils'; export type PlayerContext = { events: eventWithTime[]; @@ -194,6 +195,9 @@ export function createPlayerService( continue; } const isSync = event.timestamp < baselineTime; + if (isSync && !needCastInSyncMode(event)) { + continue; + } const castFn = getCastFn(event, isSync); if (isSync) { castFn(); @@ -207,6 +211,7 @@ export function createPlayerService( }); } } + emitter.emit(ReplayerEvents.Flush); timer.addActions(actions); timer.start(); }, diff --git a/src/types.ts b/src/types.ts index d2d121d1..749f3d33 100644 --- a/src/types.ts +++ b/src/types.ts @@ -168,13 +168,13 @@ export type hooksParam = { // https://dom.spec.whatwg.org/#interface-mutationrecord export type mutationRecord = { - type: string, - target: Node, - oldValue: string | null, - addedNodes: NodeList, - removedNodes: NodeList, - attributeName: string | null, -} + type: string; + target: Node; + oldValue: string | null; + addedNodes: NodeList; + removedNodes: NodeList; + attributeName: string | null; +}; export type textCursor = { node: Node; @@ -377,4 +377,5 @@ export enum ReplayerEvents { MouseInteraction = 'mouse-interaction', EventCast = 'event-cast', CustomEvent = 'custom-event', + Flush = 'flush', } diff --git a/src/utils.ts b/src/utils.ts index b83a74e0..4c8c480f 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -4,6 +4,16 @@ import { listenerHandler, hookResetter, blockClass, + eventWithTime, + EventType, + IncrementalSource, + addedNodeMutation, + removedNodeMutation, + textMutation, + attributeMutation, + mutationData, + scrollData, + inputData, } from './types'; import { INode } from 'rrweb-snapshot'; @@ -172,3 +182,222 @@ export function polyfill() { .forEach as unknown) as NodeList['forEach']; } } + +export function needCastInSyncMode(event: eventWithTime): boolean { + switch (event.type) { + case EventType.DomContentLoaded: + case EventType.Load: + case EventType.Custom: + return false; + case EventType.FullSnapshot: + case EventType.Meta: + return true; + default: + break; + } + + switch (event.data.source) { + case IncrementalSource.MouseMove: + case IncrementalSource.MouseInteraction: + case IncrementalSource.TouchMove: + case IncrementalSource.MediaInteraction: + return false; + case IncrementalSource.ViewportResize: + case IncrementalSource.StyleSheetRule: + case IncrementalSource.Scroll: + case IncrementalSource.Input: + return true; + default: + break; + } + + return true; +} + +export type TreeNode = { + id: number; + mutation: addedNodeMutation; + parent?: TreeNode; + children: Record; + texts: textMutation[]; + attributes: attributeMutation[]; +}; +export class TreeIndex { + public tree!: Record; + + private removeNodeMutations!: removedNodeMutation[]; + private textMutations!: textMutation[]; + private attributeMutations!: attributeMutation[]; + private indexes!: Map; + private removeIdSet!: Set; + private scrollMap!: Map; + private inputMap!: Map; + + constructor() { + this.reset(); + } + + public add(mutation: addedNodeMutation) { + const parentTreeNode = this.indexes.get(mutation.parentId); + const treeNode: TreeNode = { + id: mutation.node.id, + mutation, + children: [], + texts: [], + attributes: [], + }; + if (!parentTreeNode) { + this.tree[treeNode.id] = treeNode; + } else { + treeNode.parent = parentTreeNode; + parentTreeNode.children[treeNode.id] = treeNode; + } + this.indexes.set(treeNode.id, treeNode); + } + + public remove(mutation: removedNodeMutation) { + const parentTreeNode = this.indexes.get(mutation.parentId); + const treeNode = this.indexes.get(mutation.id); + + const deepRemoveFromMirror = (id: number) => { + this.removeIdSet.add(id); + const node = mirror.getNode(id); + node?.childNodes.forEach((childNode) => + deepRemoveFromMirror(((childNode as unknown) as INode).__sn.id), + ); + }; + const deepRemoveFromTreeIndex = (node: TreeNode) => { + this.removeIdSet.add(node.id); + Object.values(node.children).forEach((n) => deepRemoveFromTreeIndex(n)); + const _treeNode = this.indexes.get(node.id); + if (_treeNode) { + const _parentTreeNode = _treeNode.parent; + if (_parentTreeNode) { + delete _treeNode.parent; + delete _parentTreeNode.children[_treeNode.id]; + this.indexes.delete(mutation.id); + } + } + }; + + if (!treeNode) { + this.removeNodeMutations.push(mutation); + deepRemoveFromMirror(mutation.id); + } else if (!parentTreeNode) { + delete this.tree[treeNode.id]; + this.indexes.delete(treeNode.id); + deepRemoveFromTreeIndex(treeNode); + } else { + delete treeNode.parent; + delete parentTreeNode.children[treeNode.id]; + this.indexes.delete(mutation.id); + deepRemoveFromTreeIndex(treeNode); + } + } + + public text(mutation: textMutation) { + const treeNode = this.indexes.get(mutation.id); + if (treeNode) { + treeNode.texts.push(mutation); + } else { + this.textMutations.push(mutation); + } + } + + public attribute(mutation: attributeMutation) { + const treeNode = this.indexes.get(mutation.id); + if (treeNode) { + treeNode.attributes.push(mutation); + } else { + this.attributeMutations.push(mutation); + } + } + + public scroll(d: scrollData) { + this.scrollMap.set(d.id, d); + } + + public input(d: inputData) { + this.inputMap.set(d.id, d); + } + + public flush(): { + mutationData: mutationData; + scrollMap: TreeIndex['scrollMap']; + inputMap: TreeIndex['inputMap']; + } { + const { + tree, + removeNodeMutations, + textMutations, + attributeMutations, + } = this; + + const batchMutationData: mutationData = { + source: IncrementalSource.Mutation, + removes: removeNodeMutations, + texts: textMutations, + attributes: attributeMutations, + adds: [], + }; + + const walk = (treeNode: TreeNode, removed: boolean) => { + if (removed) { + this.removeIdSet.add(treeNode.id); + } + batchMutationData.texts = batchMutationData.texts + .concat(removed ? [] : treeNode.texts) + .filter((m) => !this.removeIdSet.has(m.id)); + batchMutationData.attributes = batchMutationData.attributes + .concat(removed ? [] : treeNode.attributes) + .filter((m) => !this.removeIdSet.has(m.id)); + if ( + !this.removeIdSet.has(treeNode.id) && + !this.removeIdSet.has(treeNode.mutation.parentId) && + !removed + ) { + batchMutationData.adds.push(treeNode.mutation); + if (treeNode.children) { + Object.values(treeNode.children).forEach((n) => walk(n, false)); + } + } else { + Object.values(treeNode.children).forEach((n) => walk(n, true)); + } + }; + + Object.values(tree).forEach((n) => walk(n, false)); + + for (const id of this.scrollMap.keys()) { + if (this.removeIdSet.has(id)) { + this.scrollMap.delete(id); + } + } + for (const id of this.inputMap.keys()) { + if (this.removeIdSet.has(id)) { + this.inputMap.delete(id); + } + } + + const scrollMap = new Map(this.scrollMap); + const inputMap = new Map(this.inputMap); + + this.reset(); + + return { + mutationData: batchMutationData, + scrollMap, + inputMap, + }; + } + + private reset() { + this.tree = []; + this.indexes = new Map(); + this.removeNodeMutations = []; + this.textMutations = []; + this.attributeMutations = []; + this.removeIdSet = new Set(); + this.scrollMap = new Map(); + this.inputMap = new Map(); + } +}