From 1238efce135d211f260da6a2ab2d6400224ea03f Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 29 Jul 2026 02:05:43 -0500 Subject: [PATCH] fix(desktop): track kanban lane phase in state, not a mirrored ref MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit main landed a lint rule banning refs mirrored from reactive values in an effect — they lag a render and cause stale reads. The lane-collapse override tracker did exactly that with a counts ref. Hold the empty/non-empty signature in state instead. React bails out when it's unchanged, so a poll where no lane's emptiness moved costs no extra render, and the comparison always sees the current value. --- .../lib/keybinds/contributed-actions.test.ts | 2 +- apps/desktop/src/plugins/kanban/board.tsx | 29 ++++++++++++------- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src/lib/keybinds/contributed-actions.test.ts b/apps/desktop/src/lib/keybinds/contributed-actions.test.ts index e5eb72b149d..0b82242789f 100644 --- a/apps/desktop/src/lib/keybinds/contributed-actions.test.ts +++ b/apps/desktop/src/lib/keybinds/contributed-actions.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' -import { registry } from '@/contrib/registry' import { createPluginContext } from '@/contrib/plugin' +import { registry } from '@/contrib/registry' import { allKeybindActions, contributedKeybindHandler, KEYBINDS_AREA } from '@/lib/keybinds/actions' import { bindingsFor } from '@/store/keybinds' diff --git a/apps/desktop/src/plugins/kanban/board.tsx b/apps/desktop/src/plugins/kanban/board.tsx index a68f3fafdc1..fa53a26f969 100644 --- a/apps/desktop/src/plugins/kanban/board.tsx +++ b/apps/desktop/src/plugins/kanban/board.tsx @@ -1244,28 +1244,37 @@ export function KanbanBoardPage() { // override is dropped and auto takes over — so a drained lane collapses even // if it was manually expanded ages ago, while expanding an empty lane still // sticks for as long as it stays empty. - const laneCounts = useRef>(null) + // + // The phase is a string signature held in state, not a ref: React bails out + // when it's unchanged, so the common case (a poll where no lane's emptiness + // moved) costs no extra render, and nothing lags a render behind the value + // it mirrors. + const lanePhase = filtered + ? filtered.columns.map(col => `${col.name}:${col.tasks.length === 0 ? 'empty' : 'full'}`).join('|') + : null + + const [prevLanePhase, setPrevLanePhase] = useState(null) useEffect(() => { - if (!filtered) { + if (lanePhase === null || lanePhase === prevLanePhase) { return } - const counts = Object.fromEntries(filtered.columns.map(col => [col.name, col.tasks.length])) - const prev = laneCounts.current - laneCounts.current = counts + setPrevLanePhase(lanePhase) - if (!prev) { + if (prevLanePhase === null) { return } + const before = new Map(prevLanePhase.split('|').map(entry => entry.split(':') as [string, string])) const overrides = { ...$collapsedLanes.get() } let changed = false - for (const [name, count] of Object.entries(counts)) { - const before = prev[name] + for (const entry of lanePhase.split('|')) { + const [name, phase] = entry.split(':') + const was = before.get(name) - if (before !== undefined && (before === 0) !== (count === 0) && name in overrides) { + if (was !== undefined && was !== phase && name in overrides) { delete overrides[name] changed = true } @@ -1274,7 +1283,7 @@ export function KanbanBoardPage() { if (changed) { $collapsedLanes.set(overrides) } - }, [filtered]) + }, [lanePhase, prevLanePhase]) const toggleLane = (name: string, auto: boolean) => { const overrides = { ...laneOverrides }