From 2ff05c31f9cce9c0c328ed663855b99e3cd89613 Mon Sep 17 00:00:00 2001 From: Agent Runtime Fixture Date: Sun, 27 Sep 2026 19:15:11 +0100 Subject: [PATCH] fix(web): load terminal webfont before first paint and invalidate WebGL atlas @font-face files fetch lazily on first use and xterm never observes font loading, so a terminal mounted on a cold font cache rasterized fallback glyphs. The WebGL renderer baked them into its texture atlas for the whole session. Await the resolved font family (bounded) before term.open and rebuild the atlas on every font loadingdone event while WebGL is live. --- .../terminal/surfaces/TerminalView.tsx | 50 ++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/apps/web/src/features/terminal/surfaces/TerminalView.tsx b/apps/web/src/features/terminal/surfaces/TerminalView.tsx index 8bdeeabac..edc309c6e 100644 --- a/apps/web/src/features/terminal/surfaces/TerminalView.tsx +++ b/apps/web/src/features/terminal/surfaces/TerminalView.tsx @@ -155,6 +155,47 @@ function settleWithin(promise: Promise, fallback: T): Promise { }); } +/** + * Cap on blocking terminal mount while a webfont downloads. Late arrivals + * are still caught by the loadingdone → clearTextureAtlas path in + * loadRenderer, so this only needs to cover the common fast case. + */ +const TERMINAL_FONT_WAIT_MS = 500; + +/** + * Starts fetching the terminal's resolved font family and waits up to + * {@link TERMINAL_FONT_WAIT_MS}. @font-face files fetch lazily on first use + * and xterm never observes font loading, so without an explicit load the + * first paint always races the download. Failure or timeout must not block + * the mount; the CSS fallback chain still applies. + */ +function awaitTerminalFont(fontSpec: string): Promise { + if (typeof document.fonts?.load !== "function") return Promise.resolve(); + return Promise.race([ + document.fonts.load(fontSpec), + new Promise((resolve) => setTimeout(resolve, TERMINAL_FONT_WAIT_MS)), + ]).catch(() => {}); +} + +/** + * Rebuilds the WebGL glyph atlas whenever a font-settle event fires while + * the addon is live. WebGL rasterizes glyphs at attach time and xterm never + * observes font loading, so a webfont landing late (cold cache, or a lazily + * fetched unicode-range subset) would keep showing fallback glyphs for the + * rest of the session. Returns the teardown that removes the listener. + */ +function watchWebfontSettle( + webgl: { clearTextureAtlas(): void }, + term: Terminal, +): () => void { + const onFontsLoaded = () => { + webgl.clearTextureAtlas(); + term.refresh(0, term.rows - 1); + }; + document.fonts?.addEventListener("loadingdone", onFontsLoaded); + return () => document.fonts?.removeEventListener("loadingdone", onFontsLoaded); +} + /** * Loads the xterm core and fit addon once and caches the result so view * remounts (shell-tab / thread switch) skip the cold dynamic-import cost — the @@ -355,7 +396,9 @@ async function loadRenderer( } rendererRef.current = webgl; setActiveRenderer("webgl"); + const unwatchFonts = watchWebfontSettle(webgl, term); claimWebglSlot(ptyId, () => { + unwatchFonts(); try { webgl.dispose(); } catch { @@ -606,11 +649,16 @@ export const TerminalView = memo(function TerminalView({ const mountStart = performance.now(); async function init(el: HTMLElement) { + const options = getTerminalOptions(terminalSettingsRef.current); + // Warm the terminal webfont in parallel with the xterm imports so it is + // loaded by the first paint whenever the network allows. + const fontReady = awaitTerminalFont(`${options.fontSize}px ${options.fontFamily}`); // Cached after the first mount, so remounts skip the cold import cost. const [{ Terminal: XTerminal, FitAddon: XFitAddon }, serializeModule] = await Promise.all([ loadXtermModules(), import("@xterm/addon-serialize"), + fontReady, ]); if (disposed || !containerRef.current || !fitHostRef.current) { if (!disposedNotified) { @@ -621,7 +669,7 @@ export const TerminalView = memo(function TerminalView({ } const term = new XTerminal({ - ...getTerminalOptions(terminalSettingsRef.current), + ...options, allowProposedApi: true, theme: { background: TERMINAL_BACKGROUND,