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

Skip to content

Perf: CSS-first hide for chat-widget-hide (#150 Tier 2 #13)#173

Merged
twschiller merged 2 commits into
mainfrom
perf/css-first-chat-widget-hide
Jun 5, 2026
Merged

Perf: CSS-first hide for chat-widget-hide (#150 Tier 2 #13)#173
twschiller merged 2 commits into
mainfrom
perf/css-first-chat-widget-hide

Conversation

@twschiller

Copy link
Copy Markdown
Contributor

Summary

  • Replaces chat-widget-hide's JS scan path with an injected display:none!important stylesheet (also adopted into open shadow roots).
  • New shared helper lib/css-hide-stylesheet.ts encapsulates the inject-and-adopt pattern that ads-hide was previously doing inline.
  • placeholder-count gains registerCssFirstSelectors(union) so CSS-first hides still show up in the toolbar badge total (preserves counting without keeping a JS marker pass).

Why this rule first

chat-widget-hide is the only removeEntirely: true rule with no candidateFilter — every selector targets a vendor-specific id, class prefix, or iframe attribute (Intercom, Drift, Zendesk, Crisp, Tawk.to, HubSpot, Olark, LiveChat, Freshchat, Zopim). That makes it a clean lift into pure CSS.

cookie-banner-hide and newsletter-modal-hide both depend on candidateFilter (isOverlay checks computed position; looksLikeNewsletterModal checks viewport area + text content + presence of an <input type="email">), so they stay on the JS path for now. A follow-up could split their vendor-specific selectors out to CSS-first while keeping the generic patterns on JS — but it's a per-selector judgment call worth its own PR.

Perf characteristics

Before: each chat widget injection triggered a throttled MutationObserver batch → selectorsFor(url) → full-document QSA against the union → outermost filter → per-element marker checks → inline display:none + HIDDEN_ATTR write.

After: stylesheet hides matches as soon as the browser parses them. No observer fire, no QSA per batch, no per-element JS. Lazily-injected widgets (HubSpot's conversations-embed, Intercom's loader) hide at parse time.

Counting trade-off

placeholder-count previously queried .placeholder-class, [data-abs-hidden]. CSS-first hides set neither, so without intervention the badge would drop every chat widget hidden. Three options were considered (see issue thread); chose the union-selector QSA: one extra querySelectorAll(union).length per 250ms throttle window, added to the count. Marginal cost, badge stays accurate.

EasyList in ads-hide remains uncounted — that's the existing behavior and unchanged here; lifting its 13k selectors into the badge total would be a separate decision (the count would dwarf everything else).

Test plan

  • bun run typecheck
  • bun run check (biome + eslint)
  • bun run knip
  • bun run test — 1363/1363 pass
  • Existing chat-widget-hide.test.ts rewritten to assert via getComputedStyle().display === "none" (and stylesheet injection presence) instead of HIDDEN_ATTR / inline display
  • Manual: load a page with an Intercom or HubSpot chat widget, confirm widget is hidden, confirm toolbar badge increments, confirm teardown via popup toggle restores the widget

🤖 Generated with Claude Code

chat-widget-hide is a clean CSS-first candidate — every selector is
vendor-specific id/class/iframe-attribute and the rule has no
candidateFilter. Replace the JS scan path with an injected
display:none!important stylesheet (also adopted into open shadow roots
via the existing helper).

Lazily-injected widgets (HubSpot's conversations-embed, Intercom's
loader, etc.) are now hidden at parse time with no observer, throttle,
or per-batch QSA on our side.

To preserve the toolbar badge count — which previously relied on the JS
path stamping HIDDEN_ATTR — placeholder-count now accepts a CSS-first
selector union via registerCssFirstSelectors and adds its match count to
the tally per recount. EasyList in ads-hide stays uncounted as before
(its 13k selectors would dwarf the badge).

cookie-banner-hide and newsletter-modal-hide both depend on
candidateFilter (overlay position checks, viewport area, email-input
gate) so they stay on the JS path for now.

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

Request Review

State-machine fuzzing over inject / removeHandle / pageRemovesStyle /
attachShadow ops asserts two invariants that scenario tests are bad at
exhausting:

  - After every handle is drained, no <style#id> remains in the
    document — regardless of interleaved page-side <style> removals.
  - After every handle is drained, no open shadow root carries an
    adopted sheet from any of the previously-active handles (catches a
    leak of the AdoptedShadowSheet reference past remove()).

Plus a dedicated scenario test for the orphan-cleanup path: an
orphan <style#id> planted before inject (e.g., from an interrupted
prior teardown, HMR hot-swap) is removed by the next inject + remove
cycle.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
@twschiller twschiller merged commit 10b5f9a into main Jun 5, 2026
7 checks passed
@twschiller twschiller deleted the perf/css-first-chat-widget-hide branch June 5, 2026 19:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant