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

Skip to content

Perf: trailing-only throttle, burst flush, O(C^2) outermost-match fix (#150 Tier 1)#151

Merged
twschiller merged 1 commit into
mainfrom
perf/tier-1-watcher-and-selector-fixes
Jun 5, 2026
Merged

Perf: trailing-only throttle, burst flush, O(C^2) outermost-match fix (#150 Tier 1)#151
twschiller merged 1 commit into
mainfrom
perf/tier-1-watcher-and-selector-fixes

Conversation

@twschiller

Copy link
Copy Markdown
Contributor

Tier 1 of #150 — plumbing wins inside lib/subtree-watcher.ts and lib/selector-hide-rule.ts. No architectural changes; the watcher API and rule shapes are unchanged.

Summary

lib/subtree-watcher.ts

  • Trailing-only throttle. Was leading: true, trailing: true — every burst drained twice. Now coalesces to one drain at the end of the window.
  • Burst-size flush. When pending grows past 512 roots (e.g. a React route swap dumping thousands of nodes in one microtask), cancel the timer and drain immediately. Pattern from Ghostery's DOMMonitor.
  • IGNORE_TAGS filter at enqueue. Drop <style> and <br> additions before they hit pending. Kept conservative: <script> stays through because json-ld-sanitize and schema-trust-sanitize observe <script type="application/ld+json"> additions.
  • visibilitychange pause. Disconnect the observer while document.hidden, flush pending so we don't sit on a stale snapshot, reattach on visible. Stops background tabs from running observer callbacks.

lib/selector-hide-rule.ts

  • Memoize selectorsFor(url). Single-entry cache keyed by globalThis.location.href so the URLPattern.test loop only runs on route changes. Internal scan() reuses the cached joined selector string; public selectorsFor() returns a defensive copy.
  • Fix O(C²) outermost-match. Replaces candidates.some(other => other.contains(element)) with a Set<HTMLElement> + parentElement walk. O(C·D) instead of O(C²) — matters on feeds where C grows into the hundreds over a scroll session.

Tier 1 items not in this PR

Tier 1 in #150 also listed ignoreTags at the watcher level (done — minus <script> to stay safe) and a visibilitychange pause (done). All six Tier-1 items are landed here.

Test plan

  • bun run test → 1164 passed (up from 1146 — 18 new tests covering trailing-only behavior, IGNORE_TAGS, burst flush, visibility pause)
  • bun run typecheck
  • bun run check (biome + eslint)
  • bun run knip
  • bun run test:coverage — well above ratchet (statements 85.38%, branches 76.77%, functions 88.53%, lines 85.28%)
  • Manual smoke on a hot infinite-scroll feed (Twitter/Reddit) with all rules on — confirm placeholders still apply, no visible regressions on route changes

Refs #150.

🤖 Generated with Claude Code

Tier 1 of the perf plan in #150 — plumbing wins, no architectural
change. Each item targets a measured hotspot in the watcher / hide-rule
loop that fires on every infinite-scroll burst and SPA route swap.

lib/subtree-watcher.ts:
- Trailing-only throttle. Previously `leading: true, trailing: true`
  ran drain at both edges of every window, so each burst scanned
  twice. Now coalesces to one drain at the end.
- Burst-size flush. When pending grows past 512 roots (e.g. a React
  route swap dumping thousands of nodes in one microtask), cancel
  the timer and drain immediately so the user-visible hide doesn't
  wait out 250ms of throttle. Pattern from Ghostery's DOMMonitor.
- ignoreTags filter at enqueue. Drop <style> and <br> additions
  before they hit the pending set — never interesting to any rule.
  Kept conservative: <script> stays through because
  json-ld-sanitize / schema-trust-sanitize observe
  <script type="application/ld+json"> insertions.
- visibilitychange pause. Disconnect the observer when document.hidden
  is true (background tab), flush pending so we don't sit on a stale
  snapshot, reattach + drain on visible. Stops background tabs from
  burning CPU on observer callbacks while the user isn't looking.

lib/selector-hide-rule.ts:
- Memoize selectorsFor(url). Single-entry cache keyed by
  globalThis.location.href so the URLPattern.test loop only runs
  on route changes. Defensive-copy on return; internal scan() uses
  the cached joined selector string.
- Fix O(C²) outermost-match. Replace the
  `candidates.some(other => other.contains(element))` filter with
  a Set + parentElement walk. O(C·D) instead of O(C²) — on a feed
  where C grows into the hundreds over a scroll session this stops
  being the dominant cost in scan().

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 3:39am

Request Review

@twschiller twschiller merged commit 671a91e into main Jun 5, 2026
7 checks passed
@twschiller twschiller deleted the perf/tier-1-watcher-and-selector-fixes branch June 5, 2026 03:42
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