Files
rrweb/packages/rrdom/src/virtual-dom.ts
Justin Halsall de755ae577 #853 Second try: fast-forward implementation v2: virtual dom optimization (#895)
* rrdom: add a diff function for properties

* implement diffChildren function and unit tests

* finish basic functions of diff algorithm

* fix several bugs in the diff algorithm

* replace the virtual parent optimization in applyMutation()

* fix: moveAndHover after the diff algorithm is executed

* replace virtual style map with rrdom

cssom version has to be above 0.5.0 to pass virtual style tests

* fix: failed virtual style tests in replayer.test.ts

* fix: failed polyfill tests caused by nodejs compatibility of different versions

* fix: svg viewBox attribute doesn't work

Cause the attribute viewBox is case sensitive, set value for viewbox doesn't work

* feat: replace treeIndex optimization with rrdom

* fix bug of diffProps and disable smooth scrolling animation in fast-forward mode

* feat: add iframe support

* fix: @rollup/plugin-typescript build errors in rrweb-player

Error: @rollup/plugin-typescript TS1371: This import is never used as a value and must use 'import type' because the 'importsNotUsedAsValues' is set to 'error'

* fix: bug when fast-forward input events and add test for it

* add test for fast-forward scroll events

* fix: custom style rules don't get inserted into some iframe elements

* code style tweak

* fix: enable to diff iframe elements

* fix  the jest error "Unexpected token 'export'"

* try to fix build error of rrweb-player

* correct the attributes definition in rrdom

* fix: custom style rules are not inserted in some iframes

* add support for shadow dom

* add support for MediaInteraction

* add canvas support

* fix unit test error in rrdom

* add support for Text, Comment

* try to refactor RRDom

* refactor RRDom to reduce duplicate code

* rename document-browser to virtual-dom

* increase the test coverage for document.ts and add ownerDocument for it

* Merge branch 'master' into virtual-dom

* add more test for virtual-dom.ts

* use cssstyle in document-nodejs

* fix: bundle error

* improve document-nodejs

* enable to diff scroll positions of an element

* rename rrdom to virtualDom for more readability and make the tree public

* revert unknown change

* improve the css style parser for comments

* improve code style

* update typings

* add handling for the case where legacy_missingNodeRetryMap is not empty

* only import types from rrweb into rrdom

* Apply suggestions from code review

Co-authored-by: Justin Halsall <Juice10@users.noreply.github.com>

* Apply suggestions from code review

* fix building error in rrweb

* add a method setDefaultSN to set a default value for a RRNode's __sn

* fix rrweb test error and bump up other packages

* add support for custom property of css styles

* add a switch for virtual-dom optimization

* Apply suggestions from code review

1. add an enum type for NodeType
2. rename nodeType from rrweb-snapshot to RRNodeType
3. rename notSerializedId to unserializedId
4. add comments for some confusing variables

* adapt changes of #865 to virtual-dom and improve the test case for more coverage

* apply review suggestions

https://github.com/rrweb-io/rrweb/pull/853#pullrequestreview-922474953

* tweak the diff algorithm

* add description of the flag useVirtualDom and remove outdated logConfig

* Remove console.log

* Contain changes to document

* Upgrade rollup to 2.70.2

* Revert "Upgrade rollup to 2.70.2"

This reverts commit b1be81a2a76565935c9dc391f31beb7f64d25956.

* Fix type checking rrdom

* Fix typing error while bundling

* Fix tslib error on build

Rollup would output the following error:
`semantic error TS2343: This syntax requires an imported helper named '__spreadArray' which does not exist in 'tslib'. Consider upgrading your version of 'tslib'.`

* Increase memory limit for rollup

* Use esbuild for bundling

Speeds up bundling significantly

* Avoid circular dependencies and import un-bundled rrdom

* Fix imports

* Revert back to pre-esbuild

This reverts the following commits:
b7b3c8dbaa551a0129da1477136b1baaad28e6e1
72e23b8e27f9030d911358d3a17fe5ad1b3b5d4f
85d600a20c56cfa764cf1f858932ba14e67b1d23
61e1a5d323212ca8fbe0569e0b3062ddd53fc612

* Set node to lts (12 is no longer supported)

* Speed up bundling and use less memory

