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:
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user