Move mutation processing into it's own class (#223)

* Move mutation processing into it's own object.

This should stand on it's own as a refactor, but is intended as a basis
for exposing the new MutationBuffer object to further outside control e.g.
to 'mute' or batch up mutation emission when the page becomes inactive
from a https://developer.mozilla.org/en-US/docs/Web/API/Page_Visibility_API
point of view

* The `processMutations` function needed to be bound to the `mutationBuffer` object, as otherwise `this` referred to the `MutationObserver` object itself

* Neglected to add this output of `npm run typings`

* Get around the binding problem by using Arrow function expressions

* Prettier formatting
This commit is contained in:
Eoghan Murray
2026-04-01 12:00:00 +08:00
committed by GitHub
parent 06024b245f
commit d57cae3d51
6 changed files with 357 additions and 275 deletions

View File

@@ -1,41 +0,0 @@
/**
* Some utils to handle the mutation observer DOM records.
* It should be more clear to extend the native data structure
* like Set and Map, but currently Typescript does not support
* that.
*/
import { INode } from 'rrweb-snapshot';
import { removedNodeMutation } from '../types';
import { mirror } from '../utils';
export function deepDelete(addsSet: Set<Node>, n: Node) {
addsSet.delete(n);
n.childNodes.forEach(childN => deepDelete(addsSet, childN));
}
export function isParentRemoved(
removes: removedNodeMutation[],
n: Node,
): boolean {
const { parentNode } = n;
if (!parentNode) {
return false;
}
const parentId = mirror.getId((parentNode as Node) as INode);
if (removes.some(r => r.id === parentId)) {
return true;
}
return isParentRemoved(removes, parentNode);
}
export function isAncestorInSet(set: Set<Node>, n: Node): boolean {
const { parentNode } = n;
if (!parentNode) {
return false;
}
if (set.has(parentNode)) {
return true;
}
return isAncestorInSet(set, parentNode);
}

309
src/record/mutation.ts Normal file
View File

