#1780·bubbletea

v2: cursed_renderer emits a one-tick stale frame on resize (race between resize() and the 60Hz ticker flush())

Author: Wolf258Created Aug 25, 2026Updated Aug 25, 2026

Summary

On WindowSizeMsg, the program calls renderer.resize() (which clears/resizes the screen buffer and arms pendingErase) and, shortly after in the same event-loop iteration, p.render(model) (which stores the new tea.View in s.view). The renderer's 60 Hz ticker goroutine calls renderer.flush(false) independently. If a tick fires between resize() and render(), flush() proceeds with the armed pendingErase and the new frameArea but reads the previous s.view, and draws the previous frame's content into the freshly-cleared, newly-sized cell buffer. The next tick then draws the correct frame as a diff on top. The user sees the old frame at the new terminal size for ~16 ms — a transient "ghost"/duplicate frame.

Reproducer

Minimal standalone program (repro.go):

package main

import (
	"fmt"
	"strings"

	tea "charm.land/bubbletea/v2"
)

type model struct{ w, h int }

func (model) Init() tea.Cmd { return nil }

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	switch msg := msg.(type) {
	case tea.WindowSizeMsg:
		m.w, m.h = msg.Width, msg.Height
	case tea.KeyPressMsg:
		if msg.String() == "q" {
			return m, tea.Quit
		}
	}
	return m, nil
}

// View lays out 5 columns that reflow with width, so the stale pre-resize View
// is visually distinct from the correct post-resize View.
func (m model) View() tea.View {
	var v tea.View
	v.AltScreen = true
	if m.w == 0 {
		v.SetContent("waiting for size...\n[q] quit")
		return v
	}
	nameW := m.w * 3 / 8
	srcW := m.w * 2 / 8
	sizeW := m.w * 1 / 8
	if nameW < 6 {
		nameW = 6
	}
	if srcW < 6 {
		srcW = 6
	}
	if sizeW < 8 {
		sizeW = 8
	}
	pad := func(s string, n int) string {
		if len(s) >= n {
			return s[:n]
		}
		return s + strings.Repeat(" ", n-len(s))
	}
	header := fmt.Sprintf(" %-*s  %-*s  %-*s  %-*s  %s", 6, "TYPE", nameW, "NAME", srcW, "SOURCE", sizeW, "SIZE", "CREATED")
	row := fmt.Sprintf(" %-6s  %-*s  %-*s  %-*s  %s", "rootfs", pad("debian-minimal", nameW), pad("debian", srcW), pad("58.3 MiB", sizeW), "2026-08-25")
	var b strings.Builder
	fmt.Fprintf(&b, "terminal: %dx%d\n", m.w, m.h)
	b.WriteString(header)
	b.WriteByte('\n')
	for i := 0; i < 3; i++ {
		b.WriteString(row)
		b.WriteByte('\n')
	}
	b.WriteString("[q] quit")
	v.SetContent(b.String())
	return v
}

func main() {
	_, _ = tea.NewProgram(model{}).Run()
}

How to make the ghost visible

The ghost is one frame (~16 ms) and timing-dependent. To capture it deterministically, run the program under a PTY, trigger a resize, and dump the byte stream with timestamps. The stale frame appears as a 2J clear sized for the new dimensions but containing the old column layout, emitted ~one tick before the correct frame.

A capture harness (probe.go, uses github.com/creack/pty):

package main

import (
	"bytes"
	"fmt"
	"os"
	"os/exec"
	"syscall"
	"time"

	"github.com/creack/pty"
)

func main() {
	cmd := exec.Command("go", "run", ".")
	cmd.Env = append(os.Environ(), "TERM=xterm-256color")
	ptmx, err := pty.Start(cmd)
	if err != nil {
		fmt.Println("err:", err)
		os.Exit(1)
	}
	defer ptmx.Close()

	pty.Setsize(ptmx, &pty.Winsize{Rows: 20, Cols: 100})
	cmd.Process.Signal(syscall.SIGWINCH)

	type chunk struct {
		t   time.Time
		buf []byte
	}
	chunks := make(chan chunk, 1024)
	go func() {
		var buf [4096]byte
		for {
			n, err := ptmx.Read(buf[:])
			if n > 0 {
				chunks <- chunk{time.Now(), append([]byte(nil), buf[:n]...)}
			}
			if err != nil {
				close(chunks)
				return
			}
		}
	}()

	time.Sleep(2500 * time.Millisecond)

	t0 := time.Now()
	pty.Setsize(ptmx, &pty.Winsize{Rows: 20, Cols: 140})
	cmd.Process.Signal(syscall.SIGWINCH)

	all := []chunk{}
	done := make(chan struct{})
	go func() {
		for c := range chunks {
			all = append(all, c)
		}
		close(done)
	}()

	time.Sleep(500 * time.Millisecond)
	ptmx.WriteString("q")
	time.Sleep(300 * time.Millisecond)
	cmd.Process.Signal(syscall.SIGTERM)
	<-done

	for _, c := range all {
		dt := c.t.Sub(t0).Milliseconds()
		has2J := bytes.Contains(c.buf, []byte{0x1b, '[', '2', 'J'})
		fmt.Printf("t=%+5dms bytes=%4d 2J=%v | %q\n", dt, len(c.buf), has2J, string(c.buf))
	}
}

