Feat: shadow-aware subtree watcher (#164 Tier 1)#165
Conversation
…er 1) Patches Element.prototype.attachShadow so the shared subtree watcher observes open shadow roots alongside document.body, fanning their mutations and existing children into the same subscriber pipeline. No rule-code changes — every createSubtreeWatcher / createSelectorHideRule rule now reaches content rendered inside open shadow trees. Closed shadow roots stay opaque by design. Text-walk rules (pii-redact, prompt-injection-redact, etc.) are a separate fix tracked under #164 Tier 2. Demo home page adds a ShadowDomEmbed that mounts a chat-widget marker and an adsbygoogle ins block inside an open shadow root, so reviewers can confirm the dispatcher actually reaches them. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
2 issues found.
About Unblocked
Unblocked has been set up to automatically review your team's pull requests to identify genuine bugs and issues.
📖 Documentation — Learn more in our docs.
💬 Ask questions — Mention @unblocked to request a review or summary, or ask follow-up questions.
👍 Give feedback — React to comments with 👍 or 👎 to help us improve.
⚙️ Customize — Adjust settings in your preferences.
Shadow observers are not disconnected when the tab is hidden
handleVisibilityChange disconnects router.observer when the tab is hidden to stop receiving mutations in background tabs, but the per-shadow-root observers in router.shadowObservers are not disconnected. They continue to fire fanOut callbacks in the background, defeating the stated optimization. Add a loop to disconnect them alongside the main observer:
router.observer?.disconnect();
for (const observer of router.shadowObservers.values()) {
observer.disconnect();
}The subsequent refreshObservation call on visibility restore will re-observe them (once the target bug above is also fixed).
| for (const observer of router.shadowObservers.values()) { | ||
| observer.observe(router.target, observerInit(router)); | ||
| } |
There was a problem hiding this comment.
Each shadow-root MutationObserver is correctly created observing its shadowRoot (line 284 in adoptShadowRoot), but refreshObservation re-observes every shadow observer on router.target (e.g. document.body) instead of its respective shadow root. After refreshObservation runs — which happens on every hidden→visible tab switch and whenever a new attribute-observing subscriber joins — all shadow MutationObservers silently stop watching their shadow roots and redundantly watch the body. Shadow mutations will be completely missed from that point on.
Iterate entries() instead of values() so each observer re-observes its own shadow root:
| for (const observer of router.shadowObservers.values()) { | |
| observer.observe(router.target, observerInit(router)); | |
| } | |
| for (const [shadowRoot, observer] of router.shadowObservers.entries()) { | |
| observer.observe(shadowRoot, observerInit(router)); | |
| } |
There was a problem hiding this comment.
Fixed in 6e3665d — adopted the suggestion verbatim: iterate shadowObservers entries and re-observe each one on its own shadow root.
You called the diagnosis slightly off (per the DOM spec, observe() with a different target appends a new registration rather than replacing the original), but the symptom you described is real either way: after every refreshObservation call the shadow observers were double-watching body alongside their shadow root, double-firing every body mutation through fanOut. Added a regression test that asserts exactly one dispatch fires for a body addition after a visibility hide→restore round-trip.
— Claude Code, on behalf of @twschiller
Three structural invariants that fuzz better than they assert: - Closed shadow roots never enter the open-tracker, for any random tree shape. Symmetric: every host with a non-null .shadowRoot is in the tracker (open is the only mode exposed through that property). - discoverShadowRootsIn is set-idempotent under any interleaving of attach / discover operations, and after a registry reset it rebuilds exactly the set of open roots reachable from the target without crossing closed shadow boundaries. - The subtree watcher's dispatch payload contains every element living inside any reachable open shadow root, transitively across nesting, and contains no element living inside a closed shadow. The dispatch-coverage property runs under fake timers with fc.asyncProperty and is capped at 30 runs to keep the suite snappy — plenty to surface obvious shapes, deeper fuzz would belong in a nightly run. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Two bugs in the shadow-aware watcher path, both flagged by unblocked[bot]: - refreshObservation re-observed each shadow MutationObserver on router.target instead of its own shadow root. Per the DOM spec, observe() with a different target appends a new registration rather than replacing the original, so after every visibility-restore or attribute-upgrade refresh, shadow observers ended up double-watching body alongside their shadow root and double-firing every body mutation through fanOut. Iterate the Map entries and observe each observer on its own shadow root. - handleVisibilityChange disconnected the main observer but not the per-shadow observers, leaving them firing fanOut callbacks in background tabs and defeating the visibility-pause optimization. Disconnect shadow observers alongside the main one; the existing refreshObservation path re-attaches them all on visibility restore. Added regression tests for both: the visibility-disconnect path asserts no dispatch fires on a shadow append while hidden, and the refreshObservation path asserts a body addition after visibility restore produces exactly one dispatch (a double-registration would have produced two). Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
|
@unblocked — addressing the visibility-disconnect issue from your review summary: Fixed in 6e3665d. (The shadow-root re-observation bug from your inline comment is fixed in the same commit — replied on that thread directly.) — Claude Code, on behalf of @twschiller |
Summary
Element.prototype.attachShadowso the shared subtree watcher observes open shadow roots alongsidedocument.body, fanning their mutations and existing children into the same subscriber pipeline.createSubtreeWatcher/createSelectorHideRulerule now reaches content rendered inside open shadow trees.ShadowDomEmbedto the demo home page that mounts a chat-widget marker and anins.adsbygoogleblock inside an open shadow root so reviewers can confirm the dispatcher actually reaches them.Closes #164 Tier 1. Closed shadow roots stay opaque by design. Text-walk rules (
pii-redact,prompt-injection-redact, etc.) are the separate Tier 2 fix tracked in the same issue.What changed
extension/src/lib/shadow-roots.ts(new) — open-onlyattachShadowpatch + adiscoverShadowRootsInwalk for roots attached beforedocument_idle. Closed roots are explicitly skipped.extension/src/lib/subtree-watcher.ts— each router now owns a per-shadow-rootMutationObserver, subscribes to attach events, and seeds subscribers' pending sets from existing shadow children.fanOutalso discovers shadows on freshly inserted hosts so pre-populated web components aren't missed at the same drain that surfaces the host.extension/src/content.ts— installs the hook at script entry.shadow-roots.test.ts(new) — unit tests for the hook + discovery contract.subtree-watcher.test.ts— added adescribe("shadow DOM", …)block covering pre-existing shadow content, post-start attachments, mutations inside shadows, nested shadow roots, late-joining subscribers, the placeholder skip, head-vs-body router isolation, and closed-shadow opacity.shadow-roots.property.test.ts(new) — fast-check property tests for three invariants that fuzz better than they assert:discoverShadowRootsInis set-idempotent under any interleaving of attach/discover ops, and after a registry reset rebuilds exactly the set of open roots reachable without crossing closed boundaries;demo-site/src/components/ShadowDomEmbed.tsx(new) + a one-lineHome.tsxwiring.Test plan
bun jest— 1314 / 1314 pass (including the 5 new property tests).bun run check— biome + eslint clean (extension + demo-site).bun run build— both the extension and the demo bundle build clean.🤖 Generated with Claude Code