perf(i18n): an xterm row record is decided by its rows container, once

xterm's DOM renderer rewrites its rows (`.xterm-rows > div`) on every frame
a pane changes, and with a grid of tiles that is every record the i18n
observer gets (about 4,600 a second for six printing tiles, all of them
row rewrites). Each paid one closest() over the whole skip selector list
(the per-record skip of 21beacf7, which stays). Every row of one terminal
shares its parent, so that parent's own shouldSkip() verdict is now kept
once it says skip: same verdict, one closest() per terminal instead of one
per record. A rows container outside any skipped surface keeps the full
check, so nothing that was translated stops being translated.

Measured (6 printing tiles, 30 s profiles, n=3 interleaved A/B, load 5.8
to 9.5, equivalent class-check variant): observer 391 to 445 ms -> 47 to
52 ms per 30 s in English, 473 -> 58 ms in zh-CN; main-thread script time
-0.35 s per 30 s (-14%). Frame share unchanged within noise.

Live, this code (6 printing tiles, 30 s profiles, interleaved against the
file at 00440c02, load 4.4 to 6.6): observer 417 to 430 ms -> 56 to 58 ms
per 30 s; script time 2.63 to 2.79 s -> 2.24 s in the undisturbed window.
The other window of this code was disturbed by CPU contention on the box
(every rendering cost 3 to 4 times higher, xterm's own included, 351 frames
in 30 s) and is not counted; its observer time was 56 ms all the same.

Test: 60 row rewrites in a terminal cost one closest(), the rows stay
untranslated, and a stray `.xterm-rows` outside any skip surface is still
translated (the verdict closest() gives); mutation-checked both ways.

Scope: PR 2 (i18n, on top of 21beacf7).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-07 06:45:16 +02:00
parent 409fd658f2
commit 02387b5b16
2 changed files with 71 additions and 1 deletions
+18 -1
View File
@@ -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);
+53
View File
@@ -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(
'<div class="xterm"><div class="xterm-screen"><div class="xterm-rows" id="rows"></div></div></div>' +
'<div class="xterm-rows" id="stray"></div>'
);
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;