Perf: CSS-first hide for chat-widget-hide (#150 Tier 2 #13)#173
Merged
Conversation
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]>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
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]>
This was referenced Jun 5, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
display:none!importantstylesheet (also adopted into open shadow roots).lib/css-hide-stylesheet.tsencapsulates the inject-and-adopt pattern thatads-hidewas previously doing inline.placeholder-countgainsregisterCssFirstSelectors(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-hideis the onlyremoveEntirely: truerule with nocandidateFilter— 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-hideandnewsletter-modal-hideboth depend oncandidateFilter(isOverlaychecks computed position;looksLikeNewsletterModalchecks 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 → inlinedisplay:none+HIDDEN_ATTRwrite.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-countpreviously 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 extraquerySelectorAll(union).lengthper 250ms throttle window, added to the count. Marginal cost, badge stays accurate.EasyList in
ads-hideremains 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 typecheckbun run check(biome + eslint)bun run knipbun run test— 1363/1363 passchat-widget-hide.test.tsrewritten to assert viagetComputedStyle().display === "none"(and stylesheet injection presence) instead ofHIDDEN_ATTR/ inlinedisplay🤖 Generated with Claude Code