fix(day): edits didn't persist — heap-share formValues across Bubble Tea copies
b9c9b9766a23c18b35096cb8ed1bf27d176c937a
humdrum-tiv <45084903+humdrum-tiv@users.noreply.github.com> · 2026-07-07 17:50
parent b1293a92
fix(day): edits didn't persist — heap-share formValues across Bubble Tea copies Bubble Tea passes Model by value into every Update, so binding the huh form to a value-field &m.fv let huh write typed edits into a stale copy; applyEdit then re-saved the unchanged original. Make fv a *formValues so the form and applyEdit share one instance. Regression test drives the real loop end-to-end.
2 files changed
internal/tui/day/model.go +9 −5
@@ -20,9 +20,12 @@ tbl table.Model
err error
quit bool
- editing bool
- form *huh.Form
- fv formValues
+ editing bool
+ form *huh.Form
+ // fv is heap-allocated so the huh form and applyEdit share ONE instance.
+ // Bubble Tea passes Model by value into every Update, so a value field here
+ // would let huh write typed edits into a stale copy that applyEdit never sees.
+ fv *formValues
editKey string
editOrig store.Entry
status string
@@ -111,7 +114,8 @@ if e, ok := m.selected(); ok && !e.Running() {
m.editing = true
m.editKey = e.Key
m.editOrig = e
- m.form = newEditForm(e, &m.fv)
+ m.fv = &formValues{}
+ m.form = newEditForm(e, m.fv)
return m, m.form.Init()
}
return m, nil
@@ -134,7 +138,7 @@ m.form = ff
}
if m.form.State == huh.StateCompleted {
m.editing = false
- if err := applyEdit(m.store, m.date, m.editOrig, m.fv); err != nil {
+ if err := applyEdit(m.store, m.date, m.editOrig, *m.fv); err != nil {
m.status = "edit failed: " + err.Error()
} else {
m.status = "updated " + m.editKey
internal/tui/day/model_test.go +51 −2
@@ -13,14 +13,16 @@ )
type fakeStore struct {
days map[string][]store.Entry
- replaced []string // keys passed to SafeReplace
+ replaced []string // keys passed to SafeReplace
+ updated []store.Entry // updated entry passed to SafeReplace
}
func (f *fakeStore) Day(date string) ([]store.Entry, error) { return f.days[date], nil }
func (f *fakeStore) Add(store.Entry) error { return nil }
func (f *fakeStore) Remove(string) error { return nil }
-func (f *fakeStore) SafeReplace(key string, _, _ store.Entry) error {
+func (f *fakeStore) SafeReplace(key string, updated, _ store.Entry) error {
f.replaced = append(f.replaced, key)
+ f.updated = append(f.updated, updated)
return nil
}
@@ -183,6 +185,53 @@ }
tm, _ = m.Update(tea.KeyMsg{Type: tea.KeyRight})
if got := tm.(Model).date; got != "2026-07-07" {
t.Errorf("right => date %q, want 2026-07-07", got)
+ }
+}
+
+// TestEditFieldReachesStore drives the real Bubble Tea loop: open the editor,
+// retype the start field, submit, and assert the *typed* value reaches
+// SafeReplace. Guards the value-receiver copy bug where huh wrote edits into a
+// stale Model copy and applyEdit re-saved the unchanged original.
+func TestEditFieldReachesStore(t *testing.T) {
+ fs := &fakeStore{days: map[string][]store.Entry{"2026-07-07": sample()}}
+ tm := teatest.NewTestModel(t, New(fs, "2026-07-07"),
+ teatest.WithInitialTermSize(140, 24))
+
+ // Wait for the table, then open the editor on row 0 (2026-07-07-01).
+ teatest.WaitFor(t, tm.Output(), func(b []byte) bool {
+ return bytes.Contains(b, []byte("ARCHER"))
+ }, teatest.WithDuration(2*time.Second))
+ tm.Send(tea.KeyMsg{Type: tea.KeyEnter})
+
+ // Wait for the huh form (its field titles render).
+ teatest.WaitFor(t, tm.Output(), func(b []byte) bool {
+ return bytes.Contains(b, []byte("start (HH:MM)"))
+ }, teatest.WithDuration(2*time.Second))
+
+ // Start is prefilled "09:01"; clear it and type "08:00".
+ for i := 0; i < 6; i++ {
+ tm.Send(tea.KeyMsg{Type: tea.KeyBackspace})
+ }
+ for _, r := range "08:00" {
+ tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
+ }
+ // Advance through the 6 fields and submit (enter completes on the last).
+ for i := 0; i < 6; i++ {
+ tm.Send(tea.KeyMsg{Type: tea.KeyEnter})
+ }
+
+ teatest.WaitFor(t, tm.Output(), func(b []byte) bool {
+ return bytes.Contains(b, []byte("updated 2026-07-07-01"))
+ }, teatest.WithDuration(2*time.Second))
+
+ tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}})
+ tm.WaitFinished(t, teatest.WithFinalTimeout(2*time.Second))
+
+ if len(fs.updated) != 1 {
+ t.Fatalf("SafeReplace calls = %d, want 1", len(fs.updated))
+ }
+ if got := fs.updated[0].Start.Format("15:04"); got != "08:00" {
+ t.Errorf("stored start = %q, want 08:00 (typed edit did not reach the store)", got)
}
}