Skip to content

fix(perf): benchmark processMutations/addList worst case performance #1300

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 5 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 31 additions & 71 deletions packages/rrweb/src/record/mutation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,17 +284,16 @@ export default class MutationBuffer {
}
return nextId;
};
const getParentId = (n: Node): number | null => {
if (!n.parentNode) return null;
return isShadowRoot(n.parentNode)
? this.mirror.getId(getShadowHost(n))
: this.mirror.getId(n.parentNode);
};
const pushAdd = (n: Node) => {
if (!n.parentNode || !inDom(n)) {
return;
}
const parentId = isShadowRoot(n.parentNode)
? this.mirror.getId(getShadowHost(n))
: this.mirror.getId(n.parentNode);
const nextId = getNextId(n);
if (parentId === -1 || nextId === -1) {
return addList.addNode(n);
}
const sn = serializeNodeWithId(n, {
doc: this.doc,
mirror: this.mirror,
Expand Down Expand Up @@ -334,12 +333,18 @@ export default class MutationBuffer {
},
});
if (sn) {
adds.push({
parentId,
nextId,
node: sn,
});
addedIds.add(sn.id);
const parentId = getParentId(n);
const nextId = getNextId(n);
if (parentId === -1 || nextId === -1) {
return addList.addNode(n);
} else if (parentId) {
adds.push({
parentId,
nextId,
node: sn,
});
addedIds.add(sn.id);
}
}
};

Expand Down Expand Up @@ -370,67 +375,22 @@ export default class MutationBuffer {
}
}

