fix(replay): guard attachShadow so an unsupported shadow host does not kill playback - #4594
Conversation
…t kill playback The player calls attachShadow on any node marked as a shadow host, in the full snapshot rebuild and in incremental mutations. Neither site checked that the rebuilt tag accepts a shadow root, so a host that comes back as a tag the browser refuses raised a NotSupportedError that escaped unhandled and aborted the whole rebuild. Add a shared attachShadowRootSafely helper that reports whether the attach worked. The rebuild path skips the shadow root; the mutation path skips the subtree and warns instead of dereferencing a null shadowRoot a line later. Generated-By: PostHog Desktop Task-Id: 8948b193-aacc-4857-a0cf-3b37113c8158
Replay incident risk checkThis diff touches code involved in past incidents. This is a heads-up, not a verdict: read the matched sections of INCIDENTS.md and answer their review questions before merging. For a judgment on whether this diff has the same failure mode, run the |
|
Size Change: +14.4 kB (+0.07%) Total Size: 20.2 MB 📦 View Changed
ℹ️ View Unchanged
|
Declare the packages whose source actually changed. posthog-js bumps automatically through its workspace:* dependency on @posthog/rrweb. Generated-By: PostHog Desktop Task-Id: 8948b193-aacc-4857-a0cf-3b37113c8158
Guards the rrdom diff and buildFromNode paths, skips a shadow subtree whose host refused a shadow root instead of appending it to the light DOM where its styles would apply document-wide, and adds a lint rule so a new unguarded call site fails lint.
Pull request risk assessment: REVIEWI checked commit The merge base is SnapshotRepository: The merge-base diff changes these 11 files:
One or more checks need human review. This result does not mean the change is incorrect or should be closed. SandboxI used one isolated static HTML application for the base and head builds. The application SHA-256 was I built each I ran the application in Headless Chrome 147 on macOS. I fixed the viewport at 800 by 600 pixels. The application completed these steps:
The base refused-host flow threw an exception. The head refused-host flow rendered the host and the normal sibling without an exception. Both builds recorded six events. Both builds recorded the full snapshot, one target mutation, and one button click. The linked inbox report required a PostHog login. I did not use that report as evidence. Automated checks
Required action
|
dustinbyrne
left a comment
There was a problem hiding this comment.
the fix is valid, though worth @PostHog/team-replay taking a look as this touches the playback engine
packages/rrweb/** is never published; AGENTS.md requires rrweb changes to ship under a posthog-js changeset.
|
… root Gates the skip on the parent being a recorded shadow host, so a recording that never marked one keeps rendering those children in the light DOM.
Problem
Replay playback dies — not degrades — for a recording containing a shadow host the player cannot attach a shadow root to.
attachShadowis called on every node marked as a shadow host. In the full-snapshot rebuild (rrweb-snapshot/src/rebuild.ts) that call is uncaught:rebuildFullSnapshotis reached viawrappedCastFnfromTimer.rafCheck, and neither has atry/catch, so the throw escapes the rAF callback before the next frame is scheduled and the timer stops permanently. Dead player.Two different things can refuse:
NotSupportedError: Element.attachShadow, with a deepbuildNodeWithSNrecursion in the stack — i.e. the full-snapshot path.RRMediaElementwhile the virtual DOM is in use (seeking / fast-forward) — it refuses unconditionally, throwing a plainError.The mutation and diff paths are already wrapped in
try/catch, so there the throw abandons the rest of the batch/flush rather than ending playback. Still worth guarding, but the fatal case is the rebuild.Corrections to the original write-up
customElements.definethrew — are not the mechanism.attachShadowgates on the local name alone, sodocument.createElement('my-widget').attachShadow({mode:'open'})succeeds even when the element was never defined and even whendefinethrew. Verified in jsdom and Chrome.attachShadowcall sites were originally identified.Impact
Small but live and still recurring: 5 occurrences, 2 sessions, 1 user, all Firefox 153 / macOS, hitting the replay player. First seen 2026-08-21, last seen 2026-08-24. The error-tracking issue is Active, priority High, assigned to Team Replay. The same vendored rrweb powers every replay viewer, and the failure mode is a dead player, not a degraded one.
Changes
attachShadowRootSafelyhelper that attempts the attach and reports whether it worked. It is exported from@posthog/rrweb-snapshotso the four call sites across the rrweb packages can share it rather than each keeping a copy of the sametry/catch. Not public API —packages/rrweb/**is omitted from the publish matrix inrelease.ymland is bundled into the browser SDK at build time.rrweb-snapshot/src/rebuild.ts— full-snapshot rebuild (the fatal one)rrweb/src/replay/index.ts— incremental mutationsrrdom/src/diff.ts— virtual-DOM → real-DOM diff, which also dereferencedshadowRoot!on the next linerrdom/src/index.ts—buildFromNode, returningnullsowalkskips the subtreebuildNodeWithSN's append chain fell through tonode.appendChild(childNode), which puts shadow-scoped<style>nodes in the document where their rules apply to the whole replayed page. Verified: a refused host produced<nohyphen><style>.leaked { color: red }</style><div></div></nohyphen>.warnedCustomElementNamespattern.no-restricted-syntaxrule in the rrweb ESLint config so a new unguardedattachShadowfails lint (super.calls excluded —rrdom-nodejsneeds one).mainfor the asserted reason: arebuild.tscase that also pins the subtree is not inlined into the light DOM; anrrdomdiff case; and an end-to-end replayer case in real Chromium covering theRRMediaElementpath.How this was tested
Each guard was verified by reverting it and confirming the test fails for the asserted reason, rather than only by watching it pass.
mainrebuild.test.ts(jsdom) — full-snapshot path, the fatal oneDOMException: NotSupportedErrorouterHTMLalso pins that the shadow subtree is not inlined into the light DOMrrdom/test/diff.test.ts(jsdom) — virtual → real diffDOMExceptionshadowRootnull, no children leakedreplayer.test.ts(real Chromium) — mutation path under the virtual DOM<video>host absent: the whole flush is abandoned and neither mutation batch reaches the DOMAlso run:
rrwebreplayer 57/57 in Chromium,rrdom148/148,rrweb-snapshot270 passed / 1 skipped (includes the puppeteer integration and shadow-dom suites).tsc --noEmitclean inrrweb-snapshot,rrdomandrrweb;eslint 'packages/rrweb/*/src/**/*.ts'exit 0.attachShadowerrors, andsuper.attachShadowoverrides are ignored.<nohyphen><style>.leaked { color: red }</style><div></div></nohyphen>.Not covered: the exact tag Firefox refused in the field, and no run in Firefox or Safari — all browser evidence here is headless Chromium.
Release info Sub-libraries affected
Libraries affected
Checklist
If releasing new changes
pnpm changesetto generate a changeset file🤖 Agent context
Autonomy: Fully autonomous for the original commits; the follow-up commit was authored during maintainer review.
try/catcharound the realattachShadowis the reliable check.Created with PostHog Desktop from this inbox report.