create mirror during record

This commit is contained in:
Yanzhen Yu
2026-04-01 12:00:00 +08:00
parent e9784a20cb
commit b693d667b5
16 changed files with 105 additions and 46 deletions

View File

@@ -1,6 +1,6 @@
import record from './record';
import { Replayer } from './replay';
import { mirror } from './utils';
import { _mirror } from './utils';
import * as utils from './utils';
export {
@@ -13,4 +13,11 @@ export {
const { addCustomEvent } = record;
const { freezePage } = record;
export { record, addCustomEvent, freezePage, Replayer, mirror, utils };
export {
record,
addCustomEvent,
freezePage,
Replayer,
_mirror as mirror,
utils,
};

View File

@@ -1,13 +1,13 @@
import { snapshot, MaskInputOptions, SlimDOMOptions } from 'rrweb-snapshot';
import { initObservers, mutationBuffers } from './observer';
import {
mirror,
on,
getWindowWidth,
getWindowHeight,
polyfill,
isIframeINode,
hasShadowRoot,
createMirror,
} from '../utils';
import {
EventType,
@@ -33,6 +33,7 @@ let wrappedEmit!: (e: eventWithTime, isCheckout?: boolean) => void;
let takeFullSnapshot!: (isCheckout?: boolean) => void;
const mirror = createMirror();
function record<T = eventWithTime>(
options: recordOptions<T> = {},
): listenerHandler | undefined {
@@ -215,6 +216,7 @@ function record<T = eventWithTime>(
slimDOMOptions,
iframeManager,
},
mirror,
});
takeFullSnapshot = (isCheckout = false) => {
@@ -418,6 +420,7 @@ function record<T = eventWithTime>(
logOptions,
blockSelector,
slimDOMOptions,
mirror,
iframeManager,
shadowDomManager,
},
@@ -490,4 +493,6 @@ record.takeFullSnapshot = (isCheckout?: boolean) => {
takeFullSnapshot(isCheckout);
};
record.mirror = mirror;
export default record;

View File

@@ -18,9 +18,9 @@ import {
removedNodeMutation,
addedNodeMutation,
MaskTextFn,
Mirror,
} from '../types';
import {
mirror,
isBlocked,
isAncestorRemoved,
isIgnored,
@@ -171,6 +171,7 @@ export default class MutationBuffer {
private slimDOMOptions: SlimDOMOptions;
private doc: Document;
private mirror: Mirror;
private iframeManager: IframeManager;
private shadowDomManager: ShadowDomManager;
@@ -186,6 +187,7 @@ export default class MutationBuffer {
recordCanvas: boolean,
slimDOMOptions: SlimDOMOptions,
doc: Document,
mirror: Mirror,
iframeManager: IframeManager,
shadowDomManager: ShadowDomManager,
) {
@@ -200,6 +202,7 @@ export default class MutationBuffer {
this.slimDOMOptions = slimDOMOptions;
this.emissionCallback = cb;
this.doc = doc;
this.mirror = mirror;
this.iframeManager = iframeManager;
this.shadowDomManager = shadowDomManager;
}
@@ -251,7 +254,7 @@ export default class MutationBuffer {
let nextId: number | null = IGNORED_NODE; // slimDOM: ignored
while (nextId === IGNORED_NODE) {
ns = ns && ns.nextSibling;
nextId = ns && mirror.getId((ns as unknown) as INode);
nextId = ns && this.mirror.getId((ns as unknown) as INode);
}
if (nextId === -1 && isBlocked(n.nextSibling, this.blockClass)) {
nextId = null;
@@ -267,15 +270,15 @@ export default class MutationBuffer {
return;
}
const parentId = isShadowRoot(n.parentNode)
? mirror.getId((shadowHost as unknown) as INode)
: mirror.getId((n.parentNode as Node) as INode);
? this.mirror.getId((shadowHost as unknown) as INode)
: this.mirror.getId((n.parentNode as Node) as INode);
const nextId = getNextId(n);
if (parentId === -1 || nextId === -1) {
return addList.addNode(n);
}
let sn = serializeNodeWithId(n, {
doc: this.doc,
map: mirror.map,
map: this.mirror.map,
blockClass: this.blockClass,
blockSelector: this.blockSelector,
maskTextClass: this.maskTextClass,
@@ -308,7 +311,7 @@ export default class MutationBuffer {
};
while (this.mapRemoves.length) {
mirror.removeNodeFromMap(this.mapRemoves.shift() as INode);
this.mirror.removeNodeFromMap(this.mapRemoves.shift() as INode);
}
for (const n of this.movedSet) {
@@ -338,7 +341,7 @@ export default class MutationBuffer {
while (addList.length) {
let node: DoubleLinkedListNode | null = null;
if (candidate) {
const parentId = mirror.getId(
const parentId = this.mirror.getId(
(candidate.value.parentNode as Node) as INode,
);
const nextId = getNextId(candidate.value);
@@ -349,7 +352,7 @@ export default class MutationBuffer {
if (!node) {
for (let index = addList.length - 1; index >= 0; index--) {
const _node = addList.get(index)!;
const parentId = mirror.getId(
const parentId = this.mirror.getId(
(_node.value.parentNode as Node) as INode,
);
const nextId = getNextId(_node.value);
@@ -378,18 +381,18 @@ export default class MutationBuffer {
const payload = {
texts: this.texts
.map((text) => ({
id: mirror.getId(text.node as INode),
id: this.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)),
.filter((text) => this.mirror.has(text.id)),
attributes: this.attributes
.map((attribute) => ({
id: mirror.getId(attribute.node as INode),
id: this.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)),
.filter((attribute) => this.mirror.has(attribute.id)),
removes: this.removes,
adds,
};
@@ -466,10 +469,10 @@ export default class MutationBuffer {
case 'childList': {
m.addedNodes.forEach((n) => this.genAdds(n, m.target));
m.removedNodes.forEach((n) => {
const nodeId = mirror.getId(n as INode);
const nodeId = this.mirror.getId(n as INode);
const parentId = isShadowRoot(m.target)
? mirror.getId((m.target.host as unknown) as INode)
: mirror.getId(m.target as INode);
? this.mirror.getId((m.target.host as unknown) as INode)
: this.mirror.getId(m.target as INode);
if (
isBlocked(n, this.blockClass) ||
isBlocked(m.target, this.blockClass) ||
@@ -489,7 +492,7 @@ export default class MutationBuffer {
* newly added node will be serialized without child nodes.
* TODO: verify this
*/
} else if (isAncestorRemoved(m.target as INode)) {
} else if (isAncestorRemoved(m.target as INode, this.mirror)) {
/**
* If parent id was not in the mirror map any more, it
* means the parent node has already been removed. So
@@ -560,7 +563,7 @@ function isParentRemoved(removes: removedNodeMutation[], n: Node): boolean {
if (!parentNode) {
return false;
}
const parentId = mirror.getId((parentNode as Node) as INode);
const parentId = this.mirror.getId((parentNode as Node) as INode);
if (removes.some((r) => r.id === parentId)) {
return true;
}

View File

@@ -1,7 +1,6 @@
import { INode, MaskInputOptions, SlimDOMOptions } from 'rrweb-snapshot';
import { FontFaceDescriptors, FontFaceSet } from 'css-font-loading-module';
import {
mirror,
throttle,
on,
hookSetter,
@@ -42,6 +41,7 @@ import {
LogRecordOptions,
Logger,
LogLevel,
Mirror,
} from '../types';
import MutationBuffer from './mutation';
import { stringify } from './stringify';
@@ -72,6 +72,7 @@ export function initMutationObserver(
maskTextFn: MaskTextFn | undefined,
recordCanvas: boolean,
slimDOMOptions: SlimDOMOptions,
mirror: Mirror,
iframeManager: IframeManager,
shadowDomManager: ShadowDomManager,
rootEl: Node,
@@ -91,6 +92,7 @@ export function initMutationObserver(
recordCanvas,
slimDOMOptions,
doc,
mirror,
iframeManager,
shadowDomManager,
);
@@ -137,6 +139,7 @@ function initMoveObserver(
cb: mousemoveCallBack,
sampling: SamplingStrategy,
doc: Document,
mirror: Mirror,
): listenerHandler {
if (sampling.mousemove === false) {
return () => {};
@@ -212,6 +215,7 @@ function initMoveObserver(
function initMouseInteractionObserver(
cb: mouseInteractionCallBack,
doc: Document,
mirror: Mirror,
blockClass: blockClass,
sampling: SamplingStrategy,
): listenerHandler {
@@ -264,6 +268,7 @@ function initMouseInteractionObserver(
function initScrollObserver(
cb: scrollCallback,
doc: Document,
mirror: Mirror,
blockClass: blockClass,
sampling: SamplingStrategy,
): listenerHandler {
@@ -315,6 +320,7 @@ const lastInputValueMap: WeakMap<EventTarget, inputValue> = new WeakMap();
function initInputObserver(
cb: inputCallback,
doc: Document,
mirror: Mirror,
blockClass: blockClass,
ignoreClass: string,
maskInputOptions: MaskInputOptions,
@@ -419,7 +425,10 @@ function initInputObserver(
};
}
function initStyleSheetObserver(cb: styleSheetRuleCallback): listenerHandler {
function initStyleSheetObserver(
cb: styleSheetRuleCallback,
mirror: Mirror,
): listenerHandler {
const insertRule = CSSStyleSheet.prototype.insertRule;
CSSStyleSheet.prototype.insertRule = function (rule: string, index?: number) {
const id = mirror.getId(this.ownerNode as INode);
@@ -453,6 +462,7 @@ function initStyleSheetObserver(cb: styleSheetRuleCallback): listenerHandler {
function initMediaInteractionObserver(
mediaInteractionCb: mediaInteractionCallback,
blockClass: blockClass,
mirror: Mirror,
): listenerHandler {
const handler = (type: 'play' | 'pause') => (event: Event) => {
const { target } = event;
@@ -473,6 +483,7 @@ function initMediaInteractionObserver(
function initCanvasMutationObserver(
cb: canvasMutationCallback,
blockClass: blockClass,
mirror: Mirror,
): listenerHandler {
const props = Object.getOwnPropertyNames(CanvasRenderingContext2D.prototype);
const handlers: listenerHandler[] = [];
@@ -779,20 +790,28 @@ export function initObservers(
o.maskTextFn,
o.recordCanvas,
o.slimDOMOptions,
o.mirror,
o.iframeManager,
o.shadowDomManager,
o.doc,
);
const mousemoveHandler = initMoveObserver(o.mousemoveCb, o.sampling, o.doc);
const mousemoveHandler = initMoveObserver(
o.mousemoveCb,
o.sampling,
o.doc,
o.mirror,
);
const mouseInteractionHandler = initMouseInteractionObserver(
o.mouseInteractionCb,
o.doc,
o.mirror,
o.blockClass,
o.sampling,
);
const scrollHandler = initScrollObserver(
o.scrollCb,
o.doc,
o.mirror,
o.blockClass,
o.sampling,
);
@@ -800,6 +819,7 @@ export function initObservers(
const inputHandler = initInputObserver(
o.inputCb,
o.doc,
o.mirror,
o.blockClass,
o.ignoreClass,
o.maskInputOptions,
@@ -809,10 +829,14 @@ export function initObservers(
const mediaInteractionHandler = initMediaInteractionObserver(
o.mediaInteractionCb,
o.blockClass,
o.mirror,
);
const styleSheetObserver = initStyleSheetObserver(
o.styleSheetRuleCb,
o.mirror,
);
const styleSheetObserver = initStyleSheetObserver(o.styleSheetRuleCb);
const canvasMutationObserver = o.recordCanvas
? initCanvasMutationObserver(o.canvasMutationCb, o.blockClass)
? initCanvasMutationObserver(o.canvasMutationCb, o.blockClass, o.mirror)
: () => {};
const fontObserver = o.collectFonts ? initFontObserver(o.fontCb) : () => {};
const logObserver = o.logOptions

View File

@@ -1,4 +1,10 @@
import { mutationCallBack, blockClass, maskTextClass, MaskTextFn } from '../types';
import {
mutationCallBack,
blockClass,
maskTextClass,
MaskTextFn,
Mirror,
} from '../types';
import { MaskInputOptions, SlimDOMOptions } from 'rrweb-snapshot';
import { IframeManager } from './iframe-manager';
import { initMutationObserver } from './observer';
@@ -19,13 +25,16 @@ type BypassOptions = {
export class ShadowDomManager {
private mutationCb: mutationCallBack;
private bypassOptions: BypassOptions;
private mirror: Mirror;
constructor(options: {
mutationCb: mutationCallBack;
bypassOptions: BypassOptions;
mirror: Mirror;
}) {
this.mutationCb = options.mutationCb;
this.bypassOptions = options.bypassOptions;
this.mirror = this.mirror;
}
public addShadowRoot(shadowRoot: ShadowRoot, doc: Document) {
@@ -41,6 +50,7 @@ export class ShadowDomManager {
this.bypassOptions.maskTextFn,
this.bypassOptions.recordCanvas,
this.bypassOptions.slimDOMOptions,
this.mirror,
this.bypassOptions.iframeManager,
this,
shadowRoot,

View File

@@ -779,7 +779,7 @@ export class Replayer {
d.adds.forEach((m) => this.treeIndex.add(m));
d.texts.forEach((m) => this.treeIndex.text(m));
d.attributes.forEach((m) => this.treeIndex.attribute(m));
d.removes.forEach((m) => this.treeIndex.remove(m));
d.removes.forEach((m) => this.treeIndex.remove(m, this.mirror));
}
this.applyMutation(d, isSync);
break;

View File

@@ -243,6 +243,7 @@ export type observerParam = {
collectFonts: boolean;
slimDOMOptions: SlimDOMOptions;
doc: Document;
mirror: Mirror;
iframeManager: IframeManager;
shadowDomManager: ShadowDomManager;
};

View File

@@ -68,8 +68,12 @@ export function createMirror(): Mirror {
// https://github.com/rrweb-io/rrweb/pull/407
const DEPARTED_MIRROR_ACCESS_WARNING =
'Please stop import mirror directly. Instead of that, now you can use replayer.getMirror() to access the mirror instance of a replayer.';
export let mirror: Mirror = {
'Please stop import mirror directly. Instead of that,' +
'\r\n' +
'now you can use replayer.getMirror() to access the mirror instance of a replayer,' +
'\r\n' +
'or you can use record.mirror to access the mirror instance during recording.';
export let _mirror: Mirror = {
map: {},
getId() {
console.error(DEPARTED_MIRROR_ACCESS_WARNING);
@@ -90,8 +94,8 @@ export let mirror: Mirror = {
console.error(DEPARTED_MIRROR_ACCESS_WARNING);
},
};
if (window.Proxy && window.Reflect) {
mirror = new Proxy(mirror, {
if (typeof window !== 'undefined' && window.Proxy && window.Reflect) {
_mirror = new Proxy(_mirror, {
get(target, prop, receiver) {
if (prop === 'map') {
console.error(DEPARTED_MIRROR_ACCESS_WARNING);
@@ -253,7 +257,7 @@ export function isIgnored(n: Node | INode): boolean {
return false;
}
export function isAncestorRemoved(target: INode): boolean {
export function isAncestorRemoved(target: INode, mirror: Mirror): boolean {
if (isShadowRoot(target)) {
return false;
}
@@ -271,7 +275,7 @@ export function isAncestorRemoved(target: INode): boolean {
if (!target.parentNode) {
return true;
}
return isAncestorRemoved((target.parentNode as unknown) as INode);
return isAncestorRemoved((target.parentNode as unknown) as INode, mirror);
}
export function isTouchEvent(
@@ -382,7 +386,7 @@ export class TreeIndex {
this.indexes.set(treeNode.id, treeNode);
}
public remove(mutation: removedNodeMutation) {
public remove(mutation: removedNodeMutation, mirror: Mirror) {
const parentTreeNode = this.indexes.get(mutation.parentId);
const treeNode = this.indexes.get(mutation.id);

View File

@@ -142,10 +142,10 @@ describe('record', function (this: ISuite) {
}
await this.page.waitFor(300);
expect(this.events.length).to.equal(33); // before first automatic snapshot
await this.page.waitFor(200); // could be 33 or 35 events by now depending on speed of test env
await this.page.waitFor(200); // could be 33 or 35 events by now depending on speed of test env
await this.page.type('input', 'a');
await this.page.waitFor(10);
expect(this.events.length).to.equal(36); // additionally includes the 2 checkout events
expect(this.events.length).to.equal(36); // additionally includes the 2 checkout events
expect(
this.events.filter(
(event: eventWithTime) => event.type === EventType.Meta,

4
typings/index.d.ts vendored
View File

@@ -1,8 +1,8 @@
import record from './record';
import { Replayer } from './replay';
import { mirror } from './utils';
import { _mirror } from './utils';
import * as utils from './utils';
export { EventType, IncrementalSource, MouseInteractions, ReplayerEvents, } from './types';
declare const addCustomEvent: <T>(tag: string, payload: T) => void;
declare const freezePage: () => void;
export { record, addCustomEvent, freezePage, Replayer, mirror, utils };
export { record, addCustomEvent, freezePage, Replayer, _mirror as mirror, utils, };

View File

@@ -4,5 +4,6 @@ declare namespace record {
var addCustomEvent: <T>(tag: string, payload: T) => void;
var freezePage: () => void;
var takeFullSnapshot: (isCheckout?: boolean | undefined) => void;
var mirror: import("../types").Mirror;
}
export default record;

View File

@@ -1,5 +1,5 @@
import { MaskInputOptions, SlimDOMOptions } from 'rrweb-snapshot';
import { mutationRecord, blockClass, maskTextClass, mutationCallBack, MaskTextFn } from '../types';
import { mutationRecord, blockClass, maskTextClass, mutationCallBack, MaskTextFn, Mirror } from '../types';
import { IframeManager } from './iframe-manager';
import { ShadowDomManager } from './shadow-dom-manager';
export default class MutationBuffer {
@@ -24,9 +24,10 @@ export default class MutationBuffer {
private recordCanvas;
private slimDOMOptions;
private doc;
private mirror;
private iframeManager;
private shadowDomManager;
init(cb: mutationCallBack, blockClass: blockClass, blockSelector: string | null, maskTextClass: maskTextClass, maskTextSelector: string | null, inlineStylesheet: boolean, maskInputOptions: MaskInputOptions, maskTextFn: MaskTextFn | undefined, recordCanvas: boolean, slimDOMOptions: SlimDOMOptions, doc: Document, iframeManager: IframeManager, shadowDomManager: ShadowDomManager): void;
init(cb: mutationCallBack, blockClass: blockClass, blockSelector: string | null, maskTextClass: maskTextClass, maskTextSelector: string | null, inlineStylesheet: boolean, maskInputOptions: MaskInputOptions, maskTextFn: MaskTextFn | undefined, recordCanvas: boolean, slimDOMOptions: SlimDOMOptions, doc: Document, mirror: Mirror, iframeManager: IframeManager, shadowDomManager: ShadowDomManager): void;
freeze(): void;
unfreeze(): void;
isFrozen(): boolean;

View File

@@ -1,9 +1,9 @@
import { MaskInputOptions, SlimDOMOptions } from 'rrweb-snapshot';
import { mutationCallBack, observerParam, listenerHandler, blockClass, maskTextClass, hooksParam, MaskTextFn } from '../types';
import { mutationCallBack, observerParam, listenerHandler, blockClass, maskTextClass, hooksParam, MaskTextFn, Mirror } from '../types';
import MutationBuffer from './mutation';
import { IframeManager } from './iframe-manager';
import { ShadowDomManager } from './shadow-dom-manager';
export declare const mutationBuffers: MutationBuffer[];
export declare function initMutationObserver(cb: mutationCallBack, doc: Document, blockClass: blockClass, blockSelector: string | null, maskTextClass: maskTextClass, maskTextSelector: string | null, inlineStylesheet: boolean, maskInputOptions: MaskInputOptions, maskTextFn: MaskTextFn | undefined, recordCanvas: boolean, slimDOMOptions: SlimDOMOptions, iframeManager: IframeManager, shadowDomManager: ShadowDomManager, rootEl: Node): MutationObserver;
export declare function initMutationObserver(cb: mutationCallBack, doc: Document, blockClass: blockClass, blockSelector: string | null, maskTextClass: maskTextClass, maskTextSelector: string | null, inlineStylesheet: boolean, maskInputOptions: MaskInputOptions, maskTextFn: MaskTextFn | undefined, recordCanvas: boolean, slimDOMOptions: SlimDOMOptions, mirror: Mirror, iframeManager: IframeManager, shadowDomManager: ShadowDomManager, rootEl: Node): MutationObserver;
export declare const INPUT_TAGS: string[];
export declare function initObservers(o: observerParam, hooks?: hooksParam): listenerHandler;

View File

@@ -1,4 +1,4 @@
import { mutationCallBack, blockClass, maskTextClass, MaskTextFn } from '../types';
import { mutationCallBack, blockClass, maskTextClass, MaskTextFn, Mirror } from '../types';
import { MaskInputOptions, SlimDOMOptions } from 'rrweb-snapshot';
import { IframeManager } from './iframe-manager';
declare type BypassOptions = {
@@ -16,9 +16,11 @@ declare type BypassOptions = {
export declare class ShadowDomManager {
private mutationCb;
private bypassOptions;
private mirror;
constructor(options: {
mutationCb: mutationCallBack;
bypassOptions: BypassOptions;
mirror: Mirror;
});
addShadowRoot(shadowRoot: ShadowRoot, doc: Document): void;
}

1
typings/types.d.ts vendored
View File

@@ -168,6 +168,7 @@ export declare type observerParam = {
collectFonts: boolean;
slimDOMOptions: SlimDOMOptions;
doc: Document;
mirror: Mirror;
iframeManager: IframeManager;
shadowDomManager: ShadowDomManager;
};

6
typings/utils.d.ts vendored
View File

@@ -2,7 +2,7 @@ import { Mirror, throttleOptions, listenerHandler, hookResetter, blockClass, eve
import { INode, serializedNodeWithId } from 'rrweb-snapshot';
export declare function on(type: string, fn: EventListenerOrEventListenerObject, target?: Document | Window): listenerHandler;
export declare function createMirror(): Mirror;
export declare let mirror: Mirror;
export declare let _mirror: Mirror;
export declare function throttle<T>(func: (arg: T) => void, wait: number, options?: throttleOptions): (arg: T) => void;
export declare function hookSetter<T>(target: T, key: string | number | symbol, d: PropertyDescriptor, isRevoked?: boolean, win?: Window & typeof globalThis): hookResetter;
export declare function patch(source: {
@@ -12,7 +12,7 @@ export declare function getWindowHeight(): number;
export declare function getWindowWidth(): number;
export declare function isBlocked(node: Node | null, blockClass: blockClass): boolean;
export declare function isIgnored(n: Node | INode): boolean;
export declare function isAncestorRemoved(target: INode): boolean;
export declare function isAncestorRemoved(target: INode, mirror: Mirror): boolean;
export declare function isTouchEvent(event: MouseEvent | TouchEvent): event is TouchEvent;
export declare function polyfill(win?: Window & typeof globalThis): void;
export declare function needCastInSyncMode(event: eventWithTime): boolean;
@@ -35,7 +35,7 @@ export declare class TreeIndex {
private inputMap;
constructor();
add(mutation: addedNodeMutation): void;
remove(mutation: removedNodeMutation): void;
remove(mutation: removedNodeMutation, mirror: Mirror): void;
text(mutation: textMutation): void;
attribute(mutation: attributeMutation): void;
scroll(d: scrollData): void;