shadow DOM recording GA

1. record shadow DOM event target by parsing composed path
2. nested record scroll event in shadow DOM
This commit is contained in:
Yanzhen Yu
2026-04-01 12:00:00 +08:00
parent 24d65c7cb1
commit c581cf68ca
3 changed files with 63 additions and 23 deletions

View File

@@ -18,6 +18,7 @@ import {
listenerHandler, listenerHandler,
LogRecordOptions, LogRecordOptions,
mutationCallbackParam, mutationCallbackParam,
scrollCallback,
} from '../types'; } from '../types';
import { IframeManager } from './iframe-manager'; import { IframeManager } from './iframe-manager';
import { ShadowDomManager } from './shadow-dom-manager'; import { ShadowDomManager } from './shadow-dom-manager';
@@ -197,6 +198,16 @@ function record<T = eventWithTime>(
}), }),
); );
}; };
const wrappedScrollEmit: scrollCallback = (p) =>
wrappedEmit(
wrapEvent({
type: EventType.IncrementalSnapshot,
data: {
source: IncrementalSource.Scroll,
...p,
},
}),
);
const iframeManager = new IframeManager({ const iframeManager = new IframeManager({
mutationCb: wrappedMutationEmit, mutationCb: wrappedMutationEmit,
@@ -204,6 +215,7 @@ function record<T = eventWithTime>(
const shadowDomManager = new ShadowDomManager({ const shadowDomManager = new ShadowDomManager({
mutationCb: wrappedMutationEmit, mutationCb: wrappedMutationEmit,
scrollCb: wrappedScrollEmit,
bypassOptions: { bypassOptions: {
blockClass, blockClass,
blockSelector, blockSelector,
@@ -213,6 +225,7 @@ function record<T = eventWithTime>(
maskInputOptions, maskInputOptions,
maskTextFn, maskTextFn,
recordCanvas, recordCanvas,
sampling,
slimDOMOptions, slimDOMOptions,
iframeManager, iframeManager,
}, },
@@ -325,16 +338,7 @@ function record<T = eventWithTime>(
}, },
}), }),
), ),
scrollCb: (p) => scrollCb: wrappedScrollEmit,
wrappedEmit(
wrapEvent({
type: EventType.IncrementalSnapshot,
data: {
source: IncrementalSource.Scroll,
...p,
},
}),
),
viewportResizeCb: (d) => viewportResizeCb: (d) =>
wrappedEmit( wrappedEmit(
wrapEvent({ wrapEvent({

View File

@@ -60,6 +60,25 @@ type WindowWithAngularZone = Window & {
export const mutationBuffers: MutationBuffer[] = []; export const mutationBuffers: MutationBuffer[] = [];
function getEventTarget(event: Event): EventTarget | null {
try {
if ('composedPath' in event) {
const path = event.composedPath();
if (path.length) {
return path[0];
}
} else if (
'path' in event &&
(event as { path: EventTarget[] }).path.length
) {
return (event as { path: EventTarget[] }).path[0];
}
return event.target;
} catch {
return event.target;
}
}
export function initMutationObserver( export function initMutationObserver(
cb: mutationCallBack, cb: mutationCallBack,
doc: Document, doc: Document,
@@ -176,7 +195,7 @@ function initMoveObserver(
); );
const updatePosition = throttle<MouseEvent | TouchEvent | DragEvent>( const updatePosition = throttle<MouseEvent | TouchEvent | DragEvent>(
(evt) => { (evt) => {
const { target } = evt; const target = getEventTarget(evt);
const { clientX, clientY } = isTouchEvent(evt) const { clientX, clientY } = isTouchEvent(evt)
? evt.changedTouches[0] ? evt.changedTouches[0]
: evt; : evt;
@@ -231,14 +250,15 @@ function initMouseInteractionObserver(
const handlers: listenerHandler[] = []; const handlers: listenerHandler[] = [];
const getHandler = (eventKey: keyof typeof MouseInteractions) => { const getHandler = (eventKey: keyof typeof MouseInteractions) => {
return (event: MouseEvent | TouchEvent) => { return (event: MouseEvent | TouchEvent) => {
if (isBlocked(event.target as Node, blockClass)) { const target = getEventTarget(event) as Node;
if (isBlocked(target as Node, blockClass)) {
return; return;
} }
const e = isTouchEvent(event) ? event.changedTouches[0] : event; const e = isTouchEvent(event) ? event.changedTouches[0] : event;
if (!e) { if (!e) {
return; return;
} }
const id = mirror.getId(event.target as INode); const id = mirror.getId(target as INode);
const { clientX, clientY } = e; const { clientX, clientY } = e;
cb({ cb({
type: MouseInteractions[eventKey], type: MouseInteractions[eventKey],
@@ -265,7 +285,7 @@ function initMouseInteractionObserver(
}; };
} }
function initScrollObserver( export function initScrollObserver(
cb: scrollCallback, cb: scrollCallback,
doc: Document, doc: Document,
mirror: Mirror, mirror: Mirror,
@@ -273,11 +293,12 @@ function initScrollObserver(
sampling: SamplingStrategy, sampling: SamplingStrategy,
): listenerHandler { ): listenerHandler {
const updatePosition = throttle<UIEvent>((evt) => { const updatePosition = throttle<UIEvent>((evt) => {
if (!evt.target || isBlocked(evt.target as Node, blockClass)) { const target = getEventTarget(evt);
if (!target || isBlocked(target as Node, blockClass)) {
return; return;
} }
const id = mirror.getId(evt.target as INode); const id = mirror.getId(target as INode);
if (evt.target === doc) { if (target === doc) {
const scrollEl = (doc.scrollingElement || doc.documentElement)!; const scrollEl = (doc.scrollingElement || doc.documentElement)!;
cb({ cb({
id, id,
@@ -287,12 +308,12 @@ function initScrollObserver(
} else { } else {
cb({ cb({
id, id,
x: (evt.target as HTMLElement).scrollLeft, x: (target as HTMLElement).scrollLeft,
y: (evt.target as HTMLElement).scrollTop, y: (target as HTMLElement).scrollTop,
}); });
} }
}, sampling.scroll || 100); }, sampling.scroll || 100);
return on('scroll', updatePosition); return on('scroll', updatePosition, doc);
} }
function initViewportResizeObserver( function initViewportResizeObserver(
@@ -328,7 +349,7 @@ function initInputObserver(
sampling: SamplingStrategy, sampling: SamplingStrategy,
): listenerHandler { ): listenerHandler {
function eventHandler(event: Event) { function eventHandler(event: Event) {
const { target } = event; const target = getEventTarget(event);
if ( if (
!target || !target ||
!(target as Element).tagName || !(target as Element).tagName ||
@@ -465,7 +486,7 @@ function initMediaInteractionObserver(
mirror: Mirror, mirror: Mirror,
): listenerHandler { ): listenerHandler {
const handler = (type: 'play' | 'pause') => (event: Event) => { const handler = (type: 'play' | 'pause') => (event: Event) => {
const { target } = event; const target = getEventTarget(event);
if (!target || isBlocked(target as Node, blockClass)) { if (!target || isBlocked(target as Node, blockClass)) {
return; return;
} }

View File

@@ -4,10 +4,12 @@ import {
maskTextClass, maskTextClass,
MaskTextFn, MaskTextFn,
Mirror, Mirror,
scrollCallback,
SamplingStrategy,
} from '../types'; } from '../types';
import { MaskInputOptions, SlimDOMOptions } from 'rrweb-snapshot'; import { MaskInputOptions, SlimDOMOptions } from 'rrweb-snapshot';
import { IframeManager } from './iframe-manager'; import { IframeManager } from './iframe-manager';
import { initMutationObserver } from './observer'; import { initMutationObserver, initScrollObserver } from './observer';
type BypassOptions = { type BypassOptions = {
blockClass: blockClass; blockClass: blockClass;
@@ -18,21 +20,25 @@ type BypassOptions = {
maskInputOptions: MaskInputOptions; maskInputOptions: MaskInputOptions;
maskTextFn: MaskTextFn | undefined; maskTextFn: MaskTextFn | undefined;
recordCanvas: boolean; recordCanvas: boolean;
sampling: SamplingStrategy;
slimDOMOptions: SlimDOMOptions; slimDOMOptions: SlimDOMOptions;
iframeManager: IframeManager; iframeManager: IframeManager;
}; };
export class ShadowDomManager { export class ShadowDomManager {
private mutationCb: mutationCallBack; private mutationCb: mutationCallBack;
private scrollCb: scrollCallback;
private bypassOptions: BypassOptions; private bypassOptions: BypassOptions;
private mirror: Mirror; private mirror: Mirror;
constructor(options: { constructor(options: {
mutationCb: mutationCallBack; mutationCb: mutationCallBack;
scrollCb: scrollCallback;
bypassOptions: BypassOptions; bypassOptions: BypassOptions;
mirror: Mirror; mirror: Mirror;
}) { }) {
this.mutationCb = options.mutationCb; this.mutationCb = options.mutationCb;
this.scrollCb = options.scrollCb;
this.bypassOptions = options.bypassOptions; this.bypassOptions = options.bypassOptions;
this.mirror = options.mirror; this.mirror = options.mirror;
} }
@@ -55,5 +61,14 @@ export class ShadowDomManager {
this, this,
shadowRoot, shadowRoot,
); );
initScrollObserver(
this.scrollCb,
// https://gist.github.com/praveenpuglia/0832da687ed5a5d7a0907046c9ef1813
// scroll is not allowed to pass the boundary, so we need to listen the shadow document
(shadowRoot as unknown) as Document,
this.mirror,
this.bypassOptions.blockClass,
this.bypassOptions.sampling,
);
} }
} }