#1740·bubbletea

[v2] tea.Printf/Println before the first flush emits a full-terminal-height cursor-down (scrolls the whole screen)

Author: hlubekCreated Jul 23, 2026Updated Jul 23, 2026

Affected version: charm.land/bubbletea/v2 v2.0.7 (renderer engine github.com/charmbracelet/ultraviolet)

Summary

A tea.Printf/tea.Println command that runs before the renderer's first flush emits a cursor-down of the full terminal height rather than the footer frame height, scrolling the entire screen up. The window between program start and first flush is small (~one frame at the configured FPS) but real: startup messages are delivered via go p.Send with no happens-before edge to the first flush, so any Cmd that prints on Init, or in response to WindowSizeMsg, or from a goroutine that starts logging immediately, can land in it.

Root cause (source-level)

insertAbove computes the cursor-down distance from the cellbuf height, but the cellbuf is created at full terminal size and only resized to the current view's height inside flush() - which may not have run yet.

  • tea.Printf/Println return a printLineMessage (renderer.go:59-92), handled synchronously as p.renderer.insertAbove(msg.messageBody) (tea.go:861-862).
  • insertAbove (cursed_renderer.go:707-763):
    w, h := s.cellbuf.Width(), s.cellbuf.Height()
    down := h - y - 1
    // ... emits ansi.CursorDown(down)  => ESC[<n>B  (lines ~716-723)
    
    This assumes h is the footer frame height.
  • But the cellbuf is created at full terminal size: newCursedRendereruv.NewScreenBuffer(w, h) (cursed_renderer.go:46), with w,h from term.GetSize in Run (tea.go:1045-1066).
  • The cellbuf is resized to the view height only inside flush(): frameHeight := content.Height(); s.cellbuf.Resize(...) (cursed_renderer.go:276, 295-306). render() only stashes the view (:579-584); resize() (opens at :619) does not touch the cellbuf.
  • Flushes run on a ticker at 1s/fps (~16.6ms at 60fps) started by startRenderer (tea.go:1393-1422, flush at :1417-1418). The first Printf can traverse Init → handleCommands → Send → eventLoop → insertAbove well inside that window. There is no happens-before edge between the first flush and the first insertAbove, and WindowSizeMsg does not imply a flush.

Consequences: before the first flush, h is the whole terminal, so down = h - y - 1 scrolls the full screen.

Minimal repro sketch

package main

import (
	"fmt"
	tea "charm.land/bubbletea/v2"
)

type model struct{}

func (m model) Init() tea.Cmd {
	// A print scheduled at startup - races the first flush.
	return tea.Println("this line scrolls the whole screen")
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	if _, ok := msg.(tea.KeyPressMsg); ok {
		return m, tea.Quit
	}
	return m, nil
}

func (m model) View() tea.View { return tea.NewView("footer line") }

func main() {
	if _, err := tea.NewProgram(model{}).Run(); err != nil {
		fmt.Println(err)
	}
}

Run in a terminal with several screenfuls of scrollback content already visible: the pre-first-flush Println emits ESC[<terminalHeight-1>B instead of ESC[0B, scrolling the whole viewport. Making the print race the flush more reliably (e.g. printing from a goroutine launched in Init or on the first WindowSizeMsg) reproduces it consistently.

A regression test can drive the model with a fixed WithWindowSize and assert that no large ESC[<n>B is written before the first frame.

Why the usual workarounds don't help (all verified against v2.0.7)

  • WithWindowSize only seeds p.width/p.height; it does not pre-resize the cellbuf.
  • Sending an explicit WindowSizeMsg calls resize(), which skips the cellbuf.
  • Higher WithFPS (max 120) shrinks the window but never closes it.
  • There is no readiness hook: the renderer interface exposes no first-flush callback, and flush is unexported, so a program cannot gate prints on "first frame painted" from the outside.

Suggested fix directions

Either of these closes the root cause with a one-line-ish change:

  1. Sync the cellbuf height in render()/resize() so it reflects the current view height before any insertAbove can run - i.e. resize the cellbuf to the stashed content height outside of flush().
  2. Clamp down in insertAbove to the current view/content height rather than the (possibly stale, full-terminal) cellbuf height.

A first-flush readiness signal (callback or message) would additionally let programs safely defer scrollback prints, addressing the class of races beyond this specific one.

References

  • #1666: "Add tea.PrintlnRaw to bypass insertAbove rendering": confirms the fragility of insertAbove's cursor arithmetic; the specific pre-first-flush race here is not yet tracked. https://github.com/charmbracelet/bubbletea/issues/1666
  • #1627: "[v2] Terminal Escape Sequence Leak in Short-Lived Programs": adjacent startup/teardown timing family (DEC 2026/2027 mode queries), same "startup messages race the renderer" shape; nothing shipped in v2.0.7. https://github.com/charmbracelet/bubbletea/issues/1627
  • Related context: #1384, discussion #1482 (scrollback printing fragility); older #297, #1004 (frame-height / window-size coupling).