diff --git a/web/app/components/apps/list.spec.tsx b/web/app/components/apps/list.spec.tsx index e5854f68b4..07c30cd588 100644 --- a/web/app/components/apps/list.spec.tsx +++ b/web/app/components/apps/list.spec.tsx @@ -10,6 +10,7 @@ const mockReplace = vi.fn() const mockRouter = { replace: mockReplace } vi.mock('next/navigation', () => ({ useRouter: () => mockRouter, + useSearchParams: () => new URLSearchParams(''), })) // Mock app context diff --git a/web/app/components/apps/list.tsx b/web/app/components/apps/list.tsx index 290a73fc7c..e5c9954626 100644 --- a/web/app/components/apps/list.tsx +++ b/web/app/components/apps/list.tsx @@ -12,6 +12,7 @@ import { useDebounceFn } from 'ahooks' import dynamic from 'next/dynamic' import { useRouter, + useSearchParams, } from 'next/navigation' import { parseAsString, useQueryState } from 'nuqs' import { useCallback, useEffect, useRef, useState } from 'react' @@ -36,6 +37,16 @@ import useAppsQueryState from './hooks/use-apps-query-state' import { useDSLDragDrop } from './hooks/use-dsl-drag-drop' import NewAppCard from './new-app-card' +// Define valid tabs at module scope to avoid re-creation on each render and stale closures +const validTabs = new Set([ + 'all', + AppModeEnum.WORKFLOW, + AppModeEnum.ADVANCED_CHAT, + AppModeEnum.CHAT, + AppModeEnum.AGENT_CHAT, + AppModeEnum.COMPLETION, +]) + const TagManagementModal = dynamic(() => import('@/app/components/base/tag-management'), { ssr: false, }) @@ -47,12 +58,41 @@ const List = () => { const { t } = useTranslation() const { systemFeatures } = useGlobalPublicStore() const router = useRouter() + const searchParams = useSearchParams() const { isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator, isLoadingCurrentWorkspace } = useAppContext() const showTagManagementModal = useTagStore(s => s.showTagManagementModal) const [activeTab, setActiveTab] = useQueryState( 'category', parseAsString.withDefault('all').withOptions({ history: 'push' }), ) + + // valid tabs for apps list; anything else should fallback to 'all' + + // 1) Normalize legacy/incorrect query params like ?mode=discover -> ?category=all + useEffect(() => { + // avoid running on server + if (typeof window === 'undefined') + return + const mode = searchParams.get('mode') + if (!mode) + return + const url = new URL(window.location.href) + url.searchParams.delete('mode') + if (validTabs.has(mode)) { + // migrate to category key + url.searchParams.set('category', mode) + } + else { + url.searchParams.set('category', 'all') + } + router.replace(url.pathname + url.search) + }, [router, searchParams]) + + // 2) If category has an invalid value (e.g., 'discover'), reset to 'all' + useEffect(() => { + if (!validTabs.has(activeTab)) + setActiveTab('all') + }, [activeTab, setActiveTab]) const { query: { tagIDs = [], keywords = '', isCreatedByMe: queryIsCreatedByMe = false }, setQuery } = useAppsQueryState() const [isCreatedByMe, setIsCreatedByMe] = useState(queryIsCreatedByMe) const [tagFilterValue, setTagFilterValue] = useState(tagIDs) diff --git a/web/service/use-apps.ts b/web/service/use-apps.ts index d16d44af20..74e7662492 100644 --- a/web/service/use-apps.ts +++ b/web/service/use-apps.ts @@ -10,13 +10,14 @@ import type { AppVoicesListResponse, WorkflowDailyConversationsResponse, } from '@/models/app' -import type { App, AppModeEnum } from '@/types/app' +import type { App } from '@/types/app' import { keepPreviousData, useInfiniteQuery, useQuery, useQueryClient, } from '@tanstack/react-query' +import { AppModeEnum } from '@/types/app' import { get, post } from './base' import { useInvalid } from './use-base' @@ -36,6 +37,16 @@ type DateRangeParams = { end?: string } +// Allowed app modes for filtering; defined at module scope to avoid re-creating on every call +const allowedModes = new Set([ + 'all', + AppModeEnum.WORKFLOW, + AppModeEnum.ADVANCED_CHAT, + AppModeEnum.CHAT, + AppModeEnum.AGENT_CHAT, + AppModeEnum.COMPLETION, +]) + const normalizeAppListParams = (params: AppListParams) => { const { page = 1, @@ -46,11 +57,13 @@ const normalizeAppListParams = (params: AppListParams) => { is_created_by_me, } = params + const safeMode = allowedModes.has((mode as any)) ? mode : undefined + return { page, limit, name, - ...(mode && mode !== 'all' ? { mode } : {}), + ...(safeMode && safeMode !== 'all' ? { mode: safeMode } : {}), ...(tag_ids?.length ? { tag_ids } : {}), ...(is_created_by_me ? { is_created_by_me } : {}), }