From d2ea40b4f4ecb703adce592ddabb10bb0097097e Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Sun, 20 Sep 2026 15:55:03 +0100 Subject: [PATCH 01/12] test(comments): reproduce stale cache settlements --- .../board/cardCommentStoreConcurrency.spec.ts | 199 ++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 frontend/taskdeck-web/src/tests/store/board/cardCommentStoreConcurrency.spec.ts diff --git a/frontend/taskdeck-web/src/tests/store/board/cardCommentStoreConcurrency.spec.ts b/frontend/taskdeck-web/src/tests/store/board/cardCommentStoreConcurrency.spec.ts new file mode 100644 index 000000000..92fbead2a --- /dev/null +++ b/frontend/taskdeck-web/src/tests/store/board/cardCommentStoreConcurrency.spec.ts @@ -0,0 +1,199 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' + +const { mockCardCommentsApi } = vi.hoisted(() => ({ + mockCardCommentsApi: { + getComments: vi.fn(), + createComment: vi.fn(), + updateComment: vi.fn(), + deleteComment: vi.fn(), + }, +})) + +vi.mock('../../../api/cardCommentsApi', () => ({ + cardCommentsApi: mockCardCommentsApi, +})) + +import { createCardCommentActions } from '../../../store/board/cardCommentStore' + +interface TestComment { + id: string + content: string + createdAt: string + updatedAt: string +} + +const originalComment: TestComment = { + id: 'cmt-1', + content: 'Original', + createdAt: '2026-09-20T10:00:00Z', + updatedAt: '2026-09-20T10:00:00Z', +} + +const secondComment: TestComment = { + id: 'cmt-2', + content: 'Second', + createdAt: '2026-09-20T10:01:00Z', + updatedAt: '2026-09-20T10:01:00Z', +} + +function createState() { + return { + currentBoard: ref<{ id: string } | null>({ id: 'board-1' }), + cardCommentsByCardId: ref>({ + 'card-1': [{ ...originalComment }], + }), + loading: ref(false), + error: ref(null), + } +} + +function createHelpers() { + return { + guardDemoMutation: vi.fn(), + handleApiError: vi.fn(), + isDemoMode: false, + toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() }, + } +} + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((settle) => { + resolve = settle + }) + return { promise, resolve } +} + +describe('cardCommentStore cache ordering', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCardCommentsApi.getComments.mockReset() + mockCardCommentsApi.createComment.mockReset() + mockCardCommentsApi.updateComment.mockReset() + mockCardCommentsApi.deleteComment.mockReset() + }) + + it('does not let a fetch that started first erase a created comment', async () => { + const state = createState() + const helpers = createHelpers() + const staleRead = deferred() + mockCardCommentsApi.getComments.mockReturnValueOnce(staleRead.promise) + mockCardCommentsApi.createComment.mockResolvedValueOnce({ ...secondComment }) + const actions = createCardCommentActions(state as never, helpers as never) + + const pendingRead = actions.fetchCardComments('board-1', 'card-1') + await actions.createCardComment('board-1', 'card-1', { content: 'Second' }) + staleRead.resolve([{ ...originalComment }]) + await pendingRead + + expect(state.cardCommentsByCardId.value['card-1'].map(comment => comment.id)).toEqual([ + 'cmt-1', + 'cmt-2', + ]) + }) + + it('does not let a fetch that started first erase an updated comment', async () => { + const state = createState() + const helpers = createHelpers() + const staleRead = deferred() + mockCardCommentsApi.getComments.mockReturnValueOnce(staleRead.promise) + mockCardCommentsApi.updateComment.mockResolvedValueOnce({ + ...originalComment, + content: 'Edited', + updatedAt: '2026-09-20T10:02:00Z', + }) + const actions = createCardCommentActions(state as never, helpers as never) + + const pendingRead = actions.fetchCardComments('board-1', 'card-1') + await actions.updateCardComment('board-1', 'card-1', 'cmt-1', { content: 'Edited' }) + staleRead.resolve([{ ...originalComment }]) + await pendingRead + + expect(state.cardCommentsByCardId.value['card-1'][0].content).toBe('Edited') + }) + + it('does not let a fetch that started first restore a deleted comment', async () => { + const state = createState() + state.cardCommentsByCardId.value['card-1'].push({ ...secondComment }) + const helpers = createHelpers() + const staleRead = deferred() + mockCardCommentsApi.getComments.mockReturnValueOnce(staleRead.promise) + mockCardCommentsApi.deleteComment.mockResolvedValueOnce(undefined) + const actions = createCardCommentActions(state as never, helpers as never) + + const pendingRead = actions.fetchCardComments('board-1', 'card-1') + await actions.deleteCardComment('board-1', 'card-1', 'cmt-1') + staleRead.resolve([{ ...originalComment }, { ...secondComment }]) + await pendingRead + + expect(state.cardCommentsByCardId.value['card-1'].map(comment => comment.id)).toEqual([ + 'cmt-2', + ]) + }) + + it('lets only the latest overlapping fetch commit for a card', async () => { + const state = createState() + const helpers = createHelpers() + const firstRead = deferred() + const secondRead = deferred() + mockCardCommentsApi.getComments + .mockReturnValueOnce(firstRead.promise) + .mockReturnValueOnce(secondRead.promise) + const actions = createCardCommentActions(state as never, helpers as never) + + const pendingFirstRead = actions.fetchCardComments('board-1', 'card-1') + const pendingSecondRead = actions.fetchCardComments('board-1', 'card-1') + secondRead.resolve([{ ...secondComment }]) + await pendingSecondRead + firstRead.resolve([{ ...originalComment }]) + const firstResult = await pendingFirstRead + + expect(firstResult).toEqual([{ ...originalComment }]) + expect(state.cardCommentsByCardId.value['card-1']).toEqual([{ ...secondComment }]) + }) + + it('does not repopulate the prior board cache after navigation', async () => { + const state = createState() + const helpers = createHelpers() + const staleRead = deferred() + mockCardCommentsApi.getComments.mockReturnValueOnce(staleRead.promise) + const actions = createCardCommentActions(state as never, helpers as never) + + const pendingRead = actions.fetchCardComments('board-1', 'card-1') + const nextBoardCache = { + 'card-next': [{ ...secondComment, id: 'cmt-next' }], + } + state.currentBoard.value = { id: 'board-2' } + state.cardCommentsByCardId.value = nextBoardCache + staleRead.resolve([{ ...originalComment }]) + await pendingRead + + expect(state.cardCommentsByCardId.value).toEqual(nextBoardCache) + expect(state.cardCommentsByCardId.value).not.toHaveProperty('card-1') + }) + + it('preserves a fresher stable-id comment that a refresh committed before create settles', async () => { + const state = createState() + const helpers = createHelpers() + const pendingCreateResponse = deferred() + mockCardCommentsApi.createComment.mockReturnValueOnce(pendingCreateResponse.promise) + const actions = createCardCommentActions(state as never, helpers as never) + + const pendingCreate = actions.createCardComment('board-1', 'card-1', { + content: 'Second', + }) + const refreshed = { + ...secondComment, + content: 'Second from authoritative refresh', + updatedAt: '2026-09-20T10:03:00Z', + } + state.cardCommentsByCardId.value['card-1'].push(refreshed) + pendingCreateResponse.resolve({ ...secondComment }) + await pendingCreate + + expect( + state.cardCommentsByCardId.value['card-1'].filter(comment => comment.id === 'cmt-2'), + ).toEqual([refreshed]) + }) +}) From 648ee624e88642cd479b5a39760f0ec7d26bdee7 Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Sun, 20 Sep 2026 15:55:27 +0100 Subject: [PATCH 02/12] fix(comments): order shared cache settlements --- .../src/store/board/cardCommentStore.ts | 90 ++++++++++++++----- 1 file changed, 70 insertions(+), 20 deletions(-) diff --git a/frontend/taskdeck-web/src/store/board/cardCommentStore.ts b/frontend/taskdeck-web/src/store/board/cardCommentStore.ts index e252bfb7d..615656366 100644 --- a/frontend/taskdeck-web/src/store/board/cardCommentStore.ts +++ b/frontend/taskdeck-web/src/store/board/cardCommentStore.ts @@ -7,17 +7,54 @@ import type { BoardState } from './boardState' import type { BoardHelpers } from './boardStoreHelpers' export function createCardCommentActions(state: BoardState, helpers: BoardHelpers) { + // Reads and writes share one per-card cache. Keep their ordering metadata in + // the store closure rather than exposing transport generations in UI callers. + const readVersionByCardId = new Map() + const mutationVersionByCardId = new Map() + + function nextReadVersion(cardId: string) { + const version = (readVersionByCardId.get(cardId) ?? 0) + 1 + readVersionByCardId.set(cardId, version) + return version + } + + function currentMutationVersion(cardId: string) { + return mutationVersionByCardId.get(cardId) ?? 0 + } + + function markCommentMutation(cardId: string) { + mutationVersionByCardId.set(cardId, currentMutationVersion(cardId) + 1) + } + + function ownsCurrentCommentCache(boardId: string) { + // Null preserves the existing pre-load/store-test convention. Optional + // access keeps lightweight unit fixtures that predate currentBoard valid. + const currentBoard = state.currentBoard?.value + return currentBoard == null || currentBoard.id === boardId + } + function getCardComments(cardId: string): CardComment[] { return state.cardCommentsByCardId.value[cardId] ?? [] } async function fetchCardComments(boardId: string, cardId: string) { if (helpers.isDemoMode) return [] + const readVersion = nextReadVersion(cardId) + const mutationVersion = currentMutationVersion(cardId) try { const comments = await cardCommentsApi.getComments(boardId, cardId) - state.cardCommentsByCardId.value = { - ...state.cardCommentsByCardId.value, - [cardId]: comments, + // A newer read owns the cache. A successful local mutation also + // invalidates every snapshot that began before it, even when that older + // request returns later. The payload is still returned to its caller. + if ( + ownsCurrentCommentCache(boardId) && + readVersionByCardId.get(cardId) === readVersion && + currentMutationVersion(cardId) === mutationVersion + ) { + state.cardCommentsByCardId.value = { + ...state.cardCommentsByCardId.value, + [cardId]: comments, + } } return comments } catch (e: unknown) { @@ -32,13 +69,20 @@ export function createCardCommentActions(state: BoardState, helpers: BoardHelper state.loading.value = true state.error.value = null const createdComment = await cardCommentsApi.createComment(boardId, cardId, dto) - const existingComments = state.cardCommentsByCardId.value[cardId] ?? [] - state.cardCommentsByCardId.value = { - ...state.cardCommentsByCardId.value, - [cardId]: [...existingComments, createdComment].sort( - (left, right) => - new Date(left.createdAt).getTime() - new Date(right.createdAt).getTime(), - ), + markCommentMutation(cardId) + if (ownsCurrentCommentCache(boardId)) { + const existingComments = state.cardCommentsByCardId.value[cardId] ?? [] + // A board refresh can commit the stable id before this response arrives. + // Preserve that fresher object instead of appending a duplicate. + if (!existingComments.some(comment => comment.id === createdComment.id)) { + state.cardCommentsByCardId.value = { + ...state.cardCommentsByCardId.value, + [cardId]: [...existingComments, createdComment].sort( + (left, right) => + new Date(left.createdAt).getTime() - new Date(right.createdAt).getTime(), + ), + } + } } helpers.toast.success('Comment added') @@ -62,12 +106,15 @@ export function createCardCommentActions(state: BoardState, helpers: BoardHelper state.loading.value = true state.error.value = null const updatedComment = await cardCommentsApi.updateComment(boardId, cardId, commentId, dto) - const existingComments = state.cardCommentsByCardId.value[cardId] ?? [] - state.cardCommentsByCardId.value = { - ...state.cardCommentsByCardId.value, - [cardId]: existingComments.map((comment) => - comment.id === commentId ? updatedComment : comment, - ), + markCommentMutation(cardId) + if (ownsCurrentCommentCache(boardId)) { + const existingComments = state.cardCommentsByCardId.value[cardId] ?? [] + state.cardCommentsByCardId.value = { + ...state.cardCommentsByCardId.value, + [cardId]: existingComments.map((comment) => + comment.id === commentId ? updatedComment : comment, + ), + } } helpers.toast.success('Comment updated') @@ -86,10 +133,13 @@ export function createCardCommentActions(state: BoardState, helpers: BoardHelper state.loading.value = true state.error.value = null await cardCommentsApi.deleteComment(boardId, cardId, commentId) - const existingComments = state.cardCommentsByCardId.value[cardId] ?? [] - state.cardCommentsByCardId.value = { - ...state.cardCommentsByCardId.value, - [cardId]: existingComments.filter((comment) => comment.id !== commentId), + markCommentMutation(cardId) + if (ownsCurrentCommentCache(boardId)) { + const existingComments = state.cardCommentsByCardId.value[cardId] ?? [] + state.cardCommentsByCardId.value = { + ...state.cardCommentsByCardId.value, + [cardId]: existingComments.filter((comment) => comment.id !== commentId), + } } helpers.toast.success('Comment deleted') } catch (e: unknown) { From 2b21dddebfc1b8e2460dee905be532c778b92cbd Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Sun, 20 Sep 2026 15:58:32 +0100 Subject: [PATCH 03/12] test(comments): pin late write board ownership --- .../board/cardCommentStoreNavigation.spec.ts | 138 ++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 frontend/taskdeck-web/src/tests/store/board/cardCommentStoreNavigation.spec.ts diff --git a/frontend/taskdeck-web/src/tests/store/board/cardCommentStoreNavigation.spec.ts b/frontend/taskdeck-web/src/tests/store/board/cardCommentStoreNavigation.spec.ts new file mode 100644 index 000000000..ec4843e24 --- /dev/null +++ b/frontend/taskdeck-web/src/tests/store/board/cardCommentStoreNavigation.spec.ts @@ -0,0 +1,138 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' + +const { mockCardCommentsApi } = vi.hoisted(() => ({ + mockCardCommentsApi: { + getComments: vi.fn(), + createComment: vi.fn(), + updateComment: vi.fn(), + deleteComment: vi.fn(), + }, +})) + +vi.mock('../../../api/cardCommentsApi', () => ({ + cardCommentsApi: mockCardCommentsApi, +})) + +import { createCardCommentActions } from '../../../store/board/cardCommentStore' + +interface TestComment { + id: string + content: string + createdAt: string + updatedAt: string +} + +const originalComment: TestComment = { + id: 'cmt-1', + content: 'Original', + createdAt: '2026-09-20T10:00:00Z', + updatedAt: '2026-09-20T10:00:00Z', +} + +function createState() { + return { + currentBoard: ref<{ id: string } | null>({ id: 'board-1' }), + cardCommentsByCardId: ref>({ + 'card-1': [{ ...originalComment }], + }), + loading: ref(false), + error: ref(null), + } +} + +function createHelpers() { + return { + guardDemoMutation: vi.fn(), + handleApiError: vi.fn(), + isDemoMode: false, + toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() }, + } +} + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((settle) => { + resolve = settle + }) + return { promise, resolve } +} + +function navigateToNextBoard(state: ReturnType) { + const nextBoardCache = { + 'card-next': [{ + ...originalComment, + id: 'cmt-next', + content: 'Next board', + }], + } + state.currentBoard.value = { id: 'board-2' } + state.cardCommentsByCardId.value = nextBoardCache + return nextBoardCache +} + +describe('cardCommentStore late write ownership', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCardCommentsApi.createComment.mockReset() + mockCardCommentsApi.updateComment.mockReset() + mockCardCommentsApi.deleteComment.mockReset() + }) + + it('does not append a create response after another board is selected', async () => { + const state = createState() + const helpers = createHelpers() + const response = deferred() + mockCardCommentsApi.createComment.mockReturnValueOnce(response.promise) + const { createCardComment } = createCardCommentActions(state as never, helpers as never) + + const pendingCreate = createCardComment('board-1', 'card-1', { content: 'Created' }) + const nextBoardCache = navigateToNextBoard(state) + const created = { + ...originalComment, + id: 'cmt-created', + content: 'Created', + } + response.resolve(created) + const result = await pendingCreate + + expect(result).toEqual(created) + expect(state.cardCommentsByCardId.value).toEqual(nextBoardCache) + }) + + it('does not apply an update response after another board is selected', async () => { + const state = createState() + const helpers = createHelpers() + const response = deferred() + mockCardCommentsApi.updateComment.mockReturnValueOnce(response.promise) + const { updateCardComment } = createCardCommentActions(state as never, helpers as never) + + const pendingUpdate = updateCardComment('board-1', 'card-1', 'cmt-1', { + content: 'Edited', + }) + const nextBoardCache = navigateToNextBoard(state) + response.resolve({ + ...originalComment, + content: 'Edited', + updatedAt: '2026-09-20T10:02:00Z', + }) + await pendingUpdate + + expect(state.cardCommentsByCardId.value).toEqual(nextBoardCache) + }) + + it('does not apply a delete response after another board is selected', async () => { + const state = createState() + const helpers = createHelpers() + const response = deferred() + mockCardCommentsApi.deleteComment.mockReturnValueOnce(response.promise) + const { deleteCardComment } = createCardCommentActions(state as never, helpers as never) + + const pendingDelete = deleteCardComment('board-1', 'card-1', 'cmt-1') + const nextBoardCache = navigateToNextBoard(state) + response.resolve(undefined) + await pendingDelete + + expect(state.cardCommentsByCardId.value).toEqual(nextBoardCache) + }) +}) From c48076369c31c95740ea8f8026317d09cdcd66b3 Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Sun, 20 Sep 2026 17:40:50 +0100 Subject: [PATCH 04/12] fix(comments): bind settlements to visit and mutation order --- .../src/store/board/cardCommentStore.ts | 101 +++++++++++------- 1 file changed, 64 insertions(+), 37 deletions(-) diff --git a/frontend/taskdeck-web/src/store/board/cardCommentStore.ts b/frontend/taskdeck-web/src/store/board/cardCommentStore.ts index 615656366..428df8d3e 100644 --- a/frontend/taskdeck-web/src/store/board/cardCommentStore.ts +++ b/frontend/taskdeck-web/src/store/board/cardCommentStore.ts @@ -6,11 +6,22 @@ import type { CardComment, CreateCardCommentDto, UpdateCardCommentDto } from '.. import type { BoardState } from './boardState' import type { BoardHelpers } from './boardStoreHelpers' +interface CommentCacheVisit { + boardId: string + cache: Record +} + +interface CommentMutationRequest { + key: string + version: number +} + export function createCardCommentActions(state: BoardState, helpers: BoardHelpers) { // Reads and writes share one per-card cache. Keep their ordering metadata in // the store closure rather than exposing transport generations in UI callers. const readVersionByCardId = new Map() const mutationVersionByCardId = new Map() + const mutationRequestVersionByCommentKey = new Map() function nextReadVersion(cardId: string) { const version = (readVersionByCardId.get(cardId) ?? 0) + 1 @@ -26,11 +37,30 @@ export function createCardCommentActions(state: BoardState, helpers: BoardHelper mutationVersionByCardId.set(cardId, currentMutationVersion(cardId) + 1) } - function ownsCurrentCommentCache(boardId: string) { - // Null preserves the existing pre-load/store-test convention. Optional - // access keeps lightweight unit fixtures that predate currentBoard valid. + function captureCommentCacheVisit(boardId: string): CommentCacheVisit { + return { + boardId, + cache: state.cardCommentsByCardId.value, + } + } + + function ownsCurrentCommentCache(visit: CommentCacheVisit) { const currentBoard = state.currentBoard?.value - return currentBoard == null || currentBoard.id === boardId + return ( + (currentBoard == null || currentBoard.id === visit.boardId) && + state.cardCommentsByCardId.value === visit.cache + ) + } + + function beginCommentMutation(cardId: string, commentId: string): CommentMutationRequest { + const key = `${cardId}:${commentId}` + const version = (mutationRequestVersionByCommentKey.get(key) ?? 0) + 1 + mutationRequestVersionByCommentKey.set(key, version) + return { key, version } + } + + function isCurrentCommentMutation(request: CommentMutationRequest) { + return mutationRequestVersionByCommentKey.get(request.key) === request.version } function getCardComments(cardId: string): CardComment[] { @@ -39,6 +69,7 @@ export function createCardCommentActions(state: BoardState, helpers: BoardHelper async function fetchCardComments(boardId: string, cardId: string) { if (helpers.isDemoMode) return [] + const visit = captureCommentCacheVisit(boardId) const readVersion = nextReadVersion(cardId) const mutationVersion = currentMutationVersion(cardId) try { @@ -47,14 +78,14 @@ export function createCardCommentActions(state: BoardState, helpers: BoardHelper // invalidates every snapshot that began before it, even when that older // request returns later. The payload is still returned to its caller. if ( - ownsCurrentCommentCache(boardId) && + ownsCurrentCommentCache(visit) && readVersionByCardId.get(cardId) === readVersion && currentMutationVersion(cardId) === mutationVersion ) { - state.cardCommentsByCardId.value = { - ...state.cardCommentsByCardId.value, - [cardId]: comments, - } + // Mutate the per-card slot rather than replacing the cache container. + // Board-detail commits and logout replace that container, so its identity + // is the visit/session generation without invalidating same-visit writes. + state.cardCommentsByCardId.value[cardId] = comments } return comments } catch (e: unknown) { @@ -65,27 +96,24 @@ export function createCardCommentActions(state: BoardState, helpers: BoardHelper async function createCardComment(boardId: string, cardId: string, dto: CreateCardCommentDto) { helpers.guardDemoMutation() + const visit = captureCommentCacheVisit(boardId) try { state.loading.value = true state.error.value = null const createdComment = await cardCommentsApi.createComment(boardId, cardId, dto) - markCommentMutation(cardId) - if (ownsCurrentCommentCache(boardId)) { + if (ownsCurrentCommentCache(visit)) { + markCommentMutation(cardId) const existingComments = state.cardCommentsByCardId.value[cardId] ?? [] // A board refresh can commit the stable id before this response arrives. // Preserve that fresher object instead of appending a duplicate. if (!existingComments.some(comment => comment.id === createdComment.id)) { - state.cardCommentsByCardId.value = { - ...state.cardCommentsByCardId.value, - [cardId]: [...existingComments, createdComment].sort( - (left, right) => - new Date(left.createdAt).getTime() - new Date(right.createdAt).getTime(), - ), - } + state.cardCommentsByCardId.value[cardId] = [...existingComments, createdComment].sort( + (left, right) => + new Date(left.createdAt).getTime() - new Date(right.createdAt).getTime(), + ) } + helpers.toast.success('Comment added') } - - helpers.toast.success('Comment added') return createdComment } catch (e: unknown) { helpers.handleApiError(e, 'Failed to create card comment') @@ -102,22 +130,20 @@ export function createCardCommentActions(state: BoardState, helpers: BoardHelper dto: UpdateCardCommentDto, ) { helpers.guardDemoMutation() + const visit = captureCommentCacheVisit(boardId) + const mutationRequest = beginCommentMutation(cardId, commentId) try { state.loading.value = true state.error.value = null const updatedComment = await cardCommentsApi.updateComment(boardId, cardId, commentId, dto) - markCommentMutation(cardId) - if (ownsCurrentCommentCache(boardId)) { + if (ownsCurrentCommentCache(visit) && isCurrentCommentMutation(mutationRequest)) { + markCommentMutation(cardId) const existingComments = state.cardCommentsByCardId.value[cardId] ?? [] - state.cardCommentsByCardId.value = { - ...state.cardCommentsByCardId.value, - [cardId]: existingComments.map((comment) => - comment.id === commentId ? updatedComment : comment, - ), - } + state.cardCommentsByCardId.value[cardId] = existingComments.map((comment) => + comment.id === commentId ? updatedComment : comment, + ) + helpers.toast.success('Comment updated') } - - helpers.toast.success('Comment updated') return updatedComment } catch (e: unknown) { helpers.handleApiError(e, 'Failed to update card comment') @@ -129,19 +155,20 @@ export function createCardCommentActions(state: BoardState, helpers: BoardHelper async function deleteCardComment(boardId: string, cardId: string, commentId: string) { helpers.guardDemoMutation() + const visit = captureCommentCacheVisit(boardId) + const mutationRequest = beginCommentMutation(cardId, commentId) try { state.loading.value = true state.error.value = null await cardCommentsApi.deleteComment(boardId, cardId, commentId) - markCommentMutation(cardId) - if (ownsCurrentCommentCache(boardId)) { + if (ownsCurrentCommentCache(visit) && isCurrentCommentMutation(mutationRequest)) { + markCommentMutation(cardId) const existingComments = state.cardCommentsByCardId.value[cardId] ?? [] - state.cardCommentsByCardId.value = { - ...state.cardCommentsByCardId.value, - [cardId]: existingComments.filter((comment) => comment.id !== commentId), - } + state.cardCommentsByCardId.value[cardId] = existingComments.filter( + (comment) => comment.id !== commentId, + ) + helpers.toast.success('Comment deleted') } - helpers.toast.success('Comment deleted') } catch (e: unknown) { helpers.handleApiError(e, 'Failed to delete card comment') throw e From 6c72a6b13fb397467cff012693d807407a76bafe Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Sun, 20 Sep 2026 17:41:10 +0100 Subject: [PATCH 05/12] test(comments): pin revisit and mutation-order ownership --- .../cardCommentStoreVisitOrdering.spec.ts | 158 ++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 frontend/taskdeck-web/src/tests/store/board/cardCommentStoreVisitOrdering.spec.ts diff --git a/frontend/taskdeck-web/src/tests/store/board/cardCommentStoreVisitOrdering.spec.ts b/frontend/taskdeck-web/src/tests/store/board/cardCommentStoreVisitOrdering.spec.ts new file mode 100644 index 000000000..3d42f6568 --- /dev/null +++ b/frontend/taskdeck-web/src/tests/store/board/cardCommentStoreVisitOrdering.spec.ts @@ -0,0 +1,158 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' + +const { mockCardCommentsApi } = vi.hoisted(() => ({ + mockCardCommentsApi: { + getComments: vi.fn(), + createComment: vi.fn(), + updateComment: vi.fn(), + deleteComment: vi.fn(), + }, +})) + +vi.mock('../../../api/cardCommentsApi', () => ({ + cardCommentsApi: mockCardCommentsApi, +})) + +import { createCardCommentActions } from '../../../store/board/cardCommentStore' + +interface TestComment { + id: string + content: string + createdAt: string + updatedAt: string +} + +const originalComment: TestComment = { + id: 'cmt-1', + content: 'Original', + createdAt: '2026-09-20T10:00:00Z', + updatedAt: '2026-09-20T10:00:00Z', +} + +function createState() { + return { + currentBoard: ref<{ id: string } | null>({ id: 'board-1' }), + cardCommentsByCardId: ref>({ + 'card-1': [{ ...originalComment }], + }), + loading: ref(false), + error: ref(null), + } +} + +function createHelpers() { + return { + guardDemoMutation: vi.fn(), + handleApiError: vi.fn(), + isDemoMode: false, + toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() }, + } +} + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((settle) => { + resolve = settle + }) + return { promise, resolve } +} + +describe('cardCommentStore visit and mutation ownership', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCardCommentsApi.getComments.mockReset() + mockCardCommentsApi.updateComment.mockReset() + }) + + it('does not let an earlier board visit invalidate the authoritative read after A to B to A', async () => { + const state = createState() + const helpers = createHelpers() + const oldVisitUpdate = deferred() + const reopenedRead = deferred() + mockCardCommentsApi.updateComment.mockReturnValueOnce(oldVisitUpdate.promise) + mockCardCommentsApi.getComments.mockReturnValueOnce(reopenedRead.promise) + const actions = createCardCommentActions(state as never, helpers as never) + + const pendingUpdate = actions.updateCardComment( + 'board-1', + 'card-1', + 'cmt-1', + { content: 'Old visit edit' }, + ) + + state.currentBoard.value = { id: 'board-2' } + state.cardCommentsByCardId.value = {} + state.currentBoard.value = { id: 'board-1' } + state.cardCommentsByCardId.value = {} + const pendingRead = actions.fetchCardComments('board-1', 'card-1') + + oldVisitUpdate.resolve({ + ...originalComment, + content: 'Old visit edit', + updatedAt: '2026-09-20T10:01:00Z', + }) + await pendingUpdate + reopenedRead.resolve([{ + ...originalComment, + content: 'Authoritative reopened value', + updatedAt: '2026-09-20T10:02:00Z', + }]) + await pendingRead + + expect(state.cardCommentsByCardId.value['card-1']).toEqual([{ + ...originalComment, + content: 'Authoritative reopened value', + updatedAt: '2026-09-20T10:02:00Z', + }]) + expect(helpers.toast.success).not.toHaveBeenCalledWith('Comment updated') + }) + + it('does not let an older edit settle over a newer edit or invalidate its refresh', async () => { + const state = createState() + const helpers = createHelpers() + const firstEdit = deferred() + const secondEdit = deferred() + const authoritativeRead = deferred() + mockCardCommentsApi.updateComment + .mockReturnValueOnce(firstEdit.promise) + .mockReturnValueOnce(secondEdit.promise) + mockCardCommentsApi.getComments.mockReturnValueOnce(authoritativeRead.promise) + const actions = createCardCommentActions(state as never, helpers as never) + + const pendingFirst = actions.updateCardComment( + 'board-1', + 'card-1', + 'cmt-1', + { content: 'First edit' }, + ) + const pendingSecond = actions.updateCardComment( + 'board-1', + 'card-1', + 'cmt-1', + { content: 'Second edit' }, + ) + + const secondResult = { + ...originalComment, + content: 'Second edit', + updatedAt: '2026-09-20T10:02:00Z', + } + secondEdit.resolve(secondResult) + await pendingSecond + const pendingRead = actions.fetchCardComments('board-1', 'card-1') + + firstEdit.resolve({ + ...originalComment, + content: 'First edit', + updatedAt: '2026-09-20T10:01:00Z', + }) + await pendingFirst + authoritativeRead.resolve([secondResult]) + await pendingRead + + expect(state.cardCommentsByCardId.value['card-1']).toEqual([secondResult]) + expect(helpers.toast.success).toHaveBeenCalledTimes(1) + expect(helpers.toast.success).toHaveBeenCalledWith('Comment updated') + }) +}) From e35f34c0a414b42d1d2eb85e18a9126c4ce2ffd1 Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Sun, 20 Sep 2026 18:17:37 +0100 Subject: [PATCH 06/12] fix(comments): serialize writes per comment --- .../src/store/board/cardCommentStore.ts | 56 ++++++++++++------- 1 file changed, 37 insertions(+), 19 deletions(-) diff --git a/frontend/taskdeck-web/src/store/board/cardCommentStore.ts b/frontend/taskdeck-web/src/store/board/cardCommentStore.ts index 428df8d3e..73c7cfa9a 100644 --- a/frontend/taskdeck-web/src/store/board/cardCommentStore.ts +++ b/frontend/taskdeck-web/src/store/board/cardCommentStore.ts @@ -11,17 +11,12 @@ interface CommentCacheVisit { cache: Record } -interface CommentMutationRequest { - key: string - version: number -} - export function createCardCommentActions(state: BoardState, helpers: BoardHelpers) { // Reads and writes share one per-card cache. Keep their ordering metadata in // the store closure rather than exposing transport generations in UI callers. const readVersionByCardId = new Map() const mutationVersionByCardId = new Map() - const mutationRequestVersionByCommentKey = new Map() + const mutationTailByCommentKey = new Map>() function nextReadVersion(cardId: string) { const version = (readVersionByCardId.get(cardId) ?? 0) + 1 @@ -52,15 +47,29 @@ export function createCardCommentActions(state: BoardState, helpers: BoardHelper ) } - function beginCommentMutation(cardId: string, commentId: string): CommentMutationRequest { + async function runCommentMutation( + cardId: string, + commentId: string, + mutation: () => Promise, + ): Promise { const key = `${cardId}:${commentId}` - const version = (mutationRequestVersionByCommentKey.get(key) ?? 0) + 1 - mutationRequestVersionByCommentKey.set(key, version) - return { key, version } - } + const previous = mutationTailByCommentKey.get(key) ?? Promise.resolve() + // A failed predecessor must not cancel a later user intent. It still settles + // through its own caller/error path; the next request starts afterward. + const operation = previous.catch(() => undefined).then(mutation) + const tail = operation.then( + () => undefined, + () => undefined, + ) + mutationTailByCommentKey.set(key, tail) - function isCurrentCommentMutation(request: CommentMutationRequest) { - return mutationRequestVersionByCommentKey.get(request.key) === request.version + try { + return await operation + } finally { + if (mutationTailByCommentKey.get(key) === tail) { + mutationTailByCommentKey.delete(key) + } + } } function getCardComments(cardId: string): CardComment[] { @@ -131,12 +140,18 @@ export function createCardCommentActions(state: BoardState, helpers: BoardHelper ) { helpers.guardDemoMutation() const visit = captureCommentCacheVisit(boardId) - const mutationRequest = beginCommentMutation(cardId, commentId) try { state.loading.value = true state.error.value = null - const updatedComment = await cardCommentsApi.updateComment(boardId, cardId, commentId, dto) - if (ownsCurrentCommentCache(visit) && isCurrentCommentMutation(mutationRequest)) { + // The API has no revision/If-Match field. Serialize same-comment writes so + // server commit order follows user intent order; filtering a late success + // client-side would otherwise let the server silently keep the older edit. + const updatedComment = await runCommentMutation( + cardId, + commentId, + () => cardCommentsApi.updateComment(boardId, cardId, commentId, dto), + ) + if (ownsCurrentCommentCache(visit)) { markCommentMutation(cardId) const existingComments = state.cardCommentsByCardId.value[cardId] ?? [] state.cardCommentsByCardId.value[cardId] = existingComments.map((comment) => @@ -156,12 +171,15 @@ export function createCardCommentActions(state: BoardState, helpers: BoardHelper async function deleteCardComment(boardId: string, cardId: string, commentId: string) { helpers.guardDemoMutation() const visit = captureCommentCacheVisit(boardId) - const mutationRequest = beginCommentMutation(cardId, commentId) try { state.loading.value = true state.error.value = null - await cardCommentsApi.deleteComment(boardId, cardId, commentId) - if (ownsCurrentCommentCache(visit) && isCurrentCommentMutation(mutationRequest)) { + await runCommentMutation( + cardId, + commentId, + () => cardCommentsApi.deleteComment(boardId, cardId, commentId), + ) + if (ownsCurrentCommentCache(visit)) { markCommentMutation(cardId) const existingComments = state.cardCommentsByCardId.value[cardId] ?? [] state.cardCommentsByCardId.value[cardId] = existingComments.filter( From cae9a12194800f97b3003953f5ee1817bd92de37 Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Sun, 20 Sep 2026 18:18:09 +0100 Subject: [PATCH 07/12] test(comments): prove same-comment writes serialize --- .../cardCommentStoreVisitOrdering.spec.ts | 35 +++++++++++++------ 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/frontend/taskdeck-web/src/tests/store/board/cardCommentStoreVisitOrdering.spec.ts b/frontend/taskdeck-web/src/tests/store/board/cardCommentStoreVisitOrdering.spec.ts index 3d42f6568..8cb96a9b5 100644 --- a/frontend/taskdeck-web/src/tests/store/board/cardCommentStoreVisitOrdering.spec.ts +++ b/frontend/taskdeck-web/src/tests/store/board/cardCommentStoreVisitOrdering.spec.ts @@ -58,6 +58,11 @@ function deferred() { return { promise, resolve } } +async function flushPromises() { + await Promise.resolve() + await Promise.resolve() +} + describe('cardCommentStore visit and mutation ownership', () => { beforeEach(() => { vi.clearAllMocks() @@ -108,7 +113,7 @@ describe('cardCommentStore visit and mutation ownership', () => { expect(helpers.toast.success).not.toHaveBeenCalledWith('Comment updated') }) - it('does not let an older edit settle over a newer edit or invalidate its refresh', async () => { + it('serializes overlapping edits so the later intent commits last and owns the cache', async () => { const state = createState() const helpers = createHelpers() const firstEdit = deferred() @@ -133,6 +138,21 @@ describe('cardCommentStore visit and mutation ownership', () => { { content: 'Second edit' }, ) + await flushPromises() + expect(mockCardCommentsApi.updateComment).toHaveBeenCalledTimes(1) + + const firstResult = { + ...originalComment, + content: 'First edit', + updatedAt: '2026-09-20T10:01:00Z', + } + firstEdit.resolve(firstResult) + await pendingFirst + await flushPromises() + expect(mockCardCommentsApi.updateComment).toHaveBeenCalledTimes(2) + expect(state.cardCommentsByCardId.value['card-1']).toEqual([firstResult]) + + const pendingRead = actions.fetchCardComments('board-1', 'card-1') const secondResult = { ...originalComment, content: 'Second edit', @@ -140,19 +160,12 @@ describe('cardCommentStore visit and mutation ownership', () => { } secondEdit.resolve(secondResult) await pendingSecond - const pendingRead = actions.fetchCardComments('board-1', 'card-1') - - firstEdit.resolve({ - ...originalComment, - content: 'First edit', - updatedAt: '2026-09-20T10:01:00Z', - }) - await pendingFirst authoritativeRead.resolve([secondResult]) await pendingRead expect(state.cardCommentsByCardId.value['card-1']).toEqual([secondResult]) - expect(helpers.toast.success).toHaveBeenCalledTimes(1) - expect(helpers.toast.success).toHaveBeenCalledWith('Comment updated') + expect(helpers.toast.success).toHaveBeenCalledTimes(2) + expect(helpers.toast.success).toHaveBeenNthCalledWith(1, 'Comment updated') + expect(helpers.toast.success).toHaveBeenNthCalledWith(2, 'Comment updated') }) }) From 14948e0f2aea96c571afe9f9d0c670bb464bca04 Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Sun, 20 Sep 2026 19:47:40 +0100 Subject: [PATCH 08/12] test(comments): pin session and refresh reconciliation --- .../cardCommentStoreVisitOrdering.spec.ts | 171 ++++++++++++++++-- 1 file changed, 152 insertions(+), 19 deletions(-) diff --git a/frontend/taskdeck-web/src/tests/store/board/cardCommentStoreVisitOrdering.spec.ts b/frontend/taskdeck-web/src/tests/store/board/cardCommentStoreVisitOrdering.spec.ts index 8cb96a9b5..0ac601d19 100644 --- a/frontend/taskdeck-web/src/tests/store/board/cardCommentStoreVisitOrdering.spec.ts +++ b/frontend/taskdeck-web/src/tests/store/board/cardCommentStoreVisitOrdering.spec.ts @@ -30,6 +30,13 @@ const originalComment: TestComment = { updatedAt: '2026-09-20T10:00:00Z', } +const secondComment: TestComment = { + id: 'cmt-2', + content: 'Second', + createdAt: '2026-09-20T10:01:00Z', + updatedAt: '2026-09-20T10:01:00Z', +} + function createState() { return { currentBoard: ref<{ id: string } | null>({ id: 'board-1' }), @@ -46,7 +53,7 @@ function createHelpers() { guardDemoMutation: vi.fn(), handleApiError: vi.fn(), isDemoMode: false, - toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() }, + toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() }, } } @@ -67,16 +74,97 @@ describe('cardCommentStore visit and mutation ownership', () => { beforeEach(() => { vi.clearAllMocks() mockCardCommentsApi.getComments.mockReset() + mockCardCommentsApi.createComment.mockReset() mockCardCommentsApi.updateComment.mockReset() + mockCardCommentsApi.deleteComment.mockReset() + }) + + it('patches the current same-board cache when a detail refresh replaces it before update settlement', async () => { + const state = createState() + const helpers = createHelpers() + const update = deferred() + mockCardCommentsApi.updateComment.mockReturnValueOnce(update.promise) + const actions = createCardCommentActions(state as never, helpers as never) + + const pendingUpdate = actions.updateCardComment( + 'board-1', + 'card-1', + 'cmt-1', + { content: 'Edited' }, + ) + + const refreshedCache = { + 'card-1': [{ ...originalComment, content: 'Pre-write refresh' }], + } + state.cardCommentsByCardId.value = refreshedCache + const updated = { + ...originalComment, + content: 'Edited', + updatedAt: '2026-09-20T10:02:00Z', + } + update.resolve(updated) + await pendingUpdate + + expect(state.cardCommentsByCardId.value).toBe(refreshedCache) + expect(state.cardCommentsByCardId.value['card-1']).toEqual([updated]) + expect(mockCardCommentsApi.getComments).not.toHaveBeenCalled() + }) + + it('patches the current same-board cache when a detail refresh replaces it before create settlement', async () => { + const state = createState() + const helpers = createHelpers() + const create = deferred() + mockCardCommentsApi.createComment.mockReturnValueOnce(create.promise) + const actions = createCardCommentActions(state as never, helpers as never) + + const pendingCreate = actions.createCardComment('board-1', 'card-1', { + content: 'Second', + }) + + const refreshedCache = { + 'card-1': [{ ...originalComment, content: 'Pre-write refresh' }], + } + state.cardCommentsByCardId.value = refreshedCache + create.resolve({ ...secondComment }) + await pendingCreate + + expect(state.cardCommentsByCardId.value).toBe(refreshedCache) + expect(state.cardCommentsByCardId.value['card-1'].map(comment => comment.id)).toEqual([ + 'cmt-1', + 'cmt-2', + ]) + }) + + it('patches the current same-board cache when a detail refresh replaces it before delete settlement', async () => { + const state = createState() + state.cardCommentsByCardId.value['card-1'].push({ ...secondComment }) + const helpers = createHelpers() + const deletion = deferred() + mockCardCommentsApi.deleteComment.mockReturnValueOnce(deletion.promise) + const actions = createCardCommentActions(state as never, helpers as never) + + const pendingDelete = actions.deleteCardComment('board-1', 'card-1', 'cmt-1') + + const refreshedCache = { + 'card-1': [{ ...originalComment }, { ...secondComment }], + } + state.cardCommentsByCardId.value = refreshedCache + deletion.resolve(undefined) + await pendingDelete + + expect(state.cardCommentsByCardId.value).toBe(refreshedCache) + expect(state.cardCommentsByCardId.value['card-1'].map(comment => comment.id)).toEqual([ + 'cmt-2', + ]) }) - it('does not let an earlier board visit invalidate the authoritative read after A to B to A', async () => { + it('reconciles a successful old-visit write into the currently reopened same board', async () => { const state = createState() const helpers = createHelpers() const oldVisitUpdate = deferred() - const reopenedRead = deferred() + const reconciliation = deferred() mockCardCommentsApi.updateComment.mockReturnValueOnce(oldVisitUpdate.promise) - mockCardCommentsApi.getComments.mockReturnValueOnce(reopenedRead.promise) + mockCardCommentsApi.getComments.mockReturnValueOnce(reconciliation.promise) const actions = createCardCommentActions(state as never, helpers as never) const pendingUpdate = actions.updateCardComment( @@ -89,30 +177,75 @@ describe('cardCommentStore visit and mutation ownership', () => { state.currentBoard.value = { id: 'board-2' } state.cardCommentsByCardId.value = {} state.currentBoard.value = { id: 'board-1' } - state.cardCommentsByCardId.value = {} - const pendingRead = actions.fetchCardComments('board-1', 'card-1') + state.cardCommentsByCardId.value = { + 'card-1': [{ ...originalComment, content: 'Reopened pre-write value' }], + } + const reopenedCache = state.cardCommentsByCardId.value - oldVisitUpdate.resolve({ + const updated = { ...originalComment, content: 'Old visit edit', updatedAt: '2026-09-20T10:01:00Z', - }) + } + oldVisitUpdate.resolve(updated) + await flushPromises() + expect(mockCardCommentsApi.getComments).toHaveBeenCalledWith('board-1', 'card-1') + + reconciliation.resolve([updated]) await pendingUpdate - reopenedRead.resolve([{ - ...originalComment, - content: 'Authoritative reopened value', - updatedAt: '2026-09-20T10:02:00Z', - }]) - await pendingRead - expect(state.cardCommentsByCardId.value['card-1']).toEqual([{ - ...originalComment, - content: 'Authoritative reopened value', - updatedAt: '2026-09-20T10:02:00Z', - }]) + expect(state.cardCommentsByCardId.value).toBe(reopenedCache) + expect(state.cardCommentsByCardId.value['card-1']).toEqual([updated]) expect(helpers.toast.success).not.toHaveBeenCalledWith('Comment updated') }) + it('does not start a queued comment write after the board session has ended', async () => { + const state = createState() + const helpers = createHelpers() + const firstEdit = deferred() + mockCardCommentsApi.updateComment + .mockReturnValueOnce(firstEdit.promise) + .mockResolvedValueOnce({ + ...originalComment, + content: 'Second edit', + updatedAt: '2026-09-20T10:02:00Z', + }) + const actions = createCardCommentActions(state as never, helpers as never) + + const pendingFirst = actions.updateCardComment( + 'board-1', + 'card-1', + 'cmt-1', + { content: 'First edit' }, + ) + const pendingSecond = actions + .updateCardComment( + 'board-1', + 'card-1', + 'cmt-1', + { content: 'Second edit' }, + ) + .catch(error => error as Error) + + await flushPromises() + expect(mockCardCommentsApi.updateComment).toHaveBeenCalledTimes(1) + + state.currentBoard.value = null + state.cardCommentsByCardId.value = {} + firstEdit.resolve({ + ...originalComment, + content: 'First edit', + updatedAt: '2026-09-20T10:01:00Z', + }) + await pendingFirst + const cancellation = await pendingSecond + + expect(cancellation.name).toBe('StaleBoardVisitError') + expect(mockCardCommentsApi.updateComment).toHaveBeenCalledTimes(1) + expect(helpers.handleApiError).not.toHaveBeenCalled() + expect(state.cardCommentsByCardId.value).toEqual({}) + }) + it('serializes overlapping edits so the later intent commits last and owns the cache', async () => { const state = createState() const helpers = createHelpers() From 7882346247914eff5a02e54e3ce8f631a8631d17 Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Sun, 20 Sep 2026 19:48:13 +0100 Subject: [PATCH 09/12] fix(comments): gate queued writes by board visit --- .../src/store/board/cardCommentStore.ts | 141 ++++++++++++++---- 1 file changed, 110 insertions(+), 31 deletions(-) diff --git a/frontend/taskdeck-web/src/store/board/cardCommentStore.ts b/frontend/taskdeck-web/src/store/board/cardCommentStore.ts index 73c7cfa9a..f701d66c9 100644 --- a/frontend/taskdeck-web/src/store/board/cardCommentStore.ts +++ b/frontend/taskdeck-web/src/store/board/cardCommentStore.ts @@ -1,6 +1,7 @@ /** * Card comment operations: fetch, create, update, delete comments. */ +import { watch } from 'vue' import { cardCommentsApi } from '../../api/cardCommentsApi' import type { CardComment, CreateCardCommentDto, UpdateCardCommentDto } from '../../types/comments' import type { BoardState } from './boardState' @@ -9,14 +10,34 @@ import type { BoardHelpers } from './boardStoreHelpers' interface CommentCacheVisit { boardId: string cache: Record + generation: number +} + +class StaleBoardVisitError extends Error { + constructor() { + super('The board visit that queued this comment change has ended.') + this.name = 'StaleBoardVisitError' + } } export function createCardCommentActions(state: BoardState, helpers: BoardHelpers) { - // Reads and writes share one per-card cache. Keep their ordering metadata in - // the store closure rather than exposing transport generations in UI callers. + // Reads and writes share one per-card cache. Cache-container identity protects + // ordinary reads, while a synchronous board-id generation distinguishes one + // visit/session from A→B→A or logout→login. Same-board detail refreshes keep + // that generation and may therefore receive a confirmed write into their new + // cache container. const readVersionByCardId = new Map() const mutationVersionByCardId = new Map() const mutationTailByCommentKey = new Map>() + let boardVisitGeneration = 0 + + watch( + () => state.currentBoard?.value?.id ?? null, + (nextBoardId, previousBoardId) => { + if (nextBoardId !== previousBoardId) boardVisitGeneration++ + }, + { flush: 'sync' }, + ) function nextReadVersion(cardId: string) { const version = (readVersionByCardId.get(cardId) ?? 0) + 1 @@ -36,27 +57,39 @@ export function createCardCommentActions(state: BoardState, helpers: BoardHelper return { boardId, cache: state.cardCommentsByCardId.value, + generation: boardVisitGeneration, } } - function ownsCurrentCommentCache(visit: CommentCacheVisit) { + function isCurrentBoardVisit(visit: CommentCacheVisit) { const currentBoard = state.currentBoard?.value return ( (currentBoard == null || currentBoard.id === visit.boardId) && - state.cardCommentsByCardId.value === visit.cache + boardVisitGeneration === visit.generation ) } + function ownsExactCommentCache(visit: CommentCacheVisit) { + return isCurrentBoardVisit(visit) && state.cardCommentsByCardId.value === visit.cache + } + async function runCommentMutation( cardId: string, commentId: string, + visit: CommentCacheVisit, mutation: () => Promise, ): Promise { const key = `${cardId}:${commentId}` const previous = mutationTailByCommentKey.get(key) ?? Promise.resolve() // A failed predecessor must not cancel a later user intent. It still settles // through its own caller/error path; the next request starts afterward. - const operation = previous.catch(() => undefined).then(mutation) + const operation = previous.catch(() => undefined).then(() => { + // The HTTP interceptor reads the token when transport starts. Reject a + // queued pre-logout intent before the API callback can run under another + // session's credentials. + if (!isCurrentBoardVisit(visit)) throw new StaleBoardVisitError() + return mutation() + }) const tail = operation.then( () => undefined, () => undefined, @@ -72,6 +105,33 @@ export function createCardCommentActions(state: BoardState, helpers: BoardHelper } } + async function reconcileCurrentCommentsAfterStaleVisit(boardId: string, cardId: string) { + if (state.currentBoard?.value?.id !== boardId) return + + const visit = captureCommentCacheVisit(boardId) + const readVersion = nextReadVersion(cardId) + const mutationVersion = currentMutationVersion(cardId) + try { + const comments = await cardCommentsApi.getComments(boardId, cardId) + if ( + isCurrentBoardVisit(visit) && + readVersionByCardId.get(cardId) === readVersion && + currentMutationVersion(cardId) === mutationVersion + ) { + // This read begins only after the write succeeded. It is authoritative + // for the currently reopened visit, while any older read is rejected by + // the version/mutation guards above. + state.cardCommentsByCardId.value[cardId] = comments + } + } catch { + if (isCurrentBoardVisit(visit)) { + helpers.toast.warning( + 'Comment saved, but comments could not be refreshed. Reopen the card before editing again.', + ) + } + } + } + function getCardComments(cardId: string): CardComment[] { return state.cardCommentsByCardId.value[cardId] ?? [] } @@ -87,18 +147,17 @@ export function createCardCommentActions(state: BoardState, helpers: BoardHelper // invalidates every snapshot that began before it, even when that older // request returns later. The payload is still returned to its caller. if ( - ownsCurrentCommentCache(visit) && + ownsExactCommentCache(visit) && readVersionByCardId.get(cardId) === readVersion && currentMutationVersion(cardId) === mutationVersion ) { - // Mutate the per-card slot rather than replacing the cache container. - // Board-detail commits and logout replace that container, so its identity - // is the visit/session generation without invalidating same-visit writes. - state.cardCommentsByCardId.value[cardId] = comments + visit.cache[cardId] = comments } return comments } catch (e: unknown) { - helpers.handleApiError(e, 'Failed to fetch card comments') + if (ownsExactCommentCache(visit)) { + helpers.handleApiError(e, 'Failed to fetch card comments') + } throw e } } @@ -110,25 +169,31 @@ export function createCardCommentActions(state: BoardState, helpers: BoardHelper state.loading.value = true state.error.value = null const createdComment = await cardCommentsApi.createComment(boardId, cardId, dto) - if (ownsCurrentCommentCache(visit)) { - markCommentMutation(cardId) - const existingComments = state.cardCommentsByCardId.value[cardId] ?? [] - // A board refresh can commit the stable id before this response arrives. - // Preserve that fresher object instead of appending a duplicate. + markCommentMutation(cardId) + + if (isCurrentBoardVisit(visit)) { + const currentCache = state.cardCommentsByCardId.value + const existingComments = currentCache[cardId] ?? [] + // A same-board refresh can commit the stable id before this response + // arrives. Preserve that fresher object instead of appending a duplicate. if (!existingComments.some(comment => comment.id === createdComment.id)) { - state.cardCommentsByCardId.value[cardId] = [...existingComments, createdComment].sort( + currentCache[cardId] = [...existingComments, createdComment].sort( (left, right) => new Date(left.createdAt).getTime() - new Date(right.createdAt).getTime(), ) } helpers.toast.success('Comment added') + } else if (state.currentBoard?.value?.id === boardId) { + await reconcileCurrentCommentsAfterStaleVisit(boardId, cardId) } return createdComment } catch (e: unknown) { - helpers.handleApiError(e, 'Failed to create card comment') + if (isCurrentBoardVisit(visit)) { + helpers.handleApiError(e, 'Failed to create card comment') + } throw e } finally { - state.loading.value = false + if (isCurrentBoardVisit(visit)) state.loading.value = false } } @@ -149,22 +214,29 @@ export function createCardCommentActions(state: BoardState, helpers: BoardHelper const updatedComment = await runCommentMutation( cardId, commentId, + visit, () => cardCommentsApi.updateComment(boardId, cardId, commentId, dto), ) - if (ownsCurrentCommentCache(visit)) { - markCommentMutation(cardId) - const existingComments = state.cardCommentsByCardId.value[cardId] ?? [] - state.cardCommentsByCardId.value[cardId] = existingComments.map((comment) => + markCommentMutation(cardId) + + if (isCurrentBoardVisit(visit)) { + const currentCache = state.cardCommentsByCardId.value + const existingComments = currentCache[cardId] ?? [] + currentCache[cardId] = existingComments.map((comment) => comment.id === commentId ? updatedComment : comment, ) helpers.toast.success('Comment updated') + } else if (state.currentBoard?.value?.id === boardId) { + await reconcileCurrentCommentsAfterStaleVisit(boardId, cardId) } return updatedComment } catch (e: unknown) { - helpers.handleApiError(e, 'Failed to update card comment') + if (!(e instanceof StaleBoardVisitError) && isCurrentBoardVisit(visit)) { + helpers.handleApiError(e, 'Failed to update card comment') + } throw e } finally { - state.loading.value = false + if (isCurrentBoardVisit(visit)) state.loading.value = false } } @@ -177,21 +249,28 @@ export function createCardCommentActions(state: BoardState, helpers: BoardHelper await runCommentMutation( cardId, commentId, + visit, () => cardCommentsApi.deleteComment(boardId, cardId, commentId), ) - if (ownsCurrentCommentCache(visit)) { - markCommentMutation(cardId) - const existingComments = state.cardCommentsByCardId.value[cardId] ?? [] - state.cardCommentsByCardId.value[cardId] = existingComments.filter( + markCommentMutation(cardId) + + if (isCurrentBoardVisit(visit)) { + const currentCache = state.cardCommentsByCardId.value + const existingComments = currentCache[cardId] ?? [] + currentCache[cardId] = existingComments.filter( (comment) => comment.id !== commentId, ) helpers.toast.success('Comment deleted') + } else if (state.currentBoard?.value?.id === boardId) { + await reconcileCurrentCommentsAfterStaleVisit(boardId, cardId) } } catch (e: unknown) { - helpers.handleApiError(e, 'Failed to delete card comment') + if (!(e instanceof StaleBoardVisitError) && isCurrentBoardVisit(visit)) { + helpers.handleApiError(e, 'Failed to delete card comment') + } throw e } finally { - state.loading.value = false + if (isCurrentBoardVisit(visit)) state.loading.value = false } } From e8a247bcce0c42f57acfedccb6c1521456a0e93d Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Sun, 20 Sep 2026 19:52:31 +0100 Subject: [PATCH 10/12] test(comments): narrow queued cancellation result --- .../tests/store/board/cardCommentStoreVisitOrdering.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/taskdeck-web/src/tests/store/board/cardCommentStoreVisitOrdering.spec.ts b/frontend/taskdeck-web/src/tests/store/board/cardCommentStoreVisitOrdering.spec.ts index 0ac601d19..8ba74a4a2 100644 --- a/frontend/taskdeck-web/src/tests/store/board/cardCommentStoreVisitOrdering.spec.ts +++ b/frontend/taskdeck-web/src/tests/store/board/cardCommentStoreVisitOrdering.spec.ts @@ -240,7 +240,8 @@ describe('cardCommentStore visit and mutation ownership', () => { await pendingFirst const cancellation = await pendingSecond - expect(cancellation.name).toBe('StaleBoardVisitError') + expect(cancellation).toBeInstanceOf(Error) + expect((cancellation as Error).name).toBe('StaleBoardVisitError') expect(mockCardCommentsApi.updateComment).toHaveBeenCalledTimes(1) expect(helpers.handleApiError).not.toHaveBeenCalled() expect(state.cardCommentsByCardId.value).toEqual({}) From 01c5557b61be66a17591a7c69fe754b2c5f41547 Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Sun, 20 Sep 2026 20:14:10 +0100 Subject: [PATCH 11/12] fix(comments): start unqueued writes in initiating visit --- .../src/store/board/cardCommentStore.ts | 30 +++++++++++++------ 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/frontend/taskdeck-web/src/store/board/cardCommentStore.ts b/frontend/taskdeck-web/src/store/board/cardCommentStore.ts index f701d66c9..a7327623a 100644 --- a/frontend/taskdeck-web/src/store/board/cardCommentStore.ts +++ b/frontend/taskdeck-web/src/store/board/cardCommentStore.ts @@ -80,16 +80,28 @@ export function createCardCommentActions(state: BoardState, helpers: BoardHelper mutation: () => Promise, ): Promise { const key = `${cardId}:${commentId}` - const previous = mutationTailByCommentKey.get(key) ?? Promise.resolve() - // A failed predecessor must not cancel a later user intent. It still settles - // through its own caller/error path; the next request starts afterward. - const operation = previous.catch(() => undefined).then(() => { - // The HTTP interceptor reads the token when transport starts. Reject a - // queued pre-logout intent before the API callback can run under another - // session's credentials. + const previous = mutationTailByCommentKey.get(key) + let operation: Promise + + if (previous) { + // A failed predecessor must not cancel a later user intent. It still + // settles through its own caller/error path; the next request starts + // afterward if the initiating board session still owns it. + operation = previous.catch(() => undefined).then(() => { + // The HTTP interceptor reads the token when transport starts. Reject a + // queued pre-logout intent before the API callback can run under another + // session's credentials. + if (!isCurrentBoardVisit(visit)) throw new StaleBoardVisitError() + return mutation() + }) + } else { + // The first intent is not queued. Start its transport in the initiating + // call stack so immediate navigation cannot retroactively cancel a request + // that the UI already submitted. Only later intents wait behind a tail. if (!isCurrentBoardVisit(visit)) throw new StaleBoardVisitError() - return mutation() - }) + operation = mutation() + } + const tail = operation.then( () => undefined, () => undefined, From 66e725d563e5f7d3582196346bb84e5d0adc87ce Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Sun, 20 Sep 2026 20:14:45 +0100 Subject: [PATCH 12/12] test(comments): compare installed Vue cache identities --- .../store/board/cardCommentStoreVisitOrdering.spec.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/frontend/taskdeck-web/src/tests/store/board/cardCommentStoreVisitOrdering.spec.ts b/frontend/taskdeck-web/src/tests/store/board/cardCommentStoreVisitOrdering.spec.ts index 8ba74a4a2..93eaee536 100644 --- a/frontend/taskdeck-web/src/tests/store/board/cardCommentStoreVisitOrdering.spec.ts +++ b/frontend/taskdeck-web/src/tests/store/board/cardCommentStoreVisitOrdering.spec.ts @@ -97,6 +97,7 @@ describe('cardCommentStore visit and mutation ownership', () => { 'card-1': [{ ...originalComment, content: 'Pre-write refresh' }], } state.cardCommentsByCardId.value = refreshedCache + const installedCache = state.cardCommentsByCardId.value const updated = { ...originalComment, content: 'Edited', @@ -105,7 +106,7 @@ describe('cardCommentStore visit and mutation ownership', () => { update.resolve(updated) await pendingUpdate - expect(state.cardCommentsByCardId.value).toBe(refreshedCache) + expect(state.cardCommentsByCardId.value).toBe(installedCache) expect(state.cardCommentsByCardId.value['card-1']).toEqual([updated]) expect(mockCardCommentsApi.getComments).not.toHaveBeenCalled() }) @@ -125,10 +126,11 @@ describe('cardCommentStore visit and mutation ownership', () => { 'card-1': [{ ...originalComment, content: 'Pre-write refresh' }], } state.cardCommentsByCardId.value = refreshedCache + const installedCache = state.cardCommentsByCardId.value create.resolve({ ...secondComment }) await pendingCreate - expect(state.cardCommentsByCardId.value).toBe(refreshedCache) + expect(state.cardCommentsByCardId.value).toBe(installedCache) expect(state.cardCommentsByCardId.value['card-1'].map(comment => comment.id)).toEqual([ 'cmt-1', 'cmt-2', @@ -149,10 +151,11 @@ describe('cardCommentStore visit and mutation ownership', () => { 'card-1': [{ ...originalComment }, { ...secondComment }], } state.cardCommentsByCardId.value = refreshedCache + const installedCache = state.cardCommentsByCardId.value deletion.resolve(undefined) await pendingDelete - expect(state.cardCommentsByCardId.value).toBe(refreshedCache) + expect(state.cardCommentsByCardId.value).toBe(installedCache) expect(state.cardCommentsByCardId.value['card-1'].map(comment => comment.id)).toEqual([ 'cmt-2', ])