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:
+33
-5
@@ -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 });
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user