Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
5 changes: 5 additions & 0 deletions .changeset/replay-guard-attach-shadow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'posthog-js': patch
---
Comment thread
turnipdabeets marked this conversation as resolved.

Fix session replay playback ending when a recording contains a shadow host the browser refuses. The player now skips that one subtree instead of aborting the 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
21 changes: 20 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,17 @@ export function buildNodeWithSN(
!skipChild
) {
for (const childN of n.childNodes) {
if (
childN.isShadow &&
n.isShadowHost &&
isElement(node) &&
!node.shadowRoot
) {
// The browser refused a shadow root on this host, so there is nowhere for this
// subtree to go. Appending it to the light DOM instead would put shadow-scoped
// <style> nodes in the document, where their rules apply to the whole 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
71 changes: 71 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,77 @@ 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>');
});

it('keeps shadow-marked children when the parent is not a shadow host', function () {
const node = buildNodeWithSN(
{
id: 1,
tagName: 'div',
type: NodeType.Element,
attributes: {},
childNodes: [
{
id: 2,
tagName: 'span',
type: NodeType.Element,
attributes: {},
childNodes: [],
isShadow: true,
},
],
},
{
doc: document,
mirror,
hackCss: false,
cache,
},
) as HTMLDivElement;
expect(node.outerHTML).toBe('<div><span></span></div>');
});
});

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