diff --git a/frontend/taskdeck-web/src/composables/useBoardRealtime.ts b/frontend/taskdeck-web/src/composables/useBoardRealtime.ts index c5036b58be..5de88bb996 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 } @@ -53,8 +56,13 @@ export function createBoardRealtimeController( let subscriptionTransition: Promise = Promise.resolve() let editingCardId: string | null = null let fallbackTimer: ReturnType | null = null + let recoveryPending = false + let recoveryGeneration = 0 let refreshInFlight = false - let pendingMutationRefreshBoardId: string | null = null + let pendingRefreshBoardId: string | null = null + let pendingRefreshAfterActive = false + let pendingRecoveryRefresh = false + let pendingRecoveryGeneration: number | null = null let mutationDebounceTimer: ReturnType | null = null const stopFallbackPolling = () => { @@ -66,16 +74,23 @@ export function createBoardRealtimeController( fallbackTimer = null } - const startFallbackPolling = (boardId: string) => { + const startFallbackPolling = ( + boardId: string, + canDischargeRecovery = false, + recoveryGenerationForRefresh = recoveryGeneration, + ) => { + recoveryPending = true stopFallbackPolling() + const timerCanDischargeRecovery = canDischargeRecovery + const timerRecoveryGeneration = recoveryGenerationForRefresh 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, false, timerCanDischargeRecovery, timerRecoveryGeneration) }, FALLBACK_POLL_INTERVAL_MS) } @@ -86,29 +101,82 @@ export function createBoardRealtimeController( } } - const startMutationRefresh = (boardId: string) => { - if (subscribedBoardId !== boardId || requestedBoardId !== boardId) { + const startBoardRefresh = ( + 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. + if (requestedBoardId !== boardId) { return } if (refreshInFlight) { - pendingMutationRefreshBoardId = boardId + pendingRefreshBoardId = boardId + pendingRefreshAfterActive ||= afterActive + if (dischargesRecovery) { + pendingRecoveryRefresh = true + pendingRecoveryGeneration = recoveryGenerationForRefresh + } return } refreshInFlight = true void options - .fetchBoard(boardId, { intent: 'background' }) + .fetchBoard(boardId, { intent: 'background', ...(afterActive ? { afterActive: true } : {}) }) + .then((committed) => { + if ( + !dischargesRecovery || + requestedBoardId !== boardId || + recoveryGeneration !== recoveryGenerationForRefresh + ) { + return + } + + 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 + } + + recoveryPending = false + stopFallbackPolling() + }) .catch(() => { // Background refresh failures must not escape the realtime loop. + if ( + dischargesRecovery && + requestedBoardId === boardId && + recoveryGeneration === recoveryGenerationForRefresh + ) { + recoveryPending = true + startFallbackPolling(boardId, true, recoveryGenerationForRefresh) + } }) .finally(() => { refreshInFlight = false - const pendingBoardId = pendingMutationRefreshBoardId - pendingMutationRefreshBoardId = null + const pendingBoardId = pendingRefreshBoardId + const pendingAfterActive = pendingRefreshAfterActive + const pendingRecovery = pendingRecoveryRefresh + const pendingRecoveryGenerationValue = pendingRecoveryGeneration + pendingRefreshBoardId = null + pendingRefreshAfterActive = false + pendingRecoveryRefresh = false + pendingRecoveryGeneration = null if (pendingBoardId) { - startMutationRefresh(pendingBoardId) + startBoardRefresh( + pendingBoardId, + pendingAfterActive, + pendingRecovery, + pendingRecoveryGenerationValue ?? recoveryGeneration, + ) } }) } @@ -132,7 +200,7 @@ export function createBoardRealtimeController( return } - startMutationRefresh(subscribedBoardId) + startBoardRefresh(subscribedBoardId) }, MUTATION_DEBOUNCE_MS) } @@ -165,28 +233,51 @@ export function createBoardRealtimeController( hubConnection.on(BOARD_MUTATION_EVENT, handleBoardMutation) hubConnection.on(BOARD_PRESENCE_EVENT, handleBoardPresence) hubConnection.onreconnecting(() => { - if (requestedBoardId) { - startFallbackPolling(requestedBoardId) + if (connection === hubConnection && requestedBoardId) { + recoveryGeneration += 1 + startFallbackPolling(requestedBoardId, false, recoveryGeneration) } }) hubConnection.onreconnected(async () => { - stopFallbackPolling() const boardId = requestedBoardId const generation = subscriptionGeneration - if (boardId) { - await queueBoardSubscription(boardId, generation) - if ( - editingCardId !== null && - requestedBoardId === boardId && - subscriptionGeneration === generation - ) { + const recoveryGenerationAtStart = recoveryGeneration + const isCurrentRequest = () => + connection === hubConnection && + requestedBoardId === boardId && + subscriptionGeneration === generation + 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. + try { + await queueBoardSubscription(boardId, generation, recoveryGenerationAtStart) + } catch (error) { + if (isCurrentRequest()) { + logWarn('SignalR board rejoin failed, retaining polling fallback.', error) + startFallbackPolling(boardId) + } + return + } + if (!isCurrentRequest() || hubConnection.state !== HubConnectionState.Connected) return + + // 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) + } catch (error) { + if (isCurrentRequest()) { + logWarn('SignalR editing presence could not be restored.', error) + } } } }) hubConnection.onclose(() => { - if (requestedBoardId) { - startFallbackPolling(requestedBoardId) + if (connection === hubConnection && requestedBoardId) { + recoveryGeneration += 1 + startFallbackPolling(requestedBoardId, false, recoveryGeneration) } }) @@ -194,7 +285,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 @@ -239,14 +334,30 @@ export function createBoardRealtimeController( } await hubConnection.invoke('JoinBoard', boardId) + if (connection !== hubConnection) return subscribedBoardId = boardId - stopFallbackPolling() + if (isCurrentRequest() && hubConnection.state === HubConnectionState.Connected) { + 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, 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 } @@ -258,8 +369,14 @@ 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 - return queueBoardSubscription(boardId, generation) + 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, recoveryGeneration) } const start = async (boardId: string) => { @@ -285,9 +402,14 @@ export function createBoardRealtimeController( const stop = async () => { requestedBoardId = null subscriptionGeneration++ + recoveryGeneration += 1 + recoveryPending = false stopFallbackPolling() cancelMutationDebounce() - pendingMutationRefreshBoardId = null + pendingRefreshBoardId = null + pendingRefreshAfterActive = false + pendingRecoveryRefresh = false + pendingRecoveryGeneration = null 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 new file mode 100644 index 0000000000..eed4eadb94 --- /dev/null +++ b/frontend/taskdeck-web/src/tests/composables/boardRealtimeRecovery.spec.ts @@ -0,0 +1,336 @@ +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!: (value?: T) => void + const promise = new Promise((done) => { resolve = done as (value?: T) => void }) + 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 () => true) + 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', + afterActive: true, + }) + 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 () => true) + 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 () => true) + 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', + afterActive: true, + }) + 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 () => true) + 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.toHaveBeenCalledWith('board-a', { intent: 'background' }) + if (change === 'switch') { + expect(fetchBoard).toHaveBeenCalledExactlyOnceWith('board-b', { + intent: 'background', + afterActive: true, + }) + } else { + expect(fetchBoard).not.toHaveBeenCalled() + } + }) + + 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(true) + controller = createBoardRealtimeController({ fetchBoard }) + await controller.start('board-a') + 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 reconnect() + expect(fetchBoard).toHaveBeenCalledTimes(1) + olderRead.resolve(true) + await vi.advanceTimersByTimeAsync(0) + expect(fetchBoard).toHaveBeenCalledTimes(2) + expect(fetchBoard).toHaveBeenLastCalledWith('board-a', { + intent: 'background', + afterActive: true, + }) + }) + + it('keeps polling the latest board when its transferred rejoin fails', async () => { + const fetchBoard = vi.fn(async () => true) + 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(true) + 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 () => true) + 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' }) + }) + + 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(true) + 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(true) + 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('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(true) + 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(true) + 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) + .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).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 b3f3462d7d..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') @@ -435,8 +435,14 @@ 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', + afterActive: true, + }) await vi.advanceTimersByTimeAsync(30000) - expect(fetchBoard).not.toHaveBeenCalled() + expect(fetchBoard).toHaveBeenCalledTimes(1) await controller.stop() }) diff --git a/frontend/taskdeck-web/src/tests/resilience/degradedMode.spec.ts b/frontend/taskdeck-web/src/tests/resilience/degradedMode.spec.ts index 195f5b0aa3..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() @@ -411,16 +411,21 @@ 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', + afterActive: true, + }) await vi.advanceTimersByTimeAsync(30000) - expect(fetchBoard).not.toHaveBeenCalled() + expect(fetchBoard).toHaveBeenCalledTimes(1) await controller.stop() }) 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() @@ -438,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') @@ -459,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/store/board/boardCrudStore.spec.ts b/frontend/taskdeck-web/src/tests/store/board/boardCrudStore.spec.ts index b5c9d7e3e7..04e1e27b7f 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,55 @@ 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('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 diff --git a/frontend/taskdeck-web/src/tests/views/BoardView.spec.ts b/frontend/taskdeck-web/src/tests/views/BoardView.spec.ts index 9746ce2c2c..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' }) => 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 b33cebbafd..f257cf469b 100644 --- a/frontend/taskdeck-web/src/views/BoardView.vue +++ b/frontend/taskdeck-web/src/views/BoardView.vue @@ -129,9 +129,12 @@ 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 + return false } const boardLoadErrorAtStart = boardLoadError.value @@ -149,6 +152,7 @@ const realtime = createBoardRealtimeController({ boardStore.error = null } } + return committed }, onPresenceChanged: (snapshot) => { if (snapshot.boardId !== boardId.value) { 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