fix(terminal): re-upload the shared WebGL atlas after a page merge

@xterm/addon-webgl 0.19 shares one glyph atlas between every terminal
with the same font. When it fills, four pages are merged into a larger
one and the rest shift down; a GPU texture is re-uploaded only when its
page version differs, and those counters can coincide, so a slot keeps a
stale page of another size and every glyph is drawn squeezed or foreign
(fixed upstream only in the 0.20 betas). On a merge — and when the page
comes back — toggle the font family's trailing space on the next frame,
which makes each renderer re-acquire its atlas and re-upload all pages.

This also drops yesterday's clearTextureAtlas on focus: it emptied the
SHARED atlas but redrew only the calling pane, corrupting its siblings.
This commit is contained in:
2026-10-30 10:09:28 +07:00
parent 5463e354fa
commit c850f7603a
2 changed files with 52 additions and 8 deletions
+27 -6
View File
@@ -762,13 +762,32 @@ export const TerminalView = memo(function TerminalView({
refit();
};
document.fonts?.addEventListener("loadingdone", onFontLoaded);
// The browser may drop or reuse the WebGL glyph atlas's GPU memory without
// a context-loss event (sleep/resume, a long-backgrounded tab, a display or
// zoom change on macOS): xterm keeps slicing the old coordinates and every
// cell shows a clipped or foreign glyph while the buffer stays correct.
// Rebuilding the atlas when the page comes back is cheap and redraws clean.
// @xterm/addon-webgl 0.19 shares ONE glyph atlas between every terminal
// with the same font, and when it fills up it merges four pages into one
// larger page and shifts the rest down. A GPU texture is re-uploaded only
// when its page's `version` differs from the uploaded one, and after a
// shift those counters can coincide, so a slot keeps a stale page of
// another size: every glyph is sampled squeezed or foreign while the
// buffer stays correct (fixed upstream only in 0.20 betas). Any option
// change makes each renderer re-acquire its atlas and re-upload all pages,
// so this toggles the family's trailing space (as onFontLoaded does) on
// a merge. `clearTextureAtlas` is no fix: it empties the SHARED atlas
// but redraws only this pane, leaving its siblings slicing reused space.
let atlasFrame = 0;
const rebuildAtlas = () => {
if (atlasFrame) return;
// Never inside the render that is rasterizing the merging glyph.
atlasFrame = requestAnimationFrame(() => {
atlasFrame = 0;
const family = term.options.fontFamily ?? "";
term.options.fontFamily = family.endsWith(" ") ? family.trimEnd() : `${family} `;
});
};
const atlasMerged = gpu?.onRemoveTextureAtlasCanvas(rebuildAtlas);
// The browser may also drop the textures without a context-loss event
// (sleep/resume, a long-backgrounded tab): rebuild when the page is back.
const onPageShown = () => {
if (document.visibilityState === "visible") term.clearTextureAtlas();
if (document.visibilityState === "visible") rebuildAtlas();
};
document.addEventListener("visibilitychange", onPageShown);
window.addEventListener("focus", onPageShown);
@@ -790,6 +809,8 @@ export const TerminalView = memo(function TerminalView({
searchRef.current = null;
resizeObserver.disconnect();
document.fonts?.removeEventListener("loadingdone", onFontLoaded);
if (atlasFrame) cancelAnimationFrame(atlasFrame);
atlasMerged?.dispose();
document.removeEventListener("visibilitychange", onPageShown);
window.removeEventListener("focus", onPageShown);
window.removeEventListener(FONT_SIZE_EVENT, onFontPref);
@@ -112,12 +112,15 @@ vi.mock("@xterm/addon-fit", () => ({
FitAddon: vi.fn().mockImplementation(() => ({ fit: vi.fn() })),
}));
// The newest WebGL addon, with the context-loss listener the view gave it.
const gpu: { last: { dispose: ReturnType<typeof vi.fn>; lose: () => void } | null } = {
const gpu: {
last: { dispose: ReturnType<typeof vi.fn>; lose: () => void; merge: () => void } | null;
} = {
last: null,
};
vi.mock("@xterm/addon-webgl", () => ({
WebglAddon: vi.fn().mockImplementation(() => {
let onLoss = () => {};
let onMerge = () => {};
const addon = {
__webgl: true,
dispose: vi.fn(),
@@ -125,8 +128,12 @@ vi.mock("@xterm/addon-webgl", () => ({
onLoss = fn;
return { dispose: vi.fn() };
},
onRemoveTextureAtlasCanvas: (fn: () => void) => {
onMerge = fn;
return { dispose: vi.fn() };
},
};
gpu.last = { dispose: addon.dispose, lose: () => onLoss() };
gpu.last = { dispose: addon.dispose, lose: () => onLoss(), merge: () => onMerge() };
return addon;
}),
}));
@@ -488,6 +495,22 @@ describe("TerminalView", () => {
expect(disposeMock).not.toHaveBeenCalled(); // the terminal itself lives on
});
it("re-acquires the glyph atlas on the next frame after a page merge", () => {
// addon-webgl 0.19 may keep a stale page in a GPU slot after it merges
// atlas pages; any option change makes it re-upload every page.
const raf = vi.spyOn(window, "requestAnimationFrame").mockImplementation(() => 1);
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
const opts = termOptions as { fontFamily?: string };
opts.fontFamily = "JetBrains Mono";
gpu.last!.merge();
gpu.last!.merge(); // coalesced into one frame
expect(raf).toHaveBeenCalledTimes(1);
expect(opts.fontFamily).toBe("JetBrains Mono"); // never inside the render that merged
(raf.mock.calls[0]![0] as FrameRequestCallback)(0);
expect(opts.fontFamily).toBe("JetBrains Mono ");
raf.mockRestore();
});
// xterm 6 dropped its own Alt+arrow → word-move mapping; the pane keeps it.
it("sends Alt+arrow as the word-move keys xterm 5 sent", () => {
expect(altArrowSeq("ArrowLeft", false)).toBe("\x1b[1;5D");