diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index 8a31d88a..b08a557e 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -1142,6 +1142,23 @@ return !element || Boolean(element.closest(SKIP_SELECTOR)); } + // xterm's DOM renderer rewrites its rows (`.xterm-rows > div`) on every frame + // a pane changes: thousands of mutation records a second with a grid of tiles, + // each paying a closest() over the whole skip list. All rows of one terminal + // share that parent, so its own shouldSkip() verdict is kept once it says + // skip; a skip verdict cannot lapse, since xterm keeps `.xterm-rows` inside + // its `.xterm`. A rows container that is not skipped is never kept: its rows + // go through the full check below like any other node. + const skippedRows = new WeakSet(); + function isSkippedRow(node) { + const rows = node.parentNode; + if (!rows?.classList?.contains('xterm-rows')) return false; + if (skippedRows.has(rows)) return true; + if (!shouldSkip(rows)) return false; + skippedRows.add(rows); + return true; + } + function shouldSkipText(node) { const element = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement; return shouldSkip(node) || Boolean(element?.closest(USER_TEXT_SELECTOR)); @@ -1243,7 +1260,7 @@ // 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 (isSkippedRow(mutation.target) || 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 0b77acec..8638d214 100644 --- a/test/i18n-branding.test.ts +++ b/test/i18n-branding.test.ts @@ -132,6 +132,59 @@ describe('custom display name and browser localization', () => { dom.window.close(); }); + it('decides an xterm row by its rows container, with the verdict closest() gives', async () => { + // A terminal (rows inside .xterm) and a stray `.xterm-rows` outside any + // skipped surface, which the full check translates. + const dom = makeDom( + '
' + + '
' + ); + const { window } = dom; + const api = window.CodemanI18n; + api.start(); + api.configure({ language: 'zh-CN' }); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + const rowsOf = (id: string) => + Array.from({ length: 20 }, () => { + const row = window.document.createElement('div'); + window.document.getElementById(id)!.appendChild(row); + return row; + }); + const rows = rowsOf('rows'); + const stray = rowsOf('stray'); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + let closest = 0; + const original = window.Element.prototype.closest; + window.Element.prototype.closest = function (this: Element, selector: string) { + closest++; + return original.call(this, selector); + }; + + // What xterm's DOM renderer does every frame: replace each row's children. + const frames = (list: Element[]) => { + for (let frame = 0; frame < 3; frame++) { + for (const row of list) { + const span = window.document.createElement('span'); + span.textContent = 'Settings saved'; + row.replaceChildren(span); + } + } + }; + frames(rows); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + // 60 row records, ONE closest(): the container's own verdict, then kept. + expect(closest).toBe(1); + expect(rows.every((row) => row.textContent === 'Settings saved')).toBe(true); + + // A rows container outside any skipped surface keeps the full check, and + // with it the verdict closest() gives: its rows are translated. + frames(stray); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + window.Element.prototype.closest = original; + expect(stray.every((row) => row.textContent === '设置已保存')).toBe(true); + 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;