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