Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Feat: shadow-aware subtree watcher (#164 Tier 1)#165

Merged
twschiller merged 3 commits into
mainfrom
feat/shadow-aware-subtree-watcher
Jun 5, 2026
Merged

Feat: shadow-aware subtree watcher (#164 Tier 1)#165
twschiller merged 3 commits into
mainfrom
feat/shadow-aware-subtree-watcher

Conversation

@twschiller

@twschiller twschiller commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

Summary

  • 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.
  • Adds a ShadowDomEmbed to the demo home page that mounts a chat-widget marker and an ins.adsbygoogle block 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-only attachShadow patch + a discoverShadowRootsIn walk for roots attached before document_idle. Closed roots are explicitly skipped.
  • extension/src/lib/subtree-watcher.ts — each router now owns a per-shadow-root MutationObserver, subscribes to attach events, and seeds subscribers' pending sets from existing shadow children. fanOut also 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.
  • Tests:
    • shadow-roots.test.ts (new) — unit tests for the hook + discovery contract.
    • subtree-watcher.test.ts — added a describe("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:
      • closed shadow roots never enter the open-tracker, for any random tree shape;
      • discoverShadowRootsIn is 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;
      • the subtree watcher's dispatch payload covers every open-shadow element and excludes every closed-shadow element, across nested forests.
  • demo-site/src/components/ShadowDomEmbed.tsx (new) + a one-line Home.tsx wiring.

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.
  • Manual: load the unpacked extension, open the demo home page, confirm the chat launcher and sponsored block inside the shadow root are hidden / replaced with placeholders.

🤖 Generated with Claude Code

…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]>
@vercel

vercel Bot commented Jun 5, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
agent-browser-shield-demo-site Ready Ready Preview, Comment Jun 5, 2026 3:08pm

Request Review

@unblocked unblocked Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

router.observer?.disconnect();

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).

Comment thread extension/src/lib/subtree-watcher.ts Outdated
Comment on lines +244 to +246
for (const observer of router.shadowObservers.values()) {
observer.observe(router.target, observerInit(router));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Suggested change
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));
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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]>
@twschiller

Copy link
Copy Markdown
Contributor Author

@unblocked — addressing the visibility-disconnect issue from your review summary:

Fixed in 6e3665d. handleVisibilityChange now disconnects every per-shadow observer alongside the main one when the tab goes hidden; the existing refreshObservation call on visibility restore reattaches them all. Added a regression test that asserts no dispatch fires for a shadow-root append while document.hidden is true.

(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

@twschiller twschiller self-assigned this Jun 5, 2026
@twschiller twschiller added bug Something isn't working enhancement New feature or request labels Jun 5, 2026
@twschiller twschiller merged commit 07c04df into main Jun 5, 2026
7 checks passed
@twschiller twschiller deleted the feat/shadow-aware-subtree-watcher branch June 5, 2026 15:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Audit: rules are blind to content inside shadow roots

1 participant