fix(desktop): stop an entered project refetching on every tree refresh (#77591)
Every sessions.changed and window focus refreshes projects.tree, and each refresh handed the sidebar a new array. The entered project keyed its projects.project_sessions refetch on that array, so every refresh paid for a full hydrated tree build on the backend (seconds over a remote gateway) and rebuilt the lanes. The tree now keeps unchanged nodes by reference, the drill-in refetches only when its own overview node changes, an unchanged answer keeps its reference, and a background refetch no longer reports loading.
This commit is contained in:
@@ -3,11 +3,54 @@ import { afterEach, expect, it, vi } from 'vitest'
|
||||
|
||||
import { fetchProjectSessions } from '@/store/projects'
|
||||
|
||||
import type { SidebarProjectTree } from './projects/workspace-groups'
|
||||
import { useEnteredProjectSessions } from './use-entered-project-sessions'
|
||||
|
||||
vi.mock('@/store/projects', () => ({ fetchProjectSessions: vi.fn() }))
|
||||
afterEach(cleanup)
|
||||
|
||||
const node = (id: string, sessionCount = 0): SidebarProjectTree => ({
|
||||
id,
|
||||
label: id,
|
||||
path: `/${id}`,
|
||||
repos: [],
|
||||
sessionCount
|
||||
})
|
||||
|
||||
// #77591 D: over a remote gateway every `projects.project_sessions` call is a
|
||||
// full hydrated tree build, and the tree refreshes on every sessions.changed
|
||||
// and window focus. Only a change to the entered project may refetch it.
|
||||
it('refetches only when the entered project changes, and keeps an unchanged result', async () => {
|
||||
const fetched = vi.mocked(fetchProjectSessions)
|
||||
fetched.mockReset()
|
||||
fetched.mockImplementation(async id => ({ ...node(id, 1), repos: [] }))
|
||||
|
||||
// `$projectTree` keeps unchanged nodes by reference across refreshes, so a
|
||||
// refresh that only touched another project (or nothing at all) is a new
|
||||
// array holding the same entered node.
|
||||
const enteredNode = node('a', 1)
|
||||
|
||||
const { result, rerender } = renderHook(({ tree }) => useEnteredProjectSessions('a', true, tree, 'default'), {
|
||||
initialProps: { tree: [enteredNode, node('b')] }
|
||||
})
|
||||
|
||||
await waitFor(() => expect(result.current.project?.id).toBe('a'))
|
||||
const first = result.current.project
|
||||
|
||||
rerender({ tree: [enteredNode, node('b', 5)] })
|
||||
rerender({ tree: [enteredNode, node('b', 5)] })
|
||||
await act(async () => {})
|
||||
expect(fetched).toHaveBeenCalledTimes(1)
|
||||
|
||||
// The entered project itself changed: refetch in the background, with no
|
||||
// skeleton, and keep the same object when the answer is unchanged.
|
||||
rerender({ tree: [node('a', 2), node('b', 5)] })
|
||||
expect(result.current.loading).toBe(false)
|
||||
await waitFor(() => expect(fetched).toHaveBeenCalledTimes(2))
|
||||
await act(async () => {})
|
||||
expect(result.current.project).toBe(first)
|
||||
})
|
||||
|
||||
it('ignores departed drill-ins and clears failure on retry', async () => {
|
||||
let failOld!: (error: Error) => void
|
||||
vi.mocked(fetchProjectSessions)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { replaceEqualDeep } from '@tanstack/react-query'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { fetchProjectSessions } from '@/store/projects'
|
||||
@@ -17,6 +18,12 @@ export function useEnteredProjectSessions(
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [retryToken, setRetryToken] = useState(0)
|
||||
|
||||
// Refetch when the entered project's own overview node changes, not on every
|
||||
// tree refresh: each `projects.project_sessions` call hydrates the whole tree
|
||||
// on the backend, which takes seconds over a remote gateway (#77591). The
|
||||
// tree keeps unchanged nodes by reference, so this is stable across no-ops.
|
||||
const enteredNode = projectId ? treeRevision.find(node => node.id === projectId) : undefined
|
||||
|
||||
useEffect(() => {
|
||||
setProject(null)
|
||||
}, [projectId, scope])
|
||||
@@ -36,7 +43,8 @@ export function useEnteredProjectSessions(
|
||||
void fetchProjectSessions(projectId)
|
||||
.then(next => {
|
||||
if (!cancelled) {
|
||||
setProject(next)
|
||||
// An unchanged answer keeps its reference, so the lanes don't rebuild.
|
||||
setProject(current => replaceEqualDeep(current, next))
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -53,7 +61,9 @@ export function useEnteredProjectSessions(
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [projectId, ready, treeRevision, scope, retryToken])
|
||||
}, [projectId, ready, enteredNode, scope, retryToken])
|
||||
|
||||
return { project, failed, loading, retry: () => setRetryToken(token => token + 1) }
|
||||
// A background refetch keeps painting the rows it has; only a drill-in with
|
||||
// nothing loaded yet reports loading (the sidebar shows skeletons for it).
|
||||
return { project, failed, loading: loading && !project, retry: () => setRetryToken(token => token + 1) }
|
||||
}
|
||||
|
||||
@@ -166,6 +166,35 @@ describe('projects RPC profile forwarding', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps unchanged project nodes by reference across tree refreshes (#77591)', async () => {
|
||||
const payload = () => ({
|
||||
active_id: null,
|
||||
projects: [
|
||||
{ id: 'p_a', label: 'a', path: '/a', repos: [], sessionCount: 1, sessionIds: ['s1'] },
|
||||
{ id: 'p_b', label: 'b', path: '/b', repos: [], sessionCount: 0, sessionIds: [] }
|
||||
],
|
||||
scoped_session_ids: ['s1']
|
||||
})
|
||||
|
||||
const next = payload()
|
||||
next.projects[1] = { ...next.projects[1], sessionCount: 1, sessionIds: ['s2'] }
|
||||
const request = vi.fn().mockResolvedValueOnce(payload()).mockResolvedValueOnce(payload()).mockResolvedValueOnce(next)
|
||||
const gateway = { connectionState: 'open', request }
|
||||
activeGateway.mockReturnValue(gateway as never)
|
||||
gatewayAtom.set(gateway as never)
|
||||
|
||||
await refreshProjectTree()
|
||||
const first = $projectTree.get()
|
||||
|
||||
await refreshProjectTree()
|
||||
expect($projectTree.get()).toBe(first)
|
||||
|
||||
await refreshProjectTree()
|
||||
expect($projectTree.get()).not.toBe(first)
|
||||
expect($projectTree.get()[0]).toBe(first[0])
|
||||
expect($projectTree.get()[1].sessionIds).toEqual(['s2'])
|
||||
})
|
||||
|
||||
it('skips project reads in the all-profiles view rather than forwarding its sentinel', async () => {
|
||||
const request = vi.fn()
|
||||
const gateway = { connectionState: 'open', request }
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { replaceEqualDeep } from '@tanstack/react-query'
|
||||
import { atom, computed } from 'nanostores'
|
||||
|
||||
import type { NewSessionPlacement } from '@/app/chat/new-session-drag'
|
||||
@@ -428,7 +429,10 @@ let projectTreeRefreshGeneration = 0
|
||||
|
||||
function applyProjectTreePayload(res: ProjectTreePayload): void {
|
||||
const scoped = new Set(res.scoped_session_ids ?? [])
|
||||
$projectTree.set(res.projects ?? [])
|
||||
// The tree refreshes on every sessions.changed and window focus, and most of
|
||||
// those answers are unchanged. Keep unchanged nodes by reference so the
|
||||
// entered project doesn't refetch and rebuild on a no-op (#77591).
|
||||
$projectTree.set(replaceEqualDeep($projectTree.get(), res.projects ?? []))
|
||||
$activeProjectId.set(res.active_id ?? null)
|
||||
const tombstones = $removedSessionIds.get()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user