From 21beacf7007e317f2e03f1deaa751712e6972346 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Tue, 6 Oct 2026 21:29:36 +0200 Subject: [PATCH] perf(i18n): skip a whole mutation inside a skipped surface, not each node xterm's DOM renderer replaces terminal rows every frame (the split pane's Pane B, every tile of the grid), and the translator's MutationObserver walked each added row and ran closest(SKIP_SELECTOR) for every text node and element in it, only to find each one inside .xterm and skip it. A CPU profile of six printing tiles put about 2.2 s of 40 s there (closest, translateNode, tree walks). Now one shouldSkip(mutation.target) per record decides it: every node a record adds or edits sits under that target, so both translators would return on their own closest() check anyway, and the output is identical. Measured in headless Chromium, six tiles printing 20 lines/s each, three interleaved 40 s pairs at the same machine load: frames over 20 ms fell from 9.1/11.5/11.8% to 7.3/8.1/8.0%; nine tiles 9.5% to 7.2%. Pinned in i18n-branding.test: a burst of terminal rows causes no tree walk, terminal text stays untranslated, application DOM beside it still translates. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/web/public/i18n.js | 7 +++++++ test/i18n-branding.test.ts | 41 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index 538534b2..a38d3f31 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -1128,6 +1128,13 @@ observer = new MutationObserver((mutations) => { if (applying) return; for (const mutation of mutations) { + // A change inside a skipped surface cannot need translating: every + // node it adds or edits sits under the same skip ancestor, so both + // translators would return on their own closest() check anyway. One + // check per record instead of one per text node and attribute matters + // for xterm's DOM renderer, which replaces rows every frame (the split + // pane, every tile of the grid). + if (shouldSkip(mutation.target)) continue; if (mutation.type === 'characterData') translateNode(mutation.target); if (mutation.type === 'attributes') translateAttributes(mutation.target); for (const added of mutation.addedNodes) translateNode(added); diff --git a/test/i18n-branding.test.ts b/test/i18n-branding.test.ts index 8ffff762..0b77acec 100644 --- a/test/i18n-branding.test.ts +++ b/test/i18n-branding.test.ts @@ -91,6 +91,47 @@ describe('custom display name and browser localization', () => { dom.window.close(); }); + it('does not walk what a terminal renders: one check per mutation inside a skipped surface', async () => { + const dom = makeDom('
'); + const { window } = dom; + const api = window.CodemanI18n; + api.start(); + api.configure({ language: 'zh-CN' }); + // i18n.js also starts itself on DOMContentLoaded (one walk of the body). + await new Promise((resolve) => window.setTimeout(resolve, 0)); + let walks = 0; + const createTreeWalker = window.document.createTreeWalker.bind(window.document); + window.document.createTreeWalker = (...args: Parameters) => { + walks++; + return createTreeWalker(...args); + }; + + // What xterm's DOM renderer does every frame: replace the rows. + const rows = window.document.getElementById('rows')!; + for (let frame = 0; frame < 3; frame++) { + const fresh = Array.from({ length: 20 }, () => { + const row = window.document.createElement('div'); + row.innerHTML = 'Settings savedRun'; + return row; + }); + rows.replaceChildren(...fresh); + } + rows.firstElementChild!.firstChild!.firstChild!.nodeValue = 'Settings saved'; + await new Promise((resolve) => window.setTimeout(resolve, 0)); + expect(walks).toBe(0); + expect(rows.firstElementChild?.textContent).toBe('Settings savedRun'); + expect(rows.querySelector('[title]')?.getAttribute('title')).toBe('Run'); + + // Application DOM next to it is still translated. + const button = window.document.createElement('button'); + button.textContent = 'Settings saved'; + window.document.getElementById('app')!.appendChild(button); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + expect(button.textContent).toBe('设置已保存'); + expect(walks).toBe(1); + dom.window.close(); + }); + it('keeps a quoted group name apart from the fixed "Move to" entries in zh-CN', () => { const dom = makeDom(''); const api = dom.window.CodemanI18n;