Skip to content

fix(rrweb): port upstream shadow-root release on host removal (fixes #5419) - #5421

Open
frostfire575 wants to merge 1 commit into
PostHog:mainfrom
frostfire575:fix/replay-release-detached-shadow-roots
Open

frostfire575 wants to merge 1 commit into
PostHog:mainfrom
frostfire575:fix/replay-release-detached-shadow-roots

Conversation

@frostfire575

Copy link
Copy Markdown

Fixes #5419.

Problem

ShadowDomManager.addShadowRoot() registers a mutation observer, a scroll observer and an adopted-stylesheet observer for every shadow root it records. Each teardown closure holds the shadow root. Until now they were only released by reset(), which runs from takeFullSnapshot() (every 5 minutes by default) or when recording stops. resetForDoc() releases by owning document, so it only helps when an iframe is torn down. Removing a shadow host from the main document released nothing, so the host and its whole detached subtree stayed reachable from restoreHandlers until the next full snapshot.

The reporter's Next.js site renders every price as a <number-flow-react> web component. A heap snapshot after client-side navigations showed ShadowDomManager.restoreHandlers[n].handler -> closure context -> ShadowRoot -> host -> detached subtree. Each navigation loop kept about 34,000 DOM nodes, and the heap grew from 31 MB to 87 MB over 10 loops. Blocking the shadow hosts with session_recording.blockSelector kept the heap flat.

Fix

Port the shadow-root release from upstream rrweb #1958 by @salzahrani-salla, in the final form it shipped in rrweb@2.2.0. Upstream #1971 reworked #1958's recorder side before release: the release check moved from emit time into processMutation, and the per-root teardowns moved into the shadowDoms map. This PR takes only that part of #1971.

  • MutationBuffer.processMutation calls shadowDomManager.removeShadowRootsIn(n) for every removed node that is no longer in the DOM when the batch is processed. It runs before the blocked/ignored/unserialized early returns, so a host that was never serialized is still released.
  • ShadowDomManager.shadowDoms changes from a WeakSet to a Map<ShadowRoot, { doc, handlers }>. Each root's teardowns live with that root, so one root can be released without touching the rest. removeShadowRootsIn walks the removed subtree, including nested shadow roots, and releases every observed root it finds.
  • The deferred adopted-stylesheet setup checks that its root is still registered before it runs. A root released (or a manager reset) inside that setTimeout(0) window no longer gets an observer registered after its teardown already ran.

Adapting to the fork

The fork's copy has diverged from upstream in these files, so this is an adaptation rather than a copy:

Left out of #1971 on purpose: releasing the root buffer and the cross-origin message listener on stop, observing same-origin iframes once instead of on every checkout, the reference-counted Safari MutationObserver helper iframe, and moving canvasManager freeze/lock out of MutationBuffer. None of them is needed for this leak, and each touches lifecycle code the fork has already changed on its own.

Move and re-insert

A host removed and re-attached within one mutation batch is still in the DOM when the batch is processed, so inDom(n) is true and nothing is released. A host removed in one batch and re-inserted in a later one is released, then re-observed: re-serializing it in pushAdd calls addShadowRoot again, and the root is no longer in shadowDoms.

Replay incident risk

.agents/skills/replay-incident-risk/check.mjs matches one class on path: rrweb serialization and snapshot correctness. Nothing here touches style serialization. The adopted-stylesheet change only skips a deferred registration for a root that was already released or reset. What this class warns about is silent loss. A host released while it is still attached would stop recording its shadow content with no error, which is what the bug fixed in #4112 looked like. The new real-browser test in test/record.test.ts covers that. It moves a host within one batch, removes and re-inserts it in a later batch, and asserts that shadow text changes after both are still recorded. It also asserts a change made after the final removal is not recorded. No change to recording volume or start conditions.

Tests

  • test/record/shadow-root-release.test.ts (new, jsdom, through record() with a real MutationObserver): top-level host removed, repeated mount/unmount does not grow mutationBuffers, nested shadow roots released with their ancestor host, host moved within the same batch, host removed then re-inserted, host moved then removed in the same batch.
  • test/record/shadow-dom-manager.test.ts: removeShadowRootsIn releases only roots in the removed subtree. Iframe teardown via resetForDoc still releases iframe roots after a top-level host was released, and still leaves top-level roots observed.
  • test/record.test.ts (puppeteer): the move / re-insert / final-removal case above.

On unfixed code, 5 of the 6 new jsdom tests and all 3 new ShadowDomManager tests fail. The move-within-batch test and the puppeteer test pass before and after; they guard against releasing too much.

  • pnpm --filter @posthog/rrweb check-types
  • oxlint on src and the touched tests
  • pnpm test:rrweb. I ran it locally on Windows. The only failures are puppeteer hook timeouts that also show up on unmodified main on the same machine, and they move between runs. Every file that failed only in a run with this change passed when rerun alone on the same build, though two needed more than one try: test/replay/hover.test.ts (a replayer image-snapshot test, 4 of 5 passes on its own) and rrweb-snapshot's test/integration.test.ts (failed once alone, then 3 of 3). Neither runs recorder code changed here.

Changeset

posthog-js patch only, per RELEASING.md (no changesets for packages/rrweb/*).

Attribution

The release approach, removeShadowRootsIn and the inDom check at removal time come from upstream rrweb:

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

Port the shadow-root release from rrweb-io/rrweb#1958 (as shipped in rrweb 2.2.0), adapted to the fork's per-document teardown. Fixes PostHog#5419.
@salzahrani-salla

Copy link
Copy Markdown

@marandaneto appreciate your kind attention

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Session replay: port rrweb fixes for detached shadow-DOM leak (rrweb#1958, rrweb#1971)

2 participants