Clear
Cancel assignment changes
diff --git a/frontend/taskdeck-web/src/composables/useAssignmentSaveRegistry.ts b/frontend/taskdeck-web/src/composables/useAssignmentSaveRegistry.ts
new file mode 100644
index 000000000..9b6432d0a
--- /dev/null
+++ b/frontend/taskdeck-web/src/composables/useAssignmentSaveRegistry.ts
@@ -0,0 +1,58 @@
+import type { InjectionKey } from 'vue'
+
+export interface AssignmentSaveRegistry {
+ /**
+ * Claim one submitted assignment write. The returned idempotent release
+ * function belongs to that exact operation and remains valid after the
+ * initiating component unmounts.
+ */
+ begin(ownerId: string): () => void
+ /**
+ * End the visible board session. Active old-generation operations are hidden
+ * synchronously; their later releases cannot affect a replacement session.
+ */
+ reset(): void
+}
+
+export const assignmentSaveRegistryKey: InjectionKey
=
+ Symbol('assignment-save-registry')
+
+export function createAssignmentSaveRegistry(
+ onSavingChange: (saving: boolean) => void,
+): AssignmentSaveRegistry {
+ let generation = 0
+ let nextToken = 0
+ let aggregateSaving = false
+ const activeTokens = new Set()
+
+ function publish() {
+ const nextSaving = activeTokens.size > 0
+ if (nextSaving === aggregateSaving) return
+ aggregateSaving = nextSaving
+ onSavingChange(nextSaving)
+ }
+
+ function begin(ownerId: string) {
+ const operationGeneration = generation
+ const token = `${operationGeneration}:${ownerId}:${nextToken++}`
+ activeTokens.add(token)
+ publish()
+
+ let released = false
+ return () => {
+ if (released) return
+ released = true
+ if (operationGeneration !== generation) return
+ activeTokens.delete(token)
+ publish()
+ }
+ }
+
+ function reset() {
+ generation++
+ activeTokens.clear()
+ publish()
+ }
+
+ return { begin, reset }
+}
diff --git a/frontend/taskdeck-web/src/store/board/boardCrudStore.ts b/frontend/taskdeck-web/src/store/board/boardCrudStore.ts
index d9dc57283..1f1983a91 100644
--- a/frontend/taskdeck-web/src/store/board/boardCrudStore.ts
+++ b/frontend/taskdeck-web/src/store/board/boardCrudStore.ts
@@ -707,6 +707,8 @@ export function createBoardCrudActions(state: BoardState, helpers: BoardHelpers)
* lifecycles are aborted: every open list read and the active detail read.
*/
function resetForLogout() {
+ state.boardMutationSessionGeneration.value++
+ state.boardViewVisit.value = { boardId: null }
boardListGeneration++
// The bump comes first so the rejection each abort produces lands on a
// stale generation: the catch returns before handleApiError, so no toast
diff --git a/frontend/taskdeck-web/src/store/board/boardState.ts b/frontend/taskdeck-web/src/store/board/boardState.ts
index 1472cbc1a..9c07274f9 100644
--- a/frontend/taskdeck-web/src/store/board/boardState.ts
+++ b/frontend/taskdeck-web/src/store/board/boardState.ts
@@ -6,7 +6,7 @@
* created lazily via `useBoardState()` which must be called inside
* a Pinia store setup function (i.e. after `setActivePinia`).
*/
-import { ref } from 'vue'
+import { ref, shallowRef } from 'vue'
import type { Board, BoardDetail, Card, Label } from '../../types/board'
import type { CardComment } from '../../types/comments'
import type { BoardPresenceMember } from '../../types/realtime'
@@ -18,6 +18,10 @@ export interface CardFilters {
showBlockedOnly: boolean
}
+export interface BoardViewVisit {
+ readonly boardId: string | null
+}
+
/**
* The card-filter defaults: nothing searched, no labels selected, no due-date
* narrowing, blocked cards not isolated.
@@ -42,6 +46,11 @@ export function createBoardState() {
const boards = ref([])
const activeBoardId = ref(null)
const currentBoard = ref(null)
+ // Undefined preserves programmatic store use before a route owns the store.
+ // Once bound, a fresh object identifies each visit; null boardId retires it
+ // without discarding cached detail. Detail refreshes do not change ownership.
+ const boardViewVisit = shallowRef(undefined)
+ const boardMutationSessionGeneration = ref(0)
// These two monotonic values retain the source of the current detail payload
// without extending the Board DTO. A consumer can distinguish a committed
// server response from a local patch, and reject a response whose request
@@ -62,6 +71,8 @@ export function createBoardState() {
boards,
activeBoardId,
currentBoard,
+ boardViewVisit,
+ boardMutationSessionGeneration,
currentBoardRequestGeneration,
currentBoardPayloadGeneration,
currentBoardCards,
diff --git a/frontend/taskdeck-web/src/store/board/boardUiStore.ts b/frontend/taskdeck-web/src/store/board/boardUiStore.ts
index 62af7971e..e86b31098 100644
--- a/frontend/taskdeck-web/src/store/board/boardUiStore.ts
+++ b/frontend/taskdeck-web/src/store/board/boardUiStore.ts
@@ -2,9 +2,21 @@
* Board UI state: presence members, editing card state.
*/
import type { BoardPresenceMember } from '../../types/realtime'
-import type { BoardState } from './boardState'
+import type { BoardState, BoardViewVisit } from './boardState'
export function createBoardUiActions(state: BoardState) {
+ function beginBoardViewVisit(boardId: string): BoardViewVisit {
+ const visit = { boardId }
+ state.boardViewVisit.value = visit
+ return visit
+ }
+
+ function endBoardViewVisit(visit: BoardViewVisit) {
+ if (state.boardViewVisit.value === visit) {
+ state.boardViewVisit.value = { boardId: null }
+ }
+ }
+
function setBoardPresenceMembers(members: BoardPresenceMember[]) {
state.boardPresenceMembers.value = members
}
@@ -14,6 +26,8 @@ export function createBoardUiActions(state: BoardState) {
}
return {
+ beginBoardViewVisit,
+ endBoardViewVisit,
setBoardPresenceMembers,
setEditingCard,
}
diff --git a/frontend/taskdeck-web/src/store/board/columnStore.ts b/frontend/taskdeck-web/src/store/board/columnStore.ts
index 73db0efbc..772fe11eb 100644
--- a/frontend/taskdeck-web/src/store/board/columnStore.ts
+++ b/frontend/taskdeck-web/src/store/board/columnStore.ts
@@ -1,118 +1,274 @@
/**
* Column operations: create, update, delete, reorder columns.
*/
+import { watch } from 'vue'
import { columnsApi } from '../../api/columnsApi'
import type { CreateColumnDto, UpdateColumnDto } from '../../types/board'
-import type { BoardState } from './boardState'
+import type { BoardState, BoardViewVisit } from './boardState'
import type { BoardHelpers } from './boardStoreHelpers'
+interface ColumnMutationVisit {
+ boardId: string
+ ownerBoardId: string | null
+ generation: number
+ viewVisit: BoardViewVisit | undefined
+ sessionGeneration: number
+}
+
+class StaleBoardVisitError extends Error {
+ constructor() {
+ super('The board visit that queued this column change has ended.')
+ this.name = 'StaleBoardVisitError'
+ }
+}
+
export function createColumnActions(state: BoardState, helpers: BoardHelpers) {
- async function createColumn(boardId: string, column: CreateColumnDto) {
- helpers.guardDemoMutation()
+ // Every column write can affect the board's ordered column set. The API has
+ // no revision precondition, so serialize one lane per board: server commit
+ // order then follows user-intent order while unrelated boards remain free to
+ // progress independently. The visit generation prevents a queued pre-logout
+ // intent from starting transport with a later session's credentials.
+ const mutationTailByBoardId = new Map>()
+ const mutationVersionByBoardId = new Map()
+ let boardVisitGeneration = 0
+
+ watch(
+ () => state.currentBoard.value?.id ?? null,
+ (nextBoardId, previousBoardId) => {
+ if (nextBoardId !== previousBoardId) boardVisitGeneration++
+ },
+ { flush: 'sync' },
+ )
+
+ function captureColumnVisit(boardId: string): ColumnMutationVisit {
+ return {
+ boardId,
+ ownerBoardId: state.currentBoard.value?.id ?? null,
+ generation: boardVisitGeneration,
+ viewVisit: state.boardViewVisit.value,
+ sessionGeneration: state.boardMutationSessionGeneration.value,
+ }
+ }
+
+ function isCurrentVisit(visit: ColumnMutationVisit) {
+ return (
+ (state.currentBoard.value?.id ?? null) === visit.ownerBoardId &&
+ boardVisitGeneration === visit.generation &&
+ state.boardMutationSessionGeneration.value === visit.sessionGeneration &&
+ state.boardViewVisit.value === visit.viewVisit &&
+ (visit.viewVisit === undefined || visit.viewVisit.boardId === visit.boardId)
+ )
+ }
+
+ function ownsTargetBoard(visit: ColumnMutationVisit) {
+ return isCurrentVisit(visit) && state.currentBoard.value?.id === visit.boardId
+ }
+
+ function currentMutationVersion(boardId: string) {
+ return mutationVersionByBoardId.get(boardId) ?? 0
+ }
+
+ function markColumnMutation(visit: ColumnMutationVisit) {
+ if (state.boardMutationSessionGeneration.value !== visit.sessionGeneration) return
+ helpers.markBoardDetailMutation(visit.boardId)
+ mutationVersionByBoardId.set(visit.boardId, currentMutationVersion(visit.boardId) + 1)
+ }
+
+ async function runColumnMutation(
+ visit: ColumnMutationVisit,
+ mutation: () => Promise,
+ ): Promise {
+ const previous = mutationTailByBoardId.get(visit.boardId)
+ let operation: Promise
+
+ if (previous) {
+ operation = previous.catch(() => undefined).then(() => {
+ if (!isCurrentVisit(visit)) throw new StaleBoardVisitError()
+ return mutation()
+ })
+ } else {
+ // The first intent starts transport in the initiating call stack. Only a
+ // later intent is queued and therefore needs a pre-transport session gate.
+ if (!isCurrentVisit(visit)) throw new StaleBoardVisitError()
+ operation = mutation()
+ }
+
+ const tail = operation.then(
+ () => undefined,
+ () => undefined,
+ )
+ mutationTailByBoardId.set(visit.boardId, tail)
+
try {
- state.loading.value = true
- state.error.value = null
- const newColumn = await columnsApi.createColumn(boardId, column)
- helpers.markBoardDetailMutation(boardId)
-
- if (state.currentBoard.value && state.currentBoard.value.id === boardId) {
- // A realtime refresh can install the new column before this request resolves.
- // Preserve that fresher object and only append when the stable id is absent.
- const alreadyPresent = state.currentBoard.value.columns.some(
- (existingColumn) => existingColumn.id === newColumn.id,
- )
+ return await operation
+ } finally {
+ if (mutationTailByBoardId.get(visit.boardId) === tail) {
+ mutationTailByBoardId.delete(visit.boardId)
+ }
+ }
+ }
+
+ async function reconcileReopenedBoard(previousVisit: ColumnMutationVisit, deletedColumnId?: string) {
+ const { boardId } = previousVisit
+ if (
+ state.boardMutationSessionGeneration.value !== previousVisit.sessionGeneration ||
+ state.currentBoard.value?.id !== boardId
+ ) return
- if (!alreadyPresent) {
- state.currentBoard.value.columns.push(newColumn)
+ const visit = captureColumnVisit(boardId)
+ if (!ownsTargetBoard(visit)) return
+ const mutationVersion = currentMutationVersion(boardId)
+ try {
+ const columns = await columnsApi.getColumns(boardId)
+ if (
+ ownsTargetBoard(visit) &&
+ currentMutationVersion(boardId) === mutationVersion
+ ) {
+ state.currentBoard.value!.columns = columns
+ if (deletedColumnId) {
+ // Apply the cascade under the same guard as the refreshed columns;
+ // another route can take ownership at the next await boundary.
+ state.currentBoardCards.value = state.currentBoardCards.value.filter(
+ card => card.columnId !== deletedColumnId,
+ )
}
}
-
- helpers.toast.success(`Column "${newColumn.name}" created successfully`)
- return newColumn
- } catch (e: unknown) {
- helpers.handleApiError(e, 'Failed to create column')
- throw e
- } finally {
- state.loading.value = false
+ } catch {
+ if (ownsTargetBoard(visit)) {
+ helpers.toast.warning(
+ 'Column change saved, but columns could not be refreshed. Refresh the board before editing again.',
+ )
+ }
}
}
- async function updateColumn(boardId: string, columnId: string, column: UpdateColumnDto) {
+ async function createColumn(boardId: string, column: CreateColumnDto) {
helpers.guardDemoMutation()
- try {
- state.loading.value = true
- state.error.value = null
- const updatedColumn = await columnsApi.updateColumn(boardId, columnId, column)
- helpers.markBoardDetailMutation(boardId)
-
- // Update column in current board
- if (state.currentBoard.value && state.currentBoard.value.id === boardId) {
- const index = state.currentBoard.value.columns.findIndex((c) => c.id === columnId)
- if (index !== -1) {
- state.currentBoard.value.columns[index] = updatedColumn
+ const visit = captureColumnVisit(boardId)
+ return runColumnMutation(visit, async () => {
+ try {
+ state.loading.value = true
+ state.error.value = null
+ const newColumn = await columnsApi.createColumn(boardId, column)
+ markColumnMutation(visit)
+
+ if (ownsTargetBoard(visit)) {
+ // A same-board detail refresh can install the new column before this
+ // request resolves. Preserve that fresher object when the stable id exists.
+ const currentColumns = state.currentBoard.value!.columns
+ if (!currentColumns.some(existingColumn => existingColumn.id === newColumn.id)) {
+ currentColumns.push(newColumn)
+ }
+ } else if (!isCurrentVisit(visit) && state.currentBoard.value?.id === boardId) {
+ await reconcileReopenedBoard(visit)
}
+
+ if (isCurrentVisit(visit)) {
+ helpers.toast.success(`Column "${newColumn.name}" created successfully`)
+ }
+ return newColumn
+ } catch (e: unknown) {
+ if (isCurrentVisit(visit)) {
+ helpers.handleApiError(e, 'Failed to create column')
+ }
+ throw e
+ } finally {
+ if (isCurrentVisit(visit)) state.loading.value = false
}
+ })
+ }
- helpers.toast.success('Column updated successfully')
- return updatedColumn
- } catch (e: unknown) {
- helpers.handleApiError(e, 'Failed to update column')
- throw e
- } finally {
- state.loading.value = false
- }
+ async function updateColumn(boardId: string, columnId: string, column: UpdateColumnDto) {
+ helpers.guardDemoMutation()
+ const visit = captureColumnVisit(boardId)
+ return runColumnMutation(visit, async () => {
+ try {
+ state.loading.value = true
+ state.error.value = null
+ const updatedColumn = await columnsApi.updateColumn(boardId, columnId, column)
+ markColumnMutation(visit)
+
+ if (ownsTargetBoard(visit)) {
+ const currentColumns = state.currentBoard.value!.columns
+ const index = currentColumns.findIndex(candidate => candidate.id === columnId)
+ if (index !== -1) currentColumns[index] = updatedColumn
+ } else if (!isCurrentVisit(visit) && state.currentBoard.value?.id === boardId) {
+ await reconcileReopenedBoard(visit)
+ }
+
+ if (isCurrentVisit(visit)) helpers.toast.success('Column updated successfully')
+ return updatedColumn
+ } catch (e: unknown) {
+ if (isCurrentVisit(visit)) {
+ helpers.handleApiError(e, 'Failed to update column')
+ }
+ throw e
+ } finally {
+ if (isCurrentVisit(visit)) state.loading.value = false
+ }
+ })
}
async function deleteColumn(boardId: string, columnId: string) {
helpers.guardDemoMutation()
- try {
- state.loading.value = true
- state.error.value = null
- await columnsApi.deleteColumn(boardId, columnId)
- helpers.markBoardDetailMutation(boardId)
-
- // Remove column from current board
- if (state.currentBoard.value && state.currentBoard.value.id === boardId) {
- state.currentBoard.value.columns = state.currentBoard.value.columns.filter(
- (c) => c.id !== columnId,
- )
- }
+ const visit = captureColumnVisit(boardId)
+ return runColumnMutation(visit, async () => {
+ try {
+ state.loading.value = true
+ state.error.value = null
+ await columnsApi.deleteColumn(boardId, columnId)
+ markColumnMutation(visit)
- // Remove cards from deleted column
- state.currentBoardCards.value = state.currentBoardCards.value.filter(
- (card) => card.columnId !== columnId,
- )
+ if (ownsTargetBoard(visit)) {
+ state.currentBoard.value!.columns = state.currentBoard.value!.columns.filter(
+ candidate => candidate.id !== columnId,
+ )
+ state.currentBoardCards.value = state.currentBoardCards.value.filter(
+ card => card.columnId !== columnId,
+ )
+ } else if (!isCurrentVisit(visit) && state.currentBoard.value?.id === boardId) {
+ await reconcileReopenedBoard(visit, columnId)
+ }
- helpers.toast.success('Column deleted successfully')
- } catch (e: unknown) {
- helpers.handleApiError(e, 'Failed to delete column')
- throw e
- } finally {
- state.loading.value = false
- }
+ if (isCurrentVisit(visit)) helpers.toast.success('Column deleted successfully')
+ } catch (e: unknown) {
+ if (isCurrentVisit(visit)) {
+ helpers.handleApiError(e, 'Failed to delete column')
+ }
+ throw e
+ } finally {
+ if (isCurrentVisit(visit)) state.loading.value = false
+ }
+ })
}
async function reorderColumns(boardId: string, columnIds: string[]) {
helpers.guardDemoMutation()
- try {
- state.loading.value = true
- state.error.value = null
- const reorderedColumns = await columnsApi.reorderColumns(boardId, columnIds)
- helpers.markBoardDetailMutation(boardId)
-
- // Update columns in current board with reordered list
- if (state.currentBoard.value && state.currentBoard.value.id === boardId) {
- state.currentBoard.value.columns = reorderedColumns
- }
+ const visit = captureColumnVisit(boardId)
+ return runColumnMutation(visit, async () => {
+ try {
+ state.loading.value = true
+ state.error.value = null
+ const reorderedColumns = await columnsApi.reorderColumns(boardId, columnIds)
+ markColumnMutation(visit)
- helpers.toast.success('Columns reordered successfully')
- return reorderedColumns
- } catch (e: unknown) {
- helpers.handleApiError(e, 'Failed to reorder columns')
- throw e
- } finally {
- state.loading.value = false
- }
+ if (ownsTargetBoard(visit)) {
+ state.currentBoard.value!.columns = reorderedColumns
+ } else if (!isCurrentVisit(visit) && state.currentBoard.value?.id === boardId) {
+ await reconcileReopenedBoard(visit)
+ }
+
+ if (isCurrentVisit(visit)) helpers.toast.success('Columns reordered successfully')
+ return reorderedColumns
+ } catch (e: unknown) {
+ if (isCurrentVisit(visit)) {
+ helpers.handleApiError(e, 'Failed to reorder columns')
+ }
+ throw e
+ } finally {
+ if (isCurrentVisit(visit)) state.loading.value = false
+ }
+ })
}
return {
diff --git a/frontend/taskdeck-web/src/store/boardStore.ts b/frontend/taskdeck-web/src/store/boardStore.ts
index 885d90fdf..c5ac86edb 100644
--- a/frontend/taskdeck-web/src/store/boardStore.ts
+++ b/frontend/taskdeck-web/src/store/boardStore.ts
@@ -92,6 +92,8 @@ export const useBoardStore = defineStore('board', () => {
clearFilters: filtering.clearFilters,
// Actions — UI state
+ beginBoardViewVisit: ui.beginBoardViewVisit,
+ endBoardViewVisit: ui.endBoardViewVisit,
setBoardPresenceMembers: ui.setBoardPresenceMembers,
setEditingCard: ui.setEditingCard,
diff --git a/frontend/taskdeck-web/src/tests/components/BoardCanvasSaveLifecycle.spec.ts b/frontend/taskdeck-web/src/tests/components/BoardCanvasSaveLifecycle.spec.ts
new file mode 100644
index 000000000..9c732c126
--- /dev/null
+++ b/frontend/taskdeck-web/src/tests/components/BoardCanvasSaveLifecycle.spec.ts
@@ -0,0 +1,124 @@
+import { defineComponent, inject, nextTick } from 'vue'
+import { describe, expect, it } from 'vitest'
+import { mount } from '@vue/test-utils'
+import BoardCanvas from '../../components/board/BoardCanvas.vue'
+import {
+ assignmentSaveRegistryKey,
+ type AssignmentSaveRegistry,
+} from '../../composables/useAssignmentSaveRegistry'
+import type { Column } from '../../types/board'
+
+const columns: Column[] = [
+ {
+ id: 'column-a',
+ boardId: 'board-1',
+ name: 'Todo',
+ position: 0,
+ wipLimit: null,
+ cardCount: 0,
+ createdAt: '2026-09-20T10:00:00Z',
+ updatedAt: '2026-09-20T10:00:00Z',
+ },
+ {
+ id: 'column-b',
+ boardId: 'board-1',
+ name: 'Done',
+ position: 1,
+ wipLimit: null,
+ cardCount: 0,
+ createdAt: '2026-09-20T10:00:00Z',
+ updatedAt: '2026-09-20T10:00:00Z',
+ },
+]
+
+const releases = new Map void>>()
+
+const ColumnLaneStub = defineComponent({
+ name: 'ColumnLane',
+ props: { column: { type: Object, required: true } },
+ setup(props) {
+ const registry = inject(assignmentSaveRegistryKey) as AssignmentSaveRegistry
+ function begin() {
+ const owner = (props.column as Column).id
+ const release = registry.begin(owner)
+ const owners = releases.get(owner) ?? []
+ owners.push(release)
+ releases.set(owner, owners)
+ }
+ return { begin }
+ },
+ template: 'begin ',
+})
+
+function mountCanvas() {
+ releases.clear()
+ return mount(BoardCanvas, {
+ props: {
+ sortedColumns: columns,
+ cardsByColumn: new Map(),
+ labels: [],
+ boardId: 'board-1',
+ hasColumns: true,
+ draggedColumn: null,
+ dragOverColumnId: null,
+ draggedCard: null,
+ selectedCardId: null,
+ },
+ global: { stubs: { ColumnLane: ColumnLaneStub } },
+ })
+}
+
+describe('BoardCanvas assignment-save lifecycle ownership', () => {
+ it('stays saving after the owning lane unmounts and settles from the operation closure', async () => {
+ const wrapper = mountCanvas()
+
+ await wrapper.get('[data-testid="begin-column-a"]').trigger('click')
+ expect(wrapper.emitted('cardEditorSavingChange')).toEqual([[true]])
+
+ await wrapper.setProps({ sortedColumns: [columns[1]!] })
+ expect(wrapper.find('[data-testid="begin-column-a"]').exists()).toBe(false)
+ expect(wrapper.emitted('cardEditorSavingChange')).toEqual([[true]])
+
+ releases.get('column-a')![0]!()
+ await nextTick()
+ expect(wrapper.emitted('cardEditorSavingChange')).toEqual([[true], [false]])
+ })
+
+ it('preserves multi-lane aggregation until the final operation settles', async () => {
+ const wrapper = mountCanvas()
+
+ await wrapper.get('[data-testid="begin-column-a"]').trigger('click')
+ await wrapper.get('[data-testid="begin-column-b"]').trigger('click')
+ expect(wrapper.emitted('cardEditorSavingChange')).toEqual([[true]])
+
+ releases.get('column-a')![0]!()
+ await nextTick()
+ expect(wrapper.emitted('cardEditorSavingChange')).toEqual([[true]])
+
+ releases.get('column-b')![0]!()
+ await nextTick()
+ expect(wrapper.emitted('cardEditorSavingChange')).toEqual([[true], [false]])
+ })
+
+ it('resets on board replacement and an old release cannot clear the new owner', async () => {
+ const wrapper = mountCanvas()
+
+ await wrapper.get('[data-testid="begin-column-a"]').trigger('click')
+ const oldRelease = releases.get('column-a')![0]!
+
+ await wrapper.setProps({ boardId: 'board-2' })
+ expect(wrapper.emitted('cardEditorSavingChange')).toEqual([[true], [false]])
+
+ await wrapper.get('[data-testid="begin-column-a"]').trigger('click')
+ const newRelease = releases.get('column-a')![1]!
+ expect(wrapper.emitted('cardEditorSavingChange')).toEqual([[true], [false], [true]])
+
+ oldRelease()
+ await nextTick()
+ expect(wrapper.emitted('cardEditorSavingChange')).toEqual([[true], [false], [true]])
+
+ newRelease()
+ await nextTick()
+ expect(wrapper.emitted('cardEditorSavingChange')).toEqual([[true], [false], [true], [false]])
+ })
+})
diff --git a/frontend/taskdeck-web/src/tests/components/BoardCanvasSaveOwnership.spec.ts b/frontend/taskdeck-web/src/tests/components/BoardCanvasSaveOwnership.spec.ts
index 70023ec66..b9bb737a3 100644
--- a/frontend/taskdeck-web/src/tests/components/BoardCanvasSaveOwnership.spec.ts
+++ b/frontend/taskdeck-web/src/tests/components/BoardCanvasSaveOwnership.spec.ts
@@ -1,95 +1,41 @@
import { describe, expect, it } from 'vitest'
-import { mount } from '@vue/test-utils'
-import BoardCanvas from '../../components/board/BoardCanvas.vue'
-import type { Column } from '../../types/board'
+import { createAssignmentSaveRegistry } from '../../composables/useAssignmentSaveRegistry'
-const columns: Column[] = [
- {
- id: 'column-a',
- boardId: 'board-1',
- name: 'Todo',
- position: 0,
- wipLimit: null,
- cardCount: 0,
- createdAt: '2026-09-20T10:00:00Z',
- updatedAt: '2026-09-20T10:00:00Z',
- },
- {
- id: 'column-b',
- boardId: 'board-1',
- name: 'Done',
- position: 1,
- wipLimit: null,
- cardCount: 0,
- createdAt: '2026-09-20T10:00:00Z',
- updatedAt: '2026-09-20T10:00:00Z',
- },
-]
+describe('assignment-save registry aggregation', () => {
+ it('stays globally saving until the final operation owner settles', () => {
+ const events: boolean[] = []
+ const registry = createAssignmentSaveRegistry(saving => events.push(saving))
-function mountCanvas() {
- return mount(BoardCanvas, {
- props: {
- sortedColumns: columns,
- cardsByColumn: new Map(),
- labels: [],
- boardId: 'board-1',
- hasColumns: true,
- draggedColumn: null,
- dragOverColumnId: null,
- draggedCard: null,
- selectedCardId: null,
- },
- global: {
- stubs: {
- ColumnLane: {
- name: 'ColumnLane',
- props: ['column'],
- emits: ['card-editor-saving-change'],
- template: `
-
- `,
- },
- },
- },
- })
-}
+ const releaseA = registry.begin('column-a')
+ expect(events).toEqual([true])
-describe('BoardCanvas assignment-save ownership', () => {
- it('stays globally saving until the final lane owner settles', async () => {
- const wrapper = mountCanvas()
+ const releaseB = registry.begin('column-b')
+ expect(events).toEqual([true])
- await wrapper.get('[data-testid="start-column-a"]').trigger('click')
- expect(wrapper.emitted('cardEditorSavingChange')).toEqual([[true]])
+ releaseA()
+ expect(events).toEqual([true])
- await wrapper.get('[data-testid="start-column-b"]').trigger('click')
- expect(wrapper.emitted('cardEditorSavingChange')).toEqual([[true]])
+ releaseB()
+ expect(events).toEqual([true, false])
+ })
- await wrapper.get('[data-testid="settle-column-a"]').trigger('click')
- expect(wrapper.emitted('cardEditorSavingChange')).toEqual([[true]])
+ it('treats duplicate releases as idempotent and ignores old-generation releases', () => {
+ const events: boolean[] = []
+ const registry = createAssignmentSaveRegistry(saving => events.push(saving))
- await wrapper.get('[data-testid="settle-column-b"]').trigger('click')
- expect(wrapper.emitted('cardEditorSavingChange')).toEqual([[true], [false]])
- })
+ const oldRelease = registry.begin('column-a')
+ registry.reset()
+ expect(events).toEqual([true, false])
- it('treats duplicate lane events as idempotent aggregate state', async () => {
- const wrapper = mountCanvas()
+ const newRelease = registry.begin('column-a')
+ expect(events).toEqual([true, false, true])
- await wrapper.get('[data-testid="start-column-a"]').trigger('click')
- await wrapper.get('[data-testid="start-column-a"]').trigger('click')
- await wrapper.get('[data-testid="settle-column-b"]').trigger('click')
- expect(wrapper.emitted('cardEditorSavingChange')).toEqual([[true]])
+ oldRelease()
+ oldRelease()
+ expect(events).toEqual([true, false, true])
- await wrapper.get('[data-testid="settle-column-a"]').trigger('click')
- await wrapper.get('[data-testid="settle-column-a"]').trigger('click')
- expect(wrapper.emitted('cardEditorSavingChange')).toEqual([[true], [false]])
+ newRelease()
+ newRelease()
+ expect(events).toEqual([true, false, true, false])
})
})
diff --git a/frontend/taskdeck-web/src/tests/components/CardAssignmentSaveRegistry.spec.ts b/frontend/taskdeck-web/src/tests/components/CardAssignmentSaveRegistry.spec.ts
new file mode 100644
index 000000000..5260e8dc6
--- /dev/null
+++ b/frontend/taskdeck-web/src/tests/components/CardAssignmentSaveRegistry.spec.ts
@@ -0,0 +1,105 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { flushPromises, mount } from '@vue/test-utils'
+import CardAssignmentField from '../../components/board/CardAssignmentField.vue'
+import { cardsApi } from '../../api/cardsApi'
+import {
+ assignmentSaveRegistryKey,
+ createAssignmentSaveRegistry,
+} from '../../composables/useAssignmentSaveRegistry'
+import type { Card } from '../../types/board'
+
+vi.mock('../../api/cardsApi', () => ({
+ cardsApi: {
+ getParticipants: vi.fn(),
+ replaceAssignments: vi.fn(),
+ getCard: vi.fn(),
+ },
+}))
+vi.mock('../../store/sessionStore', () => ({ useSessionStore: () => ({ userId: 'me' }) }))
+
+const card: Card = {
+ id: 'card',
+ boardId: 'board',
+ columnId: 'col',
+ title: 'Draft',
+ description: '',
+ labels: [],
+ isBlocked: false,
+ blockReason: null,
+ dueDate: null,
+ position: 0,
+ createdAt: 'old',
+ updatedAt: 'v1',
+ assignments: [],
+}
+
+function deferred() {
+ let resolve!: (value: T) => void
+ let reject!: (reason?: unknown) => void
+ const promise = new Promise((settle, fail) => {
+ resolve = settle
+ reject = fail
+ })
+ return { promise, resolve, reject }
+}
+
+function mountField(events: boolean[]) {
+ const registry = createAssignmentSaveRegistry(saving => events.push(saving))
+ return mount(CardAssignmentField, {
+ props: { card, readOnly: false },
+ global: {
+ provide: {
+ [assignmentSaveRegistryKey as symbol]: registry,
+ },
+ },
+ })
+}
+
+function saveButton(wrapper: ReturnType) {
+ return wrapper.findAll('button').find(button => button.text() === 'Save assignments')!
+}
+
+describe('CardAssignmentField assignment-save registry', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ vi.mocked(cardsApi.getParticipants).mockResolvedValue([
+ { userId: 'me', displayName: 'Owner' },
+ ])
+ })
+
+ it('releases a successful PUT after the field unmounts', async () => {
+ const request = deferred()
+ vi.mocked(cardsApi.replaceAssignments).mockReturnValueOnce(request.promise)
+ const events: boolean[] = []
+ const wrapper = mountField(events)
+ await flushPromises()
+
+ await wrapper.find('input').setValue(true)
+ await saveButton(wrapper).trigger('click')
+ expect(events).toEqual([true])
+
+ wrapper.unmount()
+ request.resolve({ ...card, updatedAt: 'v2' })
+ await flushPromises()
+
+ expect(events).toEqual([true, false])
+ })
+
+ it('releases a failed PUT after the field unmounts', async () => {
+ const request = deferred()
+ vi.mocked(cardsApi.replaceAssignments).mockReturnValueOnce(request.promise)
+ const events: boolean[] = []
+ const wrapper = mountField(events)
+ await flushPromises()
+
+ await wrapper.find('input').setValue(true)
+ await saveButton(wrapper).trigger('click')
+ expect(events).toEqual([true])
+
+ wrapper.unmount()
+ request.reject(new Error('save failed'))
+ await flushPromises()
+
+ expect(events).toEqual([true, false])
+ })
+})
diff --git a/frontend/taskdeck-web/src/tests/store/board/boardCrudStore.commentCache.spec.ts b/frontend/taskdeck-web/src/tests/store/board/boardCrudStore.commentCache.spec.ts
index f25bbe2eb..445bc3611 100644
--- a/frontend/taskdeck-web/src/tests/store/board/boardCrudStore.commentCache.spec.ts
+++ b/frontend/taskdeck-web/src/tests/store/board/boardCrudStore.commentCache.spec.ts
@@ -1,3 +1,4 @@
+import { createBoardState } from '../../../store/board/boardState'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
@@ -60,6 +61,7 @@ function deferred() {
function createState() {
return {
+ ...createBoardState(),
boards: ref([{ id: 'board-1', name: 'Board' }]),
activeBoardId: ref('board-1'),
currentBoard: ref(board('Old')),
diff --git a/frontend/taskdeck-web/src/tests/store/board/boardCrudStore.spec.ts b/frontend/taskdeck-web/src/tests/store/board/boardCrudStore.spec.ts
index 04e1e27b7..d83e4d571 100644
--- a/frontend/taskdeck-web/src/tests/store/board/boardCrudStore.spec.ts
+++ b/frontend/taskdeck-web/src/tests/store/board/boardCrudStore.spec.ts
@@ -1,3 +1,4 @@
+import { createBoardState } from '../../../store/board/boardState'
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import { ref } from 'vue'
import axios from 'axios'
@@ -59,6 +60,7 @@ import { initialCardFilters, type CardFilters } from '../../../store/board/board
function createMockState() {
return {
+ ...createBoardState(),
boards: ref([
{ id: 'board-1', name: 'My Board' },
{ id: 'board-2', name: 'Other' },
diff --git a/frontend/taskdeck-web/src/tests/store/board/columnStore.spec.ts b/frontend/taskdeck-web/src/tests/store/board/columnStore.spec.ts
index 76da590ae..9a86cc758 100644
--- a/frontend/taskdeck-web/src/tests/store/board/columnStore.spec.ts
+++ b/frontend/taskdeck-web/src/tests/store/board/columnStore.spec.ts
@@ -1,3 +1,4 @@
+import { createBoardState } from '../../../store/board/boardState'
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { ref } from 'vue'
@@ -18,6 +19,7 @@ import { createColumnActions } from '../../../store/board/columnStore'
function createMockState() {
return {
+ ...createBoardState(),
currentBoard: ref<{ id: string; columns: Array<{ id: string; name: string }> } | null>({
id: 'board-1',
columns: [
diff --git a/frontend/taskdeck-web/src/tests/store/board/columnStoreDeleteReconciliation.spec.ts b/frontend/taskdeck-web/src/tests/store/board/columnStoreDeleteReconciliation.spec.ts
new file mode 100644
index 000000000..8a7fe14dd
--- /dev/null
+++ b/frontend/taskdeck-web/src/tests/store/board/columnStoreDeleteReconciliation.spec.ts
@@ -0,0 +1,84 @@
+import { createBoardState } from '../../../store/board/boardState'
+import { describe, expect, it, vi } from 'vitest'
+import { ref } from 'vue'
+
+const { mockColumnsApi } = vi.hoisted(() => ({
+ mockColumnsApi: {
+ getColumns: vi.fn(),
+ createColumn: vi.fn(),
+ updateColumn: vi.fn(),
+ deleteColumn: vi.fn(),
+ reorderColumns: vi.fn(),
+ },
+}))
+
+vi.mock('../../../api/columnsApi', () => ({ columnsApi: mockColumnsApi }))
+
+import { createColumnActions } from '../../../store/board/columnStore'
+
+function deferred() {
+ let resolve!: (value: T) => void
+ const promise = new Promise((settle) => {
+ resolve = settle
+ })
+ return { promise, resolve }
+}
+
+describe('columnStore stale-visit delete reconciliation', () => {
+ it('removes cards for the deleted column after authoritative A to B to A reconciliation', async () => {
+ const deleteResponse = deferred()
+ const reconcileResponse = deferred>>()
+ mockColumnsApi.deleteColumn.mockReturnValueOnce(deleteResponse.promise)
+ mockColumnsApi.getColumns.mockReturnValueOnce(reconcileResponse.promise)
+
+ const deletedColumn = { id: 'col-a', boardId: 'board-1', name: 'Todo' }
+ const survivingColumn = { id: 'col-b', boardId: 'board-1', name: 'Done' }
+ const state = {
+ ...createBoardState(),
+ currentBoard: ref({
+ id: 'board-1',
+ columns: [deletedColumn, survivingColumn],
+ }),
+ currentBoardCards: ref([
+ { id: 'card-deleted', boardId: 'board-1', columnId: 'col-a' },
+ { id: 'card-surviving', boardId: 'board-1', columnId: 'col-b' },
+ ]),
+ loading: ref(false),
+ error: ref(null),
+ }
+ const helpers = {
+ guardDemoMutation: vi.fn(),
+ handleApiError: vi.fn(),
+ toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn() },
+ markBoardDetailMutation: vi.fn(),
+ }
+ const { deleteColumn } = createColumnActions(state as never, helpers as never)
+
+ const pendingDelete = deleteColumn('board-1', 'col-a')
+
+ state.currentBoard.value = { id: 'board-2', columns: [] }
+ state.currentBoardCards.value = []
+ state.currentBoard.value = {
+ id: 'board-1',
+ columns: [deletedColumn, survivingColumn],
+ }
+ state.currentBoardCards.value = [
+ { id: 'card-deleted', boardId: 'board-1', columnId: 'col-a' },
+ { id: 'card-surviving', boardId: 'board-1', columnId: 'col-b' },
+ ]
+
+ deleteResponse.resolve(undefined)
+ await Promise.resolve()
+ await Promise.resolve()
+ expect(mockColumnsApi.getColumns).toHaveBeenCalledWith('board-1')
+
+ reconcileResponse.resolve([survivingColumn])
+ await pendingDelete
+
+ expect(state.currentBoard.value.columns).toEqual([survivingColumn])
+ expect(state.currentBoardCards.value).toEqual([
+ { id: 'card-surviving', boardId: 'board-1', columnId: 'col-b' },
+ ])
+ expect(helpers.toast.success).not.toHaveBeenCalled()
+ })
+})
diff --git a/frontend/taskdeck-web/src/tests/store/board/columnStoreMutationFailureOrdering.spec.ts b/frontend/taskdeck-web/src/tests/store/board/columnStoreMutationFailureOrdering.spec.ts
new file mode 100644
index 000000000..016535995
--- /dev/null
+++ b/frontend/taskdeck-web/src/tests/store/board/columnStoreMutationFailureOrdering.spec.ts
@@ -0,0 +1,91 @@
+import { createBoardState } from '../../../store/board/boardState'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { ref } from 'vue'
+
+const { mockColumnsApi } = vi.hoisted(() => ({
+ mockColumnsApi: {
+ getColumns: vi.fn(),
+ createColumn: vi.fn(),
+ updateColumn: vi.fn(),
+ deleteColumn: vi.fn(),
+ reorderColumns: vi.fn(),
+ },
+}))
+
+vi.mock('../../../api/columnsApi', () => ({ columnsApi: mockColumnsApi }))
+
+import { createColumnActions } from '../../../store/board/columnStore'
+
+function deferred() {
+ let resolve!: (value: T) => void
+ let reject!: (reason?: unknown) => void
+ const promise = new Promise((settle, fail) => {
+ resolve = settle
+ reject = fail
+ })
+ return { promise, resolve, reject }
+}
+
+async function flushPromises() {
+ await Promise.resolve()
+ await Promise.resolve()
+}
+
+describe('columnStore failed predecessor ordering', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('starts the later same-board intent after the first request fails', async () => {
+ const firstResponse = deferred>()
+ const secondResponse = deferred>()
+ mockColumnsApi.reorderColumns
+ .mockReturnValueOnce(firstResponse.promise)
+ .mockReturnValueOnce(secondResponse.promise)
+
+ const state = {
+ ...createBoardState(),
+ currentBoard: ref({
+ id: 'board-1',
+ columns: [
+ { id: 'col-a', name: 'Todo' },
+ { id: 'col-b', name: 'Done' },
+ ],
+ }),
+ currentBoardCards: ref([]),
+ loading: ref(false),
+ error: ref(null),
+ }
+ const helpers = {
+ guardDemoMutation: vi.fn(),
+ handleApiError: vi.fn(),
+ toast: {
+ success: vi.fn(),
+ error: vi.fn(),
+ warning: vi.fn(),
+ },
+ markBoardDetailMutation: vi.fn(),
+ }
+ const { reorderColumns } = createColumnActions(state as never, helpers as never)
+
+ const first = reorderColumns('board-1', ['col-b', 'col-a'])
+ const second = reorderColumns('board-1', ['col-a', 'col-b'])
+
+ expect(mockColumnsApi.reorderColumns).toHaveBeenCalledTimes(1)
+ firstResponse.reject(new Error('first reorder failed'))
+ await expect(first).rejects.toThrow('first reorder failed')
+ await flushPromises()
+
+ expect(mockColumnsApi.reorderColumns).toHaveBeenCalledTimes(2)
+ const confirmedOrder = [
+ { id: 'col-a', name: 'Todo' },
+ { id: 'col-b', name: 'Done' },
+ ]
+ secondResponse.resolve(confirmedOrder)
+ await expect(second).resolves.toEqual(confirmedOrder)
+
+ expect(state.currentBoard.value.columns).toEqual(confirmedOrder)
+ expect(helpers.handleApiError).toHaveBeenCalledOnce()
+ expect(helpers.toast.success).toHaveBeenCalledOnce()
+ })
+})
diff --git a/frontend/taskdeck-web/src/tests/store/board/columnStoreMutationOrdering.spec.ts b/frontend/taskdeck-web/src/tests/store/board/columnStoreMutationOrdering.spec.ts
new file mode 100644
index 000000000..99eabcab3
--- /dev/null
+++ b/frontend/taskdeck-web/src/tests/store/board/columnStoreMutationOrdering.spec.ts
@@ -0,0 +1,268 @@
+import { createBoardState } from '../../../store/board/boardState'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { ref } from 'vue'
+
+const { mockColumnsApi } = vi.hoisted(() => ({
+ mockColumnsApi: {
+ getColumns: vi.fn(),
+ createColumn: vi.fn(),
+ updateColumn: vi.fn(),
+ deleteColumn: vi.fn(),
+ reorderColumns: vi.fn(),
+ },
+}))
+
+vi.mock('../../../api/columnsApi', () => ({ columnsApi: mockColumnsApi }))
+
+import { createColumnActions } from '../../../store/board/columnStore'
+
+interface TestColumn {
+ id: string
+ boardId: string
+ name: string
+ position: number
+ wipLimit: number | null
+ cardCount: number
+ createdAt: string
+ updatedAt: string
+}
+
+interface TestCard {
+ id: string
+ boardId: string
+ columnId: string
+}
+
+const columnA: TestColumn = {
+ id: 'col-a',
+ boardId: 'board-1',
+ name: 'Todo',
+ position: 0,
+ wipLimit: null,
+ cardCount: 0,
+ createdAt: '2026-09-20T10:00:00Z',
+ updatedAt: '2026-09-20T10:00:00Z',
+}
+
+const columnB: TestColumn = {
+ ...columnA,
+ id: 'col-b',
+ name: 'Done',
+ position: 1,
+}
+
+function createState() {
+ return {
+ ...createBoardState(),
+ currentBoard: ref<{
+ id: string
+ columns: TestColumn[]
+ } | null>({
+ id: 'board-1',
+ columns: [{ ...columnA }, { ...columnB }],
+ }),
+ currentBoardCards: ref([
+ { id: 'card-a', boardId: 'board-1', columnId: 'col-a' },
+ ]),
+ loading: ref(false),
+ error: ref(null),
+ }
+}
+
+function createHelpers() {
+ return {
+ guardDemoMutation: vi.fn(),
+ handleApiError: vi.fn(),
+ toast: {
+ success: vi.fn(),
+ error: vi.fn(),
+ warning: vi.fn(),
+ },
+ markBoardDetailMutation: vi.fn(),
+ }
+}
+
+function deferred() {
+ let resolve!: (value: T) => void
+ let reject!: (reason?: unknown) => void
+ const promise = new Promise((settle, fail) => {
+ resolve = settle
+ reject = fail
+ })
+ return { promise, resolve, reject }
+}
+
+async function flushPromises() {
+ await Promise.resolve()
+ await Promise.resolve()
+}
+
+describe('columnStore mutation ordering and board ownership', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockColumnsApi.getColumns.mockReset()
+ mockColumnsApi.createColumn.mockReset()
+ mockColumnsApi.updateColumn.mockReset()
+ mockColumnsApi.deleteColumn.mockReset()
+ mockColumnsApi.reorderColumns.mockReset()
+ })
+
+ it('serializes overlapping reorders so the later intent owns server and cache order', async () => {
+ const state = createState()
+ const helpers = createHelpers()
+ const firstResponse = deferred()
+ const secondResponse = deferred()
+ mockColumnsApi.reorderColumns
+ .mockReturnValueOnce(firstResponse.promise)
+ .mockReturnValueOnce(secondResponse.promise)
+ const { reorderColumns } = createColumnActions(state as never, helpers as never)
+
+ const first = reorderColumns('board-1', ['col-b', 'col-a'])
+ const second = reorderColumns('board-1', ['col-a', 'col-b'])
+
+ expect(mockColumnsApi.reorderColumns).toHaveBeenCalledTimes(1)
+
+ const firstOrder = [
+ { ...columnB, position: 0, updatedAt: '2026-09-20T10:01:00Z' },
+ { ...columnA, position: 1, updatedAt: '2026-09-20T10:01:00Z' },
+ ]
+ firstResponse.resolve(firstOrder)
+ await first
+ await flushPromises()
+
+ expect(mockColumnsApi.reorderColumns).toHaveBeenCalledTimes(2)
+ expect(state.currentBoard.value!.columns).toEqual(firstOrder)
+
+ const secondOrder = [
+ { ...columnA, position: 0, updatedAt: '2026-09-20T10:02:00Z' },
+ { ...columnB, position: 1, updatedAt: '2026-09-20T10:02:00Z' },
+ ]
+ secondResponse.resolve(secondOrder)
+ await second
+
+ expect(state.currentBoard.value!.columns).toEqual(secondOrder)
+ expect(helpers.toast.success).toHaveBeenCalledTimes(2)
+ })
+
+ it('does not start a queued pre-navigation mutation under the next board session', async () => {
+ const state = createState()
+ const helpers = createHelpers()
+ const firstResponse = deferred()
+ mockColumnsApi.reorderColumns.mockReturnValueOnce(firstResponse.promise)
+ const { reorderColumns } = createColumnActions(state as never, helpers as never)
+
+ const first = reorderColumns('board-1', ['col-b', 'col-a'])
+ const queued = reorderColumns('board-1', ['col-a', 'col-b'])
+
+ const nextColumns = [{ ...columnA, id: 'col-next', boardId: 'board-2' }]
+ state.currentBoard.value = { id: 'board-2', columns: nextColumns }
+ state.currentBoardCards.value = [
+ { id: 'card-next', boardId: 'board-2', columnId: 'col-next' },
+ ]
+ const exposedColumns = state.currentBoard.value.columns
+ const exposedCards = state.currentBoardCards.value
+ firstResponse.resolve([{ ...columnB }, { ...columnA }])
+
+ await first
+ await expect(queued).rejects.toThrow('board visit')
+
+ expect(mockColumnsApi.reorderColumns).toHaveBeenCalledTimes(1)
+ expect(state.currentBoard.value.columns).toBe(exposedColumns)
+ expect(state.currentBoard.value.columns).toEqual(nextColumns)
+ expect(state.currentBoardCards.value).toBe(exposedCards)
+ expect(state.currentBoardCards.value).toEqual([
+ { id: 'card-next', boardId: 'board-2', columnId: 'col-next' },
+ ])
+ expect(helpers.handleApiError).not.toHaveBeenCalled()
+ expect(helpers.toast.success).not.toHaveBeenCalled()
+ })
+
+ it('does not let a late delete response strip the newly selected board state', async () => {
+ const state = createState()
+ const helpers = createHelpers()
+ const response = deferred()
+ mockColumnsApi.deleteColumn.mockReturnValueOnce(response.promise)
+ const { deleteColumn } = createColumnActions(state as never, helpers as never)
+
+ const pendingDelete = deleteColumn('board-1', 'col-a')
+
+ const nextColumns = [{ ...columnA, id: 'col-next', boardId: 'board-2' }]
+ const nextCards = [{ id: 'card-next', boardId: 'board-2', columnId: 'col-next' }]
+ state.currentBoard.value = { id: 'board-2', columns: nextColumns }
+ state.currentBoardCards.value = nextCards
+ const exposedColumns = state.currentBoard.value.columns
+ const exposedCards = state.currentBoardCards.value
+ response.resolve(undefined)
+ await pendingDelete
+
+ expect(state.currentBoard.value.columns).toBe(exposedColumns)
+ expect(state.currentBoard.value.columns).toEqual(nextColumns)
+ expect(state.currentBoardCards.value).toBe(exposedCards)
+ expect(state.currentBoardCards.value).toEqual(nextCards)
+ expect(helpers.toast.success).not.toHaveBeenCalled()
+ })
+
+ it('patches the currently installed same-board detail after an update settles', async () => {
+ const state = createState()
+ const helpers = createHelpers()
+ const response = deferred()
+ mockColumnsApi.updateColumn.mockReturnValueOnce(response.promise)
+ const { updateColumn } = createColumnActions(state as never, helpers as never)
+
+ const pendingUpdate = updateColumn('board-1', 'col-a', { name: 'Backlog' })
+
+ const refreshedColumns = [
+ { ...columnA, name: 'Pre-write refresh' },
+ { ...columnB },
+ ]
+ state.currentBoard.value = { id: 'board-1', columns: refreshedColumns }
+ const exposedColumns = state.currentBoard.value.columns
+ const updated = {
+ ...columnA,
+ name: 'Backlog',
+ updatedAt: '2026-09-20T10:01:00Z',
+ }
+ response.resolve(updated)
+ await pendingUpdate
+
+ expect(state.currentBoard.value.columns).toBe(exposedColumns)
+ expect(state.currentBoard.value.columns).toEqual([updated, columnB])
+ expect(helpers.toast.success).toHaveBeenCalledWith('Column updated successfully')
+ })
+
+ it('reconciles an already-started successful write when the same board is reopened', async () => {
+ const state = createState()
+ const helpers = createHelpers()
+ const updateResponse = deferred()
+ const reconcileResponse = deferred()
+ mockColumnsApi.updateColumn.mockReturnValueOnce(updateResponse.promise)
+ mockColumnsApi.getColumns.mockReturnValueOnce(reconcileResponse.promise)
+ const { updateColumn } = createColumnActions(state as never, helpers as never)
+
+ const pendingUpdate = updateColumn('board-1', 'col-a', { name: 'Backlog' })
+
+ state.currentBoard.value = { id: 'board-2', columns: [] }
+ state.currentBoard.value = {
+ id: 'board-1',
+ columns: [{ ...columnA, name: 'Reopened pre-write value' }, { ...columnB }],
+ }
+ updateResponse.resolve({
+ ...columnA,
+ name: 'Backlog',
+ updatedAt: '2026-09-20T10:01:00Z',
+ })
+ await flushPromises()
+
+ expect(mockColumnsApi.getColumns).toHaveBeenCalledOnce()
+ const authoritative = [
+ { ...columnA, name: 'Backlog', updatedAt: '2026-09-20T10:01:00Z' },
+ { ...columnB },
+ ]
+ reconcileResponse.resolve(authoritative)
+ await pendingUpdate
+
+ expect(state.currentBoard.value!.columns).toEqual(authoritative)
+ expect(helpers.toast.success).not.toHaveBeenCalled()
+ expect(helpers.toast.warning).not.toHaveBeenCalled()
+ })
+})
diff --git a/frontend/taskdeck-web/src/tests/store/board/columnStoreRouteOwnership.spec.ts b/frontend/taskdeck-web/src/tests/store/board/columnStoreRouteOwnership.spec.ts
new file mode 100644
index 000000000..82079f95d
--- /dev/null
+++ b/frontend/taskdeck-web/src/tests/store/board/columnStoreRouteOwnership.spec.ts
@@ -0,0 +1,242 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { createBoardState } from '../../../store/board/boardState'
+import { createBoardUiActions } from '../../../store/board/boardUiStore'
+import type { BoardDetail, Card, Column } from '../../../types/board'
+
+const { api } = vi.hoisted(() => ({ api: {
+ createColumn: vi.fn(), updateColumn: vi.fn(), deleteColumn: vi.fn(),
+ reorderColumns: vi.fn(), getColumns: vi.fn(),
+} }))
+vi.mock('../../../api/columnsApi', () => ({ columnsApi: api }))
+import { createColumnActions } from '../../../store/board/columnStore'
+import { createBoardCrudActions } from '../../../store/board/boardCrudStore'
+
+const columns: Column[] = [
+ { id: 'a', boardId: 'board-1', name: 'Todo', position: 0, wipLimit: null, cardCount: 1,
+ createdAt: '2026-09-22T00:00:00Z', updatedAt: '2026-09-22T00:00:00Z' },
+ { id: 'b', boardId: 'board-1', name: 'Done', position: 1, wipLimit: null, cardCount: 0,
+ createdAt: '2026-09-22T00:00:00Z', updatedAt: '2026-09-22T00:00:00Z' },
+]
+
+function deferred() {
+ let resolve!: (value: T) => void
+ let reject!: (reason: unknown) => void
+ const promise = new Promise((done, fail) => { resolve = done; reject = fail })
+ return { promise, resolve, reject }
+}
+
+async function settle() {
+ await Promise.resolve()
+ await Promise.resolve()
+}
+
+function setup() {
+ const state = createBoardState()
+ state.currentBoard.value = { id: 'board-1', columns: columns.map(c => ({ ...c })) } as BoardDetail
+ state.currentBoardCards.value = [{ id: 'card', boardId: 'board-1', columnId: 'a' }] as Card[]
+ const helpers = {
+ guardDemoMutation: vi.fn(), handleApiError: vi.fn(), markBoardDetailMutation: vi.fn(),
+ toast: { success: vi.fn(), warning: vi.fn(), error: vi.fn() },
+ }
+ const ui = createBoardUiActions(state)
+ const visit = ui.beginBoardViewVisit('board-1')
+ const actions = createColumnActions(state, helpers as never)
+ return { state, helpers, ui, visit, actions }
+}
+
+describe('column mutations follow the board screen lifetime', () => {
+ beforeEach(() => vi.resetAllMocks())
+
+ it.each(['create', 'update', 'delete', 'reorder'] as const)(
+ 'does not publish a late %s after departure with cached detail retained', async (kind) => {
+ const { state, helpers, ui, visit, actions } = setup()
+ const response = deferred()
+ api.createColumn.mockReturnValue(response.promise)
+ api.updateColumn.mockReturnValue(response.promise)
+ api.deleteColumn.mockReturnValue(response.promise)
+ api.reorderColumns.mockReturnValue(response.promise)
+ const cachedColumns = state.currentBoard.value!.columns
+ const cachedCards = state.currentBoardCards.value
+ const pending = kind === 'create' ? actions.createColumn('board-1', { name: 'New' })
+ : kind === 'update' ? actions.updateColumn('board-1', 'a', { name: 'Changed' })
+ : kind === 'delete' ? actions.deleteColumn('board-1', 'a')
+ : actions.reorderColumns('board-1', ['b', 'a'])
+
+ ui.endBoardViewVisit(visit)
+ state.error.value = 'New screen error'
+ state.loading.value = true
+ const result = kind === 'delete' ? undefined : kind === 'reorder' ? [...columns].reverse()
+ : { ...columns[0], id: kind === 'create' ? 'new' : 'a', name: 'Changed' }
+ response.resolve(result as never)
+ await pending
+
+ expect(state.currentBoard.value!.columns).toBe(cachedColumns)
+ expect(state.currentBoard.value!.columns).toEqual(columns)
+ expect(state.currentBoardCards.value).toBe(cachedCards)
+ expect(state.currentBoardCards.value).toEqual([{ id: 'card', boardId: 'board-1', columnId: 'a' }])
+ expect(api.getColumns).not.toHaveBeenCalled()
+ expect(helpers.toast.success).not.toHaveBeenCalled()
+ expect(helpers.handleApiError).not.toHaveBeenCalled()
+ expect(state.error.value).toBe('New screen error')
+ expect(state.loading.value).toBe(true)
+ },
+ )
+
+ it('drops a queued intent and rejects a new stale-view call after departure', async () => {
+ const { state, helpers, ui, visit, actions } = setup()
+ const response = deferred()
+ api.reorderColumns.mockReturnValue(response.promise)
+ const first = actions.reorderColumns('board-1', ['b', 'a'])
+ const queued = actions.reorderColumns('board-1', ['a', 'b'])
+ const queuedFailure = expect(queued).rejects.toThrow('board visit')
+ const cached = state.currentBoard.value!.columns
+ ui.endBoardViewVisit(visit)
+ const afterDeparture = actions.updateColumn('board-1', 'a', { name: 'Too late' })
+ const departureFailure = expect(afterDeparture).rejects.toThrow('board visit')
+ response.resolve([...columns].reverse())
+ await Promise.all([first, queuedFailure, departureFailure])
+ expect(api.reorderColumns).toHaveBeenCalledTimes(1)
+ expect(api.updateColumn).not.toHaveBeenCalled()
+ expect(api.getColumns).not.toHaveBeenCalled()
+ expect(state.currentBoard.value!.columns).toBe(cached)
+ expect(state.currentBoard.value!.columns).toEqual(columns)
+ expect(helpers.toast.success).not.toHaveBeenCalled()
+ })
+
+ it.each(['departure', 'logout'])('does not publish an old failure after %s', async (boundary) => {
+ const { state, helpers, ui, visit, actions } = setup()
+ const response = deferred()
+ api.updateColumn.mockReturnValue(response.promise)
+ const pending = actions.updateColumn('board-1', 'a', { name: 'Changed' })
+ if (boundary === 'logout') createBoardCrudActions(state, helpers as never).resetForLogout()
+ else ui.endBoardViewVisit(visit)
+ state.error.value = 'New screen error'
+ state.loading.value = true
+ const failure = new Error('Late failure')
+ response.reject(failure)
+ await expect(pending).rejects.toBe(failure)
+ expect(helpers.handleApiError).not.toHaveBeenCalled()
+ expect(state.error.value).toBe('New screen error')
+ expect(state.loading.value).toBe(true)
+ })
+
+ it('drops pre-logout queued work even when a new login opens the same board', async () => {
+ const { state, helpers, ui, actions } = setup()
+ const response = deferred()
+ api.reorderColumns.mockReturnValue(response.promise)
+ const first = actions.reorderColumns('board-1', ['b', 'a'])
+ const queued = actions.reorderColumns('board-1', ['a', 'b'])
+ const queuedFailure = expect(queued).rejects.toThrow('board visit')
+ createBoardCrudActions(state, helpers as never).resetForLogout()
+ ui.beginBoardViewVisit('board-1')
+ state.currentBoard.value = { id: 'board-1', columns: columns.map(c => ({ ...c })) } as BoardDetail
+ const newAccountColumns = state.currentBoard.value.columns
+ response.resolve([...columns].reverse())
+ await Promise.all([first, queuedFailure])
+ expect(api.reorderColumns).toHaveBeenCalledTimes(1)
+ expect(api.getColumns).not.toHaveBeenCalled()
+ expect(state.currentBoard.value.columns).toBe(newAccountColumns)
+ expect(state.currentBoard.value.columns).toEqual(columns)
+ expect(helpers.toast.success).not.toHaveBeenCalled()
+ })
+
+ it('retires the previous visit before the next route payload arrives', async () => {
+ const { state, helpers, ui, actions } = setup()
+ const response = deferred()
+ api.updateColumn.mockReturnValue(response.promise)
+ const pending = actions.updateColumn('board-1', 'a', { name: 'Changed' })
+ ui.beginBoardViewVisit('board-2')
+ response.resolve({ ...columns[0]!, name: 'Changed' })
+ await pending
+ expect(state.currentBoard.value!.columns).toEqual(columns)
+ expect(api.getColumns).not.toHaveBeenCalled()
+ expect(helpers.toast.success).not.toHaveBeenCalled()
+ await expect(actions.deleteColumn('board-1', 'a')).rejects.toThrow('board visit')
+ expect(api.deleteColumn).not.toHaveBeenCalled()
+ })
+
+ it.each([false, true])('reconciles once on reopening and guards departure during refresh: %s', async (departAgain) => {
+ const { state, helpers, ui, visit, actions } = setup()
+ const response = deferred()
+ const refresh = deferred()
+ api.updateColumn.mockReturnValue(response.promise)
+ api.getColumns.mockReturnValue(refresh.promise)
+ const pending = actions.updateColumn('board-1', 'a', { name: 'Changed' })
+ ui.endBoardViewVisit(visit)
+ const reopened = ui.beginBoardViewVisit('board-1')
+ // Cleanup from an older component cannot retire the new component's visit.
+ ui.endBoardViewVisit(visit)
+ response.resolve({ ...columns[0]!, name: 'Changed' })
+ await settle()
+ expect(api.getColumns).toHaveBeenCalledTimes(1)
+ if (departAgain) ui.endBoardViewVisit(reopened)
+ const refreshed = columns.map(c => ({ ...c, name: `${c.name} refreshed` }))
+ refresh.resolve(refreshed)
+ await pending
+ expect(state.currentBoard.value!.columns).toEqual(departAgain ? columns : refreshed)
+ expect(api.getColumns).toHaveBeenCalledTimes(1)
+ expect(helpers.toast.success).not.toHaveBeenCalled()
+ })
+
+ it('keeps a reopened visit write behind the single recovery read and preserves its later result', async () => {
+ const { state, helpers, ui, visit, actions } = setup()
+ const oldResponse = deferred()
+ const nextResponse = deferred()
+ const refresh = deferred()
+ api.updateColumn.mockReturnValueOnce(oldResponse.promise).mockReturnValueOnce(nextResponse.promise)
+ api.getColumns.mockReturnValue(refresh.promise)
+ const oldWrite = actions.updateColumn('board-1', 'a', { name: 'Old visit' })
+ ui.endBoardViewVisit(visit)
+ ui.beginBoardViewVisit('board-1')
+ const nextWrite = actions.updateColumn('board-1', 'a', { name: 'New visit' })
+ oldResponse.resolve({ ...columns[0]!, name: 'Old visit' })
+ await settle()
+ expect(api.getColumns).toHaveBeenCalledTimes(1)
+ expect(api.updateColumn).toHaveBeenCalledTimes(1)
+ refresh.resolve([{ ...columns[0]!, name: 'Old visit' }, columns[1]!])
+ await oldWrite
+ await settle()
+ expect(api.updateColumn).toHaveBeenCalledTimes(2)
+ nextResponse.resolve({ ...columns[0]!, name: 'New visit' })
+ await nextWrite
+ expect(state.currentBoard.value!.columns).toEqual([{ ...columns[0]!, name: 'New visit' }, columns[1]!])
+ expect(api.getColumns).toHaveBeenCalledTimes(1)
+ expect(helpers.toast.success).toHaveBeenCalledTimes(1)
+ })
+
+ it('does not warn after a recovery read fails outside its reopened visit', async () => {
+ const { state, helpers, ui, visit, actions } = setup()
+ const response = deferred()
+ const refresh = deferred()
+ api.updateColumn.mockReturnValue(response.promise)
+ api.getColumns.mockReturnValue(refresh.promise)
+ const pending = actions.updateColumn('board-1', 'a', { name: 'Changed' })
+ ui.endBoardViewVisit(visit)
+ const reopened = ui.beginBoardViewVisit('board-1')
+ response.resolve({ ...columns[0]!, name: 'Changed' })
+ await settle()
+ expect(api.getColumns).toHaveBeenCalledTimes(1)
+ ui.endBoardViewVisit(reopened)
+ refresh.reject(new Error('Late recovery failure'))
+ await pending
+ expect(state.currentBoard.value!.columns).toEqual(columns)
+ expect(helpers.toast.warning).not.toHaveBeenCalled()
+ expect(helpers.handleApiError).not.toHaveBeenCalled()
+ })
+
+ it('does not reconcile an old session response into a new login on the same board', async () => {
+ const { state, helpers, ui, actions } = setup()
+ const response = deferred()
+ api.updateColumn.mockReturnValue(response.promise)
+ const pending = actions.updateColumn('board-1', 'a', { name: 'Old account' })
+ createBoardCrudActions(state, helpers as never).resetForLogout()
+ ui.beginBoardViewVisit('board-1')
+ state.currentBoard.value = { id: 'board-1', columns: columns.map(c => ({ ...c })) } as BoardDetail
+ response.resolve({ ...columns[0]!, name: 'Old account' })
+ await pending
+ expect(api.getColumns).not.toHaveBeenCalled()
+ expect(helpers.markBoardDetailMutation).not.toHaveBeenCalled()
+ expect(state.currentBoard.value!.columns).toEqual(columns)
+ expect(helpers.toast.success).not.toHaveBeenCalled()
+ })
+})
diff --git a/frontend/taskdeck-web/src/tests/views/BoardView.coverage.spec.ts b/frontend/taskdeck-web/src/tests/views/BoardView.coverage.spec.ts
index 071dbe194..ae95cd8fa 100644
--- a/frontend/taskdeck-web/src/tests/views/BoardView.coverage.spec.ts
+++ b/frontend/taskdeck-web/src/tests/views/BoardView.coverage.spec.ts
@@ -60,6 +60,8 @@ const mockBoardStore = reactive({
filteredCardCount: 0,
totalCardCount: 0,
fetchBoard: vi.fn(async () => true),
+ beginBoardViewVisit: vi.fn((boardId: string) => ({ boardId })),
+ endBoardViewVisit: vi.fn(),
setBoardPresenceMembers: vi.fn(),
setEditingCard: vi.fn(),
createColumn: vi.fn(async () => {}),
diff --git a/frontend/taskdeck-web/src/tests/views/BoardView.keyboardRouting.spec.ts b/frontend/taskdeck-web/src/tests/views/BoardView.keyboardRouting.spec.ts
index ab71518a7..6c6bc6095 100644
--- a/frontend/taskdeck-web/src/tests/views/BoardView.keyboardRouting.spec.ts
+++ b/frontend/taskdeck-web/src/tests/views/BoardView.keyboardRouting.spec.ts
@@ -92,6 +92,8 @@ const mockBoardStore = reactive({
filteredCardCount: 2,
totalCardCount: 2,
fetchBoard: vi.fn(async () => true),
+ beginBoardViewVisit: vi.fn((boardId: string) => ({ boardId })),
+ endBoardViewVisit: vi.fn(),
setBoardPresenceMembers: vi.fn(),
setEditingCard: vi.fn(),
createColumn: vi.fn(async () => {}),
diff --git a/frontend/taskdeck-web/src/tests/views/BoardView.spec.ts b/frontend/taskdeck-web/src/tests/views/BoardView.spec.ts
index 280dc548a..a4dce88e7 100644
--- a/frontend/taskdeck-web/src/tests/views/BoardView.spec.ts
+++ b/frontend/taskdeck-web/src/tests/views/BoardView.spec.ts
@@ -110,6 +110,8 @@ const mockBoardStore = reactive({
totalCardCount: 0,
fetchBoard: vi.fn(async () => true),
cancelBackgroundBoardFetch: vi.fn(),
+ beginBoardViewVisit: vi.fn((boardId: string) => ({ boardId })),
+ endBoardViewVisit: vi.fn(),
setBoardPresenceMembers: vi.fn(),
setEditingCard: vi.fn(),
createColumn: vi.fn(async () => {}),
@@ -624,6 +626,36 @@ describe('BoardView', () => {
expect(wrapper.find('.td-board-canvas').exists()).toBe(true)
})
+ it('binds visits before loading and retires only the mounted route on unmount', async () => {
+ const load = createDeferred()
+ mockBoardStore.fetchBoard.mockImplementationOnce(() => load.promise)
+ const wrapper = mountView()
+ const firstVisit = mockBoardStore.beginBoardViewVisit.mock.results[0]!.value
+ expect(mockBoardStore.beginBoardViewVisit).toHaveBeenCalledWith('board-1')
+ expect(mockBoardStore.beginBoardViewVisit.mock.invocationCallOrder[0])
+ .toBeLessThan(mockBoardStore.fetchBoard.mock.invocationCallOrder[0]!)
+
+ // A layout change does not leave the BoardView route.
+ usePaperThemeStore().setMode('paper')
+ await nextTick()
+ usePaperThemeStore().disable()
+ await nextTick()
+ expect(mockBoardStore.beginBoardViewVisit).toHaveBeenCalledTimes(1)
+ expect(mockBoardStore.endBoardViewVisit).not.toHaveBeenCalled()
+
+ routeMock.params.id = 'board-2'
+ // Ownership changes synchronously, before a stale payload can settle.
+ expect(mockBoardStore.beginBoardViewVisit).toHaveBeenCalledTimes(2)
+ expect(mockBoardStore.beginBoardViewVisit).toHaveBeenLastCalledWith('board-2')
+ const nextVisit = mockBoardStore.beginBoardViewVisit.mock.results[1]!.value
+ expect(nextVisit).not.toBe(firstVisit)
+ expect(mockBoardStore.currentBoard.id).toBe('board-1')
+ wrapper.unmount()
+ expect(mockBoardStore.endBoardViewVisit).toHaveBeenCalledExactlyOnceWith(nextVisit)
+ load.resolve(true)
+ await flushPromises()
+ })
+
it('keeps Retry authoritative while realtime refreshes queue as background work', async () => {
const retryLoad = createDeferred()
mockBoardStore.currentBoard = null as unknown as typeof mockBoardStore.currentBoard
diff --git a/frontend/taskdeck-web/src/tests/views/BoardViewDemoArchive.spec.ts b/frontend/taskdeck-web/src/tests/views/BoardViewDemoArchive.spec.ts
index c1b0a7151..e92170a07 100644
--- a/frontend/taskdeck-web/src/tests/views/BoardViewDemoArchive.spec.ts
+++ b/frontend/taskdeck-web/src/tests/views/BoardViewDemoArchive.spec.ts
@@ -73,6 +73,8 @@ const boardStore = reactive({
totalCardCount: 0,
fetchBoard: vi.fn(async () => true),
cancelBackgroundBoardFetch: vi.fn(),
+ beginBoardViewVisit: vi.fn((boardId: string) => ({ boardId })),
+ endBoardViewVisit: vi.fn(),
setBoardPresenceMembers: vi.fn(),
setEditingCard: vi.fn(),
createColumn: vi.fn(async () => undefined),
diff --git a/frontend/taskdeck-web/src/tests/views/LegacyAssignmentSaveNavigation.spec.ts b/frontend/taskdeck-web/src/tests/views/LegacyAssignmentSaveNavigation.spec.ts
index 07180b8e8..0324fbf26 100644
--- a/frontend/taskdeck-web/src/tests/views/LegacyAssignmentSaveNavigation.spec.ts
+++ b/frontend/taskdeck-web/src/tests/views/LegacyAssignmentSaveNavigation.spec.ts
@@ -2,7 +2,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import { nextTick, reactive, ref } from 'vue'
import BoardView from '../../views/BoardView.vue'
-import BoardCanvas from '../../components/board/BoardCanvas.vue'
import ColumnLane from '../../components/board/ColumnLane.vue'
import { usePaperThemeStore } from '../../store/paperThemeStore'
import type { BoardDetail, Card, Column } from '../../types/board'
@@ -102,6 +101,8 @@ const boardStore = reactive({
totalCardCount: 1,
fetchBoard: vi.fn(async () => true),
cancelBackgroundBoardFetch: vi.fn(),
+ beginBoardViewVisit: vi.fn((boardId: string) => ({ boardId })),
+ endBoardViewVisit: vi.fn(),
setBoardPresenceMembers: vi.fn(),
setEditingCard: vi.fn(),
createColumn: vi.fn(async () => undefined),
@@ -278,36 +279,6 @@ describe('Legacy assignment-save event propagation', () => {
expect(wrapper.emitted('card-editor-saving-change')).toEqual([[true]])
})
-
- it('forwards the lane saving state through BoardCanvas', async () => {
- const wrapper = mount(BoardCanvas, {
- props: {
- sortedColumns: [column],
- cardsByColumn: new Map([[column.id, [card]]]),
- labels: [],
- boardId: board.id,
- hasColumns: true,
- draggedColumn: null,
- dragOverColumnId: null,
- draggedCard: null,
- selectedCardId: null,
- },
- global: {
- stubs: {
- ColumnLane: {
- name: 'ColumnLane',
- emits: ['card-editor-saving-change'],
- template: 'Save ',
- },
- },
- },
- })
- mountedWrappers.push(wrapper)
-
- await wrapper.get('[data-testid="lane-save"]').trigger('click')
-
- expect(wrapper.emitted('cardEditorSavingChange')).toEqual([[true]])
- })
})
describe('Legacy assignment-save navigation boundary', () => {
diff --git a/frontend/taskdeck-web/src/views/BoardView.vue b/frontend/taskdeck-web/src/views/BoardView.vue
index 93d13abec..4cb4ed687 100644
--- a/frontend/taskdeck-web/src/views/BoardView.vue
+++ b/frontend/taskdeck-web/src/views/BoardView.vue
@@ -111,6 +111,7 @@ function normalizePresenceMembers(members: BoardPresenceMember[]): BoardPresence
}
const boardId = ref(route.params.id as string)
+let boardViewVisit = boardStore.beginBoardViewVisit(boardId.value)
const previewProposalId = computed(() => typeof route.query?.proposalId === 'string' ? route.query.proposalId : null)
const proposalMarkers = ref({})
provide(BOARD_PROPOSAL_MARKERS, readonly(proposalMarkers))
@@ -344,6 +345,7 @@ watch(
return
}
+ boardViewVisit = boardStore.beginBoardViewVisit(nextBoardId)
boardId.value = nextBoardId
boardLoadError.value = null
resetSelection()
@@ -366,7 +368,8 @@ watch(
recordBoardLoadFailure(nextBoardId, error)
logError('Failed to switch board:', error)
}
- }
+ },
+ { flush: 'sync' },
)
watch(
@@ -378,6 +381,7 @@ watch(
onBeforeUnmount(() => {
viewUnmounted = true
+ boardStore.endBoardViewVisit(boardViewVisit)
boardStore.cancelBackgroundBoardFetch?.(boardId.value)
presenceMembers.value = []
boardStore.setBoardPresenceMembers([])