This fixes the out of memory errors happening while bundling

* remove __sn from rrdom

* fix typo

* test: add a test case for StyleSheet mutation exceptions while fast-forwarding

* rename Array.prototype.slice.call() to Array.from()

* improve test cases

* fix: PR #887 in 'virtual-dom' branch

* apply justin's suggestion on 'Array.from' refactor

related commit 0f6729d27a323260b36fbe79485a86715c0bc98a

* improve import code structure

Co-authored-by: Yun Feng <yun.feng@anu.edu.au>
2022-05-12 12:01:13 +08:00

451 lines
12 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
NodeType as RRNodeType,
createMirror as createNodeMirror,
} from 'rrweb-snapshot';
import type {
Mirror as NodeMirror,
IMirror,
serializedNodeWithId,
} from 'rrweb-snapshot';
import type {
canvasMutationData,
canvasEventWithTime,
inputData,
scrollData,
} from 'rrweb/src/types';
import {
BaseRRNode as RRNode,
BaseRRCDATASectionImpl,
BaseRRCommentImpl,
BaseRRDocumentImpl,
BaseRRDocumentTypeImpl,
BaseRRElementImpl,
BaseRRMediaElementImpl,
BaseRRTextImpl,
IRRDocument,
IRRElement,
IRRNode,
NodeType,
IRRDocumentType,
IRRText,
IRRComment,
} from './document';
import type { VirtualStyleRules } from './diff';
export class RRDocument extends BaseRRDocumentImpl(RRNode) {
// In the rrweb replayer, there are some unserialized nodes like the element that stores the injected style rules.
// These unserialized nodes may interfere the execution of the diff algorithm.
// The id of serialized node is larger than 0. So this value less than 0 is used as id for these unserialized nodes.
private _unserializedId = -1;
/**
* Every time the id is used, it will minus 1 automatically to avoid collisions.
*/
public get unserializedId(): number {
return this._unserializedId--;
}
public mirror: Mirror = createMirror();
public scrollData: scrollData | null = null;
constructor(mirror?: Mirror) {
super();
if (mirror) {
this.mirror = mirror;
}
}
createDocument(
_namespace: string | null,
_qualifiedName: string | null,
_doctype?: DocumentType | null,
) {
return new RRDocument();
}
createDocumentType(
qualifiedName: string,
publicId: string,
systemId: string,
) {
const documentTypeNode = new RRDocumentType(
qualifiedName,
publicId,
systemId,
);
documentTypeNode.ownerDocument = this;
return documentTypeNode;
}
createElement<K extends keyof HTMLElementTagNameMap>(
tagName: K,
): RRElementType<K>;
createElement(tagName: string): RRElement;
createElement(tagName: string) {
const upperTagName = tagName.toUpperCase();
let element;
switch (upperTagName) {
case 'AUDIO':
case 'VIDEO':
element = new RRMediaElement(upperTagName);
break;
case 'IFRAME':
element = new RRIFrameElement(upperTagName, this.mirror);
break;
case 'CANVAS':
element = new RRCanvasElement(upperTagName);
break;
case 'STYLE':
element = new RRStyleElement(upperTagName);
break;
default:
element = new RRElement(upperTagName);
break;
}
element.ownerDocument = this;
return element;
}
createComment(data: string) {
const commentNode = new RRComment(data);
commentNode.ownerDocument = this;
return commentNode;
}
createCDATASection(data: string) {
const sectionNode = new RRCDATASection(data);
sectionNode.ownerDocument = this;
return sectionNode;
}
createTextNode(data: string) {
const textNode = new RRText(data);
textNode.ownerDocument = this;
return textNode;
}
destroyTree() {
this.childNodes = [];
this.mirror.reset();
}
open() {
super.open();
this._unserializedId = -1;
}
}
export const RRDocumentType = BaseRRDocumentTypeImpl(RRNode);
export class RRElement extends BaseRRElementImpl(RRNode) {
inputData: inputData | null = null;
scrollData: scrollData | null = null;
}
export class RRMediaElement extends BaseRRMediaElementImpl(RRElement) {}
export class RRCanvasElement extends RRElement implements IRRElement {
public canvasMutations: {
event: canvasEventWithTime;
mutation: canvasMutationData;
}[] = [];
/**
* This is a dummy implementation to distinguish RRCanvasElement from real HTMLCanvasElement.
*/
getContext(): RenderingContext | null {
return null;
}
}
export class RRStyleElement extends RRElement {
public rules: VirtualStyleRules = [];
}
export class RRIFrameElement extends RRElement {
contentDocument: RRDocument = new RRDocument();
constructor(upperTagName: string, mirror: Mirror) {
super(upperTagName);
this.contentDocument.mirror = mirror;
}
}
export const RRText = BaseRRTextImpl(RRNode);
export type RRText = typeof RRText;
export const RRComment = BaseRRCommentImpl(RRNode);
export type RRComment = typeof RRComment;
export const RRCDATASection = BaseRRCDATASectionImpl(RRNode);
export type RRCDATASection = typeof RRCDATASection;
interface RRElementTagNameMap {
audio: RRMediaElement;
canvas: RRCanvasElement;
iframe: RRIFrameElement;
style: RRStyleElement;
video: RRMediaElement;
}
type RRElementType<
K extends keyof HTMLElementTagNameMap
> = K extends keyof RRElementTagNameMap ? RRElementTagNameMap[K] : RRElement;
function getValidTagName(element: HTMLElement): string {
// https://github.com/rrweb-io/rrweb-snapshot/issues/56
if (element instanceof HTMLFormElement) {
return 'FORM';
}
return element.tagName.toUpperCase();
}
/**
* Build a RRNode from a real Node.
* @param node the real Node
* @param rrdom the RRDocument
* @param domMirror the NodeMirror that records the real document tree
* @returns the built RRNode
*/
export function buildFromNode(
node: Node,
rrdom: IRRDocument,
domMirror: NodeMirror,
parentRRNode?: IRRNode | null,
): IRRNode | null {
let rrNode: IRRNode;
switch (node.nodeType) {
case NodeType.DOCUMENT_NODE:
if (parentRRNode && parentRRNode.nodeName === 'IFRAME')
rrNode = (parentRRNode as RRIFrameElement).contentDocument;
else {
rrNode = rrdom;
(rrNode as IRRDocument).compatMode = (node as Document).compatMode as
| 'BackCompat'
| 'CSS1Compat';
}
break;
case NodeType.DOCUMENT_TYPE_NODE:
const documentType = (node as Node) as DocumentType;
rrNode = rrdom.createDocumentType(
documentType.name,
documentType.publicId,
documentType.systemId,
);
break;
case NodeType.ELEMENT_NODE:
const elementNode = (node as Node) as HTMLElement;
const tagName = getValidTagName(elementNode);
rrNode = rrdom.createElement(tagName);
const rrElement = rrNode as IRRElement;
for (const { name, value } of Array.from(elementNode.attributes)) {
rrElement.attributes[name] = value;
}
elementNode.scrollLeft && (rrElement.scrollLeft = elementNode.scrollLeft);
elementNode.scrollTop && (rrElement.scrollTop = elementNode.scrollTop);
/**
* We don't have to record special values of input elements at the beginning.
* Because if these values are changed later, the mutation will be applied through the batched input events on its RRElement after the diff algorithm is executed.
*/
break;
case NodeType.TEXT_NODE:
rrNode = rrdom.createTextNode(((node as Node) as Text).textContent || '');
break;
case NodeType.CDATA_SECTION_NODE:
rrNode = rrdom.createCDATASection(((node as Node) as CDATASection).data);
break;
case NodeType.COMMENT_NODE:
rrNode = rrdom.createComment(
((node as Node) as Comment).textContent || '',
);
break;
// if node is a shadow root
case NodeType.DOCUMENT_FRAGMENT_NODE:
rrNode = (parentRRNode as IRRElement).attachShadow({ mode: 'open' });
break;
default:
return null;
}
let sn: serializedNodeWithId | null = domMirror.getMeta(node);
if (rrdom instanceof RRDocument) {
if (!sn) {
sn = getDefaultSN(rrNode, rrdom.unserializedId);
domMirror.add(node, sn);
}
rrdom.mirror.add(rrNode, { ...sn });
}
return rrNode;
}
/**
* Build a RRDocument from a real document tree.
* @param dom the real document tree
* @param domMirror the NodeMirror that records the real document tree
* @param rrdom the rrdom object to be constructed
* @returns the build rrdom
*/
export function buildFromDom(
dom: Document,
domMirror: NodeMirror = createNodeMirror(),
rrdom: IRRDocument = new RRDocument(),
) {
function walk(node: Node, parentRRNode: IRRNode | null) {
const rrNode = buildFromNode(node, rrdom, domMirror, parentRRNode);
if (rrNode === null) return;
if (
// if the parentRRNode isn't a RRIFrameElement
parentRRNode?.nodeName !== 'IFRAME' &&
// if node isn't a shadow root
node.nodeType !== NodeType.DOCUMENT_FRAGMENT_NODE
) {
parentRRNode?.appendChild(rrNode);
rrNode.parentNode = parentRRNode;
rrNode.parentElement = parentRRNode as RRElement;
}
if (node.nodeName === 'IFRAME') {
walk((node as HTMLIFrameElement).contentDocument!, rrNode);
} else if (
node.nodeType === NodeType.DOCUMENT_NODE ||
node.nodeType === NodeType.ELEMENT_NODE ||
node.nodeType === NodeType.DOCUMENT_FRAGMENT_NODE
) {
// if the node is a shadow dom
if (
node.nodeType === NodeType.ELEMENT_NODE &&
((node as Node) as HTMLElement).shadowRoot
)
walk(((node as Node) as HTMLElement).shadowRoot!, rrNode);
node.childNodes.forEach((childNode) => walk(childNode, rrNode));
}
}
walk(dom, null);
return rrdom;
}
export function createMirror(): Mirror {
return new Mirror();
}
// based on Mirror from rrweb-snapshots
export class Mirror implements IMirror<RRNode> {
private idNodeMap: Map<number, RRNode> = new Map();
private nodeMetaMap: WeakMap<RRNode, serializedNodeWithId> = new WeakMap();
getId(n: RRNode | undefined | null): number {
if (!n) return -1;
const id = this.getMeta(n)?.id;
// if n is not a serialized Node, use -1 as its id.
return id ?? -1;
}
getNode(id: number): RRNode | null {
return this.idNodeMap.get(id) || null;
}
getIds(): number[] {
return Array.from(this.idNodeMap.keys());
}
getMeta(n: RRNode): serializedNodeWithId | null {
return this.nodeMetaMap.get(n) || null;
}
// removes the node from idNodeMap
// doesn't remove the node from nodeMetaMap
removeNodeFromMap(n: RRNode) {
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: RRNode): boolean {
return this.nodeMetaMap.has(node);
}
add(n: RRNode, meta: serializedNodeWithId) {
const id = meta.id;
this.idNodeMap.set(id, n);
this.nodeMetaMap.set(n, meta);
}
replace(id: number, n: RRNode) {
this.idNodeMap.set(id, n);
}
reset() {
this.idNodeMap = new Map();
this.nodeMetaMap = new WeakMap();
}
}
/**
* Get a default serializedNodeWithId value for a RRNode.
* @param id the serialized id to assign
*/
export function getDefaultSN(node: IRRNode, id: number): serializedNodeWithId {
switch (node.RRNodeType) {
case RRNodeType.Document:
return {
id,
type: node.RRNodeType,
childNodes: [],
};
case RRNodeType.DocumentType:
const doctype = node as IRRDocumentType;
return {
id,
type: node.RRNodeType,
name: doctype.name,
publicId: doctype.publicId,
systemId: doctype.systemId,
};
case RRNodeType.Element:
return {
id,
type: node.RRNodeType,
tagName: (node as IRRElement).tagName.toLowerCase(), // In rrweb data, all tagNames are lowercase.
attributes: {},
childNodes: [],
};
case RRNodeType.Text:
return {
id,
type: node.RRNodeType,
textContent: (node as IRRText).textContent || '',
};
case RRNodeType.Comment:
return {
id,
type: node.RRNodeType,
textContent: (node as IRRComment).textContent || '',
};
case RRNodeType.CDATA:
return {
id,
type: node.RRNodeType,
textContent: '',
};
}
}
export { RRNode };
export {
diff,
createOrGetNode,
StyleRuleType,
VirtualStyleRules,
ReplayerHandler,
} from './diff';