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

Skip to content

Feat: shadow-scoped stylesheets via adoptedStyleSheets (#164 Tier 3)#167

Merged
twschiller merged 2 commits into
mainfrom
feat/shadow-adopted-stylesheets
Jun 5, 2026
Merged

Feat: shadow-scoped stylesheets via adoptedStyleSheets (#164 Tier 3)#167
twschiller merged 2 commits into
mainfrom
feat/shadow-adopted-stylesheets

Conversation

@twschiller

Copy link
Copy Markdown
Contributor

Summary

Closes #164.

What changed

  • extension/src/lib/shadow-stylesheets.ts (new) — adoptStylesheetIntoShadowRoots(text) returns an AdoptedShadowSheet handle. Adopts into existing open shadow roots, subscribes to attach events for future ones, and handle.remove() strips the sheet from every root + unsubscribes. Closed roots are never reached (the shadow-roots tracker filters them).
  • extension/src/lib/rule-engine.tsinjectStyles additionally adopts PLACEHOLDER_STYLES into shadow roots so a placeholder landing inside a shadow tree (via irrelevant-sections-redact's ref-resolution path) renders with its stripes + chrome instead of bare.
  • extension/src/rules/ads-hide.tsinjectEasyListStylesheet additionally adopts EASYLIST_STYLESHEET_TEXT into shadow roots; removeEasyListStylesheet strips it.
  • extension/src/__test-mocks__/jsdom-extras.ts — minimal polyfill for CSSStyleSheet + adoptedStyleSheets. Production targets Chrome 148+ (per manifest.json) which ships the real APIs; jsdom hasn't, so tests need stubs. Stores cssText for introspection and tracks adoptedStyleSheets as a getter/setter pair backed by a WeakMap.
  • Tests:
    • shadow-stylesheets.test.ts (new, 9 cases): adoption into existing + future shadows, closed-root opt-out, dedup, remove() unwinding, remove() idempotence, preservation of other adopted sheets, no-touch of document.adoptedStyleSheets.
    • shadow-stylesheets.property.test.ts (new, 2 fast-check properties): membership equals current-open-shadows × active-handles for any random op sequence; adoption is idempotent (one sheet appears at most once per root) under any (shadowCount × adoptCount).
  • Demo: ShadowDomEmbed's description updated to call out which tier handles which fixture (selector dispatch, EasyList stylesheet, text walker).

Test plan

  • bun jest — 1333 / 1333 pass (11 new tests).
  • bun run check — biome + eslint clean.
  • bun run build — extension + demo bundles build clean.
  • Manual: load the unpacked extension on a page using a web component whose shadow contains an EasyList-matching ad container — confirm it's hidden.

🤖 Generated with Claude Code

Tiers 1 (#165) and 2 (#166) made the watcher + dispatcher + text
walkers shadow-aware. The remaining blind spot was the CSS path:
document stylesheets do not cross shadow boundaries, so the two
sheets the extension injects at document scope were both unreachable
inside web-component shadow trees:

  - ads-hide's EasyList stylesheet (~13k display:none selectors).
    Curated selectors went through the watcher and were already
    shadow-aware after Tier 1; the generic EasyList CSS path was not.
  - rule-engine's placeholder/reveal-button styles. A placeholder
    landed via irrelevant-sections-redact's ref resolution into an
    open shadow root rendered as a bare <div> instead of with its
    stripes + chrome.

Approach: a new lib/shadow-stylesheets.ts adopts a constructable
CSSStyleSheet into every tracked open shadow root (existing +
future). One sheet shared by reference — no second parse per root,
no per-root <style> injection. Document scope keeps its existing
<style> injection so light-tree behavior is unchanged; the adopted
sheet is purely additive for shadow trees. Closed shadow roots are
not adopted into (the shadow-roots tracker filters them by design).

Wired into both consumers:
  - rule-engine.injectStyles: adopt PLACEHOLDER_STYLES into shadows.
  - ads-hide.injectEasyListStylesheet: adopt EASYLIST_STYLESHEET_TEXT
    into shadows; teardown calls handle.remove() to strip it.

Tests
- shadow-stylesheets.test.ts: 9 cases covering adoption into existing
  + future shadows, closed-root opt-out, dedup, remove() unwinding,
  remove() idempotence, preservation of other adopted sheets.
- shadow-stylesheets.property.test.ts: 2 fast-check properties —
  membership equals current-open-shadows × active-handles for any
  random op sequence, and adoption is idempotent (one sheet appears
  at most once per root) under any (shadowCount × adoptCount).

jsdom polyfill
- jsdom-extras.ts grows a minimal CSSStyleSheet + adoptedStyleSheets
  polyfill. Production targets Chrome 148+ (per the manifest) which
  ships the real APIs; jsdom hasn't, so tests need stubs. The
  polyfill stores cssText for introspection and tracks
  adoptedStyleSheets as a getter/setter pair backed by a WeakMap.

Demo
- ShadowDomEmbed description updated to call out which tier handles
  which fixture (selector dispatch, EasyList stylesheet, text walker).

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:46pm

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.

1 issue 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.

// shares one CSSStyleSheet across every open shadow root (existing
// + future), so the hide is applied uniformly without a second
// parse per root.
adoptedSheet = adoptStylesheetIntoShadowRoots(EASYLIST_STYLESHEET_TEXT);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The guard on line 50 is injectedStyle?.isConnected, which deliberately allows re-entry when the <style> element has been removed from the DOM by page JS. In that case adoptedSheet from the previous call is still active (its subscribeShadowRootAttached listener keeps running and the old CSSStyleSheet remains adopted in every shadow root), but it is silently overwritten here without calling .remove(). This leaks the subscription and leaves orphaned sheets in shadow roots.

Call adoptedSheet?.remove() before creating the new handle:

  injectedStyle = style;
  // Clean up any prior adopted sheet that survived an external <style>
  // removal (the isConnected guard allows re-entry in that case).
  adoptedSheet?.remove();
  adoptedSheet = adoptStylesheetIntoShadowRoots(EASYLIST_STYLESHEET_TEXT);

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 7daa4d4 — adopted the suggestion verbatim: adoptedSheet?.remove() before re-adopting on re-entry.

Confirmed the diagnosis: the injectedStyle?.isConnected guard allows re-entry when page JS rips out our <style> element, and on that path we were overwriting the handle without calling .remove(), leaking both the prior CSSStyleSheet (still adopted in every shadow root) and the prior subscribeShadowRootAttached listener (still active for future shadow attachments). Each external <style> removal compounded the leak.

Added a regression test in ads-hide.test.ts that asserts exactly one adopted EasyList sheet in every open shadow root after an external <style> removal + re-apply round-trip. Verified failing without the fix (Received length: 2, two sheets accumulated) and passing with it.

— Claude Code, on behalf of @twschiller

Tear down the prior adopted shadow sheet before re-adopting on
re-entry, flagged by unblocked[bot] on #167.

injectEasyListStylesheet's reentry guard is
`injectedStyle?.isConnected` — so if page JS removed our <style>
element, the next apply() reinjects it. The shadow adoption handle
was overwritten without calling .remove(), leaving the prior
CSSStyleSheet adopted in every shadow root AND leaving the prior
subscribeShadowRootAttached listener active for every future
shadow attachment. Each external <style> removal compounded the
leak.

Fix: adoptedSheet?.remove() before re-adopting.

Regression test in ads-hide.test.ts asserts that after an external
<style> removal + re-apply, every open shadow root contains exactly
one adopted EasyList sheet (the fresh one), not two. Verified
failing without the fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
@twschiller twschiller merged commit 5b272a1 into main Jun 5, 2026
7 checks passed
@twschiller twschiller deleted the feat/shadow-adopted-stylesheets branch June 5, 2026 15:48
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.

Audit: rules are blind to content inside shadow roots

1 participant