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 }); })();