From cb4b19c830fab5bd0c64983eec2b34bbc69da4e0 Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Sun, 20 Sep 2026 15:48:14 +0100 Subject: [PATCH 01/14] test(labels): reproduce cross-board async commits --- .../store/board/labelStoreOwnership.spec.ts | 151 ++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 frontend/taskdeck-web/src/tests/store/board/labelStoreOwnership.spec.ts diff --git a/frontend/taskdeck-web/src/tests/store/board/labelStoreOwnership.spec.ts b/frontend/taskdeck-web/src/tests/store/board/labelStoreOwnership.spec.ts new file mode 100644 index 000000000..c6d0c1db3 --- /dev/null +++ b/frontend/taskdeck-web/src/tests/store/board/labelStoreOwnership.spec.ts @@ -0,0 +1,151 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' + +const { mockLabelsApi } = vi.hoisted(() => ({ + mockLabelsApi: { + getLabels: vi.fn(), + createLabel: vi.fn(), + updateLabel: vi.fn(), + deleteLabel: vi.fn(), + }, +})) + +vi.mock('../../../api/labelsApi', () => ({ labelsApi: mockLabelsApi })) + +import { createLabelActions } from '../../../store/board/labelStore' + +function createState() { + return { + currentBoard: ref<{ id: string } | null>({ id: 'board-1' }), + currentBoardLabels: ref([ + { id: 'lbl-1', name: 'Bug', colorHex: '#f00' }, + { id: 'lbl-2', name: 'Feature', colorHex: '#0f0' }, + ]), + loading: ref(false), + error: ref(null), + } +} + +function createHelpers() { + return { + guardDemoMutation: vi.fn(), + handleApiError: vi.fn(), + isDemoMode: false, + toast: { success: vi.fn(), error: vi.fn() }, + markBoardDetailMutation: vi.fn(), + } +} + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((settle) => { + resolve = settle + }) + return { promise, resolve } +} + +describe('labelStore board ownership', () => { + beforeEach(() => { + vi.clearAllMocks() + mockLabelsApi.getLabels.mockReset() + mockLabelsApi.createLabel.mockReset() + mockLabelsApi.updateLabel.mockReset() + mockLabelsApi.deleteLabel.mockReset() + }) + + it('does not let a late label read overwrite the newly selected board', async () => { + const state = createState() + const helpers = createHelpers() + const response = deferred>() + mockLabelsApi.getLabels.mockReturnValueOnce(response.promise) + const { fetchLabels } = createLabelActions(state as never, helpers as never) + + const pendingFetch = fetchLabels('board-1') + + const nextBoardLabels = [{ id: 'lbl-next', name: 'Next', colorHex: '#00f' }] + state.currentBoard.value = { id: 'board-2' } + state.currentBoardLabels.value = nextBoardLabels + response.resolve([{ id: 'lbl-old', name: 'Old board', colorHex: '#aaa' }]) + await pendingFetch + + expect(state.currentBoardLabels.value).toEqual(nextBoardLabels) + }) + + it('does not append a late create response to the newly selected board', async () => { + const state = createState() + const helpers = createHelpers() + const response = deferred<{ id: string; name: string; colorHex: string }>() + mockLabelsApi.createLabel.mockReturnValueOnce(response.promise) + const { createLabel } = createLabelActions(state as never, helpers as never) + + const pendingCreate = createLabel('board-1', { name: 'Chore', colorHex: '#abc' }) + + const nextBoardLabels = [{ id: 'lbl-next', name: 'Next', colorHex: '#00f' }] + state.currentBoard.value = { id: 'board-2' } + state.currentBoardLabels.value = nextBoardLabels + const created = { id: 'lbl-created', name: 'Chore', colorHex: '#abc' } + response.resolve(created) + const result = await pendingCreate + + expect(result).toEqual(created) + expect(state.currentBoardLabels.value).toEqual(nextBoardLabels) + expect(helpers.markBoardDetailMutation).toHaveBeenCalledWith('board-1') + }) + + it('preserves a fresher detail label when it commits before create settles', async () => { + const state = createState() + const helpers = createHelpers() + const response = deferred<{ id: string; name: string; colorHex: string }>() + mockLabelsApi.createLabel.mockReturnValueOnce(response.promise) + const { createLabel } = createLabelActions(state as never, helpers as never) + + const pendingCreate = createLabel('board-1', { name: 'Chore', colorHex: '#abc' }) + + const refreshed = { id: 'lbl-created', name: 'Chore from detail', colorHex: '#def' } + state.currentBoardLabels.value.push(refreshed) + response.resolve({ id: 'lbl-created', name: 'Chore', colorHex: '#abc' }) + await pendingCreate + + expect(state.currentBoardLabels.value.filter(label => label.id === refreshed.id)).toEqual([ + refreshed, + ]) + }) + + it('does not update the prior label collection after another board is selected', async () => { + const state = createState() + const helpers = createHelpers() + const response = deferred<{ id: string; name: string; colorHex: string }>() + mockLabelsApi.updateLabel.mockReturnValueOnce(response.promise) + const { updateLabel } = createLabelActions(state as never, helpers as never) + + const pendingUpdate = updateLabel('board-1', 'lbl-1', { name: 'Critical' }) + + state.currentBoard.value = { id: 'board-2' } + response.resolve({ id: 'lbl-1', name: 'Critical', colorHex: '#f00' }) + await pendingUpdate + + expect(state.currentBoardLabels.value[0]).toEqual({ + id: 'lbl-1', + name: 'Bug', + colorHex: '#f00', + }) + expect(helpers.markBoardDetailMutation).toHaveBeenCalledWith('board-1') + }) + + it('does not delete from the prior label collection after another board is selected', async () => { + const state = createState() + const helpers = createHelpers() + const response = deferred() + mockLabelsApi.deleteLabel.mockReturnValueOnce(response.promise) + const { deleteLabel } = createLabelActions(state as never, helpers as never) + + const pendingDelete = deleteLabel('board-1', 'lbl-1') + + state.currentBoard.value = { id: 'board-2' } + response.resolve() + await pendingDelete + + expect(state.currentBoardLabels.value.map(label => label.id)).toEqual(['lbl-1', 'lbl-2']) + expect(helpers.markBoardDetailMutation).toHaveBeenCalledWith('board-1') + }) +}) From 43e5e3a76d1fb34418b5a33ffd07a5d723e3f745 Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Sun, 20 Sep 2026 15:48:29 +0100 Subject: [PATCH 02/14] fix(labels): bind async commits to their board --- .../src/store/board/labelStore.ts | 43 ++++++++++++++----- 1 file changed, 33 insertions(+), 10 deletions(-) diff --git a/frontend/taskdeck-web/src/store/board/labelStore.ts b/frontend/taskdeck-web/src/store/board/labelStore.ts index 5ddda9473..f83b9a0af 100644 --- a/frontend/taskdeck-web/src/store/board/labelStore.ts +++ b/frontend/taskdeck-web/src/store/board/labelStore.ts @@ -12,10 +12,23 @@ import type { BoardState } from './boardState' import type { BoardHelpers } from './boardStoreHelpers' export function createLabelActions(state: BoardState, helpers: BoardHelpers) { + // Label state is one selected-board collection. A request may outlive its + // route, so every post-await commit must prove that its initiating board still + // owns the collection. Null preserves the existing pre-load/store-test + // convention used by card actions; optional access keeps lightweight unit + // fixtures that predate currentBoard compatible. + function ownsCurrentLabels(boardId: string) { + const currentBoard = state.currentBoard?.value + return currentBoard == null || currentBoard.id === boardId + } + async function fetchLabels(boardId: string) { if (helpers.isDemoMode) return try { - state.currentBoardLabels.value = await labelsApi.getLabels(boardId) + const labels = await labelsApi.getLabels(boardId) + if (ownsCurrentLabels(boardId)) { + state.currentBoardLabels.value = labels + } } catch (e: unknown) { helpers.handleApiError(e, 'Failed to fetch labels') throw e @@ -29,7 +42,14 @@ export function createLabelActions(state: BoardState, helpers: BoardHelpers) { state.error.value = null const newLabel = await labelsApi.createLabel(boardId, label) helpers.markBoardDetailMutation(boardId) - state.currentBoardLabels.value.push(newLabel) + if ( + ownsCurrentLabels(boardId) && + !state.currentBoardLabels.value.some(existingLabel => existingLabel.id === newLabel.id) + ) { + // A board-detail refresh can commit the new stable id before the POST + // resolves. Preserve that fresher object instead of appending a duplicate. + state.currentBoardLabels.value.push(newLabel) + } helpers.toast.success(`Label "${newLabel.name}" created successfully`) return newLabel } catch (e: unknown) { @@ -48,10 +68,12 @@ export function createLabelActions(state: BoardState, helpers: BoardHelpers) { const updatedLabel = await labelsApi.updateLabel(boardId, labelId, label) helpers.markBoardDetailMutation(boardId) - // Update label in store - const index = state.currentBoardLabels.value.findIndex((l) => l.id === labelId) - if (index !== -1) { - state.currentBoardLabels.value[index] = updatedLabel + if (ownsCurrentLabels(boardId)) { + // Re-resolve after the await so a detail refresh can replace the array safely. + const index = state.currentBoardLabels.value.findIndex((l) => l.id === labelId) + if (index !== -1) { + state.currentBoardLabels.value[index] = updatedLabel + } } helpers.toast.success('Label updated successfully') @@ -72,10 +94,11 @@ export function createLabelActions(state: BoardState, helpers: BoardHelpers) { await labelsApi.deleteLabel(boardId, labelId) helpers.markBoardDetailMutation(boardId) - // Remove label from store - state.currentBoardLabels.value = state.currentBoardLabels.value.filter( - (l) => l.id !== labelId, - ) + if (ownsCurrentLabels(boardId)) { + state.currentBoardLabels.value = state.currentBoardLabels.value.filter( + (l) => l.id !== labelId, + ) + } helpers.toast.success('Label deleted successfully') } catch (e: unknown) { From 56d49894154d46a6acd24d4c1d96f9eebb27aa32 Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Sun, 20 Sep 2026 15:49:16 +0100 Subject: [PATCH 03/14] test(labels): resolve void fixture explicitly --- .../src/tests/store/board/labelStoreOwnership.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/taskdeck-web/src/tests/store/board/labelStoreOwnership.spec.ts b/frontend/taskdeck-web/src/tests/store/board/labelStoreOwnership.spec.ts index c6d0c1db3..4547e7c45 100644 --- a/frontend/taskdeck-web/src/tests/store/board/labelStoreOwnership.spec.ts +++ b/frontend/taskdeck-web/src/tests/store/board/labelStoreOwnership.spec.ts @@ -142,7 +142,7 @@ describe('labelStore board ownership', () => { const pendingDelete = deleteLabel('board-1', 'lbl-1') state.currentBoard.value = { id: 'board-2' } - response.resolve() + response.resolve(undefined) await pendingDelete expect(state.currentBoardLabels.value.map(label => label.id)).toEqual(['lbl-1', 'lbl-2']) From d9682a17ecfbe6b5a34d5764f2957dd09df882b8 Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Sun, 20 Sep 2026 17:54:36 +0100 Subject: [PATCH 04/14] test(labels): reproduce revisit and settlement ordering races --- .../board/labelStoreVisitOrdering.spec.ts | 163 ++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 frontend/taskdeck-web/src/tests/store/board/labelStoreVisitOrdering.spec.ts diff --git a/frontend/taskdeck-web/src/tests/store/board/labelStoreVisitOrdering.spec.ts b/frontend/taskdeck-web/src/tests/store/board/labelStoreVisitOrdering.spec.ts new file mode 100644 index 000000000..06b365ebc --- /dev/null +++ b/frontend/taskdeck-web/src/tests/store/board/labelStoreVisitOrdering.spec.ts @@ -0,0 +1,163 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' + +const { mockLabelsApi } = vi.hoisted(() => ({ + mockLabelsApi: { + getLabels: vi.fn(), + createLabel: vi.fn(), + updateLabel: vi.fn(), + deleteLabel: vi.fn(), + }, +})) + +vi.mock('../../../api/labelsApi', () => ({ labelsApi: mockLabelsApi })) + +import { createLabelActions } from '../../../store/board/labelStore' + +interface TestLabel { + id: string + boardId: string + name: string + colorHex: string + createdAt: string + updatedAt: string +} + +const originalLabel: TestLabel = { + id: 'lbl-1', + boardId: 'board-1', + name: 'Bug', + colorHex: '#f00', + createdAt: '2026-09-20T10:00:00Z', + updatedAt: '2026-09-20T10:00:00Z', +} + +function createState() { + return { + currentBoard: ref<{ id: string } | null>({ id: 'board-1' }), + currentBoardLabels: ref([{ ...originalLabel }]), + loading: ref(false), + error: ref(null), + } +} + +function createHelpers() { + return { + guardDemoMutation: vi.fn(), + handleApiError: vi.fn(), + isDemoMode: false, + toast: { success: vi.fn(), error: vi.fn() }, + markBoardDetailMutation: vi.fn(), + } +} + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((settle) => { + resolve = settle + }) + return { promise, resolve } +} + +describe('labelStore visit and settlement ordering', () => { + beforeEach(() => { + vi.clearAllMocks() + mockLabelsApi.getLabels.mockReset() + mockLabelsApi.updateLabel.mockReset() + }) + + it('does not let an earlier A visit overwrite the authoritative A read after A to B to A', async () => { + const state = createState() + const helpers = createHelpers() + const oldVisitRead = deferred() + const reopenedRead = deferred() + mockLabelsApi.getLabels + .mockReturnValueOnce(oldVisitRead.promise) + .mockReturnValueOnce(reopenedRead.promise) + const { fetchLabels } = createLabelActions(state as never, helpers as never) + + const pendingOldRead = fetchLabels('board-1') + + state.currentBoard.value = { id: 'board-2' } + state.currentBoardLabels.value = [] + state.currentBoard.value = { id: 'board-1' } + const reopenedCache: TestLabel[] = [] + state.currentBoardLabels.value = reopenedCache + const pendingReopenedRead = fetchLabels('board-1') + + oldVisitRead.resolve([{ ...originalLabel, name: 'Old visit' }]) + await pendingOldRead + expect(state.currentBoardLabels.value).toBe(reopenedCache) + expect(state.currentBoardLabels.value).toEqual([]) + + const authoritative = [{ + ...originalLabel, + name: 'Reopened authoritative', + updatedAt: '2026-09-20T10:02:00Z', + }] + reopenedRead.resolve(authoritative) + await pendingReopenedRead + + expect(state.currentBoardLabels.value).toBe(reopenedCache) + expect(state.currentBoardLabels.value).toEqual(authoritative) + }) + + it('does not let a fetch that started first erase a confirmed label update', async () => { + const state = createState() + const helpers = createHelpers() + const staleRead = deferred() + mockLabelsApi.getLabels.mockReturnValueOnce(staleRead.promise) + const updated = { + ...originalLabel, + name: 'Critical', + updatedAt: '2026-09-20T10:01:00Z', + } + mockLabelsApi.updateLabel.mockResolvedValueOnce(updated) + const actions = createLabelActions(state as never, helpers as never) + + const pendingRead = actions.fetchLabels('board-1') + await actions.updateLabel('board-1', 'lbl-1', { name: 'Critical' }) + staleRead.resolve([{ ...originalLabel }]) + await pendingRead + + expect(state.currentBoardLabels.value).toEqual([updated]) + }) + + it('does not let an older label update settle over a newer update or invalidate its refresh', async () => { + const state = createState() + const helpers = createHelpers() + const firstUpdate = deferred() + const secondUpdate = deferred() + const authoritativeRead = deferred() + mockLabelsApi.updateLabel + .mockReturnValueOnce(firstUpdate.promise) + .mockReturnValueOnce(secondUpdate.promise) + mockLabelsApi.getLabels.mockReturnValueOnce(authoritativeRead.promise) + const actions = createLabelActions(state as never, helpers as never) + + const pendingFirst = actions.updateLabel('board-1', 'lbl-1', { name: 'First' }) + const pendingSecond = actions.updateLabel('board-1', 'lbl-1', { name: 'Second' }) + + const secondResult = { + ...originalLabel, + name: 'Second', + updatedAt: '2026-09-20T10:02:00Z', + } + secondUpdate.resolve(secondResult) + await pendingSecond + const pendingRead = actions.fetchLabels('board-1') + + firstUpdate.resolve({ + ...originalLabel, + name: 'First', + updatedAt: '2026-09-20T10:01:00Z', + }) + await pendingFirst + authoritativeRead.resolve([secondResult]) + await pendingRead + + expect(state.currentBoardLabels.value).toEqual([secondResult]) + expect(helpers.toast.success).toHaveBeenCalledTimes(1) + expect(helpers.toast.success).toHaveBeenCalledWith('Label updated successfully') + }) +}) From c6e82ae484d3b0c2117a2148d31e6c5ec8204eb2 Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Sun, 20 Sep 2026 17:55:05 +0100 Subject: [PATCH 05/14] fix(labels): bind reads and writes to visit order --- .../src/store/board/labelStore.ts | 117 +++++++++++++----- 1 file changed, 88 insertions(+), 29 deletions(-) diff --git a/frontend/taskdeck-web/src/store/board/labelStore.ts b/frontend/taskdeck-web/src/store/board/labelStore.ts index f83b9a0af..9e7e8e1fa 100644 --- a/frontend/taskdeck-web/src/store/board/labelStore.ts +++ b/frontend/taskdeck-web/src/store/board/labelStore.ts @@ -7,27 +7,82 @@ * resolves after the write (#2435). */ import { labelsApi } from '../../api/labelsApi' -import type { CreateLabelDto, UpdateLabelDto } from '../../types/board' +import type { CreateLabelDto, Label, UpdateLabelDto } from '../../types/board' import type { BoardState } from './boardState' import type { BoardHelpers } from './boardStoreHelpers' +interface LabelCacheVisit { + boardId: string + labels: Label[] +} + +interface LabelMutationRequest { + key: string + version: number +} + export function createLabelActions(state: BoardState, helpers: BoardHelpers) { - // Label state is one selected-board collection. A request may outlive its - // route, so every post-await commit must prove that its initiating board still - // owns the collection. Null preserves the existing pre-load/store-test - // convention used by card actions; optional access keeps lightweight unit - // fixtures that predate currentBoard compatible. - function ownsCurrentLabels(boardId: string) { + // Label state is one selected-board collection. Board-detail commits replace + // the array, so its identity is the visit/session boundary; same-visit label + // operations mutate that array in place. Separate read and mutation versions + // then order overlapping work inside one visit. + const readVersionByBoardId = new Map() + const mutationVersionByBoardId = new Map() + const mutationRequestVersionByLabelKey = new Map() + + function captureLabelVisit(boardId: string): LabelCacheVisit { + return { + boardId, + labels: state.currentBoardLabels.value, + } + } + + function ownsCurrentLabels(visit: LabelCacheVisit) { const currentBoard = state.currentBoard?.value - return currentBoard == null || currentBoard.id === boardId + return ( + (currentBoard == null || currentBoard.id === visit.boardId) && + state.currentBoardLabels.value === visit.labels + ) + } + + function nextReadVersion(boardId: string) { + const version = (readVersionByBoardId.get(boardId) ?? 0) + 1 + readVersionByBoardId.set(boardId, version) + return version + } + + function currentMutationVersion(boardId: string) { + return mutationVersionByBoardId.get(boardId) ?? 0 + } + + function markLabelMutation(boardId: string) { + mutationVersionByBoardId.set(boardId, currentMutationVersion(boardId) + 1) + } + + function beginLabelMutation(boardId: string, labelId: string): LabelMutationRequest { + const key = `${boardId}:${labelId}` + const version = (mutationRequestVersionByLabelKey.get(key) ?? 0) + 1 + mutationRequestVersionByLabelKey.set(key, version) + return { key, version } + } + + function isCurrentLabelMutation(request: LabelMutationRequest) { + return mutationRequestVersionByLabelKey.get(request.key) === request.version } async function fetchLabels(boardId: string) { if (helpers.isDemoMode) return + const visit = captureLabelVisit(boardId) + const readVersion = nextReadVersion(boardId) + const mutationVersion = currentMutationVersion(boardId) try { const labels = await labelsApi.getLabels(boardId) - if (ownsCurrentLabels(boardId)) { - state.currentBoardLabels.value = labels + if ( + ownsCurrentLabels(visit) && + readVersionByBoardId.get(boardId) === readVersion && + currentMutationVersion(boardId) === mutationVersion + ) { + visit.labels.splice(0, visit.labels.length, ...labels) } } catch (e: unknown) { helpers.handleApiError(e, 'Failed to fetch labels') @@ -37,20 +92,21 @@ export function createLabelActions(state: BoardState, helpers: BoardHelpers) { async function createLabel(boardId: string, label: CreateLabelDto) { helpers.guardDemoMutation() + const visit = captureLabelVisit(boardId) try { state.loading.value = true state.error.value = null const newLabel = await labelsApi.createLabel(boardId, label) helpers.markBoardDetailMutation(boardId) - if ( - ownsCurrentLabels(boardId) && - !state.currentBoardLabels.value.some(existingLabel => existingLabel.id === newLabel.id) - ) { - // A board-detail refresh can commit the new stable id before the POST - // resolves. Preserve that fresher object instead of appending a duplicate. - state.currentBoardLabels.value.push(newLabel) + if (ownsCurrentLabels(visit)) { + markLabelMutation(boardId) + if (!visit.labels.some(existingLabel => existingLabel.id === newLabel.id)) { + // A board-detail refresh can commit the new stable id before the POST + // resolves. Preserve that fresher object instead of appending a duplicate. + visit.labels.push(newLabel) + } + helpers.toast.success(`Label "${newLabel.name}" created successfully`) } - helpers.toast.success(`Label "${newLabel.name}" created successfully`) return newLabel } catch (e: unknown) { helpers.handleApiError(e, 'Failed to create label') @@ -62,21 +118,23 @@ export function createLabelActions(state: BoardState, helpers: BoardHelpers) { async function updateLabel(boardId: string, labelId: string, label: UpdateLabelDto) { helpers.guardDemoMutation() + const visit = captureLabelVisit(boardId) + const mutationRequest = beginLabelMutation(boardId, labelId) try { state.loading.value = true state.error.value = null const updatedLabel = await labelsApi.updateLabel(boardId, labelId, label) helpers.markBoardDetailMutation(boardId) - if (ownsCurrentLabels(boardId)) { - // Re-resolve after the await so a detail refresh can replace the array safely. - const index = state.currentBoardLabels.value.findIndex((l) => l.id === labelId) + if (ownsCurrentLabels(visit) && isCurrentLabelMutation(mutationRequest)) { + markLabelMutation(boardId) + const index = visit.labels.findIndex((candidate) => candidate.id === labelId) if (index !== -1) { - state.currentBoardLabels.value[index] = updatedLabel + visit.labels[index] = updatedLabel } + helpers.toast.success('Label updated successfully') } - helpers.toast.success('Label updated successfully') return updatedLabel } catch (e: unknown) { helpers.handleApiError(e, 'Failed to update label') @@ -88,19 +146,20 @@ export function createLabelActions(state: BoardState, helpers: BoardHelpers) { async function deleteLabel(boardId: string, labelId: string) { helpers.guardDemoMutation() + const visit = captureLabelVisit(boardId) + const mutationRequest = beginLabelMutation(boardId, labelId) try { state.loading.value = true state.error.value = null await labelsApi.deleteLabel(boardId, labelId) helpers.markBoardDetailMutation(boardId) - if (ownsCurrentLabels(boardId)) { - state.currentBoardLabels.value = state.currentBoardLabels.value.filter( - (l) => l.id !== labelId, - ) + if (ownsCurrentLabels(visit) && isCurrentLabelMutation(mutationRequest)) { + markLabelMutation(boardId) + const index = visit.labels.findIndex((candidate) => candidate.id === labelId) + if (index !== -1) visit.labels.splice(index, 1) + helpers.toast.success('Label deleted successfully') } - - helpers.toast.success('Label deleted successfully') } catch (e: unknown) { helpers.handleApiError(e, 'Failed to delete label') throw e From f966a3581085b60bcf016d20a363f12240c63a05 Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Sun, 20 Sep 2026 18:15:23 +0100 Subject: [PATCH 06/14] test(labels): compare installed reactive visit cache --- .../src/tests/store/board/labelStoreVisitOrdering.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/taskdeck-web/src/tests/store/board/labelStoreVisitOrdering.spec.ts b/frontend/taskdeck-web/src/tests/store/board/labelStoreVisitOrdering.spec.ts index 06b365ebc..f3d569b48 100644 --- a/frontend/taskdeck-web/src/tests/store/board/labelStoreVisitOrdering.spec.ts +++ b/frontend/taskdeck-web/src/tests/store/board/labelStoreVisitOrdering.spec.ts @@ -81,8 +81,8 @@ describe('labelStore visit and settlement ordering', () => { state.currentBoard.value = { id: 'board-2' } state.currentBoardLabels.value = [] state.currentBoard.value = { id: 'board-1' } - const reopenedCache: TestLabel[] = [] - state.currentBoardLabels.value = reopenedCache + state.currentBoardLabels.value = [] + const reopenedCache = state.currentBoardLabels.value const pendingReopenedRead = fetchLabels('board-1') oldVisitRead.resolve([{ ...originalLabel, name: 'Old visit' }]) From 8447e80a818c0736a65587f88b9f893138954f07 Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Sun, 20 Sep 2026 18:21:44 +0100 Subject: [PATCH 07/14] fix(labels): serialize writes per label --- .../src/store/board/labelStore.ts | 54 ++++++++++++------- 1 file changed, 34 insertions(+), 20 deletions(-) diff --git a/frontend/taskdeck-web/src/store/board/labelStore.ts b/frontend/taskdeck-web/src/store/board/labelStore.ts index 9e7e8e1fa..c45cf282b 100644 --- a/frontend/taskdeck-web/src/store/board/labelStore.ts +++ b/frontend/taskdeck-web/src/store/board/labelStore.ts @@ -16,19 +16,15 @@ interface LabelCacheVisit { labels: Label[] } -interface LabelMutationRequest { - key: string - version: number -} - export function createLabelActions(state: BoardState, helpers: BoardHelpers) { // Label state is one selected-board collection. Board-detail commits replace // the array, so its identity is the visit/session boundary; same-visit label // operations mutate that array in place. Separate read and mutation versions - // then order overlapping work inside one visit. + // order reads, while same-label writes are serialized because the API has no + // revision precondition to reject an older request that reaches the server last. const readVersionByBoardId = new Map() const mutationVersionByBoardId = new Map() - const mutationRequestVersionByLabelKey = new Map() + const mutationTailByLabelKey = new Map>() function captureLabelVisit(boardId: string): LabelCacheVisit { return { @@ -59,15 +55,27 @@ export function createLabelActions(state: BoardState, helpers: BoardHelpers) { mutationVersionByBoardId.set(boardId, currentMutationVersion(boardId) + 1) } - function beginLabelMutation(boardId: string, labelId: string): LabelMutationRequest { + async function runLabelMutation( + boardId: string, + labelId: string, + mutation: () => Promise, + ): Promise { const key = `${boardId}:${labelId}` - const version = (mutationRequestVersionByLabelKey.get(key) ?? 0) + 1 - mutationRequestVersionByLabelKey.set(key, version) - return { key, version } - } + const previous = mutationTailByLabelKey.get(key) ?? Promise.resolve() + const operation = previous.catch(() => undefined).then(mutation) + const tail = operation.then( + () => undefined, + () => undefined, + ) + mutationTailByLabelKey.set(key, tail) - function isCurrentLabelMutation(request: LabelMutationRequest) { - return mutationRequestVersionByLabelKey.get(request.key) === request.version + try { + return await operation + } finally { + if (mutationTailByLabelKey.get(key) === tail) { + mutationTailByLabelKey.delete(key) + } + } } async function fetchLabels(boardId: string) { @@ -119,14 +127,17 @@ export function createLabelActions(state: BoardState, helpers: BoardHelpers) { async function updateLabel(boardId: string, labelId: string, label: UpdateLabelDto) { helpers.guardDemoMutation() const visit = captureLabelVisit(boardId) - const mutationRequest = beginLabelMutation(boardId, labelId) try { state.loading.value = true state.error.value = null - const updatedLabel = await labelsApi.updateLabel(boardId, labelId, label) + const updatedLabel = await runLabelMutation( + boardId, + labelId, + () => labelsApi.updateLabel(boardId, labelId, label), + ) helpers.markBoardDetailMutation(boardId) - if (ownsCurrentLabels(visit) && isCurrentLabelMutation(mutationRequest)) { + if (ownsCurrentLabels(visit)) { markLabelMutation(boardId) const index = visit.labels.findIndex((candidate) => candidate.id === labelId) if (index !== -1) { @@ -147,14 +158,17 @@ export function createLabelActions(state: BoardState, helpers: BoardHelpers) { async function deleteLabel(boardId: string, labelId: string) { helpers.guardDemoMutation() const visit = captureLabelVisit(boardId) - const mutationRequest = beginLabelMutation(boardId, labelId) try { state.loading.value = true state.error.value = null - await labelsApi.deleteLabel(boardId, labelId) + await runLabelMutation( + boardId, + labelId, + () => labelsApi.deleteLabel(boardId, labelId), + ) helpers.markBoardDetailMutation(boardId) - if (ownsCurrentLabels(visit) && isCurrentLabelMutation(mutationRequest)) { + if (ownsCurrentLabels(visit)) { markLabelMutation(boardId) const index = visit.labels.findIndex((candidate) => candidate.id === labelId) if (index !== -1) visit.labels.splice(index, 1) From 58fa07b0273f0d98c43c382a6188f997b71099af Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Sun, 20 Sep 2026 18:22:24 +0100 Subject: [PATCH 08/14] test(labels): prove same-label writes serialize --- .../board/labelStoreVisitOrdering.spec.ts | 35 +++++++++++++------ 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/frontend/taskdeck-web/src/tests/store/board/labelStoreVisitOrdering.spec.ts b/frontend/taskdeck-web/src/tests/store/board/labelStoreVisitOrdering.spec.ts index f3d569b48..091642f22 100644 --- a/frontend/taskdeck-web/src/tests/store/board/labelStoreVisitOrdering.spec.ts +++ b/frontend/taskdeck-web/src/tests/store/board/labelStoreVisitOrdering.spec.ts @@ -59,6 +59,11 @@ function deferred() { return { promise, resolve } } +async function flushPromises() { + await Promise.resolve() + await Promise.resolve() +} + describe('labelStore visit and settlement ordering', () => { beforeEach(() => { vi.clearAllMocks() @@ -123,7 +128,7 @@ describe('labelStore visit and settlement ordering', () => { expect(state.currentBoardLabels.value).toEqual([updated]) }) - it('does not let an older label update settle over a newer update or invalidate its refresh', async () => { + it('serializes overlapping updates so the later intent commits last and owns the cache', async () => { const state = createState() const helpers = createHelpers() const firstUpdate = deferred() @@ -138,6 +143,21 @@ describe('labelStore visit and settlement ordering', () => { const pendingFirst = actions.updateLabel('board-1', 'lbl-1', { name: 'First' }) const pendingSecond = actions.updateLabel('board-1', 'lbl-1', { name: 'Second' }) + await flushPromises() + expect(mockLabelsApi.updateLabel).toHaveBeenCalledTimes(1) + + const firstResult = { + ...originalLabel, + name: 'First', + updatedAt: '2026-09-20T10:01:00Z', + } + firstUpdate.resolve(firstResult) + await pendingFirst + await flushPromises() + expect(mockLabelsApi.updateLabel).toHaveBeenCalledTimes(2) + expect(state.currentBoardLabels.value).toEqual([firstResult]) + + const pendingRead = actions.fetchLabels('board-1') const secondResult = { ...originalLabel, name: 'Second', @@ -145,19 +165,12 @@ describe('labelStore visit and settlement ordering', () => { } secondUpdate.resolve(secondResult) await pendingSecond - const pendingRead = actions.fetchLabels('board-1') - - firstUpdate.resolve({ - ...originalLabel, - name: 'First', - updatedAt: '2026-09-20T10:01:00Z', - }) - await pendingFirst authoritativeRead.resolve([secondResult]) await pendingRead expect(state.currentBoardLabels.value).toEqual([secondResult]) - expect(helpers.toast.success).toHaveBeenCalledTimes(1) - expect(helpers.toast.success).toHaveBeenCalledWith('Label updated successfully') + expect(helpers.toast.success).toHaveBeenCalledTimes(2) + expect(helpers.toast.success).toHaveBeenNthCalledWith(1, 'Label updated successfully') + expect(helpers.toast.success).toHaveBeenNthCalledWith(2, 'Label updated successfully') }) }) From ec17072f8f2c5f977e9483f64c79340e8cd83675 Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Sun, 20 Sep 2026 19:46:03 +0100 Subject: [PATCH 09/14] test(labels): pin session and refresh reconciliation --- .../board/labelStoreVisitOrdering.spec.ts | 99 ++++++++++++++++++- 1 file changed, 98 insertions(+), 1 deletion(-) diff --git a/frontend/taskdeck-web/src/tests/store/board/labelStoreVisitOrdering.spec.ts b/frontend/taskdeck-web/src/tests/store/board/labelStoreVisitOrdering.spec.ts index 091642f22..acf1742b2 100644 --- a/frontend/taskdeck-web/src/tests/store/board/labelStoreVisitOrdering.spec.ts +++ b/frontend/taskdeck-web/src/tests/store/board/labelStoreVisitOrdering.spec.ts @@ -46,7 +46,7 @@ function createHelpers() { guardDemoMutation: vi.fn(), handleApiError: vi.fn(), isDemoMode: false, - toast: { success: vi.fn(), error: vi.fn() }, + toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn() }, markBoardDetailMutation: vi.fn(), } } @@ -68,7 +68,9 @@ describe('labelStore visit and settlement ordering', () => { beforeEach(() => { vi.clearAllMocks() mockLabelsApi.getLabels.mockReset() + mockLabelsApi.createLabel.mockReset() mockLabelsApi.updateLabel.mockReset() + mockLabelsApi.deleteLabel.mockReset() }) it('does not let an earlier A visit overwrite the authoritative A read after A to B to A', async () => { @@ -128,6 +130,101 @@ describe('labelStore visit and settlement ordering', () => { expect(state.currentBoardLabels.value).toEqual([updated]) }) + it('patches the current same-board cache when a detail refresh replaces the array before write settlement', async () => { + const state = createState() + const helpers = createHelpers() + const update = deferred() + mockLabelsApi.updateLabel.mockReturnValueOnce(update.promise) + const actions = createLabelActions(state as never, helpers as never) + + const pendingUpdate = actions.updateLabel('board-1', 'lbl-1', { name: 'Critical' }) + + const refreshedCache = [{ ...originalLabel, name: 'Pre-write refresh' }] + state.currentBoardLabels.value = refreshedCache + const updated = { + ...originalLabel, + name: 'Critical', + updatedAt: '2026-09-20T10:01:00Z', + } + update.resolve(updated) + await pendingUpdate + + expect(state.currentBoardLabels.value).toBe(refreshedCache) + expect(state.currentBoardLabels.value).toEqual([updated]) + expect(mockLabelsApi.getLabels).not.toHaveBeenCalled() + }) + + it('reconciles a successful old-visit write into the currently reopened same board', async () => { + const state = createState() + const helpers = createHelpers() + const update = deferred() + const reconciliation = deferred() + mockLabelsApi.updateLabel.mockReturnValueOnce(update.promise) + mockLabelsApi.getLabels.mockReturnValueOnce(reconciliation.promise) + const actions = createLabelActions(state as never, helpers as never) + + const pendingUpdate = actions.updateLabel('board-1', 'lbl-1', { name: 'Old visit edit' }) + + state.currentBoard.value = { id: 'board-2' } + state.currentBoardLabels.value = [] + state.currentBoard.value = { id: 'board-1' } + state.currentBoardLabels.value = [{ ...originalLabel, name: 'Reopened pre-write value' }] + const reopenedCache = state.currentBoardLabels.value + + const updated = { + ...originalLabel, + name: 'Old visit edit', + updatedAt: '2026-09-20T10:01:00Z', + } + update.resolve(updated) + await flushPromises() + expect(mockLabelsApi.getLabels).toHaveBeenCalledWith('board-1') + + reconciliation.resolve([updated]) + await pendingUpdate + + expect(state.currentBoardLabels.value).toBe(reopenedCache) + expect(state.currentBoardLabels.value).toEqual([updated]) + expect(helpers.toast.success).not.toHaveBeenCalledWith('Label updated successfully') + }) + + it('does not start a queued label write after the board session has ended', async () => { + const state = createState() + const helpers = createHelpers() + const firstUpdate = deferred() + mockLabelsApi.updateLabel + .mockReturnValueOnce(firstUpdate.promise) + .mockResolvedValueOnce({ + ...originalLabel, + name: 'Second', + updatedAt: '2026-09-20T10:02:00Z', + }) + const actions = createLabelActions(state as never, helpers as never) + + const pendingFirst = actions.updateLabel('board-1', 'lbl-1', { name: 'First' }) + const pendingSecond = actions + .updateLabel('board-1', 'lbl-1', { name: 'Second' }) + .catch(error => error as Error) + + await flushPromises() + expect(mockLabelsApi.updateLabel).toHaveBeenCalledTimes(1) + + state.currentBoard.value = null + state.currentBoardLabels.value = [] + firstUpdate.resolve({ + ...originalLabel, + name: 'First', + updatedAt: '2026-09-20T10:01:00Z', + }) + await pendingFirst + const cancellation = await pendingSecond + + expect(cancellation.name).toBe('StaleBoardVisitError') + expect(mockLabelsApi.updateLabel).toHaveBeenCalledTimes(1) + expect(helpers.handleApiError).not.toHaveBeenCalled() + expect(state.currentBoardLabels.value).toEqual([]) + }) + it('serializes overlapping updates so the later intent commits last and owns the cache', async () => { const state = createState() const helpers = createHelpers() From 41bc6a42c6503ca8574833b7e46d27ff1b252b94 Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Sun, 20 Sep 2026 19:46:53 +0100 Subject: [PATCH 10/14] fix(labels): gate queued writes by board visit --- .../src/store/board/labelStore.ts | 139 ++++++++++++++---- 1 file changed, 109 insertions(+), 30 deletions(-) diff --git a/frontend/taskdeck-web/src/store/board/labelStore.ts b/frontend/taskdeck-web/src/store/board/labelStore.ts index c45cf282b..c1ec5e402 100644 --- a/frontend/taskdeck-web/src/store/board/labelStore.ts +++ b/frontend/taskdeck-web/src/store/board/labelStore.ts @@ -6,6 +6,7 @@ * pre-write state must not be allowed to replace the local update when it * resolves after the write (#2435). */ +import { watch } from 'vue' import { labelsApi } from '../../api/labelsApi' import type { CreateLabelDto, Label, UpdateLabelDto } from '../../types/board' import type { BoardState } from './boardState' @@ -14,33 +15,55 @@ import type { BoardHelpers } from './boardStoreHelpers' interface LabelCacheVisit { boardId: string labels: Label[] + generation: number +} + +class StaleBoardVisitError extends Error { + constructor() { + super('The board visit that queued this label change has ended.') + this.name = 'StaleBoardVisitError' + } } export function createLabelActions(state: BoardState, helpers: BoardHelpers) { // Label state is one selected-board collection. Board-detail commits replace - // the array, so its identity is the visit/session boundary; same-visit label - // operations mutate that array in place. Separate read and mutation versions - // order reads, while same-label writes are serialized because the API has no - // revision precondition to reject an older request that reaches the server last. + // the array, so its identity distinguishes overlapping reads within one visit. + // A separate generation observes board-id transitions synchronously: unlike + // array identity it survives a same-board detail refresh, but A→B→A and + // logout→login can never reuse the old authority. const readVersionByBoardId = new Map() const mutationVersionByBoardId = new Map() const mutationTailByLabelKey = new Map>() + let boardVisitGeneration = 0 + + watch( + () => state.currentBoard?.value?.id ?? null, + (nextBoardId, previousBoardId) => { + if (nextBoardId !== previousBoardId) boardVisitGeneration++ + }, + { flush: 'sync' }, + ) function captureLabelVisit(boardId: string): LabelCacheVisit { return { boardId, labels: state.currentBoardLabels.value, + generation: boardVisitGeneration, } } - function ownsCurrentLabels(visit: LabelCacheVisit) { + function isCurrentBoardVisit(visit: LabelCacheVisit) { const currentBoard = state.currentBoard?.value return ( (currentBoard == null || currentBoard.id === visit.boardId) && - state.currentBoardLabels.value === visit.labels + boardVisitGeneration === visit.generation ) } + function ownsExactLabelCache(visit: LabelCacheVisit) { + return isCurrentBoardVisit(visit) && state.currentBoardLabels.value === visit.labels + } + function nextReadVersion(boardId: string) { const version = (readVersionByBoardId.get(boardId) ?? 0) + 1 readVersionByBoardId.set(boardId, version) @@ -58,11 +81,18 @@ export function createLabelActions(state: BoardState, helpers: BoardHelpers) { async function runLabelMutation( boardId: string, labelId: string, + visit: LabelCacheVisit, mutation: () => Promise, ): Promise { const key = `${boardId}:${labelId}` const previous = mutationTailByLabelKey.get(key) ?? Promise.resolve() - const operation = previous.catch(() => undefined).then(mutation) + const operation = previous.catch(() => undefined).then(() => { + // The HTTP interceptor reads the token when transport starts. A queued + // pre-logout intent must therefore be rejected BEFORE invoking the API, + // not merely ignored when its response arrives under another session. + if (!isCurrentBoardVisit(visit)) throw new StaleBoardVisitError() + return mutation() + }) const tail = operation.then( () => undefined, () => undefined, @@ -78,6 +108,37 @@ export function createLabelActions(state: BoardState, helpers: BoardHelpers) { } } + async function reconcileCurrentLabelsAfterStaleVisit(boardId: string) { + if (state.currentBoard?.value?.id !== boardId) return + + const visit = captureLabelVisit(boardId) + const readVersion = nextReadVersion(boardId) + const mutationVersion = currentMutationVersion(boardId) + try { + const labels = await labelsApi.getLabels(boardId) + if ( + isCurrentBoardVisit(visit) && + readVersionByBoardId.get(boardId) === readVersion && + currentMutationVersion(boardId) === mutationVersion + ) { + // This read starts only after the write succeeded. A pre-write detail + // read is invalidated by the mutation epoch; replacing the latest + // same-visit array therefore installs the authoritative post-write set. + state.currentBoardLabels.value.splice( + 0, + state.currentBoardLabels.value.length, + ...labels, + ) + } + } catch { + if (isCurrentBoardVisit(visit)) { + helpers.toast.warning( + 'Label saved, but labels could not be refreshed. Refresh the board before editing again.', + ) + } + } + } + async function fetchLabels(boardId: string) { if (helpers.isDemoMode) return const visit = captureLabelVisit(boardId) @@ -86,14 +147,16 @@ export function createLabelActions(state: BoardState, helpers: BoardHelpers) { try { const labels = await labelsApi.getLabels(boardId) if ( - ownsCurrentLabels(visit) && + ownsExactLabelCache(visit) && readVersionByBoardId.get(boardId) === readVersion && currentMutationVersion(boardId) === mutationVersion ) { visit.labels.splice(0, visit.labels.length, ...labels) } } catch (e: unknown) { - helpers.handleApiError(e, 'Failed to fetch labels') + if (ownsExactLabelCache(visit)) { + helpers.handleApiError(e, 'Failed to fetch labels') + } throw e } } @@ -106,21 +169,27 @@ export function createLabelActions(state: BoardState, helpers: BoardHelpers) { state.error.value = null const newLabel = await labelsApi.createLabel(boardId, label) helpers.markBoardDetailMutation(boardId) - if (ownsCurrentLabels(visit)) { - markLabelMutation(boardId) - if (!visit.labels.some(existingLabel => existingLabel.id === newLabel.id)) { - // A board-detail refresh can commit the new stable id before the POST + markLabelMutation(boardId) + + if (isCurrentBoardVisit(visit)) { + const currentLabels = state.currentBoardLabels.value + if (!currentLabels.some(existingLabel => existingLabel.id === newLabel.id)) { + // A same-board refresh can install the stable id before the POST // resolves. Preserve that fresher object instead of appending a duplicate. - visit.labels.push(newLabel) + currentLabels.push(newLabel) } helpers.toast.success(`Label "${newLabel.name}" created successfully`) + } else if (state.currentBoard?.value?.id === boardId) { + await reconcileCurrentLabelsAfterStaleVisit(boardId) } return newLabel } catch (e: unknown) { - helpers.handleApiError(e, 'Failed to create label') + if (isCurrentBoardVisit(visit)) { + helpers.handleApiError(e, 'Failed to create label') + } throw e } finally { - state.loading.value = false + if (isCurrentBoardVisit(visit)) state.loading.value = false } } @@ -133,25 +202,29 @@ export function createLabelActions(state: BoardState, helpers: BoardHelpers) { const updatedLabel = await runLabelMutation( boardId, labelId, + visit, () => labelsApi.updateLabel(boardId, labelId, label), ) helpers.markBoardDetailMutation(boardId) + markLabelMutation(boardId) - if (ownsCurrentLabels(visit)) { - markLabelMutation(boardId) - const index = visit.labels.findIndex((candidate) => candidate.id === labelId) - if (index !== -1) { - visit.labels[index] = updatedLabel - } + if (isCurrentBoardVisit(visit)) { + const currentLabels = state.currentBoardLabels.value + const index = currentLabels.findIndex((candidate) => candidate.id === labelId) + if (index !== -1) currentLabels[index] = updatedLabel helpers.toast.success('Label updated successfully') + } else if (state.currentBoard?.value?.id === boardId) { + await reconcileCurrentLabelsAfterStaleVisit(boardId) } return updatedLabel } catch (e: unknown) { - helpers.handleApiError(e, 'Failed to update label') + if (!(e instanceof StaleBoardVisitError) && isCurrentBoardVisit(visit)) { + helpers.handleApiError(e, 'Failed to update label') + } throw e } finally { - state.loading.value = false + if (isCurrentBoardVisit(visit)) state.loading.value = false } } @@ -164,21 +237,27 @@ export function createLabelActions(state: BoardState, helpers: BoardHelpers) { await runLabelMutation( boardId, labelId, + visit, () => labelsApi.deleteLabel(boardId, labelId), ) helpers.markBoardDetailMutation(boardId) + markLabelMutation(boardId) - if (ownsCurrentLabels(visit)) { - markLabelMutation(boardId) - const index = visit.labels.findIndex((candidate) => candidate.id === labelId) - if (index !== -1) visit.labels.splice(index, 1) + if (isCurrentBoardVisit(visit)) { + const currentLabels = state.currentBoardLabels.value + const index = currentLabels.findIndex((candidate) => candidate.id === labelId) + if (index !== -1) currentLabels.splice(index, 1) helpers.toast.success('Label deleted successfully') + } else if (state.currentBoard?.value?.id === boardId) { + await reconcileCurrentLabelsAfterStaleVisit(boardId) } } catch (e: unknown) { - helpers.handleApiError(e, 'Failed to delete label') + if (!(e instanceof StaleBoardVisitError) && isCurrentBoardVisit(visit)) { + helpers.handleApiError(e, 'Failed to delete label') + } throw e } finally { - state.loading.value = false + if (isCurrentBoardVisit(visit)) state.loading.value = false } } From e45a2409c330cd649f9765a62adad301f3e81f26 Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Sun, 20 Sep 2026 20:12:01 +0100 Subject: [PATCH 11/14] fix(labels): start unqueued writes in initiating visit --- .../src/store/board/labelStore.ts | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/frontend/taskdeck-web/src/store/board/labelStore.ts b/frontend/taskdeck-web/src/store/board/labelStore.ts index c1ec5e402..eaa195d14 100644 --- a/frontend/taskdeck-web/src/store/board/labelStore.ts +++ b/frontend/taskdeck-web/src/store/board/labelStore.ts @@ -85,14 +85,25 @@ export function createLabelActions(state: BoardState, helpers: BoardHelpers) { mutation: () => Promise, ): Promise { const key = `${boardId}:${labelId}` - const previous = mutationTailByLabelKey.get(key) ?? Promise.resolve() - const operation = previous.catch(() => undefined).then(() => { - // The HTTP interceptor reads the token when transport starts. A queued - // pre-logout intent must therefore be rejected BEFORE invoking the API, - // not merely ignored when its response arrives under another session. + const previous = mutationTailByLabelKey.get(key) + let operation: Promise + + if (previous) { + operation = previous.catch(() => undefined).then(() => { + // The HTTP interceptor reads the token when transport starts. A queued + // pre-logout intent must therefore be rejected BEFORE invoking the API, + // not merely ignored when its response arrives under another session. + 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 ba2eba81e08aa4a221dd26920f86404580242baa Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Sun, 20 Sep 2026 20:12:30 +0100 Subject: [PATCH 12/14] test(labels): compare the installed Vue cache identity --- .../src/tests/store/board/labelStoreVisitOrdering.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/taskdeck-web/src/tests/store/board/labelStoreVisitOrdering.spec.ts b/frontend/taskdeck-web/src/tests/store/board/labelStoreVisitOrdering.spec.ts index acf1742b2..12566e6a7 100644 --- a/frontend/taskdeck-web/src/tests/store/board/labelStoreVisitOrdering.spec.ts +++ b/frontend/taskdeck-web/src/tests/store/board/labelStoreVisitOrdering.spec.ts @@ -141,6 +141,7 @@ describe('labelStore visit and settlement ordering', () => { const refreshedCache = [{ ...originalLabel, name: 'Pre-write refresh' }] state.currentBoardLabels.value = refreshedCache + const installedCache = state.currentBoardLabels.value const updated = { ...originalLabel, name: 'Critical', @@ -149,7 +150,7 @@ describe('labelStore visit and settlement ordering', () => { update.resolve(updated) await pendingUpdate - expect(state.currentBoardLabels.value).toBe(refreshedCache) + expect(state.currentBoardLabels.value).toBe(installedCache) expect(state.currentBoardLabels.value).toEqual([updated]) expect(mockLabelsApi.getLabels).not.toHaveBeenCalled() }) From 2b3248ffe4ec039d1a0f7cd7b095aa72b6b51a18 Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Tue, 22 Sep 2026 00:57:03 +0100 Subject: [PATCH 13/14] test(labels): expose stale same-board read error publication --- .../store/board/labelStoreReadErrors.spec.ts | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 frontend/taskdeck-web/src/tests/store/board/labelStoreReadErrors.spec.ts diff --git a/frontend/taskdeck-web/src/tests/store/board/labelStoreReadErrors.spec.ts b/frontend/taskdeck-web/src/tests/store/board/labelStoreReadErrors.spec.ts new file mode 100644 index 000000000..72da586ca --- /dev/null +++ b/frontend/taskdeck-web/src/tests/store/board/labelStoreReadErrors.spec.ts @@ -0,0 +1,113 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' + +const { mockLabelsApi } = vi.hoisted(() => ({ + mockLabelsApi: { + getLabels: vi.fn(), + createLabel: vi.fn(), + updateLabel: vi.fn(), + deleteLabel: vi.fn(), + }, +})) + +vi.mock('../../../api/labelsApi', () => ({ labelsApi: mockLabelsApi })) + +import { createLabelActions } from '../../../store/board/labelStore' + +const savedLabel = { id: 'label-1', boardId: 'board-1', name: 'Saved', colorHex: '#123456' } + +function deferred() { + let resolve!: (value: T) => void + let reject!: (error: unknown) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + +function createHarness() { + const state = { + currentBoard: ref<{ id: string } | null>({ id: 'board-1' }), + currentBoardLabels: ref>([]), + loading: ref(false), + error: ref(null), + } + const helpers = { + isDemoMode: false, + guardDemoMutation: vi.fn(), + markBoardDetailMutation: vi.fn(), + toast: { success: vi.fn(), warning: vi.fn(), error: vi.fn() }, + handleApiError: vi.fn((error: unknown) => { + state.error.value = error instanceof Error ? error.message : String(error) + throw error + }), + } + return { state, helpers, actions: createLabelActions(state as never, helpers as never) } +} + +describe('labelStore read error ownership', () => { + beforeEach(() => { + for (const mock of Object.values(mockLabelsApi)) mock.mockReset() + }) + + it('does not publish an older read error after a newer read succeeds', async () => { + const older = deferred>() + mockLabelsApi.getLabels.mockReturnValueOnce(older.promise).mockResolvedValueOnce([savedLabel]) + const { state, helpers, actions } = createHarness() + const pending = actions.fetchLabels('board-1').catch(error => error) + + await actions.fetchLabels('board-1') + const failure = new Error('obsolete read failed') + older.reject(failure) + + expect(await pending).toBe(failure) + expect(state.currentBoardLabels.value).toEqual([savedLabel]) + expect(state.error.value).toBeNull() + expect(helpers.handleApiError).not.toHaveBeenCalled() + }) + + it('does not replace a newer read error with an older read error', async () => { + const older = deferred>() + const latestFailure = new Error('current read failed') + mockLabelsApi.getLabels.mockReturnValueOnce(older.promise).mockRejectedValueOnce(latestFailure) + const { state, helpers, actions } = createHarness() + const pending = actions.fetchLabels('board-1').catch(error => error) + + await expect(actions.fetchLabels('board-1')).rejects.toBe(latestFailure) + const staleFailure = new Error('obsolete read failed') + older.reject(staleFailure) + + expect(await pending).toBe(staleFailure) + expect(state.error.value).toBe(latestFailure.message) + expect(helpers.handleApiError).toHaveBeenCalledExactlyOnceWith(latestFailure, 'Failed to fetch labels') + }) + + it('does not publish a pre-write read error after a confirmed label mutation', async () => { + const older = deferred>() + mockLabelsApi.getLabels.mockReturnValueOnce(older.promise) + mockLabelsApi.createLabel.mockResolvedValueOnce(savedLabel) + const { state, helpers, actions } = createHarness() + const pending = actions.fetchLabels('board-1').catch(error => error) + + await actions.createLabel('board-1', { name: savedLabel.name, colorHex: savedLabel.colorHex }) + const failure = new Error('pre-write read failed') + older.reject(failure) + + expect(await pending).toBe(failure) + expect(state.currentBoardLabels.value).toEqual([savedLabel]) + expect(state.error.value).toBeNull() + expect(helpers.handleApiError).not.toHaveBeenCalled() + }) + + it('still publishes and rejects a current read failure', async () => { + const failure = new Error('current read failed') + mockLabelsApi.getLabels.mockRejectedValueOnce(failure) + const { state, helpers, actions } = createHarness() + + await expect(actions.fetchLabels('board-1')).rejects.toBe(failure) + + expect(state.error.value).toBe(failure.message) + expect(helpers.handleApiError).toHaveBeenCalledExactlyOnceWith(failure, 'Failed to fetch labels') + }) +}) From b18f7794d945d4294c947fb4c48946f7f34ac36a Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Tue, 22 Sep 2026 00:59:42 +0100 Subject: [PATCH 14/14] fix(labels): fence read errors by current read and mutation epochs --- frontend/taskdeck-web/src/store/board/labelStore.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/frontend/taskdeck-web/src/store/board/labelStore.ts b/frontend/taskdeck-web/src/store/board/labelStore.ts index eaa195d14..7c3ddec98 100644 --- a/frontend/taskdeck-web/src/store/board/labelStore.ts +++ b/frontend/taskdeck-web/src/store/board/labelStore.ts @@ -165,7 +165,11 @@ export function createLabelActions(state: BoardState, helpers: BoardHelpers) { visit.labels.splice(0, visit.labels.length, ...labels) } } catch (e: unknown) { - if (ownsExactLabelCache(visit)) { + if ( + ownsExactLabelCache(visit) && + readVersionByBoardId.get(boardId) === readVersion && + currentMutationVersion(boardId) === mutationVersion + ) { helpers.handleApiError(e, 'Failed to fetch labels') } throw e