From fb3c4cdf8008d721c2499b8e31f3cef1edc8a520 Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Sun, 20 Sep 2026 20:28:47 +0100 Subject: [PATCH 1/7] test(board): pin assignment-save ownership beyond lane lifetime --- .../BoardCanvasSaveLifecycle.spec.ts | 124 ++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 frontend/taskdeck-web/src/tests/components/BoardCanvasSaveLifecycle.spec.ts 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: '', +}) + +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]]) + }) +}) From d6e2117216ba6d40f071af0a738919cc3f0655f8 Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Sun, 20 Sep 2026 20:29:05 +0100 Subject: [PATCH 2/7] test(assignments): settle registry tokens after component unmount --- .../CardAssignmentSaveRegistry.spec.ts | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 frontend/taskdeck-web/src/tests/components/CardAssignmentSaveRegistry.spec.ts 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]) + }) +}) From 792787ea45ad9f164c8f75397ad9bf315e614359 Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Sun, 20 Sep 2026 20:29:25 +0100 Subject: [PATCH 3/7] feat(assignments): add board-session save registry --- .../composables/useAssignmentSaveRegistry.ts | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 frontend/taskdeck-web/src/composables/useAssignmentSaveRegistry.ts 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 } +} From 04b2408895113af39194c46016eee5a21b402140 Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Sun, 20 Sep 2026 20:29:51 +0100 Subject: [PATCH 4/7] fix(board): own assignment saves above rendered lanes --- .../src/components/board/BoardCanvas.vue | 44 +++++++++++-------- 1 file changed, 26 insertions(+), 18 deletions(-) diff --git a/frontend/taskdeck-web/src/components/board/BoardCanvas.vue b/frontend/taskdeck-web/src/components/board/BoardCanvas.vue index 5a26d7db2..0e611e294 100644 --- a/frontend/taskdeck-web/src/components/board/BoardCanvas.vue +++ b/frontend/taskdeck-web/src/components/board/BoardCanvas.vue @@ -1,8 +1,13 @@