Skip to content

Fix archive board action causing ~30s browser freeze#578

Merged
Chris0Jeky merged 4 commits intomainfrom
fix/519-archive-board-freeze
Mar 29, 2026
Merged

Fix archive board action causing ~30s browser freeze#578
Chris0Jeky merged 4 commits intomainfrom
fix/519-archive-board-freeze

Conversation

@Chris0Jeky
Copy link
Copy Markdown
Owner

Summary

  • Fixes BUG: Archive board action causes ~30-second browser freeze #519
  • Root cause: when archiving a board from BoardView, boardStore.deleteBoard() sequentially cleared 6+ reactive refs (currentBoard, cards, labels, comments, presence, editingCard) while the BoardView was still mounted. Each mutation triggered a separate reactive flush, causing computed properties (sortedColumns, cardsByColumn, filteredCardCount) to re-evaluate on every intermediate state — cascading into ~30 seconds of synchronous DOM work.
  • Fix: navigate to /boards before calling deleteBoard() so the BoardView is unmounted and its reactive subscriptions are torn down before the state mutations fire
  • Reorder state mutations in deleteBoard to clear detail refs before filtering the boards array, preventing watchers on boards from reading stale detail state
  • Add loading/disabled state to the lifecycle action button for immediate user feedback

Test plan

  • All 1166 frontend unit tests pass
  • TypeScript typecheck passes
  • New test verifies navigation fires before deleteBoard (call ordering)
  • New test verifies button shows "Archiving..." and is disabled during action
  • Existing archive/restore tests still pass with updated behavior
  • Manual: archive action completes without browser freeze
  • Manual: board removed from list immediately after navigation
  • Manual: restore from archive still works correctly

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
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.
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.
@chatgpt-codex-connector
Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@Chris0Jeky
Copy link
Copy Markdown
Owner Author

Self-Review Findings

Root cause confirmed

The freeze was caused by sequential reactive mutations in deleteBoard (6 separate .value = assignments) firing while BoardView was still mounted. Each mutation triggered a Vue reactive flush cycle, causing sortedColumns, cardsByColumn, filteredCardCount, and template bindings to re-evaluate on every intermediate state. The fix navigates away first so the view is unmounted before mutations fire.

Potential issues reviewed

  1. Error handling after navigation: If deleteBoard fails after we already navigated to /boards and closed the modal, the catch block sets lifecycleActionInProgress = false on an already-hidden modal (harmless). The store's handleApiError shows a toast to the user. The server-side DELETE already succeeded (the boardsApi.deleteBoard await resolved before state mutations), so the board is archived server-side even if client state cleanup fails. On next fetchBoards, the list will be correct. Acceptable.

  2. Race condition on quick back-navigation: If the user navigates back to the archived board before deleteBoard finishes client-side cleanup, the board detail fetch would 404 (it's already archived server-side). The store handles this via handleApiError. No issue.

  3. State consistency: The BoardsListView calls fetchBoards on mount, which fetches fresh data from the server (excluding the newly archived board). The subsequent deleteBoard client mutations are then redundant but safe — they remove an entry that may no longer be in the list. No issue.

  4. Restore path unchanged: The restore flow (updateBoard with isArchived: false) still awaits the API call before emitting events, which is correct since ArchiveView does not have the same reactive cascade problem. No regression.

  5. Memory leaks: No new watchers or event listeners are created. The lifecycleActionInProgress ref is a simple primitive owned by the component. No leak.

  6. Missing disabled styling: The :disabled attribute prevents clicks but the button class doesn't include a disabled: variant for visual dimming. However, the text changes to "Archiving..." immediately and the modal closes within milliseconds, so visual feedback is adequate. Minor, acceptable.

  7. Test coverage: Three new tests cover the critical ordering invariant (navigate before delete), loading label, and disabled state. All 1166 existing tests pass. TypeScript typecheck passes. Adequate.

Verdict

No blocking issues found. The fix is minimal and targeted at the root cause.

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request addresses a performance issue (#519) where sequential reactive mutations caused a significant freeze during board archival. The fix involves reordering operations to navigate away from the board view before clearing state and updating the store to prevent cascading reactive updates. Feedback suggests using a finally block for state resets, correcting misleading comments regarding state assignments, and cleaning up redundant or confusingly named variables in the new test cases.

Comment on lines 109 to 112
} catch (error) {
console.error('Failed to update board lifecycle state:', error)
lifecycleActionInProgress.value = false
}
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
  }

