fix: scope getRoot to the comment tree so the shadow walk doesn't collect reddit chrome (closes task/getroot-comment-tree-scope)

Source: task/getroot-comment-tree-scope (plan/steward-linker-design) — getRoot's union querySelector resolved to <main> 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 <noreply@anthropic.com>
This commit is contained in:
paul
2026-06-23 16:38:11 +00:00
co-authored by Claude Opus 4.8
parent a14f566e53
commit 8ac0666a71
3 changed files with 59 additions and 26 deletions
+36 -11
View File
@@ -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 <slot>'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 <slot>'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 <main> wraps the whole app and is an
// ANCESTOR of shreddit-comment-tree, so <main> 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);