diff --git a/.changeset/replay-guard-attach-shadow.md b/.changeset/replay-guard-attach-shadow.md new file mode 100644 index 0000000000..56768d978b --- /dev/null +++ b/.changeset/replay-guard-attach-shadow.md @@ -0,0 +1,5 @@ +--- +'posthog-js': patch +--- + +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. diff --git a/packages/rrweb/.eslintrc.js b/packages/rrweb/.eslintrc.js index c3cb13949c..97f1179d38 100644 --- a/packages/rrweb/.eslintrc.js +++ b/packages/rrweb/.eslintrc.js @@ -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'], } diff --git a/packages/rrweb/rrdom/src/diff.ts b/packages/rrweb/rrdom/src/diff.ts index 9970d1d28a..21210031c1 100644 --- a/packages/rrweb/rrdom/src/diff.ts +++ b/packages/rrweb/rrdom/src/diff.ts @@ -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, @@ -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: diff --git a/packages/rrweb/rrdom/src/index.ts b/packages/rrweb/rrdom/src/index.ts index 8c9c99f389..a57c76aa40 100644 --- a/packages/rrweb/rrdom/src/index.ts +++ b/packages/rrweb/rrdom/src/index.ts @@ -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 { @@ -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; } diff --git a/packages/rrweb/rrdom/test/diff.test.ts b/packages/rrweb/rrdom/test/diff.test.ts index 743b17aa07..18005dee48 100644 --- a/packages/rrweb/rrdom/test/diff.test.ts +++ b/packages/rrweb/rrdom/test/diff.test.ts @@ -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', () => { diff --git a/packages/rrweb/rrweb-snapshot/src/rebuild.ts b/packages/rrweb/rrweb-snapshot/src/rebuild.ts index 0f61d18d16..ca813ec959 100644 --- a/packages/rrweb/rrweb-snapshot/src/rebuild.ts +++ b/packages/rrweb/rrweb-snapshot/src/rebuild.ts @@ -12,6 +12,7 @@ import { Mirror, isNodeMetaEqual, extractFileExtension, + attachShadowRootSafely, } from './utils'; import postcss, { type Parser } from 'postcss'; @@ -112,6 +113,7 @@ function isPlausibleCustomElementName(name: string): boolean { } const warnedCustomElementNames = new Set(); +const warnedShadowHostTags = new Set(); function safeDocNode( n: textNode, @@ -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); @@ -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 + //