perf(icons): scan only inserted subtrees in the MutationObserver

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 <i> 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
This commit is contained in:
Claude
2026-06-10 09:01:55 +00:00
parent 64c1e4c8e5
commit 8d040314a3
+35 -9
View File
@@ -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<Element>}
*/
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 });
}