charmbracelet / charmbracelet/bubbletea

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

Open
#1,740 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
Go
Stars
44.9k
Forks
1.3k
PR merge metrics
No merged PRs in 30d

Description

**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`):
```go
w, h := s.cellbuf.Width(), s.cellbuf.Height()
down := h - y - 1
// ... emits ansi.CursorDown(down) => ESC[B (lines ~716-723)
```
This assumes `h` is the footer frame height.
- But the cellbuf is created at **full terminal size**: `newCursedRenderer` →
`uv.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

```go
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[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[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).

Contributor guide

Open the contributing guide

Research direction

Start in cursed_renderer.go at insertAbove, render(), resize(), and flush(), then trace renderer startup and ticker handling in tea.go. Add a regression test using a fixed WithWindowSize that exercises a startup tea.Println and verifies no full-terminal-height cursor-down escape is written before the first frame.

Written by the indexing model from the issue text.

Assessment

Tech stack
go
Domain
cli
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
72/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.