Skip to content

Commit 2aac878

Browse files
sshirokovclaude
andcommitted
fix(assistant): resolve race condition, loading state, and error feedback in refreshAssistants (janhq#8091)
- Add loading:true at start of refreshAssistants (matching pattern in other hooks) - Replace console.error with toast.error for user-facing error feedback - Replace useEffect-derived state with inline computed initialData - Add ref-based request ID guard to handleEditClick to prevent race conditions - Add unit tests for refreshAssistants: fetch success, loading:true timing, toast on failure Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent bf1b040 commit 2aac878

3 files changed

Lines changed: 74 additions & 22 deletions

File tree

web-app/src/hooks/__tests__/useAssistant.coverage.test.ts

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,24 @@
1-
import { describe, it, expect, beforeEach, vi } from 'vitest'
1+
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
22
import { act, renderHook } from '@testing-library/react'
33
import { useAssistant, defaultAssistant } from '../useAssistant'
44

5+
const mockToast = vi.fn()
6+
vi.mock('sonner', () => ({
7+
toast: {
8+
error: mockToast,
9+
},
10+
}))
11+
512
const mockCreateAssistant = vi.fn().mockResolvedValue(undefined)
613
const mockDeleteAssistant = vi.fn().mockResolvedValue(undefined)
14+
const mockGetAssistants = vi.fn().mockResolvedValue([defaultAssistant])
715

816
vi.mock('@/hooks/useServiceHub', () => ({
917
getServiceHub: () => ({
1018
assistants: () => ({
1119
createAssistant: mockCreateAssistant,
1220
deleteAssistant: mockDeleteAssistant,
21+
getAssistants: mockGetAssistants,
1322
}),
1423
}),
1524
}))
@@ -219,4 +228,51 @@ describe('useAssistant - coverage', () => {
219228
// currentAssistant should still be jan
220229
expect(result.current.currentAssistant?.id).toBe('jan')
221230
})
231+
232+
it('refreshAssistants should fetch fresh data and update state', async () => {
233+
const freshAssistants = [
234+
{ ...defaultAssistant, name: 'Fresh Jan' },
235+
{ id: 'a2', name: 'A2', avatar: '', description: '', instructions: '', created_at: 2, parameters: {} },
236+
]
237+
mockGetAssistants.mockResolvedValue(freshAssistants as any)
238+
239+
const { result } = renderHook(() => useAssistant())
240+
241+
expect(result.current.loading).toBe(true)
242+
243+
await act(async () => {
244+
await result.current.refreshAssistants()
245+
})
246+
247+
expect(result.current.assistants).toEqual(freshAssistants)
248+
expect(result.current.loading).toBe(false)
249+
})
250+
251+
it('refreshAssistants should set loading:true before fetch starts', async () => {
252+
const deferred = vi.fn().mockReturnValue(new Promise((resolve) => setTimeout(resolve, 100)))
253+
mockGetAssistants.mockReturnValue(deferred())
254+
255+
const { result } = renderHook(() => useAssistant())
256+
257+
expect(result.current.loading).toBe(true)
258+
})
259+
260+
it('refreshAssistants should show toast error on failure', async () => {
261+
mockGetAssistants.mockRejectedValue(new Error('Network error'))
262+
263+
const { result } = renderHook(() => useAssistant())
264+
265+
await act(async () => {
266+
await result.current.refreshAssistants()
267+
})
268+
269+
expect(mockToast).toHaveBeenCalledWith('Failed to refresh assistants', {
270+
description: 'Network error',
271+
})
272+
expect(result.current.loading).toBe(false)
273+
})
274+
275+
afterEach(() => {
276+
mockToast.mockClear()
277+
})
222278
})

web-app/src/hooks/useAssistant.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { getServiceHub } from '@/hooks/useServiceHub'
22
import { Assistant as CoreAssistant } from '@janhq/core'
33
import { create } from 'zustand'
4+
import { toast } from 'sonner'
45
import { localStorageKey } from '@/constants/localStorage'
56

67
interface AssistantState {
@@ -217,17 +218,19 @@ export const useAssistant = create<AssistantState>((set, get) => ({
217218
}
218219
},
219220
refreshAssistants: async () => {
221+
set({ loading: true })
220222
try {
221223
const assistants = await getServiceHub().assistants().getAssistants()
222224
if (assistants) {
223-
// Update the state with fresh assistants
224225
set({
225226
assistants,
226227
loading: false
227228
})
228229
}
229230
} catch (error) {
230-
console.error('Failed to refresh assistants:', error)
231+
toast.error('Failed to refresh assistants', {
232+
description: error instanceof Error ? error.message : String(error),
233+
})
231234
set({ loading: false })
232235
}
233236
},

web-app/src/routes/settings/assistant.tsx

Lines changed: 12 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { createFileRoute } from '@tanstack/react-router'
22
import { route } from '@/constants/routes'
3-
import { useState, useEffect } from 'react'
3+
import { useState, useCallback, useRef } from 'react'
44

55
import { useAssistant } from '@/hooks/useAssistant'
66

@@ -43,7 +43,7 @@ function AssistantContent() {
4343
const [editingKey, setEditingKey] = useState<string | null>(null)
4444
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
4545
const [deletingId, setDeletingId] = useState<string | null>(null)
46-
const [editingAssistant, setEditingAssistant] = useState<Assistant | null>(null)
46+
const refreshRequestRef = useRef(0)
4747

4848
const handleDelete = (id: string) => {
4949
setDeletingId(id)
@@ -68,27 +68,20 @@ function AssistantContent() {
6868
setEditingKey(null)
6969
}
7070

71-
const handleEditClick = (assistantId: string) => {
71+
const handleEditClick = useCallback((assistantId: string) => {
7272
// Before opening the edit dialog, refresh the assistants to get the latest data
73-
refreshAssistants().then(() => {
74-
setEditingKey(assistantId)
75-
setOpen(true)
76-
})
77-
}
73+
const requestId = ++refreshRequestRef.current
74+
refreshAssistants()
75+
.then(() => {
76+
if (refreshRequestRef.current !== requestId) return
77+
setEditingKey(assistantId)
78+
setOpen(true)
79+
})
80+
}, [])
7881

7982
const sortedAssistants = assistants.slice().sort((a, b) => a.created_at - b.created_at)
8083
const defaultAssistant = sortedAssistants.find((a) => a.id === defaultAssistantId)
8184

82-
// When editingKey changes, we need to get the latest assistant data
83-
useEffect(() => {
84-
if (editingKey) {
85-
const assistant = assistants.find(a => a.id === editingKey)
86-
setEditingAssistant(assistant || null)
87-
} else {
88-
setEditingAssistant(null)
89-
}
90-
}, [editingKey, assistants])
91-
9285
return (
9386
<div className="flex flex-col h-svh w-full">
9487
<HeaderPage>
@@ -215,7 +208,7 @@ function AssistantContent() {
215208
open={open}
216209
onOpenChange={setOpen}
217210
editingKey={editingKey}
218-
initialData={editingAssistant || undefined}
211+
initialData={editingKey ? assistants.find(a => a.id === editingKey) : undefined}
219212
onSave={handleSave}
220213
/>
221214
<DeleteAssistantDialog

0 commit comments

Comments
 (0)