Skip to content

Commit

Permalink
Fix: shadow dom bugs (#1049)
Browse files Browse the repository at this point in the history
* Add test cases for bugs

* Fix shadow dom recording

When moving an element containing shadow dom
When adding an element to shadow dom before its attached to the dom

* Apply formatting changes

* Refactor in dom checking code

* Nodes don't get processed in more than one mutation buffer

* Constrain node mutations to one mutation buffer per request animation frame

* Make tests less flaky under heavy load

* Apply suggestions from code review

* Update packages/rrweb-snapshot/test/rebuild.test.ts

* Remove unused nodeSet

Co-authored-by: Yun Feng <[email protected]>
  • Loading branch information
Juice10 and YunFeng0817 authored Jan 10, 2023
1 parent 66abe17 commit 07aa1b2
Show file tree
Hide file tree
Showing 11 changed files with 831 additions and 29 deletions.
31 changes: 31 additions & 0 deletions packages/rrweb-snapshot/test/rebuild.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,37 @@ describe('rebuild', function () {
});
});

describe('shadowDom', function () {
it('rebuild shadowRoot without siblings', function () {
const node = buildNodeWithSN(
{
id: 1,
tagName: 'div',
type: NodeType.Element,
attributes: {},
childNodes: [
{
id: 2,
tagName: 'div',
type: NodeType.Element,
attributes: {},
childNodes: [],
isShadow: true,
},
],
isShadowHost: true,
},
{
doc: document,
mirror,
hackCss: false,
cache,
},
) as HTMLDivElement;
expect(node.shadowRoot?.childNodes.length).toBe(1);
});
});

