From c392ecc039cf5f46b79df007d131b23c0e36edde Mon Sep 17 00:00:00 2001 From: Shawn Daichendt Date: Fri, 24 Jul 2026 08:52:23 -0400 Subject: [PATCH] fix(textarea): prevent infinite loop in wordLeft on empty input wordLeft used an unconditional for{} that only exits on a non-space rune. On empty input (row 0, col 0) characterLeft is a no-op and the break condition 0 < 0 is never true, freezing the event loop on alt+left/alt+b. Add a start-of-text guard mirroring doWordRight's end-of-text break. Fixes charmbracelet/bubbletea#1652 --- textarea/textarea.go | 9 +++++++ textarea/textarea_test.go | 53 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/textarea/textarea.go b/textarea/textarea.go index a6ed90b1a..7dcc55e15 100644 --- a/textarea/textarea.go +++ b/textarea/textarea.go @@ -954,7 +954,16 @@ func (m *Model) characterLeft(insideLine bool) { // cursor blink should be reset. If input is masked, move input to the start // so as not to reveal word breaks in the masked input. func (m *Model) wordLeft() { + // Skip spaces backward, stopping at the start of the text. Without this + // guard, characterLeft becomes a no-op at the very start of the input + // (row 0, col 0) while the break condition below is never satisfied, + // spinning the loop forever. This mirrors the end-of-text break in + // doWordRight. for { + if m.row == 0 && m.col == 0 { + // Start of text. + return + } m.characterLeft(true /* insideLine */) if m.col < len(m.value[m.row]) && !unicode.IsSpace(m.value[m.row][m.col]) { break diff --git a/textarea/textarea_test.go b/textarea/textarea_test.go index 41d51f744..3bb8bcf32 100644 --- a/textarea/textarea_test.go +++ b/textarea/textarea_test.go @@ -4,6 +4,7 @@ import ( "fmt" "strings" "testing" + "time" "unicode" tea "charm.land/bubbletea/v2" @@ -1974,6 +1975,58 @@ func TestWord(t *testing.T) { }) } +// TestWordBackwardEmptyTextareaTerminates guards against a regression where +// wordLeft() spun forever on an empty textarea. With m.value == [][]rune{{}} +// and the cursor at row 0, col 0, characterLeft is a no-op and the loop's +// break condition (m.col < len(m.value[m.row])) is 0 < 0 == false, so alt+left +// / alt+b would freeze the event loop. See charmbracelet/bubbletea#1652. +// +// The test runs the update on a goroutine with a timeout so a regression fails +// the test instead of hanging the whole suite, and asserts the motion is a +// no-op on empty input. +func TestWordBackwardEmptyTextareaTerminates(t *testing.T) { + for _, tc := range []struct { + name string + key tea.KeyPressMsg + }{ + {"alt+left", tea.KeyPressMsg{Code: tea.KeyLeft, Mod: tea.ModAlt, Text: "alt+left"}}, + {"alt+b", tea.KeyPressMsg{Code: 'b', Mod: tea.ModAlt, Text: "alt+b"}}, + } { + tc := tc + t.Run(tc.name, func(t *testing.T) { + m := newTextArea() + + // Sanity check: the textarea starts empty with the cursor at the + // very start of the text. + if got := m.Value(); got != "" { + t.Fatalf("expected empty value, got %q", got) + } + if m.row != 0 || m.col != 0 { + t.Fatalf("expected cursor at row 0, col 0, got row %d, col %d", m.row, m.col) + } + + done := make(chan Model, 1) + go func() { + updated, _ := m.Update(tc.key) + done <- updated + }() + + select { + case updated := <-done: + // Word-backward on empty input must be a no-op. + if got := updated.Value(); got != "" { + t.Fatalf("expected value to remain empty, got %q", got) + } + if updated.row != 0 || updated.col != 0 { + t.Fatalf("expected cursor to stay at row 0, col 0, got row %d, col %d", updated.row, updated.col) + } + case <-time.After(2 * time.Second): + t.Fatalf("word backward (%s) did not terminate on empty textarea", tc.name) + } + }) + } +} + func newDynamicTextArea(minH, maxH int) Model { ta := New() ta.Prompt = ""