From b963a8099180373b4fef6729e9d65949fdae5977 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Sat, 5 Sep 2026 19:34:33 +0100 Subject: [PATCH 1/4] fix(boards): restore focus after a failed retry only when focus was lost The retry read is bounded at 10 s and the create panel renders above the loading chain, so it stays interactive while the retry is in flight. The unconditional restore pulled the caret out of the new-board name input the moment the read failed, and the next Space or Enter re-fired Retry instead of typing. The restore is now guarded on document.activeElement being null or the body, which is where the browser leaves focus after the activated button unmounts. A red-first spec opens the create panel and focuses the name input while the forced retry is pending, then rejects the read and asserts the input keeps focus; the existing spec that asserts the restore when focus was lost is unchanged. Refs #2689 --- .../src/tests/views/BoardsListView.spec.ts | 56 +++++++++++++++++++ .../taskdeck-web/src/views/BoardsListView.vue | 14 ++++- 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/frontend/taskdeck-web/src/tests/views/BoardsListView.spec.ts b/frontend/taskdeck-web/src/tests/views/BoardsListView.spec.ts index 0de062d91e..224c221b4a 100644 --- a/frontend/taskdeck-web/src/tests/views/BoardsListView.spec.ts +++ b/frontend/taskdeck-web/src/tests/views/BoardsListView.spec.ts @@ -262,6 +262,62 @@ describe('BoardsListView', () => { wrapper.unmount() }) + // #2689 item 6, the other half of that restore. The retry read is bounded + // at 10 s and the create panel sits ABOVE the loading chain, so it stays + // interactive while the retry is in flight: the user can open "+ New Board" + // and start typing during those ten seconds. Restoring unconditionally then + // pulled the caret out of the name input the moment the read failed, and + // the next Space or Enter re-fired Retry instead of typing. The restore + // exists for focus that was LOST, so it is guarded on + // `document.activeElement` being null or . + it('leaves the caret alone when the user moved into the create form during the retry', async () => { + mockBoardStore.error = 'Failed to load boards' + + const wrapper = mount(BoardsListView, { attachTo: document.body }) + await waitForUi() + + const firstButton = wrapper.find('[data-action="retry-board-load"]') + .element as HTMLButtonElement + firstButton.focus() + expect(document.activeElement).toBe(firstButton) + + let failRead!: () => void + mockBoardStore.fetchBoards.mockImplementation(() => { + mockBoardStore.loading = true + return new Promise((_resolve, reject) => { + failRead = () => { + mockBoardStore.error = 'Failed to load boards' + mockBoardStore.loading = false + reject(new Error('still failing')) + } + }) + }) + + await wrapper.find('[data-action="retry-board-load"]').trigger('click') + + // The read is on the wire and the error block is gone with the button + // that was focused. The user opens the create panel and puts the caret in + // the name input. + const newBoardBtn = wrapper.findAll('button').find((b) => b.text().includes('+ New Board')) + expect(newBoardBtn).toBeDefined() + await newBoardBtn!.trigger('click') + await waitForUi() + const nameInput = wrapper.find('#new-board-name').element as HTMLInputElement + nameInput.focus() + expect(document.activeElement).toBe(nameInput) + + failRead() + await flushPromises() + + // The retry failed and the error block was rebuilt with a new Retry + // button, but focus was never lost — so it stays where the user put it. + const rebuiltButton = wrapper.find('[data-action="retry-board-load"]') + expect(rebuiltButton.exists()).toBe(true) + expect(document.activeElement).toBe(nameInput) + + wrapper.unmount() + }) + it('shows the alert again, still retryable, when the retry also fails', async () => { mockBoardStore.error = 'Failed to load boards' diff --git a/frontend/taskdeck-web/src/views/BoardsListView.vue b/frontend/taskdeck-web/src/views/BoardsListView.vue index 9288bbc175..3814c74d15 100644 --- a/frontend/taskdeck-web/src/views/BoardsListView.vue +++ b/frontend/taskdeck-web/src/views/BoardsListView.vue @@ -98,8 +98,20 @@ async function retryLoad() { // block is gone — so the optional call is also the "only on failure" guard. // The alert paragraph is a new node on each failure, so it is announced // again independently of this. + // + // The second guard is what keeps "restore" from meaning "steal". The read is + // bounded at 10 s and the create panel is rendered ABOVE the loading chain, + // so it stays interactive for the whole wait: a user who opens "+ New Board" + // and starts typing during a hung retry had the caret yanked back to the + // rebuilt button when the read finally failed, and their next Space or Enter + // re-fired Retry instead of typing (#2689 item 6). Focus is only put back + // when it was actually LOST — `document.activeElement` null or , which + // is where the browser leaves it after the activated button unmounts. await nextTick() - retryButton.value?.focus() + const focused = document.activeElement + if (focused === null || focused === document.body) { + retryButton.value?.focus() + } } async function createBoard() { From 6866899be39405262ed5381801397c433e7c491b Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Sat, 5 Sep 2026 19:35:18 +0100 Subject: [PATCH 2/4] docs(boards): make the throttle and guarded-clear comments say what the code does The throttle docblocks in boardCrudStore.ts, BoardsListView.vue and boardCrudStore.spec.ts claimed a retry after a failed read was never blocked by the window. A stamp left by an earlier success survives every later failure, including a filtered read's failure that writes the shared error, so only force guarantees a request and the retry path always forces. The comment beside the guarded clear now names the collapsed-copy collision: every client timeout maps to one catalog string and every offline failure to axios's Network Error, so two surfaces can write byte-identical messages and a message compare cannot separate them. The store spec's forced-read comment no longer promises a skeleton over an assertion every settled read satisfies; it names the request count as the load-bearing assertion and points at the view spec that proves the skeleton. No behaviour change. Refs #2689 --- .../src/store/board/boardCrudStore.ts | 33 +++++++++++++++---- .../tests/store/board/boardCrudStore.spec.ts | 21 ++++++++---- .../taskdeck-web/src/views/BoardsListView.vue | 24 ++++++++------ 3 files changed, 54 insertions(+), 24 deletions(-) diff --git a/frontend/taskdeck-web/src/store/board/boardCrudStore.ts b/frontend/taskdeck-web/src/store/board/boardCrudStore.ts index 45f5e391f3..8db70a659f 100644 --- a/frontend/taskdeck-web/src/store/board/boardCrudStore.ts +++ b/frontend/taskdeck-web/src/store/board/boardCrudStore.ts @@ -28,14 +28,19 @@ export interface BoardListFetchOptions { * Skip the throttle window, and NOTHING else — an explicit user request for * fresh data, which the 5 s gap between mounts was never meant to answer. * - * The stamp is written only after a success, so a retry that follows a FAILED - * list read was never blocked by it. What this exists for is the stamp an - * EARLIER success left behind: `state.error` is shared by every board action, - * so a create/rename/archive failure two seconds after a good list read puts - * BoardsListView on its error branch with a Retry control, and without this - * the click returned here before touching `loading` or issuing a request — + * The stamp is written only after a success, but that is not the same as + * "a failure reopens the window": a stamp an EARLIER success left behind + * outlives every later failure, so the window is open for the whole 5 s + * regardless of what happened in between. Two ways in: `state.error` is + * shared by every board action, so a create/rename/archive failure two + * seconds after a good list read puts BoardsListView on its error branch + * with a Retry control; and a FILTERED list read (the activity selector's + * `includeArchived` one) writes the same shared `error` when it fails while + * leaving the unfiltered stamp intact. Only `force` guarantees that a + * request goes out — which is why the retry path always passes it. Without + * it the click returned here before touching `loading` or issuing a request: * no skeleton, no request, a dead button until the window passed (#2689 - * round-2 finding 1). + * round-2 finding 1, docblock corrected in #2689 item 7). * * Deliberately NOT a bypass of the in-flight share: joining a read that is * already on the wire is the correct answer to a second caller, and forcing @@ -174,6 +179,20 @@ export function createBoardCrudActions(state: BoardState, helpers: BoardHelpers) // error still identical to the one this path observed. The marker is // dropped on any current-generation success, matched or not, so a stale // message can never authorise a later clear. + // + // The documented limitation, at the same granularity as that + // precedent: the guard compares MESSAGES, and the copy collapses. Every + // client-side timeout maps to the one `boards.error.timeout` string and + // every offline failure to axios's "Network Error", so two different + // surfaces routinely produce byte-identical text and this comparison + // cannot tell them apart. Concretely: a list read times out, the user + // submits the create form, `createBoard` times out during the forced + // retry and writes the same sentence, and the retry's success then + // clears an alert the list read did not raise. It is not exotic — it is + // the ordinary offline case. Distinguishing them needs an owner tag on + // the error surface rather than a string compare, which is a wider + // change than this seam (#2689 item 8); the create's toast survives + // either way, so the failure is still reported. const listReadErrorToClear = lastListReadError lastListReadError = null if (listReadErrorToClear !== null && state.error.value === listReadErrorToClear) { diff --git a/frontend/taskdeck-web/src/tests/store/board/boardCrudStore.spec.ts b/frontend/taskdeck-web/src/tests/store/board/boardCrudStore.spec.ts index 0abfc48e48..88c635fba4 100644 --- a/frontend/taskdeck-web/src/tests/store/board/boardCrudStore.spec.ts +++ b/frontend/taskdeck-web/src/tests/store/board/boardCrudStore.spec.ts @@ -187,12 +187,14 @@ describe('boardCrudStore', () => { }) // #2689 round-2 finding 1. The throttle stamp is written only after a - // SUCCESS, so a retry following a failed read was never blocked by it — but - // the stamp an EARLIER success left behind is a different matter. `error` - // is shared by every board action, so the boards list can be sitting on its - // error branch with a live Retry control (a create/rename/archive failure) - // while this window is still open. An explicit retry has to get through; - // an ordinary mount still must not. + // SUCCESS, but a later failure does not reopen the window: the stamp an + // EARLIER success left behind survives it, including a filtered read's + // failure, which writes the shared `error` without touching the unfiltered + // stamp. Only `force` guarantees a request. `error` is shared by every + // board action, so the boards list can be sitting on its error branch with + // a live Retry control (a create/rename/archive failure) while this window + // is still open. An explicit retry has to get through; an ordinary mount + // still must not. (Comment corrected in #2689 item 7.) it('lets a forced read through the throttle window while an unforced one is still skipped', async () => { vi.useFakeTimers() mockBoardsApi.getBoards.mockResolvedValue([{ id: 'board-1', name: 'My Board' }]) @@ -206,7 +208,12 @@ describe('boardCrudStore', () => { await fetchBoards() expect(mockBoardsApi.getBoards).toHaveBeenCalledTimes(1) - // Same window, forced: a real request, and the skeleton the view needs. + // Same window, forced: a real request goes out. The request count is the + // load-bearing assertion here; `loading` back at false only says the read + // settled, which every settled read satisfies (#2689 item 10). That the + // view actually SHOWS a skeleton while a forced retry is in flight is + // proven in BoardsListView.spec.ts, "forces past the throttle window when + // the alert came from another action after a good read". await fetchBoards(undefined, false, { force: true }) expect(mockBoardsApi.getBoards).toHaveBeenCalledTimes(2) expect(state.loading.value).toBe(false) diff --git a/frontend/taskdeck-web/src/views/BoardsListView.vue b/frontend/taskdeck-web/src/views/BoardsListView.vue index 3814c74d15..955b3b33f7 100644 --- a/frontend/taskdeck-web/src/views/BoardsListView.vue +++ b/frontend/taskdeck-web/src/views/BoardsListView.vue @@ -72,16 +72,20 @@ onMounted(() => { /** * The Retry click. `force` skips the store's throttle window and nothing else. * - * The stamp is written only after a success, so a retry that follows a FAILED - * list read was never blocked by it — the earlier docblock here stated that as - * if it settled the question, and it does not. `state.error` is shared by every - * board action, so a create/rename/archive failure two seconds after a good - * list read puts this view on its error branch with a live Retry button while - * the throttle window from THAT success is still open. Unforced, the click - * returned inside the store before `loading` was touched or any request was - * made: no skeleton, no request, a dead button until the window passed (#2689 - * round-2 finding 1). The in-flight share is still respected — `force` does not - * bypass it, so a click during a read already on the wire joins that read. + * The stamp is written only after a success, but a failure does not reopen the + * window: a stamp an earlier success left behind survives every later failure, + * including a filtered read's (the activity selector's `includeArchived` read + * writes the same shared `state.error` without touching the unfiltered stamp). + * So only `force` guarantees that a request goes out, and this path always + * forces. `state.error` is shared by every board action, so a create/rename/ + * archive failure two seconds after a good list read puts this view on its + * error branch with a live Retry button while the throttle window from THAT + * success is still open. Unforced, the click returned inside the store before + * `loading` was touched or any request was made: no skeleton, no request, a + * dead button until the window passed (#2689 round-2 finding 1; this docblock + * corrected in #2689 item 7). The in-flight share is still respected — `force` + * does not bypass it, so a click during a read already on the wire joins that + * read. */ async function retryLoad() { await loadBoards({ force: true }) From 6e9eaa3c6bc1ac98b3543a05417a0f86b91022ce Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Sat, 5 Sep 2026 19:37:29 +0100 Subject: [PATCH 3/4] refactor(boards): give the boards list the store's own list-option type BoardListFetchOptions is now re-exported from store/board/index.ts beside BoardFetchOptions, and BoardsListView imports it type-only instead of declaring an inline { force?: boolean }. A second list option now propagates to the view's load signature instead of being silently dropped. Refs #2689 --- frontend/taskdeck-web/src/store/board/index.ts | 6 +++++- frontend/taskdeck-web/src/views/BoardsListView.vue | 7 ++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/frontend/taskdeck-web/src/store/board/index.ts b/frontend/taskdeck-web/src/store/board/index.ts index 0009e4975b..e7b03a0137 100644 --- a/frontend/taskdeck-web/src/store/board/index.ts +++ b/frontend/taskdeck-web/src/store/board/index.ts @@ -3,7 +3,11 @@ export type { CardFilters, BoardState } from './boardState' export { createBoardHelpers } from './boardStoreHelpers' export type { BoardHelpers } from './boardStoreHelpers' export { createBoardCrudActions } from './boardCrudStore' -export type { BoardFetchIntent, BoardFetchOptions } from './boardCrudStore' +export type { + BoardFetchIntent, + BoardFetchOptions, + BoardListFetchOptions, +} from './boardCrudStore' export { createColumnActions } from './columnStore' export { createCardActions } from './cardStore' export { createCardCommentActions } from './cardCommentStore' diff --git a/frontend/taskdeck-web/src/views/BoardsListView.vue b/frontend/taskdeck-web/src/views/BoardsListView.vue index 955b3b33f7..5d659a27bc 100644 --- a/frontend/taskdeck-web/src/views/BoardsListView.vue +++ b/frontend/taskdeck-web/src/views/BoardsListView.vue @@ -3,6 +3,11 @@ import { computed, nextTick, onMounted, ref } from 'vue' import { useRouter } from 'vue-router' import { useI18n } from 'vue-i18n' import { useBoardStore } from '../store/boardStore' +// Type-only, so the barrel adds no runtime import here: the view's load +// signature is the store's own list-option type rather than a hand-copied +// `{ force?: boolean }` that a second option would silently leave behind +// (#2689 item 9). +import type { BoardListFetchOptions } from '../store/board' import { logError } from '../utils/errorReporting' import { TdSkeleton } from '../components/ui' import PaperHLBtn from '../components/paper/PaperHLBtn.vue' @@ -58,7 +63,7 @@ function formatCreatedAt(createdAt: string): string { * retry layer, and without a control the alert stayed until the user navigated * away and back (#2689 item 1). */ -async function loadBoards(options: { force?: boolean } = {}) { +async function loadBoards(options: BoardListFetchOptions = {}) { // Catch the rethrown error — boardStore.error is already set by handleApiError // so the template can display it. Without this catch, Vue treats the unhandled // rejection as a lifecycle-hook error and may tear down the component. From a5bbcbc2ee9ac6d9724f057dccc0b8fd631e6ca1 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Sat, 5 Sep 2026 19:48:59 +0100 Subject: [PATCH 4/4] docs(boards): correct the round-2 wording in the throttle and collision comments Review round 2 on PR #2703, three findings, all in the comments this PR exists to make true. No code line changes. MEDIUM: the collision example named createBoard timing out, which cannot happen. boardsApi.createBoard is a bare http.post and the axios instance sets no default timeout, so a mutation never produces boards.error.timeout. The example is now the offline path, where any failure without a response yields axios's Network Error for reads and mutations alike, and the timeout half is attributed to the two bounded reads that can actually write that string. The comment now says outright which actor is not available. LOW: only force guarantees a request was false, since the in-flight share check runs before the throttle and is deliberately not skipped by force, and demo mode returns without a request. All three copies now say only force gets past the throttle, the in-flight share and demo mode still apply. LOW: leaving the unfiltered stamp intact held only for a filtered failure. A filtered success writes lastFetchBoardsAt like any other success, since that assignment is not gated on isFilteredRequest. All three copies now separate the two cases. Refs #2689 --- .../src/store/board/boardCrudStore.ts | 49 +++++++++++++------ .../tests/store/board/boardCrudStore.spec.ts | 14 +++--- .../taskdeck-web/src/views/BoardsListView.vue | 24 ++++----- 3 files changed, 54 insertions(+), 33 deletions(-) diff --git a/frontend/taskdeck-web/src/store/board/boardCrudStore.ts b/frontend/taskdeck-web/src/store/board/boardCrudStore.ts index 8db70a659f..120616c30a 100644 --- a/frontend/taskdeck-web/src/store/board/boardCrudStore.ts +++ b/frontend/taskdeck-web/src/store/board/boardCrudStore.ts @@ -35,12 +35,17 @@ export interface BoardListFetchOptions { * shared by every board action, so a create/rename/archive failure two * seconds after a good list read puts BoardsListView on its error branch * with a Retry control; and a FILTERED list read (the activity selector's - * `includeArchived` one) writes the same shared `error` when it fails while - * leaving the unfiltered stamp intact. Only `force` guarantees that a - * request goes out — which is why the retry path always passes it. Without - * it the click returned here before touching `loading` or issuing a request: - * no skeleton, no request, a dead button until the window passed (#2689 - * round-2 finding 1, docblock corrected in #2689 item 7). + * `includeArchived` one) writes the same shared `error` when it fails, and + * a filtered FAILURE leaves the stamp untouched — though a filtered success + * writes it like any other success, since `lastFetchBoardsAt` below is not + * gated on `isFilteredRequest`. + * + * So only `force` gets past the THROTTLE; the in-flight share and demo mode + * still apply, which is why this is a skipped window rather than a + * guaranteed request. That is enough for the retry path, which always passes + * it. Without it the click returned here before touching `loading` or + * issuing a request: no skeleton, no request, a dead button until the window + * passed (#2689 round-2 finding 1, docblock corrected in #2689 item 7). * * Deliberately NOT a bypass of the in-flight share: joining a read that is * already on the wire is the correct answer to a second caller, and forcing @@ -183,16 +188,28 @@ export function createBoardCrudActions(state: BoardState, helpers: BoardHelpers) // The documented limitation, at the same granularity as that // precedent: the guard compares MESSAGES, and the copy collapses. Every // client-side timeout maps to the one `boards.error.timeout` string and - // every offline failure to axios's "Network Error", so two different - // surfaces routinely produce byte-identical text and this comparison - // cannot tell them apart. Concretely: a list read times out, the user - // submits the create form, `createBoard` times out during the forced - // retry and writes the same sentence, and the retry's success then - // clears an alert the list read did not raise. It is not exotic — it is - // the ordinary offline case. Distinguishing them needs an owner tag on - // the error surface rather than a string compare, which is a wider - // change than this seam (#2689 item 8); the create's toast survives - // either way, so the failure is still reported. + // every offline failure to axios's "Network Error" (no response, so + // `getErrorMessage` falls through to `err.message`), for reads and + // mutations alike, so two different surfaces routinely produce + // byte-identical text and this comparison cannot tell them apart. + // + // Concretely, offline: this list read fails with "Network Error", the + // user submits the create form, `createBoard` fails with the same two + // words during the forced retry, and the retry's success then clears an + // alert the list read did not raise. The timeout string collides the + // same way between the two BOUNDED reads — this one and the detail read + // in `startBoardFetch`, both carrying `BOARD_REQUEST_TIMEOUT_MS` — since + // both write `boards.error.timeout` into the one shared `error`. Note + // which actor is NOT available for that half: `boardsApi.createBoard` + // is a bare `http.post` and the axios instance sets no default timeout + // (see the bound's own comment above), so a mutation can never produce + // the timeout string — only the offline string. + // + // It is not exotic; offline is the ordinary case. Distinguishing them + // needs an owner tag on the error surface rather than a string compare, + // which is a wider change than this seam (#2689 item 8); the losing + // surface's toast survives either way, so the failure is still + // reported. const listReadErrorToClear = lastListReadError lastListReadError = null if (listReadErrorToClear !== null && state.error.value === listReadErrorToClear) { diff --git a/frontend/taskdeck-web/src/tests/store/board/boardCrudStore.spec.ts b/frontend/taskdeck-web/src/tests/store/board/boardCrudStore.spec.ts index 88c635fba4..272ce2c0bd 100644 --- a/frontend/taskdeck-web/src/tests/store/board/boardCrudStore.spec.ts +++ b/frontend/taskdeck-web/src/tests/store/board/boardCrudStore.spec.ts @@ -189,12 +189,14 @@ describe('boardCrudStore', () => { // #2689 round-2 finding 1. The throttle stamp is written only after a // SUCCESS, but a later failure does not reopen the window: the stamp an // EARLIER success left behind survives it, including a filtered read's - // failure, which writes the shared `error` without touching the unfiltered - // stamp. Only `force` guarantees a request. `error` is shared by every - // board action, so the boards list can be sitting on its error branch with - // a live Retry control (a create/rename/archive failure) while this window - // is still open. An explicit retry has to get through; an ordinary mount - // still must not. (Comment corrected in #2689 item 7.) + // failure, which writes the shared `error` while leaving the stamp + // untouched (a filtered SUCCESS writes it like any other success). Only + // `force` gets past the throttle — the in-flight share and demo mode still + // apply. `error` is shared by every board action, so the boards list can be + // sitting on its error branch with a live Retry control (a + // create/rename/archive failure) while this window is still open. An + // explicit retry has to get through; an ordinary mount still must not. + // (Comment corrected in #2689 item 7.) it('lets a forced read through the throttle window while an unforced one is still skipped', async () => { vi.useFakeTimers() mockBoardsApi.getBoards.mockResolvedValue([{ id: 'board-1', name: 'My Board' }]) diff --git a/frontend/taskdeck-web/src/views/BoardsListView.vue b/frontend/taskdeck-web/src/views/BoardsListView.vue index 5d659a27bc..fcae9d5f5c 100644 --- a/frontend/taskdeck-web/src/views/BoardsListView.vue +++ b/frontend/taskdeck-web/src/views/BoardsListView.vue @@ -80,17 +80,19 @@ onMounted(() => { * The stamp is written only after a success, but a failure does not reopen the * window: a stamp an earlier success left behind survives every later failure, * including a filtered read's (the activity selector's `includeArchived` read - * writes the same shared `state.error` without touching the unfiltered stamp). - * So only `force` guarantees that a request goes out, and this path always - * forces. `state.error` is shared by every board action, so a create/rename/ - * archive failure two seconds after a good list read puts this view on its - * error branch with a live Retry button while the throttle window from THAT - * success is still open. Unforced, the click returned inside the store before - * `loading` was touched or any request was made: no skeleton, no request, a - * dead button until the window passed (#2689 round-2 finding 1; this docblock - * corrected in #2689 item 7). The in-flight share is still respected — `force` - * does not bypass it, so a click during a read already on the wire joins that - * read. + * writes the same shared `state.error` when it FAILS without touching the + * stamp; a filtered success writes the stamp like any other success). + * So only `force` gets past the THROTTLE — the in-flight share and demo mode + * still apply, so it is a skipped window rather than a guaranteed request — + * and this path always forces. `state.error` is shared by every board action, + * so a create/rename/archive failure two seconds after a good list read puts + * this view on its error branch with a live Retry button while the throttle + * window from THAT success is still open. Unforced, the click returned inside + * the store before `loading` was touched or any request was made: no skeleton, + * no request, a dead button until the window passed (#2689 round-2 finding 1; + * this docblock corrected in #2689 item 7). The in-flight share is still + * respected — `force` does not bypass it, so a click during a read already on + * the wire joins that read. */ async function retryLoad() { await loadBoards({ force: true })