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

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 147 additions & 0 deletions extension/src/lib/__tests__/selector-hide-rule.property.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]";
Expand Down Expand Up @@ -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<MarkedScenario> = 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);
}),
);
});
});
159 changes: 159 additions & 0 deletions extension/src/lib/__tests__/selector-hide-rule.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `<div id="widget">x</div>`;

rule.apply(document.body);
const widget = document.querySelector<HTMLElement>("#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 = `<footer id="f" data-abs-revealed="${RULE_ID}">revealed</footer>`;

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 = `
<div id="wrap" data-abs-revealed="${RULE_ID}">
<footer id="inner">x</footer>
</div>
<div id="newhome"></div>
`;

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<HTMLElement>("#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 = `<div class="${PLACEHOLDER_CLASS}">x</div>`;

const placeholder = document.querySelector<HTMLElement>(
`.${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();
});
});
31 changes: 31 additions & 0 deletions extension/src/lib/selector-hide-rule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLElement>();

function refreshMemo(url: string): void {
if (url === memoUrl) {
return;
Expand Down Expand Up @@ -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) {
Expand All @@ -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);
}
}

Expand Down
Loading