From 8ac0666a71e069e86d5a64ed932d24605ac8002e Mon Sep 17 00:00:00 2001 From: paul Date: Tue, 23 Jun 2026 16:38:11 +0000 Subject: [PATCH] fix: scope getRoot to the comment tree so the shadow walk doesn't collect reddit chrome (closes task/getroot-comment-tree-scope) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Source: task/getroot-comment-tree-scope (plan/steward-linker-design) — getRoot's union querySelector resolved to
on new reddit, so the shadow-piercing walk over-collected reddit chrome. - getRoot now tries shreddit-comment-tree, then .commentarea, then main/body sequentially (narrow-first) instead of one union querySelector, so the comment tree wins when present. - collectInto takes an allowShadow flag: a narrow comment root pierces every open shadow host; a broad fallback root descends only into comment-component hosts (tagName SHREDDIT-COMMENT*), so chrome shadow roots (nav/sidebar/composer) are never walked. - README + AGENTS shadow-DOM notes updated to describe the two-layer scoping. Co-Authored-By: Claude Opus 4.8 --- AGENTS.md | 25 +++++++++++++---------- README.md | 13 +++++++----- extension/content.js | 47 +++++++++++++++++++++++++++++++++----------- 3 files changed, 59 insertions(+), 26 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9773126..cd16c3a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,16 +24,21 @@ is tracked in the **towl** work-item store under project **`reddit-spotify-linke phrases for albums) — node-testable via `extract.test.cjs`. Album links render italicised (`.rsl-album`). - **shreddit shadow DOM:** `collectTextNodes` is shadow-piercing. A TreeWalker can't - cross a shadow boundary, so it walks the comment-tree `root`'s light DOM AND - recursively descends into the **open** shadow roots of elements within that subtree. - New reddit's `shreddit-*` components may encapsulate comment bodies in a shadow root; - this reaches them whether reddit slots (light DOM) or encapsulates (shadow DOM), with - no need to know which. It is scoped to the comment-tree root (never the whole - document's shadow roots, which would pull in reddit chrome) and is a no-op on old - reddit (no shadow roots). Slotted light-DOM nodes are not double-collected (a shadow - walker sees the shadow tree's own nodes, not a ``'s assigned light nodes; a - Set de-dupes belt-and-braces). **Closed** shadow roots are unreachable from a content - script — a documented limitation. + cross a shadow boundary, so it walks the chosen `root`'s light DOM AND recursively + descends into the **open** shadow roots of elements within that subtree. New reddit's + `shreddit-*` components may encapsulate comment bodies in a shadow root; this reaches + them whether reddit slots (light DOM) or encapsulates (shadow DOM). Scope is two-layer: + `getRoot` tries the narrow comment containers first (`shreddit-comment-tree`, then old + reddit's `.commentarea`) before the broad `main`/`body`, so on a new-reddit comment page + the root is the comment tree and the walk stays within it; and when `getRoot` falls back + to a broad root (no comment tree present) the recursion descends only into comment-component + shadow hosts (`tagName` starting `SHREDDIT-COMMENT`), so reddit's chrome shadow roots (nav, + sidebar, composer) are never walked. (A single union `querySelector` could not do this — it + returns the first match in document order, and `
` is an ancestor of the comment tree, + so it would always win.) It is a no-op on old reddit (no shadow roots). Slotted light-DOM + nodes are not double-collected (a shadow walker sees the shadow tree's own nodes, not a + ``'s assigned light nodes; a Set de-dupes belt-and-braces). **Closed** shadow roots + are unreachable from a content script — a documented limitation. - `scripts/check.sh` — the local check gate. ## How work lands (IMPORTANT — this is NOT the towl repo) diff --git a/README.md b/README.md index e6d75d4..b852e2a 100644 --- a/README.md +++ b/README.md @@ -180,11 +180,14 @@ let the content script reach it). name links to a Google search for " bandcamp" (which surfaces the Bandcamp page). - **Without Spotify creds or the LLM, nothing links** — at least one gate must be configured. -- **New reddit (shreddit) shadow DOM** — the comment-text scan pierces the **open** - shadow roots of the `shreddit-*` web components within the comment tree, so it links - comment bodies whether reddit slots them into light DOM or encapsulates them in a - shadow root. **Closed** shadow roots are unreachable from a content script and stay - unlinked (a browser limitation, not a setting). +- **New reddit (shreddit) shadow DOM** — the scan roots at the comment tree + (`shreddit-comment-tree`, falling back to old reddit's `.commentarea`, then `main`/`body`) + and pierces the **open** shadow roots of the `shreddit-*` web components within it, so it + links comment bodies whether reddit slots them into light DOM or encapsulates them in a + shadow root. On a broad fallback root (no comment tree found) only comment-component shadow + roots are descended, so reddit's chrome (nav, sidebar, composer) is never collected. + **Closed** shadow roots are unreachable from a content script and stay unlinked (a browser + limitation, not a setting). - The optional LLM adds a little latency on the **first** call (ollama warms the model), then is fast. - The resolver caches results in memory; it resets when you restart it. diff --git a/extension/content.js b/extension/content.js index 55a56b7..d0998bf 100644 --- a/extension/content.js +++ b/extension/content.js @@ -32,24 +32,28 @@ // New reddit's shreddit-* web components may render comment bodies inside an OPEN shadow // root. A TreeWalker can't cross a shadow boundary, so collectTextNodes walks `root`'s light - // DOM AND, recursively, every open shadow root of elements within that subtree — so it reaches + // DOM AND, recursively, the open shadow roots of elements within that subtree — so it reaches // the comment text whether reddit slots it (light DOM) or encapsulates it (shadow DOM). Old - // reddit has no shadow roots, so the recursion is a no-op there. Scoped to the comment-tree - // `root` (getRoot()) — it never walks the whole document's shadow roots, which would pull in - // reddit's chrome/UI text and be unbounded. A slotted light-DOM node is reached only by the - // light walk: a TreeWalker over a shadow root walks the shadow tree's own nodes, not a 's + // reddit has no shadow roots, so the recursion is a no-op there. The shadow walk is gated by + // `allowShadow`: when the root is a narrow comment container the whole subtree is comment text, + // so every shadow host is descended; on a BROAD fallback root (main/body) only a shadow host + // that is itself a comment component is descended, so reddit's chrome shadow roots (nav, + // sidebar, composer) are never walked. A slotted light-DOM node is reached only by the light + // walk: a TreeWalker over a shadow root walks the shadow tree's own nodes, not a 's // assigned (light) nodes, so it isn't double-collected — a Set keyed on node identity is a // belt-and-braces guard. Closed shadow roots (`.shadowRoot === null`) stay unreachable. const MAX_SHADOW_DEPTH = 50; // bound the shadow-host recursion; a real comment tree is far shallower. - function collectInto(root, nodes, seen, depth) { + function collectInto(root, nodes, seen, depth, allowShadow) { if (depth > MAX_SHADOW_DEPTH) return; // SHOW_TEXT to gather candidate text; SHOW_ELEMENT so we can spot shadow hosts to recurse into. const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT, { acceptNode(node) { if (node.nodeType === Node.ELEMENT_NODE) { // Elements are visited only to discover shadow hosts; never collected as text. - return node.shadowRoot ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP; + // On a broad root, only descend into comment-component hosts — never chrome. + if (!node.shadowRoot) return NodeFilter.FILTER_SKIP; + return (allowShadow || hostIsCommentComponent(node)) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP; } const v = node.nodeValue; if (!v || v.length > 5000 || !/[A-Za-z]/.test(v)) return NodeFilter.FILTER_REJECT; @@ -60,8 +64,9 @@ let n; while ((n = walker.nextNode())) { if (n.nodeType === Node.ELEMENT_NODE) { - // Open shadow host: descend into its shadow tree (and its descendants' shadow trees). - collectInto(n.shadowRoot, nodes, seen, depth + 1); + // Open shadow host (already gated above): descend. Inside a comment component the whole + // shadow subtree is comment content, so further descent is unconditionally allowed. + collectInto(n.shadowRoot, nodes, seen, depth + 1, true); } else if (!seen.has(n)) { seen.add(n); nodes.push(n); @@ -71,7 +76,9 @@ function collectTextNodes(root) { const nodes = []; - collectInto(root, nodes, new Set(), 0); + // A narrow comment container scopes the whole walk to comment text → pierce every shadow + // host. A broad fallback root (main/body) pierces only comment-component hosts (see collectInto). + collectInto(root, nodes, new Set(), 0, isNarrowRoot(root)); return nodes; } @@ -153,13 +160,31 @@ } // ---- scan orchestration --------------------------------------------------- + // Try the NARROW comment containers first, one at a time, before the broad app + // shells. A single union querySelector returns the first element in DOCUMENT ORDER + // matching ANY selector, and on new reddit
wraps the whole app and is an + // ANCESTOR of shreddit-comment-tree, so
would always win — rooting the + // shadow-piercing walk at the whole app and over-collecting reddit's chrome. Picking + // the comment tree when present keeps the walk inside the comment subtree. function getRoot() { return ( - document.querySelector("main, .content[role='main'], shreddit-comment-tree, .commentarea") || + document.querySelector("shreddit-comment-tree") || // new reddit + document.querySelector(".commentarea") || // old reddit + document.querySelector("main, .content[role='main']") || document.body ); } + // A narrow comment container scopes the whole walk to comment text, so shadow-piercing + // is always safe there. On a BROAD fallback root (main/body — no comment tree found) the + // walk must NOT descend into the app's chrome shadow roots (nav, sidebar, composer): only + // descend into a shadow host that is itself a comment component. + const NARROW_ROOT_SELECTOR = "shreddit-comment-tree, .commentarea"; + const isNarrowRoot = (root) => + root.nodeType === Node.ELEMENT_NODE && typeof root.matches === "function" && root.matches(NARROW_ROOT_SELECTOR); + const hostIsCommentComponent = (el) => + typeof el.tagName === "string" && el.tagName.startsWith("SHREDDIT-COMMENT"); + async function scan() { const root = getRoot(); const nodes = collectTextNodes(root);