@@ -0,0 +1,309 @@
import { INode, serializeNodeWithId, transformAttribute } from 'rrweb-snapshot';
import {
mutationRecord,
blockClass,
mutationCallBack,
textCursor,
attributeCursor,
removedNodeMutation,
addedNodeMutation,
} from '../types';
import { mirror, isBlocked, isAncestorRemoved } from '../utils';
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
*/
export default class MutationBuffer {
private texts: textCursor[] = [];
private attributes: attributeCursor[] = [];
private removes: removedNodeMutation[] = [];
private adds: addedNodeMutation[] = [];
private movedMap: Record<string, true> = {};
/**
* the browser MutationObserver emits multiple mutations after
* a delay for performance reasons, making tracing added nodes hard
* in our `processMutations` callback function.
* For example, if we append an element el_1 into body, and then append
* another element el_2 into el_1, these two mutations may be passed to the
* callback function together when the two operations were done.
* Generally we need to trace child nodes of newly added nodes, but in this
* case if we count el_2 as el_1's child node in the first mutation record,
* then we will count el_2 again in the second mutation record which was
* duplicated.
* To avoid of duplicate counting added nodes, we use a Set to store
* added nodes and its child nodes during iterate mutation records. Then
* collect added nodes from the Set which have no duplicate copy. But
* this also causes newly added nodes will not be serialized with id ASAP,
* which means all the id related calculation should be lazy too.
*/
private addedSet = new Set<Node>();
private movedSet = new Set<Node>();
private droppedSet = new Set<Node>();
private emissionCallback: mutationCallBack;
private blockClass: blockClass;
private inlineStylesheet: boolean;
private maskAllInputs: boolean;
constructor(
cb: mutationCallBack,
blockClass: blockClass,
inlineStylesheet: boolean,
maskAllInputs: boolean,
) {
this.blockClass = blockClass;
this.inlineStylesheet = inlineStylesheet;
this.maskAllInputs = maskAllInputs;
this.emissionCallback = cb;
}
public processMutations = (mutations: mutationRecord[]) => {
mutations.forEach(this.processMutation);
/**
* Sometimes child node may be pushed before its newly added
* parent, so we init a queue to store these nodes.
*/
const addQueue: Node[] = [];
const pushAdd = (n: Node) => {
const parentId = mirror.getId((n.parentNode as Node) as INode);
const nextId =
n.nextSibling && mirror.getId((n.nextSibling as unknown) as INode);
if (parentId === -1 || nextId === -1) {
return addQueue.push(n);
}
this.adds.push({
parentId,
nextId,
node: serializeNodeWithId(
n,
document,
mirror.map,
this.blockClass,
true,
this.inlineStylesheet,
this.maskAllInputs,
)!,
});
};
for (const n of this.movedSet) {
pushAdd(n);
}
for (const n of this.addedSet) {
if (
!isAncestorInSet(this.droppedSet, n) &&
!isParentRemoved(this.removes, n)
) {
pushAdd(n);
} else if (isAncestorInSet(this.movedSet, n)) {
pushAdd(n);
} else {
this.droppedSet.add(n);
}
}
while (addQueue.length) {
if (
addQueue.every(
(n) => mirror.getId((n.parentNode as Node) as INode) === -1,
)
) {
/**
* 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.
*/
break;
}
pushAdd(addQueue.shift()!);
}
this.emit();
};
private processMutation = (m: mutationRecord) => {
switch (m.type) {
case 'characterData': {
const value = m.target.textContent;
if (!isBlocked(m.target, this.blockClass) && value !== m.oldValue) {
this.texts.push({
value,
node: m.target,
});
}
break;
}
case 'attributes': {
const value = (m.target as HTMLElement).getAttribute(m.attributeName!);
if (isBlocked(m.target, this.blockClass) || value === m.oldValue) {
return;
}
let item: attributeCursor | undefined = this.attributes.find(
(a) => a.node === m.target,
);
if (!item) {
item = {
node: m.target,
attributes: {},
};
this.attributes.push(item);
}
// overwrite attribute if the mutations was triggered in same time
item.attributes[m.attributeName!] = transformAttribute(
document,
m.attributeName!,
value!,
);
break;
}
case 'childList': {
m.addedNodes.forEach((n) => this.genAdds(n, m.target));
m.removedNodes.forEach((n) => {
const nodeId = mirror.getId(n as INode);
const parentId = mirror.getId(m.target as INode);
if (isBlocked(n, this.blockClass)) {
return;
}
// removed node has not been serialized yet, just remove it from the Set
if (this.addedSet.has(n)) {
deepDelete(this.addedSet, n);
this.droppedSet.add(n);
} else if (this.addedSet.has(m.target) && nodeId === -1) {
/**
* If target was newly added and removed child node was
* not serialized, it means the child node has been removed
* before callback fired, so we can ignore it because
* newly added node will be serialized without child nodes.
* TODO: verify this
*/
} else if (isAncestorRemoved(m.target as INode)) {
/**
* If parent id was not in the mirror map any more, it
* means the parent node has already been removed. So
* the node is also removed which we do not need to track
* and replay.
*/
} else if (
this.movedSet.has(n) &&
this.movedMap[moveKey(nodeId, parentId)]
) {
deepDelete(this.movedSet, n);
} else {
this.removes.push({
parentId,
id: nodeId,
});
}
mirror.removeNodeFromMap(n as INode);
});
break;
}
default:
break;
}
};
private genAdds = (n: Node | INode, target?: Node | INode) => {
if (isBlocked(n, this.blockClass)) {
return;
}
if (isINode(n)) {
this.movedSet.add(n);
let targetId: number | null = null;
if (target && isINode(target)) {
targetId = target.__sn.id;
}
if (targetId) {
this.movedMap[moveKey(n.__sn.id, targetId)] = true;
}
} else {
this.addedSet.add(n);
this.droppedSet.delete(n);
}
n.childNodes.forEach((childN) => this.genAdds(childN));
};
public emit = () => {
const payload = {
texts: this.texts
.map((text) => ({
id: mirror.getId(text.node as INode),
value: text.value,
}))
// text mutation's id was not in the mirror map means the target node has been removed
.filter((text) => mirror.has(text.id)),
attributes: this.attributes
.map((attribute) => ({
id: mirror.getId(attribute.node as INode),
attributes: attribute.attributes,
}))
// attribute mutation's id was not in the mirror map means the target node has been removed
.filter((attribute) => mirror.has(attribute.id)),
removes: this.removes,
adds: this.adds,
};
// payload may be empty if the mutations happened in some blocked elements
if (
!payload.texts.length &&
!payload.attributes.length &&
!payload.removes.length &&
!payload.adds.length
) {
return;
}
this.emissionCallback(payload);
// reset
this.texts = [];
this.attributes = [];
this.removes = [];
this.adds = [];
this.addedSet = new Set<Node>();
this.movedSet = new Set<Node>();
this.droppedSet = new Set<Node>();
this.movedMap = {};
};
}
/**
* Some utils to handle the mutation observer DOM records.
* It should be more clear to extend the native data structure
* like Set and Map, but currently Typescript does not support
* that.
*/
function deepDelete(addsSet: Set<Node>, n: Node) {
addsSet.delete(n);
n.childNodes.forEach((childN) => deepDelete(addsSet, childN));
}
function isParentRemoved(removes: removedNodeMutation[], n: Node): boolean {
const { parentNode } = n;
if (!parentNode) {
return false;
}
const parentId = mirror.getId((parentNode as Node) as INode);
if (removes.some((r) => r.id === parentId)) {
return true;
}
return isParentRemoved(removes, parentNode);
}
function isAncestorInSet(set: Set<Node>, n: Node): boolean {
const { parentNode } = n;
if (!parentNode) {
return false;
}
if (set.has(parentNode)) {
return true;
}
return isAncestorInSet(set, parentNode);
}

