From 011fd973474cbc08abcd063f7d99e747133f988b Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Mon, 21 Sep 2026 12:02:49 +0100 Subject: [PATCH 01/11] fix(realtime): catch up missed board events after rejoin --- .../src/composables/useBoardRealtime.ts | 40 +++- .../composables/boardRealtimeRecovery.spec.ts | 187 ++++++++++++++++++ .../composables/useBoardRealtime.spec.ts | 5 +- 3 files changed, 223 insertions(+), 9 deletions(-) create mode 100644 frontend/taskdeck-web/src/tests/composables/boardRealtimeRecovery.spec.ts diff --git a/frontend/taskdeck-web/src/composables/useBoardRealtime.ts b/frontend/taskdeck-web/src/composables/useBoardRealtime.ts index c5036b58be..de4b643c90 100644 --- a/frontend/taskdeck-web/src/composables/useBoardRealtime.ts +++ b/frontend/taskdeck-web/src/composables/useBoardRealtime.ts @@ -170,17 +170,38 @@ export function createBoardRealtimeController( } }) hubConnection.onreconnected(async () => { - stopFallbackPolling() const boardId = requestedBoardId const generation = subscriptionGeneration - if (boardId) { + const isCurrentRequest = () => + connection === hubConnection && + requestedBoardId === boardId && + subscriptionGeneration === generation + if (!boardId) return + + // Transport recovery alone does not prove a board subscription. Keep + // polling until JoinBoard acknowledges this request's generation. + try { await queueBoardSubscription(boardId, generation) - if ( - editingCardId !== null && - requestedBoardId === boardId && - subscriptionGeneration === generation - ) { + } catch (error) { + if (isCurrentRequest()) { + logWarn('SignalR board rejoin failed, retaining polling fallback.', error) + startFallbackPolling(boardId) + } + return + } + if (!isCurrentRequest() || hubConnection.state !== HubConnectionState.Connected) return + + // Events lost while disconnected are not replayed by a new subscription. + // Reuse the mutation coordinator so an older pending read retains one + // follow-up instead of swallowing the catch-up or starting parallel reads. + startMutationRefresh(boardId) + if (editingCardId !== null) { + try { await hubConnection.invoke('SetEditingCard', boardId, editingCardId) + } catch (error) { + if (isCurrentRequest()) { + logWarn('SignalR editing presence could not be restored.', error) + } } } }) @@ -239,8 +260,11 @@ export function createBoardRealtimeController( } await hubConnection.invoke('JoinBoard', boardId) + if (connection !== hubConnection) return subscribedBoardId = boardId - stopFallbackPolling() + if (isCurrentRequest() && hubConnection.state === HubConnectionState.Connected) { + stopFallbackPolling() + } } const queueBoardSubscription = (boardId: string, generation: number) => { diff --git a/frontend/taskdeck-web/src/tests/composables/boardRealtimeRecovery.spec.ts b/frontend/taskdeck-web/src/tests/composables/boardRealtimeRecovery.spec.ts new file mode 100644 index 0000000000..436251637a --- /dev/null +++ b/frontend/taskdeck-web/src/tests/composables/boardRealtimeRecovery.spec.ts @@ -0,0 +1,187 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createBoardRealtimeController } from '../../composables/useBoardRealtime' + +const hub = vi.hoisted(() => ({ + lifecycle: {} as Record Promise | void>, + events: {} as Record void>, + state: 'Disconnected', + start: vi.fn<() => Promise>(), + stop: vi.fn<() => Promise>(), + invoke: vi.fn<(method: string, boardId: string, cardId?: string | null) => Promise>(), +})) +vi.mock('../../utils/demoMode', () => ({ isDemoMode: false })) +vi.mock('../../utils/errorReporting', () => ({ logWarn: vi.fn() })) +vi.mock('@microsoft/signalr', () => ({ + HubConnectionState: { Connected: 'Connected', Disconnected: 'Disconnected' }, + HttpTransportType: { WebSockets: 1 }, + LogLevel: { Warning: 3 }, + HubConnectionBuilder: class { + withUrl() { return this } + withAutomaticReconnect() { return this } + configureLogging() { return this } + build() { + return { + get state() { return hub.state }, + start: hub.start, + stop: hub.stop, + invoke: hub.invoke, + on: (name: string, callback: (event: { boardId: string }) => void) => { hub.events[name] = callback }, + onreconnecting: (callback: () => Promise | void) => { hub.lifecycle.reconnecting = callback }, + onreconnected: (callback: () => Promise | void) => { hub.lifecycle.reconnected = callback }, + onclose: (callback: () => Promise | void) => { hub.lifecycle.close = callback }, + } + } + }, +})) + +function deferred() { + let resolve!: () => void + const promise = new Promise((done) => { resolve = done }) + return { promise, resolve } +} + +async function disconnect() { + hub.state = 'Reconnecting' + await hub.lifecycle.reconnecting!() +} + +function reconnect() { + hub.state = 'Connected' + return hub.lifecycle.reconnected!() +} + +describe('board realtime recovery (#3319)', () => { + let controller: ReturnType + + beforeEach(() => { + vi.useFakeTimers() + hub.lifecycle = {} + hub.events = {} + hub.state = 'Disconnected' + hub.start.mockReset().mockImplementation(async () => { hub.state = 'Connected' }) + hub.stop.mockReset().mockImplementation(async () => { hub.state = 'Disconnected' }) + hub.invoke.mockReset().mockResolvedValue(undefined) + }) + + afterEach(async () => { + await controller?.stop() + vi.useRealTimers() + }) + + it('catches up after a disconnect shorter than the fallback interval', async () => { + const fetchBoard = vi.fn(async () => undefined) + controller = createBoardRealtimeController({ fetchBoard }) + await controller.start('board-a') + await disconnect() + await vi.advanceTimersByTimeAsync(1000) + expect(fetchBoard).not.toHaveBeenCalled() + await reconnect() + expect(fetchBoard).toHaveBeenCalledExactlyOnceWith('board-a', { intent: 'background' }) + await vi.advanceTimersByTimeAsync(30000) + expect(fetchBoard).toHaveBeenCalledTimes(1) + }) + + it('retains polling and contains a failed rejoin without blindly retrying JoinBoard', async () => { + const fetchBoard = vi.fn(async () => undefined) + controller = createBoardRealtimeController({ fetchBoard }) + await controller.start('board-a') + await disconnect() + hub.invoke.mockClear().mockImplementation(async (method) => { + if (method === 'JoinBoard') throw new Error('synthetic join failure') + }) + await expect(reconnect()).resolves.toBeUndefined() + expect(fetchBoard).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(30000) + expect(fetchBoard).toHaveBeenCalledExactlyOnceWith('board-a', { intent: 'background' }) + expect(hub.invoke.mock.calls.filter(([method]) => method === 'JoinBoard')).toHaveLength(1) + }) + + it('does not let optional presence restoration failure suppress catch-up', async () => { + const fetchBoard = vi.fn(async () => undefined) + controller = createBoardRealtimeController({ fetchBoard }) + await controller.start('board-a') + await controller.setEditingCard('card-a') + await disconnect() + hub.invoke.mockImplementation(async (method) => { + if (method === 'SetEditingCard') throw new Error('synthetic presence failure') + }) + await expect(reconnect()).resolves.toBeUndefined() + expect(fetchBoard).toHaveBeenCalledExactlyOnceWith('board-a', { intent: 'background' }) + await vi.advanceTimersByTimeAsync(30000) + expect(fetchBoard).toHaveBeenCalledTimes(1) + }) + + it.each(['switch', 'stop'] as const)('does not catch up an abandoned board after %s during rejoin', async (change) => { + const fetchBoard = vi.fn(async () => undefined) + controller = createBoardRealtimeController({ fetchBoard }) + await controller.start('board-a') + await disconnect() + const joined = deferred() + const started = deferred() + hub.invoke.mockImplementation(async (method, boardId) => { + if (method === 'JoinBoard' && boardId === 'board-a') { + started.resolve() + await joined.promise + } + }) + const recovery = reconnect() + await started.promise + const changed = change === 'switch' ? controller.switchBoard('board-b') : controller.stop() + joined.resolve() + await recovery + await changed + expect(fetchBoard).not.toHaveBeenCalled() + }) + + it('retains one catch-up behind an older in-flight mutation refresh', async () => { + const olderRead = deferred() + const fetchBoard = vi.fn<() => Promise>() + .mockImplementationOnce(() => olderRead.promise).mockResolvedValue(undefined) + controller = createBoardRealtimeController({ fetchBoard }) + await controller.start('board-a') + hub.events.boardMutation!({ boardId: 'board-a' }) + await vi.advanceTimersByTimeAsync(300) + expect(fetchBoard).toHaveBeenCalledTimes(1) + await disconnect() + await reconnect() + expect(fetchBoard).toHaveBeenCalledTimes(1) + olderRead.resolve() + await vi.advanceTimersByTimeAsync(0) + expect(fetchBoard).toHaveBeenCalledTimes(2) + expect(fetchBoard).toHaveBeenLastCalledWith('board-a', { intent: 'background' }) + }) + + it('contains a failed catch-up read and still responds to later mutations', async () => { + const fetchBoard = vi.fn<() => Promise>() + .mockRejectedValueOnce(new Error('synthetic read failure')).mockResolvedValue(undefined) + controller = createBoardRealtimeController({ fetchBoard }) + await controller.start('board-a') + await disconnect() + await reconnect() + await vi.advanceTimersByTimeAsync(0) + expect(fetchBoard).toHaveBeenCalledTimes(1) + hub.events.boardMutation!({ boardId: 'board-a' }) + await vi.advanceTimersByTimeAsync(300) + expect(fetchBoard).toHaveBeenCalledTimes(2) + }) + + it('does not retire fallback if another disconnect happens before rejoin settles', async () => { + const fetchBoard = vi.fn(async () => undefined) + controller = createBoardRealtimeController({ fetchBoard }) + await controller.start('board-a') + await disconnect() + const joined = deferred() + const started = deferred() + hub.invoke.mockImplementation(async (method) => { + if (method === 'JoinBoard') { started.resolve(); await joined.promise } + }) + const recovery = reconnect() + await started.promise + await disconnect() + joined.resolve() + await recovery + expect(fetchBoard).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(30000) + expect(fetchBoard).toHaveBeenCalledExactlyOnceWith('board-a', { intent: 'background' }) + }) +}) diff --git a/frontend/taskdeck-web/src/tests/composables/useBoardRealtime.spec.ts b/frontend/taskdeck-web/src/tests/composables/useBoardRealtime.spec.ts index b3f3462d7d..6ddc7e8d15 100644 --- a/frontend/taskdeck-web/src/tests/composables/useBoardRealtime.spec.ts +++ b/frontend/taskdeck-web/src/tests/composables/useBoardRealtime.spec.ts @@ -435,8 +435,11 @@ describe('createBoardRealtimeController', () => { expect(mockConnection.invoke).toHaveBeenCalledWith('JoinBoard', 'board-1') expect(mockConnection.invoke).toHaveBeenCalledWith('SetEditingCard', 'board-1', 'card-1') + // One authoritative catch-up covers events lost while disconnected; + // a successful rejoin must still retire the periodic fallback timer. + expect(fetchBoard).toHaveBeenCalledExactlyOnceWith('board-1', { intent: 'background' }) await vi.advanceTimersByTimeAsync(30000) - expect(fetchBoard).not.toHaveBeenCalled() + expect(fetchBoard).toHaveBeenCalledTimes(1) await controller.stop() }) From 78b7ed271d1569111bd5830abe370f2196fec1ae Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Mon, 21 Sep 2026 12:13:08 +0100 Subject: [PATCH 02/11] test(realtime): require catch-up while proving periodic fallback stops --- .../taskdeck-web/src/tests/resilience/degradedMode.spec.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/frontend/taskdeck-web/src/tests/resilience/degradedMode.spec.ts b/frontend/taskdeck-web/src/tests/resilience/degradedMode.spec.ts index 195f5b0aa3..02aa46f7d2 100644 --- a/frontend/taskdeck-web/src/tests/resilience/degradedMode.spec.ts +++ b/frontend/taskdeck-web/src/tests/resilience/degradedMode.spec.ts @@ -411,9 +411,11 @@ describe('useBoardRealtime — SignalR disconnect resilience', () => { await realtimeCallbacks.reconnected?.() expect(realtimeMockConnection.invoke).toHaveBeenCalledWith('JoinBoard', 'board-1') - // No more polls after reconnection + // Catch up once for mutations missed during disconnection, then prove + // periodic fallback reads stop after the acknowledged rejoin. + expect(fetchBoard).toHaveBeenCalledExactlyOnceWith('board-1', { intent: 'background' }) await vi.advanceTimersByTimeAsync(30000) - expect(fetchBoard).not.toHaveBeenCalled() + expect(fetchBoard).toHaveBeenCalledTimes(1) await controller.stop() }) From 993458005114d83c7278fc46cab81b6c14cdf19d Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Mon, 21 Sep 2026 12:26:02 +0100 Subject: [PATCH 03/11] fix(realtime): preserve recovery across fallback reads and board switches --- .../src/composables/useBoardRealtime.ts | 51 +++++++++++-------- .../composables/boardRealtimeRecovery.spec.ts | 43 ++++++++++++++-- 2 files changed, 69 insertions(+), 25 deletions(-) diff --git a/frontend/taskdeck-web/src/composables/useBoardRealtime.ts b/frontend/taskdeck-web/src/composables/useBoardRealtime.ts index de4b643c90..9ce6b8fb8b 100644 --- a/frontend/taskdeck-web/src/composables/useBoardRealtime.ts +++ b/frontend/taskdeck-web/src/composables/useBoardRealtime.ts @@ -53,8 +53,9 @@ export function createBoardRealtimeController( let subscriptionTransition: Promise = Promise.resolve() let editingCardId: string | null = null let fallbackTimer: ReturnType | null = null + let recoveryPending = false let refreshInFlight = false - let pendingMutationRefreshBoardId: string | null = null + let pendingRefreshBoardId: string | null = null let mutationDebounceTimer: ReturnType | null = null const stopFallbackPolling = () => { @@ -67,15 +68,16 @@ export function createBoardRealtimeController( } const startFallbackPolling = (boardId: string) => { + recoveryPending = true stopFallbackPolling() fallbackTimer = setInterval(() => { if (requestedBoardId !== boardId) { return } - void options.fetchBoard(boardId, { intent: 'background' }).catch(() => { - // Keep fallback resilient; fetch failures are already surfaced by store-level handling. - }) + // Polling shares the same active/read-after-active slot as mutation + // and recovery reads. Store-level deduplication must not swallow catch-up. + startBoardRefresh(boardId) }, FALLBACK_POLL_INTERVAL_MS) } @@ -86,13 +88,15 @@ export function createBoardRealtimeController( } } - const startMutationRefresh = (boardId: string) => { - if (subscribedBoardId !== boardId || requestedBoardId !== boardId) { + const startBoardRefresh = (boardId: string) => { + // Fallback must also read a requested board whose hub join is pending. + // Event handlers separately verify the confirmed subscription. + if (requestedBoardId !== boardId) { return } if (refreshInFlight) { - pendingMutationRefreshBoardId = boardId + pendingRefreshBoardId = boardId return } @@ -104,11 +108,11 @@ export function createBoardRealtimeController( }) .finally(() => { refreshInFlight = false - const pendingBoardId = pendingMutationRefreshBoardId - pendingMutationRefreshBoardId = null + const pendingBoardId = pendingRefreshBoardId + pendingRefreshBoardId = null if (pendingBoardId) { - startMutationRefresh(pendingBoardId) + startBoardRefresh(pendingBoardId) } }) } @@ -132,7 +136,7 @@ export function createBoardRealtimeController( return } - startMutationRefresh(subscribedBoardId) + startBoardRefresh(subscribedBoardId) }, MUTATION_DEBOUNCE_MS) } @@ -165,7 +169,7 @@ export function createBoardRealtimeController( hubConnection.on(BOARD_MUTATION_EVENT, handleBoardMutation) hubConnection.on(BOARD_PRESENCE_EVENT, handleBoardPresence) hubConnection.onreconnecting(() => { - if (requestedBoardId) { + if (connection === hubConnection && requestedBoardId) { startFallbackPolling(requestedBoardId) } }) @@ -176,7 +180,8 @@ export function createBoardRealtimeController( connection === hubConnection && requestedBoardId === boardId && subscriptionGeneration === generation - if (!boardId) return + if (!boardId || connection !== hubConnection) return + recoveryPending = true // Transport recovery alone does not prove a board subscription. Keep // polling until JoinBoard acknowledges this request's generation. @@ -191,10 +196,8 @@ export function createBoardRealtimeController( } if (!isCurrentRequest() || hubConnection.state !== HubConnectionState.Connected) return - // Events lost while disconnected are not replayed by a new subscription. - // Reuse the mutation coordinator so an older pending read retains one - // follow-up instead of swallowing the catch-up or starting parallel reads. - startMutationRefresh(boardId) + // joinBoard owns catch-up so navigation during this await transfers + // the recovery obligation to the latest queued board. if (editingCardId !== null) { try { await hubConnection.invoke('SetEditingCard', boardId, editingCardId) @@ -206,7 +209,7 @@ export function createBoardRealtimeController( } }) hubConnection.onclose(() => { - if (requestedBoardId) { + if (connection === hubConnection && requestedBoardId) { startFallbackPolling(requestedBoardId) } }) @@ -264,6 +267,10 @@ export function createBoardRealtimeController( subscribedBoardId = boardId if (isCurrentRequest() && hubConnection.state === HubConnectionState.Connected) { stopFallbackPolling() + if (recoveryPending) { + recoveryPending = false + startBoardRefresh(boardId) + } } } @@ -282,7 +289,10 @@ export function createBoardRealtimeController( // Cancel any debounced mutation fetch from the previous board as soon as // navigation changes intent, rather than waiting for the connection move. cancelMutationDebounce() - pendingMutationRefreshBoardId = null + pendingRefreshBoardId = null + // A switch while the old rejoin is pending inherits both recovery and + // polling. Only this latest request's successful join may retire them. + if (recoveryPending) startFallbackPolling(boardId) return queueBoardSubscription(boardId, generation) } @@ -309,9 +319,10 @@ export function createBoardRealtimeController( const stop = async () => { requestedBoardId = null subscriptionGeneration++ + recoveryPending = false stopFallbackPolling() cancelMutationDebounce() - pendingMutationRefreshBoardId = null + pendingRefreshBoardId = null editingCardId = null if (!connection) { diff --git a/frontend/taskdeck-web/src/tests/composables/boardRealtimeRecovery.spec.ts b/frontend/taskdeck-web/src/tests/composables/boardRealtimeRecovery.spec.ts index 436251637a..42f5db8f97 100644 --- a/frontend/taskdeck-web/src/tests/composables/boardRealtimeRecovery.spec.ts +++ b/frontend/taskdeck-web/src/tests/composables/boardRealtimeRecovery.spec.ts @@ -130,19 +130,29 @@ describe('board realtime recovery (#3319)', () => { joined.resolve() await recovery await changed - expect(fetchBoard).not.toHaveBeenCalled() + expect(fetchBoard).not.toHaveBeenCalledWith('board-a', { intent: 'background' }) + if (change === 'switch') { + expect(fetchBoard).toHaveBeenCalledExactlyOnceWith('board-b', { intent: 'background' }) + } else { + expect(fetchBoard).not.toHaveBeenCalled() + } }) - it('retains one catch-up behind an older in-flight mutation refresh', async () => { + it.each(['mutation', 'fallback'] as const)('retains one catch-up behind an older in-flight %s refresh', async (source) => { const olderRead = deferred() const fetchBoard = vi.fn<() => Promise>() .mockImplementationOnce(() => olderRead.promise).mockResolvedValue(undefined) controller = createBoardRealtimeController({ fetchBoard }) await controller.start('board-a') - hub.events.boardMutation!({ boardId: 'board-a' }) - await vi.advanceTimersByTimeAsync(300) + if (source === 'mutation') { + hub.events.boardMutation!({ boardId: 'board-a' }) + await vi.advanceTimersByTimeAsync(300) + await disconnect() + } else { + await disconnect() + await vi.advanceTimersByTimeAsync(30000) + } expect(fetchBoard).toHaveBeenCalledTimes(1) - await disconnect() await reconnect() expect(fetchBoard).toHaveBeenCalledTimes(1) olderRead.resolve() @@ -151,6 +161,29 @@ describe('board realtime recovery (#3319)', () => { expect(fetchBoard).toHaveBeenLastCalledWith('board-a', { intent: 'background' }) }) + it('keeps polling the latest board when its transferred rejoin fails', async () => { + const fetchBoard = vi.fn(async () => undefined) + controller = createBoardRealtimeController({ fetchBoard }) + await controller.start('board-a') + await disconnect() + const joined = deferred() + const started = deferred() + hub.invoke.mockImplementation(async (method, boardId) => { + if (method !== 'JoinBoard') return + if (boardId === 'board-a') { started.resolve(); await joined.promise } + else throw new Error('synthetic latest-board join failure') + }) + const recovery = reconnect() + await started.promise + const changed = controller.switchBoard('board-b').catch((error: unknown) => error) + joined.resolve() + await recovery + expect(await changed).toBeInstanceOf(Error) + expect(fetchBoard).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(30000) + expect(fetchBoard).toHaveBeenCalledExactlyOnceWith('board-b', { intent: 'background' }) + }) + it('contains a failed catch-up read and still responds to later mutations', async () => { const fetchBoard = vi.fn<() => Promise>() .mockRejectedValueOnce(new Error('synthetic read failure')).mockResolvedValue(undefined) From 6de59032b654088ee3eed504406754ef2dc08d09 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Mon, 21 Sep 2026 19:02:43 +0100 Subject: [PATCH 04/11] fix: preserve realtime recovery after active reads --- .../src/composables/useBoardRealtime.ts | 53 ++++++++++++++++--- .../src/store/board/boardCrudStore.ts | 8 ++- .../composables/boardRealtimeRecovery.spec.ts | 38 +++++++++++-- .../tests/store/board/boardCrudStore.spec.ts | 37 +++++++++++++ frontend/taskdeck-web/src/views/BoardView.vue | 6 ++- 5 files changed, 130 insertions(+), 12 deletions(-) diff --git a/frontend/taskdeck-web/src/composables/useBoardRealtime.ts b/frontend/taskdeck-web/src/composables/useBoardRealtime.ts index 9ce6b8fb8b..d492ce3cdb 100644 --- a/frontend/taskdeck-web/src/composables/useBoardRealtime.ts +++ b/frontend/taskdeck-web/src/composables/useBoardRealtime.ts @@ -32,7 +32,10 @@ function getAccessToken(): string { } export interface BoardRealtimeControllerOptions { - fetchBoard: (boardId: string, options: { intent: 'background' }) => Promise + fetchBoard: ( + boardId: string, + options: { intent: 'background'; afterActive?: boolean }, + ) => Promise onPresenceChanged?: (snapshot: BoardPresenceSnapshot) => void } @@ -56,6 +59,8 @@ export function createBoardRealtimeController( let recoveryPending = false let refreshInFlight = false let pendingRefreshBoardId: string | null = null + let pendingRefreshAfterActive = false + let pendingRecoveryRefresh = false let mutationDebounceTimer: ReturnType | null = null const stopFallbackPolling = () => { @@ -88,7 +93,11 @@ export function createBoardRealtimeController( } } - const startBoardRefresh = (boardId: string) => { + const startBoardRefresh = ( + boardId: string, + afterActive = false, + dischargesRecovery = false, + ) => { // Fallback must also read a requested board whose hub join is pending. // Event handlers separately verify the confirmed subscription. if (requestedBoardId !== boardId) { @@ -97,22 +106,48 @@ export function createBoardRealtimeController( if (refreshInFlight) { pendingRefreshBoardId = boardId + pendingRefreshAfterActive ||= afterActive + pendingRecoveryRefresh ||= dischargesRecovery return } refreshInFlight = true void options - .fetchBoard(boardId, { intent: 'background' }) + .fetchBoard(boardId, { intent: 'background', ...(afterActive ? { afterActive: true } : {}) }) + .then((committed) => { + if (!dischargesRecovery || requestedBoardId !== boardId) { + return + } + + if (committed === false) { + // A handled background failure resolves false rather than rejecting. + // Keep the recovery obligation alive and let bounded fallback polling + // retry it instead of treating the failed catch-up as complete. + recoveryPending = true + startFallbackPolling(boardId) + return + } + + recoveryPending = false + }) .catch(() => { // Background refresh failures must not escape the realtime loop. + if (dischargesRecovery && requestedBoardId === boardId) { + recoveryPending = true + startFallbackPolling(boardId) + } }) .finally(() => { refreshInFlight = false const pendingBoardId = pendingRefreshBoardId + const pendingAfterActive = pendingRefreshAfterActive + const pendingRecovery = pendingRecoveryRefresh pendingRefreshBoardId = null + pendingRefreshAfterActive = false + pendingRecoveryRefresh = false if (pendingBoardId) { - startBoardRefresh(pendingBoardId) + startBoardRefresh(pendingBoardId, pendingAfterActive, pendingRecovery) } }) } @@ -268,8 +303,10 @@ export function createBoardRealtimeController( if (isCurrentRequest() && hubConnection.state === HubConnectionState.Connected) { stopFallbackPolling() if (recoveryPending) { - recoveryPending = false - startBoardRefresh(boardId) + // Force the store to queue this read behind any active external + // background fetch. The recovery obligation is discharged only when + // this post-join read reports success. + startBoardRefresh(boardId, true, true) } } } @@ -290,6 +327,8 @@ export function createBoardRealtimeController( // navigation changes intent, rather than waiting for the connection move. cancelMutationDebounce() pendingRefreshBoardId = null + pendingRefreshAfterActive = false + pendingRecoveryRefresh = false // A switch while the old rejoin is pending inherits both recovery and // polling. Only this latest request's successful join may retire them. if (recoveryPending) startFallbackPolling(boardId) @@ -323,6 +362,8 @@ export function createBoardRealtimeController( stopFallbackPolling() cancelMutationDebounce() pendingRefreshBoardId = null + pendingRefreshAfterActive = false + pendingRecoveryRefresh = false editingCardId = null if (!connection) { diff --git a/frontend/taskdeck-web/src/store/board/boardCrudStore.ts b/frontend/taskdeck-web/src/store/board/boardCrudStore.ts index 8b070081a9..d9dc57283b 100644 --- a/frontend/taskdeck-web/src/store/board/boardCrudStore.ts +++ b/frontend/taskdeck-web/src/store/board/boardCrudStore.ts @@ -22,6 +22,12 @@ export type BoardFetchIntent = 'explicit' | 'background' export interface BoardFetchOptions { intent?: BoardFetchIntent + /** + * Queue this background refresh behind any active detail read, including + * another background read. Recovery uses it when the read must begin after + * an acknowledged realtime rejoin rather than joining a stale promise. + */ + afterActive?: boolean /** Report a failed refresh of an already committed mutation only while this read owns the context. */ backgroundFailureMessage?: string /** @@ -382,7 +388,7 @@ export function createBoardCrudActions(state: BoardState, helpers: BoardHelpers) // before it can clear the cache, then retain the flag on the successor. if (preserveCardComments) activeBoardFetch.preserveCardComments = true - if (activeBoardFetch.intent === 'explicit') { + if (activeBoardFetch.intent === 'explicit' || options.afterActive) { return queueBackgroundBoardFetch( id, options.backgroundFailureMessage, diff --git a/frontend/taskdeck-web/src/tests/composables/boardRealtimeRecovery.spec.ts b/frontend/taskdeck-web/src/tests/composables/boardRealtimeRecovery.spec.ts index 42f5db8f97..3fdf1a9128 100644 --- a/frontend/taskdeck-web/src/tests/composables/boardRealtimeRecovery.spec.ts +++ b/frontend/taskdeck-web/src/tests/composables/boardRealtimeRecovery.spec.ts @@ -76,7 +76,10 @@ describe('board realtime recovery (#3319)', () => { await vi.advanceTimersByTimeAsync(1000) expect(fetchBoard).not.toHaveBeenCalled() await reconnect() - expect(fetchBoard).toHaveBeenCalledExactlyOnceWith('board-a', { intent: 'background' }) + expect(fetchBoard).toHaveBeenCalledExactlyOnceWith('board-a', { + intent: 'background', + afterActive: true, + }) await vi.advanceTimersByTimeAsync(30000) expect(fetchBoard).toHaveBeenCalledTimes(1) }) @@ -106,7 +109,10 @@ describe('board realtime recovery (#3319)', () => { if (method === 'SetEditingCard') throw new Error('synthetic presence failure') }) await expect(reconnect()).resolves.toBeUndefined() - expect(fetchBoard).toHaveBeenCalledExactlyOnceWith('board-a', { intent: 'background' }) + expect(fetchBoard).toHaveBeenCalledExactlyOnceWith('board-a', { + intent: 'background', + afterActive: true, + }) await vi.advanceTimersByTimeAsync(30000) expect(fetchBoard).toHaveBeenCalledTimes(1) }) @@ -132,7 +138,10 @@ describe('board realtime recovery (#3319)', () => { await changed expect(fetchBoard).not.toHaveBeenCalledWith('board-a', { intent: 'background' }) if (change === 'switch') { - expect(fetchBoard).toHaveBeenCalledExactlyOnceWith('board-b', { intent: 'background' }) + expect(fetchBoard).toHaveBeenCalledExactlyOnceWith('board-b', { + intent: 'background', + afterActive: true, + }) } else { expect(fetchBoard).not.toHaveBeenCalled() } @@ -158,7 +167,10 @@ describe('board realtime recovery (#3319)', () => { olderRead.resolve() await vi.advanceTimersByTimeAsync(0) expect(fetchBoard).toHaveBeenCalledTimes(2) - expect(fetchBoard).toHaveBeenLastCalledWith('board-a', { intent: 'background' }) + expect(fetchBoard).toHaveBeenLastCalledWith('board-a', { + intent: 'background', + afterActive: true, + }) }) it('keeps polling the latest board when its transferred rejoin fails', async () => { @@ -217,4 +229,22 @@ describe('board realtime recovery (#3319)', () => { await vi.advanceTimersByTimeAsync(30000) expect(fetchBoard).toHaveBeenCalledExactlyOnceWith('board-a', { intent: 'background' }) }) + + it('retains fallback after a handled recovery failure', async () => { + const fetchBoard = vi.fn<() => Promise>() + .mockResolvedValueOnce(false) + .mockResolvedValue(true) + controller = createBoardRealtimeController({ fetchBoard }) + await controller.start('board-a') + await disconnect() + await reconnect() + await vi.advanceTimersByTimeAsync(0) + + expect(fetchBoard).toHaveBeenCalledExactlyOnceWith('board-a', { + intent: 'background', + afterActive: true, + }) + await vi.advanceTimersByTimeAsync(30000) + expect(fetchBoard).toHaveBeenCalledExactlyOnceWith('board-a', { intent: 'background' }) + }) }) diff --git a/frontend/taskdeck-web/src/tests/store/board/boardCrudStore.spec.ts b/frontend/taskdeck-web/src/tests/store/board/boardCrudStore.spec.ts index b5c9d7e3e7..40e47b13ec 100644 --- a/frontend/taskdeck-web/src/tests/store/board/boardCrudStore.spec.ts +++ b/frontend/taskdeck-web/src/tests/store/board/boardCrudStore.spec.ts @@ -1059,6 +1059,43 @@ describe('boardCrudStore', () => { expect(mockBoardsApi.getBoard).toHaveBeenCalledTimes(1) }) + it('queues a recovery background read behind an active background read', async () => { + const activeBoard = createDeferred<{ id: string; name: string; columns: [] }>() + const activeCards = createDeferred>() + const activeLabels = createDeferred>() + const successorBoard = createDeferred<{ id: string; name: string; columns: [] }>() + const successorCards = createDeferred>() + const successorLabels = createDeferred>() + mockBoardsApi.getBoard + .mockReturnValueOnce(activeBoard.promise) + .mockReturnValueOnce(successorBoard.promise) + mockCardsApi.getCards + .mockReturnValueOnce(activeCards.promise) + .mockReturnValueOnce(successorCards.promise) + mockLabelsApi.getLabels + .mockReturnValueOnce(activeLabels.promise) + .mockReturnValueOnce(successorLabels.promise) + + const { fetchBoard } = createBoardCrudActions(state as any, helpers as any) + const active = fetchBoard('board-1', { intent: 'background' }) + const recovery = fetchBoard('board-1', { intent: 'background', afterActive: true }) + + // The recovery read must wait behind the pre-existing background read, + // rather than joining its possibly stale snapshot. + expect(mockBoardsApi.getBoard).toHaveBeenCalledTimes(1) + activeBoard.resolve({ id: 'board-1', name: 'Active board', columns: [] }) + activeCards.resolve([]) + activeLabels.resolve([]) + await expect(active).resolves.toBe(true) + expect(mockBoardsApi.getBoard).toHaveBeenCalledTimes(2) + + successorBoard.resolve({ id: 'board-1', name: 'Recovered board', columns: [] }) + successorCards.resolve([]) + successorLabels.resolve([]) + await expect(recovery).resolves.toBe(true) + expect(state.currentBoard.value).toMatchObject({ name: 'Recovered board' }) + }) + it('queues one successor read when a local mutation invalidates a background refresh', async () => { const staleBoard = createDeferred<{ id: string diff --git a/frontend/taskdeck-web/src/views/BoardView.vue b/frontend/taskdeck-web/src/views/BoardView.vue index b33cebbafd..3abd83f0a1 100644 --- a/frontend/taskdeck-web/src/views/BoardView.vue +++ b/frontend/taskdeck-web/src/views/BoardView.vue @@ -129,7 +129,10 @@ const routedBoard = computed(() => boardStore.currentBoard?.id === boardId.value let viewUnmounted = false let realtimeStarted = false const realtime = createBoardRealtimeController({ - fetchBoard: async (id: string, options: { intent: 'background' }) => { + fetchBoard: async ( + id: string, + options: { intent: 'background'; afterActive?: boolean }, + ) => { if (viewUnmounted || id !== boardId.value) { return } @@ -149,6 +152,7 @@ const realtime = createBoardRealtimeController({ boardStore.error = null } } + return committed }, onPresenceChanged: (snapshot) => { if (snapshot.boardId !== boardId.value) { From 589f13ac62efc583dcd23a61acfb3949d4fa0825 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Mon, 21 Sep 2026 19:05:58 +0100 Subject: [PATCH 05/11] fix: stop fallback after recovery succeeds --- .../taskdeck-web/src/composables/useBoardRealtime.ts | 10 ++++++---- .../tests/composables/boardRealtimeRecovery.spec.ts | 2 ++ .../src/tests/store/board/boardCrudStore.spec.ts | 12 ++++++++++++ 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/frontend/taskdeck-web/src/composables/useBoardRealtime.ts b/frontend/taskdeck-web/src/composables/useBoardRealtime.ts index d492ce3cdb..c8797bc27f 100644 --- a/frontend/taskdeck-web/src/composables/useBoardRealtime.ts +++ b/frontend/taskdeck-web/src/composables/useBoardRealtime.ts @@ -72,9 +72,10 @@ export function createBoardRealtimeController( fallbackTimer = null } - const startFallbackPolling = (boardId: string) => { + const startFallbackPolling = (boardId: string, canDischargeRecovery = false) => { recoveryPending = true stopFallbackPolling() + const timerCanDischargeRecovery = canDischargeRecovery fallbackTimer = setInterval(() => { if (requestedBoardId !== boardId) { return @@ -82,7 +83,7 @@ export function createBoardRealtimeController( // Polling shares the same active/read-after-active slot as mutation // and recovery reads. Store-level deduplication must not swallow catch-up. - startBoardRefresh(boardId) + startBoardRefresh(boardId, false, timerCanDischargeRecovery) }, FALLBACK_POLL_INTERVAL_MS) } @@ -124,17 +125,18 @@ export function createBoardRealtimeController( // Keep the recovery obligation alive and let bounded fallback polling // retry it instead of treating the failed catch-up as complete. recoveryPending = true - startFallbackPolling(boardId) + startFallbackPolling(boardId, true) return } recoveryPending = false + stopFallbackPolling() }) .catch(() => { // Background refresh failures must not escape the realtime loop. if (dischargesRecovery && requestedBoardId === boardId) { recoveryPending = true - startFallbackPolling(boardId) + startFallbackPolling(boardId, true) } }) .finally(() => { diff --git a/frontend/taskdeck-web/src/tests/composables/boardRealtimeRecovery.spec.ts b/frontend/taskdeck-web/src/tests/composables/boardRealtimeRecovery.spec.ts index 3fdf1a9128..5bd24c90c3 100644 --- a/frontend/taskdeck-web/src/tests/composables/boardRealtimeRecovery.spec.ts +++ b/frontend/taskdeck-web/src/tests/composables/boardRealtimeRecovery.spec.ts @@ -246,5 +246,7 @@ describe('board realtime recovery (#3319)', () => { }) await vi.advanceTimersByTimeAsync(30000) expect(fetchBoard).toHaveBeenCalledExactlyOnceWith('board-a', { intent: 'background' }) + await vi.advanceTimersByTimeAsync(30000) + expect(fetchBoard).toHaveBeenCalledTimes(2) }) }) diff --git a/frontend/taskdeck-web/src/tests/store/board/boardCrudStore.spec.ts b/frontend/taskdeck-web/src/tests/store/board/boardCrudStore.spec.ts index 40e47b13ec..04e1e27b7f 100644 --- a/frontend/taskdeck-web/src/tests/store/board/boardCrudStore.spec.ts +++ b/frontend/taskdeck-web/src/tests/store/board/boardCrudStore.spec.ts @@ -1096,6 +1096,18 @@ describe('boardCrudStore', () => { expect(state.currentBoard.value).toMatchObject({ name: 'Recovered board' }) }) + it('starts a recovery background read immediately when no read is active', async () => { + mockBoardsApi.getBoard.mockResolvedValueOnce({ id: 'board-1', name: 'Fresh', columns: [] }) + mockCardsApi.getCards.mockResolvedValueOnce([]) + mockLabelsApi.getLabels.mockResolvedValueOnce([]) + + const { fetchBoard } = createBoardCrudActions(state as any, helpers as any) + const recovery = fetchBoard('board-1', { intent: 'background', afterActive: true }) + + expect(mockBoardsApi.getBoard).toHaveBeenCalledTimes(1) + await expect(recovery).resolves.toBe(true) + }) + it('queues one successor read when a local mutation invalidates a background refresh', async () => { const staleBoard = createDeferred<{ id: string From 4f04fc8bd73a96491d2acc1d48c2041c1aded82c Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Mon, 21 Sep 2026 19:20:50 +0100 Subject: [PATCH 06/11] test: track mutation smoke seam after recovery change --- frontend/taskdeck-web/stryker.smoke.contract.mjs | 4 ++-- scripts/ci/smart-ci/mutation-smoke-contract.test.mjs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/taskdeck-web/stryker.smoke.contract.mjs b/frontend/taskdeck-web/stryker.smoke.contract.mjs index d1109cef84..dbe013e031 100644 --- a/frontend/taskdeck-web/stryker.smoke.contract.mjs +++ b/frontend/taskdeck-web/stryker.smoke.contract.mjs @@ -19,8 +19,8 @@ export const mutationSmokeContract = Object.freeze({ schemaVersion: '1.0', file: 'src/store/board/boardCrudStore.ts', - start: Object.freeze({ line: 661, column: 28 }), - end: Object.freeze({ line: 661, column: 78 }), + start: Object.freeze({ line: 667, column: 28 }), + end: Object.freeze({ line: 667, column: 78 }), source: 'state.boards.value.filter((b) => b.id !== boardId)', }) diff --git a/scripts/ci/smart-ci/mutation-smoke-contract.test.mjs b/scripts/ci/smart-ci/mutation-smoke-contract.test.mjs index 886c911727..d6209f2045 100644 --- a/scripts/ci/smart-ci/mutation-smoke-contract.test.mjs +++ b/scripts/ci/smart-ci/mutation-smoke-contract.test.mjs @@ -70,7 +70,7 @@ test('the shared range addresses the seam in the real tracked source', async () // are 0-based while the contract's are 1-based, so a derived assertion would // pass whichever base `mutationSmokeRange` happened to use. Passing column 28 // starts the range inside the expression and drops its outermost mutant. - assert.equal(mutationSmokeRange, 'src/store/board/boardCrudStore.ts:661:27-661:77') + assert.equal(mutationSmokeRange, 'src/store/board/boardCrudStore.ts:667:27-667:77') assert.equal(mutationSmokeContract.source.length, 50) // Real payload, not a fabricated line: the contract must address the seam in From eda04b07afb8f00e46106e911bb3071615fd1088 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Mon, 21 Sep 2026 19:38:52 +0100 Subject: [PATCH 07/11] test(realtime): update recovery fetch expectations --- .../src/tests/composables/boardRealtimeRecovery.spec.ts | 2 +- .../src/tests/composables/useBoardRealtime.spec.ts | 5 ++++- .../taskdeck-web/src/tests/resilience/degradedMode.spec.ts | 5 ++++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/frontend/taskdeck-web/src/tests/composables/boardRealtimeRecovery.spec.ts b/frontend/taskdeck-web/src/tests/composables/boardRealtimeRecovery.spec.ts index 5bd24c90c3..48837f16e9 100644 --- a/frontend/taskdeck-web/src/tests/composables/boardRealtimeRecovery.spec.ts +++ b/frontend/taskdeck-web/src/tests/composables/boardRealtimeRecovery.spec.ts @@ -245,7 +245,7 @@ describe('board realtime recovery (#3319)', () => { afterActive: true, }) await vi.advanceTimersByTimeAsync(30000) - expect(fetchBoard).toHaveBeenCalledExactlyOnceWith('board-a', { intent: 'background' }) + expect(fetchBoard).toHaveBeenLastCalledWith('board-a', { intent: 'background' }) await vi.advanceTimersByTimeAsync(30000) expect(fetchBoard).toHaveBeenCalledTimes(2) }) diff --git a/frontend/taskdeck-web/src/tests/composables/useBoardRealtime.spec.ts b/frontend/taskdeck-web/src/tests/composables/useBoardRealtime.spec.ts index 6ddc7e8d15..8911cdae4a 100644 --- a/frontend/taskdeck-web/src/tests/composables/useBoardRealtime.spec.ts +++ b/frontend/taskdeck-web/src/tests/composables/useBoardRealtime.spec.ts @@ -437,7 +437,10 @@ describe('createBoardRealtimeController', () => { // One authoritative catch-up covers events lost while disconnected; // a successful rejoin must still retire the periodic fallback timer. - expect(fetchBoard).toHaveBeenCalledExactlyOnceWith('board-1', { intent: 'background' }) + expect(fetchBoard).toHaveBeenCalledExactlyOnceWith('board-1', { + intent: 'background', + afterActive: true, + }) await vi.advanceTimersByTimeAsync(30000) expect(fetchBoard).toHaveBeenCalledTimes(1) diff --git a/frontend/taskdeck-web/src/tests/resilience/degradedMode.spec.ts b/frontend/taskdeck-web/src/tests/resilience/degradedMode.spec.ts index 02aa46f7d2..9ebefae6bf 100644 --- a/frontend/taskdeck-web/src/tests/resilience/degradedMode.spec.ts +++ b/frontend/taskdeck-web/src/tests/resilience/degradedMode.spec.ts @@ -413,7 +413,10 @@ describe('useBoardRealtime — SignalR disconnect resilience', () => { // Catch up once for mutations missed during disconnection, then prove // periodic fallback reads stop after the acknowledged rejoin. - expect(fetchBoard).toHaveBeenCalledExactlyOnceWith('board-1', { intent: 'background' }) + expect(fetchBoard).toHaveBeenCalledExactlyOnceWith('board-1', { + intent: 'background', + afterActive: true, + }) await vi.advanceTimersByTimeAsync(30000) expect(fetchBoard).toHaveBeenCalledTimes(1) From 1e9029cd07f4feecf6e59640604f1ddc46bd9fb0 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Mon, 21 Sep 2026 21:39:23 +0100 Subject: [PATCH 08/11] fix(realtime): scope reconnect recovery completion --- .../src/composables/useBoardRealtime.ts | 79 ++++++++++++++----- .../composables/boardRealtimeRecovery.spec.ts | 42 ++++++++++ .../src/tests/views/BoardView.spec.ts | 4 +- frontend/taskdeck-web/src/views/BoardView.vue | 2 +- 4 files changed, 106 insertions(+), 21 deletions(-) diff --git a/frontend/taskdeck-web/src/composables/useBoardRealtime.ts b/frontend/taskdeck-web/src/composables/useBoardRealtime.ts index c8797bc27f..063a1d60ea 100644 --- a/frontend/taskdeck-web/src/composables/useBoardRealtime.ts +++ b/frontend/taskdeck-web/src/composables/useBoardRealtime.ts @@ -57,10 +57,12 @@ export function createBoardRealtimeController( let editingCardId: string | null = null let fallbackTimer: ReturnType | null = null let recoveryPending = false + let recoveryGeneration = 0 let refreshInFlight = false let pendingRefreshBoardId: string | null = null let pendingRefreshAfterActive = false let pendingRecoveryRefresh = false + let pendingRecoveryGeneration: number | null = null let mutationDebounceTimer: ReturnType | null = null const stopFallbackPolling = () => { @@ -72,10 +74,15 @@ export function createBoardRealtimeController( fallbackTimer = null } - const startFallbackPolling = (boardId: string, canDischargeRecovery = false) => { + const startFallbackPolling = ( + boardId: string, + canDischargeRecovery = false, + recoveryGenerationForRefresh = recoveryGeneration, + ) => { recoveryPending = true stopFallbackPolling() const timerCanDischargeRecovery = canDischargeRecovery + const timerRecoveryGeneration = recoveryGenerationForRefresh fallbackTimer = setInterval(() => { if (requestedBoardId !== boardId) { return @@ -83,7 +90,7 @@ export function createBoardRealtimeController( // Polling shares the same active/read-after-active slot as mutation // and recovery reads. Store-level deduplication must not swallow catch-up. - startBoardRefresh(boardId, false, timerCanDischargeRecovery) + startBoardRefresh(boardId, false, timerCanDischargeRecovery, timerRecoveryGeneration) }, FALLBACK_POLL_INTERVAL_MS) } @@ -98,6 +105,7 @@ export function createBoardRealtimeController( boardId: string, afterActive = false, dischargesRecovery = false, + recoveryGenerationForRefresh = recoveryGeneration, ) => { // Fallback must also read a requested board whose hub join is pending. // Event handlers separately verify the confirmed subscription. @@ -108,7 +116,10 @@ export function createBoardRealtimeController( if (refreshInFlight) { pendingRefreshBoardId = boardId pendingRefreshAfterActive ||= afterActive - pendingRecoveryRefresh ||= dischargesRecovery + if (dischargesRecovery) { + pendingRecoveryRefresh = true + pendingRecoveryGeneration = recoveryGenerationForRefresh + } return } @@ -116,7 +127,11 @@ export function createBoardRealtimeController( void options .fetchBoard(boardId, { intent: 'background', ...(afterActive ? { afterActive: true } : {}) }) .then((committed) => { - if (!dischargesRecovery || requestedBoardId !== boardId) { + if ( + !dischargesRecovery || + requestedBoardId !== boardId || + recoveryGeneration !== recoveryGenerationForRefresh + ) { return } @@ -125,7 +140,7 @@ export function createBoardRealtimeController( // Keep the recovery obligation alive and let bounded fallback polling // retry it instead of treating the failed catch-up as complete. recoveryPending = true - startFallbackPolling(boardId, true) + startFallbackPolling(boardId, true, recoveryGenerationForRefresh) return } @@ -134,9 +149,13 @@ export function createBoardRealtimeController( }) .catch(() => { // Background refresh failures must not escape the realtime loop. - if (dischargesRecovery && requestedBoardId === boardId) { + if ( + dischargesRecovery && + requestedBoardId === boardId && + recoveryGeneration === recoveryGenerationForRefresh + ) { recoveryPending = true - startFallbackPolling(boardId, true) + startFallbackPolling(boardId, true, recoveryGenerationForRefresh) } }) .finally(() => { @@ -144,12 +163,19 @@ export function createBoardRealtimeController( const pendingBoardId = pendingRefreshBoardId const pendingAfterActive = pendingRefreshAfterActive const pendingRecovery = pendingRecoveryRefresh + const pendingRecoveryGenerationValue = pendingRecoveryGeneration pendingRefreshBoardId = null pendingRefreshAfterActive = false pendingRecoveryRefresh = false + pendingRecoveryGeneration = null if (pendingBoardId) { - startBoardRefresh(pendingBoardId, pendingAfterActive, pendingRecovery) + startBoardRefresh( + pendingBoardId, + pendingAfterActive, + pendingRecovery, + pendingRecoveryGenerationValue ?? recoveryGeneration, + ) } }) } @@ -207,12 +233,14 @@ export function createBoardRealtimeController( hubConnection.on(BOARD_PRESENCE_EVENT, handleBoardPresence) hubConnection.onreconnecting(() => { if (connection === hubConnection && requestedBoardId) { - startFallbackPolling(requestedBoardId) + recoveryGeneration += 1 + startFallbackPolling(requestedBoardId, false, recoveryGeneration) } }) hubConnection.onreconnected(async () => { const boardId = requestedBoardId const generation = subscriptionGeneration + const recoveryGenerationAtStart = recoveryGeneration const isCurrentRequest = () => connection === hubConnection && requestedBoardId === boardId && @@ -223,7 +251,7 @@ export function createBoardRealtimeController( // Transport recovery alone does not prove a board subscription. Keep // polling until JoinBoard acknowledges this request's generation. try { - await queueBoardSubscription(boardId, generation) + await queueBoardSubscription(boardId, generation, recoveryGenerationAtStart) } catch (error) { if (isCurrentRequest()) { logWarn('SignalR board rejoin failed, retaining polling fallback.', error) @@ -247,7 +275,8 @@ export function createBoardRealtimeController( }) hubConnection.onclose(() => { if (connection === hubConnection && requestedBoardId) { - startFallbackPolling(requestedBoardId) + recoveryGeneration += 1 + startFallbackPolling(requestedBoardId, false, recoveryGeneration) } }) @@ -255,7 +284,11 @@ export function createBoardRealtimeController( return hubConnection } - const joinBoard = async (boardId: string, generation: number) => { + const joinBoard = async ( + boardId: string, + generation: number, + recoveryGenerationForJoin = recoveryGeneration, + ) => { const hubConnection = ensureConnection() const isCurrentRequest = () => requestedBoardId === boardId && subscriptionGeneration === generation @@ -303,20 +336,27 @@ export function createBoardRealtimeController( if (connection !== hubConnection) return subscribedBoardId = boardId if (isCurrentRequest() && hubConnection.state === HubConnectionState.Connected) { - stopFallbackPolling() - if (recoveryPending) { + const ownsRecovery = recoveryPending && recoveryGenerationForJoin === recoveryGeneration + if (!recoveryPending || ownsRecovery) { + stopFallbackPolling() + } + if (ownsRecovery) { // Force the store to queue this read behind any active external // background fetch. The recovery obligation is discharged only when // this post-join read reports success. - startBoardRefresh(boardId, true, true) + startBoardRefresh(boardId, true, true, recoveryGenerationForJoin) } } } - const queueBoardSubscription = (boardId: string, generation: number) => { + const queueBoardSubscription = ( + boardId: string, + generation: number, + recoveryGenerationForJoin = recoveryGeneration, + ) => { const transition = subscriptionTransition .catch(() => undefined) - .then(() => joinBoard(boardId, generation)) + .then(() => joinBoard(boardId, generation, recoveryGenerationForJoin)) subscriptionTransition = transition return transition } @@ -331,10 +371,11 @@ export function createBoardRealtimeController( pendingRefreshBoardId = null pendingRefreshAfterActive = false pendingRecoveryRefresh = false + pendingRecoveryGeneration = null // A switch while the old rejoin is pending inherits both recovery and // polling. Only this latest request's successful join may retire them. if (recoveryPending) startFallbackPolling(boardId) - return queueBoardSubscription(boardId, generation) + return queueBoardSubscription(boardId, generation, recoveryGeneration) } const start = async (boardId: string) => { @@ -360,12 +401,14 @@ export function createBoardRealtimeController( const stop = async () => { requestedBoardId = null subscriptionGeneration++ + recoveryGeneration += 1 recoveryPending = false stopFallbackPolling() cancelMutationDebounce() pendingRefreshBoardId = null pendingRefreshAfterActive = false pendingRecoveryRefresh = false + pendingRecoveryGeneration = null editingCardId = null if (!connection) { diff --git a/frontend/taskdeck-web/src/tests/composables/boardRealtimeRecovery.spec.ts b/frontend/taskdeck-web/src/tests/composables/boardRealtimeRecovery.spec.ts index 48837f16e9..11b598ace9 100644 --- a/frontend/taskdeck-web/src/tests/composables/boardRealtimeRecovery.spec.ts +++ b/frontend/taskdeck-web/src/tests/composables/boardRealtimeRecovery.spec.ts @@ -230,6 +230,48 @@ describe('board realtime recovery (#3319)', () => { expect(fetchBoard).toHaveBeenCalledExactlyOnceWith('board-a', { intent: 'background' }) }) + it('does not let an older catch-up discharge a newer reconnect recovery', async () => { + const firstCatchUp = deferred() + const secondJoin = deferred() + let joinCount = 0 + const fetchBoard = vi.fn<() => Promise>() + .mockImplementationOnce(() => firstCatchUp.promise) + .mockResolvedValue(undefined) + controller = createBoardRealtimeController({ fetchBoard }) + await controller.start('board-a') + await disconnect() + + hub.invoke.mockImplementation(async (method) => { + if (method === 'JoinBoard') { + joinCount += 1 + if (joinCount === 2) await secondJoin.promise + } + }) + + await reconnect() + expect(fetchBoard).toHaveBeenCalledExactlyOnceWith('board-a', { + intent: 'background', + afterActive: true, + }) + + await disconnect() + const newerRecovery = reconnect() + await vi.waitFor(() => expect(joinCount).toBe(2)) + + firstCatchUp.resolve() + await vi.advanceTimersByTimeAsync(0) + expect(fetchBoard).toHaveBeenCalledTimes(1) + + secondJoin.resolve() + await newerRecovery + await vi.advanceTimersByTimeAsync(0) + expect(fetchBoard).toHaveBeenCalledTimes(2) + expect(fetchBoard).toHaveBeenLastCalledWith('board-a', { + intent: 'background', + afterActive: true, + }) + }) + it('retains fallback after a handled recovery failure', async () => { const fetchBoard = vi.fn<() => Promise>() .mockResolvedValueOnce(false) diff --git a/frontend/taskdeck-web/src/tests/views/BoardView.spec.ts b/frontend/taskdeck-web/src/tests/views/BoardView.spec.ts index 9746ce2c2c..3d51a39d09 100644 --- a/frontend/taskdeck-web/src/tests/views/BoardView.spec.ts +++ b/frontend/taskdeck-web/src/tests/views/BoardView.spec.ts @@ -69,7 +69,7 @@ const realtimeMock = { // simulate incoming SignalR presence snapshots. let capturedOnPresenceChanged: ((snapshot: BoardPresenceSnapshot) => void) | undefined let capturedRealtimeFetchBoard: - | ((boardId: string, options: { intent: 'background' }) => Promise) + | ((boardId: string, options: { intent: 'background'; afterActive?: boolean }) => Promise) | undefined const mockBoardStore = reactive({ @@ -971,7 +971,7 @@ describe('BoardView', () => { expect(mockBoardStore.fetchBoard).toHaveBeenNthCalledWith(2, 'board-2') expect(capturedRealtimeFetchBoard).toBeDefined() - await capturedRealtimeFetchBoard!('board-1', { intent: 'background' }) + await expect(capturedRealtimeFetchBoard!('board-1', { intent: 'background' })).resolves.toBe(false) expect(mockBoardStore.fetchBoard).toHaveBeenCalledTimes(2) boardBLoad.resolve(true) diff --git a/frontend/taskdeck-web/src/views/BoardView.vue b/frontend/taskdeck-web/src/views/BoardView.vue index 3abd83f0a1..f257cf469b 100644 --- a/frontend/taskdeck-web/src/views/BoardView.vue +++ b/frontend/taskdeck-web/src/views/BoardView.vue @@ -134,7 +134,7 @@ const realtime = createBoardRealtimeController({ options: { intent: 'background'; afterActive?: boolean }, ) => { if (viewUnmounted || id !== boardId.value) { - return + return false } const boardLoadErrorAtStart = boardLoadError.value From 90d41eaf1fe0069fda116e72925a5f65044cb5ae Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Mon, 21 Sep 2026 21:41:30 +0100 Subject: [PATCH 09/11] fix(realtime): retarget queued recovery generation --- .../src/composables/useBoardRealtime.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/frontend/taskdeck-web/src/composables/useBoardRealtime.ts b/frontend/taskdeck-web/src/composables/useBoardRealtime.ts index 063a1d60ea..909a83efbe 100644 --- a/frontend/taskdeck-web/src/composables/useBoardRealtime.ts +++ b/frontend/taskdeck-web/src/composables/useBoardRealtime.ts @@ -74,6 +74,14 @@ export function createBoardRealtimeController( fallbackTimer = null } + const beginRecoveryGeneration = () => { + recoveryGeneration += 1 + if (pendingRecoveryRefresh) { + pendingRecoveryGeneration = recoveryGeneration + } + return recoveryGeneration + } + const startFallbackPolling = ( boardId: string, canDischargeRecovery = false, @@ -233,8 +241,8 @@ export function createBoardRealtimeController( hubConnection.on(BOARD_PRESENCE_EVENT, handleBoardPresence) hubConnection.onreconnecting(() => { if (connection === hubConnection && requestedBoardId) { - recoveryGeneration += 1 - startFallbackPolling(requestedBoardId, false, recoveryGeneration) + const nextRecoveryGeneration = beginRecoveryGeneration() + startFallbackPolling(requestedBoardId, false, nextRecoveryGeneration) } }) hubConnection.onreconnected(async () => { @@ -275,8 +283,8 @@ export function createBoardRealtimeController( }) hubConnection.onclose(() => { if (connection === hubConnection && requestedBoardId) { - recoveryGeneration += 1 - startFallbackPolling(requestedBoardId, false, recoveryGeneration) + const nextRecoveryGeneration = beginRecoveryGeneration() + startFallbackPolling(requestedBoardId, false, nextRecoveryGeneration) } }) @@ -401,7 +409,7 @@ export function createBoardRealtimeController( const stop = async () => { requestedBoardId = null subscriptionGeneration++ - recoveryGeneration += 1 + beginRecoveryGeneration() recoveryPending = false stopFallbackPolling() cancelMutationDebounce() From 2e90bfd340612b12fc0a01eb4fb79d461dd89542 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Mon, 21 Sep 2026 21:49:41 +0100 Subject: [PATCH 10/11] fix(realtime): keep recovery generation tied to join --- .../src/composables/useBoardRealtime.ts | 18 +++----- .../composables/boardRealtimeRecovery.spec.ts | 42 +++++++++++++++++++ 2 files changed, 47 insertions(+), 13 deletions(-) diff --git a/frontend/taskdeck-web/src/composables/useBoardRealtime.ts b/frontend/taskdeck-web/src/composables/useBoardRealtime.ts index 909a83efbe..063a1d60ea 100644 --- a/frontend/taskdeck-web/src/composables/useBoardRealtime.ts +++ b/frontend/taskdeck-web/src/composables/useBoardRealtime.ts @@ -74,14 +74,6 @@ export function createBoardRealtimeController( fallbackTimer = null } - const beginRecoveryGeneration = () => { - recoveryGeneration += 1 - if (pendingRecoveryRefresh) { - pendingRecoveryGeneration = recoveryGeneration - } - return recoveryGeneration - } - const startFallbackPolling = ( boardId: string, canDischargeRecovery = false, @@ -241,8 +233,8 @@ export function createBoardRealtimeController( hubConnection.on(BOARD_PRESENCE_EVENT, handleBoardPresence) hubConnection.onreconnecting(() => { if (connection === hubConnection && requestedBoardId) { - const nextRecoveryGeneration = beginRecoveryGeneration() - startFallbackPolling(requestedBoardId, false, nextRecoveryGeneration) + recoveryGeneration += 1 + startFallbackPolling(requestedBoardId, false, recoveryGeneration) } }) hubConnection.onreconnected(async () => { @@ -283,8 +275,8 @@ export function createBoardRealtimeController( }) hubConnection.onclose(() => { if (connection === hubConnection && requestedBoardId) { - const nextRecoveryGeneration = beginRecoveryGeneration() - startFallbackPolling(requestedBoardId, false, nextRecoveryGeneration) + recoveryGeneration += 1 + startFallbackPolling(requestedBoardId, false, recoveryGeneration) } }) @@ -409,7 +401,7 @@ export function createBoardRealtimeController( const stop = async () => { requestedBoardId = null subscriptionGeneration++ - beginRecoveryGeneration() + recoveryGeneration += 1 recoveryPending = false stopFallbackPolling() cancelMutationDebounce() diff --git a/frontend/taskdeck-web/src/tests/composables/boardRealtimeRecovery.spec.ts b/frontend/taskdeck-web/src/tests/composables/boardRealtimeRecovery.spec.ts index 11b598ace9..1ad0320fe7 100644 --- a/frontend/taskdeck-web/src/tests/composables/boardRealtimeRecovery.spec.ts +++ b/frontend/taskdeck-web/src/tests/composables/boardRealtimeRecovery.spec.ts @@ -272,6 +272,48 @@ describe('board realtime recovery (#3319)', () => { }) }) + it('does not discharge a newer recovery with a queued older catch-up', async () => { + const activeRead = deferred() + const secondJoin = deferred() + let joinCount = 0 + const fetchBoard = vi.fn<() => Promise>() + .mockImplementationOnce(() => activeRead.promise) + .mockResolvedValue(undefined) + controller = createBoardRealtimeController({ fetchBoard }) + await controller.start('board-a') + hub.events.boardMutation!({ boardId: 'board-a' }) + await vi.advanceTimersByTimeAsync(300) + expect(fetchBoard).toHaveBeenCalledTimes(1) + + await disconnect() + hub.invoke.mockImplementation(async (method) => { + if (method === 'JoinBoard') { + joinCount += 1 + if (joinCount === 2) await secondJoin.promise + } + }) + + await reconnect() + expect(fetchBoard).toHaveBeenCalledTimes(1) + + await disconnect() + const newerRecovery = reconnect() + await vi.waitFor(() => expect(joinCount).toBe(2)) + + activeRead.resolve() + await vi.advanceTimersByTimeAsync(0) + expect(fetchBoard).toHaveBeenCalledTimes(2) + + secondJoin.resolve() + await newerRecovery + await vi.advanceTimersByTimeAsync(0) + expect(fetchBoard).toHaveBeenCalledTimes(3) + expect(fetchBoard).toHaveBeenLastCalledWith('board-a', { + intent: 'background', + afterActive: true, + }) + }) + it('retains fallback after a handled recovery failure', async () => { const fetchBoard = vi.fn<() => Promise>() .mockResolvedValueOnce(false) From 51448e91bc413a8ffb8a1b7cc1d85fa9b062e802 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Mon, 21 Sep 2026 21:59:19 +0100 Subject: [PATCH 11/11] fix(realtime): require affirmative recovery commits --- .../src/composables/useBoardRealtime.ts | 11 ++-- .../composables/boardRealtimeRecovery.spec.ts | 46 ++++++++--------- .../composables/useBoardRealtime.spec.ts | 50 +++++++++---------- .../src/tests/resilience/degradedMode.spec.ts | 8 +-- .../src/tests/views/BoardView.spec.ts | 2 +- 5 files changed, 59 insertions(+), 58 deletions(-) diff --git a/frontend/taskdeck-web/src/composables/useBoardRealtime.ts b/frontend/taskdeck-web/src/composables/useBoardRealtime.ts index 063a1d60ea..5de88bb996 100644 --- a/frontend/taskdeck-web/src/composables/useBoardRealtime.ts +++ b/frontend/taskdeck-web/src/composables/useBoardRealtime.ts @@ -35,7 +35,7 @@ export interface BoardRealtimeControllerOptions { fetchBoard: ( boardId: string, options: { intent: 'background'; afterActive?: boolean }, - ) => Promise + ) => Promise onPresenceChanged?: (snapshot: BoardPresenceSnapshot) => void } @@ -135,10 +135,11 @@ export function createBoardRealtimeController( return } - if (committed === false) { - // A handled background failure resolves false rather than rejecting. - // Keep the recovery obligation alive and let bounded fallback polling - // retry it instead of treating the failed catch-up as complete. + if (committed !== true) { + // A handled background failure or stale adapter resolves without an + // affirmative commit. Keep the recovery obligation alive and let + // bounded fallback polling retry it instead of treating the read as + // complete. recoveryPending = true startFallbackPolling(boardId, true, recoveryGenerationForRefresh) return diff --git a/frontend/taskdeck-web/src/tests/composables/boardRealtimeRecovery.spec.ts b/frontend/taskdeck-web/src/tests/composables/boardRealtimeRecovery.spec.ts index 1ad0320fe7..eed4eadb94 100644 --- a/frontend/taskdeck-web/src/tests/composables/boardRealtimeRecovery.spec.ts +++ b/frontend/taskdeck-web/src/tests/composables/boardRealtimeRecovery.spec.ts @@ -34,9 +34,9 @@ vi.mock('@microsoft/signalr', () => ({ }, })) -function deferred() { - let resolve!: () => void - const promise = new Promise((done) => { resolve = done }) +function deferred() { + let resolve!: (value?: T) => void + const promise = new Promise((done) => { resolve = done as (value?: T) => void }) return { promise, resolve } } @@ -69,7 +69,7 @@ describe('board realtime recovery (#3319)', () => { }) it('catches up after a disconnect shorter than the fallback interval', async () => { - const fetchBoard = vi.fn(async () => undefined) + const fetchBoard = vi.fn(async () => true) controller = createBoardRealtimeController({ fetchBoard }) await controller.start('board-a') await disconnect() @@ -85,7 +85,7 @@ describe('board realtime recovery (#3319)', () => { }) it('retains polling and contains a failed rejoin without blindly retrying JoinBoard', async () => { - const fetchBoard = vi.fn(async () => undefined) + const fetchBoard = vi.fn(async () => true) controller = createBoardRealtimeController({ fetchBoard }) await controller.start('board-a') await disconnect() @@ -100,7 +100,7 @@ describe('board realtime recovery (#3319)', () => { }) it('does not let optional presence restoration failure suppress catch-up', async () => { - const fetchBoard = vi.fn(async () => undefined) + const fetchBoard = vi.fn(async () => true) controller = createBoardRealtimeController({ fetchBoard }) await controller.start('board-a') await controller.setEditingCard('card-a') @@ -118,7 +118,7 @@ describe('board realtime recovery (#3319)', () => { }) it.each(['switch', 'stop'] as const)('does not catch up an abandoned board after %s during rejoin', async (change) => { - const fetchBoard = vi.fn(async () => undefined) + const fetchBoard = vi.fn(async () => true) controller = createBoardRealtimeController({ fetchBoard }) await controller.start('board-a') await disconnect() @@ -148,9 +148,9 @@ describe('board realtime recovery (#3319)', () => { }) it.each(['mutation', 'fallback'] as const)('retains one catch-up behind an older in-flight %s refresh', async (source) => { - const olderRead = deferred() - const fetchBoard = vi.fn<() => Promise>() - .mockImplementationOnce(() => olderRead.promise).mockResolvedValue(undefined) + const olderRead = deferred() + const fetchBoard = vi.fn<() => Promise>() + .mockImplementationOnce(() => olderRead.promise).mockResolvedValue(true) controller = createBoardRealtimeController({ fetchBoard }) await controller.start('board-a') if (source === 'mutation') { @@ -164,7 +164,7 @@ describe('board realtime recovery (#3319)', () => { expect(fetchBoard).toHaveBeenCalledTimes(1) await reconnect() expect(fetchBoard).toHaveBeenCalledTimes(1) - olderRead.resolve() + olderRead.resolve(true) await vi.advanceTimersByTimeAsync(0) expect(fetchBoard).toHaveBeenCalledTimes(2) expect(fetchBoard).toHaveBeenLastCalledWith('board-a', { @@ -174,7 +174,7 @@ describe('board realtime recovery (#3319)', () => { }) it('keeps polling the latest board when its transferred rejoin fails', async () => { - const fetchBoard = vi.fn(async () => undefined) + const fetchBoard = vi.fn(async () => true) controller = createBoardRealtimeController({ fetchBoard }) await controller.start('board-a') await disconnect() @@ -197,8 +197,8 @@ describe('board realtime recovery (#3319)', () => { }) it('contains a failed catch-up read and still responds to later mutations', async () => { - const fetchBoard = vi.fn<() => Promise>() - .mockRejectedValueOnce(new Error('synthetic read failure')).mockResolvedValue(undefined) + const fetchBoard = vi.fn<() => Promise>() + .mockRejectedValueOnce(new Error('synthetic read failure')).mockResolvedValue(true) controller = createBoardRealtimeController({ fetchBoard }) await controller.start('board-a') await disconnect() @@ -211,7 +211,7 @@ describe('board realtime recovery (#3319)', () => { }) it('does not retire fallback if another disconnect happens before rejoin settles', async () => { - const fetchBoard = vi.fn(async () => undefined) + const fetchBoard = vi.fn(async () => true) controller = createBoardRealtimeController({ fetchBoard }) await controller.start('board-a') await disconnect() @@ -231,12 +231,12 @@ describe('board realtime recovery (#3319)', () => { }) it('does not let an older catch-up discharge a newer reconnect recovery', async () => { - const firstCatchUp = deferred() + const firstCatchUp = deferred() const secondJoin = deferred() let joinCount = 0 - const fetchBoard = vi.fn<() => Promise>() + const fetchBoard = vi.fn<() => Promise>() .mockImplementationOnce(() => firstCatchUp.promise) - .mockResolvedValue(undefined) + .mockResolvedValue(true) controller = createBoardRealtimeController({ fetchBoard }) await controller.start('board-a') await disconnect() @@ -258,7 +258,7 @@ describe('board realtime recovery (#3319)', () => { const newerRecovery = reconnect() await vi.waitFor(() => expect(joinCount).toBe(2)) - firstCatchUp.resolve() + firstCatchUp.resolve(true) await vi.advanceTimersByTimeAsync(0) expect(fetchBoard).toHaveBeenCalledTimes(1) @@ -273,12 +273,12 @@ describe('board realtime recovery (#3319)', () => { }) it('does not discharge a newer recovery with a queued older catch-up', async () => { - const activeRead = deferred() + const activeRead = deferred() const secondJoin = deferred() let joinCount = 0 - const fetchBoard = vi.fn<() => Promise>() + const fetchBoard = vi.fn<() => Promise>() .mockImplementationOnce(() => activeRead.promise) - .mockResolvedValue(undefined) + .mockResolvedValue(true) controller = createBoardRealtimeController({ fetchBoard }) await controller.start('board-a') hub.events.boardMutation!({ boardId: 'board-a' }) @@ -300,7 +300,7 @@ describe('board realtime recovery (#3319)', () => { const newerRecovery = reconnect() await vi.waitFor(() => expect(joinCount).toBe(2)) - activeRead.resolve() + activeRead.resolve(true) await vi.advanceTimersByTimeAsync(0) expect(fetchBoard).toHaveBeenCalledTimes(2) diff --git a/frontend/taskdeck-web/src/tests/composables/useBoardRealtime.spec.ts b/frontend/taskdeck-web/src/tests/composables/useBoardRealtime.spec.ts index 8911cdae4a..ca79d8e5d9 100644 --- a/frontend/taskdeck-web/src/tests/composables/useBoardRealtime.spec.ts +++ b/frontend/taskdeck-web/src/tests/composables/useBoardRealtime.spec.ts @@ -91,7 +91,7 @@ describe('createBoardRealtimeController', () => { }) it('joins board stream when started', async () => { - const fetchBoard = vi.fn(async () => undefined) + const fetchBoard = vi.fn(async () => true) const controller = createBoardRealtimeController({ fetchBoard }) await controller.start('board-1') @@ -101,7 +101,7 @@ describe('createBoardRealtimeController', () => { }) it('configures SignalR with websocket transport and negotiation enabled', async () => { - const fetchBoard = vi.fn(async () => undefined) + const fetchBoard = vi.fn(async () => true) // Use a structurally valid JWT (three base64url segments) so tokenStorage.getToken() accepts it const fakeJwt = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyLTEifQ.fakesig' localStorage.setItem('taskdeck_token', fakeJwt) @@ -118,7 +118,7 @@ describe('createBoardRealtimeController', () => { }) it('uses an empty access token when no session token is present', async () => { - const fetchBoard = vi.fn(async () => undefined) + const fetchBoard = vi.fn(async () => true) const controller = createBoardRealtimeController({ fetchBoard }) await controller.start('board-1') @@ -131,7 +131,7 @@ describe('createBoardRealtimeController', () => { it('refreshes board when matching board mutation event arrives', async () => { vi.useFakeTimers() - const fetchBoard = vi.fn(async () => undefined) + const fetchBoard = vi.fn(async () => true) const controller = createBoardRealtimeController({ fetchBoard }) await controller.start('board-1') @@ -146,7 +146,7 @@ describe('createBoardRealtimeController', () => { it('ignores mutation events for other boards', async () => { vi.useFakeTimers() - const fetchBoard = vi.fn(async () => undefined) + const fetchBoard = vi.fn(async () => true) const controller = createBoardRealtimeController({ fetchBoard }) await controller.start('board-1') @@ -161,7 +161,7 @@ describe('createBoardRealtimeController', () => { it('coalesces rapid burst mutation events into a single fetchBoard call', async () => { vi.useFakeTimers() - const fetchBoard = vi.fn(async () => undefined) + const fetchBoard = vi.fn(async () => true) const controller = createBoardRealtimeController({ fetchBoard }) await controller.start('board-1') @@ -185,11 +185,11 @@ describe('createBoardRealtimeController', () => { it('drains one coalesced mutation refresh after the active refresh succeeds', async () => { vi.useFakeTimers() - const firstRefresh = createDeferred() + const firstRefresh = createDeferred() const fetchBoard = vi .fn() .mockImplementationOnce(() => firstRefresh.promise) - .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(true) const controller = createBoardRealtimeController({ fetchBoard }) await controller.start('board-1') @@ -202,7 +202,7 @@ describe('createBoardRealtimeController', () => { await vi.advanceTimersByTimeAsync(300) expect(fetchBoard).toHaveBeenCalledTimes(1) - firstRefresh.resolve() + firstRefresh.resolve(true) await Promise.resolve() await Promise.resolve() await Promise.resolve() @@ -215,13 +215,13 @@ describe('createBoardRealtimeController', () => { it('clears a retained mutation refresh on route switch and stop', async () => { vi.useFakeTimers() - const firstRefresh = createDeferred() - const secondRefresh = createDeferred() + const firstRefresh = createDeferred() + const secondRefresh = createDeferred() const fetchBoard = vi .fn() .mockImplementationOnce(() => firstRefresh.promise) .mockImplementationOnce(() => secondRefresh.promise) - .mockResolvedValue(undefined) + .mockResolvedValue(true) const controller = createBoardRealtimeController({ fetchBoard }) await controller.start('board-1') @@ -231,7 +231,7 @@ describe('createBoardRealtimeController', () => { await vi.advanceTimersByTimeAsync(300) await controller.switchBoard('board-2') - firstRefresh.resolve() + firstRefresh.resolve(true) await Promise.resolve() await Promise.resolve() await Promise.resolve() @@ -244,7 +244,7 @@ describe('createBoardRealtimeController', () => { expect(fetchBoard).toHaveBeenCalledTimes(2) await controller.stop() - secondRefresh.resolve() + secondRefresh.resolve(true) await Promise.resolve() await Promise.resolve() await Promise.resolve() @@ -253,11 +253,11 @@ describe('createBoardRealtimeController', () => { it('contains a failed background refresh and allows the next mutation to refresh', async () => { vi.useFakeTimers() - const firstRefresh = createDeferred() + const firstRefresh = createDeferred() const fetchBoard = vi .fn() .mockImplementationOnce(() => firstRefresh.promise) - .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(true) const controller = createBoardRealtimeController({ fetchBoard }) await controller.start('board-1') @@ -278,7 +278,7 @@ describe('createBoardRealtimeController', () => { }) it('emits presence snapshots for the currently subscribed board', async () => { - const fetchBoard = vi.fn(async () => undefined) + const fetchBoard = vi.fn(async () => true) const onPresenceChanged = vi.fn() const controller = createBoardRealtimeController({ fetchBoard, onPresenceChanged }) @@ -292,7 +292,7 @@ describe('createBoardRealtimeController', () => { }) it('ignores presence snapshots for other boards', async () => { - const fetchBoard = vi.fn(async () => undefined) + const fetchBoard = vi.fn(async () => true) const onPresenceChanged = vi.fn() const controller = createBoardRealtimeController({ fetchBoard, onPresenceChanged }) @@ -303,7 +303,7 @@ describe('createBoardRealtimeController', () => { }) it('leaves previous board and joins next board when switched', async () => { - const fetchBoard = vi.fn(async () => undefined) + const fetchBoard = vi.fn(async () => true) const controller = createBoardRealtimeController({ fetchBoard }) await controller.start('board-1') @@ -319,7 +319,7 @@ describe('createBoardRealtimeController', () => { mockConnection.state = 'Connecting' return connectionStarted.promise }) - const fetchBoard = vi.fn(async () => undefined) + const fetchBoard = vi.fn(async () => true) const controller = createBoardRealtimeController({ fetchBoard }) const startA = controller.start('board-a') @@ -342,7 +342,7 @@ describe('createBoardRealtimeController', () => { it('defers a reconnecting switch until connected while polling the requested board', async () => { vi.useFakeTimers() - const fetchBoard = vi.fn(async () => undefined) + const fetchBoard = vi.fn(async () => true) const controller = createBoardRealtimeController({ fetchBoard }) await controller.start('board-a') @@ -375,7 +375,7 @@ describe('createBoardRealtimeController', () => { // Regression: a board-A mutation event with a debounce timer pending must // not fire fetchBoard after subscribedBoardId has advanced to board-B. vi.useFakeTimers() - const fetchBoard = vi.fn(async () => undefined) + const fetchBoard = vi.fn(async () => true) const controller = createBoardRealtimeController({ fetchBoard }) await controller.start('board-1') @@ -397,7 +397,7 @@ describe('createBoardRealtimeController', () => { it('falls back to polling when websocket connection cannot start', async () => { vi.useFakeTimers() - const fetchBoard = vi.fn(async () => undefined) + const fetchBoard = vi.fn(async () => true) mockConnection.start.mockRejectedValueOnce(new Error('websocket unavailable')) const controller = createBoardRealtimeController({ fetchBoard }) @@ -410,7 +410,7 @@ describe('createBoardRealtimeController', () => { }) it('sends editing-card status when connected', async () => { - const fetchBoard = vi.fn(async () => undefined) + const fetchBoard = vi.fn(async () => true) const controller = createBoardRealtimeController({ fetchBoard }) await controller.start('board-1') @@ -421,7 +421,7 @@ describe('createBoardRealtimeController', () => { it('starts polling on reconnecting and re-joins board when reconnected', async () => { vi.useFakeTimers() - const fetchBoard = vi.fn(async () => undefined) + const fetchBoard = vi.fn(async () => true) const controller = createBoardRealtimeController({ fetchBoard }) await controller.start('board-1') diff --git a/frontend/taskdeck-web/src/tests/resilience/degradedMode.spec.ts b/frontend/taskdeck-web/src/tests/resilience/degradedMode.spec.ts index 9ebefae6bf..e788363ace 100644 --- a/frontend/taskdeck-web/src/tests/resilience/degradedMode.spec.ts +++ b/frontend/taskdeck-web/src/tests/resilience/degradedMode.spec.ts @@ -394,7 +394,7 @@ describe('useBoardRealtime — SignalR disconnect resilience', () => { it('starts fallback polling during reconnecting then stops on reconnected', async () => { // reconnecting → starts polling; reconnected → stops polling and re-joins board const { createBoardRealtimeController } = await import('../../composables/useBoardRealtime') - const fetchBoard = vi.fn(async () => undefined) + const fetchBoard = vi.fn(async () => true) const controller = createBoardRealtimeController({ fetchBoard }) vi.useFakeTimers() @@ -425,7 +425,7 @@ describe('useBoardRealtime — SignalR disconnect resilience', () => { it('starts fallback polling when SignalR closes unexpectedly', async () => { const { createBoardRealtimeController } = await import('../../composables/useBoardRealtime') - const fetchBoard = vi.fn(async () => undefined) + const fetchBoard = vi.fn(async () => true) const controller = createBoardRealtimeController({ fetchBoard }) vi.useFakeTimers() @@ -443,7 +443,7 @@ describe('useBoardRealtime — SignalR disconnect resilience', () => { it('ignores boardMutation events for a different board without crashing', async () => { const { createBoardRealtimeController } = await import('../../composables/useBoardRealtime') - const fetchBoard = vi.fn(async () => undefined) + const fetchBoard = vi.fn(async () => true) const controller = createBoardRealtimeController({ fetchBoard }) await controller.start('board-1') @@ -464,7 +464,7 @@ describe('useBoardRealtime — SignalR disconnect resilience', () => { it('falls back to polling when SignalR connection cannot be established', async () => { vi.useFakeTimers() const { createBoardRealtimeController } = await import('../../composables/useBoardRealtime') - const fetchBoard = vi.fn(async () => undefined) + const fetchBoard = vi.fn(async () => true) realtimeMockConnection.start.mockRejectedValueOnce(new Error('SignalR unavailable')) const controller = createBoardRealtimeController({ fetchBoard }) diff --git a/frontend/taskdeck-web/src/tests/views/BoardView.spec.ts b/frontend/taskdeck-web/src/tests/views/BoardView.spec.ts index 3d51a39d09..c7b20d9346 100644 --- a/frontend/taskdeck-web/src/tests/views/BoardView.spec.ts +++ b/frontend/taskdeck-web/src/tests/views/BoardView.spec.ts @@ -69,7 +69,7 @@ const realtimeMock = { // simulate incoming SignalR presence snapshots. let capturedOnPresenceChanged: ((snapshot: BoardPresenceSnapshot) => void) | undefined let capturedRealtimeFetchBoard: - | ((boardId: string, options: { intent: 'background'; afterActive?: boolean }) => Promise) + | ((boardId: string, options: { intent: 'background'; afterActive?: boolean }) => Promise) | undefined const mockBoardStore = reactive({