Comment on lines +142 to +146
// Clear current board state in a single assignment to avoid cascading
// reactive updates. Previously, each `.value = …` triggered its own
// flush cycle, which caused watchers/computed properties in mounted
// views (BoardView, BoardCanvas, etc.) to re-evaluate on every
// intermediate state — the root cause of the ~30 s freeze in #519.
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

This comment is slightly misleading. It states "Clear current board state in a single assignment", but the code performs multiple assignments to different reactive refs. The reordering of operations is the key change here to ensure state consistency for any watchers. The comment should be updated to reflect this more accurately.

Suggested change
// Clear current board state in a single assignment to avoid cascading
// reactive updates. Previously, each `.value = …` triggered its own
// flush cycle, which caused watchers/computed properties in mounted
// views (BoardView, BoardCanvas, etc.) to re-evaluate on every
// intermediate state — the root cause of the ~30 s freeze in #519.
// 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.

Comment on lines +248 to +274
it('should show loading label while archive action is in progress', async () => {
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
let resolveDelete: () => void
mockStore.deleteBoard.mockReturnValue(new Promise<void>((resolve) => { resolveDelete = resolve }))

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

const archiveButton = wrapper
.findAll('button')
.find((btn) => btn.text().includes('Move to Archive'))
// Trigger without await so we can inspect intermediate state
void archiveButton?.trigger('click')
await wrapper.vm.$nextTick()
await wrapper.vm.$nextTick()

// The close event fires immediately, so the modal will already be hidden,
// but the button label should have been set to the in-progress state
expect(wrapper.emitted('close')).toBeTruthy()

resolveDelete!()
confirmSpy.mockRestore()
})
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

This test case, should show loading label while archive action is in progress, is redundant and incomplete. It doesn't contain any assertions to verify the loading label. The subsequent test, should disable lifecycle button while action is in progress, already covers this behavior by checking for the button with the "Archiving..." text and verifying its disabled state. To avoid redundancy and keep the test suite clean, consider removing this test case.


