fix(desktop): kanban queries fetch only while keyed to the routed connection

On a connection switch the request tag moves (applyActive →
setApiRequestConnection) before the descriptor publishes, and React re-keys
the kanban observers later still. The app-wide invalidations that fire in that
window (the connection twin, the profile twin) refetched observers still
sitting on the OUTGOING scope's key with the INCOMING tag — writing the new
gateway's boards/tasks under the old connection's cache key, which then
painted on the way back. Three fetches per query per switch, one of them
poison.

Install `enabled: routedToScope` as the `['kanban']` query default in bindApi:
a kanban query only fetches while its key's scope segment matches where a
request is routed right now (`host.activeConnectionId()`). Outgoing observers
are skipped; the incoming keys are already a cache miss and fetch once on
re-render. The drawer's two `enabled: !!id` sites compose it.

Also: the connection listener uses nanostores' previous value instead of a
mutable tracker; 'local' literal hoisted to LOCAL_SCOPE; duplicated rationale
comments cut.

Test: an observer on boardsKey('local') with the route moved to spark is not
refetched by invalidateQueries(); back on local it is. Red without the
default (`[null, 'spark']`), green with it.
This commit is contained in:
kshitijk4poor
2026-09-22 16:52:44 +05:30
committed by kshitij
parent 49f47ef64c
commit 944e017cbe
3 changed files with 87 additions and 30 deletions

View File

@@ -1,20 +1,28 @@
import { QueryObserver } from '@tanstack/react-query'
import { act, renderHook } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
// A board lives on ONE gateway. The kanban data layer follows the active
// connection: keys built during render change with it (so an observer is a
// clean cache miss on a switch), the selected slug is remembered per
// connection (the local pool keeps the bare key — the lib/connection-scoped.ts
// contract, and the slug picked before per-connection keys existed survives),
// and the events socket dials the new backend exactly once per switch.
// A board lives on ONE gateway; the kanban data layer follows the active
// connection. See the scope comments in ./api.ts.
const routed = vi.hoisted(() => ({ id: null as null | string }))
vi.mock('@/hermes', () => ({ setApiRequestProfile: vi.fn() }))
vi.mock('@/store/gateway', async importOriginal => ({
...(await importOriginal<Record<string, unknown>>()),
activeGatewayConnectionId: () => routed.id
}))
const { $boardSlug, bindApi, boardsKey, useKanbanScope } = await import('./api')
const { setConnection } = await import('@/store/session')
const { queryClient } = await import('@/lib/query-client')
const noopStorage = { get: <T>(_key: string, fallback: T) => fallback, remove: vi.fn(), set: vi.fn() }
afterEach(() => {
setConnection(null)
routed.id = null
queryClient.clear()
})
describe('kanban connection scope', () => {
@@ -77,4 +85,41 @@ describe('kanban connection scope', () => {
dispose()
})
it('an observer still keyed to the outgoing scope is not refetched onto the incoming backend', async () => {
const dispose = bindApi(
async () => ({}) as never,
noopStorage,
vi.fn(() => vi.fn())
)
const fetches: Array<null | string> = []
const observer = new QueryObserver(queryClient, {
queryFn: async () => {
fetches.push(routed.id)
return { boards: [] }
},
queryKey: boardsKey('local')
})
const unsubscribe = observer.subscribe(() => undefined)
await vi.waitFor(() => expect(observer.getCurrentResult().status).toBe('success'))
expect(fetches).toEqual([null])
// The request tag has moved to spark but React has not re-keyed the
// observer yet: the switch's invalidation must skip it.
routed.id = 'spark'
await queryClient.invalidateQueries()
expect(fetches).toEqual([null])
// Back on local the same observer is live again.
routed.id = null
await queryClient.invalidateQueries()
expect(fetches).toEqual([null, null])
unsubscribe()
dispose()
})
})

View File