View File

@@ -1,4 +1,4 @@
import { INode, serializeNodeWithId, transformAttribute } from 'rrweb-snapshot';
import { INode } from 'rrweb-snapshot';
import {
mirror,
throttle,
@@ -7,13 +7,10 @@ import {
getWindowHeight,
getWindowWidth,
isBlocked,
isAncestorRemoved,
isTouchEvent,
} from '../utils';
import {
mutationCallBack,
removedNodeMutation,
addedNodeMutation,
observerParam,
mousemoveCallBack,
mousePosition,
@@ -26,8 +23,6 @@ import {
inputValue,
inputCallback,
hookResetter,
textCursor,
attributeCursor,
blockClass,
IncrementalSource,
hooksParam,
@@ -35,241 +30,22 @@ import {
mediaInteractionCallback,
MediaInteractions,
} from '../types';
import { deepDelete, isParentRemoved, isAncestorInSet } from './collection';
import MutationBuffer from './mutation';
const moveKey = (id: number, parentId: number) => `${id}@${parentId}`;
function isINode(n: Node | INode): n is INode {
return '__sn' in n;
}
/**
* Mutation observer will merge several mutations into an array and pass
* it to the callback function, this may make tracing added nodes hard.
* For example, if we append an element el_1 into body, and then append
* another element el_2 into el_1, these two mutations may be passed to the
* callback function together when the two operations were done.
* Generally we need trace child nodes of newly added node, but in this
* case if we count el_2 as el_1's child node in the first mutation record,
* then we will count el_2 again in the secoond mutation record which was
* duplicated.
* To avoid of duplicate counting added nodes, we will use a Set to store
* added nodes and its child nodes during iterate mutation records. Then
* collect added nodes from the Set which will has no duplicate copy. But
* this also cause newly added node will not be serialized with id ASAP,
* which means all the id related calculation should be lazy too.
* @param cb mutationCallBack
*/
function initMutationObserver(
cb: mutationCallBack,
blockClass: blockClass,
inlineStylesheet: boolean,
maskAllInputs: boolean,
): MutationObserver {
const observer = new MutationObserver((mutations) => {
const texts: textCursor[] = [];
const attributes: attributeCursor[] = [];
let removes: removedNodeMutation[] = [];
const adds: addedNodeMutation[] = [];
const addedSet = new Set<Node>();
const movedSet = new Set<Node>();
const droppedSet = new Set<Node>();
const movedMap: Record<string, true> = {};
const genAdds = (n: Node | INode, target?: Node | INode) => {
if (isBlocked(n, blockClass)) {
return;
}
if (isINode(n)) {
movedSet.add(n);
let targetId: number | null = null;
if (target && isINode(target)) {
targetId = target.__sn.id;
}
if (targetId) {
movedMap[moveKey(n.__sn.id, targetId)] = true;
}
} else {
addedSet.add(n);
droppedSet.delete(n);
}
n.childNodes.forEach((childN) => genAdds(childN));
};
mutations.forEach((mutation) => {
const {
type,
target,
oldValue,
addedNodes,
removedNodes,
attributeName,
} = mutation;
switch (type) {
case 'characterData': {
const value = target.textContent;
if (!isBlocked(target, blockClass) && value !== oldValue) {
texts.push({
value,
node: target,
});
}
break;
}
case 'attributes': {
const value = (target as HTMLElement).getAttribute(attributeName!);
if (isBlocked(target, blockClass) || value === oldValue) {
return;
}
let item: attributeCursor | undefined = attributes.find(
(a) => a.node === target,
);
if (!item) {
item = {
node: target,
attributes: {},
};
attributes.push(item);
}
// overwrite attribute if the mutations was triggered in same time
item.attributes[attributeName!] = transformAttribute(
document,
attributeName!,
value!,
);
break;
}
case 'childList': {
addedNodes.forEach((n) => genAdds(n, target));
removedNodes.forEach((n) => {
const nodeId = mirror.getId(n as INode);
const parentId = mirror.getId(target as INode);
if (isBlocked(n, blockClass)) {
return;
}
// removed node has not been serialized yet, just remove it from the Set
if (addedSet.has(n)) {
deepDelete(addedSet, n);
droppedSet.add(n);
} else if (addedSet.has(target) && nodeId === -1) {
/**
* If target was newly added and removed child node was
* not serialized, it means the child node has been removed
* before callback fired, so we can ignore it because
* newly added node will be serialized without child nodes.
* TODO: verify this
*/
} else if (isAncestorRemoved(target as INode)) {
/**
* If parent id was not in the mirror map any more, it
* means the parent node has already been removed. So
* the node is also removed which we do not need to track
* and replay.
*/
} else if (movedSet.has(n) && movedMap[moveKey(nodeId, parentId)]) {
deepDelete(movedSet, n);
} else {
removes.push({
parentId,
id: nodeId,
});
}
mirror.removeNodeFromMap(n as INode);
});
break;
}
default:
break;
}
});
/**
* Sometimes child node may be pushed before its newly added
* parent, so we init a queue to store these nodes.
*/
const addQueue: Node[] = [];
const pushAdd = (n: Node) => {
const parentId = mirror.getId((n.parentNode as Node) as INode);
const nextId =
n.nextSibling && mirror.getId((n.nextSibling as unknown) as INode);
if (parentId === -1 || nextId === -1) {
return addQueue.push(n);
}
adds.push({
parentId,
nextId,
node: serializeNodeWithId(
n,
document,
mirror.map,
blockClass,
true,
inlineStylesheet,
maskAllInputs,
)!,
});
};
for (const n of movedSet) {
pushAdd(n);
}
for (const n of addedSet) {
if (!isAncestorInSet(droppedSet, n) && !isParentRemoved(removes, n)) {
pushAdd(n);
} else if (isAncestorInSet(movedSet, n)) {
pushAdd(n);
} else {
droppedSet.add(n);
}
}
while (addQueue.length) {
if (
addQueue.every(
(n) => mirror.getId((n.parentNode as Node) as INode) === -1,
)
) {
/**
* 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.
*/
break;
}
pushAdd(addQueue.shift()!);
}
const payload = {
texts: texts
.map((text) => ({
id: mirror.getId(text.node as INode),
value: text.value,
}))
// text mutation's id was not in the mirror map means the target node has been removed
.filter((text) => mirror.has(text.id)),
attributes: attributes
.map((attribute) => ({
id: mirror.getId(attribute.node as INode),
attributes: attribute.attributes,
}))
// attribute mutation's id was not in the mirror map means the target node has been removed
.filter((attribute) => mirror.has(attribute.id)),
removes,
adds,
};
// payload may be empty if the mutations happened in some blocked elements
if (
!payload.texts.length &&
!payload.attributes.length &&
!payload.removes.length &&
!payload.adds.length
) {
return;
}
cb(payload);
});
// see mutation.ts for details
const mutationBuffer = new MutationBuffer(
cb,
blockClass,
inlineStylesheet,
maskAllInputs,
);
const observer = new MutationObserver(mutationBuffer.processMutations);
observer.observe(document, {
attributes: true,
attributeOldValue: true,

View File

@@ -166,6 +166,16 @@ export type hooksParam = {
styleSheetRule?: styleSheetRuleCallback;
};
// https://dom.spec.whatwg.org/#interface-mutationrecord
export type mutationRecord = {
type: string,
target: Node,
oldValue: string | null,
addedNodes: NodeList,
removedNodes: NodeList,
attributeName: string | null,
}
export type textCursor = {
node: Node;
value: string | null;

20
typings/record/mutation.d.ts vendored Normal file
View File

@@ -0,0 +1,20 @@
import { mutationRecord, blockClass, mutationCallBack } from '../types';
export default class MutationBuffer {
private texts;
private attributes;
private removes;
private adds;
private movedMap;
private addedSet;
private movedSet;
private droppedSet;
private emissionCallback;
private blockClass;
private inlineStylesheet;
private maskAllInputs;
constructor(cb: mutationCallBack, blockClass: blockClass, inlineStylesheet: boolean, maskAllInputs: boolean);
processMutations(mutations: mutationRecord[]): void;
private processMutation;
private genAdds;
emit(): void;
}

8
typings/types.d.ts vendored
View File

@@ -127,6 +127,14 @@ export declare type hooksParam = {
mediaInteaction?: mediaInteractionCallback;
styleSheetRule?: styleSheetRuleCallback;
};
export declare type mutationRecord = {
type: string;
target: Node;
oldValue: string | null;
addedNodes: NodeList;
removedNodes: NodeList;
attributeName: string | null;
};
export declare type textCursor = {
node: Node;
value: string | null;