diff --git a/frontend/taskdeck-web/src/composables/useAssignmentSaveRegistry.ts b/frontend/taskdeck-web/src/composables/useAssignmentSaveRegistry.ts
new file mode 100644
index 0000000000..9b6432d0a4
--- /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/tests/components/BoardCanvasSaveLifecycle.spec.ts b/frontend/taskdeck-web/src/tests/components/BoardCanvasSaveLifecycle.spec.ts
new file mode 100644
index 0000000000..9c732c1265
--- /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]])
+ })
+})
diff --git a/frontend/taskdeck-web/src/tests/components/BoardCanvasSaveOwnership.spec.ts b/frontend/taskdeck-web/src/tests/components/BoardCanvasSaveOwnership.spec.ts
index 70023ec66c..b9bb737a38 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 0000000000..5260e8dc6f
--- /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/views/LegacyAssignmentSaveNavigation.spec.ts b/frontend/taskdeck-web/src/tests/views/LegacyAssignmentSaveNavigation.spec.ts
index 07180b8e86..2efdc813ea 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'
@@ -278,36 +277,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: '',
- },
- },
- },
- })
- mountedWrappers.push(wrapper)
-
- await wrapper.get('[data-testid="lane-save"]').trigger('click')
-
- expect(wrapper.emitted('cardEditorSavingChange')).toEqual([[true]])
- })
})
describe('Legacy assignment-save navigation boundary', () => {