fix: refresh terminal renderer after fonts load

This commit is contained in:
Bohdan Triapitsyn
2026-09-03 11:49:34 +03:00
parent 6c4923b8c8
commit 29ca970362
4 changed files with 43 additions and 17 deletions
@@ -2,6 +2,8 @@ import React from 'react';
import type { FitAddon, Ghostty, Terminal as GhosttyTerminal } from 'ghostty-web'; import type { FitAddon, Ghostty, Terminal as GhosttyTerminal } from 'ghostty-web';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { loadMonoFont } from '@/lib/fontLoader';
import type { MonoFontOption } from '@/lib/fontOptions';
import type { TerminalTheme } from '@/lib/terminalTheme'; import type { TerminalTheme } from '@/lib/terminalTheme';
import { getGhosttyTerminalOptions } from '@/lib/terminalTheme'; import { getGhosttyTerminalOptions } from '@/lib/terminalTheme';
import { import {
@@ -27,20 +29,24 @@ const loadGhostty = (): Promise<GhosttyRuntime> =>
ghostty: await module.Ghostty.load(), ghostty: await module.Ghostty.load(),
})); }));
// The web entry defers its ~2 MB Nerd Font download until a terminal actually // Wait briefly for both the selected mono font and the web entry's deferred
// mounts (see the `__openchamberEnsureNerdFonts` hook in index.html). Wait for // Nerd Fonts before Ghostty measures glyphs. A cold CDN fetch must not block
// it with a short bound so a cached font is in place before the glyph atlas is // opening the terminal, so the renderer starts after the bound and is rebuilt
// built, while a cold CDN fetch never blocks the terminal from opening; the // once the fonts arrive. Runtimes without the Nerd Font hook resolve it at once.
// runtimes without the hook (VS Code, mobile) resolve immediately. const TERMINAL_FONT_WAIT_MS = 2000;
const NERD_FONT_WAIT_MS = 2000; const loadNerdFonts = (): Promise<void> =>
const ensureNerdFonts = (): Promise<void> => { Promise.resolve(window.__openchamberEnsureNerdFonts?.()).catch(() => undefined);
if (typeof window === 'undefined') return Promise.resolve();
const loader = (window as typeof window & { __openchamberEnsureNerdFonts?: () => Promise<void> }).__openchamberEnsureNerdFonts; const waitForTerminalFonts = (font: MonoFontOption) => {
if (typeof loader !== 'function') return Promise.resolve(); const loaded = Promise.all([loadMonoFont(font), loadNerdFonts()]).then(() => undefined);
return Promise.race([ const loadedBeforeTimeout = new Promise<boolean>((resolve) => {
Promise.resolve(loader()).catch(() => undefined), const timeout = setTimeout(() => resolve(false), TERMINAL_FONT_WAIT_MS);
new Promise<void>((resolve) => setTimeout(resolve, NERD_FONT_WAIT_MS)), void loaded.then(() => {
]).then(() => undefined); clearTimeout(timeout);
resolve(true);
});
});
return { loaded, loadedBeforeTimeout };
}; };
type TerminalSize = { cols: number; rows: number }; type TerminalSize = { cols: number; rows: number };
@@ -91,6 +97,7 @@ type Props = {
onInput: (data: string) => void; onInput: (data: string) => void;
onResize: (cols: number, rows: number) => void; onResize: (cols: number, rows: number) => void;
theme: TerminalTheme; theme: TerminalTheme;
monoFont: MonoFontOption;
fontFamily: string; fontFamily: string;
fontSize: number; fontSize: number;
className?: string; className?: string;
@@ -100,7 +107,7 @@ type Props = {
}; };
const TerminalViewport = React.forwardRef<TerminalController, Props>(({ const TerminalViewport = React.forwardRef<TerminalController, Props>(({
sessionKey, chunks, onInput, onResize, theme, fontFamily, fontSize, className, sessionKey, chunks, onInput, onResize, theme, monoFont, fontFamily, fontSize, className,
enableTouchScroll = false, autoFocus = true, isVisible = true, enableTouchScroll = false, autoFocus = true, isVisible = true,
}, ref) => { }, ref) => {
const containerRef = React.useRef<HTMLDivElement>(null); const containerRef = React.useRef<HTMLDivElement>(null);
@@ -236,7 +243,8 @@ const TerminalViewport = React.forwardRef<TerminalController, Props>(({
window.addEventListener('focus', handleWindowFocus); window.addEventListener('focus', handleWindowFocus);
window.addEventListener('blur', handleWindowBlur); window.addEventListener('blur', handleWindowBlur);
Promise.all([loadGhostty(), ensureNerdFonts()]).then(([{ module, ghostty }]) => { const fonts = waitForTerminalFonts(monoFont);
Promise.all([loadGhostty(), fonts.loadedBeforeTimeout]).then(([{ module, ghostty }, fontsLoaded]) => {
if (disposed) return; if (disposed) return;
terminal = new module.Terminal({ terminal = new module.Terminal({
...getGhosttyTerminalOptions(fontFamily, fontSize, theme, ghostty, false), ...getGhosttyTerminalOptions(fontFamily, fontSize, theme, ghostty, false),
@@ -264,6 +272,13 @@ const TerminalViewport = React.forwardRef<TerminalController, Props>(({
const safeReset = safeResetRef.current; const safeReset = safeResetRef.current;
if (safeReset) terminal.write(`${safeReset}\u001b[2J\u001b[H`); if (safeReset) terminal.write(`${safeReset}\u001b[2J\u001b[H`);
fitFrame = requestAnimationFrame(fit); fitFrame = requestAnimationFrame(fit);
if (!fontsLoaded) {
void fonts.loaded.then(() => {
if (!disposed && terminalRef.current === terminal) {
setRendererGeneration((value) => value + 1);
}
});
}
}); });
return () => { return () => {
@@ -301,7 +316,7 @@ const TerminalViewport = React.forwardRef<TerminalController, Props>(({
writeEpochRef.current += 1; writeEpochRef.current += 1;
rendererReadyRef.current = false; rendererReadyRef.current = false;
}; };
}, [fit, fontFamily, fontSize, rendererGeneration, theme]); }, [fit, fontFamily, fontSize, monoFont, rendererGeneration, theme]);
React.useEffect(() => { React.useEffect(() => {
const terminal = terminalRef.current; const terminal = terminalRef.current;
@@ -1140,6 +1140,7 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
onInput={handleViewportInput} onInput={handleViewportInput}
onResize={handleViewportResize} onResize={handleViewportResize}
theme={xtermTheme} theme={xtermTheme}
monoFont={monoFont}
fontFamily={resolvedFontStack} fontFamily={resolvedFontStack}
fontSize={terminalFontSize} fontSize={terminalFontSize}
enableTouchScroll={useTouchTerminalInput} enableTouchScroll={useTouchTerminalInput}
@@ -100,4 +100,13 @@ describe('terminal viewport remount guard', () => {
expect(terminalViewportSource).toContain('resizeRef.current(size.cols, size.rows)'); expect(terminalViewportSource).toContain('resizeRef.current(size.cols, size.rows)');
expect(terminalViewportSource).toContain('...(provisionalSizeRef.current ?? {})'); expect(terminalViewportSource).toContain('...(provisionalSizeRef.current ?? {})');
}); });
test('rebuilds the canvas renderer when terminal fonts finish loading after the startup bound', () => {
expect(terminalViewportSource).toContain('loadMonoFont(font)');
expect(terminalViewportSource).toContain('Promise.all([loadMonoFont(font), loadNerdFonts()])');
expect(terminalViewportSource).toContain('Promise.all([loadGhostty(), fonts.loadedBeforeTimeout])');
expect(terminalViewportSource).toContain('if (!fontsLoaded)');
expect(terminalViewportSource).toContain('void fonts.loaded.then(() => {');
expect(terminalViewportSource).toContain('setRendererGeneration((value) => value + 1)');
});
}); });
+1
View File
@@ -1,6 +1,7 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
interface Window { interface Window {
__openchamberEnsureNerdFonts?: () => Promise<void>;
__opencodeDebug?: { __opencodeDebug?: {
getLastAssistantMessage: () => unknown; getLastAssistantMessage: () => unknown;
getAllMessages: (truncate?: boolean) => unknown[]; getAllMessages: (truncate?: boolean) => unknown[];