From 0877f23b959a4305721b13ed7e213acfc916c321 Mon Sep 17 00:00:00 2001 From: Todd Schiller Date: Fri, 5 Jun 2026 00:53:20 -0400 Subject: [PATCH 1/2] Perf: id/class token index + scan from inserted roots (#150 Tier 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../lib/__tests__/selector-hide-rule.test.ts | 240 +++++++++ .../__tests__/selector-token-index.test.ts | 505 ++++++++++++++++++ extension/src/lib/selector-hide-rule.ts | 66 ++- extension/src/lib/selector-token-index.ts | 260 +++++++++ 4 files changed, 1050 insertions(+), 21 deletions(-) create mode 100644 extension/src/lib/__tests__/selector-token-index.test.ts create mode 100644 extension/src/lib/selector-token-index.ts diff --git a/extension/src/lib/__tests__/selector-hide-rule.test.ts b/extension/src/lib/__tests__/selector-hide-rule.test.ts index 6b09cfb..93875f9 100644 --- a/extension/src/lib/__tests__/selector-hide-rule.test.ts +++ b/extension/src/lib/__tests__/selector-hide-rule.test.ts @@ -11,14 +11,29 @@ import { URLPattern } from "urlpattern-polyfill"; import { HIDDEN_ATTR } from "../dom-markers"; import { PLACEHOLDER_CLASS } from "../placeholder"; +import { __resetRouteChangeForTesting } from "../route-change"; import { createSelectorHideRule } from "../selector-hide-rule"; +import { __resetSelectorTokenIndexForTesting } from "../selector-token-index"; import type { RuleId } from "../storage"; +import { __resetSubtreeWatcherForTesting } from "../subtree-watcher"; const RULE_ID = "footer-redact" as RuleId; const HIDE_LABEL = "[hidden — click to reveal]"; beforeEach(() => { document.body.innerHTML = ""; + // Reset the shared dispatcher / watcher / route-change subscription so + // tests that build watchSubtrees=true rules don't leak registrations + // across cases. + __resetSelectorTokenIndexForTesting(); + __resetSubtreeWatcherForTesting(); + __resetRouteChangeForTesting(); +}); + +afterEach(() => { + __resetSelectorTokenIndexForTesting(); + __resetSubtreeWatcherForTesting(); + __resetRouteChangeForTesting(); }); describe("createSelectorHideRule constructor", () => { @@ -497,3 +512,228 @@ describe("REVEALED_ATTR ancestor skip", () => { expect(document.querySelector(`.${PLACEHOLDER_CLASS}`)).toBeNull(); }); }); + +describe("scan from inserted root", () => { + // Once the token-index dispatcher is online, the watcher hands each + // rule the added subtree root — not document.body. The scan has to + // match the root itself, not just its descendants; otherwise a + // widget whose top-level container is the match (HubSpot, OneTrust, + // Cookiebot) slips through every batch. + + it("matches the root element itself when its id matches a selector", () => { + const { rule } = createSelectorHideRule({ + id: RULE_ID, + label: "test", + description: "test", + alwaysOnSelectors: ["#hubspot-messages-iframe-container"], + removeEntirely: true, + }); + const widget = document.createElement("div"); + widget.id = "hubspot-messages-iframe-container"; + document.body.append(widget); + + // Scan from the widget itself (the dispatcher's call shape). + rule.apply(widget); + + expect(widget.style.display).toBe("none"); + expect(widget.getAttribute(HIDDEN_ATTR)).toBe(RULE_ID); + }); + + it("matches the root element itself when one of its classes matches", () => { + const { rule } = createSelectorHideRule({ + id: RULE_ID, + label: "test", + description: "test", + alwaysOnSelectors: [".cookie-banner"], + removeEntirely: true, + }); + const banner = document.createElement("div"); + banner.className = "cookie-banner sticky"; + document.body.append(banner); + + rule.apply(banner); + + expect(banner.style.display).toBe("none"); + }); + + it("still matches descendants of the root", () => { + // The root itself doesn't match; a descendant does. The previous + // behavior (scan from document.body) handled this; the new behavior + // (scan from added root) must still walk descendants via QSA. + const { rule } = createSelectorHideRule({ + id: RULE_ID, + label: "test", + description: "test", + alwaysOnSelectors: [".inner-target"], + hideLabel: HIDE_LABEL, + }); + const wrapper = document.createElement("section"); + const inner = document.createElement("div"); + inner.className = "inner-target"; + inner.textContent = "x"; + wrapper.append(inner); + document.body.append(wrapper); + + rule.apply(wrapper); + + expect(wrapper.querySelector(".inner-target")).toBeNull(); + expect(wrapper.querySelector(`.${PLACEHOLDER_CLASS}`)).not.toBeNull(); + }); + + it("matches both root and a descendant when both qualify (outermost dedupe wins)", () => { + // The outermost-match filter must still apply when scanning from + // an added root that itself matches and contains a deeper match. + const { rule } = createSelectorHideRule({ + id: RULE_ID, + label: "test", + description: "test", + alwaysOnSelectors: ["footer"], + hideLabel: HIDE_LABEL, + }); + const outer = document.createElement("footer"); + outer.id = "outer"; + const inner = document.createElement("footer"); + inner.id = "inner"; + outer.append(inner); + document.body.append(outer); + + rule.apply(outer); + + // Only one placeholder lands — outer subsumed inner. + expect(document.querySelector("#outer")).toBeNull(); + expect(document.querySelector("#inner")).toBeNull(); + expect(document.querySelectorAll(`.${PLACEHOLDER_CLASS}`)).toHaveLength(1); + }); +}); + +describe("subtree dispatcher integration", () => { + // Verifies the end-to-end path: watchSubtrees=true rule's apply + // registers with the token index, a mutation lands on document.body, + // the shared dispatcher routes to this rule, scan runs on the added + // root. + + const THROTTLE_MS = 250; + + async function flushMutations(): Promise { + await Promise.resolve(); + } + + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it("hides a top-level container injected after apply", async () => { + // The classic chat-widget shape: rule mounts first, then the + // vendor script injects the widget container as a direct child of + // document.body. With watchSubtrees=true the dispatcher catches it. + const { rule } = createSelectorHideRule({ + id: RULE_ID, + label: "test", + description: "test", + alwaysOnSelectors: ["#late-widget"], + removeEntirely: true, + watchSubtrees: true, + }); + + rule.apply(document.body); + + const widget = document.createElement("div"); + widget.id = "late-widget"; + document.body.append(widget); + + await flushMutations(); + jest.advanceTimersByTime(THROTTLE_MS); + + expect(widget.style.display).toBe("none"); + + rule.teardown?.(); + }); + + it("ignores additions whose tokens don't appear in this rule's selectors", async () => { + // Index dispatch means we shouldn't even run the rule's scan + // (no QSA on the added subtree) when no token matches. + const { rule } = createSelectorHideRule({ + id: RULE_ID, + label: "test", + description: "test", + alwaysOnSelectors: ["#target"], + removeEntirely: true, + watchSubtrees: true, + }); + + rule.apply(document.body); + + const unrelated = document.createElement("div"); + unrelated.id = "something-else"; + document.body.append(unrelated); + + await flushMutations(); + jest.advanceTimersByTime(THROTTLE_MS); + + expect(unrelated.style.display).toBe(""); + + rule.teardown?.(); + }); + + it("teardown unregisters from the dispatcher", async () => { + const { rule } = createSelectorHideRule({ + id: RULE_ID, + label: "test", + description: "test", + alwaysOnSelectors: ["#target"], + removeEntirely: true, + watchSubtrees: true, + }); + + rule.apply(document.body); + rule.teardown?.(); + + const widget = document.createElement("div"); + widget.id = "target"; + document.body.append(widget); + + await flushMutations(); + jest.advanceTimersByTime(THROTTLE_MS); + + // Teardown ran — the dispatcher no longer routes to this rule, + // so the post-teardown injection stays untouched. + expect(widget.style.display).toBe(""); + }); + + it("registers siteRule selectors in the token index too", async () => { + // Without this, a URL-gated selector would never be triggered by + // the dispatcher even when the URL matches — the rule's scan would + // run only via complex-fallback (if it landed there at all). + const { rule } = createSelectorHideRule({ + id: RULE_ID, + label: "test", + description: "test", + alwaysOnSelectors: [], + siteRules: [ + { + patterns: [new URLPattern({ pathname: "/*" })], + selectors: ["#site-specific"], + }, + ], + removeEntirely: true, + watchSubtrees: true, + }); + + rule.apply(document.body); + + const widget = document.createElement("div"); + widget.id = "site-specific"; + document.body.append(widget); + + await flushMutations(); + jest.advanceTimersByTime(THROTTLE_MS); + + expect(widget.style.display).toBe("none"); + + rule.teardown?.(); + }); +}); diff --git a/extension/src/lib/__tests__/selector-token-index.test.ts b/extension/src/lib/__tests__/selector-token-index.test.ts new file mode 100644 index 0000000..5928108 --- /dev/null +++ b/extension/src/lib/__tests__/selector-token-index.test.ts @@ -0,0 +1,505 @@ +// Copyright (c) 2026 PixieBrix, Inc. +// Licensed under PolyForm Shield 1.0.0 — see LICENSE. + +// Tests for the token index that drives subtree dispatch. Two layers: +// - parseSelector / registerRule / findTriggeredRules: pure data, no +// DOM mutations needed. +// - Shared-watcher dispatch: register two rules, mutate the DOM, advance +// the throttle, assert each rule's dispatchScan only fires when its +// tokens (or complex-fallback bucket) match. + +import { + __getIndexSnapshotForTesting, + __resetSelectorTokenIndexForTesting, + findTriggeredRules, + parseSelector, + registerRule, +} from "../selector-token-index"; +import type { RuleId } from "../storage"; +import { __resetSubtreeWatcherForTesting } from "../subtree-watcher"; + +const THROTTLE_MS = 250; +const RULE_A = "footer-redact" as RuleId; +const RULE_B = "comments-redact" as RuleId; +const RULE_C = "reviews-redact" as RuleId; + +async function flushMutations(): Promise { + await Promise.resolve(); +} + +beforeEach(() => { + document.body.innerHTML = ""; + jest.useFakeTimers(); + __resetSelectorTokenIndexForTesting(); + __resetSubtreeWatcherForTesting(); +}); + +afterEach(() => { + jest.useRealTimers(); + __resetSelectorTokenIndexForTesting(); + __resetSubtreeWatcherForTesting(); +}); + +describe("parseSelector", () => { + it("returns kind=id for a bare #identifier", () => { + expect(parseSelector("#hubspot")).toEqual({ + kind: "id", + token: "hubspot", + }); + }); + + it("returns kind=class for a bare .identifier", () => { + expect(parseSelector(".cookie-banner")).toEqual({ + kind: "class", + token: "cookie-banner", + }); + }); + + it("trims surrounding whitespace before matching", () => { + expect(parseSelector(" #foo ")).toEqual({ kind: "id", token: "foo" }); + }); + + it("accepts identifiers containing hyphens, underscores, digits", () => { + expect(parseSelector("#a-b_c-9")).toEqual({ + kind: "id", + token: "a-b_c-9", + }); + expect(parseSelector(".one_two-3")).toEqual({ + kind: "class", + token: "one_two-3", + }); + }); + + it("treats tag selectors as complex (no token bucket)", () => { + expect(parseSelector("footer")).toEqual({ kind: "complex", token: "" }); + expect(parseSelector("div")).toEqual({ kind: "complex", token: "" }); + }); + + it("treats compounds, combinators, attributes, pseudos as complex", () => { + expect(parseSelector("div#foo")).toEqual({ kind: "complex", token: "" }); + expect(parseSelector(".a.b")).toEqual({ kind: "complex", token: "" }); + expect(parseSelector("#foo .child")).toEqual({ + kind: "complex", + token: "", + }); + expect(parseSelector('[role="dialog"]')).toEqual({ + kind: "complex", + token: "", + }); + expect(parseSelector('[id^="sp_message_"]')).toEqual({ + kind: "complex", + token: "", + }); + expect(parseSelector("a:hover")).toEqual({ kind: "complex", token: "" }); + }); + + it("treats wildcard / empty selectors as complex", () => { + expect(parseSelector("*")).toEqual({ kind: "complex", token: "" }); + expect(parseSelector("")).toEqual({ kind: "complex", token: "" }); + }); +}); + +describe("registerRule index population", () => { + it("buckets id and class selectors separately", () => { + registerRule({ + ruleId: RULE_A, + selectors: ["#nav-footer", ".site-footer", "#site-footer"], + dispatchScan: jest.fn(), + }); + + const snap = __getIndexSnapshotForTesting(); + expect(snap.idIndex.get("nav-footer")).toEqual(new Set([RULE_A])); + expect(snap.idIndex.get("site-footer")).toEqual(new Set([RULE_A])); + expect(snap.classIndex.get("site-footer")).toEqual(new Set([RULE_A])); + // No complex selectors among these — the rule should NOT land in + // complexFallback, otherwise we'd over-trigger it on every batch. + expect(snap.complexFallback.has(RULE_A)).toBe(false); + }); + + it("puts rules with any complex selector in the fallback bucket", () => { + registerRule({ + ruleId: RULE_A, + // Mixed: ".foo" is indexable, but "[role=dialog]" is not — we have + // to fall back so the complex selector can still run. + selectors: [".foo", '[role="dialog"]'], + dispatchScan: jest.fn(), + }); + + const snap = __getIndexSnapshotForTesting(); + expect(snap.complexFallback.has(RULE_A)).toBe(true); + expect(snap.classIndex.get("foo")).toEqual(new Set([RULE_A])); + }); + + it("puts a rule with no selectors at all in the fallback bucket", () => { + // Defensive: a rule that registers an empty list (e.g., URL-gated + // siteRules only, no alwaysOnSelectors) must still hear about every + // batch so URL gating can pick up dispatched calls later. + registerRule({ + ruleId: RULE_A, + selectors: [], + dispatchScan: jest.fn(), + }); + + expect(__getIndexSnapshotForTesting().complexFallback.has(RULE_A)).toBe( + true, + ); + }); + + it("merges multiple rules under the same token", () => { + registerRule({ + ruleId: RULE_A, + selectors: [".overlay"], + dispatchScan: jest.fn(), + }); + registerRule({ + ruleId: RULE_B, + selectors: [".overlay"], + dispatchScan: jest.fn(), + }); + + expect(__getIndexSnapshotForTesting().classIndex.get("overlay")).toEqual( + new Set([RULE_A, RULE_B]), + ); + }); + + it("unregister removes the rule from every bucket and the registry", () => { + const cleanup = registerRule({ + ruleId: RULE_A, + selectors: ["#nav-footer", ".site-footer", '[role="dialog"]'], + dispatchScan: jest.fn(), + }); + + cleanup(); + const snap = __getIndexSnapshotForTesting(); + expect(snap.idIndex.size).toBe(0); + expect(snap.classIndex.size).toBe(0); + expect(snap.complexFallback.size).toBe(0); + }); + + it("unregister leaves the other rule sharing the same token intact", () => { + const cleanupA = registerRule({ + ruleId: RULE_A, + selectors: [".overlay"], + dispatchScan: jest.fn(), + }); + registerRule({ + ruleId: RULE_B, + selectors: [".overlay"], + dispatchScan: jest.fn(), + }); + cleanupA(); + + expect(__getIndexSnapshotForTesting().classIndex.get("overlay")).toEqual( + new Set([RULE_B]), + ); + }); + + it("re-registering the same ruleId drops the older entry", () => { + registerRule({ + ruleId: RULE_A, + selectors: ["#one"], + dispatchScan: jest.fn(), + }); + registerRule({ + ruleId: RULE_A, + selectors: ["#two"], + dispatchScan: jest.fn(), + }); + + const snap = __getIndexSnapshotForTesting(); + expect(snap.idIndex.get("one")).toBeUndefined(); + expect(snap.idIndex.get("two")).toEqual(new Set([RULE_A])); + }); +}); + +describe("findTriggeredRules", () => { + it("returns only the complex-fallback rules for a token-less element", () => { + registerRule({ + ruleId: RULE_A, + selectors: [".overlay"], + dispatchScan: jest.fn(), + }); + registerRule({ + ruleId: RULE_B, + selectors: ['[role="dialog"]'], + dispatchScan: jest.fn(), + }); + + const root = document.createElement("div"); + document.body.append(root); + + expect(findTriggeredRules(root)).toEqual(new Set([RULE_B])); + }); + + it("triggers a rule via the element's own id", () => { + registerRule({ + ruleId: RULE_A, + selectors: ["#hubspot"], + dispatchScan: jest.fn(), + }); + + const root = document.createElement("div"); + root.id = "hubspot"; + document.body.append(root); + + expect(findTriggeredRules(root).has(RULE_A)).toBe(true); + }); + + it("triggers a rule via the element's own class", () => { + registerRule({ + ruleId: RULE_A, + selectors: [".overlay"], + dispatchScan: jest.fn(), + }); + + const root = document.createElement("div"); + root.className = "overlay banner"; + document.body.append(root); + + expect(findTriggeredRules(root).has(RULE_A)).toBe(true); + }); + + it("triggers a rule via a descendant's token", () => { + registerRule({ + ruleId: RULE_A, + selectors: [".comment"], + dispatchScan: jest.fn(), + }); + + const root = document.createElement("section"); + const child = document.createElement("article"); + child.className = "comment"; + root.append(child); + document.body.append(root); + + expect(findTriggeredRules(root).has(RULE_A)).toBe(true); + }); + + it("does not trigger a non-matching rule even with descendants present", () => { + registerRule({ + ruleId: RULE_A, + selectors: [".not-here"], + dispatchScan: jest.fn(), + }); + registerRule({ + ruleId: RULE_B, + selectors: ["#also-not-here"], + dispatchScan: jest.fn(), + }); + + const root = document.createElement("div"); + root.className = "unrelated"; + const child = document.createElement("span"); + child.id = "other"; + root.append(child); + document.body.append(root); + + expect(findTriggeredRules(root).size).toBe(0); + }); + + it("collects multiple class tokens on the same element", () => { + registerRule({ + ruleId: RULE_A, + selectors: [".a"], + dispatchScan: jest.fn(), + }); + registerRule({ + ruleId: RULE_B, + selectors: [".b"], + dispatchScan: jest.fn(), + }); + + const root = document.createElement("div"); + root.className = "a b"; + document.body.append(root); + + expect(findTriggeredRules(root)).toEqual(new Set([RULE_A, RULE_B])); + }); + + it("complex-fallback rules trigger even when no descendant has any token", () => { + registerRule({ + ruleId: RULE_A, + selectors: ['[role="contentinfo"]'], + dispatchScan: jest.fn(), + }); + + const root = document.createElement("div"); + document.body.append(root); + + expect(findTriggeredRules(root)).toEqual(new Set([RULE_A])); + }); +}); + +describe("dispatch via the shared subtree watcher", () => { + // End-to-end: register two rules, append elements that should only + // trigger one of them, advance the throttle, and assert each rule's + // dispatchScan fired (or didn't) with the right root. + + it("invokes a rule's dispatchScan with the added subtree root", async () => { + const scanA = jest.fn(); + registerRule({ + ruleId: RULE_A, + selectors: ["#hubspot"], + dispatchScan: scanA, + }); + + const widget = document.createElement("div"); + widget.id = "hubspot"; + document.body.append(widget); + + await flushMutations(); + jest.advanceTimersByTime(THROTTLE_MS); + + expect(scanA).toHaveBeenCalledTimes(1); + expect(scanA).toHaveBeenCalledWith(widget); + }); + + it("skips a rule whose tokens do not appear in the added subtree", async () => { + const scanA = jest.fn(); + const scanB = jest.fn(); + registerRule({ + ruleId: RULE_A, + selectors: ["#hubspot"], + dispatchScan: scanA, + }); + registerRule({ + ruleId: RULE_B, + selectors: ["#onetrust"], + dispatchScan: scanB, + }); + + const widget = document.createElement("div"); + widget.id = "hubspot"; + document.body.append(widget); + + await flushMutations(); + jest.advanceTimersByTime(THROTTLE_MS); + + expect(scanA).toHaveBeenCalledTimes(1); + expect(scanB).not.toHaveBeenCalled(); + }); + + it("always dispatches to complex-fallback rules on every batch", async () => { + const fallbackScan = jest.fn(); + const tokenScan = jest.fn(); + registerRule({ + ruleId: RULE_A, + selectors: ['[role="dialog"]'], + dispatchScan: fallbackScan, + }); + registerRule({ + ruleId: RULE_B, + selectors: ["#hubspot"], + dispatchScan: tokenScan, + }); + + // Add a node whose id doesn't match RULE_B; RULE_A's complex selector + // can only be checked at scan time, so it must always fire. + const unrelated = document.createElement("div"); + document.body.append(unrelated); + + await flushMutations(); + jest.advanceTimersByTime(THROTTLE_MS); + + expect(fallbackScan).toHaveBeenCalledTimes(1); + expect(tokenScan).not.toHaveBeenCalled(); + }); + + it("dispatches to multiple triggered rules with the same root", async () => { + const scanA = jest.fn(); + const scanB = jest.fn(); + const scanC = jest.fn(); + registerRule({ + ruleId: RULE_A, + selectors: [".overlay"], + dispatchScan: scanA, + }); + registerRule({ + ruleId: RULE_B, + selectors: [".overlay"], + dispatchScan: scanB, + }); + registerRule({ + ruleId: RULE_C, + selectors: ["#other"], + dispatchScan: scanC, + }); + + const div = document.createElement("div"); + div.className = "overlay"; + document.body.append(div); + + await flushMutations(); + jest.advanceTimersByTime(THROTTLE_MS); + + expect(scanA).toHaveBeenCalledWith(div); + expect(scanB).toHaveBeenCalledWith(div); + expect(scanC).not.toHaveBeenCalled(); + }); + + it("triggers via a descendant token: scan still gets the added root", async () => { + const scanA = jest.fn(); + registerRule({ + ruleId: RULE_A, + selectors: [".comment"], + dispatchScan: scanA, + }); + + const wrapper = document.createElement("section"); + const comment = document.createElement("article"); + comment.className = "comment"; + wrapper.append(comment); + document.body.append(wrapper); + + await flushMutations(); + jest.advanceTimersByTime(THROTTLE_MS); + + // The rule's scan receives the *outer* added root — its own QSA + // will descend to find the .comment match. + expect(scanA).toHaveBeenCalledWith(wrapper); + }); + + it("stops dispatching after unregister, even mid-burst", async () => { + const scanA = jest.fn(); + const cleanup = registerRule({ + ruleId: RULE_A, + selectors: [".overlay"], + dispatchScan: scanA, + }); + + cleanup(); + + const div = document.createElement("div"); + div.className = "overlay"; + document.body.append(div); + + await flushMutations(); + jest.advanceTimersByTime(THROTTLE_MS); + + expect(scanA).not.toHaveBeenCalled(); + }); + + it("body-rooted sweep triggers every registered rule (route-change path)", () => { + const scanA = jest.fn(); + const scanB = jest.fn(); + registerRule({ + ruleId: RULE_A, + selectors: ["#nope-a"], + dispatchScan: scanA, + }); + registerRule({ + ruleId: RULE_B, + selectors: ["#nope-b"], + dispatchScan: scanB, + }); + + // Simulate the shared router's route-change sweep: subscriber + // receives [document.body] regardless of pending mutations. + history.replaceState(null, "", "/route-b"); + globalThis.dispatchEvent(new Event("popstate")); + jest.advanceTimersToNextFrame(); + + expect(scanA).toHaveBeenCalledWith(document.body); + expect(scanB).toHaveBeenCalledWith(document.body); + }); +}); diff --git a/extension/src/lib/selector-hide-rule.ts b/extension/src/lib/selector-hide-rule.ts index 8df56ce..2511eb0 100644 --- a/extension/src/lib/selector-hide-rule.ts +++ b/extension/src/lib/selector-hide-rule.ts @@ -18,8 +18,8 @@ import type { Rule } from "../rules/types"; import { HIDDEN_ATTR, REVEALED_ATTR } from "./dom-markers"; import { filterToOutermost } from "./dom-utils"; import { PLACEHOLDER_CLASS, replaceWithBlockPlaceholder } from "./placeholder"; +import { registerRule as registerWithTokenIndex } from "./selector-token-index"; import type { RuleId } from "./storage"; -import { createSubtreeWatcher } from "./subtree-watcher"; export interface SiteRule { patterns: URLPattern[]; @@ -116,7 +116,21 @@ export function createSelectorHideRule( return; } - let candidates = [...root.querySelectorAll(memoJoined)]; + // Include `root` itself when it's an Element that matches — the + // shared dispatcher now hands us inserted subtree roots directly, + // and querySelectorAll only matches descendants. Without this, + // top-level container insertions (HubSpot's + // #hubspot-messages-iframe-container, OneTrust's + // #onetrust-banner-sdk) would slip through every batch where + // they're the added root. + let candidates: HTMLElement[] = []; + if ( + root.nodeType === Node.ELEMENT_NODE && + (root as Element).matches(memoJoined) + ) { + candidates.push(root as HTMLElement); + } + candidates.push(...root.querySelectorAll(memoJoined)); if (candidateFilter) { candidates = candidates.filter(candidateFilter); } @@ -165,29 +179,38 @@ export function createSelectorHideRule( } } - // When the watcher is enabled, rescan from document.body on every batch - // rather than from the added subtree roots. MutationObserver hands us the - // newly-inserted element itself, but querySelectorAll on that element does - // not match the element itself — so a widget whose top-level container is - // appended directly (e.g., HubSpot's #hubspot-messages-iframe-container) - // would be missed. Scanning from body is idempotent thanks to the - // placeholder-skip in `scan`, and the throttle inside the watcher coalesces - // bursts of mutations into a single pass. - const watcher = watchSubtrees - ? createSubtreeWatcher({ - skipPlaceholderSubtrees: true, - onSubtrees: () => { - scan(document.body); - }, - }) - : null; + // Build the union selector list passed to the token index. Indexing + // siteRule selectors alongside alwaysOn means rules see no surprise + // misses when a URL-gated id/class appears on the current page — + // even though the rule's effective `memoJoined` for that URL may + // include only a subset. Over-trigger cost is bounded: the dispatch + // costs one no-op scan call against the added root, not a full-doc + // QSA against all selectors. + const allSelectors: string[] = [...alwaysOnSelectors]; + for (const siteRule of siteRules) { + allSelectors.push(...siteRule.selectors); + } + + let unregisterFromTokenIndex: (() => void) | null = null; function apply(root: ParentNode): void { scan(root); - watcher?.start(root); + if (watchSubtrees && !unregisterFromTokenIndex) { + // Lazy-register on first apply so module-load doesn't touch the + // document body before the rule engine asks for it. The shared + // dispatcher owns the watcher and fans out per added subtree + // root — this rule's dispatchScan only fires when the token + // index says one of `id` / `class` tokens appeared, or when the + // rule landed in complex-fallback. + unregisterFromTokenIndex = registerWithTokenIndex({ + ruleId: id, + selectors: allSelectors, + dispatchScan: scan, + }); + } } - const rule: Rule = watcher + const rule: Rule = watchSubtrees ? { id, label, @@ -195,7 +218,8 @@ export function createSelectorHideRule( topFrameOnly, apply, teardown: () => { - watcher.stop(); + unregisterFromTokenIndex?.(); + unregisterFromTokenIndex = null; }, } : { id, label, description, topFrameOnly, apply }; diff --git a/extension/src/lib/selector-token-index.ts b/extension/src/lib/selector-token-index.ts new file mode 100644 index 0000000..0798837 --- /dev/null +++ b/extension/src/lib/selector-token-index.ts @@ -0,0 +1,260 @@ +// Copyright (c) 2026 PixieBrix, Inc. +// Licensed under PolyForm Shield 1.0.0 — see LICENSE. + +// Reverse index from id / class tokens to the rules that contain a +// selector keyed on them. Lets the subtree dispatcher run constant-time +// per-added-node lookup instead of full-document querySelectorAll for +// every rule. +// +// Each selector is parsed into a "primary token" if it reduces to a +// trivial `#id` or `.class` form. Selectors with combinators, compounds, +// attribute filters, or pseudos fall into the complex-fallback bucket — +// every added subtree triggers them, but they're a minority of the +// rule set (footers via `[role="contentinfo"]`, generic role markers). +// +// uBO, Brave Shields, and Ghostery's adblocker all converge on this +// pattern independently: on token-dense scroll feeds it's how cosmetic +// filter engines avoid quadratic dispatch. + +import type { RuleId } from "./storage"; +import type { SubtreeWatcher } from "./subtree-watcher"; +import { createSubtreeWatcher } from "./subtree-watcher"; + +export type SelectorKind = "id" | "class" | "complex"; + +export interface ParsedSelector { + kind: SelectorKind; + // Token value when kind is "id" or "class"; empty when kind is "complex". + token: string; +} + +// Strict shape: bare `#ident` or `.ident` only. Anything with whitespace, +// attribute brackets, combinators, pseudo-classes, multiple compound +// segments, or tag prefixes is complex. +const ID_SELECTOR = /^#([\w-]+)$/; +const CLASS_SELECTOR = /^\.([\w-]+)$/; + +export function parseSelector(selector: string): ParsedSelector { + const trimmed = selector.trim(); + const idMatch = ID_SELECTOR.exec(trimmed); + if (idMatch) { + return { kind: "id", token: idMatch[1] as string }; + } + const classMatch = CLASS_SELECTOR.exec(trimmed); + if (classMatch) { + return { kind: "class", token: classMatch[1] as string }; + } + return { kind: "complex", token: "" }; +} + +interface Registration { + ruleId: RuleId; + dispatchScan: (root: Element) => void; +} + +const idIndex = new Map>(); +const classIndex = new Map>(); +const complexFallback = new Set(); +const registrations = new Map(); + +let sharedWatcher: SubtreeWatcher | null = null; + +function addToBucket( + map: Map>, + key: string, + ruleId: RuleId, +): void { + let bucket = map.get(key); + if (!bucket) { + bucket = new Set(); + map.set(key, bucket); + } + bucket.add(ruleId); +} + +function removeFromBucket(map: Map>, ruleId: RuleId): void { + for (const [key, bucket] of map) { + if (bucket.delete(ruleId) && bucket.size === 0) { + map.delete(key); + } + } +} + +function collectTokens(element: Element, into: Set): void { + if (element.id !== "" && idIndex.size > 0) { + const bucket = idIndex.get(element.id); + if (bucket) { + for (const ruleId of bucket) { + into.add(ruleId); + } + } + } + if (classIndex.size > 0 && element.classList.length > 0) { + for (const cls of element.classList) { + const bucket = classIndex.get(cls); + if (bucket) { + for (const ruleId of bucket) { + into.add(ruleId); + } + } + } + } +} + +// Walk root + its descendants, collecting rule IDs whose tokens (id or +// class) appear on any element in the subtree. Always includes +// complexFallback — attribute/combinator/pseudo selectors can't be +// filtered with the token index, so they have to run against every +// added subtree. +export function findTriggeredRules(root: Element): Set { + const triggered = new Set(complexFallback); + // Walking the added subtree's elements once (rather than N times) + // amortizes the token check across every registered rule — the win the + // index is named for. + collectTokens(root, triggered); + if (idIndex.size > 0 || classIndex.size > 0) { + const descendants = root.querySelectorAll("*"); + for (const descendant of descendants) { + collectTokens(descendant, triggered); + } + } + return triggered; +} + +// Walk added roots, look up triggered rule IDs via the token index, +// and fan out scan calls. Each rule's dispatchScan receives the added +// subtree root — not document.body — so the rule's own querySelectorAll +// runs against just the new subtree. +// +// One special case: when the router signals a full-body sweep (route +// change, see subtree-watcher.handleRouteChange), the root is the body +// itself. Walking every body descendant just to compute the triggered +// set defeats the purpose, so we shortcut to "every registered rule." +function dispatchToRules(roots: Element[]): void { + for (const root of roots) { + const triggered = + root === document.body + ? new Set(registrations.keys()) + : findTriggeredRules(root); + for (const ruleId of triggered) { + const registration = registrations.get(ruleId); + + if (!registration) { + continue; + } + registration.dispatchScan(root); + } + } +} + +function ensureWatcherStarted(): void { + if (sharedWatcher) { + return; + } + sharedWatcher = createSubtreeWatcher({ + skipPlaceholderSubtrees: true, + onSubtrees: dispatchToRules, + }); + sharedWatcher.start(document.body); +} + +function maybeStopWatcher(): void { + if (registrations.size === 0 && sharedWatcher) { + sharedWatcher.stop(); + sharedWatcher = null; + } +} + +export interface RegisterOptions { + ruleId: RuleId; + // Full selector list — the union of `alwaysOnSelectors` and every + // siteRule's `selectors`. Indexed once at registration; URL-gating is + // handled inside the rule's scan, not at dispatch time. Over-triggering + // for off-URL siteRule tokens is OK: the rule's scan will refresh its + // memo, find no matches against the current URL's effective selectors, + // and bail. + selectors: readonly string[]; + // Invoked when the dispatcher decides this rule may match an added + // subtree root. The rule's own scan handles candidateFilter, + // outermost-match, placeholder skips, and URL gating. + dispatchScan: (root: Element) => void; +} + +export function registerRule(options: RegisterOptions): () => void { + const { ruleId, selectors, dispatchScan } = options; + // Duplicate registration shouldn't happen at module load (each rule + // is built once), but tests may re-construct rules. Drop the older + // entry first to keep the index consistent. + if (registrations.has(ruleId)) { + unregisterRule(ruleId); + } + + let hasIdOrClass = false; + let hasComplex = false; + for (const selector of selectors) { + const parsed = parseSelector(selector); + if (parsed.kind === "id") { + addToBucket(idIndex, parsed.token, ruleId); + hasIdOrClass = true; + } else if (parsed.kind === "class") { + addToBucket(classIndex, parsed.token, ruleId); + hasIdOrClass = true; + } else { + hasComplex = true; + } + } + // A rule with no selectors at all (empty alwaysOn, no siteRules) + // still goes into the fallback bucket so a future call site that + // toggles selectors on at runtime doesn't get silently dropped. + if (hasComplex || !hasIdOrClass) { + complexFallback.add(ruleId); + } + + registrations.set(ruleId, { ruleId, dispatchScan }); + ensureWatcherStarted(); + + return () => { + unregisterRule(ruleId); + }; +} + +function unregisterRule(ruleId: RuleId): void { + registrations.delete(ruleId); + complexFallback.delete(ruleId); + removeFromBucket(idIndex, ruleId); + removeFromBucket(classIndex, ruleId); + maybeStopWatcher(); +} + +// Test-only: clear all registrations and stop the shared watcher. Tests +// build per-rule fixtures and would otherwise see indexes leak across +// cases. +export function __resetSelectorTokenIndexForTesting(): void { + idIndex.clear(); + classIndex.clear(); + complexFallback.clear(); + registrations.clear(); + if (sharedWatcher) { + sharedWatcher.stop(); + sharedWatcher = null; + } +} + +// Test-only: introspect index contents. Lets unit tests assert that +// registration produced the right bucket shape without needing to +// trigger a full dispatch round-trip. +export interface IndexSnapshot { + idIndex: ReadonlyMap>; + classIndex: ReadonlyMap>; + complexFallback: ReadonlySet; +} + +export function __getIndexSnapshotForTesting(): IndexSnapshot { + return { + idIndex: new Map([...idIndex].map(([key, value]) => [key, new Set(value)])), + classIndex: new Map( + [...classIndex].map(([key, value]) => [key, new Set(value)]), + ), + complexFallback: new Set(complexFallback), + }; +} From 4da06f7ead9efccf45e6dd438fb5fc6dc7f97206 Mon Sep 17 00:00:00 2001 From: Todd Schiller Date: Fri, 5 Jun 2026 01:01:11 -0400 Subject: [PATCH 2/2] =?UTF-8?q?Test:=20property=20suite=20for=20findTrigge?= =?UTF-8?q?redRules=20=E2=80=94=20subtree=20monotonicity=20+=20token=20sou?= =?UTF-8?q?ndness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../selector-token-index.property.test.ts | 435 ++++++++++++++++++ 1 file changed, 435 insertions(+) create mode 100644 extension/src/lib/__tests__/selector-token-index.property.test.ts diff --git a/extension/src/lib/__tests__/selector-token-index.property.test.ts b/extension/src/lib/__tests__/selector-token-index.property.test.ts new file mode 100644 index 0000000..1b5e5ad --- /dev/null +++ b/extension/src/lib/__tests__/selector-token-index.property.test.ts @@ -0,0 +1,435 @@ +// Copyright (c) 2026 PixieBrix, Inc. +// Licensed under PolyForm Shield 1.0.0 — see LICENSE. + +// Property tests for findTriggeredRules. The example tests pin down a +// handful of canonical shapes (root match, descendant match, no-match, +// complex fallback). The dispatcher's correctness rests on two +// structural invariants that fuzz better than they assert: +// +// - Subtree monotonicity: triggered(parent) ⊇ triggered(child) for +// every parent/child pair in the tree. A bug like "only descend N +// levels" or "miss the root itself" violates this. +// - Token soundness: rules registered with `#X` (or `.X`) appear in +// triggered(root) iff some element in the subtree has that id +// (or class). Catches "we look at the wrong DOM property" and +// "we only enumerate the first classList token" style mistakes. +// +// Tree generator mirrors selector-hide-rule.property.test.ts: flat +// `(size, parents[])` encoding so fast-check's shrinker can reduce +// random trees without losing structural validity. + +import fc from "fast-check"; + +import { + __resetSelectorTokenIndexForTesting, + findTriggeredRules, + registerRule, +} from "../selector-token-index"; +import type { RuleId } from "../storage"; +import { __resetSubtreeWatcherForTesting } from "../subtree-watcher"; + +// Small alphabets force collisions — a 3-symbol id alphabet means +// roughly one in four nodes shares an id with the registered rule's +// token. Otherwise the random space is too sparse for the +// "rule triggers iff a node has the id" property to fire its iff +// branch in both directions within fast-check's budget. +const ID_ALPHABET = ["a", "b", "c"] as const; +const CLASS_ALPHABET = ["x", "y", "z"] as const; + +// `""` in the id alphabet stands for "no id attribute" — about a quarter +// of nodes end up unannotated, which keeps the iff-direction of the +// soundness property exercised in both branches. +const idArb = fc.constantFrom(...ID_ALPHABET, ""); +const classesArb = fc.subarray([...CLASS_ALPHABET], { + minLength: 0, + maxLength: 3, +}); + +interface NodeSpec { + id: string; + classes: readonly string[]; +} + +const nodeSpecArb: fc.Arbitrary = fc.record({ + id: idArb, + classes: classesArb, +}); + +interface FlatTree { + size: number; + parents: readonly number[]; + specs: readonly NodeSpec[]; +} + +const flatTreeArb: fc.Arbitrary = fc + .integer({ min: 1, max: 12 }) + .chain((size) => { + const specsArb = fc.array(nodeSpecArb, { + minLength: size, + maxLength: size, + }); + if (size === 1) { + return specsArb.map((specs) => ({ size, parents: [], specs })); + } + const parentArbs = Array.from({ length: size - 1 }, (_, index) => + fc.integer({ min: 0, max: index }), + ); + return fc + .tuple(fc.tuple(...parentArbs), specsArb) + .map(([parents, specs]) => ({ size, parents, specs })); + }); + +interface BuiltTree { + root: HTMLElement; + nodes: HTMLElement[]; + parents: readonly number[]; +} + +function buildTree({ size, parents, specs }: FlatTree): BuiltTree { + const nodes: HTMLElement[] = Array.from({ length: size }, (_, index) => { + const element = document.createElement("div"); + const spec = specs[index] as NodeSpec; + if (spec.id !== "") { + element.id = spec.id; + } + for (const cls of spec.classes) { + element.classList.add(cls); + } + return element; + }); + for (let i = 1; i < size; i++) { + const parentIndex = parents[i - 1]; + if (parentIndex === undefined) { + throw new Error("parents array shorter than expected"); + } + nodes[parentIndex]?.append(nodes[i] as HTMLElement); + } + const root = nodes[0]; + if (!root) { + throw new Error("empty tree (size should be >= 1)"); + } + return { root, nodes, parents }; +} + +// Resolve a node's full ancestor chain back to the root, including +// the node itself. Used to verify that every (descendant, ancestor) +// pair satisfies the monotonicity invariant. +function ancestorChain(index: number, parents: readonly number[]): number[] { + const chain = [index]; + let current = index; + while (current !== 0) { + const parent = parents[current - 1]; + if (parent === undefined) { + throw new Error("invalid parent chain"); + } + chain.push(parent); + current = parent; + } + return chain; +} + +const RULE_FOOTER = "footer-redact" as RuleId; +const RULE_COMMENTS = "comments-redact" as RuleId; +const RULE_REVIEWS = "reviews-redact" as RuleId; +const RULE_COOKIE = "cookie-banner-hide" as RuleId; +const RULE_CHAT = "chat-widget-hide" as RuleId; +const RULE_NEWSLETTER = "newsletter-modal-hide" as RuleId; +const RULE_FALLBACK = "ads-hide" as RuleId; + +beforeEach(() => { + document.body.innerHTML = ""; + __resetSelectorTokenIndexForTesting(); + __resetSubtreeWatcherForTesting(); +}); + +afterEach(() => { + __resetSelectorTokenIndexForTesting(); + __resetSubtreeWatcherForTesting(); +}); + +describe("findTriggeredRules — structural invariants", () => { + it("triggered(parent) ⊇ triggered(child) for every parent/child pair", () => { + fc.assert( + fc.property(flatTreeArb, (tree) => { + __resetSelectorTokenIndexForTesting(); + // One rule per id token and per class token — covers both + // index axes in a single property run. + registerRule({ + ruleId: RULE_FOOTER, + selectors: ["#a"], + dispatchScan: () => undefined, + }); + registerRule({ + ruleId: RULE_COMMENTS, + selectors: ["#b"], + dispatchScan: () => undefined, + }); + registerRule({ + ruleId: RULE_REVIEWS, + selectors: [".x"], + dispatchScan: () => undefined, + }); + registerRule({ + ruleId: RULE_COOKIE, + selectors: [".y"], + dispatchScan: () => undefined, + }); + + const built = buildTree(tree); + document.body.append(built.root); + + const triggeredByIndex = built.nodes.map((node) => + findTriggeredRules(node), + ); + + for (let i = 1; i < tree.size; i++) { + const parentIndex = tree.parents[i - 1] as number; + const childTriggered = triggeredByIndex[i] as Set; + const parentTriggered = triggeredByIndex[parentIndex] as Set; + for (const ruleId of childTriggered) { + // Parent's subtree contains child's subtree by construction, + // so anything child triggers, parent must trigger. + expect(parentTriggered.has(ruleId)).toBe(true); + } + } + }), + ); + }); + + it("triggered(root) is the union of all per-node triggers (no rule lost to ancestry)", () => { + fc.assert( + fc.property(flatTreeArb, (tree) => { + __resetSelectorTokenIndexForTesting(); + registerRule({ + ruleId: RULE_FOOTER, + selectors: ["#a"], + dispatchScan: () => undefined, + }); + registerRule({ + ruleId: RULE_REVIEWS, + selectors: [".x"], + dispatchScan: () => undefined, + }); + + const built = buildTree(tree); + document.body.append(built.root); + + const rootTriggered = findTriggeredRules(built.root); + const perNodeUnion = new Set(); + for (const node of built.nodes) { + for (const ruleId of findTriggeredRules(node)) { + perNodeUnion.add(ruleId); + } + } + + expect(rootTriggered).toEqual(perNodeUnion); + }), + ); + }); + + it("is idempotent — repeated calls produce equal sets", () => { + fc.assert( + fc.property(flatTreeArb, (tree) => { + __resetSelectorTokenIndexForTesting(); + registerRule({ + ruleId: RULE_FOOTER, + selectors: ["#a", ".x"], + dispatchScan: () => undefined, + }); + + const built = buildTree(tree); + document.body.append(built.root); + + const first = findTriggeredRules(built.root); + const second = findTriggeredRules(built.root); + expect(second).toEqual(first); + }), + ); + }); +}); + +describe("findTriggeredRules — token soundness", () => { + it("id-keyed rule fires iff some element in the subtree has the matching id", () => { + fc.assert( + fc.property( + flatTreeArb, + fc.constantFrom(...ID_ALPHABET), + (tree, targetId) => { + __resetSelectorTokenIndexForTesting(); + registerRule({ + ruleId: RULE_FOOTER, + selectors: [`#${targetId}`], + dispatchScan: () => undefined, + }); + + const built = buildTree(tree); + document.body.append(built.root); + + // For each node, the rule should fire iff the subtree rooted + // at that node contains an element with id=targetId. + for (let i = 0; i < built.nodes.length; i++) { + const node = built.nodes[i] as HTMLElement; + const chain = ancestorChain(i, tree.parents); + const subtreeIndexes = new Set([i]); + // A subtree contains every node whose ancestor chain passes + // through `i`. Walk all nodes and check. + for (let j = 0; j < built.nodes.length; j++) { + if (ancestorChain(j, tree.parents).includes(i)) { + subtreeIndexes.add(j); + } + } + const hasMatch = [...subtreeIndexes].some( + (index) => (tree.specs[index] as NodeSpec).id === targetId, + ); + const triggered = findTriggeredRules(node); + expect(triggered.has(RULE_FOOTER)).toBe(hasMatch); + // `chain` only exists to anchor i within the larger tree; + // touching it in a property assertion would let fast-check + // shrink past the case we're checking. + expect(chain.at(-1)).toBe(0); + } + }, + ), + ); + }); + + it("class-keyed rule fires iff some element in the subtree has the matching class", () => { + fc.assert( + fc.property( + flatTreeArb, + fc.constantFrom(...CLASS_ALPHABET), + (tree, targetClass) => { + __resetSelectorTokenIndexForTesting(); + registerRule({ + ruleId: RULE_REVIEWS, + selectors: [`.${targetClass}`], + dispatchScan: () => undefined, + }); + + const built = buildTree(tree); + document.body.append(built.root); + + for (let i = 0; i < built.nodes.length; i++) { + const node = built.nodes[i] as HTMLElement; + const subtreeIndexes = new Set([i]); + for (let j = 0; j < built.nodes.length; j++) { + if (ancestorChain(j, tree.parents).includes(i)) { + subtreeIndexes.add(j); + } + } + const hasMatch = [...subtreeIndexes].some((index) => + (tree.specs[index] as NodeSpec).classes.includes(targetClass), + ); + const triggered = findTriggeredRules(node); + expect(triggered.has(RULE_REVIEWS)).toBe(hasMatch); + } + }, + ), + ); + }); + + it("multiple class tokens on one element all surface their rules", () => { + fc.assert( + fc.property( + fc.subarray([...CLASS_ALPHABET], { + minLength: 1, + maxLength: 3, + }), + (classes) => { + __resetSelectorTokenIndexForTesting(); + const ruleByClass: Record = { + x: RULE_FOOTER, + y: RULE_COMMENTS, + z: RULE_REVIEWS, + }; + for (const cls of CLASS_ALPHABET) { + registerRule({ + ruleId: ruleByClass[cls] as RuleId, + selectors: [`.${cls}`], + dispatchScan: () => undefined, + }); + } + + const node = document.createElement("div"); + for (const cls of classes) { + node.classList.add(cls); + } + document.body.append(node); + + const triggered = findTriggeredRules(node); + // Every class the node carries should surface its rule; + // classes it doesn't carry should not. + for (const cls of CLASS_ALPHABET) { + expect(triggered.has(ruleByClass[cls] as RuleId)).toBe( + classes.includes(cls), + ); + } + }, + ), + ); + }); +}); + +describe("findTriggeredRules — complex fallback", () => { + it("complex-fallback rules appear in triggered(root) for every tree", () => { + fc.assert( + fc.property(flatTreeArb, (tree) => { + __resetSelectorTokenIndexForTesting(); + // No id/class tokens on the rule — it can only run via the + // complex-fallback bucket, so every dispatched root must + // trigger it regardless of contents. + registerRule({ + ruleId: RULE_FALLBACK, + selectors: ['[role="dialog"]'], + dispatchScan: () => undefined, + }); + // A non-fallback rule for contrast: should only fire when a + // matching token is present. + registerRule({ + ruleId: RULE_FOOTER, + selectors: ["#a"], + dispatchScan: () => undefined, + }); + + const built = buildTree(tree); + document.body.append(built.root); + + for (const node of built.nodes) { + const triggered = findTriggeredRules(node); + expect(triggered.has(RULE_FALLBACK)).toBe(true); + } + }), + ); + }); + + it("mixed id+complex rule still fires on the id path when the complex bucket also applies", () => { + fc.assert( + fc.property(flatTreeArb, (tree) => { + __resetSelectorTokenIndexForTesting(); + // The rule has both an id selector and a complex selector — + // it lands in *both* idIndex["a"] and complexFallback, so it + // should always fire (via fallback). The non-complex rule + // is a control: it should only fire when its id is present. + registerRule({ + ruleId: RULE_CHAT, + selectors: ["#a", '[role="dialog"]'], + dispatchScan: () => undefined, + }); + registerRule({ + ruleId: RULE_NEWSLETTER, + selectors: ["#b"], + dispatchScan: () => undefined, + }); + + const built = buildTree(tree); + document.body.append(built.root); + + const rootTriggered = findTriggeredRules(built.root); + expect(rootTriggered.has(RULE_CHAT)).toBe(true); + + const hasB = tree.specs.some((spec) => spec.id === "b"); + expect(rootTriggered.has(RULE_NEWSLETTER)).toBe(hasB); + }), + ); + }); +});