Repository navigation
fix(rrweb): port upstream shadow-root release on host removal (fixes #5419) - #5421
Open
frostfire575 wants to merge 1 commit into
Open
frostfire575 wants to merge 1 commit into
frostfire575 wants to merge 1 commit into
Conversation
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.
frostfire575
requested review from
TueHaulund,
fasyy612 and
ksvat
and removed request for
a team
October 24, 2026 18:26
|
@marandaneto appreciate your kind attention |
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 byreset(), which runs fromtakeFullSnapshot()(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 fromrestoreHandlersuntil 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 showedShadowDomManager.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 withsession_recording.blockSelectorkept 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 intoprocessMutation, and the per-root teardowns moved into theshadowDomsmap. This PR takes only that part of #1971.MutationBuffer.processMutationcallsshadowDomManager.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.shadowDomschanges from aWeakSetto aMap<ShadowRoot, { doc, handlers }>. Each root's teardowns live with that root, so one root can be released without touching the rest.removeShadowRootsInwalks the removed subtree, including nested shadow roots, and releases every observed root it finds.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:
restoreHandlersstays tagged by document and now holds only theattachShadowpatches. Upstream has noresetForDoc. Here it also releases every shadow root whose owner document matches, so iframe teardown (fix(replay): stop iframe teardown from disconnecting shadow DOM observers #4112, fix(replay): key shadow roots by host owner document #4113) works the same as before. It now also drops those roots fromshadowDomsinstead of leaving them in aWeakSet.dom.host(shadowRoot)?.ownerDocument), as fix(replay): key shadow roots by host owner document #4113 introduced.buffer.destroy()andbuffer.releaseCanvasManager(), and splices the buffer out ofmutationBuffers. It still never callsbuffer.reset(), which the recursion-guard test requires. Upstream'sremoveMutationBuffer/observerBuffershelpers are not needed.rrweb-snapshotchange (makeremoveNodeFromMaprecurse into shadow roots) has been in the fork since the monorepo move (chore: move posthog-rrweb into the monorepo #3510) and is already covered inrrweb-snapshot/test/utils.test.ts.forEachMutationBufferhelper. feat: allow answer question when clicking on the choice #1958 added it because emit-time removal could splicemutationBufferswhile thelock()/unlock()loops iterate it. Upstream then replaced it by turningmutationBuffersinto aSet(Remove awkwardforEachMutationBufferby replacing with Set rrweb-io/rrweb#1973). The fork keeps its array, and that is safe here: with the release inprocessMutation, removal only runs from aMutationObservercallback, never inside those loops.Left out of #1971 on purpose: releasing the root buffer and the cross-origin
messagelistener on stop, observing same-origin iframes once instead of on every checkout, the reference-counted SafariMutationObserverhelper iframe, and movingcanvasManagerfreeze/lock out ofMutationBuffer. 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 inpushAddcallsaddShadowRootagain, and the root is no longer inshadowDoms.Replay incident risk
.agents/skills/replay-incident-risk/check.mjsmatches 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 intest/record.test.tscovers 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, throughrecord()with a realMutationObserver): top-level host removed, repeated mount/unmount does not growmutationBuffers, 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:removeShadowRootsInreleases only roots in the removed subtree. Iframe teardown viaresetForDocstill 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
ShadowDomManagertests 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-typesoxlintonsrcand the touched testspnpm test:rrweb. I ran it locally on Windows. The only failures are puppeteer hook timeouts that also show up on unmodifiedmainon 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) andrrweb-snapshot'stest/integration.test.ts(failed once alone, then 3 of 3). Neither runs recorder code changed here.Changeset
posthog-jspatch only, per RELEASING.md (no changesets forpackages/rrweb/*).Attribution
The release approach,
removeShadowRootsInand theinDomcheck at removal time come from upstream rrweb:processMutationand the teardowns into theshadowDomsmap🤖 Agent context
Autonomy: Human-driven (agent-assisted)
processMutation) rather than feat: allow answer question when clicking on the choice #1958's original emit-time release, because fix(record): release observers, buffers and listeners the recorder no longer needs (supersedes #1968) rrweb-io/rrweb#1971 reworked that part before release. The rest of chore: fix changelog #1971 was left out to keep this PR to the leak.test/record.test.tswas added after the repo's replay incident-risk check flagged silent recording loss as the relevant risk.