Skip to content

Terminal render artifacts on non-focused tiles (split: #1305 sub-pixel seams, #1306 renderer metric divergence) #1299

@ic4-y

Description

@ic4-y

Problem

When running multiple opencodes (xterm.js-based terminals) side-by-side in kolu, the terminal that loses focus exhibits visual render artifacts — text overlap, corrupted glyphs, or stale content that is never repainted.

Root Cause

Kolu uses a focus-gated renderer policy (Terminal.tsx:221): WebGL on the focused tile, DOM renderer on all others. When focus moves from tile A → tile B:

  1. Tile A: unloadWebgl() disposes the WebGL addon and calls loseContext() on the GPU context (Terminal.tsx:269-296). xterm falls back to its DOM renderer — but no terminal.refresh() is queued, so the DOM renderer never repaints the now-stale framebuffer.
  2. Tile B: loadWebgl() creates a fresh WebGL addon and renders normally.

The result: the formerly-focused tile shows corrupted rendering because the DOM renderer was never told to repaint after the WebGL teardown.

Evidence in the code

Location Issue
Terminal.tsx:269-296 unloadWebgl() calls w.dispose() but never calls terminal.refresh(0, terminal.rows - 1) afterwards.
Terminal.tsx:349-359 The shouldUseWebgl effect is the only rendering-pipeline effect that does not call debouncedFit() or clearTextureAtlas() after a renderer switch. Every other effect (font size, theme, tab visibility) does.
Terminal.tsx:213 clearTextureAtlas() is a silent no-op when webgl is null (DOM-rendered tiles), so the refitOnTabVisible and theme effects never trigger a DOM repaint either.
SettingsPopover.tsx:51 The DOM renderer option is labeled "stable font on focus", tacitly acknowledging that the default auto mode has focus-related rendering instability.

Why this is a kolu bug (not an xterm.js bug)

xterm.js does not promise to auto-repaint when a renderer addon is disposed mid-session — that is the host app's responsibility. Kolu actively switches renderers based on focus, but the transition code is incomplete.

Remediation

Proposed change 1: Force DOM repaint after WebGL unload

In Terminal.tsx, inside unloadWebgl(), after w.dispose():

terminal.refresh(0, terminal.rows - 1);

This forces the DOM renderer to repaint the entire visible area after the WebGL addon is disposed — a known xterm.js pattern for clean renderer transitions.

Proposed change 2: Add fit + atlas clear to the shouldUseWebgl effect

In the shouldUseWebgl effect (Terminal.tsx:349-359), call debouncedFit() and clearTextureAtlas() after the load/unload, mirroring what every other rendering-pipeline effect does:

createEffect(
  on(
    shouldUseWebgl,
    (should) => {
      if (!terminal) return;
      if (should) loadWebgl();
      else unloadWebgl();
      debouncedFit();
      clearTextureAtlas();
    },
    { defer: true },
  ),
);

Proposed change 3 (optional): Defer the cross-tile WebGL handoff

Unloading tile A's WebGL and loading tile B's WebGL in the same reactive cycle stresses Chrome's ~16 WebGL context budget (see the #575 references in Terminal.tsx:219). Consider deferring the load to the next frame:

createEffect(
  on(
    shouldUseWebgl,
    (should) => {
      if (!terminal) return;
      if (should) requestAnimationFrame(() => loadWebgl());
      else unloadWebgl();
    },
    { defer: true },
  ),
);

Immediate workaround for users

Switch the renderer preference from Auto to DOM in settings. This eliminates the WebGL↔DOM transition entirely, at the cost of lower GPU throughput.

Related

  • Issue references in code: #239 (font rendering corruption), #575 (context exhaustion), #595 (WebGL lifecycle)
  • See also docs/perf-investigations/memory-learnings.md Chapter 3 (IntersectionObserver interference)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions