12 KiB
12 KiB
Common Bubble Tea Issues and Solutions
Reference guide for diagnosing and fixing common problems in Bubble Tea applications.
Performance Issues
Issue: Slow/Laggy UI
Symptoms:
- UI freezes when typing
- Delayed response to key presses
- Stuttering animations
Common Causes:
-
Blocking Operations in Update()
// ❌ BAD func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: data := http.Get("https://api.example.com") // BLOCKS! m.data = data } return m, nil } // ✅ GOOD func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: return m, fetchDataCmd // Non-blocking case dataFetchedMsg: m.data = msg.data } return m, nil } func fetchDataCmd() tea.Msg { data := http.Get("https://api.example.com") // Runs in goroutine return dataFetchedMsg{data: data} } -
Heavy Processing in View()
// ❌ BAD func (m model) View() string { content, _ := os.ReadFile("large_file.txt") // EVERY RENDER! return string(content) } // ✅ GOOD type model struct { cachedContent string } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case fileLoadedMsg: m.cachedContent = msg.content // Cache it } return m, nil } func (m model) View() string { return m.cachedContent // Just return cached data } -
String Concatenation with +
// ❌ BAD - Allocates many temp strings func (m model) View() string { s := "" for _, line := range m.lines { s += line + "\\n" // Expensive! } return s } // ✅ GOOD - Single allocation func (m model) View() string { var b strings.Builder for _, line := range m.lines { b.WriteString(line) b.WriteString("\\n") } return b.String() }
Performance Target: Update() should complete in <16ms (60 FPS)
Layout Issues
Issue: Content Overflows Terminal
Symptoms:
- Text wraps unexpectedly
- Content gets clipped
- Layout breaks on different terminal sizes
Common Causes:
-
Hardcoded Dimensions
// ❌ BAD content := lipgloss.NewStyle(). Width(80). // What if terminal is 120 wide? Height(24). // What if terminal is 40 tall? Render(text) // ✅ GOOD type model struct { termWidth int termHeight int } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.termWidth = msg.Width m.termHeight = msg.Height } return m, nil } func (m model) View() string { content := lipgloss.NewStyle(). Width(m.termWidth). Height(m.termHeight - 2). // Leave room for status bar Render(text) return content } -
Not Accounting for Padding/Borders
// ❌ BAD style := lipgloss.NewStyle(). Padding(2). Border(lipgloss.RoundedBorder()). Width(80) content := style.Render(text) // Text area is 76 (80 - 2*2 padding), NOT 80! // ✅ GOOD style := lipgloss.NewStyle(). Padding(2). Border(lipgloss.RoundedBorder()) contentWidth := 80 - style.GetHorizontalPadding() - style.GetHorizontalBorderSize() innerContent := lipgloss.NewStyle().Width(contentWidth).Render(text) result := style.Width(80).Render(innerContent) -
Manual Height Calculations
// ❌ BAD - Magic numbers availableHeight := 24 - 3 // Where did 3 come from? // ✅ GOOD - Calculated headerHeight := lipgloss.Height(m.renderHeader()) footerHeight := lipgloss.Height(m.renderFooter()) availableHeight := m.termHeight - headerHeight - footerHeight
Message Handling Issues
Issue: Messages Arrive Out of Order
Symptoms:
- State becomes inconsistent
- Operations complete in wrong order
- Race conditions
Cause: Concurrent tea.Cmd messages aren't guaranteed to arrive in order
Solution: Use State Tracking
// ❌ BAD - Assumes order
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
if msg.String() == "r" {
return m, tea.Batch(
fetchUsersCmd, // Might complete second
fetchPostsCmd, // Might complete first
)
}
case usersLoadedMsg:
m.users = msg.users
case postsLoadedMsg:
m.posts = msg.posts
// Assumes users are loaded! May not be!
}
return m, nil
}
// ✅ GOOD - Track operations
type model struct {
operations map[string]bool
users []User
posts []Post
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
if msg.String() == "r" {
m.operations["users"] = true
m.operations["posts"] = true
return m, tea.Batch(fetchUsersCmd, fetchPostsCmd)
}
case usersLoadedMsg:
m.users = msg.users
delete(m.operations, "users")
return m, m.checkAllLoaded()
case postsLoadedMsg:
m.posts = msg.posts
delete(m.operations, "posts")
return m, m.checkAllLoaded()
}
return m, nil
}
func (m model) checkAllLoaded() tea.Cmd {
if len(m.operations) == 0 {
// All operations complete, can proceed
return m.processData
}
return nil
}
Terminal Recovery Issues
Issue: Terminal Gets Messed Up After Crash
Symptoms:
- Cursor disappears
- Mouse mode still active
- Terminal looks corrupted
Solution: Add Panic Recovery
func main() {
defer func() {
if r := recover(); r != nil {
// Restore terminal state
tea.DisableMouseAllMotion()
tea.ShowCursor()
fmt.Printf("Panic: %v\\n", r)
debug.PrintStack()
os.Exit(1)
}
}()
p := tea.NewProgram(initialModel())
if err := p.Start(); err != nil {
fmt.Printf("Error: %v\\n", err)
os.Exit(1)
}
}
Architecture Issues
Issue: Model Too Complex
Symptoms:
- Model struct has 20+ fields
- Update() is hundreds of lines
- Hard to maintain
Solution: Use Model Tree Pattern
// ❌ BAD - Flat model
type model struct {
// List view fields
listItems []string
listCursor int
listFilter string
// Detail view fields
detailItem string
detailHTML string
detailScroll int
// Search view fields
searchQuery string
searchResults []string
searchCursor int
// ... 15 more fields
}
// ✅ GOOD - Model tree
type appModel struct {
activeView int
listView listViewModel
detailView detailViewModel
searchView searchViewModel
}
type listViewModel struct {
items []string
cursor int
filter string
}
func (m listViewModel) Update(msg tea.Msg) (listViewModel, tea.Cmd) {
// Only handles list-specific messages
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "up":
m.cursor--
case "down":
m.cursor++
case "enter":
return m, func() tea.Msg {
return itemSelectedMsg{itemID: m.items[m.cursor]}
}
}
}
return m, nil
}
// Parent routes messages
func (m appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Handle global messages
switch msg := msg.(type) {
case itemSelectedMsg:
m.detailView.LoadItem(msg.itemID)
m.activeView = 1 // Switch to detail
return m, nil
}
// Route to active child
var cmd tea.Cmd
switch m.activeView {
case 0:
m.listView, cmd = m.listView.Update(msg)
case 1:
m.detailView, cmd = m.detailView.Update(msg)
case 2:
m.searchView, cmd = m.searchView.Update(msg)
}
return m, cmd
}
Memory Issues
Issue: Memory Leak / Growing Memory Usage
Symptoms:
- Memory usage increases over time
- Never gets garbage collected
Common Causes:
-
Goroutine Leaks
// ❌ BAD - Goroutines never stop func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: if msg.String() == "s" { return m, func() tea.Msg { go func() { for { // INFINITE LOOP! time.Sleep(time.Second) // Do something } }() return nil } } } return m, nil } // ✅ GOOD - Use context for cancellation type model struct { ctx context.Context cancel context.CancelFunc } func initialModel() model { ctx, cancel := context.WithCancel(context.Background()) return model{ctx: ctx, cancel: cancel} } func worker(ctx context.Context) tea.Msg { for { select { case <-ctx.Done(): return nil // Stop gracefully case <-time.After(time.Second): // Do work } } } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: if msg.String() == "q" { m.cancel() // Stop all workers return m, tea.Quit } } return m, nil } -
Unreleased Resources
// ❌ BAD func loadFile() tea.Msg { file, _ := os.Open("data.txt") // Never closed! data, _ := io.ReadAll(file) return dataMsg{data: data} } // ✅ GOOD func loadFile() tea.Msg { file, err := os.Open("data.txt") if err != nil { return errorMsg{err: err} } defer file.Close() // Always close data, err := io.ReadAll(file) return dataMsg{data: data, err: err} }
Testing Issues
Issue: Hard to Test TUI
Solution: Use teatest
import (
"testing"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/bubbletea/teatest"
)
func TestNavigation(t *testing.T) {
m := initialModel()
// Create test program
tm := teatest.NewTestModel(t, m)
// Send key presses
tm.Send(tea.KeyMsg{Type: tea.KeyDown})
tm.Send(tea.KeyMsg{Type: tea.KeyDown})
// Wait for program to process
teatest.WaitFor(
t, tm.Output(),
func(bts []byte) bool {
return bytes.Contains(bts, []byte("Item 2"))
},
teatest.WithCheckInterval(time.Millisecond*100),
teatest.WithDuration(time.Second*3),
)
// Verify state
finalModel := tm.FinalModel(t).(model)
if finalModel.cursor != 2 {
t.Errorf("Expected cursor at 2, got %d", finalModel.cursor)
}
}
Debugging Tips
Enable Message Dumping
import "github.com/davecgh/go-spew/spew"
type model struct {
dump io.Writer
}
func main() {
// Create debug file
f, _ := os.Create("debug.log")
defer f.Close()
m := model{dump: f}
p := tea.NewProgram(m)
p.Start()
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Dump every message
if m.dump != nil {
spew.Fdump(m.dump, msg)
}
// ... rest of Update()
return m, nil
}
Live Reload with Air
.air.toml:
[build]
cmd = "go build -o ./tmp/main ."
bin = "tmp/main"
include_ext = ["go"]
exclude_dir = ["tmp"]
delay = 1000
Run: air
Quick Checklist
Before deploying your Bubble Tea app:
- No blocking operations in Update() or View()
- Terminal resize handled (tea.WindowSizeMsg)
- Panic recovery with terminal cleanup
- Dynamic layout (no hardcoded dimensions)
- Lipgloss padding/borders accounted for
- String operations use strings.Builder
- Goroutines have cancellation (context)
- Resources properly closed (defer)
- State machine handles message ordering
- Tests with teatest for key interactions
Generated for Bubble Tea Maintenance Agent v1.0.0