@@ -63,6 +63,11 @@ export const $lanesByProfile = atom<boolean>(false)
* auto: empty lanes collapse to a rail, occupied lanes expand. Persisted. */
export const $collapsedLanes = atom<Record<string, boolean>>({})
/** Cache scope of the local pool — the SDK atom's own spelling. */
const LOCAL_SCOPE = 'local'
const KANBAN_KEY_ROOT = ['kanban'] as const
const BOARD_SLUG_KEY = 'boardSlug'
const INTRO_KEY = 'introDismissed'
const LANES_KEY = 'lanesByProfile'
@@ -74,13 +79,27 @@ const COLLAPSED_KEY = 'collapsedLanes'
* socket frames); rendering components use `useKanbanScope` so the keys they
* build during render recompute when the connection changes. */
export function kanbanConnectionScope(): string {
return host.state.connectionId.get() ?? 'local'
return host.state.connectionId.get() ?? LOCAL_SCOPE
}
export function useKanbanScope(): string {
return useValue(host.state.connectionId) ?? 'local'
return useValue(host.state.connectionId) ?? LOCAL_SCOPE
}
/** Where a request issued NOW is routed, as a cache scope. The request tag
* moves before the connection descriptor publishes, and React re-keys the
* observers later still — so between the two an observer can sit on the
* outgoing scope's key while a fetch would land on the incoming backend. */
const routedScope = (): string => host.activeConnectionId() ?? LOCAL_SCOPE
/** `enabled` for every kanban query: only fetch while the key's scope is the
* routed one. A switch's app-wide invalidation then leaves the outgoing
* observers alone (the incoming keys are already a cache miss) instead of
* writing the new gateway's payload under the old connection's key — which
* would paint on the way back. Installed as the `['kanban']` query default in
* `bindApi`; sites with their own `enabled` compose it. */
export const routedToScope = (query: { queryKey: readonly unknown[] }): boolean => query.queryKey[2] === routedScope()
/** One live `task_events` frame → precise cache invalidation: the board, plus
* each touched task's detail. The polls (8s board / 4s drawer) stay as the
* fallback — the socket just makes the board feel instant. */
@@ -116,14 +135,7 @@ interface Persisted<T> {
/** Bind the plugin's doors at register time and return a disposer the host
* runs on unload/disable — so nothing (store sync, socket) survives a toggle
* or duplicates on re-enable. The events socket is pinned to a board at
* handshake, so a board switch closes + reopens it.
*
* The events socket is ALSO re-opened when the active CONNECTION changes:
* `pluginSocket` resolves the backend only at connect time, so a socket left
* open across a switch keeps streaming the previous gateway's events against
* the new connection's request scope. The board slug re-hydrates from the
* per-connection storage key for the same reason — one gateway's slug pins a
* board the next gateway 404s on. */
* handshake, so a board switch closes + reopens it. */
export function bindApi(
r: Rest,
storage: PluginStorage,
@@ -135,6 +147,9 @@ export function bindApi(
bindCompletionNotify(r, notifyDoors?.t, notifyDoors?.os)
const unsubs: Array<() => void> = []
queryClient.setQueryDefaults(KANBAN_KEY_ROOT, { enabled: routedToScope })
unsubs.push(() => queryClient.setQueryDefaults(KANBAN_KEY_ROOT, {}))
// Hydrate an atom from storage and keep storage in sync with it.
const persist = <T>(atom: Persisted<T>, key: string, fallback: T) => {
atom.set(storage.get(key, fallback))
@@ -152,33 +167,31 @@ export function bindApi(
close = socket(slug ? `/events?board=${encodeURIComponent(slug)}` : '/events', data => onEventsFrame(slug, data))
}
// The local connection keeps the BARE key (same rule as lib/connection-scoped
// for single-backend users: byte-identical storage, and the slug they picked
// before per-connection keys existed survives the upgrade). Remotes are
// suffixed by registry id.
// The local connection keeps the BARE key (the bare-local rule of
// lib/connection-scoped: byte-identical storage for single-backend users, and
// the slug picked before per-connection keys existed survives the upgrade).
// Remotes are suffixed by registry id.
const slugStorageKey = () => {
const scope = kanbanConnectionScope()
return scope === 'local' ? BOARD_SLUG_KEY : `${BOARD_SLUG_KEY}.${scope}`
return scope === LOCAL_SCOPE ? BOARD_SLUG_KEY : `${BOARD_SLUG_KEY}.${scope}`
}
$boardSlug.set(storage.get(slugStorageKey(), ''))
unsubs.push($boardSlug.listen(slug => storage.set(slugStorageKey(), slug)))
open($boardSlug.get())
unsubs.push($boardSlug.listen(open))
let scope = kanbanConnectionScope()
unsubs.push(
host.state.connectionId.listen(() => {
host.state.connectionId.listen((next, prev) => {
// Query keys embed the scope, so the new connection is already a cache
// miss; only the LIVE bindings (socket, slug) follow it. The boot-time
// null → 'local' publish is the same scope, not a switch. A changed slug
// reopens the socket through the $boardSlug listener above; an unchanged
// slug still needs a dial because the backend behind it changed.
if (kanbanConnectionScope() === scope) {
if ((next ?? LOCAL_SCOPE) === (prev ?? LOCAL_SCOPE)) {
return
}
scope = kanbanConnectionScope()
const previous = $boardSlug.get()
$boardSlug.set(storage.get(slugStorageKey(), ''))
@@ -218,9 +231,7 @@ function withBoard(path: string, params: Record<string, string> = {}): string {
return qs ? `${path}?${qs}` : path
}
// ── query keys (connection- AND board-scoped so switching either is a clean
// cache miss — one gateway's boards/tasks must never paint under another
// connection's route) ─────────────────────────────────────────────────────────
// ── query keys (connection- and board-scoped; scope is always segment [2]) ────
/** Prefix matching every board query on one connection (all slugs, both
* archived views) — the mutation-settled invalidation target. */

View File

@@ -44,6 +44,7 @@ import {
profilesKey,
reassignTask,
reclaimTask,
routedToScope,
taskKey,
uploadAttachment,
useKanbanScope
@@ -559,7 +560,7 @@ export function TaskDrawer({
// Socket-invalidated (bindApi); the interval is only the socketless heartbeat.
const { data: detail, error } = useQuery({
enabled: !!id,
enabled: query => !!id && routedToScope(query),
queryFn: () => fetchTask(id!),
queryKey: taskKey(scope, slug, id ?? ''),
refetchInterval: 30_000
@@ -570,7 +571,7 @@ export function TaskDrawer({
const defaultAssignee = useDefaultAssignee()
const { data: log } = useQuery({
enabled: !!id,
enabled: query => !!id && routedToScope(query),
queryFn: () => fetchLog(id!),
queryKey: logKey(scope, slug, id ?? ''),
refetchInterval: running ? 3_000 : 15_000