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:
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user