impl shadow DOM manager

part of #38
1. observe DOM mutations in shadow DOM
2. rebuild DOM mutations in shadow DOM
This commit is contained in:
Yanzhen Yu
2026-04-01 12:00:00 +08:00
parent 66c7c8f028
commit 0e688bba0c
12 changed files with 680 additions and 48 deletions

View File

@@ -7,6 +7,7 @@ import {
getWindowHeight,
polyfill,
isIframeINode,
hasShadowRoot,
} from '../utils';
import {
EventType,
@@ -16,8 +17,10 @@ import {
IncrementalSource,
listenerHandler,
LogRecordOptions,
mutationCallbackParam,
} from '../types';
import { IframeManager } from './iframe-manager';
import { ShadowDomManager } from './shadow-dom-manager';
function wrapEvent(e: event): eventWithTime {
return {
@@ -179,17 +182,33 @@ function record<T = eventWithTime>(
}
};
const wrappedMutationEmit = (m: mutationCallbackParam) => {
wrappedEmit(
wrapEvent({
type: EventType.IncrementalSnapshot,
data: {
source: IncrementalSource.Mutation,
...m,
},
}),
);
};
const iframeManager = new IframeManager({
mutationCb: (m) =>
wrappedEmit(
wrapEvent({
type: EventType.IncrementalSnapshot,
data: {
source: IncrementalSource.Mutation,
...m,
},
}),
),
mutationCb: wrappedMutationEmit,
});
const shadowDomManager = new ShadowDomManager({
mutationCb: wrappedMutationEmit,
bypassOptions: {
blockClass,
blockSelector,
inlineStylesheet,
maskInputOptions,
recordCanvas,
slimDOMOptions,
iframeManager,
},
});
takeFullSnapshot = (isCheckout = false) => {
@@ -217,6 +236,9 @@ function record<T = eventWithTime>(
if (isIframeINode(n)) {
iframeManager.addIframe(n);
}
if (hasShadowRoot(n)) {
shadowDomManager.addShadowRoot(n.shadowRoot, document);
}
},
onIframeLoad: (iframe, childSn) => {
iframeManager.attachIframe(iframe, childSn);
@@ -271,16 +293,7 @@ function record<T = eventWithTime>(
const observe = (doc: Document) => {
return initObservers(
{
mutationCb: (m) =>
wrappedEmit(
wrapEvent({
type: EventType.IncrementalSnapshot,
data: {
source: IncrementalSource.Mutation,
...m,
},
}),
),
mutationCb: wrappedMutationEmit,
mousemoveCb: (positions, source) =>
wrappedEmit(
wrapEvent({
@@ -394,6 +407,7 @@ function record<T = eventWithTime>(
blockSelector,
slimDOMOptions,
iframeManager,
shadowDomManager,
},
hooks,
);

View File

@@ -5,7 +5,7 @@ import {
MaskInputOptions,
SlimDOMOptions,
IGNORED_NODE,
NodeType,
isShadowRoot,
} from 'rrweb-snapshot';
import {
mutationRecord,
@@ -16,8 +16,16 @@ import {
removedNodeMutation,
addedNodeMutation,
} from '../types';
import { mirror, isBlocked, isAncestorRemoved, isIgnored } from '../utils';
import {
mirror,
isBlocked,
isAncestorRemoved,
isIgnored,
isIframeINode,
hasShadowRoot,
} from '../utils';
import { IframeManager } from './iframe-manager';
import { ShadowDomManager } from './shadow-dom-manager';
type DoubleLinkedListNode = {
previous: DoubleLinkedListNode | null;
@@ -158,6 +166,7 @@ export default class MutationBuffer {
private doc: Document;
private iframeManager: IframeManager;
private shadowDomManager: ShadowDomManager;
public init(
cb: mutationCallBack,
@@ -169,6 +178,7 @@ export default class MutationBuffer {
slimDOMOptions: SlimDOMOptions,
doc: Document,
iframeManager: IframeManager,
shadowDomManager: ShadowDomManager,
) {
this.blockClass = blockClass;
this.blockSelector = blockSelector;
@@ -179,6 +189,7 @@ export default class MutationBuffer {
this.emissionCallback = cb;
this.doc = doc;
this.iframeManager = iframeManager;
this.shadowDomManager = shadowDomManager;
}
public freeze() {
@@ -236,10 +247,14 @@ export default class MutationBuffer {
return nextId;
};
const pushAdd = (n: Node) => {
if (!n.parentNode || !this.doc.contains(n)) {
const shadowHost: Element | null = (n.getRootNode() as ShadowRoot)?.host;
const notInDoc = !this.doc.contains(n) && !this.doc.contains(shadowHost);
if (!n.parentNode || notInDoc) {
return;
}
const parentId = mirror.getId((n.parentNode as Node) as INode);
const parentId = isShadowRoot(n.parentNode)
? mirror.getId((shadowHost as unknown) as INode)
: mirror.getId((n.parentNode as Node) as INode);
const nextId = getNextId(n);
if (parentId === -1 || nextId === -1) {
return addList.addNode(n);
@@ -255,13 +270,11 @@ export default class MutationBuffer {
slimDOMOptions: this.slimDOMOptions,
recordCanvas: this.recordCanvas,
onSerialize: (currentN) => {
if (
currentN.__sn.type === NodeType.Element &&
currentN.__sn.tagName === 'iframe'
) {
this.iframeManager.addIframe(
(currentN as unknown) as HTMLIFrameElement,
);
if (isIframeINode(currentN)) {
this.iframeManager.addIframe(currentN);
}
if (hasShadowRoot(n)) {
this.shadowDomManager.addShadowRoot(n.shadowRoot, document);
}
},
onIframeLoad: (iframe, childSn) => {
@@ -418,6 +431,7 @@ export default class MutationBuffer {
// overwrite attribute if the mutations was triggered in same time
item.attributes[m.attributeName!] = transformAttribute(
this.doc,
(m.target as HTMLElement).tagName,
m.attributeName!,
value!,
);
@@ -427,7 +441,9 @@ export default class MutationBuffer {
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);
const parentId = isShadowRoot(m.target)
? mirror.getId((m.target.host as unknown) as INode)
: mirror.getId(m.target as INode);
if (
isBlocked(n, this.blockClass) ||
isBlocked(m.target, this.blockClass) ||
@@ -463,6 +479,7 @@ export default class MutationBuffer {
this.removes.push({
parentId,
id: nodeId,
isShadow: isShadowRoot(m.target) ? true : undefined,
});
}
this.mapRemoves.push(n);

View File

@@ -44,6 +44,7 @@ import {
import MutationBuffer from './mutation';
import { stringify } from './stringify';
import { IframeManager } from './iframe-manager';
import { ShadowDomManager } from './shadow-dom-manager';
type WindowWithStoredMutationObserver = Window & {
__rrMutationObserver?: MutationObserver;
@@ -56,7 +57,7 @@ type WindowWithAngularZone = Window & {
export const mutationBuffers: MutationBuffer[] = [];
function initMutationObserver(
export function initMutationObserver(
cb: mutationCallBack,
doc: Document,
blockClass: blockClass,
@@ -66,6 +67,8 @@ function initMutationObserver(
recordCanvas: boolean,
slimDOMOptions: SlimDOMOptions,
iframeManager: IframeManager,
shadowDomManager: ShadowDomManager,
rootEl: Node,
): MutationObserver {
const mutationBuffer = new MutationBuffer();
mutationBuffers.push(mutationBuffer);
@@ -80,6 +83,7 @@ function initMutationObserver(
slimDOMOptions,
doc,
iframeManager,
shadowDomManager,
);
let mutationObserverCtor =
window.MutationObserver ||
@@ -109,7 +113,7 @@ function initMutationObserver(
const observer = new mutationObserverCtor(
mutationBuffer.processMutations.bind(mutationBuffer),
);
observer.observe(doc, {
observer.observe(rootEl, {
attributes: true,
attributeOldValue: true,
characterData: true,
@@ -763,6 +767,8 @@ export function initObservers(
o.recordCanvas,
o.slimDOMOptions,
o.iframeManager,
o.shadowDomManager,
o.doc,
);
const mousemoveHandler = initMoveObserver(o.mousemoveCb, o.sampling, o.doc);
const mouseInteractionHandler = initMouseInteractionObserver(

View File

@@ -0,0 +1,43 @@
import { mutationCallBack, blockClass } from '../types';
import { MaskInputOptions, SlimDOMOptions } from 'rrweb-snapshot';
import { IframeManager } from './iframe-manager';
import { initMutationObserver } from './observer';
type BypassOptions = {
blockClass: blockClass;
blockSelector: string | null;
inlineStylesheet: boolean;
maskInputOptions: MaskInputOptions;
recordCanvas: boolean;
slimDOMOptions: SlimDOMOptions;
iframeManager: IframeManager;
};
export class ShadowDomManager {
private mutationCb: mutationCallBack;
private bypassOptions: BypassOptions;
constructor(options: {
mutationCb: mutationCallBack;
bypassOptions: BypassOptions;
}) {
this.mutationCb = options.mutationCb;
this.bypassOptions = options.bypassOptions;
}
public addShadowRoot(shadowRoot: ShadowRoot, doc: Document) {
initMutationObserver(
this.mutationCb,
doc,
this.bypassOptions.blockClass,
this.bypassOptions.blockSelector,
this.bypassOptions.inlineStylesheet,
this.bypassOptions.maskInputOptions,
this.bypassOptions.recordCanvas,
this.bypassOptions.slimDOMOptions,
this.bypassOptions.iframeManager,
this,
shadowRoot,
);
}
}

View File

@@ -40,6 +40,7 @@ import {
AppendedIframe,
isIframeINode,
getBaseDimension,
hasShadowRoot,
} from '../utils';
import getInjectStyleRules from './styles/inject-style';
import './styles/style.css';
@@ -1048,14 +1049,18 @@ export class Replayer {
if (!target) {
return this.warnNodeNotFound(d, mutation.id);
}
const parent = mirror.getNode(mutation.parentId);
let parent: INode | null | ShadowRoot = mirror.getNode(mutation.parentId);
if (!parent) {
return this.warnNodeNotFound(d, mutation.parentId);
}
if (mutation.isShadow && hasShadowRoot(parent)) {
parent = parent.shadowRoot;
}
// target may be removed with its parents before
mirror.removeNodeFromMap(target);
if (parent) {
const realParent = this.fragmentParentMap.get(parent);
const realParent =
'__sn' in parent ? this.fragmentParentMap.get(parent) : undefined;
if (realParent && realParent.contains(target)) {
realParent.removeChild(target);
} else if (this.fragmentParentMap.has(target)) {
@@ -1100,7 +1105,7 @@ export class Replayer {
if (!this.iframe.contentDocument) {
return console.warn('Looks like your replayer has been destroyed.');
}
let parent = mirror.getNode(mutation.parentId);
let parent: INode | null | ShadowRoot = mirror.getNode(mutation.parentId);
if (!parent) {
if (mutation.node.type === NodeType.Document) {
// is newly added document, maybe the document node of an iframe
@@ -1133,6 +1138,10 @@ export class Replayer {
parent = virtualParent;
}
if (mutation.node.isShadow && hasShadowRoot(parent)) {
parent = parent.shadowRoot;
}
let previous: Node | null = null;
let next: Node | null = null;
if (mutation.previousId) {

View File

@@ -8,6 +8,7 @@ import {
import { PackFn, UnpackFn } from './packer/base';
import { FontFaceDescriptors } from 'css-font-loading-module';
import { IframeManager } from './record/iframe-manager';
import { ShadowDomManager } from './record/shadow-dom-manager';
export enum EventType {
DomContentLoaded,
@@ -231,6 +232,7 @@ export type observerParam = {
slimDOMOptions: SlimDOMOptions;
doc: Document;
iframeManager: IframeManager;
shadowDomManager: ShadowDomManager;
};
export type hooksParam = {
@@ -282,6 +284,7 @@ export type attributeMutation = {
export type removedNodeMutation = {
parentId: number;
id: number;
isShadow?: boolean;
};
export type addedNodeMutation = {
@@ -292,7 +295,7 @@ export type addedNodeMutation = {
node: serializedNodeWithId;
};
type mutationCallbackParam = {
export type mutationCallbackParam = {
texts: textMutation[];
attributes: attributeMutation[];
removes: removedNodeMutation[];

View File

@@ -21,6 +21,7 @@ import {
IGNORED_NODE,
serializedNodeWithId,
NodeType,
isShadowRoot,
} from 'rrweb-snapshot';
export function on(
@@ -213,6 +214,9 @@ export function isIgnored(n: Node | INode): boolean {
}
export function isAncestorRemoved(target: INode): boolean {
if (isShadowRoot(target)) {
return false;
}
const id = mirror.getId(target);
if (!mirror.has(id)) {
return true;
@@ -542,12 +546,16 @@ export type AppendedIframe = {
builtNode: HTMLIFrameINode;
};
export function isIframeINode(node: INode): node is HTMLIFrameINode {
// node can be document fragment when using the virtual parent feature
if (!node.__sn) {
return false;
export function isIframeINode(
node: INode | ShadowRoot,
): node is HTMLIFrameINode {
if ('__sn' in node) {
return (
node.__sn.type === NodeType.Element && node.__sn.tagName === 'iframe'
);
}
return node.__sn.type === NodeType.Element && node.__sn.tagName === 'iframe';
// node can be document fragment when using the virtual parent feature
return false;
}
export function getBaseDimension(
@@ -579,3 +587,9 @@ export function getBaseDimension(
absoluteScale: frameBaseDimension.absoluteScale * relativeScale,
};
}
export function hasShadowRoot<T extends Node>(
n: T,
): n is T & { shadowRoot: ShadowRoot } {
return Boolean(((n as unknown) as Element)?.shadowRoot);
}