From 8d040314a333b2b0610edfee2ea0b27a7ace8f0e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 09:01:55 +0000 Subject: [PATCH] perf(icons): scan only inserted subtrees in the MutationObserver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The global childList observer scheduled replaceIconsInElement() with no scope on ANY node insertion — including bare text nodes — so every notification-bell progress tick during an upload and every infinite-scroll batch re-scanned the whole document with an attribute substring selector. Cost grew with total DOM size (5-10k nodes), not with what was inserted. Queue the added element roots per animation frame and scan just those subtrees (an inserted itself is caught via its parent). Text-node churn no longer triggers any scan at all. The icon replacement is idempotent, so overlapping roots are harmless. https://claude.ai/code/session_01Dp3oWon5GBMVn4j3QXZdgx --- static/js/core/icons.js | 44 ++++++++++++++++++++++++++++++++--------- 1 file changed, 35 insertions(+), 9 deletions(-) diff --git a/static/js/core/icons.js b/static/js/core/icons.js index 66420925..b5c857cd 100644 --- a/static/js/core/icons.js +++ b/static/js/core/icons.js @@ -618,27 +618,53 @@ function replaceIconsInElement(container) { function oxiIconsInit() { let raf = 0; + /** + * Subtree roots added since the last animation frame. Scanning only + * these (instead of the whole document) keeps the cost proportional + * to what was inserted, not to the total DOM size. + * @type {Set} + */ + let pendingRoots = new Set(); + const scan = () => { raf = 0; - replaceIconsInElement(); + const roots = pendingRoots; + pendingRoots = new Set(); + for (const root of roots) { + if (!root.isConnected) continue; // removed (or replaced) meanwhile + if (root.matches('i[class*="fa-"]')) { + // The inserted node IS the icon — scan via its parent so the + // descendant selector pass picks it up. + replaceIconsInElement(root.parentElement || document.body); + } else { + replaceIconsInElement(root); + } + } }; // Initial sweep once the DOM is ready + const fullSweep = () => replaceIconsInElement(); if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', scan); + document.addEventListener('DOMContentLoaded', fullSweep); } else { - scan(); + fullSweep(); } - // Observe future mutations (dynamic renders, modals, etc.) + // Observe future mutations (dynamic renders, modals, etc.). Only element + // insertions can carry icons — text-node churn (progress counters, + // notification text) no longer triggers any scan at all. new MutationObserver((mutations) => { - if (raf) return; - for (let i = 0; i < mutations.length; i++) { - if (mutations[i].addedNodes.length) { - raf = requestAnimationFrame(scan); - return; + for (const mutation of mutations) { + for (let i = 0; i < mutation.addedNodes.length; i++) { + const node = mutation.addedNodes[i]; + if (node.nodeType === Node.ELEMENT_NODE) { + pendingRoots.add(/** @type {Element} */ (node)); + } } } + if (!raf && pendingRoots.size) { + raf = requestAnimationFrame(scan); + } }).observe(document.documentElement, { childList: true, subtree: true }); }