From f3dcc9bae32d3d40a744278ce97eeed731c979f2 Mon Sep 17 00:00:00 2001 From: Todd Schiller Date: Fri, 5 Jun 2026 07:55:56 -0400 Subject: [PATCH] Fix: disguised-ad-flag rescanning re-hid an article the user revealed The reveal stamp (`data-abs-revealed`) lands on the article ancestor, not the disclosure label. `isCandidateSkipped` only checked `element.hasAttribute(REVEALED_ATTR)` on the label, so on the next mutation burst the watcher re-found the same Sponsored label, walked up to the same revealed article, and re-wrapped it into an unrevealable loop. Switch the check to a rule-scoped `closest(...)` walk that honors the ancestor stamp. Surfaced on the demo's "Recommended for you" section, which contains a sponsored ProductCard inside an article-shaped grid wrapper. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../rules/__tests__/disguised-ad-flag.test.ts | 50 +++++++++++++++++++ extension/src/rules/disguised-ad-flag.ts | 7 ++- 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/extension/src/rules/__tests__/disguised-ad-flag.test.ts b/extension/src/rules/__tests__/disguised-ad-flag.test.ts index fd6317b..9afc036 100644 --- a/extension/src/rules/__tests__/disguised-ad-flag.test.ts +++ b/extension/src/rules/__tests__/disguised-ad-flag.test.ts @@ -294,3 +294,53 @@ describe("disguisedAdFlagRule false-positive guards", () => { expect(document.querySelector(`.${PLACEHOLDER_CLASS}`)).toBeNull(); }); }); + +describe("disguisedAdFlagRule reveal flow", () => { + it("does not re-hide an article the user revealed via click", () => { + document.body.innerHTML = articleCard({ label: "Sponsored" }); + disguisedAdFlagRule.apply(document.body); + + const placeholder = document.querySelector( + `.${PLACEHOLDER_CLASS}`, + ); + expect(placeholder).not.toBeNull(); + + // attachReveal in lib/placeholder restores the original element with + // REVEALED_ATTR stamped on it. A subsequent scan (the second apply + // call here stands in for the mutation burst the subtree-watcher + // fans out when the original re-attaches) must not re-wrap the + // same article — otherwise the placeholder comes back the moment + // the user clicks reveal. + placeholder?.click(); + expect(document.querySelector('[data-fixture="card"]')).not.toBeNull(); + + disguisedAdFlagRule.apply(document.body); + + expect(document.querySelectorAll(`.${PLACEHOLDER_CLASS}`)).toHaveLength(0); + expect(document.querySelector('[data-fixture="card"]')).not.toBeNull(); + }); + + it("re-hides a freshly-inserted advertorial card after a reveal elsewhere", () => { + // A reveal on one card must not desensitize the rule for unrelated + // siblings — only the revealed ancestor (and its descendants) are + // exempt. + document.body.innerHTML = ` + ${articleCard({ label: "Sponsored", heading: "First card" })} + ${articleCard({ label: "Sponsored", heading: "Second card" })} + `; + disguisedAdFlagRule.apply(document.body); + expect(document.querySelectorAll(`.${PLACEHOLDER_CLASS}`)).toHaveLength(2); + + const [first] = document.querySelectorAll( + `.${PLACEHOLDER_CLASS}`, + ); + first?.click(); + + disguisedAdFlagRule.apply(document.body); + + // First card is revealed and stays revealed; second card stays hidden. + expect(document.querySelectorAll(`.${PLACEHOLDER_CLASS}`)).toHaveLength(1); + const revealedCards = document.querySelectorAll('[data-fixture="card"]'); + expect(revealedCards).toHaveLength(1); + }); +}); diff --git a/extension/src/rules/disguised-ad-flag.ts b/extension/src/rules/disguised-ad-flag.ts index 4d1a88c..14d34dd 100644 --- a/extension/src/rules/disguised-ad-flag.ts +++ b/extension/src/rules/disguised-ad-flag.ts @@ -266,7 +266,12 @@ function isCandidateSkipped(element: HTMLElement): boolean { if (element.hasAttribute(CONSIDERED_ATTR)) { return true; } - if (element.hasAttribute(REVEALED_ATTR)) { + // Skip if the label sits inside (or is) an element this rule already + // hid and the user then revealed. The reveal stamp lives on the article + // ancestor — not on the label — so checking only `element.hasAttribute` + // missed the common case and let a post-reveal mutation burst re-wrap + // the same article into an unrevealable loop. + if (element.closest(`[${REVEALED_ATTR}="${RULE_ID}"]`)) { return true; } if (isInsidePlaceholder(element)) {