Skip to content

Commit cc3ad18

Browse files
authored
Fix archive board action causing ~30s browser freeze (#578)
* Fix archive board freeze by navigating before clearing state Navigate to /boards before calling boardStore.deleteBoard so the BoardView is unmounted and its reactive subscriptions (sortedColumns, cardsByColumn, filter computeds) are torn down before the sequential state mutations fire. This eliminates the ~30-second browser freeze caused by cascading re-renders while the view was still mounted. Add loading state to the lifecycle action button to provide immediate feedback and prevent double-clicks. Fixes #519 * Clear board detail state before filtering boards list in deleteBoard Reorder state mutations in deleteBoard so detail refs (currentBoard, cards, labels, comments, presence) are cleared before the boards array is filtered. This prevents downstream watchers on `boards` from reading stale detail state during the reactive flush. * Add tests for archive navigation order and loading state Verify that router.push fires before boardStore.deleteBoard to prevent reactive cascade freeze. Add tests for the disabled/loading button label during the archive action. * Address review findings: use finally block, fix misleading comment, clean up tests - Use finally block to always reset lifecycleActionInProgress (Major #1) - Fix misleading "single assignment" comment in boardCrudStore (Minor #5) - Remove redundant test with no meaningful assertions (Minor #3) - Rename resolveDelete to resolvePush for clarity (Minor #4)
2 parents 0c9b499 + 6c6d018 commit cc3ad18

3 files changed

Lines changed: 96 additions & 20 deletions

File tree

frontend/taskdeck-web/src/components/board/BoardSettingsModal.vue

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ const router = useRouter()
2121
// Form state
2222
const name = ref('')
2323
const description = ref('')
24+
const lifecycleActionInProgress = ref(false)
2425
2526
// Watch for board changes
2627
watch(() => props.board, (newBoard) => {
@@ -30,9 +31,12 @@ watch(() => props.board, (newBoard) => {
3031
}
3132
}, { immediate: true })
3233
33-
const lifecycleActionLabel = computed(() => (
34-
props.board.isArchived ? 'Restore Board' : 'Move to Archive'
35-
))
34+
const lifecycleActionLabel = computed(() => {
35+
if (lifecycleActionInProgress.value) {
36+
return props.board.isArchived ? 'Restoring...' : 'Archiving...'
37+
}
38+
return props.board.isArchived ? 'Restore Board' : 'Move to Archive'
39+
})
3640
3741
const lifecycleActionButtonClass = computed(() => (
3842
props.board.isArchived
@@ -80,21 +84,32 @@ async function handleLifecycleTransition() {
8084
return
8185
}
8286
87+
lifecycleActionInProgress.value = true
88+
8389
try {
8490
if (shouldArchive) {
91+
// Navigate away BEFORE clearing board state to prevent the mounted
92+
// BoardView from re-rendering every computed (columns, cards, filters)
93+
// as each piece of state is set to null/empty. This was the root cause
94+
// of the ~30-second freeze reported in #519 — sequential reactive
95+
// mutations cascaded through dozens of watchers while the view was
96+
// still mounted.
97+
emit('updated')
98+
emit('close')
99+
await router.push('/boards')
100+
101+
// Run the store mutation after navigation so the old BoardView is
102+
// already unmounted and its reactive subscriptions are torn down.
85103
await boardStore.deleteBoard(props.board.id)
86104
} else {
87105
await boardStore.updateBoard(props.board.id, { isArchived: false })
88-
}
89-
90-
emit('updated')
91-
emit('close')
92-
93-
if (shouldArchive) {
94-
router.push('/boards')
106+
emit('updated')
107+
emit('close')
95108
}
96109
} catch (error) {
97110
console.error('Failed to update board lifecycle state:', error)
111+
} finally {
112+
lifecycleActionInProgress.value = false
98113
}
99114
}
100115
@@ -188,6 +203,7 @@ useEscapeToClose(() => props.isOpen, handleClose)
188203
<button
189204
@click="handleLifecycleTransition"
190205
type="button"
206+
:disabled="lifecycleActionInProgress"
191207
:class="lifecycleActionButtonClass"
192208
>
193209
{{ lifecycleActionLabel }}

frontend/taskdeck-web/src/store/board/boardCrudStore.ts

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -139,16 +139,13 @@ export function createBoardCrudActions(state: BoardState, helpers: BoardHelpers)
139139
state.error.value = null
140140
await boardsApi.deleteBoard(boardId)
141141

142-
// Remove from boards list
143-
state.boards.value = state.boards.value.filter((b) => b.id !== boardId)
144-
145-
// Clear activeBoardId if the deleted board was the active selection
146-
if (state.activeBoardId.value === boardId) {
147-
state.activeBoardId.value = state.boards.value[0]?.id ?? null
148-
}
149-
150-
// Clear current board if it's the one being deleted
151-
if (state.currentBoard.value && state.currentBoard.value.id === boardId) {
142+
// Clear detailed state for the current board before removing it from the
143+
// main boards list. This prevents any watchers on the `boards` array
144+
// from accidentally accessing stale detail state (like cards, labels, etc.)
145+
// that belongs to the board being deleted. The primary performance fix
146+
// for #519 is unmounting the BoardView before this action is called.
147+
const isCurrent = state.currentBoard.value?.id === boardId
148+
if (isCurrent) {
152149
state.currentBoard.value = null
153150
state.currentBoardCards.value = []
154151
state.currentBoardLabels.value = []
@@ -157,6 +154,15 @@ export function createBoardCrudActions(state: BoardState, helpers: BoardHelpers)
157154
state.editingCardId.value = null
158155
}
159156

157+
// Remove from boards list after clearing detail state so downstream
158+
// watchers on `boards` do not attempt to read stale detail refs.
159+
state.boards.value = state.boards.value.filter((b) => b.id !== boardId)
160+
161+
// Clear activeBoardId if the deleted board was the active selection
162+
if (state.activeBoardId.value === boardId) {
163+
state.activeBoardId.value = state.boards.value[0]?.id ?? null
164+
}
165+
160166
helpers.toast.success('Board archived successfully')
161167
} catch (e: unknown) {
162168
helpers.handleApiError(e, 'Failed to archive board')

frontend/taskdeck-web/src/tests/components/BoardSettingsModal.spec.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,11 +215,65 @@ describe('BoardSettingsModal', () => {
215215
expect(mockStore.deleteBoard).toHaveBeenCalledWith('board-1')
216216
expect(mockStore.updateBoard).not.toHaveBeenCalled()
217217
expect(wrapper.emitted('close')).toBeTruthy()
218+
// Navigation happens before deleteBoard to prevent reactive cascade freeze (#519)
218219
expect(mockRouter.push).toHaveBeenCalledWith('/boards')
219220

220221
confirmSpy.mockRestore()
221222
})
222223

224+
it('should navigate before deleting board to avoid reactive cascade freeze', async () => {
225+
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
226+
const callOrder: string[] = []
227+
mockRouter.push.mockImplementation(async () => { callOrder.push('navigate') })
228+
mockStore.deleteBoard.mockImplementation(async () => { callOrder.push('delete') })
229+
230+
const wrapper = mount(BoardSettingsModal, {
231+
props: {
232+
board,
233+
isOpen: true,
234+
},
235+
})
236+
237+
const archiveButton = wrapper
238+
.findAll('button')
239+
.find((btn) => btn.text().includes('Move to Archive'))
240+
await archiveButton?.trigger('click')
241+
await wrapper.vm.$nextTick()
242+
243+
expect(callOrder).toEqual(['navigate', 'delete'])
244+
245+
confirmSpy.mockRestore()
246+
})
247+
248+
it('should disable lifecycle button while action is in progress', async () => {
249+
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
250+
let resolvePush: () => void
251+
mockRouter.push.mockReturnValue(new Promise<void>((resolve) => { resolvePush = resolve }))
252+
253+
const wrapper = mount(BoardSettingsModal, {
254+
props: {
255+
board,
256+
isOpen: true,
257+
},
258+
})
259+
260+
const archiveButton = wrapper
261+
.findAll('button')
262+
.find((btn) => btn.text().includes('Move to Archive'))
263+
void archiveButton?.trigger('click')
264+
await wrapper.vm.$nextTick()
265+
await wrapper.vm.$nextTick()
266+
267+
const buttonAfterClick = wrapper
268+
.findAll('button')
269+
.find((btn) => btn.text().includes('Archiving...'))
270+
expect(buttonAfterClick?.exists()).toBe(true)
271+
expect((buttonAfterClick?.element as HTMLButtonElement).disabled).toBe(true)
272+
273+
resolvePush!()
274+
confirmSpy.mockRestore()
275+
})
276+
223277
it('should show restore action when board is archived', () => {
224278
const archivedBoard = { ...board, isArchived: true }
225279

0 commit comments

Comments
 (0)