describe('add hover class to hover selector related rules', function () {
it('will do nothing to css text without :hover', () => {
const cssText = 'body { color: white }';
Expand Down
8 changes: 7 additions & 1 deletion packages/rrweb/src/record/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ import {
SlimDOMOptions,
createMirror,
} from 'rrweb-snapshot';
import { initObservers, mutationBuffers } from './observer';
import {
initObservers,
mutationBuffers,
processedNodeManager,
} from './observer';
import {
on,
getWindowWidth,
Expand Down Expand Up @@ -316,6 +320,7 @@ function record<T = eventWithTime>(
stylesheetManager,
canvasManager,
keepIframeSrcFn,
processedNodeManager,
},
mirror,
});
Expand Down Expand Up @@ -528,6 +533,7 @@ function record<T = eventWithTime>(
iframeManager,
stylesheetManager,
shadowDomManager,
processedNodeManager,
canvasManager,
ignoreCSSAttributes,
plugins:
Expand Down
30 changes: 16 additions & 14 deletions packages/rrweb/src/record/mutation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
hasShadowRoot,
isSerializedIframe,
isSerializedStylesheet,
inDom,
} from '../utils';

type DoubleLinkedListNode = {
Expand Down Expand Up @@ -175,6 +176,7 @@ export default class MutationBuffer {
private stylesheetManager: observerParam['stylesheetManager'];
private shadowDomManager: observerParam['shadowDomManager'];
private canvasManager: observerParam['canvasManager'];
private processedNodeManager: observerParam['processedNodeManager'];

public init(options: MutationBufferParam) {
([
Expand All @@ -198,6 +200,7 @@ export default class MutationBuffer {
'stylesheetManager',
'shadowDomManager',
'canvasManager',
'processedNodeManager',
] as const).forEach((key) => {
// just a type trick, the runtime result is correct
this[key] = options[key] as never;
Expand Down Expand Up @@ -271,19 +274,8 @@ export default class MutationBuffer {
(n.getRootNode() as ShadowRoot).host
)
shadowHost = (n.getRootNode() as ShadowRoot).host;
// If n is in a nested shadow dom.
let rootShadowHost = shadowHost;
while (
rootShadowHost?.getRootNode?.()?.nodeType ===
Node.DOCUMENT_FRAGMENT_NODE &&
(rootShadowHost.getRootNode() as ShadowRoot).host
)
rootShadowHost = (rootShadowHost.getRootNode() as ShadowRoot).host;
// ensure contains is passed a Node, or it will throw an error
const notInDoc =
!this.doc.contains(n) &&
(!rootShadowHost || !this.doc.contains(rootShadowHost));
if (!n.parentNode || notInDoc) {

if (!n.parentNode || !inDom(n)) {
return;
}
const parentId = isShadowRoot(n.parentNode)
Expand Down Expand Up @@ -647,6 +639,9 @@ export default class MutationBuffer {
* Make sure you check if `n`'s parent is blocked before calling this function
* */
private genAdds = (n: Node, target?: Node) => {
// this node was already recorded in other buffer, ignore it
if (this.processedNodeManager.inOtherBuffer(n, this)) return;

if (this.mirror.hasNode(n)) {
if (isIgnored(n, this.mirror)) {
return;
Expand All @@ -666,8 +661,15 @@ export default class MutationBuffer {

// if this node is blocked `serializeNode` will turn it into a placeholder element
// but we have to remove it's children otherwise they will be added as placeholders too
if (!isBlocked(n, this.blockClass, this.blockSelector, false))
if (!isBlocked(n, this.blockClass, this.blockSelector, false)) {
n.childNodes.forEach((childN) => this.genAdds(childN));
if (hasShadowRoot(n)) {
n.shadowRoot.childNodes.forEach((childN) => {
this.processedNodeManager.add(childN, this);
this.genAdds(childN, n);
});
}
}
};
}

Expand Down
2 changes: 2 additions & 0 deletions packages/rrweb/src/record/observer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {
selectionCallback,
} from '@rrweb/types';
import MutationBuffer from './mutation';
import ProcessedNodeManager from './processed-node-manager';

type WindowWithStoredMutationObserver = IWindow & {
__rrMutationObserver?: MutationObserver;
Expand All @@ -51,6 +52,7 @@ type WindowWithAngularZone = IWindow & {
};

export const mutationBuffers: MutationBuffer[] = [];
export const processedNodeManager = new ProcessedNodeManager();

const isCSSGroupingRuleSupported = typeof CSSGroupingRule !== 'undefined';
const isCSSMediaRuleSupported = typeof CSSMediaRule !== 'undefined';
Expand Down
34 changes: 34 additions & 0 deletions packages/rrweb/src/record/processed-node-manager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type MutationBuffer from './mutation';

/**
* Keeps a log of nodes that could show up in multiple mutation buffer but shouldn't be handled twice.
*/
export default class ProcessedNodeManager {
private nodeMap: WeakMap<Node, Set<MutationBuffer>> = new WeakMap();

constructor() {
this.periodicallyClear();
}

private periodicallyClear() {
requestAnimationFrame(() => {
this.clear();
this.periodicallyClear();
});
}

public inOtherBuffer(node: Node, thisBuffer: MutationBuffer) {
const buffers = this.nodeMap.get(node);
return (
buffers && Array.from(buffers).some((buffer) => buffer !== thisBuffer)
);
}

public add(node: Node, buffer: MutationBuffer) {
this.nodeMap.set(node, (this.nodeMap.get(node) || new Set()).add(buffer));
}

private clear() {
this.nodeMap = new WeakMap();
}
}
8 changes: 6 additions & 2 deletions packages/rrweb/src/record/shadow-dom-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
initScrollObserver,
initAdoptedStyleSheetObserver,
} from './observer';
import { patch } from '../utils';
import { patch, inDom } from '../utils';
import type { Mirror } from 'rrweb-snapshot';
import { isNativeShadowDom } from 'rrweb-snapshot';

Expand Down Expand Up @@ -49,7 +49,11 @@ export class ShadowDomManager {
function (original: (init: ShadowRootInit) => ShadowRoot) {
return function (this: HTMLElement, option: ShadowRootInit) {
const shadowRoot = original.call(this, option);
if (this.shadowRoot)

// For the shadow dom elements in the document, monitor their dom mutations.
// For shadow dom elements that aren't in the document yet,
// we start monitoring them once their shadow dom host is appended to the document.
if (this.shadowRoot && inDom(this))
manager.addShadowRoot(this.shadowRoot, this.ownerDocument);
return shadowRoot;
};
Expand Down
3 changes: 3 additions & 0 deletions packages/rrweb/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import type {
styleSheetRuleCallback,
viewportResizeCallback,
} from '@rrweb/types';
import type ProcessedNodeManager from './record/processed-node-manager';

export type recordOptions<T> = {
emit?: (e: T, isCheckout?: boolean) => void;
Expand Down Expand Up @@ -105,6 +106,7 @@ export type observerParam = {
stylesheetManager: StylesheetManager;
shadowDomManager: ShadowDomManager;
canvasManager: CanvasManager;
processedNodeManager: ProcessedNodeManager;
ignoreCSSAttributes: Set<string>;
plugins: Array<{
observer: (
Expand Down Expand Up @@ -139,6 +141,7 @@ export type MutationBufferParam = Pick<
| 'stylesheetManager'
| 'shadowDomManager'
| 'canvasManager'
| 'processedNodeManager'
>;

export type ReplayPlugin = {
Expand Down
27 changes: 27 additions & 0 deletions packages/rrweb/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -518,3 +518,30 @@ export class StyleSheetMirror {
return this.id++;
}
}

export function getRootShadowHost(n: Node): Node | null {
const shadowHost = (n.getRootNode() as ShadowRoot).host;
// If n is in a nested shadow dom.
let rootShadowHost = shadowHost;

while (
rootShadowHost?.getRootNode?.()?.nodeType === Node.DOCUMENT_FRAGMENT_NODE &&
(rootShadowHost.getRootNode() as ShadowRoot).host
)
rootShadowHost = (rootShadowHost.getRootNode() as ShadowRoot).host;

return rootShadowHost;
}

export function shadowHostInDom(n: Node): boolean {
const doc = n.ownerDocument;
if (!doc) return false;
const shadowHost = getRootShadowHost(n);
return Boolean(shadowHost && doc.contains(shadowHost));
}

export function inDom(n: Node): boolean {
const doc = n.ownerDocument;
if (!doc) return false;
return doc.contains(n) || shadowHostInDom(n);
}
Loading

0 comments on commit 07aa1b2

Please sign in to comment.