diff --git a/extension/src/lib/__tests__/selector-hide-rule.property.test.ts b/extension/src/lib/__tests__/selector-hide-rule.property.test.ts index 07bd5e2..4f91e05 100644 --- a/extension/src/lib/__tests__/selector-hide-rule.property.test.ts +++ b/extension/src/lib/__tests__/selector-hide-rule.property.test.ts @@ -14,12 +14,14 @@ import fc from "fast-check"; +import { HIDDEN_ATTR, REVEALED_ATTR } from "../dom-markers"; import { filterToOutermost } from "../dom-utils"; import { PLACEHOLDER_CLASS } from "../placeholder"; import { createSelectorHideRule } from "../selector-hide-rule"; import type { RuleId } from "../storage"; const RULE_ID = "footer-redact" as RuleId; +const OTHER_RULE_ID = "comments-redact" as RuleId; const HIDE_LABEL = "[hidden]"; const TARGET_ATTR = "data-target"; const TARGET_SELECTOR = "[data-target]"; @@ -200,3 +202,148 @@ describe("scan() outermost-match (property)", () => { ); }); }); + +describe("processed-WeakSet idempotence under apply N times (property)", () => { + // The strongest property the WeakSet must satisfy: with the DOM held + // fixed between calls, apply 1 must produce the same DOM as apply N. + // Fuzzes over random tree shape + random pre-existing markers + // (PLACEHOLDER_CLASS, HIDDEN_ATTR for this rule, HIDDEN_ATTR for + // another rule, REVEALED_ATTR for this rule, REVEALED_ATTR for + // another rule) so the test covers every shape the rule's marker- + // skip ladder can encounter. A regression in WeakSet membership + // (added when it shouldn't be, OR not added when it should be) + // surfaces as innerHTML divergence between the first and N-th apply. + + // Marker kind labels: + // 0 = no marker + // 1 = PLACEHOLDER_CLASS + // 2 = HIDDEN_ATTR for this rule (already hidden) + // 3 = HIDDEN_ATTR for some other rule (we should overwrite per spec) + // 4 = REVEALED_ATTR for this rule (skip) + // 5 = REVEALED_ATTR for some other rule (don't skip) + const markerArb = fc.integer({ min: 0, max: 5 }); + + interface MarkedScenario { + tree: FlatTree; + targetMask: boolean[]; + markers: number[]; + extraApplies: number; + removeEntirely: boolean; + } + + const scenarioArb: fc.Arbitrary = flatTreeArb.chain((tree) => + fc + .tuple( + fc.array(fc.boolean(), { minLength: tree.size, maxLength: tree.size }), + fc.array(markerArb, { minLength: tree.size, maxLength: tree.size }), + fc.integer({ min: 1, max: 5 }), + fc.boolean(), + ) + .map(([targetMask, markers, extraApplies, removeEntirely]) => ({ + tree, + targetMask, + markers, + extraApplies, + removeEntirely, + })), + ); + + function applyMarker(element: HTMLElement, kind: number): void { + switch (kind) { + case 1: { + element.classList.add(PLACEHOLDER_CLASS); + break; + } + case 2: { + element.setAttribute(HIDDEN_ATTR, RULE_ID); + break; + } + case 3: { + element.setAttribute(HIDDEN_ATTR, OTHER_RULE_ID); + break; + } + case 4: { + element.setAttribute(REVEALED_ATTR, RULE_ID); + break; + } + case 5: { + element.setAttribute(REVEALED_ATTR, OTHER_RULE_ID); + break; + } + default: { + // 0 — no marker. + } + } + } + + function buildMarkedTree(scenario: MarkedScenario): { + root: HTMLElement; + all: HTMLElement[]; + } { + const all: HTMLElement[] = []; + const root = buildFlatTree(scenario.tree, all); + for (const [i, node] of all.entries()) { + if (scenario.targetMask[i]) { + node.setAttribute(TARGET_ATTR, ""); + } + applyMarker(node, scenario.markers[i] as number); + } + return { root, all }; + } + + it("DOM after apply once == DOM after apply N (placeholder mode)", () => { + fc.assert( + fc.property(scenarioArb, (scenario) => { + document.body.innerHTML = ""; + const { root } = buildMarkedTree(scenario); + document.body.append(root); + + const { rule } = createSelectorHideRule({ + id: RULE_ID, + label: "test", + description: "test", + alwaysOnSelectors: [TARGET_SELECTOR], + hideLabel: HIDE_LABEL, + }); + + rule.apply(document.body); + const afterFirst = document.body.innerHTML; + + for (let i = 0; i < scenario.extraApplies; i++) { + rule.apply(document.body); + } + const afterNth = document.body.innerHTML; + + expect(afterNth).toBe(afterFirst); + }), + ); + }); + + it("DOM after apply once == DOM after apply N (removeEntirely mode)", () => { + fc.assert( + fc.property(scenarioArb, (scenario) => { + document.body.innerHTML = ""; + const { root } = buildMarkedTree(scenario); + document.body.append(root); + + const { rule } = createSelectorHideRule({ + id: RULE_ID, + label: "test", + description: "test", + alwaysOnSelectors: [TARGET_SELECTOR], + removeEntirely: true, + }); + + rule.apply(document.body); + const afterFirst = document.body.innerHTML; + + for (let i = 0; i < scenario.extraApplies; i++) { + rule.apply(document.body); + } + const afterNth = document.body.innerHTML; + + expect(afterNth).toBe(afterFirst); + }), + ); + }); +}); diff --git a/extension/src/lib/__tests__/selector-hide-rule.test.ts b/extension/src/lib/__tests__/selector-hide-rule.test.ts index 0568b42..bde8505 100644 --- a/extension/src/lib/__tests__/selector-hide-rule.test.ts +++ b/extension/src/lib/__tests__/selector-hide-rule.test.ts @@ -797,3 +797,162 @@ describe("subtree dispatcher integration", () => { rule.teardown?.(); }); }); + +describe("processed-node WeakSet bypass", () => { + // The WeakSet is a perf cache: once a rule's scan concludes "skip" + // for an element based on the element's own state, future scans + // short-circuit. Markers (HIDDEN_ATTR, REVEALED_ATTR) stay in the DOM + // for cross-rule coordination and for reveal click handlers; the + // WeakSet is purely a hot-loop bypass for this rule's own re-scans. + + it("skips an already-hidden element on re-scan even if HIDDEN_ATTR is removed externally", () => { + // Demonstrates the perf-cache nature: the rule trusts its own + // record over the DOM marker. If page JS strips HIDDEN_ATTR, the + // rule still doesn't re-process the element — this is what makes + // the bypass a "cache" rather than a recomputation. + const { rule } = createSelectorHideRule({ + id: RULE_ID, + label: "test", + description: "test", + alwaysOnSelectors: ["#widget"], + removeEntirely: true, + }); + document.body.innerHTML = `
x
`; + + rule.apply(document.body); + const widget = document.querySelector("#widget"); + expect(widget?.getAttribute(HIDDEN_ATTR)).toBe(RULE_ID); + + // External actor strips the marker, but the element stays in the + // DOM. The widget is still display:none (rule won't undo that). + widget?.removeAttribute(HIDDEN_ATTR); + // Tamper with display so a re-hide would be visible — verifies + // the rule didn't re-run. + widget?.style.removeProperty("display"); + + rule.apply(document.body); + + expect(widget?.style.display).toBe(""); + expect(widget?.getAttribute(HIDDEN_ATTR)).toBeNull(); + }); + + it("skips an element revealed for this rule on re-scan", () => { + // REVEALED_ATTR is own-state and monotonic — once set, never + // cleared. The WeakSet memoizes the skip. + const { rule } = createSelectorHideRule({ + id: RULE_ID, + label: "test", + description: "test", + alwaysOnSelectors: ["footer"], + hideLabel: HIDE_LABEL, + }); + document.body.innerHTML = `
revealed
`; + + rule.apply(document.body); + rule.apply(document.body); + + // Skipped both times: no placeholder, original footer present. + expect(document.querySelector("#f")).not.toBeNull(); + expect(document.querySelector(`.${PLACEHOLDER_CLASS}`)).toBeNull(); + }); + + it("does NOT memoize ancestor-relative skips — element re-evaluated if it moves out from under a revealed ancestor", () => { + // The safety boundary the WeakSet design intentionally preserves. + // closest('[REVEALED_ATTR=id]') skips depend on ancestry, which + // can change; if we memoized this we'd silently miss matches that + // move out of a revealed wrapper. + const { rule } = createSelectorHideRule({ + id: RULE_ID, + label: "test", + description: "test", + alwaysOnSelectors: ["footer"], + hideLabel: HIDE_LABEL, + }); + document.body.innerHTML = ` +
+
x
+
+
+ `; + + rule.apply(document.body); + // Inside the revealed wrapper — skipped. + expect(document.querySelector("#inner")).not.toBeNull(); + expect(document.querySelector(`.${PLACEHOLDER_CLASS}`)).toBeNull(); + + // Move the footer out of the wrapper. + const inner = document.querySelector("#inner"); + document.querySelector("#newhome")?.append(inner as HTMLElement); + + rule.apply(document.body); + // Now eligible — should have been hidden. + expect(document.querySelector("#inner")).toBeNull(); + expect(document.querySelector(`.${PLACEHOLDER_CLASS}`)).not.toBeNull(); + }); + + it("does NOT memoize 'inside an existing placeholder' skips", () => { + // Parallel safety boundary for the closest('.placeholder') check. + // If the placeholder is replaced (e.g., user reveals it), an + // element that used to live inside it is now exposed and should + // be re-evaluated. + const { rule } = createSelectorHideRule({ + id: RULE_ID, + label: "test", + description: "test", + alwaysOnSelectors: ["footer"], + hideLabel: HIDE_LABEL, + }); + const fakePlaceholder = document.createElement("div"); + fakePlaceholder.classList.add(PLACEHOLDER_CLASS); + const inner = document.createElement("footer"); + inner.id = "inner"; + inner.textContent = "x"; + fakePlaceholder.append(inner); + document.body.append(fakePlaceholder); + + rule.apply(document.body); + expect(document.querySelector("#inner")).not.toBeNull(); + + // Strip placeholder-ness from the wrapper (simulates the user + // revealing it). + fakePlaceholder.classList.remove(PLACEHOLDER_CLASS); + + rule.apply(document.body); + expect(document.querySelector("#inner")).toBeNull(); + }); + + it("memoizes the placeholder-self check so re-scans don't re-pay the classList read", () => { + // Behavioral assertion via spy: once the rule scans a candidate + // that is itself a placeholder, the next scan should not call + // classList.contains for that same element. Hard to assert + // directly without internals, so we proxy via a spy on + // Element.prototype.getAttribute and confirm the count is bounded. + const { rule } = createSelectorHideRule({ + id: RULE_ID, + label: "test", + description: "test", + alwaysOnSelectors: ["div"], + candidateFilter: (element) => + element.classList.contains(PLACEHOLDER_CLASS), + hideLabel: HIDE_LABEL, + }); + document.body.innerHTML = `
x
`; + + const placeholder = document.querySelector( + `.${PLACEHOLDER_CLASS}`, + ) as HTMLElement; + const getAttributeSpy = jest.spyOn(placeholder, "getAttribute"); + + rule.apply(document.body); + // First scan: own-state classList check hits, getAttribute not + // called for marker checks on this element (the placeholder + // branch short-circuits before the getAttribute lines). + const firstScanCalls = getAttributeSpy.mock.calls.length; + + rule.apply(document.body); + // Second scan: WeakSet bypass means we never even reach the + // classList check. getAttribute call count stays flat. + expect(getAttributeSpy.mock.calls.length).toBe(firstScanCalls); + getAttributeSpy.mockRestore(); + }); +}); diff --git a/extension/src/lib/selector-hide-rule.ts b/extension/src/lib/selector-hide-rule.ts index 2511eb0..4d69e6c 100644 --- a/extension/src/lib/selector-hide-rule.ts +++ b/extension/src/lib/selector-hide-rule.ts @@ -89,6 +89,23 @@ export function createSelectorHideRule( let memoSelectors: readonly string[] = []; let memoJoined = ""; + // Per-rule cache of elements this scan has already concluded a skip + // (or hide) for. Bypasses the 5 marker reads (PLACEHOLDER_CLASS check, + // 2 closest() walks, 2 getAttribute calls) on subsequent scans where + // the same element keeps surfacing — typical on infinite-scroll feeds + // and SPA route sweeps where the dispatcher's QSA re-finds every + // already-processed candidate. + // + // Markers stay in the DOM so other rules and reveal-click handlers + // can read them; the WeakSet is purely a hot-loop perf bypass for + // this rule's own re-scans. Membership is only added when the skip + // reason is the element's *own* state (its own classList / + // attribute marker) — ancestor-relative checks (`closest(.placeholder)` + // / `closest([REVEALED_ATTR=id])`) intentionally do NOT add to the + // set, because the element could later move out from under the + // matched ancestor. + const processed = new WeakSet(); + function refreshMemo(url: string): void { if (url === memoUrl) { return; @@ -148,19 +165,32 @@ export function createSelectorHideRule( if (!element.isConnected) { continue; } + // Fast path: a previous scan already concluded this element should + // be skipped (or hid it). Saves the 5 marker reads below. + if (processed.has(element)) { + continue; + } if (element.classList.contains(PLACEHOLDER_CLASS)) { + processed.add(element); continue; } if (element.closest(`.${PLACEHOLDER_CLASS}`)) { + // Ancestor-relative — don't memoize. If the placeholder is + // later revealed (its click handler swaps the placeholder back + // out), this element's "inside a placeholder" status flips and + // we want the next scan to re-evaluate. continue; } if (element.getAttribute(REVEALED_ATTR) === id) { + processed.add(element); continue; } if (element.closest(`[${REVEALED_ATTR}="${id}"]`)) { + // Same reason as above: ancestor-relative, don't memoize. continue; } if (element.getAttribute(HIDDEN_ATTR) === id) { + processed.add(element); continue; } if (removeEntirely) { @@ -176,6 +206,7 @@ export function createSelectorHideRule( // hideLabel is guaranteed non-undefined by the constructor check above. replaceWithBlockPlaceholder(element, id, hideLabel as string); } + processed.add(element); } }