568 lines
12 KiB
Markdown
568 lines
12 KiB
Markdown
# 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:**
|
|
|
|
1. **Blocking Operations in Update()**
|
|
```go
|
|
// ❌ 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}
|
|
}
|
|
```
|
|
|
|
2. **Heavy Processing in View()**
|
|
```go
|
|
// ❌ 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
|
|
}
|
|
```
|
|
|
|
3. **String Concatenation with +**
|
|
```go
|
|
// ❌ 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:**
|
|
|
|
1. **Hardcoded Dimensions**
|
|
```go
|
|
// ❌ 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
|
|
}
|
|
```
|
|
|
|
2. **Not Accounting for Padding/Borders**
|
|
```go
|
|
// ❌ 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)
|
|
```
|
|
|
|
3. **Manual Height Calculations**
|
|
```go
|
|
// ❌ 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**
|
|
|
|
```go
|
|
// ❌ 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**
|
|
|
|
```go
|
|
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**
|
|
|
|
```go
|
|
// ❌ 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:**
|
|
|
|
1. **Goroutine Leaks**
|
|
```go
|
|
// ❌ 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
|
|
}
|
|
```
|
|
|
|
2. **Unreleased Resources**
|
|
```go
|
|
// ❌ 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**
|
|
|
|
```go
|
|
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
|
|
|
|
```go
|
|
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`:
|
|
```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**
|