From a68993d099886ebf52c62fba5dd1e24d417863ef Mon Sep 17 00:00:00 2001 From: Lauri Ojansivu Date: Mon, 22 Dec 2025 23:26:30 +0200 Subject: [PATCH] perf(unicode-icons): replace body-wide scans with added-nodes observer; prevent unresponsiveness while greying icons --- client/components/unicode-icons.js | 154 ++++++++++++++++++----------- 1 file changed, 96 insertions(+), 58 deletions(-) diff --git a/client/components/unicode-icons.js b/client/components/unicode-icons.js index b7a321067..2b9569b04 100644 --- a/client/components/unicode-icons.js +++ b/client/components/unicode-icons.js @@ -1,6 +1,4 @@ Meteor.startup(() => { - // Unicode pictographic ranges (emoji, symbols, etc.) - // Only greyscale these icons: const greyscaleIcons = [ '🔼', '❌', '🏷️', '📅', '📥', '🚀', '👤', '👥', '✍️', '📋', '✏️', '🌐', '📎', '📝', '📋', '📜', '🏠', '🔒', '🔕', '🃏', '⏰', '🛒', '🔢', '✅', '❌', '👁️', '👍', '📋', '🕐', '🎨', @@ -9,75 +7,115 @@ Meteor.startup(() => { '🔔', '⚙️', '🖼️', '🔑', '🚪', '◀️', '⌨️', '👥', '🏷️', '✅', '🚫' ]; - function wrapUnicodeIcons(root) { - try { - // Exclude avatar initials from wrapping - const excludeSelector = '.header-user-bar-avatar, .avatar-initials'; + const EXCLUDE_SELECTOR = '.header-user-bar-avatar, .avatar-initials, script, style'; + let observer = null; + let enabled = false; - const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null, false); - while (walker.nextNode()) { - const node = walker.currentNode; - if (!node || !node.nodeValue) continue; - const parent = node.parentNode; - if (!parent) continue; - if (parent.closest && (parent.closest('.unicode-icon') || parent.closest(excludeSelector))) continue; - if (parent.tagName === 'SCRIPT' || parent.tagName === 'STYLE') continue; - // Only wrap if the text node is a single greyscale icon (no other text) - const txt = node.nodeValue.trim(); - if (greyscaleIcons.includes(txt)) { - const span = document.createElement('span'); - span.className = 'unicode-icon'; - span.textContent = txt; - parent.replaceChild(span, node); + function isExcluded(el) { + if (!el) return true; + if (el.nodeType === Node.ELEMENT_NODE && (el.matches('script') || el.matches('style'))) return true; + if (el.closest && el.closest(EXCLUDE_SELECTOR)) return true; + return false; + } + + function wrapTextNodeOnce(parent, textNode) { + if (!parent || !textNode) return; + if (isExcluded(parent)) return; + if (parent.closest && parent.closest('.unicode-icon')) return; + const raw = textNode.nodeValue; + if (!raw) return; + const txt = raw.trim(); + // small guard against long text processing + if (txt.length > 3) return; + if (!greyscaleIcons.includes(txt)) return; + const span = document.createElement('span'); + span.className = 'unicode-icon'; + span.textContent = txt; + parent.replaceChild(span, textNode); + } + + function processNode(root) { + try { + if (!root) return; + if (root.nodeType === Node.TEXT_NODE) { + wrapTextNodeOnce(root.parentNode, root); + return; + } + if (root.nodeType !== Node.ELEMENT_NODE) return; + if (isExcluded(root)) return; + // Fast path: only check direct text children first + const children = Array.from(root.childNodes); + for (const child of children) { + if (child.nodeType === Node.TEXT_NODE) { + wrapTextNodeOnce(root, child); } } - - // Also wrap direct unicode icon children (e.g., 🎨), including Member Settings and card details, but not avatar initials - const elements = root.querySelectorAll('*:not(script):not(style):not(.header-user-bar-avatar):not(.avatar-initials)'); - elements.forEach((el) => { - el.childNodes.forEach((child) => { - if (child.nodeType === Node.TEXT_NODE) { - const txt = child.nodeValue.trim(); - if (greyscaleIcons.includes(txt)) { - const span = document.createElement('span'); - span.className = 'unicode-icon'; - span.textContent = txt; - el.replaceChild(span, child); + // If element is small, also scan one level deeper to catch common structures + if (children.length <= 20) { + for (const child of children) { + if (child.nodeType === Node.ELEMENT_NODE && !isExcluded(child)) { + for (const gchild of Array.from(child.childNodes)) { + if (gchild.nodeType === Node.TEXT_NODE) wrapTextNodeOnce(child, gchild); } } - }); - }); - } catch (e) { - // ignore - } - } - function unwrap() { - document.querySelectorAll('span.unicode-icon').forEach((span) => { - const txt = document.createTextNode(span.textContent); - span.parentNode.replaceChild(txt, span); - }); + } + } + } catch (_) {} } - function runWrapAfterDOM() { - Meteor.defer(() => { - setTimeout(() => wrapUnicodeIcons(document.body), 100); - }); - // Also rerun after Blaze renders popups - const observer = new MutationObserver(() => { - const user = Meteor.user(); - if (user && user.profile && user.profile.GreyIcons) { - wrapUnicodeIcons(document.body); + function processInitial() { + // Process only frequently used UI containers to avoid full-page walks + const roots = [ + document.body, + document.querySelector('#header-user-bar'), + ...Array.from(document.querySelectorAll('.pop-over, .pop-over-list, .board-header, .card-details, .sidebar-content')), + ].filter(Boolean); + roots.forEach(processNode); + } + + function startObserver() { + if (observer) return; + observer = new MutationObserver((mutations) => { + // Batch process only added nodes, ignore attribute/character changes + for (const m of mutations) { + if (m.type !== 'childList') continue; + m.addedNodes && m.addedNodes.forEach((n) => { + // Avoid scanning huge subtrees repeatedly by limiting depth + processNode(n); + }); } }); observer.observe(document.body, { childList: true, subtree: true }); } + function stopObserver() { + if (observer) { + try { observer.disconnect(); } catch (_) {} + } + observer = null; + } + + function enableGrey() { + if (enabled) return; + enabled = true; + Meteor.defer(processInitial); + startObserver(); + } + + function disableGrey() { + if (!enabled) return; + enabled = false; + stopObserver(); + // unwrap existing + document.querySelectorAll('span.unicode-icon').forEach((span) => { + const txt = document.createTextNode(span.textContent || ''); + if (span.parentNode) span.parentNode.replaceChild(txt, span); + }); + } + Tracker.autorun(() => { const user = Meteor.user(); - if (user && user.profile && user.profile.GreyIcons) { - runWrapAfterDOM(); - } else { - Meteor.defer(() => unwrap()); - } + const on = !!(user && user.profile && user.profile.GreyIcons); + if (on) enableGrey(); else disableGrey(); }); });