This change include two critical fix for both recorder and replayer. In the recorder, previously we filter text and attribute mutations by check its target id, but this was a wrong approach because removed node also has id at the callback moment. We corrected this by checking whether the mirror map still has the target id in its keys. In the replayer side, the issue was we got exceptions when calling insertBefore which says the ref node was not the child node of the caller node. This will happen when the previous or next sibling has been removed in the same callback but the previousId or nextId was recorded. After apply remove node mutations before add node mutations, we can make sure the removed siblings will not exist in the mirror map when apply add node mutations. When we get node from mirror map with an removed id, we will get null and pass it to insertBefore which is valid. As a side note, this apply order is safe because we ensured all the remove node mutations do not include removing newly added nodes in the same callback.
385 lines
11 KiB
TypeScript
385 lines
11 KiB
TypeScript
import { INode, serializeNodeWithId } from 'rrweb-snapshot';
|
|
import {
|
|
mirror,
|
|
throttle,
|
|
on,
|
|
hookSetter,
|
|
getWindowHeight,
|
|
getWindowWidth,
|
|
} from '../utils';
|
|
import {
|
|
mutationCallBack,
|
|
removedNodeMutation,
|
|
addedNodeMutation,
|
|
observerParam,
|
|
mousemoveCallBack,
|
|
mousePosition,
|
|
mouseInteractionCallBack,
|
|
MouseInteractions,
|
|
listenerHandler,
|
|
scrollCallback,
|
|
viewportResizeCallback,
|
|
inputValue,
|
|
inputCallback,
|
|
hookResetter,
|
|
textCursor,
|
|
attributeCursor,
|
|
} from '../types';
|
|
|
|
/**
|
|
* 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): MutationObserver {
|
|
const observer = new MutationObserver(mutations => {
|
|
const texts: textCursor[] = [];
|
|
const attributes: attributeCursor[] = [];
|
|
let removes: removedNodeMutation[] = [];
|
|
const adds: addedNodeMutation[] = [];
|
|
const dropped: Node[] = [];
|
|
|
|
const addsSet = new Set<Node>();
|
|
const genAdds = (n: Node) => {
|
|
addsSet.add(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 (value !== oldValue) {
|
|
texts.push({
|
|
value,
|
|
node: target,
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
case 'attributes': {
|
|
const value = (target as HTMLElement).getAttribute(attributeName!);
|
|
if (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!] = value;
|
|
}
|
|
case 'childList': {
|
|
addedNodes.forEach(n => genAdds(n));
|
|
removedNodes.forEach(n => {
|
|
// removed node has not been serialized yet, just remove it from the Set
|
|
if (addsSet.has(n)) {
|
|
addsSet.delete(n);
|
|
dropped.push(n);
|
|
} else if (addsSet.has(target) && !mirror.getId(n as INode)) {
|
|
/**
|
|
* 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.
|
|
* TODO: verify this
|
|
*/
|
|
} else {
|
|
removes.push({
|
|
parentId: mirror.getId(target as INode),
|
|
id: mirror.getId(n as INode),
|
|
});
|
|
}
|
|
mirror.removeNodeFromMap(n as INode);
|
|
});
|
|
break;
|
|
}
|
|
default:
|
|
break;
|
|
}
|
|
});
|
|
|
|
removes = removes.map(remove => {
|
|
if (remove.parentNode) {
|
|
remove.parentId = mirror.getId(remove.parentNode as INode);
|
|
delete remove.parentNode;
|
|
}
|
|
return remove;
|
|
});
|
|
|
|
Array.from(addsSet).forEach(n => {
|
|
const parentId = mirror.getId(n.parentNode as INode);
|
|
if (
|
|
parentId &&
|
|
!dropped.some(d => d === n.parentNode) &&
|
|
!removes.some(r => r.id === parentId)
|
|
) {
|
|
adds.push({
|
|
parentId: mirror.getId(n.parentNode as INode),
|
|
previousId: !n.previousSibling
|
|
? n.previousSibling
|
|
: mirror.getId(n.previousSibling as INode),
|
|
nextId: !n.nextSibling
|
|
? n.nextSibling
|
|
: mirror.getId(n.nextSibling as INode),
|
|
node: serializeNodeWithId(n, document, mirror.map, true)!,
|
|
});
|
|
} else {
|
|
dropped.push(n);
|
|
}
|
|
});
|
|
|
|
cb({
|
|
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,
|
|
});
|
|
});
|
|
observer.observe(document, {
|
|
attributes: true,
|
|
attributeOldValue: true,
|
|
characterData: true,
|
|
characterDataOldValue: true,
|
|
childList: true,
|
|
subtree: true,
|
|
});
|
|
return observer;
|
|
}
|
|
|
|
function initMousemoveObserver(cb: mousemoveCallBack): listenerHandler {
|
|
let positions: mousePosition[] = [];
|
|
let timeBaseline: number | null;
|
|
const wrappedCb = throttle(() => {
|
|
const totalOffset = Date.now() - timeBaseline!;
|
|
cb(
|
|
positions.map(p => {
|
|
p.timeOffset -= totalOffset;
|
|
return p;
|
|
}),
|
|
);
|
|
positions = [];
|
|
timeBaseline = null;
|
|
}, 500);
|
|
const updatePosition = throttle<MouseEvent>(
|
|
evt => {
|
|
const { clientX, clientY } = evt;
|
|
if (!timeBaseline) {
|
|
timeBaseline = Date.now();
|
|
}
|
|
positions.push({
|
|
x: clientX,
|
|
y: clientY,
|
|
timeOffset: Date.now() - timeBaseline,
|
|
});
|
|
wrappedCb();
|
|
},
|
|
20,
|
|
{
|
|
trailing: false,
|
|
},
|
|
);
|
|
return on('mousemove', updatePosition);
|
|
}
|
|
|
|
function initMouseInteractionObserver(
|
|
cb: mouseInteractionCallBack,
|
|
): listenerHandler {
|
|
const handlers: listenerHandler[] = [];
|
|
const getHandler = (eventKey: keyof typeof MouseInteractions) => {
|
|
return (event: MouseEvent) => {
|
|
const id = mirror.getId(event.target as INode);
|
|
const { clientX, clientY } = event;
|
|
cb({
|
|
type: MouseInteractions[eventKey],
|
|
id,
|
|
x: clientX,
|
|
y: clientY,
|
|
});
|
|
};
|
|
};
|
|
Object.keys(MouseInteractions)
|
|
.filter(key => Number.isNaN(Number(key)))
|
|
.forEach((eventKey: keyof typeof MouseInteractions) => {
|
|
const eventName = eventKey.toLowerCase();
|
|
const handler = getHandler(eventKey);
|
|
handlers.push(on(eventName, handler));
|
|
});
|
|
return () => {
|
|
handlers.forEach(h => h());
|
|
};
|
|
}
|
|
|
|
function initScrollObserver(cb: scrollCallback): listenerHandler {
|
|
const updatePosition = throttle<UIEvent>(evt => {
|
|
if (!evt.target) {
|
|
return;
|
|
}
|
|
const id = mirror.getId(evt.target as INode);
|
|
if (evt.target === document) {
|
|
cb({
|
|
id,
|
|
x: document.documentElement.scrollLeft,
|
|
y: document.documentElement.scrollTop,
|
|
});
|
|
} else {
|
|
cb({
|
|
id,
|
|
x: (evt.target as HTMLElement).scrollLeft,
|
|
y: (evt.target as HTMLElement).scrollTop,
|
|
});
|
|
}
|
|
}, 100);
|
|
return on('scroll', updatePosition);
|
|
}
|
|
|
|
function initViewportResizeObserver(
|
|
cb: viewportResizeCallback,
|
|
): listenerHandler {
|
|
const updateDimension = throttle(() => {
|
|
const height = getWindowHeight();
|
|
const width = getWindowWidth();
|
|
cb({
|
|
width: Number(width),
|
|
height: Number(height),
|
|
});
|
|
}, 200);
|
|
return on('resize', updateDimension, window);
|
|
}
|
|
|
|
const INPUT_TAGS = ['INPUT', 'TEXTAREA', 'SELECT'];
|
|
const HOOK_PROPERTIES: Array<[HTMLElement, string]> = [
|
|
[HTMLInputElement.prototype, 'value'],
|
|
[HTMLInputElement.prototype, 'checked'],
|
|
[HTMLSelectElement.prototype, 'value'],
|
|
[HTMLTextAreaElement.prototype, 'value'],
|
|
];
|
|
const lastInputValueMap: WeakMap<EventTarget, inputValue> = new WeakMap();
|
|
function initInputObserver(cb: inputCallback): listenerHandler {
|
|
function eventHandler(event: Event) {
|
|
const { target } = event;
|
|
if (
|
|
!target ||
|
|
!(target as Element).tagName ||
|
|
INPUT_TAGS.indexOf((target as Element).tagName) < 0
|
|
) {
|
|
return;
|
|
}
|
|
const type: string | undefined = (target as HTMLInputElement).type;
|
|
const text = (target as HTMLInputElement).value;
|
|
let isChecked = false;
|
|
if (type === 'radio' || type === 'checkbox') {
|
|
isChecked = (target as HTMLInputElement).checked;
|
|
}
|
|
cbWithDedup(target, { text, isChecked });
|
|
// if a radio was checked
|
|
// the other radios with the same name attribute will be unchecked.
|
|
const name: string | undefined = (target as HTMLInputElement).name;
|
|
if (type === 'radio' && name && isChecked) {
|
|
document
|
|
.querySelectorAll(`input[type="radio"][name="${name}"]`)
|
|
.forEach(el => {
|
|
if (el !== target) {
|
|
cbWithDedup(el, {
|
|
text: (el as HTMLInputElement).value,
|
|
isChecked: !isChecked,
|
|
});
|
|
}
|
|
});
|
|
}
|
|
}
|
|
function cbWithDedup(target: EventTarget, v: inputValue) {
|
|
const lastInputValue = lastInputValueMap.get(target);
|
|
if (
|
|
!lastInputValue ||
|
|
lastInputValue.text !== v.text ||
|
|
lastInputValue.isChecked !== v.isChecked
|
|
) {
|
|
lastInputValueMap.set(target, v);
|
|
const id = mirror.getId(target as INode);
|
|
cb({
|
|
...v,
|
|
id,
|
|
});
|
|
}
|
|
}
|
|
const handlers: Array<listenerHandler | hookResetter> = [
|
|
'input',
|
|
'change',
|
|
].map(eventName => on(eventName, eventHandler));
|
|
const propertyDescriptor = Object.getOwnPropertyDescriptor(
|
|
HTMLInputElement.prototype,
|
|
'value',
|
|
);
|
|
if (propertyDescriptor && propertyDescriptor.set) {
|
|
handlers.push(
|
|
...HOOK_PROPERTIES.map(p =>
|
|
hookSetter<HTMLElement>(p[0], p[1], {
|
|
set() {
|
|
// mock to a normal event
|
|
eventHandler({ target: this } as Event);
|
|
},
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
return () => {
|
|
handlers.forEach(h => h());
|
|
};
|
|
}
|
|
|
|
export default function initObservers(o: observerParam) {
|
|
const mutationObserver = initMutationObserver(o.mutationCb);
|
|
const mousemoveHandler = initMousemoveObserver(o.mousemoveCb);
|
|
const mouseInteractionHandler = initMouseInteractionObserver(
|
|
o.mouseInteractionCb,
|
|
);
|
|
const scrollHandler = initScrollObserver(o.scrollCb);
|
|
const viewportResizeHandler = initViewportResizeObserver(o.viewportResizeCb);
|
|
const inputHandler = initInputObserver(o.inputCb);
|
|
return {
|
|
mutationObserver,
|
|
mousemoveHandler,
|
|
mouseInteractionHandler,
|
|
scrollHandler,
|
|
viewportResizeHandler,
|
|
inputHandler,
|
|
};
|
|
}
|