From a14f566e53ddf01a81887a7672fa52803a91aaf9 Mon Sep 17 00:00:00 2001 From: paul Date: Tue, 23 Jun 2026 16:28:33 +0000 Subject: [PATCH] perf: reuse per-node matches + skip scans on node-less mutations in content.js (closes task/content-scan-efficiency) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Source: task/content-scan-efficiency (plan/steward-linker-quality) — the observer re-scanned on unrelated churn and the regex ran twice per node per scan. - scan() now caches each text node's allMatchesIn() result in a per-scan Map; wrapNode reuses that array instead of re-running the regex battery, so the regex runs once per node per scan, not twice. - The MutationObserver stays broad (documentElement, subtree) but short-circuits: it only schedules a scan when a mutation batch actually ADDED an element/text node. SPA navigation and "load more" add nodes and still scan; removal-only / attribute / characterData churn (vote counts, timers, hovercards) no longer fires the 600ms full scan. - Behavior-preserving: same matches, same wrapping, same injected-link set; rsl-link idempotency skip + running/pending serialization latch + debounce all intact. Co-Authored-By: Claude Opus 4.8 --- extension/content.js | 38 +++++++++++++++++++++++++++++++++----- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/extension/content.js b/extension/content.js index ec9fec3..55a56b7 100644 --- a/extension/content.js +++ b/extension/content.js @@ -102,9 +102,11 @@ return a; } - function wrapNode(node) { + // `matches` is the node's allMatchesIn(text) result, computed once per scan in scan() + // and reused here — the regex battery runs once per node per scan, not twice. + function wrapNode(node, matches) { const text = node.nodeValue; - const ms = Extract.allMatchesIn(text).filter((m) => { + const ms = matches.filter((m) => { const links = resolved.get(m.name); return links && ((links.primary && links.primary.url) || links.spotify); }); @@ -164,8 +166,17 @@ log("scan: root", root.tagName || root.nodeName, "| text nodes", nodes.length); if (!nodes.length) return; + // Run the regex battery ONCE per node this scan: cache each node's full match array in + // matchesByNode and derive the candidate `seen` set from it, so wrapNode can reuse the same + // array instead of re-running allMatchesIn. Scoped to this scan (rebuilt each call) — node text + // can change between scans, so it is never persisted. + const matchesByNode = new Map(); const seen = new Set(); - for (const node of nodes) for (const m of Extract.allMatchesIn(node.nodeValue)) seen.add(m.name); + for (const node of nodes) { + const ms = Extract.allMatchesIn(node.nodeValue); + matchesByNode.set(node, ms); + for (const m of ms) seen.add(m.name); + } log("candidates found:", seen.size); const ask = [...seen].filter((c) => !resolved.has(c)); @@ -180,7 +191,7 @@ if (Object.prototype.hasOwnProperty.call(res, c)) resolved.set(c, res[c] || null); } } - for (const node of nodes) if (node.isConnected) wrapNode(node); + for (const node of nodes) if (node.isConnected) wrapNode(node, matchesByNode.get(node)); const total = document.querySelectorAll("a.rsl-link").length; api.runtime.sendMessage({ type: "badge", count: total }).catch(() => {}); @@ -208,8 +219,25 @@ const onThread = () => /\/comments\//.test(location.pathname); + // Only ADDED element/text nodes can introduce new comment text to link, so a mutation batch + // that added none (pure attribute/characterData churn on existing nodes — vote counts, timers, + // hovercard text) can't change the link set and is skipped. SPA navigation and "load more" both + // ADD nodes, so they still trigger a scan. Conservative: any addedNodes of a relevant type + // schedules — we never inspect the added node's contents (late-rendered text would be missed). + function addedRelevantNodes(records) { + for (const r of records) { + for (const n of r.addedNodes) { + if (n.nodeType === Node.ELEMENT_NODE || n.nodeType === Node.TEXT_NODE) return true; + } + } + return false; + } + log("content script loaded:", location.href, "| thread page:", onThread()); if (onThread()) schedule(); - new MutationObserver(debounce(() => { if (onThread()) schedule(); }, 600)) + // The observer stays broad (documentElement, subtree) so a SPA nav that replaces the comment-tree + // root is still seen; the short-circuit below cuts the churn-scans without narrowing the root. + const scheduleScan = debounce(() => { if (onThread()) schedule(); }, 600); + new MutationObserver((records) => { if (addedRelevantNodes(records)) scheduleScan(); }) .observe(document.documentElement, { childList: true, subtree: true }); })();