Perf: id/class token index + scan from inserted roots (#150 Tier 2)#157
Merged
Conversation
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]>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
…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]>
6 tasks
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]>
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
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.lib/selector-hide-rule.tsto register with the index instead of building its own MutationObserver. The token index owns a single sharedcreateSubtreeWatchersubscription; on each added subtree it computes the triggered rule set in one walk and fans out scans only to matching rules.scan(root)now matchesrootitself (viaElement.matches) in addition to its descendants. The old "rescan fromdocument.body" workaround for top-level container insertions (HubSpot, OneTrust, Cookiebot) is no longer needed — and the comment at the formerselector-hide-rule.ts:158is gone with it.What changes for callers
createSelectorHideRulecontinues to exposeapply/ optionalteardown. The watcher-vs-no-watcher distinction is preserved.watchSubtrees: truerules, 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
parseSelectorstrictly 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.Behavioral risk under DOM mutations
The shared
MutationObserveris configured{ childList: true, subtree: true }(unchanged from before this PR). What changes is what we do with each batch's added roots.appendChildafter building the node off-document, CMP<div id=\"onetrust-banner-sdk\">injection)addedNodes=[el]on new parent; full-doc QSA finds itHIDDEN_ATTR/REVEALED_ATTRso already-hidden elements stay hiddenreplaceChild(new, old)addedNodes=[new]→ full-doc QSA picks it upaddedNodes=[new]→ dispatcher walksnewinnerHTMLoverwrite on a containeraddedNodescarries the parsed children → full-doc QSA picks up matchesaddedNodes; dispatcher walks eachroot === document.bodyto "trigger every registered rule," each rule scansdocument.bodyelement.id = 'x'orelement.classList.add('x')on an already-inserted elementscan(document.body), incidentally catching the renamed elementThe 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:
$('#foo').addClass('cookie-banner')after the page renders: this is the risky pattern. At risk.Mitigation path (follow-up, not in this PR): extend the shared MO config to
{ childList: true, subtree: true, attributes: true, attributeFilter: ['id', 'class'] }, and infanOutdispatch 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 > .childorbody > .x) behave identically:Element.matchesevaluates against the live DOM context, andElement.querySelectorAllresolves 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 typecheckbun run check(biome + eslint)bun run knipselector-token-index.test.tscovers 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.tsfuzzes 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.tsgains 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