Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/replay-guard-attach-shadow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@posthog/rrweb': patch
'@posthog/rrweb-snapshot': patch
'@posthog/rrdom': patch
---
Comment thread
turnipdabeets marked this conversation as resolved.

Fix the session replay player aborting playback when a recording contains a shadow host the browser refuses. The player now skips that one subtree instead of letting `attachShadow` throw and kill the whole rebuild.
20 changes: 20 additions & 0 deletions packages/rrweb/.eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,5 +42,25 @@ module.exports = {
'posthog-js/no-direct-window-check': 'off',
'compat/compat': 'off',
},
overrides: [
{
// The replayer rebuilds hosts with `createElement(tagName)`, so a host the
// browser refuses raises NotSupportedError. In the full-snapshot rebuild that
// is uncaught and ends playback; elsewhere it abandons the rest of the batch.
files: ['*/src/**/*.ts'],
excludedFiles: ['rrweb-snapshot/src/utils.ts', '**/*.spec.*', '**/*.test.*'],
rules: {
'no-restricted-syntax': [
'error',
{
selector:
"CallExpression[callee.property.name='attachShadow'][callee.object.type!='Super']",
message:
'Use `attachShadowRootSafely` from @posthog/rrweb-snapshot instead of calling attachShadow directly.',
},
],
},
},
],
ignorePatterns: ['dist/', 'node_modules/', '*.js', '*.cjs', '*.mjs'],
}
25 changes: 16 additions & 9 deletions packages/rrweb/rrdom/src/diff.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { type Mirror as NodeMirror } from '@posthog/rrweb-snapshot';
import {
type Mirror as NodeMirror,
attachShadowRootSafely,
} from '@posthog/rrweb-snapshot';
import { NodeType as RRNodeType } from '@posthog/rrweb-types';
import type {
canvasMutationData,
Expand Down Expand Up @@ -184,14 +187,18 @@ function diffBeforeUpdatingChildren(
}
}
if (newRRElement.shadowRoot) {
if (!oldElement.shadowRoot) oldElement.attachShadow({ mode: 'open' });
diffChildren(
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
oldElement.shadowRoot!,
newRRElement.shadowRoot,
replayer,
rrnodeMirror,
);
// The recorded host can come back as a tag the real element refuses as
// a shadow host. Skip that subtree rather than let the exception
// abandon the rest of the diff.
if (oldElement.shadowRoot || attachShadowRootSafely(oldElement)) {
diffChildren(
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
oldElement.shadowRoot!,
newRRElement.shadowRoot,
replayer,
rrnodeMirror,
);
}
}
/**
* Attributes and styles of the old element need to be updated before updating its children because of an edge case:
Expand Down
13 changes: 10 additions & 3 deletions packages/rrweb/rrdom/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { createMirror as createNodeMirror } from '@posthog/rrweb-snapshot';
import {
attachShadowRootSafely,
createMirror as createNodeMirror,
} from '@posthog/rrweb-snapshot';
import type { Mirror as NodeMirror } from '@posthog/rrweb-snapshot';
import { NodeType as RRNodeType } from '@posthog/rrweb-types';
import type {
Expand Down Expand Up @@ -268,9 +271,13 @@ export function buildFromNode(
rrNode = rrdom.createComment((node as Comment).textContent || '');
break;
// if node is a shadow root
case NodeType.DOCUMENT_FRAGMENT_NODE:
rrNode = (parentRRNode as IRRElement).attachShadow({ mode: 'open' });
case NodeType.DOCUMENT_FRAGMENT_NODE: {
const shadowHost = parentRRNode as IRRElement;
if (!attachShadowRootSafely(shadowHost)) return null;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
rrNode = shadowHost.shadowRoot!;
break;
}
default:
return null;
}
Expand Down
31 changes: 31 additions & 0 deletions packages/rrweb/rrdom/test/diff.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1218,6 +1218,37 @@ describe('diff algorithm for rrdom', () => {
.childNodes[0] as HTMLElement;
expect(childElement.tagName).toEqual('DIV');
});

it('should skip a shadow dom the real element refuses', () => {
const tagName = 'NOHYPHEN';
const node = document.createElement(tagName);
mirror.add(node, {
...elementSn,
tagName,
id: 1,
} as serializedNodeWithId);

const rrDocument = new RRDocument();
const rrNode = rrDocument.createElement(tagName);
rrDocument.mirror.add(
rrNode,
Object.assign({}, elementSn, { tagName, id: 1 }),
);

rrNode.attachShadow({ mode: 'open' });
const child = rrDocument.createElement('div');
rrDocument.mirror.add(
child,
Object.assign({}, elementSn, { tagName: 'div', id: 2 }),
);
rrNode.shadowRoot!.appendChild(child);

expect(() =>
diff(node, rrNode, replayer, rrDocument.mirror),
).not.toThrow();
expect((node as Node as HTMLElement).shadowRoot).toBeNull();
expect(node.childNodes.length).toBe(0);
});
});

describe('diff iframe elements', () => {
Expand Down
16 changes: 15 additions & 1 deletion packages/rrweb/rrweb-snapshot/src/rebuild.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
Mirror,
isNodeMetaEqual,
extractFileExtension,
attachShadowRootSafely,
} from './utils';
import postcss, { type Parser } from 'postcss';

Expand Down Expand Up @@ -112,6 +113,7 @@ function isPlausibleCustomElementName(name: string): boolean {
}

const warnedCustomElementNames = new Set<string>();
const warnedShadowHostTags = new Set<string>();

function safeDocNode(
n: textNode,
Expand Down Expand Up @@ -409,7 +411,13 @@ function buildNode(
* we can remove it.
*/
if (!node.shadowRoot) {
node.attachShadow({ mode: 'open' });
if (
!attachShadowRootSafely(node) &&
!warnedShadowHostTags.has(tagName)
) {
warnedShadowHostTags.add(tagName);
console.warn('rrweb: browser refused a shadow root on', tagName);
}
} else {
while (node.shadowRoot.firstChild) {
node.shadowRoot.removeChild(node.shadowRoot.firstChild);
Expand Down Expand Up @@ -519,6 +527,12 @@ export function buildNodeWithSN(
!skipChild
) {
for (const childN of n.childNodes) {
if (childN.isShadow && isElement(node) && !node.shadowRoot) {
// The host has no shadow root to receive this subtree. Appending it to
// the light DOM instead would put shadow-scoped <style> nodes in the
// document, where their rules apply to the whole replayed page.
continue;
}
const childNode = buildNodeWithSN(childN, {
doc,
mirror,
Expand Down
20 changes: 20 additions & 0 deletions packages/rrweb/rrweb-snapshot/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,26 @@ export function isShadowRoot(n: Node): n is ShadowRoot {
);
}

/**
* Attach an open shadow root to a rebuilt element and report whether it worked.
* Two different things can refuse. A real element is only accepted when its tag
* is a valid shadow host name per the DOM spec, so a rebuilt host can be refused
* where the recorded one was not, and `attachShadow` raises a `NotSupportedError`
* that the full-snapshot rebuild does not catch, ending playback. Under the
* virtual DOM the element is an `RRElement`, and `RRMediaElement` refuses every
* time. Callers use the return value to skip that one subtree instead.
*/
export function attachShadowRootSafely(el: {
attachShadow(init: ShadowRootInit): unknown;
}): boolean {
try {
el.attachShadow({ mode: 'open' });
return true;
} catch {
return false;
}
}
Comment thread
turnipdabeets marked this conversation as resolved.

/**
* To fix the issue https://github.com/rrweb-io/rrweb/issues/933.
* Some websites use polyfilled shadow dom and this function is used to detect this situation.
Expand Down
43 changes: 43 additions & 0 deletions packages/rrweb/rrweb-snapshot/test/rebuild.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,49 @@ describe('rebuild', function () {
) as HTMLDivElement;
expect(node.shadowRoot?.childNodes.length).toBe(1);
});

it('skips a shadow host the browser refuses instead of throwing', function () {
let node: Node | null | undefined;
expect(() => {
node = buildNodeWithSN(
{
id: 1,
// a tag that is not a valid shadow host, so attachShadow throws
tagName: 'nohyphen',
type: NodeType.Element,
attributes: {},
childNodes: [
{
id: 2,
tagName: 'style',
type: NodeType.Element,
attributes: { _cssText: '.a { color: red }' },
childNodes: [],
isShadow: true,
},
{
id: 3,
tagName: 'div',
type: NodeType.Element,
attributes: {},
childNodes: [],
isShadow: true,
},
],
isCustom: true,
isShadowHost: true,
},
{
doc: document,
mirror,
hackCss: false,
cache,
},
);
}).not.toThrow();
expect((node as HTMLElement).shadowRoot).toBeNull();
expect((node as HTMLElement).outerHTML).toBe('<nohyphen></nohyphen>');
});
});

describe('add hover class to hover selector related rules', function () {
Expand Down
12 changes: 11 additions & 1 deletion packages/rrweb/rrweb/src/replay/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
Mirror,
createMirror,
toLowerCase,
attachShadowRootSafely,
} from '@posthog/rrweb-snapshot';
import {
RRDocument,
Expand Down Expand Up @@ -1759,7 +1760,16 @@ export class Replayer {
if (mutation.node.isShadow) {
// If the parent is attached a shadow dom after it's created, it won't have a shadow root.
if (!hasShadowRoot(parent)) {
(parent as Element | RRElement).attachShadow({ mode: 'open' });
// The parent can be a tag that refuses a shadow root — a real element
// the browser rejects, or an RRMediaElement while the virtual DOM is
// in use. Skip this subtree instead of letting attachShadow abandon
// the rest of the mutation batch.
if (!attachShadowRootSafely(parent as Element | RRElement)) {
return this.warn(
'Parent does not support shadow root, skipping mutation',
mutation,
);
}
parent = (parent as Element | RRElement).shadowRoot! as Node | RRNode;
} else parent = parent.shadowRoot as Node | RRNode;
// adopt stylesheets whose event arrived before this shadow root existed
Expand Down
Loading