perf: reuse per-node matches + skip scans on node-less mutations in content.js (closes task/content-scan-efficiency)

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<node, matches>; 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 <noreply@anthropic.com>
This commit is contained in:
paul
2026-06-23 16:28:33 +00:00
co-authored by Claude Opus 4.8
parent 071cc3f85a
commit a14f566e53
+33 -5
View File
@@ -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 });
})();