fix(desktop): discard file tree reads from obsolete roots

This commit is contained in:
liuzikaii
2026-10-01 09:25:53 +08:00
committed by brooklyn!
parent 4b8a43719b
commit fb2db65eab
2 changed files with 121 additions and 2 deletions

View File

@@ -271,6 +271,111 @@ describe('useProjectTree', () => {
expect(readDir).toHaveBeenCalledTimes(2)
})
it.each((['child', 'targeted', 'full'] as const).flatMap(read => [false, true].map(refresh => ({ read, refresh }))))(
'keeps $read results within the root generation (refresh=$refresh)',
async ({ read, refresh }) => {
const src = { name: 'src', path: '/p/src', isDirectory: true }
const fresh = { name: 'fresh.txt', path: '/p/fresh.txt', isDirectory: false }
let resolveOld!: (value: HermesReadDirResult) => void
const oldRead = new Promise<HermesReadDirResult>(resolve => {
resolveOld = resolve
})
readDir.mockResolvedValueOnce(ok([src]))
readDir.mockImplementationOnce(() => oldRead)
readDir.mockImplementation(async path =>
ok(path === '/p/src' ? [{ ...fresh, path: '/p/src/fresh.txt' }] : [src, fresh])
)
const { result } = renderHook(() => useProjectTree('/p'))
await waitFor(() => expect(result.current.data.map(node => node.name)).toEqual(['src']))
act(() => {
if (read === 'child') {
void result.current.loadChildren('/p/src')
} else {
notifyWorkspaceChanged(read === 'targeted' ? '/p/changed.txt' : undefined)
}
})
await waitFor(() => expect(readDir).toHaveBeenCalledTimes(2))
if (refresh) {
await act(async () => {
await result.current.refreshRoot()
})
expect(result.current.data.map(node => node.name)).toEqual(['src', 'fresh.txt'])
if (read === 'child') {
await act(async () => {
await result.current.loadChildren('/p/src')
})
}
}
await act(async () => {
resolveOld(
ok([{ name: 'stale.txt', path: read === 'child' ? '/p/src/stale.txt' : '/p/stale.txt', isDirectory: false }])
)
})
if (refresh) {
expect(result.current.data.map(node => node.name)).toEqual(['src', 'fresh.txt'])
if (read === 'child') {
expect(result.current.data[0]?.children?.map(node => node.name)).toEqual(['fresh.txt'])
}
} else if (read === 'child') {
expect(result.current.data[0]?.children?.map(node => node.name)).toEqual(['stale.txt'])
} else {
expect(result.current.data.map(node => node.name)).toEqual(['stale.txt'])
}
}
)
it('keeps a refreshed folder read in flight when its predecessor finishes', async () => {
const src = { name: 'src', path: '/p/src', isDirectory: true }
let finishOld!: (value: HermesReadDirResult) => void
let finishNew!: (value: HermesReadDirResult) => void
const oldRead = new Promise<HermesReadDirResult>(resolve => {
finishOld = resolve
})
const newRead = new Promise<HermesReadDirResult>(resolve => {
finishNew = resolve
})
readDir.mockResolvedValueOnce(ok([src]))
readDir.mockImplementationOnce(() => oldRead)
readDir.mockResolvedValueOnce(ok([src]))
readDir.mockImplementationOnce(() => newRead)
readDir.mockResolvedValue(ok([]))
const { result } = renderHook(() => useProjectTree('/p'))
await waitFor(() => expect(result.current.data[0]?.name).toBe('src'))
act(() => {
void result.current.loadChildren(src.path)
})
await waitFor(() => expect(readDir).toHaveBeenCalledTimes(2))
await act(async () => {
await result.current.refreshRoot()
})
act(() => {
void result.current.loadChildren(src.path)
})
await waitFor(() => expect(readDir).toHaveBeenCalledTimes(4))
await act(async () => {
finishOld(ok([]))
})
act(() => {
void result.current.loadChildren(src.path)
})
await act(async () => {
finishNew(ok([{ name: 'new.txt', path: '/p/src/new.txt', isDirectory: false }]))
})
expect(readDir).toHaveBeenCalledTimes(4)
expect(result.current.data[0]?.children?.map(node => node.name)).toEqual(['new.txt'])
})
it('refreshRoot reloads the root and clears prior error', async () => {
readDir.mockResolvedValueOnce({ entries: [], error: 'EACCES' })
readDir.mockResolvedValueOnce(ok([{ name: 'README.md', path: '/p/README.md', isDirectory: false }]))

View File

@@ -294,7 +294,15 @@ async function revalidateTree(
): Promise<void> {
const state = $projectTree.get()
if (!cwd || state.cwd !== cwd || !state.loaded || desktopFsCacheKey() !== connectionKey) {
// The workspace effect runs before the connection-change root effect. Do
// not start a new backend's read using the previous backend's loaded tree.
if (
!cwd ||
state.cwd !== cwd ||
!state.loaded ||
lastConnectionKey !== connectionKey ||
desktopFsCacheKey() !== connectionKey
) {
return
}
@@ -319,6 +327,7 @@ async function revalidateTree(
setProjectTree(latest => {
if (
latest.cwd !== cwd ||
latest.requestId !== state.requestId ||
!latest.loaded ||
desktopFsCacheKey() !== connectionKey ||
showsIgnoredFiles(rootPath) !== filterAtRead
@@ -375,6 +384,7 @@ async function revalidateTree(
setProjectTree(latest =>
latest.cwd === cwd &&
latest.requestId === state.requestId &&
latest.loaded &&
desktopFsCacheKey() === connectionKey &&
showsIgnoredFiles(rootPath) === filterAtRead
@@ -456,7 +466,10 @@ export function useProjectTree(cwd: string): UseProjectTreeResult {
const loadChildren = useCallback(
async (id: string) => {
const inflightKey = `${connectionKey}:${id}`
const requestId = $projectTree.get().requestId
// A root refresh can start a new read of this same folder. Its pending
// entry and result must not be released or replaced by the old read.
const inflightKey = `${connectionKey}:${requestId}:${id}`
if (!cwd || inflight.has(inflightKey)) {
return
@@ -493,6 +506,7 @@ export function useProjectTree(cwd: string): UseProjectTreeResult {
// show-ignored toggle must not land after it.
if (
current.cwd !== cwd ||
current.requestId !== requestId ||
desktopFsCacheKey() !== connectionKey ||
showsIgnoredFiles(rootPath) !== filterAtRead
) {