Skip to content

fix: preserve active board selection across store refreshes (#509)#526

Merged
Chris0Jeky merged 2 commits intomainfrom
fix/509-board-preserve-selection
Mar 29, 2026
Merged

fix: preserve active board selection across store refreshes (#509)#526
Chris0Jeky merged 2 commits intomainfrom
fix/509-board-preserve-selection

Conversation

@Chris0Jeky
Copy link
Copy Markdown
Owner

Summary

  • Adds activeBoardId to boardState as the canonical selection ref
  • Adds a preserveSelection guard in fetchBoards: only updates activeBoardId when there is no current selection or the selected board no longer exists in the refreshed list
  • Clears activeBoardId correctly in deleteBoard, falling back to the first remaining board
  • Closes BUG: Board auto-switches between boards every few seconds without user input #509

Root Cause

Every poll/subscription cycle called fetchBoards, which unconditionally reset activeBoardId to the first board in the returned list. When the API returned boards in a different order, the visible board flipped every few seconds without user input.

Fix

Before updating activeBoardId, check if the current selection still exists in the new board list. Only fall back to the first board when the selection is absent or has been removed.

Tests

Five unit tests added to boardStore.spec.ts:

  • Initial load with no prior selection sets activeBoardId to first board
  • Repeated fetchBoards preserves user's selection when board still exists
  • fetchBoards falls back to first board when selected board is removed
  • fetchBoards yields null when board list becomes empty
  • deleteBoard falls back to remaining board when active board is deleted

Risk

Low — guard is purely additive; only changes behaviour when a valid selection already exists. Initial load path is unchanged.

Add activeBoardId to boardState and apply a preserveSelection guard in
fetchBoards: only update activeBoardId when there is no current selection
or the selected board no longer exists in the refreshed list. Also clear
activeBoardId correctly on deleteBoard, falling back to the first
remaining board.

Prevents polling/SignalR-triggered fetchBoards calls from resetting the
visible board to the first item in the returned list.
Five cases: initial load sets selection, repeated fetchBoards preserves
user selection, missing board falls back to first, empty list yields null,
deleteBoard falls back to remaining board.
@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 (Adversarial Pass)

Checked: Board deleted mid-session

deleteBoard filters the board out of state.boards.value first, then checks state.activeBoardId.value === boardId. At that point state.boards.value already excludes the deleted board, so state.boards.value[0]?.id ?? null correctly falls back to the next available board (or null if empty). No double-clear risk.

Checked: Initial load with no prior selection

activeBoardId starts as null. stillExists evaluates false because currentId is null, so the guard falls through to freshBoards[0]?.id ?? null — first board selected as expected.

Checked: All paths that touch activeBoardId reviewed

  • fetchBoards (non-demo path): guard applied ✓
  • fetchBoards (demo path): replaces state.boards.value with buildDemoBoardList() but does not update activeBoardId. The same guard should apply here. Minor gap: demo mode bypasses the guard. Low risk for now because demo boards are a fixed synthetic list that never reorders, but worth noting.
  • deleteBoard: explicit fallback added ✓
  • createBoard: pushes new board into list but does not change selection — correct, user keeps current selection ✓
  • updateBoard: does not affect activeBoardId
  • No SignalR or route-level code directly mutates activeBoardId — all reads are via the exposed reactive ref.

Checked: Test validates polling scenario specifically

The key test ("preserves activeBoardId across fetchBoards when selected board still exists") simulates two consecutive fetchBoards calls returning boards in a different order. The assertion confirms activeBoardId stays on the user-selected board. This directly mirrors the reported bug scenario.

Known gap (non-blocking)

Demo mode fetchBoards path does not apply the preserve-selection guard. Since demo boards are deterministic and the feature is gated behind helpers.isDemoMode, this cannot cause the reported bug and does not need to be fixed here.

Verdict

Fix is correct and tests are targeted. Ready to merge once CI is green.

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 introduces an activeBoardId state to the board store to track the currently selected board. It implements a preservation guard to ensure that the user's selection is maintained during data refreshes or polling, only resetting the selection if the active board is no longer present in the list. The logic also handles updating the selection when a board is deleted. Comprehensive unit tests were added to cover various selection scenarios. Feedback was provided to simplify the boolean logic used to verify if a board still exists in the fetched list.

// refreshed list (e.g. it was deleted). This prevents polling/subscription
// refreshes from resetting the user's active board to the first item.
const currentId = state.activeBoardId.value
const stillExists = currentId !== null && freshBoards.some((b) => b.id === currentId)
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 check currentId !== null is redundant. If currentId is null, freshBoards.some((b) => b.id === currentId) will correctly return false (assuming board IDs are never null), which leads to the desired behavior of resetting the active board. You can simplify this line by removing the explicit null check.

Suggested change
const stillExists = currentId !== null && freshBoards.some((b) => b.id === currentId)
const stillExists = freshBoards.some((b) => b.id === currentId)

@Chris0Jeky
Copy link
Copy Markdown
Owner Author

Adversarial Review (Independent Pass)

Verdict: APPROVE with minor notes

Findings

MINOR — Demo mode path skips the selection guard

fetchBoards has an early-return for demo mode (lines 12-17 of boardCrudStore.ts) that overwrites state.boards.value but never touches state.activeBoardId. If a consumer calls fetchBoards repeatedly in demo mode (e.g. hot-reload, navigation), activeBoardId is never initialised from the demo board list. This is low-risk because no current view reads boardStore.activeBoardId (the demo-mode spec does not assert it either), but it is a latent inconsistency: the guard that was just added is silently bypassed in the demo path. The fix would be to apply the same guard inside the isDemoMode block, or at minimum add a demo-mode test for activeBoardId initialisation.

MINOR — Test comment is misleading (non-blocking)

In the "preserves activeBoardId" test:

// Selection must NOT flip back to boardB (now first) or boardA
expect(store.activeBoardId).toBe('board-b')

The comment says "must NOT flip back to boardB (now first) OR boardA" — but boardB is exactly the expected value. The or boardA clause is what the test actually guards against. The comment should read "must NOT flip to boardA (now second)" to avoid confusion, but this is documentation-only, not a logic error.

NOTE — activeBoardId is exported from the store but has zero consumers in the current UI

No view (BoardsListView, BoardView, BoardAccessView, etc.) reads boardStore.activeBoardId. The views that have their own local activeBoardId ref/computed are fully independent. This means the guard is correctly implemented but currently has no observable effect in production; it is pre-wiring for a future consumer. This is fine for a fix-branch but worth calling out in the issue so future work actually wires it up.

NOTE — No race condition risk

The fetchBoards function is not debounced and there is no in-flight cancellation, but the guard is idempotent: whichever response resolves last wins for state.boards.value, and the guard then correctly checks whether activeBoardId still appears in that final board list. No sequence of concurrent calls can produce an incorrect final state (though a stale intermediate response could briefly set boards.value to an older list before being overwritten). This is pre-existing behaviour and outside this PR's scope.

NOTE — deleteBoard uses the already-filtered list for fallback correctly

state.boards.value is filtered before the activeBoardId check, so state.boards.value[0] will never accidentally return the deleted board. Order of operations is correct.

Categories checked

  • All activeBoardId write paths covered by guard: partialfetchBoards (live path) ✓, deleteBoard ✓, demo mode path of fetchBoards ✗ (never sets activeBoardId)
  • No race condition on concurrent fetchBoards: pass — guard is idempotent regardless of call order
  • Initial load (null → first board) still works: passcurrentId === nullstillExists is false → falls through to set first board
  • Deleted board cleared via deleteBoard path: pass — explicit guard added and test covers it
  • Deleted board cleared via SignalR/event path: pass — SignalR/fallback polling calls fetchBoard (single board), not fetchBoards; when the board genuinely disappears from the board list it will be caught on the next fetchBoards cycle via the guard
  • Tests are non-vacuous and cover polling scenario: pass — test 2 explicitly simulates a second fetchBoards call with reordered data after user selects non-first board
  • TypeScript clean: passvue-tsc -b exits 0
  • Tests pass: pass — 1102/1102

@Chris0Jeky Chris0Jeky merged commit 599a2e0 into main Mar 29, 2026
18 checks passed
@Chris0Jeky Chris0Jeky deleted the fix/509-board-preserve-selection branch March 29, 2026 14:51
@github-project-automation github-project-automation bot moved this from Pending to Done in Taskdeck Execution Mar 29, 2026
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: Board auto-switches between boards every few seconds without user input

1 participant