it('should disable lifecycle button while action is in progress', async () => {
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
let resolveDelete: () => void
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

The variable resolveDelete is used to resolve the promise from the router.push mock. This is confusing as the name implies it's related to deleteBoard. Renaming it to resolvePush on lines 278, 279, and 301 would make the test's intent clearer.

@Chris0Jeky
Copy link
Copy Markdown
Owner Author

Adversarial Review -- PR #578

Critical

None found.

Major

1. lifecycleActionInProgress is never reset on the success path (archive branch)
File: frontend/taskdeck-web/src/components/board/BoardSettingsModal.vue, lines 87-112

On the archive path, lifecycleActionInProgress is set to true at line 87, but it is only reset to false inside the catch block (line 111). On the success path the ref stays true forever. The self-review dismissed this as "harmless because the modal is hidden", but that assumption is fragile:

  • If the modal were ever controlled by v-show instead of v-if, the component would persist in a broken state.
  • If the parent re-opens the modal for the same board instance before the component is destroyed (possible in fast navigation sequences), the button would remain disabled with "Archiving..." text.
  • It violates the principle of cleaning up state you set. The Gemini bot also flagged this.

Fix: Use a finally block to always reset lifecycleActionInProgress.value = false, or at minimum reset it at the end of the try block.

2. deleteBoard failure after navigation leaves user stranded with no error feedback in context
File: frontend/taskdeck-web/src/components/board/BoardSettingsModal.vue, lines 97-103

If boardStore.deleteBoard(props.board.id) throws after the modal is already closed and the user has navigated to /boards, the catch block logs to console and resets a ref on an effectively dead component. The store's handleApiError does show a toast (good), but the board will still appear in the boards list because the client-side removal in deleteBoard is skipped. The user sees the board, clicks on it, and gets a 404 or stale state from the server (the API already returned success for the DELETE, so the server considers it archived).

Wait -- re-reading the store code: boardsApi.deleteBoard(boardId) is the first await in deleteBoard. If that succeeds (the board IS archived server-side), the subsequent client mutations are pure in-memory work that cannot throw. If the API call itself fails, the throw e in the store propagates, and the board is NOT archived server-side, so the boards list is still correct. On further analysis, this is actually safe. The only risk is if one of the in-memory assignments throws (e.g., some exotic proxy trap), which is not realistic.

Downgrading to informational -- no action needed.

Minor

3. Test should show loading label while archive action is in progress asserts nothing about the loading label
File: frontend/taskdeck-web/src/tests/components/BoardSettingsModal.spec.ts, lines 248-274

This test's name promises it checks the loading label, but the only assertion is expect(wrapper.emitted('close')).toBeTruthy() -- which has nothing to do with the loading label. The test is effectively a no-op for its stated purpose. The Gemini bot also flagged this as redundant.

The subsequent test ("should disable lifecycle button while action is in progress") does properly verify the "Archiving..." text and disabled state, making this test purely dead weight.

Recommendation: Either add a real assertion (e.g., check that the button text is "Archiving...") or remove this test entirely.

4. Misleading variable name resolveDelete in the "disable lifecycle button" test
File: frontend/taskdeck-web/src/tests/components/BoardSettingsModal.spec.ts, lines 278-279

The variable resolveDelete resolves the promise returned by mockRouter.push, not deleteBoard. This is confusing when reading the test. The Gemini bot also flagged this.

Recommendation: Rename to resolvePush.

5. Comment in boardCrudStore.ts says "single assignment" but code performs 6 assignments
File: frontend/taskdeck-web/src/store/board/boardCrudStore.ts, lines 142-146

The comment says "Clear current board state in a single assignment" but the code still does 6 individual .value = assignments. The reordering (clear detail state before removing from list) is the real change. The comment is misleading.

Recommendation: Update the comment as Gemini suggested -- describe the reordering rationale, not a false claim about batching.

6. No test for the restore (un-archive) path's error handling
File: frontend/taskdeck-web/src/tests/components/BoardSettingsModal.spec.ts

There is no test that verifies behavior when updateBoard (the restore path) throws. The restore path now has different event-emission timing than before (emit happens after await), so an error on restore would correctly not emit 'updated'/'close'. But this scenario is untested.

Nits

7. The boardCrudStore comment about "single assignment" preventing cascading flushes is technically inaccurate
The real fix is in BoardSettingsModal.vue where navigation happens before the store mutation. The store-side reordering (clearing detail state before removing from the list) is a defensive improvement for watchers that read both, but it does NOT batch into a single synchronous flush -- Vue will still schedule separate updates for each .value =. The comment overstates the effect.

8. Missing aria-label on the disabled archive button
File: frontend/taskdeck-web/src/components/board/BoardSettingsModal.vue, line 202
The button changes text dynamically but has no aria-label or aria-busy attribute for screen readers. Not a regression (it wasn't there before), but worth noting.

Overall Assessment

Pass with fixes. The core approach (navigate before mutating store state) is sound and well-targeted at the root cause. The only actionable issue is Major #1 (missing lifecycleActionInProgress reset on success path via finally block). The test quality issues (Minor #3, #4) should also be cleaned up before merge.

…lean 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)
@Chris0Jeky
Copy link
Copy Markdown
Owner Author

Review Fixes Applied (6c6d018)

Addressed the following findings from the adversarial review:

  1. Major Claude/create feature f 01 qr r as j4 s14advm nn qhp2 la #1 -- lifecycleActionInProgress never reset on success: Changed catch-only reset to a finally block so the flag is always cleaned up regardless of outcome.

  2. Minor Add comprehensive Application layer test suite #3 -- Redundant test with no assertions: Removed the should show loading label while archive action is in progress test, which had no assertion about the loading label. The subsequent should disable lifecycle button while action is in progress test already covers both the label text and disabled state.

  3. Minor Add label validation coverage for CardService #4 -- Misleading variable name: Renamed resolveDelete to resolvePush in the disabled-button test since the variable resolves the router.push mock, not deleteBoard.

  4. Minor Review recent changes and documentation #5 -- Misleading comment: Updated the boardCrudStore.ts comment to accurately describe the reordering rationale (clear detail state before filtering the boards list) rather than falsely claiming "single assignment" batching.

All 1165 tests pass. Typecheck and production build clean.

@Chris0Jeky Chris0Jeky merged commit cc3ad18 into main Mar 29, 2026
18 checks passed
@Chris0Jeky Chris0Jeky deleted the fix/519-archive-board-freeze branch March 29, 2026 22:19
@github-project-automation github-project-automation bot moved this from Pending to Done in Taskdeck Execution Mar 29, 2026
Chris0Jeky added a commit that referenced this pull request Mar 29, 2026
Update two analysis docs (chat-to-proposal gap and manual testing findings) to reflect recent fixes and testing status. Key changes: add Last Updated and status notes; mark Tier 1 improvements shipped (intent classifier regex/stemming/negation fixes, substring ordering bug, PR #579), UX parse hints shipped (PR #582), unit/integration tests shipped (PR #580), and note PR range #578#582. In manual testing findings mark OBS-2/OBS-3 resolved (PR #581) and BUG-M5 resolved (PR #578), update resolutions and remove duplicate checklist items. Minor editorial clarifications and test counts added.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

BUG: Archive board action causes ~30-second browser freeze

1 participant