let candidate: DoubleLinkedListNode | null = null;
while (addList.length) {
let node: DoubleLinkedListNode | null = null;
if (candidate) {
const parentId = this.mirror.getId(candidate.value.parentNode);
const nextId = getNextId(candidate.value);
if (parentId !== -1 && nextId !== -1) {
node = candidate;
}
}
if (!node) {
let tailNode = addList.tail;
while (tailNode) {
const _node = tailNode;
tailNode = tailNode.previous;
// ensure _node is defined before attempting to find value
if (_node) {
const parentId = this.mirror.getId(_node.value.parentNode);
const nextId = getNextId(_node.value);

if (nextId === -1) continue;
// nextId !== -1 && parentId !== -1
else if (parentId !== -1) {
node = _node;
break;
}
// nextId !== -1 && parentId === -1 This branch can happen if the node is the child of shadow root
else {
const unhandledNode = _node.value;
// If the node is the direct child of a shadow root, we treat the shadow host as its parent node.
if (
unhandledNode.parentNode &&
unhandledNode.parentNode.nodeType ===
Node.DOCUMENT_FRAGMENT_NODE
) {
const shadowHost = (unhandledNode.parentNode as ShadowRoot)
.host;
const parentId = this.mirror.getId(shadowHost);
if (parentId !== -1) {
node = _node;
break;
}
}
}
}
}
}
if (!node) {
/**
* If all nodes in queue could not find a serialized parent,
* it may be a bug or corner case. We need to escape the
* dead while loop at once.
*/
while (addList.head) {
addList.removeNode(addList.head.value);
const current = addList.head as DoubleLinkedListNode;
const addedNode = current.value;
const sn = this.mirror.getMeta(addedNode);
if (sn) {
const parentId = getParentId(addedNode);
if (parentId) {
adds.push({
parentId,
nextId: getNextId(addedNode),
node: sn,
});
addedIds.add(sn.id);
}
break;
}
candidate = node.previous;
addList.removeNode(node.value);
pushAdd(node.value);
addList.removeNode(addedNode);
}

const payload = {
Expand Down
88 changes: 64 additions & 24 deletions packages/rrweb/src/replay/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,7 @@ import {
} from '@rrweb/types';
import {
polyfill,
queueToResolveTrees,
iterateResolveTree,
ResolveTree,
AppendedIframe,
getBaseDimension,
hasShadowRoot,
Expand Down Expand Up @@ -1448,7 +1447,6 @@ export class Replayer {
const legacy_missingNodeMap: missingNodeMap = {
...this.legacy_missingNodeRetryMap,
};
const queue: addedNodeMutation[] = [];

// next not present at this moment
const nextNotInDOM = (mutation: addedNodeMutation) => {
Expand All @@ -1468,7 +1466,37 @@ export class Replayer {
return false;
};

const appendNode = (mutation: addedNodeMutation) => {
const queue = new Set<ResolveTree>();
const idMap = new Map<number, ResolveTree>();

const getOrCreateNode = (nodeId: number) => {
let nodeInTree = idMap.get(nodeId);
if (!nodeInTree) {
nodeInTree = {
id: nodeId,
children: new Map<number | null, ResolveTree>(),
value: null,
};
idMap.set(nodeId, nodeInTree);
}
return nodeInTree;
};
const addToQueue = (mutation: addedNodeMutation) => {
const nodeInTree = getOrCreateNode(mutation.node.id);
nodeInTree.value = mutation;
const parentExists = idMap.has(mutation.parentId);
const parent = getOrCreateNode(mutation.parentId);
parent.children.set(mutation.nextId || null, nodeInTree);

if (queue.has(nodeInTree)) {
queue.delete(nodeInTree);
}
if (!queue.has(parent) && !parentExists) {
queue.add(parent);
}
};

const appendNode = (mutation: addedNodeMutation, allowQueue = true) => {
if (!this.iframe.contentDocument) {
return this.warn('Looks like your replayer has been destroyed.');
}
Expand All @@ -1480,7 +1508,7 @@ export class Replayer {
// is newly added document, maybe the document node of an iframe
return this.newDocumentQueue.push(mutation);
}
return queue.push(mutation);
return allowQueue ? addToQueue(mutation) : false;
}

if (mutation.node.isShadow) {
Expand All @@ -1500,7 +1528,7 @@ export class Replayer {
next = mirror.getNode(mutation.nextId);
}
if (nextNotInDOM(mutation)) {
return queue.push(mutation);
return allowQueue ? addToQueue(mutation) : false;
}

if (mutation.node.rootId && !mirror.getNode(mutation.node.rootId)) {
Expand Down Expand Up @@ -1659,32 +1687,44 @@ export class Replayer {
appendNode(mutation);
});

const iterateResolveTree = (
tree: ResolveTree,
mirror: RRDOMMirror | Mirror,
cb: (mutation: addedNodeMutation) => unknown,
) => {
if (tree.value) {
cb(tree.value);
}
let nextChild = tree.children.get(null);
if (!nextChild) {
const parentNode = mirror.getNode(tree.id);
if (parentNode && parentNode.firstChild) {
const nextId = mirror.getId(parentNode.firstChild as RRNode & Node);
nextChild = tree.children.get(nextId);
}
}
while (nextChild) {
iterateResolveTree(nextChild, mirror, cb);
nextChild = nextChild.value
? tree.children.get(nextChild.value.node.id)
: undefined;
}
};
const startTime = Date.now();
while (queue.length) {
// transform queue to resolve tree
const resolveTrees = queueToResolveTrees(queue);
queue.length = 0;
for (const tree of queue) {
iterateResolveTree(tree, mirror, (mutation: addedNodeMutation) => {
appendNode(mutation, false);
});
if (Date.now() - startTime > 500) {
this.warn(
'Timeout in the loop, please check the resolve tree data:',
resolveTrees,
queue,
);
break;
}
for (const tree of resolveTrees) {
const parent = mirror.getNode(tree.value.parentId);
if (!parent) {
this.debug(
'Drop resolve tree since there is no parent for the root node.',
tree,
);
} else {
iterateResolveTree(tree, (mutation) => {
appendNode(mutation);
});
}
}
}
queue.clear();
idMap.clear();

if (Object.keys(legacy_missingNodeMap).length) {
Object.assign(this.legacy_missingNodeRetryMap, legacy_missingNodeMap);
Expand Down
67 changes: 4 additions & 63 deletions packages/rrweb/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -341,71 +341,12 @@ export function polyfill(win = window) {
}
}

type ResolveTree = {
value: addedNodeMutation;
children: ResolveTree[];
parent: ResolveTree | null;
export type ResolveTree = {
id: number;
value: addedNodeMutation | null;
children: Map<number | null, ResolveTree>;
};

export function queueToResolveTrees(queue: addedNodeMutation[]): ResolveTree[] {
const queueNodeMap: Record<number, ResolveTree> = {};
const putIntoMap = (
m: addedNodeMutation,
parent: ResolveTree | null,
): ResolveTree => {
const nodeInTree: ResolveTree = {
value: m,
parent,
children: [],
};
queueNodeMap[m.node.id] = nodeInTree;
return nodeInTree;
};

const queueNodeTrees: ResolveTree[] = [];
for (const mutation of queue) {
const { nextId, parentId } = mutation;
if (nextId && nextId in queueNodeMap) {
const nextInTree = queueNodeMap[nextId];
if (nextInTree.parent) {
const idx = nextInTree.parent.children.indexOf(nextInTree);
nextInTree.parent.children.splice(
idx,
0,
putIntoMap(mutation, nextInTree.parent),
);
} else {
const idx = queueNodeTrees.indexOf(nextInTree);
queueNodeTrees.splice(idx, 0, putIntoMap(mutation, null));
}
continue;
}
if (parentId in queueNodeMap) {
const parentInTree = queueNodeMap[parentId];
parentInTree.children.push(putIntoMap(mutation, parentInTree));
continue;
}
queueNodeTrees.push(putIntoMap(mutation, null));
}

return queueNodeTrees;
}

export function iterateResolveTree(
tree: ResolveTree,
cb: (mutation: addedNodeMutation) => unknown,
) {
cb(tree.value);
/**
* The resolve tree was designed to reflect the DOM layout,
* but we need append next sibling first, so we do a reverse
* loop here.
*/
for (let i = tree.children.length - 1; i >= 0; i--) {
iterateResolveTree(tree.children[i], cb);
}
}

export type AppendedIframe = {
mutationInQueue: addedNodeMutation;
builtNode: HTMLIFrameElement | RRIFrameElement;
Expand Down
12 changes: 12 additions & 0 deletions packages/rrweb/test/benchmark/dom-mutation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,18 @@ const suites: Array<
eval: 'window.workload()',
times: 10,
},
{
title: 'add and reorder 10000 DOM nodes',
html: 'benchmark-dom-mutation-add-reorder.html',
eval: 'window.workload()',
times: 5,
},
{
title: 'add 5x2000 DOM nodes in random order',
html: 'benchmark-dom-mutation-random-add.html',
eval: 'window.workload()',
times: 5,
},
];

function avg(v: number[]): number {
Expand Down
33 changes: 33 additions & 0 deletions packages/rrweb/test/html/benchmark-dom-mutation-add-reorder.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<html>
<body>
<select id="select"></select>
</body>
<script>
window.workload = () => {
const optionCount = 10000;
const options = [];
for (let i = 0; i < optionCount; ++i) {
const value = `${i}`;
options.push({ value, label: value });
}
const frag = document.createDocumentFragment();
const select = document.getElementById('select');
const parent = select.parentNode;
parent.removeChild(select);
for (let o of options) {
const option = document.createElement('option');
option.value = o.value;
option.textContent = o.label;
o.optionElement = option;
frag.appendChild(option);
}
select.appendChild(frag);
parent.appendChild(select);
// re-shuffle the options
options.sort(() => Math.random() - 0.5);
for (var o of options) {
o.optionElement.parentNode.appendChild(o.optionElement);
}
};
</script>
</html>
Loading