Skip to content

Commit 9ff5d92

Browse files
authored
Merge pull request #568 from Chris0Jeky/fix/510-board-list-polling
fix: throttle board list polling (#510)
2 parents 0abdd01 + d60bb6f commit 9ff5d92

File tree

4 files changed

+131
-14
lines changed

4 files changed

+131
-14
lines changed

frontend/taskdeck-web/src/composables/useBoardRealtime.ts

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,12 @@ import { getToken } from '../utils/tokenStorage'
1111
const BOARD_MUTATION_EVENT = 'boardMutation'
1212
const BOARD_PRESENCE_EVENT = 'boardPresence'
1313
const RECONNECT_DELAYS_MS = [0, 2000, 5000, 10000]
14-
const FALLBACK_POLL_INTERVAL_MS = 15000
14+
const FALLBACK_POLL_INTERVAL_MS = 30000
15+
// Coalesce rapid burst events so the board is not re-fetched on every
16+
// individual mutation when multiple events arrive in quick succession (e.g.
17+
// bulk import, automation runs). 300 ms is imperceptible to users but
18+
// prevents the ~3 req/s thrash observed with rapid SignalR event bursts.
19+
const MUTATION_DEBOUNCE_MS = 300
1520

1621
function resolveHubUrl(): string {
1722
const apiBase = import.meta.env.VITE_API_BASE_URL || 'http://localhost:5000/api'
@@ -43,6 +48,7 @@ export function createBoardRealtimeController(
4348
let editingCardId: string | null = null
4449
let fallbackTimer: ReturnType<typeof setInterval> | null = null
4550
let refreshInFlight = false
51+
let mutationDebounceTimer: ReturnType<typeof setTimeout> | null = null
4652

4753
const stopFallbackPolling = () => {
4854
if (!fallbackTimer) {
@@ -62,17 +68,36 @@ export function createBoardRealtimeController(
6268
}, FALLBACK_POLL_INTERVAL_MS)
6369
}
6470

65-
const handleBoardMutation = async (event: BoardRealtimeEvent) => {
66-
if (!subscribedBoardId || event.boardId !== subscribedBoardId || refreshInFlight) {
67-
return
71+
const cancelMutationDebounce = () => {
72+
if (mutationDebounceTimer !== null) {
73+
clearTimeout(mutationDebounceTimer)
74+
mutationDebounceTimer = null
6875
}
76+
}
6977

70-
refreshInFlight = true
71-
try {
72-
await options.fetchBoard(subscribedBoardId)
73-
} finally {
74-
refreshInFlight = false
78+
const handleBoardMutation = (event: BoardRealtimeEvent) => {
79+
if (!subscribedBoardId || event.boardId !== subscribedBoardId) {
80+
return
7581
}
82+
83+
// Debounce: cancel any pending refresh scheduled by a prior burst event.
84+
cancelMutationDebounce()
85+
86+
mutationDebounceTimer = setTimeout(() => {
87+
mutationDebounceTimer = null
88+
89+
// Skip if a refresh is already in-flight (started by a previous debounced
90+
// call that hasn't resolved yet).
91+
if (refreshInFlight || !subscribedBoardId) {
92+
return
93+
}
94+
95+
const boardId = subscribedBoardId
96+
refreshInFlight = true
97+
void options.fetchBoard(boardId).finally(() => {
98+
refreshInFlight = false
99+
})
100+
}, MUTATION_DEBOUNCE_MS)
76101
}
77102

78103
const handleBoardPresence = (snapshot: BoardPresenceSnapshot) => {
@@ -124,6 +149,10 @@ export function createBoardRealtimeController(
124149
}
125150

126151
const joinBoard = async (boardId: string) => {
152+
// Cancel any debounced mutation fetch from the previous board so it cannot
153+
// fire against the newly-subscribed boardId after subscribedBoardId changes.
154+
cancelMutationDebounce()
155+
127156
const hubConnection = ensureConnection()
128157

129158
if (hubConnection.state === HubConnectionState.Disconnected) {
@@ -166,6 +195,7 @@ export function createBoardRealtimeController(
166195

167196
const stop = async () => {
168197
stopFallbackPolling()
198+
cancelMutationDebounce()
169199
editingCardId = null
170200

171201
if (!connection) {

frontend/taskdeck-web/src/store/board/boardCrudStore.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,21 @@ import type { CreateBoardDto, UpdateBoardDto } from '../../types/board'
77
import type { BoardState } from './boardState'
88
import type { BoardHelpers } from './boardStoreHelpers'
99

10+
// Minimum gap between board-list fetches. Multiple views (BoardsListView,
11+
// ActivityView, ReviewView, etc.) can call fetchBoards on mount in quick
12+
// succession; the throttle guard prevents duplicate network round-trips.
13+
const FETCH_BOARDS_THROTTLE_MS = 5000
14+
1015
export function createBoardCrudActions(state: BoardState, helpers: BoardHelpers) {
16+
let lastFetchBoardsAt = 0
17+
1118
async function fetchBoards(search?: string, includeArchived = false) {
19+
const now = Date.now()
20+
// Allow forced refreshes (search/archive filter changes) to bypass throttle.
21+
const isFilteredRequest = !!search || includeArchived
22+
if (!isFilteredRequest && now - lastFetchBoardsAt < FETCH_BOARDS_THROTTLE_MS) {
23+
return
24+
}
1225
if (helpers.isDemoMode) {
1326
state.loading.value = true
1427
state.error.value = null
@@ -21,6 +34,7 @@ export function createBoardCrudActions(state: BoardState, helpers: BoardHelpers)
2134
state.loading.value = true
2235
state.error.value = null
2336
const freshBoards = await boardsApi.getBoards(search, includeArchived)
37+
lastFetchBoardsAt = Date.now()
2438
state.boards.value = freshBoards
2539

2640
// Preserve selection guard: only update activeBoardId if there is no

frontend/taskdeck-web/src/tests/composables/useBoardRealtime.spec.ts

Lines changed: 63 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -120,23 +120,57 @@ describe('createBoardRealtimeController', () => {
120120
})
121121

122122
it('refreshes board when matching board mutation event arrives', async () => {
123+
vi.useFakeTimers()
123124
const fetchBoard = vi.fn(async () => undefined)
124125
const controller = createBoardRealtimeController({ fetchBoard })
125126

126127
await controller.start('board-1')
127-
await callbacks.boardMutation?.({ boardId: 'board-1' })
128+
callbacks.boardMutation?.({ boardId: 'board-1' })
129+
130+
// The handler debounces the refresh — advance past the debounce window.
131+
await vi.advanceTimersByTimeAsync(300)
128132

129133
expect(fetchBoard).toHaveBeenCalledWith('board-1')
134+
vi.useRealTimers()
130135
})
131136

132137
it('ignores mutation events for other boards', async () => {
138+
vi.useFakeTimers()
139+
const fetchBoard = vi.fn(async () => undefined)
140+
const controller = createBoardRealtimeController({ fetchBoard })
141+
142+
await controller.start('board-1')
143+
callbacks.boardMutation?.({ boardId: 'board-2' })
144+
145+
// Advance past the debounce window to confirm nothing fires.
146+
await vi.advanceTimersByTimeAsync(300)
147+
148+
expect(fetchBoard).not.toHaveBeenCalled()
149+
vi.useRealTimers()
150+
})
151+
152+
it('coalesces rapid burst mutation events into a single fetchBoard call', async () => {
153+
vi.useFakeTimers()
133154
const fetchBoard = vi.fn(async () => undefined)
134155
const controller = createBoardRealtimeController({ fetchBoard })
135156

136157
await controller.start('board-1')
137-
await callbacks.boardMutation?.({ boardId: 'board-2' })
138158

159+
// Fire three mutation events in rapid succession (within the debounce window).
160+
callbacks.boardMutation?.({ boardId: 'board-1' })
161+
callbacks.boardMutation?.({ boardId: 'board-1' })
162+
callbacks.boardMutation?.({ boardId: 'board-1' })
163+
164+
// Before the debounce window closes, fetchBoard should not have been called.
139165
expect(fetchBoard).not.toHaveBeenCalled()
166+
167+
// Advance past the debounce — only one fetch should fire for the burst.
168+
await vi.advanceTimersByTimeAsync(300)
169+
expect(fetchBoard).toHaveBeenCalledTimes(1)
170+
expect(fetchBoard).toHaveBeenCalledWith('board-1')
171+
172+
vi.useRealTimers()
173+
await controller.stop()
140174
})
141175

142176
it('emits presence snapshots for the currently subscribed board', async () => {
@@ -175,6 +209,30 @@ describe('createBoardRealtimeController', () => {
175209
expect(mockConnection.invoke).toHaveBeenCalledWith('JoinBoard', 'board-2')
176210
})
177211

212+
it('cancels a pending debounce timer when switching boards', async () => {
213+
// Regression: a board-A mutation event with a debounce timer pending must
214+
// not fire fetchBoard after subscribedBoardId has advanced to board-B.
215+
vi.useFakeTimers()
216+
const fetchBoard = vi.fn(async () => undefined)
217+
const controller = createBoardRealtimeController({ fetchBoard })
218+
219+
await controller.start('board-1')
220+
221+
// A mutation event for board-1 starts the 300 ms debounce timer.
222+
callbacks.boardMutation?.({ boardId: 'board-1' })
223+
224+
// Switch to board-2 before the timer fires — must discard the pending timer.
225+
await controller.switchBoard('board-2')
226+
227+
// Advance past the original debounce window.
228+
await vi.advanceTimersByTimeAsync(300)
229+
230+
// fetchBoard must not have been called at all; the stale timer was cancelled.
231+
expect(fetchBoard).not.toHaveBeenCalled()
232+
233+
await controller.stop()
234+
})
235+
178236
it('falls back to polling when websocket connection cannot start', async () => {
179237
vi.useFakeTimers()
180238
const fetchBoard = vi.fn(async () => undefined)
@@ -183,7 +241,7 @@ describe('createBoardRealtimeController', () => {
183241
const controller = createBoardRealtimeController({ fetchBoard })
184242
await controller.start('board-1')
185243

186-
await vi.advanceTimersByTimeAsync(15000)
244+
await vi.advanceTimersByTimeAsync(30000)
187245
expect(fetchBoard).toHaveBeenCalledWith('board-1')
188246

189247
await controller.stop()
@@ -207,15 +265,15 @@ describe('createBoardRealtimeController', () => {
207265
await controller.start('board-1')
208266
await controller.setEditingCard('card-1')
209267
await callbacks.reconnecting?.()
210-
await vi.advanceTimersByTimeAsync(15000)
268+
await vi.advanceTimersByTimeAsync(30000)
211269
expect(fetchBoard).toHaveBeenCalledWith('board-1')
212270

213271
fetchBoard.mockClear()
214272
await callbacks.reconnected?.()
215273
expect(mockConnection.invoke).toHaveBeenCalledWith('JoinBoard', 'board-1')
216274
expect(mockConnection.invoke).toHaveBeenCalledWith('SetEditingCard', 'board-1', 'card-1')
217275

218-
await vi.advanceTimersByTimeAsync(15000)
276+
await vi.advanceTimersByTimeAsync(30000)
219277
expect(fetchBoard).not.toHaveBeenCalled()
220278

221279
await controller.stop()

frontend/taskdeck-web/src/tests/store/boardStore.spec.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -729,6 +729,7 @@ describe('boardStore', () => {
729729
})
730730

731731
it('preserves activeBoardId across fetchBoards when selected board still exists', async () => {
732+
vi.useFakeTimers()
732733
// First load — sets selection to boardA (first item)
733734
vi.mocked(boardsApi.getBoards).mockResolvedValue([boardA, boardB])
734735
await store.fetchBoards()
@@ -737,35 +738,49 @@ describe('boardStore', () => {
737738
// User selects boardB
738739
store.activeBoardId = 'board-b'
739740

741+
// Advance past throttle window so the next fetchBoards is not suppressed.
742+
vi.advanceTimersByTime(6000)
743+
740744
// Poll cycle returns boards in a different order — boardB is still present
741745
vi.mocked(boardsApi.getBoards).mockResolvedValue([boardB, boardA])
742746
await store.fetchBoards()
743747

744748
// Selection must NOT flip back to boardB (now first) or boardA
745749
expect(store.activeBoardId).toBe('board-b')
750+
vi.useRealTimers()
746751
})
747752

748753
it('falls back to first board when the selected board is removed from the list', async () => {
754+
vi.useFakeTimers()
749755
vi.mocked(boardsApi.getBoards).mockResolvedValue([boardA, boardB])
750756
await store.fetchBoards()
751757
store.activeBoardId = 'board-b'
752758

759+
// Advance past throttle window so the next fetchBoards is not suppressed.
760+
vi.advanceTimersByTime(6000)
761+
753762
// boardB has been deleted on the server
754763
vi.mocked(boardsApi.getBoards).mockResolvedValue([boardA])
755764
await store.fetchBoards()
756765

757766
expect(store.activeBoardId).toBe('board-a')
767+
vi.useRealTimers()
758768
})
759769

760770
it('sets activeBoardId to null when no boards remain after refresh', async () => {
771+
vi.useFakeTimers()
761772
vi.mocked(boardsApi.getBoards).mockResolvedValue([boardA])
762773
await store.fetchBoards()
763774
store.activeBoardId = 'board-a'
764775

776+
// Advance past throttle window so the next fetchBoards is not suppressed.
777+
vi.advanceTimersByTime(6000)
778+
765779
vi.mocked(boardsApi.getBoards).mockResolvedValue([])
766780
await store.fetchBoards()
767781

768782
expect(store.activeBoardId).toBeNull()
783+
vi.useRealTimers()
769784
})
770785

771786
it('clears activeBoardId when the active board is deleted', async () => {

0 commit comments

Comments
 (0)