Feat: shadow-scoped stylesheets via adoptedStyleSheets (#164 Tier 3)#167
Conversation
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]>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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);There was a problem hiding this comment.
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]>
Summary
lib/shadow-stylesheets.tsutility adopts a constructableCSSStyleSheetinto 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>so light-tree behavior is unchanged; the adopted sheet is purely additive for shadow trees.Closes #164.
What changed
extension/src/lib/shadow-stylesheets.ts(new) —adoptStylesheetIntoShadowRoots(text)returns anAdoptedShadowSheethandle. Adopts into existing open shadow roots, subscribes to attach events for future ones, andhandle.remove()strips the sheet from every root + unsubscribes. Closed roots are never reached (the shadow-roots tracker filters them).extension/src/lib/rule-engine.ts—injectStylesadditionally adoptsPLACEHOLDER_STYLESinto shadow roots so a placeholder landing inside a shadow tree (viairrelevant-sections-redact's ref-resolution path) renders with its stripes + chrome instead of bare.extension/src/rules/ads-hide.ts—injectEasyListStylesheetadditionally adoptsEASYLIST_STYLESHEET_TEXTinto shadow roots;removeEasyListStylesheetstrips it.extension/src/__test-mocks__/jsdom-extras.ts— minimal polyfill forCSSStyleSheet+adoptedStyleSheets. Production targets Chrome 148+ (permanifest.json) which ships the real APIs; jsdom hasn't, so tests need stubs. StorescssTextfor introspection and tracksadoptedStyleSheetsas a getter/setter pair backed by a WeakMap.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 ofdocument.adoptedStyleSheets.shadow-stylesheets.property.test.ts(new, 2 fast-check properties): membership equalscurrent-open-shadows × active-handlesfor any random op sequence; adoption is idempotent (one sheet appears at most once per root) under any (shadowCount × adoptCount).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.🤖 Generated with Claude Code