[v2] tea.Printf/Println before the first flush emits a full-terminal-height cursor-down (scrolls the whole screen)
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/Printlnreturn aprintLineMessage(renderer.go:59-92), handled synchronously asp.renderer.insertAbove(msg.messageBody)(tea.go:861-862).insertAbove(cursed_renderer.go:707-763):
This assumesw, h := s.cellbuf.Width(), s.cellbuf.Height() down := h - y - 1 // ... emits ansi.CursorDown(down) => ESC[<n>B (lines ~716-723)his the footer frame height.- But the cellbuf is created at full terminal size:
newCursedRenderer→uv.NewScreenBuffer(w, h)(cursed_renderer.go:46), withw,hfromterm.GetSizeinRun(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 bystartRenderer(tea.go:1393-1422, flush at:1417-1418). The firstPrintfcan traverseInit → handleCommands → Send → eventLoop → insertAbovewell inside that window. There is no happens-before edge between the first flush and the firstinsertAbove, andWindowSizeMsgdoes 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)
WithWindowSizeonly seedsp.width/p.height; it does not pre-resize the cellbuf.- Sending an explicit
WindowSizeMsgcallsresize(), 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
flushis 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:
- Sync the cellbuf height in
render()/resize()so it reflects the current view height before anyinsertAbovecan run - i.e. resize the cellbuf to the stashed content height outside offlush(). - Clamp
downininsertAboveto 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.PrintlnRawto bypass insertAbove rendering": confirms the fragility ofinsertAbove'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).
Source: charmbracelet/bubbletea