diff --git a/extension/src/lib/dom-utils.ts b/extension/src/lib/dom-utils.ts index 8ac7a9a..b741aac 100644 --- a/extension/src/lib/dom-utils.ts +++ b/extension/src/lib/dom-utils.ts @@ -63,51 +63,58 @@ export function isInsidePlaceholder(element: Element): boolean { return element.closest(`.${PLACEHOLDER_CLASS}`) !== null; } -// True if any element in the candidate list is an ancestor of `element`. -// Used to dedupe to the outermost match when multiple nested candidates all -// satisfy a rule. -function hasAncestorIn( - candidate: T, - candidates: readonly T[], - getElement: (item: T) => Element, -): boolean { - const element = getElement(candidate); - return candidates.some( - (other) => other !== candidate && getElement(other).contains(element), - ); -} - -// True if any element in the candidate list is a descendant of `element`. -function hasDescendantIn( - candidate: T, - candidates: readonly T[], - getElement: (item: T) => Element, -): boolean { - const element = getElement(candidate); - return candidates.some( - (other) => other !== candidate && element.contains(getElement(other)), - ); -} - // Keep only candidates that have no candidate ancestor — the outermost // match of each nested group. Use when hiding a wrapper should subsume its // nested matches (prompt injection, irrelevant sections). +// +// O(C·D) where D is the maximum depth: build a Set of candidate elements +// once, then walk each candidate's parent chain checking Set membership. +// The naive `candidates.some(other.contains(element))` shape was O(C²·D), +// which matters on feeds where the same rule sees hundreds of candidates +// accumulate across a scroll session. export function filterToOutermost( candidates: readonly T[], getElement: (item: T) => Element = (item) => item as unknown as Element, ): T[] { - return candidates.filter((c) => !hasAncestorIn(c, candidates, getElement)); + const elementSet = new Set(candidates.map(getElement)); + return candidates.filter((candidate) => { + let parent = getElement(candidate).parentElement; + while (parent) { + if (elementSet.has(parent)) { + return false; + } + parent = parent.parentElement; + } + return true; + }); } // Keep only candidates that have no candidate descendant — the innermost // match of each nested group. Use when the urgency/scarcity/match lives on // a small leaf inside a larger card we shouldn't black out (countdown, // scarcity, cart-addon). +// +// Computed as the dual of filterToOutermost: for each candidate, walk up +// and mark any candidate-ancestors encountered as "has descendant." Anything +// not marked is innermost. O(C·D) total. export function filterToInnermost( candidates: readonly T[], getElement: (item: T) => Element = (item) => item as unknown as Element, ): T[] { - return candidates.filter((c) => !hasDescendantIn(c, candidates, getElement)); + const elementSet = new Set(candidates.map(getElement)); + const hasCandidateDescendant = new Set(); + for (const candidate of candidates) { + let parent = getElement(candidate).parentElement; + while (parent) { + if (elementSet.has(parent)) { + hasCandidateDescendant.add(parent); + } + parent = parent.parentElement; + } + } + return candidates.filter( + (candidate) => !hasCandidateDescendant.has(getElement(candidate)), + ); } interface FindInnermostMatchesOptions { diff --git a/extension/src/lib/selector-hide-rule.ts b/extension/src/lib/selector-hide-rule.ts index 0946dc0..8df56ce 100644 --- a/extension/src/lib/selector-hide-rule.ts +++ b/extension/src/lib/selector-hide-rule.ts @@ -16,6 +16,7 @@ import type { URLPattern } from "urlpattern-polyfill"; 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 type { RuleId } from "./storage"; import { createSubtreeWatcher } from "./subtree-watcher"; @@ -120,23 +121,16 @@ export function createSelectorHideRule( candidates = candidates.filter(candidateFilter); } - // Outermost-match dedupe: build a Set once, then for each candidate walk - // parentElement up to body. O(C·D) instead of the prior O(C²) - // `candidates.some(other.contains(element))` — matters on feeds where C - // grows into the hundreds over a scroll session. - const candidateSet = new Set(candidates); - function hasCandidateAncestor(element: HTMLElement): boolean { - let parent = element.parentElement; - while (parent) { - if (candidateSet.has(parent)) { - return true; - } - parent = parent.parentElement; - } - return false; - } + // Outermost-match dedupe via the shared helper. Pre-filtering up front + // (instead of an inline ancestor check inside the loop) keeps the loop + // body focused on the placeholder/marker skips, and means only one + // implementation of "outermost" exists across the codebase. + const outermost = new Set(filterToOutermost(candidates)); for (const element of candidates) { + if (!outermost.has(element)) { + continue; + } if (!element.isConnected) { continue; } @@ -155,9 +149,6 @@ export function createSelectorHideRule( if (element.getAttribute(HIDDEN_ATTR) === id) { continue; } - if (hasCandidateAncestor(element)) { - continue; - } if (removeEntirely) { // Don't detach: if the page renders this overlay through a framework // that retains DOM references (React's fiber, Vue's vnode), removing