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

Skip to content

Perf: id/class token index + scan from inserted roots (#150 Tier 2)#157

Merged
twschiller merged 2 commits into
mainfrom
perf/id-class-token-index
Jun 5, 2026
Merged

Perf: id/class token index + scan from inserted roots (#150 Tier 2)#157
twschiller merged 2 commits into
mainfrom
perf/id-class-token-index

Conversation

@twschiller

@twschiller twschiller commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Adds lib/selector-token-index.ts: a reverse index keyed on id/class tokens that maps each token to the rules whose selectors contain it, plus a complex-fallback bucket for attribute/combinator/pseudo selectors. uBO, Brave, and Ghostery all converge on this independently.
  • Refactors lib/selector-hide-rule.ts to register with the index instead of building its own MutationObserver. The token index owns a single shared createSubtreeWatcher subscription; on each added subtree it computes the triggered rule set in one walk and fans out scans only to matching rules.
  • scan(root) now matches root itself (via Element.matches) in addition to its descendants. The old "rescan from document.body" workaround for top-level container insertions (HubSpot, OneTrust, Cookiebot) is no longer needed — and the comment at the former selector-hide-rule.ts:158 is gone with it.

What changes for callers

  • No public API change. Each rule built via createSelectorHideRule continues to expose apply / optional teardown. The watcher-vs-no-watcher distinction is preserved.
  • For watchSubtrees: true rules, the per-rule MutationObserver subscription is replaced by a single shared dispatcher. Throttle / route-change / visibility behavior all flow through the existing shared router unchanged.

Mechanics

  • parseSelector strictly recognizes bare `#ident` and `.ident`. Anything else (`div`, `.a.b`, `[role="dialog"]`, `#x .y`, pseudos) lands in complex-fallback.
  • findTriggeredRules(root) walks `root` + descendants once, looking up each `element.id` and `element.classList` in the indexes and union-ing `complexFallback`. Constant-time per element rather than full-doc QSA per rule.
  • Document.body sweeps (router's route-change rAF) short-circuit token lookup and trigger every registered rule — walking the full body to compute triggers would defeat the purpose.
  • siteRule selectors are indexed alongside `alwaysOnSelectors`. URL gating stays inside the rule's own `scan` via the existing per-URL memo; over-triggering an off-URL rule costs one no-op `scan` call against the added root.

Behavioral risk under DOM mutations

The shared MutationObserver is configured { childList: true, subtree: true } (unchanged from before this PR). What changes is what we do with each batch's added roots.

Scenario Old behavior New behavior Risk
Element inserted with id/class already set (React/Vue/Svelte commit, appendChild after building the node off-document, CMP <div id=\"onetrust-banner-sdk\"> injection) Full-doc QSA per rule on next throttle drain → match found Dispatcher walks added root, looks up tokens → match found ✓ none
Element moved between parents MO fires addedNodes=[el] on new parent; full-doc QSA finds it MO fires same; dispatcher walks added root; rule's scan checks HIDDEN_ATTR/REVEALED_ATTR so already-hidden elements stay hidden ✓ none
replaceChild(new, old) addedNodes=[new] → full-doc QSA picks it up addedNodes=[new] → dispatcher walks new ✓ none
Element removed Nothing to do Nothing to do ✓ none
innerHTML overwrite on a container addedNodes carries the parsed children → full-doc QSA picks up matches Same addedNodes; dispatcher walks each ✓ none
SPA route change Full-doc rescan via the router's rAF callback Dispatcher short-circuits root === document.body to "trigger every registered rule," each rule scans document.body ✓ none
element.id = 'x' or element.classList.add('x') on an already-inserted element MO doesn't fire for attribute mutations — but any later unrelated childList mutation triggers a full-doc rescan via scan(document.body), incidentally catching the renamed element MO doesn't fire; the renamed element is not in any subsequent batch's added roots, so the dispatcher never re-walks it ⚠ regression

The one real regression: attribute mutations on already-inserted elements were incidentally caught by the old code because every batch's scan ran against document.body. The new dispatch path only walks added subtrees, so post-insertion id/class changes are silently missed until something underneath the renamed element is also added or removed (which would re-walk it as part of that batch).

Practical impact:

  • React, Vue, Svelte, HTMX, Alpine: build nodes with full attribute state, then mount. The renamed-after-insert pattern doesn't occur. Safe.
  • jQuery / vanilla JS that does $('#foo').addClass('cookie-banner') after the page renders: this is the risky pattern. At risk.
  • CMP vendors (OneTrust, Cookiebot, Sourcepoint, Quantcast, Osano, Didomi) inject with the id already present. Safe.
  • Chat-widget vendors (HubSpot, Intercom, Drift, Zendesk): same — id is set at insertion. Safe.

Mitigation path (follow-up, not in this PR): extend the shared MO config to { childList: true, subtree: true, attributes: true, attributeFilter: ['id', 'class'] }, and in fanOut dispatch attribute-mutation targets through the dispatcher the same way as added subtrees. AdGuard ExtendedCss takes exactly this approach. The follow-up is mechanical; flagging it here so the reviewer can decide whether it ships in this PR or the next.

Selectors that depend on ancestor combinators (e.g., .parent > .child or body > .x) behave identically: Element.matches evaluates against the live DOM context, and Element.querySelectorAll resolves the selector against the document while constraining results to the scope. None of the rules in the catalog currently use ancestor-combinator selectors, so this is theoretical, but worth noting.

Test plan

  • bun run test — 1244 / 1244 pass (29 new example tests + 7 property tests for the token index)
  • bun run typecheck
  • bun run check (biome + eslint)
  • bun run knip
  • selector-token-index.test.ts covers parseSelector cases, registerRule bucket population, unregister, findTriggeredRules across token / no-token / fallback shapes, and end-to-end dispatch through the shared watcher.
  • selector-token-index.property.test.ts fuzzes subtree monotonicity (`triggered(parent) ⊇ triggered(child)`), token soundness iff (rule fires iff the matching id/class is present somewhere in the subtree), and complex-fallback always-on.
  • selector-hide-rule.test.ts gains coverage for scan-from-inserted-root, root-itself matching, outermost dedupe when both root and descendant qualify, dispatcher integration (token hit / miss / teardown), and siteRule registration.

🤖 Generated with Claude Code

Adds a reverse index mapping id/class tokens to selector-hide rules, so
the shared subtree dispatcher only invokes rules whose tokens (or
complex-fallback bucket) appear in the added subtree — replacing
full-document QSA per rule with a constant-time per-node lookup.
Selector-hide-rule's scan now matches the added root itself, not just
its descendants, removing the comment that pinned scans to
document.body.

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 5:01am

Request Review

…token soundness

Fuzzes the dispatcher's two structural invariants across random tree
shapes and random id/class assignments: triggered(parent) ⊇
triggered(child), and per-rule iff-presence of the matching id or class
token in the subtree. Catches "miss the root itself" and "only
enumerate first classList token" regressions that the example matrix
can pass by accident. Also pins down that complex-fallback rules
always appear in the triggered set.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
@twschiller twschiller merged commit b893cf8 into main Jun 5, 2026
7 checks passed
@twschiller twschiller deleted the perf/id-class-token-index branch June 5, 2026 11:24
twschiller added a commit that referenced this pull request Jun 5, 2026
…150 Tier 2)

Stacks on #157. Closes the regression flagged in that PR's risk
section: a jQuery-style `element.id = 'x'` or `classList.add('x')` on
an already-inserted element used to be incidentally caught by the old
full-doc rescan, but the token-index dispatcher's per-added-subtree
walk would miss it.

Adds an opt-in `observeAttributes` flag to createSubtreeWatcher. When
any subscriber asks for it, the shared MO upgrades to
`attributes: true, attributeFilter: ['id', 'class']` — and the router
fans attribute mutations only to subscribers that requested them,
keeping the existing rules off the hot path. The token-index opts in
so its dispatcher re-walks the changed element through the same
findTriggeredRules path as a freshly-added subtree.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
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.

1 participant