Skip to content

Commit

Permalink
call afterAppend hook in a consistent traversal order
Browse files Browse the repository at this point in the history
  • Loading branch information
YunFeng0817 committed Feb 1, 2023
1 parent fee57d8 commit 8eaf2d3
Show file tree
Hide file tree
Showing 2 changed files with 217 additions and 22 deletions.
62 changes: 43 additions & 19 deletions packages/rrdom/src/diff.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { NodeType as RRNodeType, Mirror as NodeMirror } from 'rrweb-snapshot';
import {
NodeType as RRNodeType,
Mirror as NodeMirror,
elementNode,
} from 'rrweb-snapshot';
import type {
canvasMutationData,
canvasEventWithTime,
Expand Down Expand Up @@ -88,6 +92,9 @@ export type ReplayerHandler = {
afterAppend?(node: Node, id: number): void;
};

// A set contains newly appended nodes. It's used to make sure the afterAppend callback can iterate newly appended nodes in the same traversal order as that in the `rrweb-snapshot` package.
let createdNodeSet: WeakSet<Node> | null = null;

/**
* Make the old tree to have the same structure and properties as the new tree with the diff algorithm.
* @param oldTree - The old tree to be modified.
Expand All @@ -102,19 +109,12 @@ export function diff(
rrnodeMirror: Mirror = (newTree as RRDocument).mirror ||
(newTree.ownerDocument as RRDocument).mirror,
) {
// If the Mirror data has some flaws, the diff function may throw errors. We check the node consistency here to make it robust.
if (!sameNodeType(oldTree, newTree)) {
const calibratedOldTree = createOrGetNode(
newTree,
replayer.mirror,
rrnodeMirror,
);
oldTree.parentNode?.replaceChild(calibratedOldTree, oldTree);
oldTree = calibratedOldTree;
replayer.afterAppend?.(oldTree, replayer.mirror.getId(oldTree));
}

diffBeforeUpdatingChildren(oldTree, newTree, replayer, rrnodeMirror);
oldTree = diffBeforeUpdatingChildren(
oldTree,
newTree,
replayer,
rrnodeMirror,
);

const oldChildren = oldTree.childNodes;
const newChildren = newTree.childNodes;
Expand All @@ -140,6 +140,22 @@ function diffBeforeUpdatingChildren(
replayer: ReplayerHandler,
rrnodeMirror: Mirror,
) {
if (replayer.afterAppend && !createdNodeSet) {
createdNodeSet = new WeakSet();
setTimeout(() => {
createdNodeSet = null;
}, 0);
}
// If the Mirror data has some flaws, the diff function may throw errors. We check the node consistency here to make it robust.
if (!sameNodeType(oldTree, newTree)) {
const calibratedOldTree = createOrGetNode(
newTree,
replayer.mirror,
rrnodeMirror,
);
oldTree.parentNode?.replaceChild(calibratedOldTree, oldTree);
oldTree = calibratedOldTree;
}
switch (newTree.RRNodeType) {
case RRNodeType.Document: {
/**
Expand All @@ -154,7 +170,7 @@ function diffBeforeUpdatingChildren(
(oldTree as Document).close();
(oldTree as Document).open();
replayer.mirror.add(oldTree, newMeta);
replayer.afterAppend?.(oldTree, replayer.mirror.getId(oldTree));
createdNodeSet?.add(oldTree);
}
}
break;
Expand Down Expand Up @@ -196,6 +212,7 @@ function diffBeforeUpdatingChildren(
break;
}
}
return oldTree;
}

/**
Expand Down Expand Up @@ -291,6 +308,10 @@ function diffAfterUpdatingChildren(
break;
}
}
if (createdNodeSet?.has(oldTree)) {
createdNodeSet.delete(oldTree);
replayer.afterAppend?.(oldTree, replayer.mirror.getId(oldTree));
}
}

function diffProps(
Expand All @@ -303,8 +324,8 @@ function diffProps(

for (const name in newAttributes) {
const newValue = newAttributes[name];
const sn = rrnodeMirror.getMeta(newTree);
if (sn && 'isSVG' in sn && sn.isSVG && NAMESPACES[name])
const sn = rrnodeMirror.getMeta(newTree) as elementNode | null;
if (sn?.isSVG && NAMESPACES[name])
oldTree.setAttributeNS(NAMESPACES[name], name, newValue);
else if (newTree.tagName === 'CANVAS' && name === 'rr_dataURL') {
const image = document.createElement('img');
Expand Down Expand Up @@ -441,7 +462,6 @@ function diffChildren(
try {
parentNode.insertBefore(newNode, oldStartNode || null);
diff(newNode, newStartNode, replayer, rrnodeMirror);
replayer.afterAppend?.(newNode, replayer.mirror.getId(newNode));
} catch (e) {
console.warn(e);
}
Expand All @@ -465,7 +485,6 @@ function diffChildren(
try {
parentNode.insertBefore(newNode, referenceNode);
diff(newNode, newChildren[newStartIndex], replayer, rrnodeMirror);
replayer.afterAppend?.(newNode, replayer.mirror.getId(newNode));
} catch (e) {
console.warn(e);
}
Expand Down Expand Up @@ -526,6 +545,11 @@ export function createOrGetNode(
}

if (sn) domMirror.add(node, { ...sn });
try {
createdNodeSet?.add(node);
} catch (e) {
// Just for safety concern.
}
return node;
}

Expand Down
177 changes: 174 additions & 3 deletions packages/rrdom/test/diff.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ import type {
} from '@rrweb/types';
import { EventType, IncrementalSource } from '@rrweb/types';
import { compileTSCode } from './utils';
import { printRRDom } from '../src/index';

const elementSn = {
type: RRNodeType.Element,
Expand Down Expand Up @@ -114,7 +113,9 @@ describe('diff algorithm for rrdom', () => {
applyInput: () => {},
applyScroll: () => {},
applyStyleSheetMutation: () => {},
afterAppend: () => {},
};
document.write('<!DOCTYPE html><html><head></head><body></body></html>');
});

describe('diff single node', () => {
Expand All @@ -133,7 +134,7 @@ describe('diff algorithm for rrdom', () => {
x: 0,
y: 0,
};
replayer.applyScroll = jest.fn();
const applyScrollFn = jest.spyOn(replayer, 'applyScroll');
diff(document, rrNode, replayer);
expect(document.childNodes.length).toEqual(1);
expect(document.childNodes[0]).toBeInstanceOf(DocumentType);
Expand All @@ -142,7 +143,24 @@ describe('diff algorithm for rrdom', () => {
'-//W3C//DTD XHTML 1.0 Transitional//EN',
);
expect(document.doctype?.systemId).toEqual('');
expect(replayer.applyScroll).toBeCalledTimes(1);
expect(applyScrollFn).toHaveBeenCalledTimes(1);
applyScrollFn.mockRestore();
});

it('should apply scroll data on an element', () => {
const element = document.createElement('div');
const rrDocument = new RRDocument();
const rrNode = rrDocument.createElement('div');
rrNode.scrollData = {
source: IncrementalSource.Scroll,
id: 0,
x: 0,
y: 0,
};
const applyScrollFn = jest.spyOn(replayer, 'applyScroll');
diff(element, rrNode, replayer);
expect(applyScrollFn).toHaveBeenCalledTimes(1);
applyScrollFn.mockRestore();
});

it('should apply input data on an input element', () => {
Expand Down Expand Up @@ -1442,6 +1460,159 @@ describe('diff algorithm for rrdom', () => {
});
});

describe('afterAppend callback', () => {
it('should call afterAppend callback', () => {
const afterAppendFn = jest.spyOn(replayer, 'afterAppend');
const node = createTree(
{
tagName: 'div',
id: 1,
},
undefined,
mirror,
) as Node;

const rrdom = new RRDocument();
const rrNode = createTree(
{
tagName: 'div',
id: 1,
children: [
{
tagName: 'span',
id: 2,
},
],
},
rrdom,
) as RRNode;
diff(node, rrNode, replayer);
expect(afterAppendFn).toHaveBeenCalledTimes(1);
expect(afterAppendFn).toHaveBeenCalledWith(node.childNodes[0], 2);
afterAppendFn.mockRestore();
});

it('should diff without afterAppend callback', () => {
replayer.afterAppend = undefined;
const rrdom = buildFromDom(document);
document.open();
diff(document, rrdom, replayer);
replayer.afterAppend = () => {};
});

it('should call afterAppend callback in the post traversal order', () => {
const afterAppendFn = jest.spyOn(replayer, 'afterAppend');
document.open();

const rrdom = new RRDocument();
rrdom.mirror.add(rrdom, getDefaultSN(rrdom, 1));
const rrNode = createTree(
{
tagName: 'html',
id: 1,
children: [
{
tagName: 'head',
id: 2,
},
{
tagName: 'body',
id: 3,
children: [
{
tagName: 'span',
id: 4,
children: [
{
tagName: 'li',
id: 5,
},
{
tagName: 'li',
id: 6,
},
],
},
{
tagName: 'p',
id: 7,
},
{
tagName: 'p',
id: 8,
children: [
{
tagName: 'li',
id: 9,
},
{
tagName: 'li',
id: 10,
},
],
},
],
},
],
},
rrdom,
) as RRNode;
diff(document, rrNode, replayer);

expect(afterAppendFn).toHaveBeenCalledTimes(10);
// the correct traversal order
[2, 5, 6, 4, 7, 9, 10, 8, 3, 1].forEach((id, index) => {
expect((mirror.getNode(id) as HTMLElement).tagName).toEqual(
(rrdom.mirror.getNode(id) as IRRElement).tagName,
);
expect(afterAppendFn).toHaveBeenNthCalledWith(
index + 1,
mirror.getNode(id),
id,
);
});
});

it('should only call afterAppend for newly created nodes', () => {
const afterAppendFn = jest.spyOn(replayer, 'afterAppend');
const rrdom = buildFromDom(document, replayer.mirror) as RRDocument;

// Append 3 nodes to rrdom.
const rrNode = createTree(
{
tagName: 'span',
id: 1,
children: [
{
tagName: 'li',
id: 2,
},
{
tagName: 'li',
id: 3,
},
],
},
rrdom,
) as RRNode;
rrdom.body?.appendChild(rrNode);
diff(document, rrdom, replayer);
expect(afterAppendFn).toHaveBeenCalledTimes(3);
// Should only call afterAppend for 3 newly appended nodes.
[2, 3, 1].forEach((id, index) => {
expect((mirror.getNode(id) as HTMLElement).tagName).toEqual(
(rrdom.mirror.getNode(id) as IRRElement).tagName,
);
expect(afterAppendFn).toHaveBeenNthCalledWith(
index + 1,
mirror.getNode(id),
id,
);
});
afterAppendFn.mockClear();
});
});

describe('create or get a Node', () => {
it('create a real HTML element from RRElement', () => {
const rrDocument = new RRDocument();
Expand Down

0 comments on commit 8eaf2d3

Please sign in to comment.