Skip to content

Commit d7a7104

Browse files
committed
Fix stale fetch race condition and empty-table search restore
1 parent e672906 commit d7a7104

File tree

2 files changed

+108
-17
lines changed

2 files changed

+108
-17
lines changed

libs/tableview/paginated.go

Lines changed: 29 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,10 @@ const (
1919

2020
// rowsFetchedMsg carries newly fetched rows from the iterator.
2121
type rowsFetchedMsg struct {
22-
rows [][]string
23-
exhausted bool
24-
err error
22+
rows [][]string
23+
exhausted bool
24+
err error
25+
generation int
2526
}
2627

2728
type paginatedModel struct {
@@ -38,20 +39,22 @@ type paginatedModel struct {
3839
err error
3940

4041
// Fetch state
41-
rowIter RowIterator
42-
makeFetchCmd func(m paginatedModel) tea.Cmd // closure capturing ctx
43-
makeSearchIter func(query string) RowIterator // closure capturing ctx
42+
rowIter RowIterator
43+
makeFetchCmd func(m paginatedModel) tea.Cmd // closure capturing ctx
44+
makeSearchIter func(query string) RowIterator // closure capturing ctx
45+
fetchGeneration int
4446

4547
// Display
4648
cursor int
4749
widths []int
4850

4951
// Search
50-
searching bool
51-
searchInput string
52-
savedRows [][]string
53-
savedIter RowIterator
54-
savedExhaust bool
52+
searching bool
53+
searchInput string
54+
hasSearchState bool
55+
savedRows [][]string
56+
savedIter RowIterator
57+
savedExhaust bool
5558

5659
// Limits
5760
maxItems int
@@ -64,6 +67,7 @@ func newFetchCmdFunc(ctx context.Context) func(paginatedModel) tea.Cmd {
6467
iter := m.rowIter
6568
currentLen := len(m.rows)
6669
maxItems := m.maxItems
70+
generation := m.fetchGeneration
6771

6872
return func() tea.Msg {
6973
var rows [][]string
@@ -73,7 +77,7 @@ func newFetchCmdFunc(ctx context.Context) func(paginatedModel) tea.Cmd {
7377
if maxItems > 0 {
7478
remaining := maxItems - currentLen
7579
if remaining <= 0 {
76-
return rowsFetchedMsg{exhausted: true}
80+
return rowsFetchedMsg{exhausted: true, generation: generation}
7781
}
7882
limit = min(limit, remaining)
7983
}
@@ -85,7 +89,7 @@ func newFetchCmdFunc(ctx context.Context) func(paginatedModel) tea.Cmd {
8589
}
8690
row, err := iter.Next(ctx)
8791
if err != nil {
88-
return rowsFetchedMsg{err: err}
92+
return rowsFetchedMsg{err: err, generation: generation}
8993
}
9094
rows = append(rows, row)
9195
}
@@ -94,7 +98,7 @@ func newFetchCmdFunc(ctx context.Context) func(paginatedModel) tea.Cmd {
9498
exhausted = true
9599
}
96100

97-
return rowsFetchedMsg{rows: rows, exhausted: exhausted}
101+
return rowsFetchedMsg{rows: rows, exhausted: exhausted, generation: generation}
98102
}
99103
}
100104
}
@@ -160,6 +164,9 @@ func (m paginatedModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
160164
return m, nil
161165

162166
case rowsFetchedMsg:
167+
if msg.generation != m.fetchGeneration {
168+
return m, nil
169+
}
163170
m.loading = false
164171
if msg.err != nil {
165172
m.err = msg.err
@@ -345,25 +352,31 @@ func (m paginatedModel) updateSearch(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
345352
query := m.searchInput
346353
if query == "" {
347354
// Restore original state
348-
if m.savedRows != nil {
355+
if m.hasSearchState {
356+
m.fetchGeneration++
349357
m.rows = m.savedRows
350358
m.rowIter = m.savedIter
351359
m.exhausted = m.savedExhaust
360+
m.loading = false
361+
m.hasSearchState = false
352362
m.savedRows = nil
353363
m.savedIter = nil
364+
m.savedExhaust = false
354365
m.cursor = 0
355366
m.viewport.SetContent(m.renderContent())
356367
m.viewport.GotoTop()
357368
}
358369
return m, nil
359370
}
360371
// Save current state
361-
if m.savedRows == nil {
372+
if !m.hasSearchState {
373+
m.hasSearchState = true
362374
m.savedRows = m.rows
363375
m.savedIter = m.rowIter
364376
m.savedExhaust = m.exhausted
365377
}
366378
// Create new iterator with search
379+
m.fetchGeneration++
367380
m.rows = nil
368381
m.exhausted = false
369382
m.loading = false

libs/tableview/paginated_test.go

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -273,7 +273,8 @@ func TestPaginatedSearchEnterAndRestore(t *testing.T) {
273273
assert.False(t, pm.searching)
274274
assert.True(t, searchCalled)
275275
assert.NotNil(t, cmd)
276-
assert.NotNil(t, pm.savedRows)
276+
assert.True(t, pm.hasSearchState)
277+
assert.Equal(t, 1, pm.fetchGeneration)
277278

278279
// Restore by submitting empty search
279280
pm.searching = true
@@ -283,7 +284,60 @@ func TestPaginatedSearchEnterAndRestore(t *testing.T) {
283284
pm = result.(paginatedModel)
284285

285286
assert.Equal(t, [][]string{{"original"}}, pm.rows)
287+
assert.False(t, pm.hasSearchState)
286288
assert.Nil(t, pm.savedRows)
289+
assert.Equal(t, 2, pm.fetchGeneration)
290+
}
291+
292+
func TestPaginatedSearchRestoreEmptyOriginalTable(t *testing.T) {
293+
cfg := &TableConfig{
294+
Columns: []ColumnDef{
295+
{Header: "Name"},
296+
},
297+
Search: &SearchConfig{
298+
Placeholder: "search...",
299+
NewIterator: func(_ context.Context, query string) RowIterator {
300+
return &stringRowIterator{rows: [][]string{{"found:" + query}}}
301+
},
302+
},
303+
}
304+
305+
ctx := t.Context()
306+
originalIter := &stringRowIterator{}
307+
m := paginatedModel{
308+
cfg: cfg,
309+
headers: []string{"Name"},
310+
rowIter: originalIter,
311+
makeFetchCmd: newFetchCmdFunc(ctx),
312+
makeSearchIter: newSearchIterFunc(ctx, cfg.Search),
313+
exhausted: true,
314+
ready: true,
315+
}
316+
m.viewport.Width = 80
317+
m.viewport.Height = 20
318+
319+
m.searching = true
320+
m.searchInput = "test"
321+
322+
result, cmd := m.updateSearch(tea.KeyMsg{Type: tea.KeyEnter})
323+
pm := result.(paginatedModel)
324+
325+
assert.NotNil(t, cmd)
326+
assert.True(t, pm.hasSearchState)
327+
assert.Nil(t, pm.savedRows)
328+
assert.Equal(t, 1, pm.fetchGeneration)
329+
330+
pm.searching = true
331+
pm.searchInput = ""
332+
pm.rows = [][]string{{"found:test"}}
333+
result, _ = pm.updateSearch(tea.KeyMsg{Type: tea.KeyEnter})
334+
pm = result.(paginatedModel)
335+
336+
assert.Nil(t, pm.rows)
337+
assert.Equal(t, originalIter, pm.rowIter)
338+
assert.True(t, pm.exhausted)
339+
assert.False(t, pm.hasSearchState)
340+
assert.Equal(t, 2, pm.fetchGeneration)
287341
}
288342

289343
func TestPaginatedSearchEscCancels(t *testing.T) {
@@ -389,6 +443,28 @@ func TestMaybeFetchSetsLoadingAndPreventsDoubleFetch(t *testing.T) {
389443
assert.Nil(t, cmd2, "should not trigger second fetch while loading")
390444
}
391445

446+
func TestPaginatedIgnoresStaleFetchMessages(t *testing.T) {
447+
m := newTestModel(t, nil, 0)
448+
m.ready = true
449+
m.viewport.Width = 80
450+
m.viewport.Height = 20
451+
m.rows = [][]string{{"search", "1"}}
452+
m.widths = []int{6, 1}
453+
m.loading = true
454+
m.fetchGeneration = 1
455+
456+
result, _ := m.Update(rowsFetchedMsg{
457+
rows: [][]string{{"stale", "2"}},
458+
exhausted: true,
459+
generation: 0,
460+
})
461+
pm := result.(paginatedModel)
462+
463+
assert.Equal(t, [][]string{{"search", "1"}}, pm.rows)
464+
assert.False(t, pm.exhausted)
465+
assert.True(t, pm.loading)
466+
}
467+
392468
func TestFetchCmdWithIterator(t *testing.T) {
393469
rows := make([][]string, 60)
394470
for i := range rows {
@@ -406,6 +482,7 @@ func TestFetchCmdWithIterator(t *testing.T) {
406482
require.True(t, ok)
407483

408484
assert.NoError(t, fetched.err)
485+
assert.Equal(t, 0, fetched.generation)
409486
assert.Len(t, fetched.rows, fetchBatchSize)
410487
assert.False(t, fetched.exhausted, "iterator should have more rows")
411488
}
@@ -422,6 +499,7 @@ func TestFetchCmdExhaustsSmallIterator(t *testing.T) {
422499
require.True(t, ok)
423500

424501
assert.NoError(t, fetched.err)
502+
assert.Equal(t, 0, fetched.generation)
425503
assert.Len(t, fetched.rows, 2)
426504
assert.True(t, fetched.exhausted, "small iterator should be exhausted")
427505
}

0 commit comments

Comments
 (0)