Observed (project artifacts panel, bubbletea v2.0.9)

A real app with a heavier View() (a bubbles/table that recomputes column widths on resize) reproduces the race more readily than the minimal program above. Captured on a resize from 100x30 to 120x40:

t= -830ms  ... 2J=true   layout: NAME[29X] SOURCE[17X]   (100 cols — correct, initial)
t=   +4ms  ... 2J=true   layout: NAME[29X] SOURCE[17X]   <- GHOST: OLD layout
                   but preceded by \x1b[30d (cursor to row 30) which is only
                   valid at the NEW height of 40 → stale view at new size
t=  +19ms  ... 2J=false  layout: NAME[36X] SOURCE[24X]   (120 cols — correct, drawn as a diff over the ghost)

The t=+4ms frame is the racing ticker: resize() already set the new size (hence the \x1b[30d/[J clear sequence sized for height 40) but s.view was still the 100-column frame, so the old column widths are emitted at the new size. t=+19ms is render()'s new view drawn as a diff.

Root cause (code walkthrough, v2.0.9)

Two goroutines share cursedRenderer.s.mu:

  1. Event loop (tea.go), on WindowSizeMsg:

    • tea.go:858: p.renderer.resize(msg.Width, msg.Height)
    • cursed_renderer.go:675-679: under s.mu, s.scr.Erase(); sets s.width,s.height = w,h; s.scr.Resize(...); s.pendingErase = true; s.mu.Unlock(). Does not touch s.view.
    • tea.go:888: p.render(model)cursed_renderer.go:627-631: under s.mu, s.view = v. Updates s.view after resize() released the lock.
  2. Ticker goroutine (tea.go:1418-1430): on p.ticker.C (~60 Hz), calls p.renderer.flush(false).

flush() (cursed_renderer.go:290-...):

  • :294: view := s.view (may be the previous frame if it runs before render()).
  • :320: skips the no-change early-return when s.pendingErase is true or frameArea != s.cellbuf.Bounds().
  • :329-339: on frameArea != s.cellbuf.Bounds() (true after resize()), s.scr.Erase() + s.cellbuf.Resize(...) and draws view (the stale one) into the resized buffer.

The race window is the gap between resize() releasing s.mu and render() taking it again. Both run in the event loop, but the ticker goroutine can acquire s.mu in that gap and flush the stale s.view with the armed pendingErase at the new size.

Why tea.ClearScreen() does not help

tea.ClearScreen() returns a clearScreenMsg that is queued to p.msgs and processed on the next event-loop iteration, so it cannot prevent the ticker flush that already fires between the current resize() and render(). It only adds an extra full-screen clear one tick later; the ghost is unchanged. (We verified this empirically by re-adding ClearScreen() to the resize handler.)

Suggested fix

resize() should ensure that a flush() that runs before the next render() does not draw stale content into the new buffer. Options:

  • In resize(), set a flag (e.g. reuse/extend pendingErase or add pendingResize) that makes flush() only emit the clear+resize and skip drawing s.view until render() has stored the new view; or
  • Invalidate s.view in resize() so flush() sees an empty view until render() repopulates it; or
  • Perform resize() and render() under a single held lock (or otherwise make the resize→render step atomic w.r.t. the ticker).

Environment

  • charm.land/bubbletea/v2 v2.0.9 (Go module path charm.land/bubbletea/v2)
  • Go 1.24
  • Linux, TERM=xterm-256color, repro over a creack/pty PTY
  • Affects alt-screen programs whose View() content depends on terminal width (e.g. tables that reflow columns). Programs whose View() is width-independent do not show a visible ghost because the stale and correct views are identical.