Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 26 additions & 10 deletions frontend/taskdeck-web/src/components/board/BoardSettingsModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const router = useRouter()
// Form state
const name = ref('')
const description = ref('')
const lifecycleActionInProgress = ref(false)

// Watch for board changes
watch(() => props.board, (newBoard) => {
Expand All @@ -30,9 +31,12 @@ watch(() => props.board, (newBoard) => {
}
}, { immediate: true })

const lifecycleActionLabel = computed(() => (
props.board.isArchived ? 'Restore Board' : 'Move to Archive'
))
const lifecycleActionLabel = computed(() => {
if (lifecycleActionInProgress.value) {
return props.board.isArchived ? 'Restoring...' : 'Archiving...'
}
return props.board.isArchived ? 'Restore Board' : 'Move to Archive'
})

const lifecycleActionButtonClass = computed(() => (
props.board.isArchived
Expand Down Expand Up @@ -80,21 +84,32 @@ async function handleLifecycleTransition() {
return
}

lifecycleActionInProgress.value = true

try {
if (shouldArchive) {
// Navigate away BEFORE clearing board state to prevent the mounted
// BoardView from re-rendering every computed (columns, cards, filters)
// as each piece of state is set to null/empty. This was the root cause
// of the ~30-second freeze reported in #519 — sequential reactive
// mutations cascaded through dozens of watchers while the view was
// still mounted.
emit('updated')
emit('close')
await router.push('/boards')

// Run the store mutation after navigation so the old BoardView is
// already unmounted and its reactive subscriptions are torn down.
await boardStore.deleteBoard(props.board.id)
} else {
await boardStore.updateBoard(props.board.id, { isArchived: false })
}

emit('updated')
emit('close')

if (shouldArchive) {
router.push('/boards')
emit('updated')
emit('close')
}
} catch (error) {
console.error('Failed to update board lifecycle state:', error)
} finally {
lifecycleActionInProgress.value = false
}
Comment on lines 109 to 113
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To ensure the lifecycleActionInProgress state is always reset, regardless of success or failure, it's better to use a finally block. This makes the component more robust, for instance if its visibility were controlled by v-show instead of v-if, where the component state is preserved.

  } catch (error) {
    console.error('Failed to update board lifecycle state:', error)
  } finally {
    lifecycleActionInProgress.value = false
  }

}

Expand Down Expand Up @@ -188,6 +203,7 @@ useEscapeToClose(() => props.isOpen, handleClose)
<button
@click="handleLifecycleTransition"
type="button"
:disabled="lifecycleActionInProgress"
:class="lifecycleActionButtonClass"
>
{{ lifecycleActionLabel }}
Expand Down
26 changes: 16 additions & 10 deletions frontend/taskdeck-web/src/store/board/boardCrudStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,16 +139,13 @@ export function createBoardCrudActions(state: BoardState, helpers: BoardHelpers)
state.error.value = null
await boardsApi.deleteBoard(boardId)

// Remove from boards list
state.boards.value = state.boards.value.filter((b) => b.id !== boardId)

// Clear activeBoardId if the deleted board was the active selection
if (state.activeBoardId.value === boardId) {
state.activeBoardId.value = state.boards.value[0]?.id ?? null
}

// Clear current board if it's the one being deleted
if (state.currentBoard.value && state.currentBoard.value.id === boardId) {
// Clear detailed state for the current board before removing it from the
// main boards list. This prevents any watchers on the `boards` array
// from accidentally accessing stale detail state (like cards, labels, etc.)
// that belongs to the board being deleted. The primary performance fix
// for #519 is unmounting the BoardView before this action is called.
const isCurrent = state.currentBoard.value?.id === boardId
if (isCurrent) {
state.currentBoard.value = null
state.currentBoardCards.value = []
state.currentBoardLabels.value = []
Expand All @@ -157,6 +154,15 @@ export function createBoardCrudActions(state: BoardState, helpers: BoardHelpers)
state.editingCardId.value = null
}

// Remove from boards list after clearing detail state so downstream
// watchers on `boards` do not attempt to read stale detail refs.
state.boards.value = state.boards.value.filter((b) => b.id !== boardId)

// Clear activeBoardId if the deleted board was the active selection
if (state.activeBoardId.value === boardId) {
state.activeBoardId.value = state.boards.value[0]?.id ?? null
}

helpers.toast.success('Board archived successfully')
} catch (e: unknown) {
helpers.handleApiError(e, 'Failed to archive board')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -215,11 +215,65 @@ describe('BoardSettingsModal', () => {
expect(mockStore.deleteBoard).toHaveBeenCalledWith('board-1')
expect(mockStore.updateBoard).not.toHaveBeenCalled()
expect(wrapper.emitted('close')).toBeTruthy()
// Navigation happens before deleteBoard to prevent reactive cascade freeze (#519)
expect(mockRouter.push).toHaveBeenCalledWith('/boards')

confirmSpy.mockRestore()
})

it('should navigate before deleting board to avoid reactive cascade freeze', async () => {
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
const callOrder: string[] = []
mockRouter.push.mockImplementation(async () => { callOrder.push('navigate') })
mockStore.deleteBoard.mockImplementation(async () => { callOrder.push('delete') })

const wrapper = mount(BoardSettingsModal, {
props: {
board,
isOpen: true,
},
})

const archiveButton = wrapper
.findAll('button')
.find((btn) => btn.text().includes('Move to Archive'))
await archiveButton?.trigger('click')
await wrapper.vm.$nextTick()

expect(callOrder).toEqual(['navigate', 'delete'])

confirmSpy.mockRestore()
})

it('should disable lifecycle button while action is in progress', async () => {
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
let resolvePush: () => void
mockRouter.push.mockReturnValue(new Promise<void>((resolve) => { resolvePush = resolve }))

const wrapper = mount(BoardSettingsModal, {
props: {
board,
isOpen: true,
},
})

const archiveButton = wrapper
.findAll('button')
.find((btn) => btn.text().includes('Move to Archive'))
void archiveButton?.trigger('click')
await wrapper.vm.$nextTick()
await wrapper.vm.$nextTick()

const buttonAfterClick = wrapper
.findAll('button')
.find((btn) => btn.text().includes('Archiving...'))
expect(buttonAfterClick?.exists()).toBe(true)
expect((buttonAfterClick?.element as HTMLButtonElement).disabled).toBe(true)

resolvePush!()
confirmSpy.mockRestore()
})

it('should show restore action when board is archived', () => {
const archivedBoard = { ...board, isArchived: true }

Expand Down
Loading