diff --git a/.claude/skills/component-refactoring/SKILL.md b/.claude/skills/component-refactoring/SKILL.md
index ea695ea442..7006c382c8 100644
--- a/.claude/skills/component-refactoring/SKILL.md
+++ b/.claude/skills/component-refactoring/SKILL.md
@@ -187,7 +187,7 @@ const Template = useMemo(() => {
**When**: Component directly handles API calls, data transformation, or complex async operations.
-**Dify Convention**: Use `@tanstack/react-query` hooks from `web/service/use-*.ts` or create custom data hooks. Project is migrating from SWR to React Query.
+**Dify Convention**: Use `@tanstack/react-query` hooks from `web/service/use-*.ts` or create custom data hooks.
```typescript
// ❌ Before: API logic in component
diff --git a/web/app/(commonLayout)/layout.tsx b/web/app/(commonLayout)/layout.tsx
index 91bdb3f99a..a0ccde957d 100644
--- a/web/app/(commonLayout)/layout.tsx
+++ b/web/app/(commonLayout)/layout.tsx
@@ -1,5 +1,6 @@
import type { ReactNode } from 'react'
import * as React from 'react'
+import { AppInitializer } from '@/app/components/app-initializer'
import AmplitudeProvider from '@/app/components/base/amplitude'
import GA, { GaType } from '@/app/components/base/ga'
import Zendesk from '@/app/components/base/zendesk'
@@ -7,7 +8,6 @@ import GotoAnything from '@/app/components/goto-anything'
import Header from '@/app/components/header'
import HeaderWrapper from '@/app/components/header/header-wrapper'
import ReadmePanel from '@/app/components/plugins/readme-panel'
-import SwrInitializer from '@/app/components/swr-initializer'
import { AppContextProvider } from '@/context/app-context'
import { EventEmitterContextProvider } from '@/context/event-emitter'
import { ModalContextProvider } from '@/context/modal-context'
@@ -20,7 +20,7 @@ const Layout = ({ children }: { children: ReactNode }) => {
<>
-
+
@@ -38,7 +38,7 @@ const Layout = ({ children }: { children: ReactNode }) => {
-
+
>
)
}
diff --git a/web/app/account/(commonLayout)/layout.tsx b/web/app/account/(commonLayout)/layout.tsx
index f264441b86..e4125015d9 100644
--- a/web/app/account/(commonLayout)/layout.tsx
+++ b/web/app/account/(commonLayout)/layout.tsx
@@ -1,9 +1,9 @@
import type { ReactNode } from 'react'
import * as React from 'react'
+import { AppInitializer } from '@/app/components/app-initializer'
import AmplitudeProvider from '@/app/components/base/amplitude'
import GA, { GaType } from '@/app/components/base/ga'
import HeaderWrapper from '@/app/components/header/header-wrapper'
-import SwrInitor from '@/app/components/swr-initializer'
import { AppContextProvider } from '@/context/app-context'
import { EventEmitterContextProvider } from '@/context/event-emitter'
import { ModalContextProvider } from '@/context/modal-context'
@@ -15,7 +15,7 @@ const Layout = ({ children }: { children: ReactNode }) => {
<>
-
+
@@ -30,7 +30,7 @@ const Layout = ({ children }: { children: ReactNode }) => {
-
+
>
)
}
diff --git a/web/app/components/swr-initializer.tsx b/web/app/components/app-initializer.tsx
similarity index 80%
rename from web/app/components/swr-initializer.tsx
rename to web/app/components/app-initializer.tsx
index 31be6d62b5..0f710abf39 100644
--- a/web/app/components/swr-initializer.tsx
+++ b/web/app/components/app-initializer.tsx
@@ -3,7 +3,6 @@
import type { ReactNode } from 'react'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import { useCallback, useEffect, useState } from 'react'
-import { SWRConfig } from 'swr'
import {
EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION,
EDUCATION_VERIFYING_LOCALSTORAGE_ITEM,
@@ -11,12 +10,13 @@ import {
import { fetchSetupStatus } from '@/service/common'
import { resolvePostLoginRedirect } from '../signin/utils/post-login-redirect'
-type SwrInitializerProps = {
+type AppInitializerProps = {
children: ReactNode
}
-const SwrInitializer = ({
+
+export const AppInitializer = ({
children,
-}: SwrInitializerProps) => {
+}: AppInitializerProps) => {
const router = useRouter()
const searchParams = useSearchParams()
// Tokens are now stored in cookies, no need to check localStorage
@@ -69,20 +69,5 @@ const SwrInitializer = ({
})()
}, [isSetupFinished, router, pathname, searchParams])
- return init
- ? (
- new Map(),
- }}
- >
- {children}
-
- )
- : null
+ return init ? children : null
}
-
-export default SwrInitializer
diff --git a/web/app/components/base/chat/chat-with-history/hooks.spec.tsx b/web/app/components/base/chat/chat-with-history/hooks.spec.tsx
new file mode 100644
index 0000000000..32ef133453
--- /dev/null
+++ b/web/app/components/base/chat/chat-with-history/hooks.spec.tsx
@@ -0,0 +1,270 @@
+import type { ReactNode } from 'react'
+import type { ChatConfig } from '../types'
+import type { AppConversationData, AppData, AppMeta, ConversationItem } from '@/models/share'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { act, renderHook, waitFor } from '@testing-library/react'
+import { ToastProvider } from '@/app/components/base/toast'
+import {
+ fetchChatList,
+ fetchConversations,
+ generationConversationName,
+} from '@/service/share'
+import { shareQueryKeys } from '@/service/use-share'
+import { CONVERSATION_ID_INFO } from '../constants'
+import { useChatWithHistory } from './hooks'
+
+vi.mock('@/hooks/use-app-favicon', () => ({
+ useAppFavicon: vi.fn(),
+}))
+
+vi.mock('@/i18n-config/i18next-config', () => ({
+ changeLanguage: vi.fn().mockResolvedValue(undefined),
+}))
+
+const mockStoreState: {
+ appInfo: AppData | null
+ appMeta: AppMeta | null
+ appParams: ChatConfig | null
+} = {
+ appInfo: null,
+ appMeta: null,
+ appParams: null,
+}
+
+const useWebAppStoreMock = vi.fn((selector?: (state: typeof mockStoreState) => unknown) => {
+ return selector ? selector(mockStoreState) : mockStoreState
+})
+
+vi.mock('@/context/web-app-context', () => ({
+ useWebAppStore: (selector?: (state: typeof mockStoreState) => unknown) => useWebAppStoreMock(selector),
+}))
+
+vi.mock('../utils', async () => {
+ const actual = await vi.importActual('../utils')
+ return {
+ ...actual,
+ getProcessedSystemVariablesFromUrlParams: vi.fn().mockResolvedValue({ user_id: 'user-1' }),
+ getRawInputsFromUrlParams: vi.fn().mockResolvedValue({}),
+ getRawUserVariablesFromUrlParams: vi.fn().mockResolvedValue({}),
+ }
+})
+
+vi.mock('@/service/share', () => ({
+ fetchChatList: vi.fn(),
+ fetchConversations: vi.fn(),
+ generationConversationName: vi.fn(),
+ fetchAppInfo: vi.fn(),
+ fetchAppMeta: vi.fn(),
+ fetchAppParams: vi.fn(),
+ getAppAccessModeByAppCode: vi.fn(),
+ delConversation: vi.fn(),
+ pinConversation: vi.fn(),
+ renameConversation: vi.fn(),
+ unpinConversation: vi.fn(),
+ updateFeedback: vi.fn(),
+}))
+
+const mockFetchConversations = vi.mocked(fetchConversations)
+const mockFetchChatList = vi.mocked(fetchChatList)
+const mockGenerationConversationName = vi.mocked(generationConversationName)
+
+const createQueryClient = () => new QueryClient({
+ defaultOptions: {
+ queries: {
+ retry: false,
+ },
+ },
+})
+
+const createWrapper = (queryClient: QueryClient) => {
+ return ({ children }: { children: ReactNode }) => (
+
+ {children}
+
+ )
+}
+
+const renderWithClient = (hook: () => T) => {
+ const queryClient = createQueryClient()
+ const wrapper = createWrapper(queryClient)
+ return {
+ queryClient,
+ ...renderHook(hook, { wrapper }),
+ }
+}
+
+const createConversationItem = (overrides: Partial = {}): ConversationItem => ({
+ id: 'conversation-1',
+ name: 'Conversation 1',
+ inputs: null,
+ introduction: '',
+ ...overrides,
+})
+
+const createConversationData = (overrides: Partial = {}): AppConversationData => ({
+ data: [createConversationItem()],
+ has_more: false,
+ limit: 100,
+ ...overrides,
+})
+
+const setConversationIdInfo = (appId: string, conversationId: string) => {
+ const value = {
+ [appId]: {
+ 'user-1': conversationId,
+ 'DEFAULT': conversationId,
+ },
+ }
+ localStorage.setItem(CONVERSATION_ID_INFO, JSON.stringify(value))
+}
+
+// Scenario: useChatWithHistory integrates share queries for conversations and chat list.
+describe('useChatWithHistory', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ localStorage.removeItem(CONVERSATION_ID_INFO)
+ mockStoreState.appInfo = {
+ app_id: 'app-1',
+ custom_config: null,
+ site: {
+ title: 'Test App',
+ default_language: 'en-US',
+ },
+ }
+ mockStoreState.appMeta = {
+ tool_icons: {},
+ }
+ mockStoreState.appParams = null
+ setConversationIdInfo('app-1', 'conversation-1')
+ })
+
+ afterEach(() => {
+ localStorage.removeItem(CONVERSATION_ID_INFO)
+ })
+
+ // Scenario: share query results populate conversation lists and trigger chat list fetch.
+ describe('Share queries', () => {
+ it('should load pinned, unpinned, and chat list data from share queries', async () => {
+ // Arrange
+ const pinnedData = createConversationData({
+ data: [createConversationItem({ id: 'pinned-1', name: 'Pinned' })],
+ })
+ const listData = createConversationData({
+ data: [createConversationItem({ id: 'conversation-1', name: 'First' })],
+ })
+ mockFetchConversations.mockImplementation(async (_isInstalledApp, _appId, _lastId, pinned) => {
+ return pinned ? pinnedData : listData
+ })
+ mockFetchChatList.mockResolvedValue({ data: [] })
+
+ // Act
+ const { result } = renderWithClient(() => useChatWithHistory())
+
+ // Assert
+ await waitFor(() => {
+ expect(mockFetchConversations).toHaveBeenCalledWith(false, 'app-1', undefined, true, 100)
+ })
+ await waitFor(() => {
+ expect(mockFetchConversations).toHaveBeenCalledWith(false, 'app-1', undefined, false, 100)
+ })
+ await waitFor(() => {
+ expect(mockFetchChatList).toHaveBeenCalledWith('conversation-1', false, 'app-1')
+ })
+ expect(result.current.pinnedConversationList).toEqual(pinnedData.data)
+ expect(result.current.conversationList).toEqual(listData.data)
+ })
+ })
+
+ // Scenario: completion invalidates share caches and merges generated names.
+ describe('New conversation completion', () => {
+ it('should invalidate share conversations and apply generated name', async () => {
+ // Arrange
+ const listData = createConversationData({
+ data: [createConversationItem({ id: 'conversation-1', name: 'First' })],
+ })
+ const generatedConversation = createConversationItem({
+ id: 'conversation-new',
+ name: 'Generated',
+ })
+ mockFetchConversations.mockResolvedValue(listData)
+ mockFetchChatList.mockResolvedValue({ data: [] })
+ mockGenerationConversationName.mockResolvedValue(generatedConversation)
+
+ const { result, queryClient } = renderWithClient(() => useChatWithHistory())
+ const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries')
+
+ // Act
+ act(() => {
+ result.current.handleNewConversationCompleted('conversation-new')
+ })
+
+ // Assert
+ await waitFor(() => {
+ expect(mockGenerationConversationName).toHaveBeenCalledWith(false, 'app-1', 'conversation-new')
+ })
+ await waitFor(() => {
+ expect(result.current.conversationList[0]).toEqual(generatedConversation)
+ })
+ expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: shareQueryKeys.conversations })
+ })
+ })
+
+ // Scenario: chat list queries stop when reload key is cleared.
+ describe('Chat list gating', () => {
+ it('should not refetch chat list when newConversationId matches current conversation', async () => {
+ // Arrange
+ const listData = createConversationData({
+ data: [createConversationItem({ id: 'conversation-1', name: 'First' })],
+ })
+ mockFetchConversations.mockResolvedValue(listData)
+ mockFetchChatList.mockResolvedValue({ data: [] })
+ mockGenerationConversationName.mockResolvedValue(createConversationItem({ id: 'conversation-1' }))
+
+ const { result } = renderWithClient(() => useChatWithHistory())
+
+ await waitFor(() => {
+ expect(mockFetchChatList).toHaveBeenCalledTimes(1)
+ })
+
+ // Act
+ act(() => {
+ result.current.handleNewConversationCompleted('conversation-1')
+ })
+
+ // Assert
+ await waitFor(() => {
+ expect(result.current.chatShouldReloadKey).toBe('')
+ })
+ expect(mockFetchChatList).toHaveBeenCalledTimes(1)
+ })
+ })
+
+ // Scenario: conversation id updates persist to localStorage.
+ describe('Conversation id persistence', () => {
+ it('should store new conversation id in localStorage after completion', async () => {
+ // Arrange
+ const listData = createConversationData({
+ data: [createConversationItem({ id: 'conversation-1', name: 'First' })],
+ })
+ mockFetchConversations.mockResolvedValue(listData)
+ mockFetchChatList.mockResolvedValue({ data: [] })
+ mockGenerationConversationName.mockResolvedValue(createConversationItem({ id: 'conversation-new' }))
+
+ const { result } = renderWithClient(() => useChatWithHistory())
+
+ // Act
+ act(() => {
+ result.current.handleNewConversationCompleted('conversation-new')
+ })
+
+ // Assert
+ await waitFor(() => {
+ const storedValue = localStorage.getItem(CONVERSATION_ID_INFO)
+ const parsed = storedValue ? JSON.parse(storedValue) : {}
+ const storedUserId = parsed['app-1']?.['user-1']
+ const storedDefaultId = parsed['app-1']?.DEFAULT
+ expect([storedUserId, storedDefaultId]).toContain('conversation-new')
+ })
+ })
+ })
+})
diff --git a/web/app/components/base/chat/chat-with-history/hooks.tsx b/web/app/components/base/chat/chat-with-history/hooks.tsx
index e0857e98db..154729ded0 100644
--- a/web/app/components/base/chat/chat-with-history/hooks.tsx
+++ b/web/app/components/base/chat/chat-with-history/hooks.tsx
@@ -20,7 +20,6 @@ import {
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
-import useSWR from 'swr'
import { getProcessedFilesFromResponse } from '@/app/components/base/file-uploader/utils'
import { useToastContext } from '@/app/components/base/toast'
import { InputVarType } from '@/app/components/workflow/types'
@@ -29,14 +28,17 @@ import { useAppFavicon } from '@/hooks/use-app-favicon'
import { changeLanguage } from '@/i18n-config/i18next-config'
import {
delConversation,
- fetchChatList,
- fetchConversations,
- generationConversationName,
pinConversation,
renameConversation,
unpinConversation,
updateFeedback,
} from '@/service/share'
+import {
+ useInvalidateShareConversations,
+ useShareChatList,
+ useShareConversationName,
+ useShareConversations,
+} from '@/service/use-share'
import { TransferMethod } from '@/types/app'
import { addFileInfos, sortAgentSorts } from '../../../tools/utils'
import { CONVERSATION_ID_INFO } from '../constants'
@@ -174,21 +176,42 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
return currentConversationId
}, [currentConversationId, newConversationId])
- const { data: appPinnedConversationData, mutate: mutateAppPinnedConversationData } = useSWR(
- appId ? ['appConversationData', isInstalledApp, appId, true] : null,
- () => fetchConversations(isInstalledApp, appId, undefined, true, 100),
- { revalidateOnFocus: false, revalidateOnReconnect: false },
- )
- const { data: appConversationData, isLoading: appConversationDataLoading, mutate: mutateAppConversationData } = useSWR(
- appId ? ['appConversationData', isInstalledApp, appId, false] : null,
- () => fetchConversations(isInstalledApp, appId, undefined, false, 100),
- { revalidateOnFocus: false, revalidateOnReconnect: false },
- )
- const { data: appChatListData, isLoading: appChatListDataLoading } = useSWR(
- chatShouldReloadKey ? ['appChatList', chatShouldReloadKey, isInstalledApp, appId] : null,
- () => fetchChatList(chatShouldReloadKey, isInstalledApp, appId),
- { revalidateOnFocus: false, revalidateOnReconnect: false },
- )
+ const { data: appPinnedConversationData } = useShareConversations({
+ isInstalledApp,
+ appId,
+ pinned: true,
+ limit: 100,
+ }, {
+ enabled: !!appId,
+ refetchOnWindowFocus: false,
+ refetchOnReconnect: false,
+ })
+ const {
+ data: appConversationData,
+ isLoading: appConversationDataLoading,
+ } = useShareConversations({
+ isInstalledApp,
+ appId,
+ pinned: false,
+ limit: 100,
+ }, {
+ enabled: !!appId,
+ refetchOnWindowFocus: false,
+ refetchOnReconnect: false,
+ })
+ const {
+ data: appChatListData,
+ isLoading: appChatListDataLoading,
+ } = useShareChatList({
+ conversationId: chatShouldReloadKey,
+ isInstalledApp,
+ appId,
+ }, {
+ enabled: !!chatShouldReloadKey,
+ refetchOnWindowFocus: false,
+ refetchOnReconnect: false,
+ })
+ const invalidateShareConversations = useInvalidateShareConversations()
const [clearChatList, setClearChatList] = useState(false)
const [isResponding, setIsResponding] = useState(false)
@@ -309,7 +332,13 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
handleNewConversationInputsChange(conversationInputs)
}, [handleNewConversationInputsChange, inputsForms])
- const { data: newConversation } = useSWR(newConversationId ? [isInstalledApp, appId, newConversationId] : null, () => generationConversationName(isInstalledApp, appId, newConversationId), { revalidateOnFocus: false })
+ const { data: newConversation } = useShareConversationName({
+ conversationId: newConversationId,
+ isInstalledApp,
+ appId,
+ }, {
+ refetchOnWindowFocus: false,
+ })
const [originConversationList, setOriginConversationList] = useState([])
useEffect(() => {
if (appConversationData?.data && !appConversationDataLoading)
@@ -429,9 +458,8 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
setClearChatList(true)
}, [handleChangeConversation, setShowNewConversationItemInList, handleNewConversationInputsChange, setClearChatList, inputsForms])
const handleUpdateConversationList = useCallback(() => {
- mutateAppConversationData()
- mutateAppPinnedConversationData()
- }, [mutateAppConversationData, mutateAppPinnedConversationData])
+ invalidateShareConversations()
+ }, [invalidateShareConversations])
const handlePinConversation = useCallback(async (conversationId: string) => {
await pinConversation(isInstalledApp, appId, conversationId)
@@ -518,8 +546,8 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
setNewConversationId(newConversationId)
handleConversationIdInfoChange(newConversationId)
setShowNewConversationItemInList(false)
- mutateAppConversationData()
- }, [mutateAppConversationData, handleConversationIdInfoChange])
+ invalidateShareConversations()
+ }, [handleConversationIdInfoChange, invalidateShareConversations])
const handleFeedback = useCallback(async (messageId: string, feedback: Feedback) => {
await updateFeedback({ url: `/messages/${messageId}/feedbacks`, body: { rating: feedback.rating, content: feedback.content } }, isInstalledApp, appId)
diff --git a/web/app/components/base/chat/embedded-chatbot/hooks.spec.tsx b/web/app/components/base/chat/embedded-chatbot/hooks.spec.tsx
new file mode 100644
index 0000000000..ca6a90c4d8
--- /dev/null
+++ b/web/app/components/base/chat/embedded-chatbot/hooks.spec.tsx
@@ -0,0 +1,257 @@
+import type { ReactNode } from 'react'
+import type { ChatConfig } from '../types'
+import type { AppConversationData, AppData, AppMeta, ConversationItem } from '@/models/share'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { act, renderHook, waitFor } from '@testing-library/react'
+import { ToastProvider } from '@/app/components/base/toast'
+import {
+ fetchChatList,
+ fetchConversations,
+ generationConversationName,
+} from '@/service/share'
+import { shareQueryKeys } from '@/service/use-share'
+import { CONVERSATION_ID_INFO } from '../constants'
+import { useEmbeddedChatbot } from './hooks'
+
+vi.mock('@/i18n-config/i18next-config', () => ({
+ changeLanguage: vi.fn().mockResolvedValue(undefined),
+}))
+
+const mockStoreState: {
+ appInfo: AppData | null
+ appMeta: AppMeta | null
+ appParams: ChatConfig | null
+ embeddedConversationId: string | null
+ embeddedUserId: string | null
+} = {
+ appInfo: null,
+ appMeta: null,
+ appParams: null,
+ embeddedConversationId: null,
+ embeddedUserId: null,
+}
+
+const useWebAppStoreMock = vi.fn((selector?: (state: typeof mockStoreState) => unknown) => {
+ return selector ? selector(mockStoreState) : mockStoreState
+})
+
+vi.mock('@/context/web-app-context', () => ({
+ useWebAppStore: (selector?: (state: typeof mockStoreState) => unknown) => useWebAppStoreMock(selector),
+}))
+
+vi.mock('../utils', async () => {
+ const actual = await vi.importActual('../utils')
+ return {
+ ...actual,
+ getProcessedInputsFromUrlParams: vi.fn().mockResolvedValue({}),
+ getProcessedSystemVariablesFromUrlParams: vi.fn().mockResolvedValue({}),
+ getProcessedUserVariablesFromUrlParams: vi.fn().mockResolvedValue({}),
+ }
+})
+
+vi.mock('@/service/share', () => ({
+ fetchChatList: vi.fn(),
+ fetchConversations: vi.fn(),
+ generationConversationName: vi.fn(),
+ fetchAppInfo: vi.fn(),
+ fetchAppMeta: vi.fn(),
+ fetchAppParams: vi.fn(),
+ getAppAccessModeByAppCode: vi.fn(),
+ updateFeedback: vi.fn(),
+}))
+
+const mockFetchConversations = vi.mocked(fetchConversations)
+const mockFetchChatList = vi.mocked(fetchChatList)
+const mockGenerationConversationName = vi.mocked(generationConversationName)
+
+const createQueryClient = () => new QueryClient({
+ defaultOptions: {
+ queries: {
+ retry: false,
+ },
+ },
+})
+
+const createWrapper = (queryClient: QueryClient) => {
+ return ({ children }: { children: ReactNode }) => (
+
+ {children}
+
+ )
+}
+
+const renderWithClient = (hook: () => T) => {
+ const queryClient = createQueryClient()
+ const wrapper = createWrapper(queryClient)
+ return {
+ queryClient,
+ ...renderHook(hook, { wrapper }),
+ }
+}
+
+const createConversationItem = (overrides: Partial = {}): ConversationItem => ({
+ id: 'conversation-1',
+ name: 'Conversation 1',
+ inputs: null,
+ introduction: '',
+ ...overrides,
+})
+
+const createConversationData = (overrides: Partial = {}): AppConversationData => ({
+ data: [createConversationItem()],
+ has_more: false,
+ limit: 100,
+ ...overrides,
+})
+
+// Scenario: useEmbeddedChatbot integrates share queries for conversations and chat list.
+describe('useEmbeddedChatbot', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ localStorage.removeItem(CONVERSATION_ID_INFO)
+ mockStoreState.appInfo = {
+ app_id: 'app-1',
+ custom_config: null,
+ site: {
+ title: 'Test App',
+ default_language: 'en-US',
+ },
+ }
+ mockStoreState.appMeta = {
+ tool_icons: {},
+ }
+ mockStoreState.appParams = null
+ mockStoreState.embeddedConversationId = 'conversation-1'
+ mockStoreState.embeddedUserId = 'embedded-user-1'
+ })
+
+ afterEach(() => {
+ localStorage.removeItem(CONVERSATION_ID_INFO)
+ })
+
+ // Scenario: share query results populate conversation lists and trigger chat list fetch.
+ describe('Share queries', () => {
+ it('should load pinned, unpinned, and chat list data from share queries', async () => {
+ // Arrange
+ const pinnedData = createConversationData({
+ data: [createConversationItem({ id: 'pinned-1', name: 'Pinned' })],
+ })
+ const listData = createConversationData({
+ data: [createConversationItem({ id: 'conversation-1', name: 'First' })],
+ })
+ mockFetchConversations.mockImplementation(async (_isInstalledApp, _appId, _lastId, pinned) => {
+ return pinned ? pinnedData : listData
+ })
+ mockFetchChatList.mockResolvedValue({ data: [] })
+
+ // Act
+ const { result } = renderWithClient(() => useEmbeddedChatbot())
+
+ // Assert
+ await waitFor(() => {
+ expect(mockFetchConversations).toHaveBeenCalledWith(false, 'app-1', undefined, true, 100)
+ })
+ await waitFor(() => {
+ expect(mockFetchConversations).toHaveBeenCalledWith(false, 'app-1', undefined, false, 100)
+ })
+ await waitFor(() => {
+ expect(mockFetchChatList).toHaveBeenCalledWith('conversation-1', false, 'app-1')
+ })
+ expect(result.current.pinnedConversationList).toEqual(pinnedData.data)
+ expect(result.current.conversationList).toEqual(listData.data)
+ })
+ })
+
+ // Scenario: completion invalidates share caches and merges generated names.
+ describe('New conversation completion', () => {
+ it('should invalidate share conversations and apply generated name', async () => {
+ // Arrange
+ const listData = createConversationData({
+ data: [createConversationItem({ id: 'conversation-1', name: 'First' })],
+ })
+ const generatedConversation = createConversationItem({
+ id: 'conversation-new',
+ name: 'Generated',
+ })
+ mockFetchConversations.mockResolvedValue(listData)
+ mockFetchChatList.mockResolvedValue({ data: [] })
+ mockGenerationConversationName.mockResolvedValue(generatedConversation)
+
+ const { result, queryClient } = renderWithClient(() => useEmbeddedChatbot())
+ const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries')
+
+ // Act
+ act(() => {
+ result.current.handleNewConversationCompleted('conversation-new')
+ })
+
+ // Assert
+ await waitFor(() => {
+ expect(mockGenerationConversationName).toHaveBeenCalledWith(false, 'app-1', 'conversation-new')
+ })
+ await waitFor(() => {
+ expect(result.current.conversationList[0]).toEqual(generatedConversation)
+ })
+ expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: shareQueryKeys.conversations })
+ })
+ })
+
+ // Scenario: chat list queries stop when reload key is cleared.
+ describe('Chat list gating', () => {
+ it('should not refetch chat list when newConversationId matches current conversation', async () => {
+ // Arrange
+ const listData = createConversationData({
+ data: [createConversationItem({ id: 'conversation-1', name: 'First' })],
+ })
+ mockFetchConversations.mockResolvedValue(listData)
+ mockFetchChatList.mockResolvedValue({ data: [] })
+ mockGenerationConversationName.mockResolvedValue(createConversationItem({ id: 'conversation-1' }))
+
+ const { result } = renderWithClient(() => useEmbeddedChatbot())
+
+ await waitFor(() => {
+ expect(mockFetchChatList).toHaveBeenCalledTimes(1)
+ })
+
+ // Act
+ act(() => {
+ result.current.handleNewConversationCompleted('conversation-1')
+ })
+
+ // Assert
+ await waitFor(() => {
+ expect(result.current.chatShouldReloadKey).toBe('')
+ })
+ expect(mockFetchChatList).toHaveBeenCalledTimes(1)
+ })
+ })
+
+ // Scenario: conversation id updates persist to localStorage.
+ describe('Conversation id persistence', () => {
+ it('should store new conversation id in localStorage after completion', async () => {
+ // Arrange
+ const listData = createConversationData({
+ data: [createConversationItem({ id: 'conversation-1', name: 'First' })],
+ })
+ mockFetchConversations.mockResolvedValue(listData)
+ mockFetchChatList.mockResolvedValue({ data: [] })
+ mockGenerationConversationName.mockResolvedValue(createConversationItem({ id: 'conversation-new' }))
+
+ const { result } = renderWithClient(() => useEmbeddedChatbot())
+
+ // Act
+ act(() => {
+ result.current.handleNewConversationCompleted('conversation-new')
+ })
+
+ // Assert
+ await waitFor(() => {
+ const storedValue = localStorage.getItem(CONVERSATION_ID_INFO)
+ const parsed = storedValue ? JSON.parse(storedValue) : {}
+ const storedUserId = parsed['app-1']?.['embedded-user-1']
+ const storedDefaultId = parsed['app-1']?.DEFAULT
+ expect([storedUserId, storedDefaultId]).toContain('conversation-new')
+ })
+ })
+ })
+})
diff --git a/web/app/components/base/chat/embedded-chatbot/hooks.tsx b/web/app/components/base/chat/embedded-chatbot/hooks.tsx
index 98c8c71192..7a7cf4ffd3 100644
--- a/web/app/components/base/chat/embedded-chatbot/hooks.tsx
+++ b/web/app/components/base/chat/embedded-chatbot/hooks.tsx
@@ -19,18 +19,18 @@ import {
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
-import useSWR from 'swr'
import { useToastContext } from '@/app/components/base/toast'
import { addFileInfos, sortAgentSorts } from '@/app/components/tools/utils'
import { InputVarType } from '@/app/components/workflow/types'
import { useWebAppStore } from '@/context/web-app-context'
import { changeLanguage } from '@/i18n-config/i18next-config'
+import { updateFeedback } from '@/service/share'
import {
- fetchChatList,
- fetchConversations,
- generationConversationName,
- updateFeedback,
-} from '@/service/share'
+ useInvalidateShareConversations,
+ useShareChatList,
+ useShareConversationName,
+ useShareConversations,
+} from '@/service/use-share'
import { TransferMethod } from '@/types/app'
import { getProcessedFilesFromResponse } from '../../file-uploader/utils'
import { CONVERSATION_ID_INFO } from '../constants'
@@ -137,9 +137,30 @@ export const useEmbeddedChatbot = () => {
return currentConversationId
}, [currentConversationId, newConversationId])
- const { data: appPinnedConversationData } = useSWR(['appConversationData', isInstalledApp, appId, true], () => fetchConversations(isInstalledApp, appId, undefined, true, 100))
- const { data: appConversationData, isLoading: appConversationDataLoading, mutate: mutateAppConversationData } = useSWR(['appConversationData', isInstalledApp, appId, false], () => fetchConversations(isInstalledApp, appId, undefined, false, 100))
- const { data: appChatListData, isLoading: appChatListDataLoading } = useSWR(chatShouldReloadKey ? ['appChatList', chatShouldReloadKey, isInstalledApp, appId] : null, () => fetchChatList(chatShouldReloadKey, isInstalledApp, appId))
+ const { data: appPinnedConversationData } = useShareConversations({
+ isInstalledApp,
+ appId,
+ pinned: true,
+ limit: 100,
+ })
+ const {
+ data: appConversationData,
+ isLoading: appConversationDataLoading,
+ } = useShareConversations({
+ isInstalledApp,
+ appId,
+ pinned: false,
+ limit: 100,
+ })
+ const {
+ data: appChatListData,
+ isLoading: appChatListDataLoading,
+ } = useShareChatList({
+ conversationId: chatShouldReloadKey,
+ isInstalledApp,
+ appId,
+ })
+ const invalidateShareConversations = useInvalidateShareConversations()
const [clearChatList, setClearChatList] = useState(false)
const [isResponding, setIsResponding] = useState(false)
@@ -259,7 +280,13 @@ export const useEmbeddedChatbot = () => {
handleNewConversationInputsChange(conversationInputs)
}, [handleNewConversationInputsChange, inputsForms])
- const { data: newConversation } = useSWR(newConversationId ? [isInstalledApp, appId, newConversationId] : null, () => generationConversationName(isInstalledApp, appId, newConversationId), { revalidateOnFocus: false })
+ const { data: newConversation } = useShareConversationName({
+ conversationId: newConversationId,
+ isInstalledApp,
+ appId,
+ }, {
+ refetchOnWindowFocus: false,
+ })
const [originConversationList, setOriginConversationList] = useState([])
useEffect(() => {
if (appConversationData?.data && !appConversationDataLoading)
@@ -379,8 +406,8 @@ export const useEmbeddedChatbot = () => {
setNewConversationId(newConversationId)
handleConversationIdInfoChange(newConversationId)
setShowNewConversationItemInList(false)
- mutateAppConversationData()
- }, [mutateAppConversationData, handleConversationIdInfoChange])
+ invalidateShareConversations()
+ }, [handleConversationIdInfoChange, invalidateShareConversations])
const handleFeedback = useCallback(async (messageId: string, feedback: Feedback) => {
await updateFeedback({ url: `/messages/${messageId}/feedbacks`, body: { rating: feedback.rating, content: feedback.content } }, isInstalledApp, appId)
diff --git a/web/knip.config.ts b/web/knip.config.ts
index 34457c27a4..8598d94e2d 100644
--- a/web/knip.config.ts
+++ b/web/knip.config.ts
@@ -76,7 +76,7 @@ const config: KnipConfig = {
// Browser initialization (runs on client startup)
'app/components/browser-initializer.tsx!',
'app/components/sentry-initializer.tsx!',
- 'app/components/swr-initializer.tsx!',
+ 'app/components/app-initializer.tsx!',
// i18n initialization (server and client)
'app/components/i18n.tsx!',
diff --git a/web/package.json b/web/package.json
index cad21b1b17..2e03f04a62 100644
--- a/web/package.json
+++ b/web/package.json
@@ -139,7 +139,6 @@
"sharp": "^0.33.5",
"sortablejs": "^1.15.6",
"string-ts": "^2.3.1",
- "swr": "^2.3.6",
"tailwind-merge": "^2.6.0",
"tldts": "^7.0.17",
"use-context-selector": "^2.0.0",
diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml
index 7971d62958..5c2986c190 100644
--- a/web/pnpm-lock.yaml
+++ b/web/pnpm-lock.yaml
@@ -333,9 +333,6 @@ importers:
string-ts:
specifier: ^2.3.1
version: 2.3.1
- swr:
- specifier: ^2.3.6
- version: 2.3.7(react@19.2.3)
tailwind-merge:
specifier: ^2.6.0
version: 2.6.0
@@ -8127,11 +8124,6 @@ packages:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'}
- swr@2.3.7:
- resolution: {integrity: sha512-ZEquQ82QvalqTxhBVv/DlAg2mbmUjF4UgpPg9wwk4ufb9rQnZXh1iKyyKBqV6bQGu1Ie7L1QwSYO07qFIa1p+g==}
- peerDependencies:
- react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
-
symbol-tree@3.2.4:
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
@@ -17753,12 +17745,6 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {}
- swr@2.3.7(react@19.2.3):
- dependencies:
- dequal: 2.0.3
- react: 19.2.3
- use-sync-external-store: 1.6.0(react@19.2.3)
-
symbol-tree@3.2.4: {}
synckit@0.11.11:
diff --git a/web/scripts/analyze-component.js b/web/scripts/analyze-component.js
index 8b01744b3d..9347a82fa5 100755
--- a/web/scripts/analyze-component.js
+++ b/web/scripts/analyze-component.js
@@ -46,7 +46,6 @@ Features Detected:
${analysis.hasEvents ? '✓' : '✗'} Event handlers
${analysis.hasRouter ? '✓' : '✗'} Next.js routing
${analysis.hasAPI ? '✓' : '✗'} API calls
- ${analysis.hasSWR ? '✓' : '✗'} SWR data fetching
${analysis.hasReactQuery ? '✓' : '✗'} React Query
${analysis.hasAhooks ? '✓' : '✗'} ahooks
${analysis.hasForwardRef ? '✓' : '✗'} Ref forwarding (forwardRef)
@@ -236,7 +235,7 @@ Create the test file at: ${testPath}
// ===== API Calls =====
if (analysis.hasAPI) {
guidelines.push('🌐 API calls detected:')
- guidelines.push(' - Mock API calls/hooks (useSWR, useQuery, fetch, etc.)')
+ guidelines.push(' - Mock API calls/hooks (useQuery, useMutation, fetch, etc.)')
guidelines.push(' - Test loading, success, and error states')
guidelines.push(' - Focus on component behavior, not the data fetching lib')
}
diff --git a/web/scripts/component-analyzer.js b/web/scripts/component-analyzer.js
index c53b652bc2..8bd3dc4409 100644
--- a/web/scripts/component-analyzer.js
+++ b/web/scripts/component-analyzer.js
@@ -21,6 +21,7 @@ export class ComponentAnalyzer {
const resolvedPath = absolutePath ?? path.resolve(process.cwd(), filePath)
const fileName = path.basename(filePath, path.extname(filePath))
const lineCount = code.split('\n').length
+ const hasReactQuery = /\buse(?:Query|Queries|InfiniteQuery|SuspenseQuery|SuspenseInfiniteQuery|Mutation)\b/.test(code)
// Calculate complexity metrics
const { total: rawComplexity, max: rawMaxComplexity } = this.calculateCognitiveComplexity(code)
@@ -44,14 +45,13 @@ export class ComponentAnalyzer {
hasMemo: code.includes('useMemo'),
hasEvents: /on[A-Z]\w+/.test(code),
hasRouter: code.includes('useRouter') || code.includes('usePathname'),
- hasAPI: code.includes('service/') || code.includes('fetch(') || code.includes('useSWR'),
+ hasAPI: code.includes('service/') || code.includes('fetch(') || hasReactQuery,
hasForwardRef: code.includes('forwardRef'),
hasComponentMemo: /React\.memo|memo\(/.test(code),
hasSuspense: code.includes('Suspense') || /\blazy\(/.test(code),
hasPortal: code.includes('createPortal'),
hasImperativeHandle: code.includes('useImperativeHandle'),
- hasSWR: code.includes('useSWR'),
- hasReactQuery: code.includes('useQuery') || code.includes('useMutation'),
+ hasReactQuery,
hasAhooks: code.includes('from \'ahooks\''),
complexity,
maxComplexity,
diff --git a/web/scripts/refactor-component.js b/web/scripts/refactor-component.js
index f890540515..a054650ba3 100644
--- a/web/scripts/refactor-component.js
+++ b/web/scripts/refactor-component.js
@@ -123,7 +123,6 @@ Usage: ${analysis.usageCount} reference${analysis.usageCount !== 1
${analysis.hasRouter ? '✓' : '✗'} Next.js routing
${analysis.hasAPI ? '✓' : '✗'} API calls
${analysis.hasReactQuery ? '✓' : '✗'} React Query
- ${analysis.hasSWR ? '✓' : '✗'} SWR (should migrate to React Query)
${analysis.hasAhooks ? '✓' : '✗'} ahooks
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@@ -150,7 +149,7 @@ ${this.buildRequirements(analysis)}
Follow Dify project conventions:
- Place extracted hooks in \`hooks/\` subdirectory or as \`use-.ts\`
-- Use React Query (\`@tanstack/react-query\`) for data fetching, not SWR
+- Use React Query (\`@tanstack/react-query\`) for data fetching
- Follow existing patterns in \`web/service/use-*.ts\` for API hooks
- Keep each new file under 300 lines
- Maintain TypeScript strict typing
@@ -173,12 +172,8 @@ After refactoring, verify:
}
// Priority 2: Extract API/data logic
- if (analysis.hasAPI && (analysis.hasEffects || analysis.hasSWR)) {
- if (analysis.hasSWR) {
- actions.push('🔄 MIGRATE SWR TO REACT QUERY: Replace useSWR with useQuery from @tanstack/react-query')
- }
+ if (analysis.hasAPI)
actions.push('🌐 EXTRACT DATA HOOK: Move API calls and data fetching logic into a dedicated hook using React Query')
- }
// Priority 3: Split large components
if (analysis.lineCount > 300) {
diff --git a/web/service/annotation.ts b/web/service/annotation.ts
index acdb944386..8a19425044 100644
--- a/web/service/annotation.ts
+++ b/web/service/annotation.ts
@@ -1,4 +1,3 @@
-import type { Fetcher } from 'swr'
import type { AnnotationCreateResponse, AnnotationEnableStatus, AnnotationItemBasic, EmbeddingModelConfig } from '@/app/components/app/annotation/type'
import { ANNOTATION_DEFAULT } from '@/config'
import { del, get, post } from './base'
@@ -44,11 +43,11 @@ export const addAnnotation = (appId: string, body: AnnotationItemBasic) => {
return post(`apps/${appId}/annotations`, { body })
}
-export const annotationBatchImport: Fetcher<{ job_id: string, job_status: string }, { url: string, body: FormData }> = ({ url, body }) => {
+export const annotationBatchImport = ({ url, body }: { url: string, body: FormData }): Promise<{ job_id: string, job_status: string }> => {
return post<{ job_id: string, job_status: string }>(url, { body }, { bodyStringify: false, deleteContentType: true })
}
-export const checkAnnotationBatchImportProgress: Fetcher<{ job_id: string, job_status: string }, { jobID: string, appId: string }> = ({ jobID, appId }) => {
+export const checkAnnotationBatchImportProgress = ({ jobID, appId }: { jobID: string, appId: string }): Promise<{ job_id: string, job_status: string }> => {
return get<{ job_id: string, job_status: string }>(`/apps/${appId}/annotations/batch-import-status/${jobID}`)
}
diff --git a/web/service/apps.ts b/web/service/apps.ts
index 1e3c93a33a..db79141ec6 100644
--- a/web/service/apps.ts
+++ b/web/service/apps.ts
@@ -12,7 +12,6 @@ export const fetchAppDetail = ({ url, id }: { url: string, id: string }): Promis
return get(`${url}/${id}`)
}
-// Direct API call function for non-SWR usage
export const fetchAppDetailDirect = async ({ url, id }: { url: string, id: string }): Promise => {
return get(`${url}/${id}`)
}
diff --git a/web/service/plugins.ts b/web/service/plugins.ts
index afaebf4fb5..cf05d8c20d 100644
--- a/web/service/plugins.ts
+++ b/web/service/plugins.ts
@@ -1,4 +1,3 @@
-import type { Fetcher } from 'swr'
import type {
MarketplaceCollectionPluginsResponse,
MarketplaceCollectionsResponse,
@@ -82,11 +81,11 @@ export const fetchPluginInfoFromMarketPlace = async ({
return getMarketplace<{ data: { plugin: PluginInfoFromMarketPlace, version: { version: string } } }>(`/plugins/${org}/${name}`)
}
-export const fetchMarketplaceCollections: Fetcher = ({ url }) => {
+export const fetchMarketplaceCollections = ({ url }: { url: string }): Promise => {
return get(url)
}
-export const fetchMarketplaceCollectionPlugins: Fetcher = ({ url }) => {
+export const fetchMarketplaceCollectionPlugins = ({ url }: { url: string }): Promise => {
return get(url)
}
diff --git a/web/service/use-common.ts b/web/service/use-common.ts
index 7db65caccb..77bb2a11fa 100644
--- a/web/service/use-common.ts
+++ b/web/service/use-common.ts
@@ -55,7 +55,7 @@ export const commonQueryKeys = {
] as const,
notionBinding: (code?: string | null) => [NAME_SPACE, 'notion-binding', code] as const,
modelParameterRules: (provider?: string, model?: string) => [NAME_SPACE, 'model-parameter-rules', provider, model] as const,
- langGeniusVersion: (currentVersion?: string | null) => [NAME_SPACE, 'lang-genius-version', currentVersion] as const,
+ langGeniusVersion: (currentVersion?: string | null) => [NAME_SPACE, 'langgenius-version', currentVersion] as const,
forgotPasswordValidity: (token?: string | null) => [NAME_SPACE, 'forgot-password-validity', token] as const,
dataSourceIntegrates: [NAME_SPACE, 'data-source-integrates'] as const,
}
diff --git a/web/service/use-share.spec.tsx b/web/service/use-share.spec.tsx
new file mode 100644
index 0000000000..d0202ed140
--- /dev/null
+++ b/web/service/use-share.spec.tsx
@@ -0,0 +1,231 @@
+import type { ReactNode } from 'react'
+import type { AppConversationData, ConversationItem } from '@/models/share'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { act, renderHook, waitFor } from '@testing-library/react'
+import {
+ fetchChatList,
+ fetchConversations,
+ generationConversationName,
+} from './share'
+import {
+ shareQueryKeys,
+ useInvalidateShareConversations,
+ useShareChatList,
+ useShareConversationName,
+ useShareConversations,
+} from './use-share'
+
+vi.mock('./share', () => ({
+ fetchChatList: vi.fn(),
+ fetchConversations: vi.fn(),
+ generationConversationName: vi.fn(),
+ fetchAppInfo: vi.fn(),
+ fetchAppMeta: vi.fn(),
+ fetchAppParams: vi.fn(),
+ getAppAccessModeByAppCode: vi.fn(),
+}))
+
+const mockFetchConversations = vi.mocked(fetchConversations)
+const mockFetchChatList = vi.mocked(fetchChatList)
+const mockGenerationConversationName = vi.mocked(generationConversationName)
+
+const createQueryClient = () => new QueryClient({
+ defaultOptions: {
+ queries: {
+ retry: false,
+ },
+ },
+})
+
+const createWrapper = (queryClient: QueryClient) => {
+ return ({ children }: { children: ReactNode }) => (
+ {children}
+ )
+}
+
+const renderShareHook = (hook: () => T) => {
+ const queryClient = createQueryClient()
+ const wrapper = createWrapper(queryClient)
+ return {
+ queryClient,
+ ...renderHook(hook, { wrapper }),
+ }
+}
+
+const createConversationItem = (overrides: Partial = {}): ConversationItem => ({
+ id: 'conversation-1',
+ name: 'Conversation 1',
+ inputs: null,
+ introduction: 'Intro',
+ ...overrides,
+})
+
+const createConversationData = (overrides: Partial = {}): AppConversationData => ({
+ data: [createConversationItem()],
+ has_more: false,
+ limit: 20,
+ ...overrides,
+})
+
+// Scenario: share conversation list queries behave consistently with params and enablement.
+describe('useShareConversations', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('should fetch conversations when enabled for non-installed apps', async () => {
+ // Arrange
+ const params = {
+ isInstalledApp: false,
+ appId: undefined,
+ pinned: true,
+ limit: 50,
+ }
+ const response = createConversationData()
+ mockFetchConversations.mockResolvedValueOnce(response)
+
+ // Act
+ const { result, queryClient } = renderShareHook(() => useShareConversations(params))
+
+ // Assert
+ await waitFor(() => {
+ expect(mockFetchConversations).toHaveBeenCalledWith(false, undefined, undefined, true, 50)
+ })
+ await waitFor(() => {
+ expect(result.current.data).toEqual(response)
+ })
+ expect(queryClient.getQueryCache().find({ queryKey: shareQueryKeys.conversationList(params) })).toBeDefined()
+ })
+
+ it('should not fetch conversations when installed app lacks appId', async () => {
+ // Arrange
+ const params = {
+ isInstalledApp: true,
+ appId: undefined,
+ }
+
+ // Act
+ const { result } = renderShareHook(() => useShareConversations(params))
+
+ // Assert
+ await waitFor(() => {
+ expect(result.current.fetchStatus).toBe('idle')
+ })
+ expect(mockFetchConversations).not.toHaveBeenCalled()
+ })
+})
+
+// Scenario: chat list queries respect conversation ID and app installation rules.
+describe('useShareChatList', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('should fetch chat list when conversationId is provided', async () => {
+ // Arrange
+ const params = {
+ conversationId: 'conversation-1',
+ isInstalledApp: true,
+ appId: 'app-1',
+ }
+ const response = { data: [] }
+ mockFetchChatList.mockResolvedValueOnce(response)
+
+ // Act
+ const { result } = renderShareHook(() => useShareChatList(params))
+
+ // Assert
+ await waitFor(() => {
+ expect(mockFetchChatList).toHaveBeenCalledWith('conversation-1', true, 'app-1')
+ })
+ await waitFor(() => {
+ expect(result.current.data).toEqual(response)
+ })
+ })
+
+ it('should not fetch chat list when conversationId is empty', async () => {
+ // Arrange
+ const params = {
+ conversationId: '',
+ isInstalledApp: false,
+ appId: undefined,
+ }
+
+ // Act
+ const { result } = renderShareHook(() => useShareChatList(params))
+
+ // Assert
+ await waitFor(() => {
+ expect(result.current.fetchStatus).toBe('idle')
+ })
+ expect(mockFetchChatList).not.toHaveBeenCalled()
+ })
+})
+
+// Scenario: conversation name queries follow enabled flags and installation constraints.
+describe('useShareConversationName', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('should fetch conversation name when enabled and conversationId exists', async () => {
+ // Arrange
+ const params = {
+ conversationId: 'conversation-2',
+ isInstalledApp: false,
+ appId: undefined,
+ }
+ const response = createConversationItem({ id: 'conversation-2', name: 'Generated' })
+ mockGenerationConversationName.mockResolvedValueOnce(response)
+
+ // Act
+ const { result } = renderShareHook(() => useShareConversationName(params))
+
+ // Assert
+ await waitFor(() => {
+ expect(mockGenerationConversationName).toHaveBeenCalledWith(false, undefined, 'conversation-2')
+ })
+ await waitFor(() => {
+ expect(result.current.data).toEqual(response)
+ })
+ })
+
+ it('should not fetch conversation name when disabled via options', async () => {
+ // Arrange
+ const params = {
+ conversationId: 'conversation-3',
+ isInstalledApp: false,
+ appId: undefined,
+ }
+
+ // Act
+ const { result } = renderShareHook(() => useShareConversationName(params, { enabled: false }))
+
+ // Assert
+ await waitFor(() => {
+ expect(result.current.fetchStatus).toBe('idle')
+ })
+ expect(mockGenerationConversationName).not.toHaveBeenCalled()
+ })
+})
+
+// Scenario: invalidation helper clears share conversation caches.
+describe('useInvalidateShareConversations', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('should invalidate share conversations query key when invoked', () => {
+ // Arrange
+ const { result, queryClient } = renderShareHook(() => useInvalidateShareConversations())
+ const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries')
+
+ // Act
+ act(() => {
+ result.current()
+ })
+
+ // Assert
+ expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: shareQueryKeys.conversations })
+ })
+})
diff --git a/web/service/use-share.ts b/web/service/use-share.ts
index a5e0a11100..4dd43e06aa 100644
--- a/web/service/use-share.ts
+++ b/web/service/use-share.ts
@@ -1,11 +1,58 @@
+import type { AppConversationData, ConversationItem } from '@/models/share'
import { useQuery } from '@tanstack/react-query'
-import { fetchAppInfo, fetchAppMeta, fetchAppParams, getAppAccessModeByAppCode } from './share'
+import {
+ fetchAppInfo,
+ fetchAppMeta,
+ fetchAppParams,
+ fetchChatList,
+ fetchConversations,
+ generationConversationName,
+ getAppAccessModeByAppCode,
+} from './share'
+import { useInvalid } from './use-base'
const NAME_SPACE = 'webapp'
+type ShareConversationsParams = {
+ isInstalledApp: boolean
+ appId?: string
+ lastId?: string
+ pinned?: boolean
+ limit?: number
+}
+
+type ShareChatListParams = {
+ conversationId: string
+ isInstalledApp: boolean
+ appId?: string
+}
+
+type ShareConversationNameParams = {
+ conversationId: string
+ isInstalledApp: boolean
+ appId?: string
+}
+
+type ShareQueryOptions = {
+ enabled?: boolean
+ refetchOnWindowFocus?: boolean
+ refetchOnReconnect?: boolean
+}
+
+export const shareQueryKeys = {
+ appAccessMode: (code: string | null) => [NAME_SPACE, 'appAccessMode', code] as const,
+ appInfo: [NAME_SPACE, 'appInfo'] as const,
+ appParams: [NAME_SPACE, 'appParams'] as const,
+ appMeta: [NAME_SPACE, 'appMeta'] as const,
+ conversations: [NAME_SPACE, 'conversations'] as const,
+ conversationList: (params: ShareConversationsParams) => [NAME_SPACE, 'conversations', params] as const,
+ chatList: (params: ShareChatListParams) => [NAME_SPACE, 'chatList', params] as const,
+ conversationName: (params: ShareConversationNameParams) => [NAME_SPACE, 'conversationName', params] as const,
+}
+
export const useGetWebAppAccessModeByCode = (code: string | null) => {
return useQuery({
- queryKey: [NAME_SPACE, 'appAccessMode', code],
+ queryKey: shareQueryKeys.appAccessMode(code),
queryFn: () => getAppAccessModeByAppCode(code!),
enabled: !!code,
staleTime: 0, // backend change the access mode may cause the logic error. Because /permission API is no cached.
@@ -15,7 +62,7 @@ export const useGetWebAppAccessModeByCode = (code: string | null) => {
export const useGetWebAppInfo = () => {
return useQuery({
- queryKey: [NAME_SPACE, 'appInfo'],
+ queryKey: shareQueryKeys.appInfo,
queryFn: () => {
return fetchAppInfo()
},
@@ -24,7 +71,7 @@ export const useGetWebAppInfo = () => {
export const useGetWebAppParams = () => {
return useQuery({
- queryKey: [NAME_SPACE, 'appParams'],
+ queryKey: shareQueryKeys.appParams,
queryFn: () => {
return fetchAppParams(false)
},
@@ -33,9 +80,67 @@ export const useGetWebAppParams = () => {
export const useGetWebAppMeta = () => {
return useQuery({
- queryKey: [NAME_SPACE, 'appMeta'],
+ queryKey: shareQueryKeys.appMeta,
queryFn: () => {
return fetchAppMeta(false)
},
})
}
+
+export const useShareConversations = (params: ShareConversationsParams, options: ShareQueryOptions = {}) => {
+ const {
+ enabled = true,
+ refetchOnReconnect,
+ refetchOnWindowFocus,
+ } = options
+ const isEnabled = enabled && (!params.isInstalledApp || !!params.appId)
+ return useQuery({
+ queryKey: shareQueryKeys.conversationList(params),
+ queryFn: () => fetchConversations(
+ params.isInstalledApp,
+ params.appId,
+ params.lastId,
+ params.pinned,
+ params.limit,
+ ),
+ enabled: isEnabled,
+ refetchOnReconnect,
+ refetchOnWindowFocus,
+ })
+}
+
+export const useShareChatList = (params: ShareChatListParams, options: ShareQueryOptions = {}) => {
+ const {
+ enabled = true,
+ refetchOnReconnect,
+ refetchOnWindowFocus,
+ } = options
+ const isEnabled = enabled && (!params.isInstalledApp || !!params.appId) && !!params.conversationId
+ return useQuery({
+ queryKey: shareQueryKeys.chatList(params),
+ queryFn: () => fetchChatList(params.conversationId, params.isInstalledApp, params.appId),
+ enabled: isEnabled,
+ refetchOnReconnect,
+ refetchOnWindowFocus,
+ })
+}
+
+export const useShareConversationName = (params: ShareConversationNameParams, options: ShareQueryOptions = {}) => {
+ const {
+ enabled = true,
+ refetchOnReconnect,
+ refetchOnWindowFocus,
+ } = options
+ const isEnabled = enabled && (!params.isInstalledApp || !!params.appId) && !!params.conversationId
+ return useQuery({
+ queryKey: shareQueryKeys.conversationName(params),
+ queryFn: () => generationConversationName(params.isInstalledApp, params.appId, params.conversationId),
+ enabled: isEnabled,
+ refetchOnReconnect,
+ refetchOnWindowFocus,
+ })
+}
+
+export const useInvalidateShareConversations = () => {
+ return useInvalid(shareQueryKeys.conversations)
+}