From 58550a5191ef61bc4cd5d2fcc9d46ca66d708372 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Wed, 7 Jan 2026 19:33:23 +0800 Subject: [PATCH 01/58] refactor: replace marketplace context with nuqs + jotai + tanstack query MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Delete context.tsx, use nuqs for URL state (q, category, tags) - Use jotai for local state (sort) via useMarketplaceSort hook - Use TanStack Query for reactive data fetching - Add useMarketplaceMoreClick hook to avoid prop drilling - Add query-keys.ts for centralized query key management - Add marketplace-client.tsx for client-side hydration - Simplify component props by using hooks directly πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- web/app/(commonLayout)/plugins/page.tsx | 2 +- .../components/plugins/marketplace/atoms.ts | 10 + .../plugins/marketplace/context.tsx | 332 ----------- .../components/plugins/marketplace/hooks.ts | 355 +++++++----- .../plugins/marketplace/index.spec.tsx | 516 +++++------------- .../components/plugins/marketplace/index.tsx | 62 +-- .../plugins/marketplace/list/index.spec.tsx | 225 ++------ .../plugins/marketplace/list/index.tsx | 3 - .../marketplace/list/list-with-collection.tsx | 9 +- .../plugins/marketplace/list/list-wrapper.tsx | 98 ++-- .../marketplace/marketplace-client.tsx | 32 ++ .../marketplace/plugin-type-switch.tsx | 76 +-- .../plugins/marketplace/query-keys.ts | 12 + .../search-box/search-box-wrapper.tsx | 17 +- .../marketplace/sort-dropdown/index.tsx | 5 +- .../sticky-search-and-switch-wrapper.tsx | 10 +- web/app/components/tools/marketplace/hooks.ts | 74 +-- web/context/query-client-server.ts | 20 + web/context/query-client.tsx | 36 +- web/hooks/use-query-params.ts | 60 +- 20 files changed, 680 insertions(+), 1274 deletions(-) create mode 100644 web/app/components/plugins/marketplace/atoms.ts delete mode 100644 web/app/components/plugins/marketplace/context.tsx create mode 100644 web/app/components/plugins/marketplace/marketplace-client.tsx create mode 100644 web/app/components/plugins/marketplace/query-keys.ts create mode 100644 web/context/query-client-server.ts diff --git a/web/app/(commonLayout)/plugins/page.tsx b/web/app/(commonLayout)/plugins/page.tsx index 81bda3a8a31dff..8b954a37b59a81 100644 --- a/web/app/(commonLayout)/plugins/page.tsx +++ b/web/app/(commonLayout)/plugins/page.tsx @@ -6,7 +6,7 @@ const PluginList = async () => { return ( } - marketplace={} + marketplace={} /> ) } diff --git a/web/app/components/plugins/marketplace/atoms.ts b/web/app/components/plugins/marketplace/atoms.ts new file mode 100644 index 00000000000000..035a887ada97eb --- /dev/null +++ b/web/app/components/plugins/marketplace/atoms.ts @@ -0,0 +1,10 @@ +import type { PluginsSort } from './types' +import { atom, useAtom } from 'jotai' +import { DEFAULT_SORT } from './constants' + +// Sort state - not persisted in URL +const marketplaceSortAtom = atom(DEFAULT_SORT) + +export function useMarketplaceSort() { + return useAtom(marketplaceSortAtom) +} diff --git a/web/app/components/plugins/marketplace/context.tsx b/web/app/components/plugins/marketplace/context.tsx deleted file mode 100644 index 31b6a7f592e6e3..00000000000000 --- a/web/app/components/plugins/marketplace/context.tsx +++ /dev/null @@ -1,332 +0,0 @@ -'use client' - -import type { - ReactNode, -} from 'react' -import type { TagKey } from '../constants' -import type { Plugin } from '../types' -import type { - MarketplaceCollection, - PluginsSort, - SearchParams, - SearchParamsFromCollection, -} from './types' -import { debounce } from 'es-toolkit/compat' -import { noop } from 'es-toolkit/function' -import { - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react' -import { - createContext, - useContextSelector, -} from 'use-context-selector' -import { useMarketplaceFilters } from '@/hooks/use-query-params' -import { useInstalledPluginList } from '@/service/use-plugins' -import { - getValidCategoryKeys, - getValidTagKeys, -} from '../utils' -import { DEFAULT_SORT } from './constants' -import { - useMarketplaceCollectionsAndPlugins, - useMarketplaceContainerScroll, - useMarketplacePlugins, -} from './hooks' -import { PLUGIN_TYPE_SEARCH_MAP } from './plugin-type-switch' -import { - getMarketplaceListCondition, - getMarketplaceListFilterType, -} from './utils' - -export type MarketplaceContextValue = { - searchPluginText: string - handleSearchPluginTextChange: (text: string) => void - filterPluginTags: string[] - handleFilterPluginTagsChange: (tags: string[]) => void - activePluginType: string - handleActivePluginTypeChange: (type: string) => void - page: number - handlePageChange: () => void - plugins?: Plugin[] - pluginsTotal?: number - resetPlugins: () => void - sort: PluginsSort - handleSortChange: (sort: PluginsSort) => void - handleQueryPlugins: () => void - handleMoreClick: (searchParams: SearchParamsFromCollection) => void - marketplaceCollectionsFromClient?: MarketplaceCollection[] - setMarketplaceCollectionsFromClient: (collections: MarketplaceCollection[]) => void - marketplaceCollectionPluginsMapFromClient?: Record - setMarketplaceCollectionPluginsMapFromClient: (map: Record) => void - isLoading: boolean - isSuccessCollections: boolean -} - -export const MarketplaceContext = createContext({ - searchPluginText: '', - handleSearchPluginTextChange: noop, - filterPluginTags: [], - handleFilterPluginTagsChange: noop, - activePluginType: 'all', - handleActivePluginTypeChange: noop, - page: 1, - handlePageChange: noop, - plugins: undefined, - pluginsTotal: 0, - resetPlugins: noop, - sort: DEFAULT_SORT, - handleSortChange: noop, - handleQueryPlugins: noop, - handleMoreClick: noop, - marketplaceCollectionsFromClient: [], - setMarketplaceCollectionsFromClient: noop, - marketplaceCollectionPluginsMapFromClient: {}, - setMarketplaceCollectionPluginsMapFromClient: noop, - isLoading: false, - isSuccessCollections: false, -}) - -type MarketplaceContextProviderProps = { - children: ReactNode - searchParams?: SearchParams - shouldExclude?: boolean - scrollContainerId?: string - showSearchParams?: boolean -} - -export function useMarketplaceContext(selector: (value: MarketplaceContextValue) => any) { - return useContextSelector(MarketplaceContext, selector) -} - -export const MarketplaceContextProvider = ({ - children, - searchParams, - shouldExclude, - scrollContainerId, - showSearchParams, -}: MarketplaceContextProviderProps) => { - // Use nuqs hook for URL-based filter state - const [urlFilters, setUrlFilters] = useMarketplaceFilters() - - const { data, isSuccess } = useInstalledPluginList(!shouldExclude) - const exclude = useMemo(() => { - if (shouldExclude) - return data?.plugins.map(plugin => plugin.plugin_id) - }, [data?.plugins, shouldExclude]) - - // Initialize from URL params (legacy support) or use nuqs state - const queryFromSearchParams = searchParams?.q || urlFilters.q - const tagsFromSearchParams = getValidTagKeys(urlFilters.tags as TagKey[]) - const hasValidTags = !!tagsFromSearchParams.length - const hasValidCategory = getValidCategoryKeys(urlFilters.category) - const categoryFromSearchParams = hasValidCategory || PLUGIN_TYPE_SEARCH_MAP.all - - const [searchPluginText, setSearchPluginText] = useState(queryFromSearchParams) - const searchPluginTextRef = useRef(searchPluginText) - const [filterPluginTags, setFilterPluginTags] = useState(tagsFromSearchParams) - const filterPluginTagsRef = useRef(filterPluginTags) - const [activePluginType, setActivePluginType] = useState(categoryFromSearchParams) - const activePluginTypeRef = useRef(activePluginType) - const [sort, setSort] = useState(DEFAULT_SORT) - const sortRef = useRef(sort) - const { - marketplaceCollections: marketplaceCollectionsFromClient, - setMarketplaceCollections: setMarketplaceCollectionsFromClient, - marketplaceCollectionPluginsMap: marketplaceCollectionPluginsMapFromClient, - setMarketplaceCollectionPluginsMap: setMarketplaceCollectionPluginsMapFromClient, - queryMarketplaceCollectionsAndPlugins, - isLoading, - isSuccess: isSuccessCollections, - } = useMarketplaceCollectionsAndPlugins() - const { - plugins, - total: pluginsTotal, - resetPlugins, - queryPlugins, - queryPluginsWithDebounced, - cancelQueryPluginsWithDebounced, - isLoading: isPluginsLoading, - fetchNextPage: fetchNextPluginsPage, - hasNextPage: hasNextPluginsPage, - page: pluginsPage, - } = useMarketplacePlugins() - const page = Math.max(pluginsPage || 0, 1) - - useEffect(() => { - if (queryFromSearchParams || hasValidTags || hasValidCategory) { - queryPlugins({ - query: queryFromSearchParams, - category: hasValidCategory, - tags: hasValidTags ? tagsFromSearchParams : [], - sortBy: sortRef.current.sortBy, - sortOrder: sortRef.current.sortOrder, - type: getMarketplaceListFilterType(activePluginTypeRef.current), - }) - } - else { - if (shouldExclude && isSuccess) { - queryMarketplaceCollectionsAndPlugins({ - exclude, - type: getMarketplaceListFilterType(activePluginTypeRef.current), - }) - } - } - }, [queryPlugins, queryMarketplaceCollectionsAndPlugins, isSuccess, exclude]) - - const handleQueryMarketplaceCollectionsAndPlugins = useCallback(() => { - queryMarketplaceCollectionsAndPlugins({ - category: activePluginTypeRef.current === PLUGIN_TYPE_SEARCH_MAP.all ? undefined : activePluginTypeRef.current, - condition: getMarketplaceListCondition(activePluginTypeRef.current), - exclude, - type: getMarketplaceListFilterType(activePluginTypeRef.current), - }) - resetPlugins() - }, [exclude, queryMarketplaceCollectionsAndPlugins, resetPlugins]) - - const applyUrlFilters = useCallback(() => { - if (!showSearchParams) - return - const nextFilters = { - q: searchPluginTextRef.current, - category: activePluginTypeRef.current, - tags: filterPluginTagsRef.current, - } - const categoryChanged = urlFilters.category !== nextFilters.category - setUrlFilters(nextFilters, { - history: categoryChanged ? 'push' : 'replace', - }) - }, [setUrlFilters, showSearchParams, urlFilters.category]) - - const debouncedUpdateSearchParams = useMemo(() => debounce(() => { - applyUrlFilters() - }, 500), [applyUrlFilters]) - - const handleUpdateSearchParams = useCallback((debounced?: boolean) => { - if (debounced) { - debouncedUpdateSearchParams() - } - else { - applyUrlFilters() - } - }, [applyUrlFilters, debouncedUpdateSearchParams]) - - const handleQueryPlugins = useCallback((debounced?: boolean) => { - handleUpdateSearchParams(debounced) - if (debounced) { - queryPluginsWithDebounced({ - query: searchPluginTextRef.current, - category: activePluginTypeRef.current === PLUGIN_TYPE_SEARCH_MAP.all ? undefined : activePluginTypeRef.current, - tags: filterPluginTagsRef.current, - sortBy: sortRef.current.sortBy, - sortOrder: sortRef.current.sortOrder, - exclude, - type: getMarketplaceListFilterType(activePluginTypeRef.current), - }) - } - else { - queryPlugins({ - query: searchPluginTextRef.current, - category: activePluginTypeRef.current === PLUGIN_TYPE_SEARCH_MAP.all ? undefined : activePluginTypeRef.current, - tags: filterPluginTagsRef.current, - sortBy: sortRef.current.sortBy, - sortOrder: sortRef.current.sortOrder, - exclude, - type: getMarketplaceListFilterType(activePluginTypeRef.current), - }) - } - }, [exclude, queryPluginsWithDebounced, queryPlugins, handleUpdateSearchParams]) - - const handleQuery = useCallback((debounced?: boolean) => { - if (!searchPluginTextRef.current && !filterPluginTagsRef.current.length) { - handleUpdateSearchParams(debounced) - cancelQueryPluginsWithDebounced() - handleQueryMarketplaceCollectionsAndPlugins() - return - } - - handleQueryPlugins(debounced) - }, [handleQueryMarketplaceCollectionsAndPlugins, handleQueryPlugins, cancelQueryPluginsWithDebounced, handleUpdateSearchParams]) - - const handleSearchPluginTextChange = useCallback((text: string) => { - setSearchPluginText(text) - searchPluginTextRef.current = text - - handleQuery(true) - }, [handleQuery]) - - const handleFilterPluginTagsChange = useCallback((tags: string[]) => { - setFilterPluginTags(tags) - filterPluginTagsRef.current = tags - - handleQuery() - }, [handleQuery]) - - const handleActivePluginTypeChange = useCallback((type: string) => { - setActivePluginType(type) - activePluginTypeRef.current = type - - handleQuery() - }, [handleQuery]) - - const handleSortChange = useCallback((sort: PluginsSort) => { - setSort(sort) - sortRef.current = sort - - handleQueryPlugins() - }, [handleQueryPlugins]) - - const handlePageChange = useCallback(() => { - if (hasNextPluginsPage) - fetchNextPluginsPage() - }, [fetchNextPluginsPage, hasNextPluginsPage]) - - const handleMoreClick = useCallback((searchParams: SearchParamsFromCollection) => { - setSearchPluginText(searchParams?.query || '') - searchPluginTextRef.current = searchParams?.query || '' - setSort({ - sortBy: searchParams?.sort_by || DEFAULT_SORT.sortBy, - sortOrder: searchParams?.sort_order || DEFAULT_SORT.sortOrder, - }) - sortRef.current = { - sortBy: searchParams?.sort_by || DEFAULT_SORT.sortBy, - sortOrder: searchParams?.sort_order || DEFAULT_SORT.sortOrder, - } - handleQueryPlugins() - }, [handleQueryPlugins]) - - useMarketplaceContainerScroll(handlePageChange, scrollContainerId) - - return ( - - {children} - - ) -} diff --git a/web/app/components/plugins/marketplace/hooks.ts b/web/app/components/plugins/marketplace/hooks.ts index 11558e8c96f50a..0d47ea9f5919fd 100644 --- a/web/app/components/plugins/marketplace/hooks.ts +++ b/web/app/components/plugins/marketplace/hooks.ts @@ -1,6 +1,6 @@ -import type { - Plugin, -} from '../types' +'use client' + +import type { Plugin } from '../types' import type { CollectionsAndPluginsSearchParams, MarketplaceCollection, @@ -10,139 +10,156 @@ import type { PluginsFromMarketplaceResponse } from '@/app/components/plugins/ty import { useInfiniteQuery, useQuery, - useQueryClient, } from '@tanstack/react-query' import { useDebounceFn } from 'ahooks' import { useCallback, useEffect, + useMemo, useState, } from 'react' +import { useMarketplaceFilters } from '@/hooks/use-query-params' import { postMarketplace } from '@/service/base' -import { SCROLL_BOTTOM_THRESHOLD } from './constants' +import { useMarketplaceSort } from './atoms' +import { DEFAULT_SORT, SCROLL_BOTTOM_THRESHOLD } from './constants' +import { PLUGIN_TYPE_SEARCH_MAP } from './plugin-type-switch' +import { marketplaceKeys } from './query-keys' import { getFormattedPlugin, getMarketplaceCollectionsAndPlugins, + getMarketplaceListCondition, + getMarketplaceListFilterType, getMarketplacePluginsByCollectionId, } from './utils' -export const useMarketplaceCollectionsAndPlugins = () => { - const [queryParams, setQueryParams] = useState() - const [marketplaceCollectionsOverride, setMarketplaceCollections] = useState() - const [marketplaceCollectionPluginsMapOverride, setMarketplaceCollectionPluginsMap] = useState>() +export { marketplaceKeys } - const { - data, - isFetching, - isSuccess, - isPending, - } = useQuery({ - queryKey: ['marketplaceCollectionsAndPlugins', queryParams], - queryFn: ({ signal }) => getMarketplaceCollectionsAndPlugins(queryParams, { signal }), - enabled: queryParams !== undefined, - staleTime: 1000 * 60 * 5, - gcTime: 1000 * 60 * 10, - retry: false, +// Stable empty object for query key matching with server prefetch +const EMPTY_PARAMS = {} + +/** + * Fetches marketplace collections and their plugins + */ +export function useMarketplaceCollectionsAndPlugins( + params?: CollectionsAndPluginsSearchParams, + options?: { enabled?: boolean }, +) { + return useQuery({ + queryKey: marketplaceKeys.collections(params), + queryFn: ({ signal }) => getMarketplaceCollectionsAndPlugins(params, { signal }), + enabled: options?.enabled ?? true, }) +} + +/** + * Reactive hook that automatically fetches collections based on current state + */ +export function useMarketplaceCollectionsData() { + const [urlFilters] = useMarketplaceFilters() + + const activePluginType = urlFilters.category + const searchPluginText = urlFilters.q + const filterPluginTags = urlFilters.tags - const queryMarketplaceCollectionsAndPlugins = useCallback((query?: CollectionsAndPluginsSearchParams) => { - setQueryParams(query ? { ...query } : {}) - }, []) - const isLoading = !!queryParams && (isFetching || isPending) + const isSearchMode = !!searchPluginText || filterPluginTags.length > 0 + + const collectionsParams = useMemo(() => { + if (activePluginType === PLUGIN_TYPE_SEARCH_MAP.all) { + return EMPTY_PARAMS + } + return { + category: activePluginType === PLUGIN_TYPE_SEARCH_MAP.all ? undefined : activePluginType, + condition: getMarketplaceListCondition(activePluginType), + type: getMarketplaceListFilterType(activePluginType), + } + }, [activePluginType]) + + const collectionsQuery = useMarketplaceCollectionsAndPlugins( + collectionsParams, + { enabled: !isSearchMode }, + ) return { - marketplaceCollections: marketplaceCollectionsOverride ?? data?.marketplaceCollections, - setMarketplaceCollections, - marketplaceCollectionPluginsMap: marketplaceCollectionPluginsMapOverride ?? data?.marketplaceCollectionPluginsMap, - setMarketplaceCollectionPluginsMap, - queryMarketplaceCollectionsAndPlugins, - isLoading, - isSuccess, + marketplaceCollections: collectionsQuery.data?.marketplaceCollections, + marketplaceCollectionPluginsMap: collectionsQuery.data?.marketplaceCollectionPluginsMap, + isLoading: collectionsQuery.isLoading, + isSearchMode, } } -export const useMarketplacePluginsByCollectionId = ( +/** + * Fetches plugins for a specific collection + */ +export function useMarketplacePluginsByCollectionId( collectionId?: string, - query?: CollectionsAndPluginsSearchParams, -) => { - const { - data, - isFetching, - isSuccess, - isPending, - } = useQuery({ - queryKey: ['marketplaceCollectionPlugins', collectionId, query], + params?: CollectionsAndPluginsSearchParams, +) { + const query = useQuery({ + queryKey: marketplaceKeys.collectionPlugins(collectionId || '', params), queryFn: ({ signal }) => { if (!collectionId) return Promise.resolve([]) - return getMarketplacePluginsByCollectionId(collectionId, query, { signal }) + return getMarketplacePluginsByCollectionId(collectionId, params, { signal }) }, enabled: !!collectionId, - staleTime: 1000 * 60 * 5, - gcTime: 1000 * 60 * 10, - retry: false, }) return { - plugins: data || [], - isLoading: !!collectionId && (isFetching || isPending), - isSuccess, + plugins: query.data || [], + isLoading: query.isLoading, + isSuccess: query.isSuccess, } } -export const useMarketplacePlugins = () => { - const queryClient = useQueryClient() - const [queryParams, setQueryParams] = useState() - - const normalizeParams = useCallback((pluginsSearchParams: PluginsSearchParams) => { - const pageSize = pluginsSearchParams.pageSize || 40 +const DEFAULT_PAGE_SIZE = 40 - return { - ...pluginsSearchParams, - pageSize, - } - }, []) +/** + * Fetches plugins with infinite scroll support - imperative version + * Used by external components (workflow block selectors, etc.) + */ +export function useMarketplacePlugins(initialParams?: PluginsSearchParams) { + const [queryParams, setQueryParams] = useState(initialParams) - const marketplacePluginsQuery = useInfiniteQuery({ - queryKey: ['marketplacePlugins', queryParams], + const query = useInfiniteQuery({ + queryKey: marketplaceKeys.plugins(queryParams), queryFn: async ({ pageParam = 1, signal }) => { if (!queryParams) { return { plugins: [] as Plugin[], total: 0, page: 1, - pageSize: 40, + pageSize: DEFAULT_PAGE_SIZE, } } - const params = normalizeParams(queryParams) const { query, sortBy, sortOrder, category, tags, - exclude, type, - pageSize, - } = params + pageSize = DEFAULT_PAGE_SIZE, + } = queryParams const pluginOrBundle = type === 'bundle' ? 'bundles' : 'plugins' try { - const res = await postMarketplace<{ data: PluginsFromMarketplaceResponse }>(`/${pluginOrBundle}/search/advanced`, { - body: { - page: pageParam, - page_size: pageSize, - query, - sort_by: sortBy, - sort_order: sortOrder, - category: category !== 'all' ? category : '', - tags, - exclude, - type, + const res = await postMarketplace<{ data: PluginsFromMarketplaceResponse }>( + `/${pluginOrBundle}/search/advanced`, + { + body: { + page: pageParam, + page_size: pageSize, + query, + sort_by: sortBy, + sort_order: sortOrder, + category: category !== 'all' ? category : '', + tags, + type, + }, + signal, }, - signal, - }) + ) const resPlugins = res.data.bundles || res.data.plugins || [] return { @@ -168,65 +185,150 @@ export const useMarketplacePlugins = () => { }, initialPageParam: 1, enabled: !!queryParams, - staleTime: 1000 * 60 * 5, - gcTime: 1000 * 60 * 10, - retry: false, }) - const resetPlugins = useCallback(() => { - setQueryParams(undefined) - queryClient.removeQueries({ - queryKey: ['marketplacePlugins'], - }) - }, [queryClient]) - - const handleUpdatePlugins = useCallback((pluginsSearchParams: PluginsSearchParams) => { - setQueryParams(normalizeParams(pluginsSearchParams)) - }, [normalizeParams]) - - const { run: queryPluginsWithDebounced, cancel: cancelQueryPluginsWithDebounced } = useDebounceFn((pluginsSearchParams: PluginsSearchParams) => { - handleUpdatePlugins(pluginsSearchParams) - }, { - wait: 500, - }) - - const hasQuery = !!queryParams - const hasData = marketplacePluginsQuery.data !== undefined - const plugins = hasQuery && hasData - ? marketplacePluginsQuery.data.pages.flatMap(page => page.plugins) - : undefined - const total = hasQuery && hasData ? marketplacePluginsQuery.data.pages?.[0]?.total : undefined - const isPluginsLoading = hasQuery && ( - marketplacePluginsQuery.isPending - || (marketplacePluginsQuery.isFetching && !marketplacePluginsQuery.data) + const { run: queryPluginsDebounced, cancel: cancelDebounced } = useDebounceFn( + (params: PluginsSearchParams) => setQueryParams(params), + { wait: 500 }, ) + const plugins = useMemo(() => { + if (!queryParams || !query.data) + return undefined + return query.data.pages.flatMap(page => page.plugins) + }, [queryParams, query.data]) + + const total = queryParams && query.data ? query.data.pages[0]?.total : undefined + return { plugins, total, - resetPlugins, - queryPlugins: handleUpdatePlugins, - queryPluginsWithDebounced, - cancelQueryPluginsWithDebounced, + queryPlugins: setQueryParams, + queryPluginsDebounced, + queryPluginsWithDebounced: queryPluginsDebounced, + cancelDebounced, + cancelQueryPluginsWithDebounced: cancelDebounced, + resetPlugins: useCallback(() => setQueryParams(undefined), []), + isLoading: !!queryParams && query.isPending, + isFetchingNextPage: query.isFetchingNextPage, + hasNextPage: query.hasNextPage, + fetchNextPage: query.fetchNextPage, + page: query.data?.pages?.length || 0, + } +} + +/** + * Reactive hook that automatically fetches plugins based on current state + */ +export function useMarketplacePluginsData() { + const [urlFilters] = useMarketplaceFilters() + const [sort, setSort] = useMarketplaceSort() + + const searchPluginText = urlFilters.q + const filterPluginTags = urlFilters.tags + const activePluginType = urlFilters.category + + const isSearchMode = !!searchPluginText || filterPluginTags.length > 0 + + // Compute query params reactively - TanStack Query will auto-refetch when this changes + const queryParams = useMemo((): PluginsSearchParams | undefined => { + if (!isSearchMode) + return undefined + return { + query: searchPluginText, + category: activePluginType === PLUGIN_TYPE_SEARCH_MAP.all ? undefined : activePluginType, + tags: filterPluginTags, + sortBy: sort.sortBy, + sortOrder: sort.sortOrder, + type: getMarketplaceListFilterType(activePluginType), + } + }, [isSearchMode, searchPluginText, activePluginType, filterPluginTags, sort]) + + const { + plugins, + total: pluginsTotal, + isLoading: isPluginsLoading, + fetchNextPage, + hasNextPage, + page: pluginsPage, + } = useMarketplacePlugins(queryParams) + + const handleSortChange = useCallback((newSort: typeof sort) => { + setSort(newSort) + }, [setSort]) + + const handlePageChange = useCallback(() => { + if (hasNextPage) + fetchNextPage() + }, [fetchNextPage, hasNextPage]) + + // Scroll pagination + useMarketplaceContainerScroll(handlePageChange) + + return { + plugins, + pluginsTotal, + page: Math.max(pluginsPage, 1), isLoading: isPluginsLoading, - isFetchingNextPage: marketplacePluginsQuery.isFetchingNextPage, - hasNextPage: marketplacePluginsQuery.hasNextPage, - fetchNextPage: marketplacePluginsQuery.fetchNextPage, - page: marketplacePluginsQuery.data?.pages?.length || (marketplacePluginsQuery.isPending && hasQuery ? 1 : 0), + sort, + handleSortChange, } } -export const useMarketplaceContainerScroll = ( +/** + * Hook for handling "More" click in collection headers + */ +export function useMarketplaceMoreClick() { + const [, setUrlFilters] = useMarketplaceFilters() + const [, setSort] = useMarketplaceSort() + + return useCallback((searchParams: { query?: string, sort_by?: string, sort_order?: string }) => { + const newQuery = searchParams?.query || '' + const newSort = { + sortBy: searchParams?.sort_by || DEFAULT_SORT.sortBy, + sortOrder: searchParams?.sort_order || DEFAULT_SORT.sortOrder, + } + setUrlFilters({ q: newQuery }) + setSort(newSort) + }, [setUrlFilters, setSort]) +} + +/** + * Combined hook for marketplace data + */ +export function useMarketplaceData() { + const collectionsData = useMarketplaceCollectionsData() + const pluginsData = useMarketplacePluginsData() + + return { + // Collections data + marketplaceCollections: collectionsData.marketplaceCollections, + marketplaceCollectionPluginsMap: collectionsData.marketplaceCollectionPluginsMap, + + // Plugins data + plugins: pluginsData.plugins, + pluginsTotal: pluginsData.pluginsTotal, + page: pluginsData.page, + + // Sort + sort: pluginsData.sort, + handleSortChange: pluginsData.handleSortChange, + + // Loading state + isLoading: collectionsData.isLoading || pluginsData.isLoading, + } +} + +/** + * Handles scroll-based pagination + */ +export function useMarketplaceContainerScroll( callback: () => void, scrollContainerId = 'marketplace-container', -) => { +) { const handleScroll = useCallback((e: Event) => { const target = e.target as HTMLDivElement - const { - scrollTop, - scrollHeight, - clientHeight, - } = target + const { scrollTop, scrollHeight, clientHeight } = target if (scrollTop + clientHeight >= scrollHeight - SCROLL_BOTTOM_THRESHOLD && scrollTop > 0) callback() }, [callback]) @@ -240,5 +342,8 @@ export const useMarketplaceContainerScroll = ( if (container) container.removeEventListener('scroll', handleScroll) } - }, [handleScroll]) + }, [handleScroll, scrollContainerId]) } + +// Re-export for external usage (workflow block selector, etc.) +export type { MarketplaceCollection, PluginsSearchParams } diff --git a/web/app/components/plugins/marketplace/index.spec.tsx b/web/app/components/plugins/marketplace/index.spec.tsx index b3b1d58dd48846..165941c4d42a39 100644 --- a/web/app/components/plugins/marketplace/index.spec.tsx +++ b/web/app/components/plugins/marketplace/index.spec.tsx @@ -1,4 +1,4 @@ -import type { MarketplaceCollection, SearchParams, SearchParamsFromCollection } from './types' +import type { MarketplaceCollection, SearchParamsFromCollection } from './types' import type { Plugin } from '@/app/components/plugins/types' import { act, fireEvent, render, renderHook, screen } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -42,11 +42,13 @@ vi.mock('@/i18n-config/i18next-config', () => ({ // Mock use-query-params hook const mockSetUrlFilters = vi.fn() +const mockFiltersState = { q: '', tags: [] as string[], category: 'all' } +const mockUseMarketplaceFilters = () => [mockFiltersState, mockSetUrlFilters] as const vi.mock('@/hooks/use-query-params', () => ({ - useMarketplaceFilters: () => [ - { q: '', tags: [], category: '' }, - mockSetUrlFilters, - ], + useMarketplaceFilters: () => mockUseMarketplaceFilters(), + useMarketplaceSearchQuery: () => [mockFiltersState.q, mockSetUrlFilters], + useMarketplaceCategory: () => [mockFiltersState.category, mockSetUrlFilters], + useMarketplaceTags: () => [mockFiltersState.tags, mockSetUrlFilters], })) // Mock use-plugins service @@ -81,6 +83,7 @@ vi.mock('@tanstack/react-query', () => ({ data: enabled ? { marketplaceCollections: [], marketplaceCollectionPluginsMap: {} } : undefined, isFetching: false, isPending: false, + isLoading: false, isSuccess: enabled, } }), @@ -371,43 +374,32 @@ const createMockCollection = (overrides?: Partial): Marke // Shared Test Components // ================================ -// Search input test component - used in multiple tests +// Search input test component - uses setUrlFilters directly const SearchInputTestComponent = () => { - const searchText = useMarketplaceContext(v => v.searchPluginText) - const handleChange = useMarketplaceContext(v => v.handleSearchPluginTextChange) + const [{ q: searchText }, setUrlFilters] = mockUseMarketplaceFilters() return (
handleChange(e.target.value)} + onChange={e => setUrlFilters({ q: e.target.value })} />
{searchText}
) } -// Plugin type change test component +// Plugin type change test component - uses setUrlFilters directly const PluginTypeChangeTestComponent = () => { - const handleChange = useMarketplaceContext(v => v.handleActivePluginTypeChange) + const [, setUrlFilters] = mockUseMarketplaceFilters() return ( - ) } -// Page change test component -const PageChangeTestComponent = () => { - const handlePageChange = useMarketplaceContext(v => v.handlePageChange) - return ( - - ) -} - // ================================ // Constants Tests // ================================ @@ -609,52 +601,31 @@ describe('useMarketplaceCollectionsAndPlugins', () => { vi.clearAllMocks() }) - it('should return initial state correctly', async () => { + it('should return TanStack Query result with standard properties', async () => { const { useMarketplaceCollectionsAndPlugins } = await import('./hooks') const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins()) - expect(result.current.isLoading).toBe(false) - expect(result.current.isSuccess).toBe(false) - expect(result.current.queryMarketplaceCollectionsAndPlugins).toBeDefined() - expect(result.current.setMarketplaceCollections).toBeDefined() - expect(result.current.setMarketplaceCollectionPluginsMap).toBeDefined() + // TanStack Query returns standard properties + expect(result.current.isPending).toBeDefined() + expect(result.current.isSuccess).toBeDefined() + expect(result.current.data).toBeDefined() }) - it('should provide queryMarketplaceCollectionsAndPlugins function', async () => { + it('should return data with collections when query succeeds', async () => { const { useMarketplaceCollectionsAndPlugins } = await import('./hooks') const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins()) - expect(typeof result.current.queryMarketplaceCollectionsAndPlugins).toBe('function') + // Mock returns data when enabled + expect(result.current.data?.marketplaceCollections).toBeDefined() + expect(result.current.data?.marketplaceCollectionPluginsMap).toBeDefined() }) - it('should provide setMarketplaceCollections function', async () => { + it('should accept enabled option', async () => { const { useMarketplaceCollectionsAndPlugins } = await import('./hooks') - const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins()) + const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins({}, { enabled: false })) - expect(typeof result.current.setMarketplaceCollections).toBe('function') - }) - - it('should provide setMarketplaceCollectionPluginsMap function', async () => { - const { useMarketplaceCollectionsAndPlugins } = await import('./hooks') - const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins()) - - expect(typeof result.current.setMarketplaceCollectionPluginsMap).toBe('function') - }) - - it('should return marketplaceCollections from data or override', async () => { - const { useMarketplaceCollectionsAndPlugins } = await import('./hooks') - const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins()) - - // Initial state - expect(result.current.marketplaceCollections).toBeUndefined() - }) - - it('should return marketplaceCollectionPluginsMap from data or override', async () => { - const { useMarketplaceCollectionsAndPlugins } = await import('./hooks') - const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins()) - - // Initial state - expect(result.current.marketplaceCollectionPluginsMap).toBeUndefined() + // When disabled, data should be undefined + expect(result.current.data).toBeUndefined() }) }) @@ -1022,27 +993,25 @@ describe('Advanced Hook Integration', () => { mockPostMarketplaceShouldFail = false }) - it('should test useMarketplaceCollectionsAndPlugins with query call', async () => { + it('should test useMarketplaceCollectionsAndPlugins with query params', async () => { const { useMarketplaceCollectionsAndPlugins } = await import('./hooks') - const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins()) - - // Call the query function - result.current.queryMarketplaceCollectionsAndPlugins({ + const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins({ condition: 'category=tool', type: 'plugin', - }) + })) - expect(result.current.queryMarketplaceCollectionsAndPlugins).toBeDefined() + // Should return standard TanStack Query result + expect(result.current.data).toBeDefined() + expect(result.current.isSuccess).toBeDefined() }) - it('should test useMarketplaceCollectionsAndPlugins with empty query', async () => { + it('should test useMarketplaceCollectionsAndPlugins with empty params', async () => { const { useMarketplaceCollectionsAndPlugins } = await import('./hooks') const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins()) - // Call with undefined (converts to empty object) - result.current.queryMarketplaceCollectionsAndPlugins() - - expect(result.current.queryMarketplaceCollectionsAndPlugins).toBeDefined() + // Should return standard TanStack Query result + expect(result.current.data).toBeDefined() + expect(result.current.isSuccess).toBeDefined() }) it('should test useMarketplacePluginsByCollectionId with different params', async () => { @@ -1178,18 +1147,18 @@ describe('Direct queryFn Coverage', () => { it('should test useMarketplaceCollectionsAndPlugins queryFn', async () => { const { useMarketplaceCollectionsAndPlugins } = await import('./hooks') - const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins()) - - // Trigger query to enable and capture queryFn - result.current.queryMarketplaceCollectionsAndPlugins({ + const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins({ condition: 'category=tool', - }) + })) + // TanStack Query captures queryFn internally if (capturedQueryFn) { const controller = new AbortController() const response = await capturedQueryFn({ signal: controller.signal }) expect(response).toBeDefined() } + + expect(result.current.data).toBeDefined() }) it('should test queryFn with all category', async () => { @@ -1532,8 +1501,8 @@ describe('MarketplaceContext', () => { describe('useMarketplaceContext', () => { it('should return selected value from context', () => { const TestComponent = () => { - const searchText = useMarketplaceContext(v => v.searchPluginText) - return
{searchText}
+ const sort = useMarketplaceContext(v => v.sort) + return
{sort.sortBy}
} render( @@ -1542,7 +1511,7 @@ describe('MarketplaceContext', () => { , ) - expect(screen.getByTestId('search-text')).toHaveTextContent('') + expect(screen.getByTestId('sort')).toHaveTextContent('install_count') }) }) @@ -1562,8 +1531,7 @@ describe('MarketplaceContext', () => { mockInfiniteQueryData = undefined const TestComponent = () => { - const activePluginType = useMarketplaceContext(v => v.activePluginType) - const filterPluginTags = useMarketplaceContext(v => v.filterPluginTags) + const [{ category: activePluginType, tags: filterPluginTags }] = mockUseMarketplaceFilters() const sort = useMarketplaceContext(v => v.sort) const page = useMarketplaceContext(v => v.page) @@ -1590,24 +1558,20 @@ describe('MarketplaceContext', () => { expect(screen.getByTestId('page')).toBeInTheDocument() }) - it('should initialize with searchParams from props', () => { - const searchParams: SearchParams = { - q: 'test query', - category: 'tool', - } - + it('should provide searchPluginText from URL state via nuqs hook', () => { const TestComponent = () => { - const searchText = useMarketplaceContext(v => v.searchPluginText) - return
{searchText}
+ const [{ q: searchText }] = mockUseMarketplaceFilters() + return
{searchText || 'empty'}
} render( - + , ) - expect(screen.getByTestId('search')).toHaveTextContent('test query') + // Initial state from mock is empty string + expect(screen.getByTestId('search')).toHaveTextContent('empty') }) it('should provide handleSearchPluginTextChange function', () => { @@ -1620,19 +1584,19 @@ describe('MarketplaceContext', () => { const input = screen.getByTestId('search-input') fireEvent.change(input, { target: { value: 'new search' } }) - expect(screen.getByTestId('search-display')).toHaveTextContent('new search') + // Handler calls setUrlFilters + expect(mockSetUrlFilters).toHaveBeenCalledWith({ q: 'new search' }) }) - it('should provide handleFilterPluginTagsChange function', () => { + it('should react to filter tags changes via URL', () => { const TestComponent = () => { - const tags = useMarketplaceContext(v => v.filterPluginTags) - const handleChange = useMarketplaceContext(v => v.handleFilterPluginTagsChange) + const [{ tags }, setUrlFilters] = mockUseMarketplaceFilters() return (
@@ -1649,19 +1613,19 @@ describe('MarketplaceContext', () => { fireEvent.click(screen.getByTestId('add-tag')) - expect(screen.getByTestId('tags-display')).toHaveTextContent('search,image') + // setUrlFilters is called directly + expect(mockSetUrlFilters).toHaveBeenCalledWith({ tags: ['search', 'image'] }) }) - it('should provide handleActivePluginTypeChange function', () => { + it('should react to plugin type changes via URL', () => { const TestComponent = () => { - const activeType = useMarketplaceContext(v => v.activePluginType) - const handleChange = useMarketplaceContext(v => v.handleActivePluginTypeChange) + const [{ category: activeType }, setUrlFilters] = mockUseMarketplaceFilters() return (
@@ -1678,7 +1642,8 @@ describe('MarketplaceContext', () => { fireEvent.click(screen.getByTestId('change-type')) - expect(screen.getByTestId('type-display')).toHaveTextContent('tool') + // setUrlFilters is called directly + expect(mockSetUrlFilters).toHaveBeenCalledWith({ category: 'tool' }, { history: 'push' }) }) it('should provide handleSortChange function', () => { @@ -1712,7 +1677,7 @@ describe('MarketplaceContext', () => { it('should provide handleMoreClick function', () => { const TestComponent = () => { - const searchText = useMarketplaceContext(v => v.searchPluginText) + const [{ q: searchText }] = mockUseMarketplaceFilters() const sort = useMarketplaceContext(v => v.sort) const handleMoreClick = useMarketplaceContext(v => v.handleMoreClick) @@ -1744,38 +1709,8 @@ describe('MarketplaceContext', () => { fireEvent.click(screen.getByTestId('more-click')) - expect(screen.getByTestId('search-display')).toHaveTextContent('more query') - expect(screen.getByTestId('sort-display')).toHaveTextContent('version_updated_at-DESC') - }) - - it('should provide resetPlugins function', () => { - const TestComponent = () => { - const resetPlugins = useMarketplaceContext(v => v.resetPlugins) - const plugins = useMarketplaceContext(v => v.plugins) - - return ( -
- -
{plugins ? 'has plugins' : 'no plugins'}
-
- ) - } - - render( - - - , - ) - - fireEvent.click(screen.getByTestId('reset-plugins')) - - // Plugins should remain undefined after reset - expect(screen.getByTestId('plugins-display')).toHaveTextContent('no plugins') + // Handler calls setUrlFilters with the query + expect(mockSetUrlFilters).toHaveBeenCalledWith({ q: 'more query' }) }) it('should accept shouldExclude prop', () => { @@ -1802,16 +1737,6 @@ describe('MarketplaceContext', () => { expect(screen.getByTestId('child')).toBeInTheDocument() }) - - it('should accept showSearchParams prop', () => { - render( - -
Child
-
, - ) - - expect(screen.getByTestId('child')).toBeInTheDocument() - }) }) }) @@ -1839,21 +1764,20 @@ describe('PluginTypeSwitch', () => { describe('Rendering', () => { it('should render without crashing', () => { const TestComponent = () => { - const activeType = useMarketplaceContext(v => v.activePluginType) - const handleChange = useMarketplaceContext(v => v.handleActivePluginTypeChange) + const [{ category: activeType }, setUrlFilters] = mockUseMarketplaceFilters() return (
handleChange('all')} + onClick={() => setUrlFilters({ category: 'all' }, { history: 'push' })} data-testid="all-option" > All
handleChange('tool')} + onClick={() => setUrlFilters({ category: 'tool' }, { history: 'push' })} data-testid="tool-option" > Tools @@ -1874,14 +1798,13 @@ describe('PluginTypeSwitch', () => { it('should highlight active plugin type', () => { const TestComponent = () => { - const activeType = useMarketplaceContext(v => v.activePluginType) - const handleChange = useMarketplaceContext(v => v.handleActivePluginTypeChange) + const [{ category: activeType }, setUrlFilters] = mockUseMarketplaceFilters() return (
handleChange('all')} + onClick={() => setUrlFilters({ category: 'all' }, { history: 'push' })} data-testid="all-option" > All @@ -1901,15 +1824,14 @@ describe('PluginTypeSwitch', () => { }) describe('User Interactions', () => { - it('should call handleActivePluginTypeChange when option is clicked', () => { + it('should call setUrlFilters when option is clicked', () => { const TestComponent = () => { - const handleChange = useMarketplaceContext(v => v.handleActivePluginTypeChange) - const activeType = useMarketplaceContext(v => v.activePluginType) + const [{ category: activeType }, setUrlFilters] = mockUseMarketplaceFilters() return (
handleChange('tool')} + onClick={() => setUrlFilters({ category: 'tool' }, { history: 'push' })} data-testid="tool-option" > Tools @@ -1926,19 +1848,18 @@ describe('PluginTypeSwitch', () => { ) fireEvent.click(screen.getByTestId('tool-option')) - expect(screen.getByTestId('active-type')).toHaveTextContent('tool') + expect(mockSetUrlFilters).toHaveBeenCalledWith({ category: 'tool' }, { history: 'push' }) }) it('should update active type when different option is selected', () => { const TestComponent = () => { - const activeType = useMarketplaceContext(v => v.activePluginType) - const handleChange = useMarketplaceContext(v => v.handleActivePluginTypeChange) + const [{ category: activeType }, setUrlFilters] = mockUseMarketplaceFilters() return (
handleChange('model')} + onClick={() => setUrlFilters({ category: 'model' }, { history: 'push' })} data-testid="model-option" > Models @@ -1956,14 +1877,14 @@ describe('PluginTypeSwitch', () => { fireEvent.click(screen.getByTestId('model-option')) - expect(screen.getByTestId('active-display')).toHaveTextContent('model') + expect(mockSetUrlFilters).toHaveBeenCalledWith({ category: 'model' }, { history: 'push' }) }) }) describe('Props', () => { it('should accept locale prop', () => { const TestComponent = () => { - const activeType = useMarketplaceContext(v => v.activePluginType) + const [{ category: activeType }] = mockUseMarketplaceFilters() return
{activeType}
} @@ -2045,16 +1966,6 @@ describe('StickySearchAndSwitchWrapper', () => { }) describe('Props', () => { - it('should accept showSearchParams prop', () => { - render( - - - , - ) - - expect(screen.getByTestId('portal-elem')).toBeInTheDocument() - }) - it('should pass pluginTypeSwitchClassName to wrapper', () => { const { container } = render( @@ -2081,16 +1992,16 @@ describe('Marketplace Integration', () => { describe('Context with child components', () => { it('should share state between multiple consumers', () => { const SearchDisplay = () => { - const searchText = useMarketplaceContext(v => v.searchPluginText) + const [{ q: searchText }] = mockUseMarketplaceFilters() return
{searchText || 'empty'}
} const SearchInput = () => { - const handleChange = useMarketplaceContext(v => v.handleSearchPluginTextChange) + const [, setUrlFilters] = mockUseMarketplaceFilters() return ( handleChange(e.target.value)} + onChange={e => setUrlFilters({ q: e.target.value })} /> ) } @@ -2106,22 +2017,20 @@ describe('Marketplace Integration', () => { fireEvent.change(screen.getByTestId('search-input'), { target: { value: 'test' } }) - expect(screen.getByTestId('search-display')).toHaveTextContent('test') + // setUrlFilters is called directly + expect(mockSetUrlFilters).toHaveBeenCalledWith({ q: 'test' }) }) - it('should update tags and reset plugins when search criteria changes', () => { + it('should update tags when search criteria changes', () => { const TestComponent = () => { - const tags = useMarketplaceContext(v => v.filterPluginTags) - const handleTagsChange = useMarketplaceContext(v => v.handleFilterPluginTagsChange) - const resetPlugins = useMarketplaceContext(v => v.resetPlugins) + const [{ tags }, setUrlFilters] = mockUseMarketplaceFilters() const handleAddTag = () => { - handleTagsChange(['search']) + setUrlFilters({ tags: ['search'] }) } const handleReset = () => { - handleTagsChange([]) - resetPlugins() + setUrlFilters({ tags: [] }) } return ( @@ -2142,10 +2051,10 @@ describe('Marketplace Integration', () => { expect(screen.getByTestId('tags')).toHaveTextContent('none') fireEvent.click(screen.getByTestId('add-tag')) - expect(screen.getByTestId('tags')).toHaveTextContent('search') + expect(mockSetUrlFilters).toHaveBeenCalledWith({ tags: ['search'] }) fireEvent.click(screen.getByTestId('reset')) - expect(screen.getByTestId('tags')).toHaveTextContent('none') + expect(mockSetUrlFilters).toHaveBeenCalledWith({ tags: [] }) }) }) @@ -2193,8 +2102,7 @@ describe('Marketplace Integration', () => { describe('Plugin type switching', () => { it('should filter by plugin type', () => { const TestComponent = () => { - const activeType = useMarketplaceContext(v => v.activePluginType) - const handleTypeChange = useMarketplaceContext(v => v.handleActivePluginTypeChange) + const [{ category: activeType }, setUrlFilters] = mockUseMarketplaceFilters() return (
@@ -2202,7 +2110,7 @@ describe('Marketplace Integration', () => { @@ -2221,13 +2129,13 @@ describe('Marketplace Integration', () => { expect(screen.getByTestId('active-type')).toHaveTextContent('all') fireEvent.click(screen.getByTestId('type-tool')) - expect(screen.getByTestId('active-type')).toHaveTextContent('tool') + expect(mockSetUrlFilters).toHaveBeenCalledWith({ category: 'tool' }, { history: 'push' }) fireEvent.click(screen.getByTestId('type-model')) - expect(screen.getByTestId('active-type')).toHaveTextContent('model') + expect(mockSetUrlFilters).toHaveBeenCalledWith({ category: 'model' }, { history: 'push' }) fireEvent.click(screen.getByTestId('type-bundle')) - expect(screen.getByTestId('active-type')).toHaveTextContent('bundle') + expect(mockSetUrlFilters).toHaveBeenCalledWith({ category: 'bundle' }, { history: 'push' }) }) }) }) @@ -2244,12 +2152,12 @@ describe('Edge Cases', () => { describe('Empty states', () => { it('should handle empty search text', () => { const TestComponent = () => { - const searchText = useMarketplaceContext(v => v.searchPluginText) + const [{ q: searchText }] = mockUseMarketplaceFilters() return
{searchText || 'empty'}
} render( - + , ) @@ -2259,7 +2167,7 @@ describe('Edge Cases', () => { it('should handle empty tags array', () => { const TestComponent = () => { - const tags = useMarketplaceContext(v => v.filterPluginTags) + const [{ tags }] = mockUseMarketplaceFilters() return
{tags.length === 0 ? 'no tags' : tags.join(',')}
} @@ -2300,15 +2208,15 @@ describe('Edge Cases', () => { // Test with special characters fireEvent.change(input, { target: { value: 'test@#$%^&*()' } }) - expect(screen.getByTestId('search-display')).toHaveTextContent('test@#$%^&*()') + expect(mockSetUrlFilters).toHaveBeenCalledWith({ q: 'test@#$%^&*()' }) // Test with unicode characters fireEvent.change(input, { target: { value: 'ζ΅‹θ―•δΈ­ζ–‡' } }) - expect(screen.getByTestId('search-display')).toHaveTextContent('ζ΅‹θ―•δΈ­ζ–‡') + expect(mockSetUrlFilters).toHaveBeenCalledWith({ q: 'ζ΅‹θ―•δΈ­ζ–‡' }) // Test with emojis fireEvent.change(input, { target: { value: 'πŸ” search' } }) - expect(screen.getByTestId('search-display')).toHaveTextContent('πŸ” search') + expect(mockSetUrlFilters).toHaveBeenCalledWith({ q: 'πŸ” search' }) }) }) @@ -2329,20 +2237,19 @@ describe('Edge Cases', () => { fireEvent.change(input, { target: { value: 'abcd' } }) fireEvent.change(input, { target: { value: 'abcde' } }) - // Final value should be the last one - expect(screen.getByTestId('search-display')).toHaveTextContent('abcde') + // Final call should be with 'abcde' + expect(mockSetUrlFilters).toHaveBeenLastCalledWith({ q: 'abcde' }) }) it('should handle rapid type changes', () => { const TestComponent = () => { - const activeType = useMarketplaceContext(v => v.activePluginType) - const handleChange = useMarketplaceContext(v => v.handleActivePluginTypeChange) + const [{ category: activeType }, setUrlFilters] = mockUseMarketplaceFilters() return (
- - - + + +
{activeType}
) @@ -2360,7 +2267,8 @@ describe('Edge Cases', () => { fireEvent.click(screen.getByTestId('type-all')) fireEvent.click(screen.getByTestId('type-tool')) - expect(screen.getByTestId('active-type')).toHaveTextContent('tool') + // Verify last call was with 'tool' + expect(mockSetUrlFilters).toHaveBeenLastCalledWith({ category: 'tool' }, { history: 'push' }) }) }) @@ -2369,15 +2277,14 @@ describe('Edge Cases', () => { const longText = 'a'.repeat(1000) const TestComponent = () => { - const searchText = useMarketplaceContext(v => v.searchPluginText) - const handleChange = useMarketplaceContext(v => v.handleSearchPluginTextChange) + const [{ q: searchText }, setUrlFilters] = mockUseMarketplaceFilters() return (
handleChange(e.target.value)} + onChange={e => setUrlFilters({ q: e.target.value })} />
{searchText.length}
@@ -2392,21 +2299,21 @@ describe('Edge Cases', () => { fireEvent.change(screen.getByTestId('search-input'), { target: { value: longText } }) - expect(screen.getByTestId('search-length')).toHaveTextContent('1000') + // setUrlFilters is called with the long text + expect(mockSetUrlFilters).toHaveBeenCalledWith({ q: longText }) }) it('should handle large number of tags', () => { const manyTags = Array.from({ length: 100 }, (_, i) => `tag-${i}`) const TestComponent = () => { - const tags = useMarketplaceContext(v => v.filterPluginTags) - const handleChange = useMarketplaceContext(v => v.handleFilterPluginTagsChange) + const [{ tags }, setUrlFilters] = mockUseMarketplaceFilters() return (
@@ -2423,7 +2330,8 @@ describe('Edge Cases', () => { fireEvent.click(screen.getByTestId('add-many-tags')) - expect(screen.getByTestId('tags-count')).toHaveTextContent('100') + // setUrlFilters is called with all tags + expect(mockSetUrlFilters).toHaveBeenCalledWith({ tags: manyTags }) }) }) @@ -2725,7 +2633,7 @@ describe('PluginTypeSwitch Component', () => { it('should call handleActivePluginTypeChange on option click', () => { const TestWrapper = () => { - const activeType = useMarketplaceContext(v => v.activePluginType) + const [{ category: activeType }] = mockUseMarketplaceFilters() return (
@@ -2741,15 +2649,16 @@ describe('PluginTypeSwitch Component', () => { ) fireEvent.click(screen.getByText('plugin.category.tools')) - expect(screen.getByTestId('active-type-display')).toHaveTextContent('tool') + // Now uses useMarketplaceCategory directly, so it calls setCategory(value, options) + expect(mockSetUrlFilters).toHaveBeenCalledWith('tool', { history: 'push' }) }) - it('should highlight active option with correct classes', () => { + it('should call setUrlFilters when option is clicked', () => { const TestWrapper = () => { - const handleChange = useMarketplaceContext(v => v.handleActivePluginTypeChange) + const [, setUrlFilters] = mockUseMarketplaceFilters() return (
- +
) @@ -2762,27 +2671,26 @@ describe('PluginTypeSwitch Component', () => { ) fireEvent.click(screen.getByTestId('set-model')) - const modelOption = screen.getByText('plugin.category.models').closest('div') - expect(modelOption).toHaveClass('shadow-xs') + expect(mockSetUrlFilters).toHaveBeenCalledWith({ category: 'model' }, { history: 'push' }) }) }) describe('Popstate handling', () => { - it('should handle popstate event when showSearchParams is true', () => { + it('should handle popstate event', () => { const originalHref = window.location.href const TestWrapper = () => { - const activeType = useMarketplaceContext(v => v.activePluginType) + const [{ category: activeType }] = mockUseMarketplaceFilters() return (
- +
{activeType}
) } render( - + , ) @@ -2793,31 +2701,6 @@ describe('PluginTypeSwitch Component', () => { expect(screen.getByTestId('active-type')).toBeInTheDocument() expect(window.location.href).toBe(originalHref) }) - - it('should not handle popstate when showSearchParams is false', () => { - const TestWrapper = () => { - const activeType = useMarketplaceContext(v => v.activePluginType) - return ( -
- -
{activeType}
-
- ) - } - - render( - - - , - ) - - expect(screen.getByTestId('active-type')).toHaveTextContent('all') - - const popstateEvent = new PopStateEvent('popstate') - window.dispatchEvent(popstateEvent) - - expect(screen.getByTestId('active-type')).toHaveTextContent('all') - }) }) }) @@ -2833,9 +2716,9 @@ describe('Context Advanced', () => { }) describe('URL filter synchronization', () => { - it('should update URL filters when showSearchParams is true and type changes', () => { + it('should update URL filters when type changes', () => { render( - + , ) @@ -2843,126 +2726,6 @@ describe('Context Advanced', () => { fireEvent.click(screen.getByTestId('change-type')) expect(mockSetUrlFilters).toHaveBeenCalled() }) - - it('should not update URL filters when showSearchParams is false', () => { - render( - - - , - ) - - fireEvent.click(screen.getByTestId('change-type')) - expect(mockSetUrlFilters).not.toHaveBeenCalled() - }) - }) - - describe('handlePageChange', () => { - it('should invoke fetchNextPage when hasNextPage is true', () => { - mockHasNextPage = true - - render( - - - , - ) - - fireEvent.click(screen.getByTestId('next-page')) - expect(mockFetchNextPage).toHaveBeenCalled() - }) - - it('should not invoke fetchNextPage when hasNextPage is false', () => { - mockHasNextPage = false - - render( - - - , - ) - - fireEvent.click(screen.getByTestId('next-page')) - expect(mockFetchNextPage).not.toHaveBeenCalled() - }) - }) - - describe('setMarketplaceCollectionsFromClient', () => { - it('should provide setMarketplaceCollectionsFromClient function', () => { - const TestComponent = () => { - const setCollections = useMarketplaceContext(v => v.setMarketplaceCollectionsFromClient) - - return ( -
- -
- ) - } - - render( - - - , - ) - - expect(screen.getByTestId('set-collections')).toBeInTheDocument() - // The function should be callable without throwing - expect(() => fireEvent.click(screen.getByTestId('set-collections'))).not.toThrow() - }) - }) - - describe('setMarketplaceCollectionPluginsMapFromClient', () => { - it('should provide setMarketplaceCollectionPluginsMapFromClient function', () => { - const TestComponent = () => { - const setPluginsMap = useMarketplaceContext(v => v.setMarketplaceCollectionPluginsMapFromClient) - - return ( -
- -
- ) - } - - render( - - - , - ) - - expect(screen.getByTestId('set-plugins-map')).toBeInTheDocument() - // The function should be callable without throwing - expect(() => fireEvent.click(screen.getByTestId('set-plugins-map'))).not.toThrow() - }) - }) - - describe('handleQueryPlugins', () => { - it('should provide handleQueryPlugins function that can be called', () => { - const TestComponent = () => { - const handleQueryPlugins = useMarketplaceContext(v => v.handleQueryPlugins) - return ( - - ) - } - - render( - - - , - ) - - expect(screen.getByTestId('query-plugins')).toBeInTheDocument() - fireEvent.click(screen.getByTestId('query-plugins')) - expect(screen.getByTestId('query-plugins')).toBeInTheDocument() - }) }) describe('isLoading state', () => { @@ -2982,23 +2745,6 @@ describe('Context Advanced', () => { }) }) - describe('isSuccessCollections state', () => { - it('should expose isSuccessCollections state', () => { - const TestComponent = () => { - const isSuccess = useMarketplaceContext(v => v.isSuccessCollections) - return
{isSuccess.toString()}
- } - - render( - - - , - ) - - expect(screen.getByTestId('success')).toHaveTextContent('false') - }) - }) - describe('pluginsTotal', () => { it('should expose plugins total count', () => { const TestComponent = () => { diff --git a/web/app/components/plugins/marketplace/index.tsx b/web/app/components/plugins/marketplace/index.tsx index 08d1bc833fa3e0..6786ed51098ae4 100644 --- a/web/app/components/plugins/marketplace/index.tsx +++ b/web/app/components/plugins/marketplace/index.tsx @@ -1,56 +1,32 @@ -import type { MarketplaceCollection, SearchParams } from './types' -import type { Plugin } from '@/app/components/plugins/types' -import { TanstackQueryInitializer } from '@/context/query-client' -import { MarketplaceContextProvider } from './context' -import Description from './description' -import ListWrapper from './list/list-wrapper' -import StickySearchAndSwitchWrapper from './sticky-search-and-switch-wrapper' +import { dehydrate } from '@tanstack/react-query' +import { getQueryClient } from '@/context/query-client-server' +import { MarketplaceClient } from './marketplace-client' +import { marketplaceKeys } from './query-keys' import { getMarketplaceCollectionsAndPlugins } from './utils' type MarketplaceProps = { showInstallButton?: boolean - shouldExclude?: boolean - searchParams?: SearchParams pluginTypeSwitchClassName?: string - scrollContainerId?: string - showSearchParams?: boolean } -const Marketplace = async ({ + +async function Marketplace({ showInstallButton = true, - shouldExclude, - searchParams, pluginTypeSwitchClassName, - scrollContainerId, - showSearchParams = true, -}: MarketplaceProps) => { - let marketplaceCollections: MarketplaceCollection[] = [] - let marketplaceCollectionPluginsMap: Record = {} - if (!shouldExclude) { - const marketplaceCollectionsAndPluginsData = await getMarketplaceCollectionsAndPlugins() - marketplaceCollections = marketplaceCollectionsAndPluginsData.marketplaceCollections - marketplaceCollectionPluginsMap = marketplaceCollectionsAndPluginsData.marketplaceCollectionPluginsMap - } +}: MarketplaceProps) { + const queryClient = getQueryClient() + + // Prefetch collections and plugins for the default view (all categories) + await queryClient.prefetchQuery({ + queryKey: marketplaceKeys.collections({}), + queryFn: () => getMarketplaceCollectionsAndPlugins({}), + }) return ( - - - - - - - + ) } diff --git a/web/app/components/plugins/marketplace/list/index.spec.tsx b/web/app/components/plugins/marketplace/list/index.spec.tsx index c8fc6309a4b9cf..6e2c482d4a6040 100644 --- a/web/app/components/plugins/marketplace/list/index.spec.tsx +++ b/web/app/components/plugins/marketplace/list/index.spec.tsx @@ -1,6 +1,6 @@ import type { MarketplaceCollection, SearchParamsFromCollection } from '../types' import type { Plugin } from '@/app/components/plugins/types' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { PluginCategoryEnum } from '@/app/components/plugins/types' import List from './index' @@ -34,11 +34,9 @@ vi.mock('#i18n', () => ({ const mockContextValues = { plugins: undefined as Plugin[] | undefined, pluginsTotal: 0, - marketplaceCollectionsFromClient: undefined as MarketplaceCollection[] | undefined, - marketplaceCollectionPluginsMapFromClient: undefined as Record | undefined, + marketplaceCollections: undefined as MarketplaceCollection[] | undefined, + marketplaceCollectionPluginsMap: undefined as Record | undefined, isLoading: false, - isSuccessCollections: false, - handleQueryPlugins: vi.fn(), searchPluginText: '', filterPluginTags: [] as string[], page: 1, @@ -803,8 +801,6 @@ describe('ListWithCollection', () => { // ================================ describe('ListWrapper', () => { const defaultProps = { - marketplaceCollections: [] as MarketplaceCollection[], - marketplaceCollectionPluginsMap: {} as Record, showInstallButton: false, } @@ -813,10 +809,9 @@ describe('ListWrapper', () => { // Reset context values mockContextValues.plugins = undefined mockContextValues.pluginsTotal = 0 - mockContextValues.marketplaceCollectionsFromClient = undefined - mockContextValues.marketplaceCollectionPluginsMapFromClient = undefined + mockContextValues.marketplaceCollections = undefined + mockContextValues.marketplaceCollectionPluginsMap = undefined mockContextValues.isLoading = false - mockContextValues.isSuccessCollections = false mockContextValues.searchPluginText = '' mockContextValues.filterPluginTags = [] mockContextValues.page = 1 @@ -894,18 +889,12 @@ describe('ListWrapper', () => { describe('List Rendering Logic', () => { it('should render List when not loading', () => { mockContextValues.isLoading = false - const collections = createMockCollectionList(1) - const pluginsMap: Record = { + mockContextValues.marketplaceCollections = createMockCollectionList(1) + mockContextValues.marketplaceCollectionPluginsMap = { 'collection-0': createMockPluginList(1), } - render( - , - ) + render() expect(screen.getByText('Collection 0')).toBeInTheDocument() }) @@ -913,69 +902,28 @@ describe('ListWrapper', () => { it('should render List when loading but page > 1', () => { mockContextValues.isLoading = true mockContextValues.page = 2 - const collections = createMockCollectionList(1) - const pluginsMap: Record = { + mockContextValues.marketplaceCollections = createMockCollectionList(1) + mockContextValues.marketplaceCollectionPluginsMap = { 'collection-0': createMockPluginList(1), } - render( - , - ) + render() expect(screen.getByText('Collection 0')).toBeInTheDocument() }) - it('should use client collections when available', () => { - const serverCollections = createMockCollectionList(1) - serverCollections[0].label = { 'en-US': 'Server Collection' } - const clientCollections = createMockCollectionList(1) - clientCollections[0].label = { 'en-US': 'Client Collection' } - - const serverPluginsMap: Record = { - 'collection-0': createMockPluginList(1), - } - const clientPluginsMap: Record = { - 'collection-0': createMockPluginList(1), - } - - mockContextValues.marketplaceCollectionsFromClient = clientCollections - mockContextValues.marketplaceCollectionPluginsMapFromClient = clientPluginsMap - - render( - , - ) - - expect(screen.getByText('Client Collection')).toBeInTheDocument() - expect(screen.queryByText('Server Collection')).not.toBeInTheDocument() - }) + it('should render collections from context', () => { + const collections = createMockCollectionList(1) + collections[0].label = { 'en-US': 'Context Collection' } - it('should use server collections when client collections are not available', () => { - const serverCollections = createMockCollectionList(1) - serverCollections[0].label = { 'en-US': 'Server Collection' } - const serverPluginsMap: Record = { + mockContextValues.marketplaceCollections = collections + mockContextValues.marketplaceCollectionPluginsMap = { 'collection-0': createMockPluginList(1), } - mockContextValues.marketplaceCollectionsFromClient = undefined - mockContextValues.marketplaceCollectionPluginsMapFromClient = undefined - - render( - , - ) + render() - expect(screen.getByText('Server Collection')).toBeInTheDocument() + expect(screen.getByText('Context Collection')).toBeInTheDocument() }) }) @@ -1002,17 +950,12 @@ describe('ListWrapper', () => { searchable: true, search_params: { query: 'test' }, })] - const pluginsMap: Record = { + mockContextValues.marketplaceCollections = collections + mockContextValues.marketplaceCollectionPluginsMap = { 'collection-0': createMockPluginList(1), } - render( - , - ) + render() fireEvent.click(screen.getByText('View More')) @@ -1020,72 +963,6 @@ describe('ListWrapper', () => { }) }) - // ================================ - // Effect Tests (handleQueryPlugins) - // ================================ - describe('handleQueryPlugins Effect', () => { - it('should call handleQueryPlugins when conditions are met', async () => { - const mockHandleQueryPlugins = vi.fn() - mockContextValues.handleQueryPlugins = mockHandleQueryPlugins - mockContextValues.isSuccessCollections = true - mockContextValues.marketplaceCollectionsFromClient = undefined - mockContextValues.searchPluginText = '' - mockContextValues.filterPluginTags = [] - - render() - - await waitFor(() => { - expect(mockHandleQueryPlugins).toHaveBeenCalled() - }) - }) - - it('should not call handleQueryPlugins when client collections exist', async () => { - const mockHandleQueryPlugins = vi.fn() - mockContextValues.handleQueryPlugins = mockHandleQueryPlugins - mockContextValues.isSuccessCollections = true - mockContextValues.marketplaceCollectionsFromClient = createMockCollectionList(1) - mockContextValues.searchPluginText = '' - mockContextValues.filterPluginTags = [] - - render() - - // Give time for effect to run - await waitFor(() => { - expect(mockHandleQueryPlugins).not.toHaveBeenCalled() - }) - }) - - it('should not call handleQueryPlugins when search text exists', async () => { - const mockHandleQueryPlugins = vi.fn() - mockContextValues.handleQueryPlugins = mockHandleQueryPlugins - mockContextValues.isSuccessCollections = true - mockContextValues.marketplaceCollectionsFromClient = undefined - mockContextValues.searchPluginText = 'search text' - mockContextValues.filterPluginTags = [] - - render() - - await waitFor(() => { - expect(mockHandleQueryPlugins).not.toHaveBeenCalled() - }) - }) - - it('should not call handleQueryPlugins when filter tags exist', async () => { - const mockHandleQueryPlugins = vi.fn() - mockContextValues.handleQueryPlugins = mockHandleQueryPlugins - mockContextValues.isSuccessCollections = true - mockContextValues.marketplaceCollectionsFromClient = undefined - mockContextValues.searchPluginText = '' - mockContextValues.filterPluginTags = ['tag1'] - - render() - - await waitFor(() => { - expect(mockHandleQueryPlugins).not.toHaveBeenCalled() - }) - }) - }) - // ================================ // Edge Cases Tests // ================================ @@ -1432,20 +1309,17 @@ describe('Combined Workflows', () => { mockContextValues.pluginsTotal = 0 mockContextValues.isLoading = false mockContextValues.page = 1 - mockContextValues.marketplaceCollectionsFromClient = undefined - mockContextValues.marketplaceCollectionPluginsMapFromClient = undefined + mockContextValues.marketplaceCollections = undefined + mockContextValues.marketplaceCollectionPluginsMap = undefined }) it('should transition from loading to showing collections', async () => { mockContextValues.isLoading = true mockContextValues.page = 1 + mockContextValues.marketplaceCollections = [] + mockContextValues.marketplaceCollectionPluginsMap = {} - const { rerender } = render( - , - ) + const { rerender } = render() expect(screen.getByTestId('loading-component')).toBeInTheDocument() @@ -1455,15 +1329,10 @@ describe('Combined Workflows', () => { const pluginsMap: Record = { 'collection-0': createMockPluginList(1), } - mockContextValues.marketplaceCollectionsFromClient = collections - mockContextValues.marketplaceCollectionPluginsMapFromClient = pluginsMap + mockContextValues.marketplaceCollections = collections + mockContextValues.marketplaceCollectionPluginsMap = pluginsMap - rerender( - , - ) + rerender() expect(screen.queryByTestId('loading-component')).not.toBeInTheDocument() expect(screen.getByText('Collection 0')).toBeInTheDocument() @@ -1474,15 +1343,10 @@ describe('Combined Workflows', () => { const pluginsMap: Record = { 'collection-0': createMockPluginList(1), } - mockContextValues.marketplaceCollectionsFromClient = collections - mockContextValues.marketplaceCollectionPluginsMapFromClient = pluginsMap + mockContextValues.marketplaceCollections = collections + mockContextValues.marketplaceCollectionPluginsMap = pluginsMap - const { rerender } = render( - , - ) + const { rerender } = render() expect(screen.getByText('Collection 0')).toBeInTheDocument() @@ -1490,12 +1354,7 @@ describe('Combined Workflows', () => { mockContextValues.plugins = createMockPluginList(5) mockContextValues.pluginsTotal = 5 - rerender( - , - ) + rerender() expect(screen.queryByText('Collection 0')).not.toBeInTheDocument() expect(screen.getByText('5 plugins found')).toBeInTheDocument() @@ -1504,13 +1363,10 @@ describe('Combined Workflows', () => { it('should handle empty search results', () => { mockContextValues.plugins = [] mockContextValues.pluginsTotal = 0 + mockContextValues.marketplaceCollections = [] + mockContextValues.marketplaceCollectionPluginsMap = {} - render( - , - ) + render() expect(screen.getByTestId('empty-component')).toBeInTheDocument() expect(screen.getByText('0 plugins found')).toBeInTheDocument() @@ -1521,13 +1377,10 @@ describe('Combined Workflows', () => { mockContextValues.pluginsTotal = 80 mockContextValues.isLoading = true mockContextValues.page = 2 + mockContextValues.marketplaceCollections = [] + mockContextValues.marketplaceCollectionPluginsMap = {} - render( - , - ) + render() // Should show existing results while loading more expect(screen.getByText('80 plugins found')).toBeInTheDocument() diff --git a/web/app/components/plugins/marketplace/list/index.tsx b/web/app/components/plugins/marketplace/list/index.tsx index 80b33d0ffd0f6f..4ce7272e805542 100644 --- a/web/app/components/plugins/marketplace/list/index.tsx +++ b/web/app/components/plugins/marketplace/list/index.tsx @@ -13,7 +13,6 @@ type ListProps = { showInstallButton?: boolean cardContainerClassName?: string cardRender?: (plugin: Plugin) => React.JSX.Element | null - onMoreClick?: () => void emptyClassName?: string } const List = ({ @@ -23,7 +22,6 @@ const List = ({ showInstallButton, cardContainerClassName, cardRender, - onMoreClick, emptyClassName, }: ListProps) => { return ( @@ -36,7 +34,6 @@ const List = ({ showInstallButton={showInstallButton} cardContainerClassName={cardContainerClassName} cardRender={cardRender} - onMoreClick={onMoreClick} /> ) } diff --git a/web/app/components/plugins/marketplace/list/list-with-collection.tsx b/web/app/components/plugins/marketplace/list/list-with-collection.tsx index c17715e71e1dfe..6673abcbd37bc8 100644 --- a/web/app/components/plugins/marketplace/list/list-with-collection.tsx +++ b/web/app/components/plugins/marketplace/list/list-with-collection.tsx @@ -1,12 +1,12 @@ 'use client' import type { MarketplaceCollection } from '../types' -import type { SearchParamsFromCollection } from '@/app/components/plugins/marketplace/types' import type { Plugin } from '@/app/components/plugins/types' import { useLocale, useTranslation } from '#i18n' import { RiArrowRightSLine } from '@remixicon/react' import { getLanguage } from '@/i18n-config/language' import { cn } from '@/utils/classnames' +import { useMarketplaceMoreClick } from '../hooks' import CardWrapper from './card-wrapper' type ListWithCollectionProps = { @@ -15,7 +15,6 @@ type ListWithCollectionProps = { showInstallButton?: boolean cardContainerClassName?: string cardRender?: (plugin: Plugin) => React.JSX.Element | null - onMoreClick?: (searchParams?: SearchParamsFromCollection) => void } const ListWithCollection = ({ marketplaceCollections, @@ -23,10 +22,10 @@ const ListWithCollection = ({ showInstallButton, cardContainerClassName, cardRender, - onMoreClick, }: ListWithCollectionProps) => { const { t } = useTranslation() const locale = useLocale() + const handleMoreClick = useMarketplaceMoreClick() return ( <> @@ -44,10 +43,10 @@ const ListWithCollection = ({
{collection.description[getLanguage(locale)]}
{ - collection.searchable && onMoreClick && ( + collection.searchable && (
onMoreClick?.(collection.search_params)} + onClick={() => handleMoreClick(collection.search_params || {})} > {t('marketplace.viewMore', { ns: 'plugin' })} diff --git a/web/app/components/plugins/marketplace/list/list-wrapper.tsx b/web/app/components/plugins/marketplace/list/list-wrapper.tsx index 84fcf92dafb1e3..9d1a848682f8a2 100644 --- a/web/app/components/plugins/marketplace/list/list-wrapper.tsx +++ b/web/app/components/plugins/marketplace/list/list-wrapper.tsx @@ -1,79 +1,61 @@ 'use client' -import type { Plugin } from '../../types' -import type { MarketplaceCollection } from '../types' + import { useTranslation } from '#i18n' -import { useEffect } from 'react' import Loading from '@/app/components/base/loading' -import { useMarketplaceContext } from '../context' +import { useMarketplaceData } from '../hooks' import SortDropdown from '../sort-dropdown' import List from './index' type ListWrapperProps = { - marketplaceCollections: MarketplaceCollection[] - marketplaceCollectionPluginsMap: Record showInstallButton?: boolean } -const ListWrapper = ({ - marketplaceCollections, - marketplaceCollectionPluginsMap, - showInstallButton, -}: ListWrapperProps) => { + +function ListWrapper({ showInstallButton }: ListWrapperProps) { const { t } = useTranslation() - const plugins = useMarketplaceContext(v => v.plugins) - const pluginsTotal = useMarketplaceContext(v => v.pluginsTotal) - const marketplaceCollectionsFromClient = useMarketplaceContext(v => v.marketplaceCollectionsFromClient) - const marketplaceCollectionPluginsMapFromClient = useMarketplaceContext(v => v.marketplaceCollectionPluginsMapFromClient) - const isLoading = useMarketplaceContext(v => v.isLoading) - const isSuccessCollections = useMarketplaceContext(v => v.isSuccessCollections) - const handleQueryPlugins = useMarketplaceContext(v => v.handleQueryPlugins) - const searchPluginText = useMarketplaceContext(v => v.searchPluginText) - const filterPluginTags = useMarketplaceContext(v => v.filterPluginTags) - const page = useMarketplaceContext(v => v.page) - const handleMoreClick = useMarketplaceContext(v => v.handleMoreClick) - useEffect(() => { - if ( - !marketplaceCollectionsFromClient?.length - && isSuccessCollections - && !searchPluginText - && !filterPluginTags.length - ) { - handleQueryPlugins() - } - }, [handleQueryPlugins, marketplaceCollections, marketplaceCollectionsFromClient, isSuccessCollections, searchPluginText, filterPluginTags]) + const { + marketplaceCollections, + marketplaceCollectionPluginsMap, + plugins, + pluginsTotal, + isLoading, + page, + } = useMarketplaceData() + + // Show loading spinner only on initial load (page 1) + if (isLoading && page === 1) { + return ( +
+
+ +
+
+ ) + } return (
- { - plugins && ( -
-
{t('marketplace.pluginsResult', { ns: 'plugin', num: pluginsTotal })}
-
- -
- ) - } - { - isLoading && page === 1 && ( -
- + {plugins && ( +
+
+ {t('marketplace.pluginsResult', { ns: 'plugin', num: pluginsTotal })}
- ) - } - { - (!isLoading || page > 1) && ( - - ) - } +
+ +
+ )} +
) } diff --git a/web/app/components/plugins/marketplace/marketplace-client.tsx b/web/app/components/plugins/marketplace/marketplace-client.tsx new file mode 100644 index 00000000000000..a744b6a1a275dd --- /dev/null +++ b/web/app/components/plugins/marketplace/marketplace-client.tsx @@ -0,0 +1,32 @@ +'use client' + +import type { DehydratedState } from '@tanstack/react-query' +import { HydrationBoundary } from '@tanstack/react-query' +import { TanstackQueryInner } from '@/context/query-client' +import Description from './description' +import ListWrapper from './list/list-wrapper' +import StickySearchAndSwitchWrapper from './sticky-search-and-switch-wrapper' + +export type MarketplaceClientProps = { + showInstallButton?: boolean + pluginTypeSwitchClassName?: string + dehydratedState?: DehydratedState +} + +export function MarketplaceClient({ + showInstallButton = true, + pluginTypeSwitchClassName, + dehydratedState, +}: MarketplaceClientProps) { + return ( + + + + + + + + ) +} diff --git a/web/app/components/plugins/marketplace/plugin-type-switch.tsx b/web/app/components/plugins/marketplace/plugin-type-switch.tsx index b9572413ed8652..b0d9b796aa007f 100644 --- a/web/app/components/plugins/marketplace/plugin-type-switch.tsx +++ b/web/app/components/plugins/marketplace/plugin-type-switch.tsx @@ -1,4 +1,5 @@ 'use client' + import { useTranslation } from '#i18n' import { RiArchive2Line, @@ -8,11 +9,11 @@ import { RiPuzzle2Line, RiSpeakAiLine, } from '@remixicon/react' -import { useCallback, useEffect } from 'react' +import { useCallback } from 'react' import { Trigger as TriggerIcon } from '@/app/components/base/icons/src/vender/plugin' +import { useMarketplaceCategory } from '@/hooks/use-query-params' import { cn } from '@/utils/classnames' import { PluginCategoryEnum } from '../types' -import { useMarketplaceContext } from './context' export const PLUGIN_TYPE_SEARCH_MAP = { all: 'all', @@ -24,17 +25,18 @@ export const PLUGIN_TYPE_SEARCH_MAP = { trigger: PluginCategoryEnum.trigger, bundle: 'bundle', } + type PluginTypeSwitchProps = { className?: string - showSearchParams?: boolean } -const PluginTypeSwitch = ({ - className, - showSearchParams, -}: PluginTypeSwitchProps) => { + +function PluginTypeSwitch({ className }: PluginTypeSwitchProps) { const { t } = useTranslation() - const activePluginType = useMarketplaceContext(s => s.activePluginType) - const handleActivePluginTypeChange = useMarketplaceContext(s => s.handleActivePluginTypeChange) + const [activePluginType, setCategory] = useMarketplaceCategory() + + const handleActivePluginTypeChange = useCallback((type: string) => { + setCategory(type, { history: 'push' }) + }, [setCategory]) const options = [ { @@ -79,46 +81,26 @@ const PluginTypeSwitch = ({ }, ] - const handlePopState = useCallback(() => { - if (!showSearchParams) - return - // nuqs handles popstate automatically - const url = new URL(window.location.href) - const category = url.searchParams.get('category') || PLUGIN_TYPE_SEARCH_MAP.all - handleActivePluginTypeChange(category) - }, [showSearchParams, handleActivePluginTypeChange]) - - useEffect(() => { - // nuqs manages popstate internally, but we keep this for URL sync - window.addEventListener('popstate', handlePopState) - return () => { - window.removeEventListener('popstate', handlePopState) - } - }, [handlePopState]) - return ( -
- { - options.map(option => ( -
{ - handleActivePluginTypeChange(option.value) - }} - > - {option.icon} - {option.text} -
- )) - } + {options.map(option => ( +
handleActivePluginTypeChange(option.value)} + > + {option.icon} + {option.text} +
+ ))}
) } diff --git a/web/app/components/plugins/marketplace/query-keys.ts b/web/app/components/plugins/marketplace/query-keys.ts new file mode 100644 index 00000000000000..24a2de147667ea --- /dev/null +++ b/web/app/components/plugins/marketplace/query-keys.ts @@ -0,0 +1,12 @@ +import type { CollectionsAndPluginsSearchParams, PluginsSearchParams } from './types' + +// Query key factory for consistent cache keys +export const marketplaceKeys = { + all: ['marketplace'] as const, + collections: (params?: CollectionsAndPluginsSearchParams) => + [...marketplaceKeys.all, 'collections', params] as const, + collectionPlugins: (collectionId: string, params?: CollectionsAndPluginsSearchParams) => + [...marketplaceKeys.all, 'collectionPlugins', collectionId, params] as const, + plugins: (params?: PluginsSearchParams) => + [...marketplaceKeys.all, 'plugins', params] as const, +} diff --git a/web/app/components/plugins/marketplace/search-box/search-box-wrapper.tsx b/web/app/components/plugins/marketplace/search-box/search-box-wrapper.tsx index d7fc004236e820..a129dd3357cc7f 100644 --- a/web/app/components/plugins/marketplace/search-box/search-box-wrapper.tsx +++ b/web/app/components/plugins/marketplace/search-box/search-box-wrapper.tsx @@ -1,15 +1,22 @@ 'use client' import { useTranslation } from '#i18n' -import { useMarketplaceContext } from '../context' +import { useCallback } from 'react' +import { useMarketplaceSearchQuery, useMarketplaceTags } from '@/hooks/use-query-params' import SearchBox from './index' const SearchBoxWrapper = () => { const { t } = useTranslation() - const searchPluginText = useMarketplaceContext(v => v.searchPluginText) - const handleSearchPluginTextChange = useMarketplaceContext(v => v.handleSearchPluginTextChange) - const filterPluginTags = useMarketplaceContext(v => v.filterPluginTags) - const handleFilterPluginTagsChange = useMarketplaceContext(v => v.handleFilterPluginTagsChange) + const [searchPluginText, setSearchPluginText] = useMarketplaceSearchQuery() + const [filterPluginTags, setFilterPluginTags] = useMarketplaceTags() + + const handleSearchPluginTextChange = useCallback((text: string) => { + setSearchPluginText(text) + }, [setSearchPluginText]) + + const handleFilterPluginTagsChange = useCallback((tags: string[]) => { + setFilterPluginTags(tags) + }, [setFilterPluginTags]) return ( { const { t } = useTranslation() @@ -36,8 +36,7 @@ const SortDropdown = () => { text: t('marketplace.sortOption.firstReleased', { ns: 'plugin' }), }, ] - const sort = useMarketplaceContext(v => v.sort) - const handleSortChange = useMarketplaceContext(v => v.handleSortChange) + const { sort, handleSortChange } = useMarketplaceData() const [open, setOpen] = useState(false) const selectedOption = options.find(option => option.value === sort.sortBy && option.order === sort.sortOrder) ?? options[0] diff --git a/web/app/components/plugins/marketplace/sticky-search-and-switch-wrapper.tsx b/web/app/components/plugins/marketplace/sticky-search-and-switch-wrapper.tsx index 3d3530c83edef7..475e17992b843f 100644 --- a/web/app/components/plugins/marketplace/sticky-search-and-switch-wrapper.tsx +++ b/web/app/components/plugins/marketplace/sticky-search-and-switch-wrapper.tsx @@ -6,13 +6,11 @@ import SearchBoxWrapper from './search-box/search-box-wrapper' type StickySearchAndSwitchWrapperProps = { pluginTypeSwitchClassName?: string - showSearchParams?: boolean } -const StickySearchAndSwitchWrapper = ({ +function StickySearchAndSwitchWrapper({ pluginTypeSwitchClassName, - showSearchParams, -}: StickySearchAndSwitchWrapperProps) => { +}: StickySearchAndSwitchWrapperProps) { const hasCustomTopClass = pluginTypeSwitchClassName?.includes('top-') return ( @@ -24,9 +22,7 @@ const StickySearchAndSwitchWrapper = ({ )} > - +
) } diff --git a/web/app/components/tools/marketplace/hooks.ts b/web/app/components/tools/marketplace/hooks.ts index 6db444f012a1d8..c9edfd22153ff0 100644 --- a/web/app/components/tools/marketplace/hooks.ts +++ b/web/app/components/tools/marketplace/hooks.ts @@ -13,18 +13,28 @@ import { getMarketplaceListCondition } from '@/app/components/plugins/marketplac import { PluginCategoryEnum } from '@/app/components/plugins/types' import { useAllToolProviders } from '@/service/use-tools' -export const useMarketplace = (searchPluginText: string, filterPluginTags: string[]) => { +export function useMarketplace(searchPluginText: string, filterPluginTags: string[]) { const { data: toolProvidersData, isSuccess } = useAllToolProviders() const exclude = useMemo(() => { if (isSuccess) return toolProvidersData?.filter(toolProvider => !!toolProvider.plugin_id).map(toolProvider => toolProvider.plugin_id!) + return undefined }, [isSuccess, toolProvidersData]) - const { - isLoading, - marketplaceCollections, - marketplaceCollectionPluginsMap, - queryMarketplaceCollectionsAndPlugins, - } = useMarketplaceCollectionsAndPlugins() + + const isSearchMode = !!searchPluginText || filterPluginTags.length > 0 + + // Collections query (only when not searching) + const collectionsQuery = useMarketplaceCollectionsAndPlugins( + { + category: PluginCategoryEnum.tool, + condition: getMarketplaceListCondition(PluginCategoryEnum.tool), + exclude, + type: 'plugin', + }, + { enabled: !isSearchMode && isSuccess }, + ) + + // Plugins search const { plugins, resetPlugins, @@ -35,6 +45,7 @@ export const useMarketplace = (searchPluginText: string, filterPluginTags: strin hasNextPage, page: pluginsPage, } = useMarketplacePlugins() + const searchPluginTextRef = useRef(searchPluginText) const filterPluginTagsRef = useRef(filterPluginTags) @@ -42,8 +53,12 @@ export const useMarketplace = (searchPluginText: string, filterPluginTags: strin searchPluginTextRef.current = searchPluginText filterPluginTagsRef.current = filterPluginTags }, [searchPluginText, filterPluginTags]) + useEffect(() => { - if ((searchPluginText || filterPluginTags.length) && isSuccess) { + if (!isSuccess) + return + + if (isSearchMode) { if (searchPluginText) { queryPluginsWithDebounced({ category: PluginCategoryEnum.tool, @@ -52,48 +67,37 @@ export const useMarketplace = (searchPluginText: string, filterPluginTags: strin exclude, type: 'plugin', }) - return } - queryPlugins({ - category: PluginCategoryEnum.tool, - query: searchPluginText, - tags: filterPluginTags, - exclude, - type: 'plugin', - }) - } - else { - if (isSuccess) { - queryMarketplaceCollectionsAndPlugins({ + else { + queryPlugins({ category: PluginCategoryEnum.tool, - condition: getMarketplaceListCondition(PluginCategoryEnum.tool), + query: searchPluginText, + tags: filterPluginTags, exclude, type: 'plugin', }) - resetPlugins() } } - }, [searchPluginText, filterPluginTags, queryPlugins, queryMarketplaceCollectionsAndPlugins, queryPluginsWithDebounced, resetPlugins, exclude, isSuccess]) + else { + resetPlugins() + } + }, [searchPluginText, filterPluginTags, queryPlugins, queryPluginsWithDebounced, resetPlugins, exclude, isSuccess, isSearchMode]) const handleScroll = useCallback((e: Event) => { const target = e.target as HTMLDivElement - const { - scrollTop, - scrollHeight, - clientHeight, - } = target + const { scrollTop, scrollHeight, clientHeight } = target if (scrollTop + clientHeight >= scrollHeight - SCROLL_BOTTOM_THRESHOLD && scrollTop > 0) { - const searchPluginText = searchPluginTextRef.current - const filterPluginTags = filterPluginTagsRef.current - if (hasNextPage && (!!searchPluginText || !!filterPluginTags.length)) + const searchText = searchPluginTextRef.current + const tags = filterPluginTagsRef.current + if (hasNextPage && (!!searchText || !!tags.length)) fetchNextPage() } - }, [exclude, fetchNextPage, hasNextPage, plugins, queryPlugins]) + }, [fetchNextPage, hasNextPage]) return { - isLoading: isLoading || isPluginsLoading, - marketplaceCollections, - marketplaceCollectionPluginsMap, + isLoading: collectionsQuery.isLoading || isPluginsLoading, + marketplaceCollections: collectionsQuery.data?.marketplaceCollections, + marketplaceCollectionPluginsMap: collectionsQuery.data?.marketplaceCollectionPluginsMap, plugins, handleScroll, page: Math.max(pluginsPage || 0, 1), diff --git a/web/context/query-client-server.ts b/web/context/query-client-server.ts new file mode 100644 index 00000000000000..bc46529e6660cf --- /dev/null +++ b/web/context/query-client-server.ts @@ -0,0 +1,20 @@ +import { QueryClient } from '@tanstack/react-query' +import { cache } from 'react' + +const STALE_TIME = 1000 * 60 * 30 // 30 minutes + +export function makeQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { + staleTime: STALE_TIME, + }, + }, + }) +} + +/** + * Get QueryClient for server components + * Uses React cache() to ensure the same instance is reused within a single request + */ +export const getQueryClient = cache(makeQueryClient) diff --git a/web/context/query-client.tsx b/web/context/query-client.tsx index 9562686f6fd046..bec6e11c861c2b 100644 --- a/web/context/query-client.tsx +++ b/web/context/query-client.tsx @@ -1,25 +1,37 @@ 'use client' +import type { QueryClient } from '@tanstack/react-query' import type { FC, PropsWithChildren } from 'react' -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { QueryClientProvider } from '@tanstack/react-query' +import { useState } from 'react' import { TanStackDevtoolsLoader } from '@/app/components/devtools/tanstack/loader' +import { makeQueryClient } from './query-client-server' -const STALE_TIME = 1000 * 60 * 30 // 30 minutes +let browserQueryClient: QueryClient | undefined -const client = new QueryClient({ - defaultOptions: { - queries: { - staleTime: STALE_TIME, - }, - }, -}) +function getQueryClient() { + if (typeof window === 'undefined') { + // Server: always make a new query client + return makeQueryClient() + } + // Browser: make a new query client if we don't already have one + if (!browserQueryClient) + browserQueryClient = makeQueryClient() + return browserQueryClient +} -export const TanstackQueryInitializer: FC = (props) => { - const { children } = props +export const TanstackQueryInner: FC = ({ children }) => { + // Use useState to ensure stable QueryClient across re-renders + const [queryClient] = useState(getQueryClient) return ( - + {children} ) } + +/** + * @deprecated Use TanstackQueryInner instead for new code + */ +export const TanstackQueryInitializer = TanstackQueryInner diff --git a/web/hooks/use-query-params.ts b/web/hooks/use-query-params.ts index e0d7cc3c028dfd..5be72a40716436 100644 --- a/web/hooks/use-query-params.ts +++ b/web/hooks/use-query-params.ts @@ -13,6 +13,7 @@ * - Use shallow routing to avoid unnecessary re-renders */ +import type { Options } from 'nuqs' import { createParser, parseAsArrayOf, @@ -93,37 +94,42 @@ export function useAccountSettingModal() { return [{ isOpen, payload: currentTab }, setState] as const } -/** - * Marketplace Search Query Parameters - */ -export type MarketplaceFilters = { - q: string // search query - category: string // plugin category - tags: string[] // array of tags +export function useMarketplaceSearchQuery() { + return useQueryState('q', parseAsString.withDefault('').withOptions({ history: 'replace' })) +} +export function useMarketplaceCategory() { + return useQueryState('category', parseAsString.withDefault('all').withOptions({ history: 'replace', clearOnDefault: false })) +} +export function useMarketplaceTags() { + return useQueryState('tags', parseAsArrayOf(parseAsString).withDefault([]).withOptions({ history: 'replace' })) } - -/** - * Hook to manage marketplace search/filter state via URL - * Provides atomic updates - all params update together - * - * @example - * const [filters, setFilters] = useMarketplaceFilters() - * setFilters({ q: 'search', category: 'tool', tags: ['ai'] }) // Updates all at once - * setFilters({ q: '' }) // Only updates q, keeps others - * setFilters(null) // Clears all marketplace params - */ export function useMarketplaceFilters() { - return useQueryStates( - { - q: parseAsString.withDefault(''), - category: parseAsString.withDefault('all').withOptions({ clearOnDefault: false }), - tags: parseAsArrayOf(parseAsString).withDefault([]), - }, - { - // Update URL without pushing to history (replaceState behavior) - history: 'replace', + const [q, setQ] = useMarketplaceSearchQuery() + const [category, setCategory] = useMarketplaceCategory() + const [tags, setTags] = useMarketplaceTags() + + const setFilters = useCallback( + ( + updates: Partial<{ q: string, category: string, tags: string[] }> | null, + options?: Options, + ) => { + if (updates === null) { + setQ(null, options) + setCategory(null, options) + setTags(null, options) + return + } + if ('q' in updates) + setQ(updates.q!, options) + if ('category' in updates) + setCategory(updates.category!, options) + if ('tags' in updates) + setTags(updates.tags!, options) }, + [setQ, setCategory, setTags], ) + + return [{ q, category, tags }, setFilters] as const } /** From 64909f1890d84bc5673d58a1efc12e743283367a Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Wed, 7 Jan 2026 19:35:37 +0800 Subject: [PATCH 02/58] no test change for now --- .../plugins/marketplace/index.spec.tsx | 516 +++++++++++++----- .../plugins/marketplace/list/index.spec.tsx | 225 ++++++-- 2 files changed, 571 insertions(+), 170 deletions(-) diff --git a/web/app/components/plugins/marketplace/index.spec.tsx b/web/app/components/plugins/marketplace/index.spec.tsx index 165941c4d42a39..b3b1d58dd48846 100644 --- a/web/app/components/plugins/marketplace/index.spec.tsx +++ b/web/app/components/plugins/marketplace/index.spec.tsx @@ -1,4 +1,4 @@ -import type { MarketplaceCollection, SearchParamsFromCollection } from './types' +import type { MarketplaceCollection, SearchParams, SearchParamsFromCollection } from './types' import type { Plugin } from '@/app/components/plugins/types' import { act, fireEvent, render, renderHook, screen } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -42,13 +42,11 @@ vi.mock('@/i18n-config/i18next-config', () => ({ // Mock use-query-params hook const mockSetUrlFilters = vi.fn() -const mockFiltersState = { q: '', tags: [] as string[], category: 'all' } -const mockUseMarketplaceFilters = () => [mockFiltersState, mockSetUrlFilters] as const vi.mock('@/hooks/use-query-params', () => ({ - useMarketplaceFilters: () => mockUseMarketplaceFilters(), - useMarketplaceSearchQuery: () => [mockFiltersState.q, mockSetUrlFilters], - useMarketplaceCategory: () => [mockFiltersState.category, mockSetUrlFilters], - useMarketplaceTags: () => [mockFiltersState.tags, mockSetUrlFilters], + useMarketplaceFilters: () => [ + { q: '', tags: [], category: '' }, + mockSetUrlFilters, + ], })) // Mock use-plugins service @@ -83,7 +81,6 @@ vi.mock('@tanstack/react-query', () => ({ data: enabled ? { marketplaceCollections: [], marketplaceCollectionPluginsMap: {} } : undefined, isFetching: false, isPending: false, - isLoading: false, isSuccess: enabled, } }), @@ -374,32 +371,43 @@ const createMockCollection = (overrides?: Partial): Marke // Shared Test Components // ================================ -// Search input test component - uses setUrlFilters directly +// Search input test component - used in multiple tests const SearchInputTestComponent = () => { - const [{ q: searchText }, setUrlFilters] = mockUseMarketplaceFilters() + const searchText = useMarketplaceContext(v => v.searchPluginText) + const handleChange = useMarketplaceContext(v => v.handleSearchPluginTextChange) return (
setUrlFilters({ q: e.target.value })} + onChange={e => handleChange(e.target.value)} />
{searchText}
) } -// Plugin type change test component - uses setUrlFilters directly +// Plugin type change test component const PluginTypeChangeTestComponent = () => { - const [, setUrlFilters] = mockUseMarketplaceFilters() + const handleChange = useMarketplaceContext(v => v.handleActivePluginTypeChange) return ( - ) } +// Page change test component +const PageChangeTestComponent = () => { + const handlePageChange = useMarketplaceContext(v => v.handlePageChange) + return ( + + ) +} + // ================================ // Constants Tests // ================================ @@ -601,31 +609,52 @@ describe('useMarketplaceCollectionsAndPlugins', () => { vi.clearAllMocks() }) - it('should return TanStack Query result with standard properties', async () => { + it('should return initial state correctly', async () => { const { useMarketplaceCollectionsAndPlugins } = await import('./hooks') const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins()) - // TanStack Query returns standard properties - expect(result.current.isPending).toBeDefined() - expect(result.current.isSuccess).toBeDefined() - expect(result.current.data).toBeDefined() + expect(result.current.isLoading).toBe(false) + expect(result.current.isSuccess).toBe(false) + expect(result.current.queryMarketplaceCollectionsAndPlugins).toBeDefined() + expect(result.current.setMarketplaceCollections).toBeDefined() + expect(result.current.setMarketplaceCollectionPluginsMap).toBeDefined() }) - it('should return data with collections when query succeeds', async () => { + it('should provide queryMarketplaceCollectionsAndPlugins function', async () => { const { useMarketplaceCollectionsAndPlugins } = await import('./hooks') const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins()) - // Mock returns data when enabled - expect(result.current.data?.marketplaceCollections).toBeDefined() - expect(result.current.data?.marketplaceCollectionPluginsMap).toBeDefined() + expect(typeof result.current.queryMarketplaceCollectionsAndPlugins).toBe('function') }) - it('should accept enabled option', async () => { + it('should provide setMarketplaceCollections function', async () => { const { useMarketplaceCollectionsAndPlugins } = await import('./hooks') - const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins({}, { enabled: false })) + const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins()) - // When disabled, data should be undefined - expect(result.current.data).toBeUndefined() + expect(typeof result.current.setMarketplaceCollections).toBe('function') + }) + + it('should provide setMarketplaceCollectionPluginsMap function', async () => { + const { useMarketplaceCollectionsAndPlugins } = await import('./hooks') + const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins()) + + expect(typeof result.current.setMarketplaceCollectionPluginsMap).toBe('function') + }) + + it('should return marketplaceCollections from data or override', async () => { + const { useMarketplaceCollectionsAndPlugins } = await import('./hooks') + const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins()) + + // Initial state + expect(result.current.marketplaceCollections).toBeUndefined() + }) + + it('should return marketplaceCollectionPluginsMap from data or override', async () => { + const { useMarketplaceCollectionsAndPlugins } = await import('./hooks') + const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins()) + + // Initial state + expect(result.current.marketplaceCollectionPluginsMap).toBeUndefined() }) }) @@ -993,25 +1022,27 @@ describe('Advanced Hook Integration', () => { mockPostMarketplaceShouldFail = false }) - it('should test useMarketplaceCollectionsAndPlugins with query params', async () => { + it('should test useMarketplaceCollectionsAndPlugins with query call', async () => { const { useMarketplaceCollectionsAndPlugins } = await import('./hooks') - const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins({ + const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins()) + + // Call the query function + result.current.queryMarketplaceCollectionsAndPlugins({ condition: 'category=tool', type: 'plugin', - })) + }) - // Should return standard TanStack Query result - expect(result.current.data).toBeDefined() - expect(result.current.isSuccess).toBeDefined() + expect(result.current.queryMarketplaceCollectionsAndPlugins).toBeDefined() }) - it('should test useMarketplaceCollectionsAndPlugins with empty params', async () => { + it('should test useMarketplaceCollectionsAndPlugins with empty query', async () => { const { useMarketplaceCollectionsAndPlugins } = await import('./hooks') const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins()) - // Should return standard TanStack Query result - expect(result.current.data).toBeDefined() - expect(result.current.isSuccess).toBeDefined() + // Call with undefined (converts to empty object) + result.current.queryMarketplaceCollectionsAndPlugins() + + expect(result.current.queryMarketplaceCollectionsAndPlugins).toBeDefined() }) it('should test useMarketplacePluginsByCollectionId with different params', async () => { @@ -1147,18 +1178,18 @@ describe('Direct queryFn Coverage', () => { it('should test useMarketplaceCollectionsAndPlugins queryFn', async () => { const { useMarketplaceCollectionsAndPlugins } = await import('./hooks') - const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins({ + const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins()) + + // Trigger query to enable and capture queryFn + result.current.queryMarketplaceCollectionsAndPlugins({ condition: 'category=tool', - })) + }) - // TanStack Query captures queryFn internally if (capturedQueryFn) { const controller = new AbortController() const response = await capturedQueryFn({ signal: controller.signal }) expect(response).toBeDefined() } - - expect(result.current.data).toBeDefined() }) it('should test queryFn with all category', async () => { @@ -1501,8 +1532,8 @@ describe('MarketplaceContext', () => { describe('useMarketplaceContext', () => { it('should return selected value from context', () => { const TestComponent = () => { - const sort = useMarketplaceContext(v => v.sort) - return
{sort.sortBy}
+ const searchText = useMarketplaceContext(v => v.searchPluginText) + return
{searchText}
} render( @@ -1511,7 +1542,7 @@ describe('MarketplaceContext', () => { , ) - expect(screen.getByTestId('sort')).toHaveTextContent('install_count') + expect(screen.getByTestId('search-text')).toHaveTextContent('') }) }) @@ -1531,7 +1562,8 @@ describe('MarketplaceContext', () => { mockInfiniteQueryData = undefined const TestComponent = () => { - const [{ category: activePluginType, tags: filterPluginTags }] = mockUseMarketplaceFilters() + const activePluginType = useMarketplaceContext(v => v.activePluginType) + const filterPluginTags = useMarketplaceContext(v => v.filterPluginTags) const sort = useMarketplaceContext(v => v.sort) const page = useMarketplaceContext(v => v.page) @@ -1558,20 +1590,24 @@ describe('MarketplaceContext', () => { expect(screen.getByTestId('page')).toBeInTheDocument() }) - it('should provide searchPluginText from URL state via nuqs hook', () => { + it('should initialize with searchParams from props', () => { + const searchParams: SearchParams = { + q: 'test query', + category: 'tool', + } + const TestComponent = () => { - const [{ q: searchText }] = mockUseMarketplaceFilters() - return
{searchText || 'empty'}
+ const searchText = useMarketplaceContext(v => v.searchPluginText) + return
{searchText}
} render( - + , ) - // Initial state from mock is empty string - expect(screen.getByTestId('search')).toHaveTextContent('empty') + expect(screen.getByTestId('search')).toHaveTextContent('test query') }) it('should provide handleSearchPluginTextChange function', () => { @@ -1584,19 +1620,19 @@ describe('MarketplaceContext', () => { const input = screen.getByTestId('search-input') fireEvent.change(input, { target: { value: 'new search' } }) - // Handler calls setUrlFilters - expect(mockSetUrlFilters).toHaveBeenCalledWith({ q: 'new search' }) + expect(screen.getByTestId('search-display')).toHaveTextContent('new search') }) - it('should react to filter tags changes via URL', () => { + it('should provide handleFilterPluginTagsChange function', () => { const TestComponent = () => { - const [{ tags }, setUrlFilters] = mockUseMarketplaceFilters() + const tags = useMarketplaceContext(v => v.filterPluginTags) + const handleChange = useMarketplaceContext(v => v.handleFilterPluginTagsChange) return (
@@ -1613,19 +1649,19 @@ describe('MarketplaceContext', () => { fireEvent.click(screen.getByTestId('add-tag')) - // setUrlFilters is called directly - expect(mockSetUrlFilters).toHaveBeenCalledWith({ tags: ['search', 'image'] }) + expect(screen.getByTestId('tags-display')).toHaveTextContent('search,image') }) - it('should react to plugin type changes via URL', () => { + it('should provide handleActivePluginTypeChange function', () => { const TestComponent = () => { - const [{ category: activeType }, setUrlFilters] = mockUseMarketplaceFilters() + const activeType = useMarketplaceContext(v => v.activePluginType) + const handleChange = useMarketplaceContext(v => v.handleActivePluginTypeChange) return (
@@ -1642,8 +1678,7 @@ describe('MarketplaceContext', () => { fireEvent.click(screen.getByTestId('change-type')) - // setUrlFilters is called directly - expect(mockSetUrlFilters).toHaveBeenCalledWith({ category: 'tool' }, { history: 'push' }) + expect(screen.getByTestId('type-display')).toHaveTextContent('tool') }) it('should provide handleSortChange function', () => { @@ -1677,7 +1712,7 @@ describe('MarketplaceContext', () => { it('should provide handleMoreClick function', () => { const TestComponent = () => { - const [{ q: searchText }] = mockUseMarketplaceFilters() + const searchText = useMarketplaceContext(v => v.searchPluginText) const sort = useMarketplaceContext(v => v.sort) const handleMoreClick = useMarketplaceContext(v => v.handleMoreClick) @@ -1709,8 +1744,38 @@ describe('MarketplaceContext', () => { fireEvent.click(screen.getByTestId('more-click')) - // Handler calls setUrlFilters with the query - expect(mockSetUrlFilters).toHaveBeenCalledWith({ q: 'more query' }) + expect(screen.getByTestId('search-display')).toHaveTextContent('more query') + expect(screen.getByTestId('sort-display')).toHaveTextContent('version_updated_at-DESC') + }) + + it('should provide resetPlugins function', () => { + const TestComponent = () => { + const resetPlugins = useMarketplaceContext(v => v.resetPlugins) + const plugins = useMarketplaceContext(v => v.plugins) + + return ( +
+ +
{plugins ? 'has plugins' : 'no plugins'}
+
+ ) + } + + render( + + + , + ) + + fireEvent.click(screen.getByTestId('reset-plugins')) + + // Plugins should remain undefined after reset + expect(screen.getByTestId('plugins-display')).toHaveTextContent('no plugins') }) it('should accept shouldExclude prop', () => { @@ -1737,6 +1802,16 @@ describe('MarketplaceContext', () => { expect(screen.getByTestId('child')).toBeInTheDocument() }) + + it('should accept showSearchParams prop', () => { + render( + +
Child
+
, + ) + + expect(screen.getByTestId('child')).toBeInTheDocument() + }) }) }) @@ -1764,20 +1839,21 @@ describe('PluginTypeSwitch', () => { describe('Rendering', () => { it('should render without crashing', () => { const TestComponent = () => { - const [{ category: activeType }, setUrlFilters] = mockUseMarketplaceFilters() + const activeType = useMarketplaceContext(v => v.activePluginType) + const handleChange = useMarketplaceContext(v => v.handleActivePluginTypeChange) return (
setUrlFilters({ category: 'all' }, { history: 'push' })} + onClick={() => handleChange('all')} data-testid="all-option" > All
setUrlFilters({ category: 'tool' }, { history: 'push' })} + onClick={() => handleChange('tool')} data-testid="tool-option" > Tools @@ -1798,13 +1874,14 @@ describe('PluginTypeSwitch', () => { it('should highlight active plugin type', () => { const TestComponent = () => { - const [{ category: activeType }, setUrlFilters] = mockUseMarketplaceFilters() + const activeType = useMarketplaceContext(v => v.activePluginType) + const handleChange = useMarketplaceContext(v => v.handleActivePluginTypeChange) return (
setUrlFilters({ category: 'all' }, { history: 'push' })} + onClick={() => handleChange('all')} data-testid="all-option" > All @@ -1824,14 +1901,15 @@ describe('PluginTypeSwitch', () => { }) describe('User Interactions', () => { - it('should call setUrlFilters when option is clicked', () => { + it('should call handleActivePluginTypeChange when option is clicked', () => { const TestComponent = () => { - const [{ category: activeType }, setUrlFilters] = mockUseMarketplaceFilters() + const handleChange = useMarketplaceContext(v => v.handleActivePluginTypeChange) + const activeType = useMarketplaceContext(v => v.activePluginType) return (
setUrlFilters({ category: 'tool' }, { history: 'push' })} + onClick={() => handleChange('tool')} data-testid="tool-option" > Tools @@ -1848,18 +1926,19 @@ describe('PluginTypeSwitch', () => { ) fireEvent.click(screen.getByTestId('tool-option')) - expect(mockSetUrlFilters).toHaveBeenCalledWith({ category: 'tool' }, { history: 'push' }) + expect(screen.getByTestId('active-type')).toHaveTextContent('tool') }) it('should update active type when different option is selected', () => { const TestComponent = () => { - const [{ category: activeType }, setUrlFilters] = mockUseMarketplaceFilters() + const activeType = useMarketplaceContext(v => v.activePluginType) + const handleChange = useMarketplaceContext(v => v.handleActivePluginTypeChange) return (
setUrlFilters({ category: 'model' }, { history: 'push' })} + onClick={() => handleChange('model')} data-testid="model-option" > Models @@ -1877,14 +1956,14 @@ describe('PluginTypeSwitch', () => { fireEvent.click(screen.getByTestId('model-option')) - expect(mockSetUrlFilters).toHaveBeenCalledWith({ category: 'model' }, { history: 'push' }) + expect(screen.getByTestId('active-display')).toHaveTextContent('model') }) }) describe('Props', () => { it('should accept locale prop', () => { const TestComponent = () => { - const [{ category: activeType }] = mockUseMarketplaceFilters() + const activeType = useMarketplaceContext(v => v.activePluginType) return
{activeType}
} @@ -1966,6 +2045,16 @@ describe('StickySearchAndSwitchWrapper', () => { }) describe('Props', () => { + it('should accept showSearchParams prop', () => { + render( + + + , + ) + + expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + }) + it('should pass pluginTypeSwitchClassName to wrapper', () => { const { container } = render( @@ -1992,16 +2081,16 @@ describe('Marketplace Integration', () => { describe('Context with child components', () => { it('should share state between multiple consumers', () => { const SearchDisplay = () => { - const [{ q: searchText }] = mockUseMarketplaceFilters() + const searchText = useMarketplaceContext(v => v.searchPluginText) return
{searchText || 'empty'}
} const SearchInput = () => { - const [, setUrlFilters] = mockUseMarketplaceFilters() + const handleChange = useMarketplaceContext(v => v.handleSearchPluginTextChange) return ( setUrlFilters({ q: e.target.value })} + onChange={e => handleChange(e.target.value)} /> ) } @@ -2017,20 +2106,22 @@ describe('Marketplace Integration', () => { fireEvent.change(screen.getByTestId('search-input'), { target: { value: 'test' } }) - // setUrlFilters is called directly - expect(mockSetUrlFilters).toHaveBeenCalledWith({ q: 'test' }) + expect(screen.getByTestId('search-display')).toHaveTextContent('test') }) - it('should update tags when search criteria changes', () => { + it('should update tags and reset plugins when search criteria changes', () => { const TestComponent = () => { - const [{ tags }, setUrlFilters] = mockUseMarketplaceFilters() + const tags = useMarketplaceContext(v => v.filterPluginTags) + const handleTagsChange = useMarketplaceContext(v => v.handleFilterPluginTagsChange) + const resetPlugins = useMarketplaceContext(v => v.resetPlugins) const handleAddTag = () => { - setUrlFilters({ tags: ['search'] }) + handleTagsChange(['search']) } const handleReset = () => { - setUrlFilters({ tags: [] }) + handleTagsChange([]) + resetPlugins() } return ( @@ -2051,10 +2142,10 @@ describe('Marketplace Integration', () => { expect(screen.getByTestId('tags')).toHaveTextContent('none') fireEvent.click(screen.getByTestId('add-tag')) - expect(mockSetUrlFilters).toHaveBeenCalledWith({ tags: ['search'] }) + expect(screen.getByTestId('tags')).toHaveTextContent('search') fireEvent.click(screen.getByTestId('reset')) - expect(mockSetUrlFilters).toHaveBeenCalledWith({ tags: [] }) + expect(screen.getByTestId('tags')).toHaveTextContent('none') }) }) @@ -2102,7 +2193,8 @@ describe('Marketplace Integration', () => { describe('Plugin type switching', () => { it('should filter by plugin type', () => { const TestComponent = () => { - const [{ category: activeType }, setUrlFilters] = mockUseMarketplaceFilters() + const activeType = useMarketplaceContext(v => v.activePluginType) + const handleTypeChange = useMarketplaceContext(v => v.handleActivePluginTypeChange) return (
@@ -2110,7 +2202,7 @@ describe('Marketplace Integration', () => { @@ -2129,13 +2221,13 @@ describe('Marketplace Integration', () => { expect(screen.getByTestId('active-type')).toHaveTextContent('all') fireEvent.click(screen.getByTestId('type-tool')) - expect(mockSetUrlFilters).toHaveBeenCalledWith({ category: 'tool' }, { history: 'push' }) + expect(screen.getByTestId('active-type')).toHaveTextContent('tool') fireEvent.click(screen.getByTestId('type-model')) - expect(mockSetUrlFilters).toHaveBeenCalledWith({ category: 'model' }, { history: 'push' }) + expect(screen.getByTestId('active-type')).toHaveTextContent('model') fireEvent.click(screen.getByTestId('type-bundle')) - expect(mockSetUrlFilters).toHaveBeenCalledWith({ category: 'bundle' }, { history: 'push' }) + expect(screen.getByTestId('active-type')).toHaveTextContent('bundle') }) }) }) @@ -2152,12 +2244,12 @@ describe('Edge Cases', () => { describe('Empty states', () => { it('should handle empty search text', () => { const TestComponent = () => { - const [{ q: searchText }] = mockUseMarketplaceFilters() + const searchText = useMarketplaceContext(v => v.searchPluginText) return
{searchText || 'empty'}
} render( - + , ) @@ -2167,7 +2259,7 @@ describe('Edge Cases', () => { it('should handle empty tags array', () => { const TestComponent = () => { - const [{ tags }] = mockUseMarketplaceFilters() + const tags = useMarketplaceContext(v => v.filterPluginTags) return
{tags.length === 0 ? 'no tags' : tags.join(',')}
} @@ -2208,15 +2300,15 @@ describe('Edge Cases', () => { // Test with special characters fireEvent.change(input, { target: { value: 'test@#$%^&*()' } }) - expect(mockSetUrlFilters).toHaveBeenCalledWith({ q: 'test@#$%^&*()' }) + expect(screen.getByTestId('search-display')).toHaveTextContent('test@#$%^&*()') // Test with unicode characters fireEvent.change(input, { target: { value: 'ζ΅‹θ―•δΈ­ζ–‡' } }) - expect(mockSetUrlFilters).toHaveBeenCalledWith({ q: 'ζ΅‹θ―•δΈ­ζ–‡' }) + expect(screen.getByTestId('search-display')).toHaveTextContent('ζ΅‹θ―•δΈ­ζ–‡') // Test with emojis fireEvent.change(input, { target: { value: 'πŸ” search' } }) - expect(mockSetUrlFilters).toHaveBeenCalledWith({ q: 'πŸ” search' }) + expect(screen.getByTestId('search-display')).toHaveTextContent('πŸ” search') }) }) @@ -2237,19 +2329,20 @@ describe('Edge Cases', () => { fireEvent.change(input, { target: { value: 'abcd' } }) fireEvent.change(input, { target: { value: 'abcde' } }) - // Final call should be with 'abcde' - expect(mockSetUrlFilters).toHaveBeenLastCalledWith({ q: 'abcde' }) + // Final value should be the last one + expect(screen.getByTestId('search-display')).toHaveTextContent('abcde') }) it('should handle rapid type changes', () => { const TestComponent = () => { - const [{ category: activeType }, setUrlFilters] = mockUseMarketplaceFilters() + const activeType = useMarketplaceContext(v => v.activePluginType) + const handleChange = useMarketplaceContext(v => v.handleActivePluginTypeChange) return (
- - - + + +
{activeType}
) @@ -2267,8 +2360,7 @@ describe('Edge Cases', () => { fireEvent.click(screen.getByTestId('type-all')) fireEvent.click(screen.getByTestId('type-tool')) - // Verify last call was with 'tool' - expect(mockSetUrlFilters).toHaveBeenLastCalledWith({ category: 'tool' }, { history: 'push' }) + expect(screen.getByTestId('active-type')).toHaveTextContent('tool') }) }) @@ -2277,14 +2369,15 @@ describe('Edge Cases', () => { const longText = 'a'.repeat(1000) const TestComponent = () => { - const [{ q: searchText }, setUrlFilters] = mockUseMarketplaceFilters() + const searchText = useMarketplaceContext(v => v.searchPluginText) + const handleChange = useMarketplaceContext(v => v.handleSearchPluginTextChange) return (
setUrlFilters({ q: e.target.value })} + onChange={e => handleChange(e.target.value)} />
{searchText.length}
@@ -2299,21 +2392,21 @@ describe('Edge Cases', () => { fireEvent.change(screen.getByTestId('search-input'), { target: { value: longText } }) - // setUrlFilters is called with the long text - expect(mockSetUrlFilters).toHaveBeenCalledWith({ q: longText }) + expect(screen.getByTestId('search-length')).toHaveTextContent('1000') }) it('should handle large number of tags', () => { const manyTags = Array.from({ length: 100 }, (_, i) => `tag-${i}`) const TestComponent = () => { - const [{ tags }, setUrlFilters] = mockUseMarketplaceFilters() + const tags = useMarketplaceContext(v => v.filterPluginTags) + const handleChange = useMarketplaceContext(v => v.handleFilterPluginTagsChange) return (
@@ -2330,8 +2423,7 @@ describe('Edge Cases', () => { fireEvent.click(screen.getByTestId('add-many-tags')) - // setUrlFilters is called with all tags - expect(mockSetUrlFilters).toHaveBeenCalledWith({ tags: manyTags }) + expect(screen.getByTestId('tags-count')).toHaveTextContent('100') }) }) @@ -2633,7 +2725,7 @@ describe('PluginTypeSwitch Component', () => { it('should call handleActivePluginTypeChange on option click', () => { const TestWrapper = () => { - const [{ category: activeType }] = mockUseMarketplaceFilters() + const activeType = useMarketplaceContext(v => v.activePluginType) return (
@@ -2649,16 +2741,15 @@ describe('PluginTypeSwitch Component', () => { ) fireEvent.click(screen.getByText('plugin.category.tools')) - // Now uses useMarketplaceCategory directly, so it calls setCategory(value, options) - expect(mockSetUrlFilters).toHaveBeenCalledWith('tool', { history: 'push' }) + expect(screen.getByTestId('active-type-display')).toHaveTextContent('tool') }) - it('should call setUrlFilters when option is clicked', () => { + it('should highlight active option with correct classes', () => { const TestWrapper = () => { - const [, setUrlFilters] = mockUseMarketplaceFilters() + const handleChange = useMarketplaceContext(v => v.handleActivePluginTypeChange) return (
- +
) @@ -2671,26 +2762,27 @@ describe('PluginTypeSwitch Component', () => { ) fireEvent.click(screen.getByTestId('set-model')) - expect(mockSetUrlFilters).toHaveBeenCalledWith({ category: 'model' }, { history: 'push' }) + const modelOption = screen.getByText('plugin.category.models').closest('div') + expect(modelOption).toHaveClass('shadow-xs') }) }) describe('Popstate handling', () => { - it('should handle popstate event', () => { + it('should handle popstate event when showSearchParams is true', () => { const originalHref = window.location.href const TestWrapper = () => { - const [{ category: activeType }] = mockUseMarketplaceFilters() + const activeType = useMarketplaceContext(v => v.activePluginType) return (
- +
{activeType}
) } render( - + , ) @@ -2701,6 +2793,31 @@ describe('PluginTypeSwitch Component', () => { expect(screen.getByTestId('active-type')).toBeInTheDocument() expect(window.location.href).toBe(originalHref) }) + + it('should not handle popstate when showSearchParams is false', () => { + const TestWrapper = () => { + const activeType = useMarketplaceContext(v => v.activePluginType) + return ( +
+ +
{activeType}
+
+ ) + } + + render( + + + , + ) + + expect(screen.getByTestId('active-type')).toHaveTextContent('all') + + const popstateEvent = new PopStateEvent('popstate') + window.dispatchEvent(popstateEvent) + + expect(screen.getByTestId('active-type')).toHaveTextContent('all') + }) }) }) @@ -2716,9 +2833,9 @@ describe('Context Advanced', () => { }) describe('URL filter synchronization', () => { - it('should update URL filters when type changes', () => { + it('should update URL filters when showSearchParams is true and type changes', () => { render( - + , ) @@ -2726,6 +2843,126 @@ describe('Context Advanced', () => { fireEvent.click(screen.getByTestId('change-type')) expect(mockSetUrlFilters).toHaveBeenCalled() }) + + it('should not update URL filters when showSearchParams is false', () => { + render( + + + , + ) + + fireEvent.click(screen.getByTestId('change-type')) + expect(mockSetUrlFilters).not.toHaveBeenCalled() + }) + }) + + describe('handlePageChange', () => { + it('should invoke fetchNextPage when hasNextPage is true', () => { + mockHasNextPage = true + + render( + + + , + ) + + fireEvent.click(screen.getByTestId('next-page')) + expect(mockFetchNextPage).toHaveBeenCalled() + }) + + it('should not invoke fetchNextPage when hasNextPage is false', () => { + mockHasNextPage = false + + render( + + + , + ) + + fireEvent.click(screen.getByTestId('next-page')) + expect(mockFetchNextPage).not.toHaveBeenCalled() + }) + }) + + describe('setMarketplaceCollectionsFromClient', () => { + it('should provide setMarketplaceCollectionsFromClient function', () => { + const TestComponent = () => { + const setCollections = useMarketplaceContext(v => v.setMarketplaceCollectionsFromClient) + + return ( +
+ +
+ ) + } + + render( + + + , + ) + + expect(screen.getByTestId('set-collections')).toBeInTheDocument() + // The function should be callable without throwing + expect(() => fireEvent.click(screen.getByTestId('set-collections'))).not.toThrow() + }) + }) + + describe('setMarketplaceCollectionPluginsMapFromClient', () => { + it('should provide setMarketplaceCollectionPluginsMapFromClient function', () => { + const TestComponent = () => { + const setPluginsMap = useMarketplaceContext(v => v.setMarketplaceCollectionPluginsMapFromClient) + + return ( +
+ +
+ ) + } + + render( + + + , + ) + + expect(screen.getByTestId('set-plugins-map')).toBeInTheDocument() + // The function should be callable without throwing + expect(() => fireEvent.click(screen.getByTestId('set-plugins-map'))).not.toThrow() + }) + }) + + describe('handleQueryPlugins', () => { + it('should provide handleQueryPlugins function that can be called', () => { + const TestComponent = () => { + const handleQueryPlugins = useMarketplaceContext(v => v.handleQueryPlugins) + return ( + + ) + } + + render( + + + , + ) + + expect(screen.getByTestId('query-plugins')).toBeInTheDocument() + fireEvent.click(screen.getByTestId('query-plugins')) + expect(screen.getByTestId('query-plugins')).toBeInTheDocument() + }) }) describe('isLoading state', () => { @@ -2745,6 +2982,23 @@ describe('Context Advanced', () => { }) }) + describe('isSuccessCollections state', () => { + it('should expose isSuccessCollections state', () => { + const TestComponent = () => { + const isSuccess = useMarketplaceContext(v => v.isSuccessCollections) + return
{isSuccess.toString()}
+ } + + render( + + + , + ) + + expect(screen.getByTestId('success')).toHaveTextContent('false') + }) + }) + describe('pluginsTotal', () => { it('should expose plugins total count', () => { const TestComponent = () => { diff --git a/web/app/components/plugins/marketplace/list/index.spec.tsx b/web/app/components/plugins/marketplace/list/index.spec.tsx index 6e2c482d4a6040..c8fc6309a4b9cf 100644 --- a/web/app/components/plugins/marketplace/list/index.spec.tsx +++ b/web/app/components/plugins/marketplace/list/index.spec.tsx @@ -1,6 +1,6 @@ import type { MarketplaceCollection, SearchParamsFromCollection } from '../types' import type { Plugin } from '@/app/components/plugins/types' -import { fireEvent, render, screen } from '@testing-library/react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { PluginCategoryEnum } from '@/app/components/plugins/types' import List from './index' @@ -34,9 +34,11 @@ vi.mock('#i18n', () => ({ const mockContextValues = { plugins: undefined as Plugin[] | undefined, pluginsTotal: 0, - marketplaceCollections: undefined as MarketplaceCollection[] | undefined, - marketplaceCollectionPluginsMap: undefined as Record | undefined, + marketplaceCollectionsFromClient: undefined as MarketplaceCollection[] | undefined, + marketplaceCollectionPluginsMapFromClient: undefined as Record | undefined, isLoading: false, + isSuccessCollections: false, + handleQueryPlugins: vi.fn(), searchPluginText: '', filterPluginTags: [] as string[], page: 1, @@ -801,6 +803,8 @@ describe('ListWithCollection', () => { // ================================ describe('ListWrapper', () => { const defaultProps = { + marketplaceCollections: [] as MarketplaceCollection[], + marketplaceCollectionPluginsMap: {} as Record, showInstallButton: false, } @@ -809,9 +813,10 @@ describe('ListWrapper', () => { // Reset context values mockContextValues.plugins = undefined mockContextValues.pluginsTotal = 0 - mockContextValues.marketplaceCollections = undefined - mockContextValues.marketplaceCollectionPluginsMap = undefined + mockContextValues.marketplaceCollectionsFromClient = undefined + mockContextValues.marketplaceCollectionPluginsMapFromClient = undefined mockContextValues.isLoading = false + mockContextValues.isSuccessCollections = false mockContextValues.searchPluginText = '' mockContextValues.filterPluginTags = [] mockContextValues.page = 1 @@ -889,12 +894,18 @@ describe('ListWrapper', () => { describe('List Rendering Logic', () => { it('should render List when not loading', () => { mockContextValues.isLoading = false - mockContextValues.marketplaceCollections = createMockCollectionList(1) - mockContextValues.marketplaceCollectionPluginsMap = { + const collections = createMockCollectionList(1) + const pluginsMap: Record = { 'collection-0': createMockPluginList(1), } - render() + render( + , + ) expect(screen.getByText('Collection 0')).toBeInTheDocument() }) @@ -902,28 +913,69 @@ describe('ListWrapper', () => { it('should render List when loading but page > 1', () => { mockContextValues.isLoading = true mockContextValues.page = 2 - mockContextValues.marketplaceCollections = createMockCollectionList(1) - mockContextValues.marketplaceCollectionPluginsMap = { + const collections = createMockCollectionList(1) + const pluginsMap: Record = { 'collection-0': createMockPluginList(1), } - render() + render( + , + ) expect(screen.getByText('Collection 0')).toBeInTheDocument() }) - it('should render collections from context', () => { - const collections = createMockCollectionList(1) - collections[0].label = { 'en-US': 'Context Collection' } + it('should use client collections when available', () => { + const serverCollections = createMockCollectionList(1) + serverCollections[0].label = { 'en-US': 'Server Collection' } + const clientCollections = createMockCollectionList(1) + clientCollections[0].label = { 'en-US': 'Client Collection' } - mockContextValues.marketplaceCollections = collections - mockContextValues.marketplaceCollectionPluginsMap = { + const serverPluginsMap: Record = { + 'collection-0': createMockPluginList(1), + } + const clientPluginsMap: Record = { 'collection-0': createMockPluginList(1), } - render() + mockContextValues.marketplaceCollectionsFromClient = clientCollections + mockContextValues.marketplaceCollectionPluginsMapFromClient = clientPluginsMap - expect(screen.getByText('Context Collection')).toBeInTheDocument() + render( + , + ) + + expect(screen.getByText('Client Collection')).toBeInTheDocument() + expect(screen.queryByText('Server Collection')).not.toBeInTheDocument() + }) + + it('should use server collections when client collections are not available', () => { + const serverCollections = createMockCollectionList(1) + serverCollections[0].label = { 'en-US': 'Server Collection' } + const serverPluginsMap: Record = { + 'collection-0': createMockPluginList(1), + } + + mockContextValues.marketplaceCollectionsFromClient = undefined + mockContextValues.marketplaceCollectionPluginsMapFromClient = undefined + + render( + , + ) + + expect(screen.getByText('Server Collection')).toBeInTheDocument() }) }) @@ -950,12 +1002,17 @@ describe('ListWrapper', () => { searchable: true, search_params: { query: 'test' }, })] - mockContextValues.marketplaceCollections = collections - mockContextValues.marketplaceCollectionPluginsMap = { + const pluginsMap: Record = { 'collection-0': createMockPluginList(1), } - render() + render( + , + ) fireEvent.click(screen.getByText('View More')) @@ -963,6 +1020,72 @@ describe('ListWrapper', () => { }) }) + // ================================ + // Effect Tests (handleQueryPlugins) + // ================================ + describe('handleQueryPlugins Effect', () => { + it('should call handleQueryPlugins when conditions are met', async () => { + const mockHandleQueryPlugins = vi.fn() + mockContextValues.handleQueryPlugins = mockHandleQueryPlugins + mockContextValues.isSuccessCollections = true + mockContextValues.marketplaceCollectionsFromClient = undefined + mockContextValues.searchPluginText = '' + mockContextValues.filterPluginTags = [] + + render() + + await waitFor(() => { + expect(mockHandleQueryPlugins).toHaveBeenCalled() + }) + }) + + it('should not call handleQueryPlugins when client collections exist', async () => { + const mockHandleQueryPlugins = vi.fn() + mockContextValues.handleQueryPlugins = mockHandleQueryPlugins + mockContextValues.isSuccessCollections = true + mockContextValues.marketplaceCollectionsFromClient = createMockCollectionList(1) + mockContextValues.searchPluginText = '' + mockContextValues.filterPluginTags = [] + + render() + + // Give time for effect to run + await waitFor(() => { + expect(mockHandleQueryPlugins).not.toHaveBeenCalled() + }) + }) + + it('should not call handleQueryPlugins when search text exists', async () => { + const mockHandleQueryPlugins = vi.fn() + mockContextValues.handleQueryPlugins = mockHandleQueryPlugins + mockContextValues.isSuccessCollections = true + mockContextValues.marketplaceCollectionsFromClient = undefined + mockContextValues.searchPluginText = 'search text' + mockContextValues.filterPluginTags = [] + + render() + + await waitFor(() => { + expect(mockHandleQueryPlugins).not.toHaveBeenCalled() + }) + }) + + it('should not call handleQueryPlugins when filter tags exist', async () => { + const mockHandleQueryPlugins = vi.fn() + mockContextValues.handleQueryPlugins = mockHandleQueryPlugins + mockContextValues.isSuccessCollections = true + mockContextValues.marketplaceCollectionsFromClient = undefined + mockContextValues.searchPluginText = '' + mockContextValues.filterPluginTags = ['tag1'] + + render() + + await waitFor(() => { + expect(mockHandleQueryPlugins).not.toHaveBeenCalled() + }) + }) + }) + // ================================ // Edge Cases Tests // ================================ @@ -1309,17 +1432,20 @@ describe('Combined Workflows', () => { mockContextValues.pluginsTotal = 0 mockContextValues.isLoading = false mockContextValues.page = 1 - mockContextValues.marketplaceCollections = undefined - mockContextValues.marketplaceCollectionPluginsMap = undefined + mockContextValues.marketplaceCollectionsFromClient = undefined + mockContextValues.marketplaceCollectionPluginsMapFromClient = undefined }) it('should transition from loading to showing collections', async () => { mockContextValues.isLoading = true mockContextValues.page = 1 - mockContextValues.marketplaceCollections = [] - mockContextValues.marketplaceCollectionPluginsMap = {} - const { rerender } = render() + const { rerender } = render( + , + ) expect(screen.getByTestId('loading-component')).toBeInTheDocument() @@ -1329,10 +1455,15 @@ describe('Combined Workflows', () => { const pluginsMap: Record = { 'collection-0': createMockPluginList(1), } - mockContextValues.marketplaceCollections = collections - mockContextValues.marketplaceCollectionPluginsMap = pluginsMap + mockContextValues.marketplaceCollectionsFromClient = collections + mockContextValues.marketplaceCollectionPluginsMapFromClient = pluginsMap - rerender() + rerender( + , + ) expect(screen.queryByTestId('loading-component')).not.toBeInTheDocument() expect(screen.getByText('Collection 0')).toBeInTheDocument() @@ -1343,10 +1474,15 @@ describe('Combined Workflows', () => { const pluginsMap: Record = { 'collection-0': createMockPluginList(1), } - mockContextValues.marketplaceCollections = collections - mockContextValues.marketplaceCollectionPluginsMap = pluginsMap + mockContextValues.marketplaceCollectionsFromClient = collections + mockContextValues.marketplaceCollectionPluginsMapFromClient = pluginsMap - const { rerender } = render() + const { rerender } = render( + , + ) expect(screen.getByText('Collection 0')).toBeInTheDocument() @@ -1354,7 +1490,12 @@ describe('Combined Workflows', () => { mockContextValues.plugins = createMockPluginList(5) mockContextValues.pluginsTotal = 5 - rerender() + rerender( + , + ) expect(screen.queryByText('Collection 0')).not.toBeInTheDocument() expect(screen.getByText('5 plugins found')).toBeInTheDocument() @@ -1363,10 +1504,13 @@ describe('Combined Workflows', () => { it('should handle empty search results', () => { mockContextValues.plugins = [] mockContextValues.pluginsTotal = 0 - mockContextValues.marketplaceCollections = [] - mockContextValues.marketplaceCollectionPluginsMap = {} - render() + render( + , + ) expect(screen.getByTestId('empty-component')).toBeInTheDocument() expect(screen.getByText('0 plugins found')).toBeInTheDocument() @@ -1377,10 +1521,13 @@ describe('Combined Workflows', () => { mockContextValues.pluginsTotal = 80 mockContextValues.isLoading = true mockContextValues.page = 2 - mockContextValues.marketplaceCollections = [] - mockContextValues.marketplaceCollectionPluginsMap = {} - render() + render( + , + ) // Should show existing results while loading more expect(screen.getByText('80 plugins found')).toBeInTheDocument() From e529368a966e53895af9acc4c65c2db2b87573e5 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Wed, 7 Jan 2026 19:46:54 +0800 Subject: [PATCH 03/58] tab --- web/app/components/plugins/marketplace/index.tsx | 14 +++++++------- web/app/components/plugins/plugin-page/context.tsx | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/web/app/components/plugins/marketplace/index.tsx b/web/app/components/plugins/marketplace/index.tsx index 6786ed51098ae4..39e91ebb4a0daf 100644 --- a/web/app/components/plugins/marketplace/index.tsx +++ b/web/app/components/plugins/marketplace/index.tsx @@ -13,19 +13,19 @@ async function Marketplace({ showInstallButton = true, pluginTypeSwitchClassName, }: MarketplaceProps) { - const queryClient = getQueryClient() + // const queryClient = getQueryClient() - // Prefetch collections and plugins for the default view (all categories) - await queryClient.prefetchQuery({ - queryKey: marketplaceKeys.collections({}), - queryFn: () => getMarketplaceCollectionsAndPlugins({}), - }) + // // Prefetch collections and plugins for the default view (all categories) + // await queryClient.prefetchQuery({ + // queryKey: marketplaceKeys.collections({}), + // queryFn: () => getMarketplaceCollectionsAndPlugins({}), + // }) return ( ) } diff --git a/web/app/components/plugins/plugin-page/context.tsx b/web/app/components/plugins/plugin-page/context.tsx index fea78ae181eb99..abc4408d628df2 100644 --- a/web/app/components/plugins/plugin-page/context.tsx +++ b/web/app/components/plugins/plugin-page/context.tsx @@ -68,7 +68,7 @@ export const PluginPageContextProvider = ({ const options = useMemo(() => { return enable_marketplace ? tabs : tabs.filter(tab => tab.value !== PLUGIN_PAGE_TABS_MAP.marketplace) }, [tabs, enable_marketplace]) - const [activeTab, setActiveTab] = useQueryState('category', { + const [activeTab, setActiveTab] = useQueryState('tab', { defaultValue: options[0].value, }) From 712c2c1d0923df0d0eab134c57ed859ea1217024 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Wed, 7 Jan 2026 22:22:36 +0800 Subject: [PATCH 04/58] reative version --- .../components/plugins/marketplace/hooks.ts | 160 +++++++++++------- 1 file changed, 102 insertions(+), 58 deletions(-) diff --git a/web/app/components/plugins/marketplace/hooks.ts b/web/app/components/plugins/marketplace/hooks.ts index 0d47ea9f5919fd..831e8db4ed57b0 100644 --- a/web/app/components/plugins/marketplace/hooks.ts +++ b/web/app/components/plugins/marketplace/hooks.ts @@ -113,6 +113,67 @@ export function useMarketplacePluginsByCollectionId( const DEFAULT_PAGE_SIZE = 40 +async function fetchMarketplacePlugins( + queryParams: PluginsSearchParams | undefined, + pageParam: number, + signal?: AbortSignal, +) { + if (!queryParams) { + return { + plugins: [] as Plugin[], + total: 0, + page: 1, + pageSize: DEFAULT_PAGE_SIZE, + } + } + + const { + query, + sortBy, + sortOrder, + category, + tags, + type, + pageSize = DEFAULT_PAGE_SIZE, + } = queryParams + const pluginOrBundle = type === 'bundle' ? 'bundles' : 'plugins' + + try { + const res = await postMarketplace<{ data: PluginsFromMarketplaceResponse }>( + `/${pluginOrBundle}/search/advanced`, + { + body: { + page: pageParam, + page_size: pageSize, + query, + sort_by: sortBy, + sort_order: sortOrder, + category: category !== 'all' ? category : '', + tags, + type, + }, + signal, + }, + ) + const resPlugins = res.data.bundles || res.data.plugins || [] + + return { + plugins: resPlugins.map(plugin => getFormattedPlugin(plugin)), + total: res.data.total, + page: pageParam, + pageSize, + } + } + catch { + return { + plugins: [], + total: 0, + page: pageParam, + pageSize, + } + } +} + /** * Fetches plugins with infinite scroll support - imperative version * Used by external components (workflow block selectors, etc.) @@ -122,62 +183,7 @@ export function useMarketplacePlugins(initialParams?: PluginsSearchParams) { const query = useInfiniteQuery({ queryKey: marketplaceKeys.plugins(queryParams), - queryFn: async ({ pageParam = 1, signal }) => { - if (!queryParams) { - return { - plugins: [] as Plugin[], - total: 0, - page: 1, - pageSize: DEFAULT_PAGE_SIZE, - } - } - - const { - query, - sortBy, - sortOrder, - category, - tags, - type, - pageSize = DEFAULT_PAGE_SIZE, - } = queryParams - const pluginOrBundle = type === 'bundle' ? 'bundles' : 'plugins' - - try { - const res = await postMarketplace<{ data: PluginsFromMarketplaceResponse }>( - `/${pluginOrBundle}/search/advanced`, - { - body: { - page: pageParam, - page_size: pageSize, - query, - sort_by: sortBy, - sort_order: sortOrder, - category: category !== 'all' ? category : '', - tags, - type, - }, - signal, - }, - ) - const resPlugins = res.data.bundles || res.data.plugins || [] - - return { - plugins: resPlugins.map(plugin => getFormattedPlugin(plugin)), - total: res.data.total, - page: pageParam, - pageSize, - } - } - catch { - return { - plugins: [], - total: 0, - page: pageParam, - pageSize, - } - } - }, + queryFn: ({ pageParam = 1, signal }) => fetchMarketplacePlugins(queryParams, pageParam, signal), getNextPageParam: (lastPage) => { const nextPage = lastPage.page + 1 const loaded = lastPage.page * lastPage.pageSize @@ -217,6 +223,42 @@ export function useMarketplacePlugins(initialParams?: PluginsSearchParams) { } } +/** + * Fetches plugins with infinite scroll support - reactive version + * Automatically refetches when queryParams changes + */ +export function useMarketplacePluginsReactive(queryParams?: PluginsSearchParams) { + const query = useInfiniteQuery({ + queryKey: marketplaceKeys.plugins(queryParams), + queryFn: ({ pageParam = 1, signal }) => fetchMarketplacePlugins(queryParams, pageParam, signal), + getNextPageParam: (lastPage) => { + const nextPage = lastPage.page + 1 + const loaded = lastPage.page * lastPage.pageSize + return loaded < (lastPage.total || 0) ? nextPage : undefined + }, + initialPageParam: 1, + enabled: !!queryParams, + }) + + const plugins = useMemo(() => { + if (!queryParams || !query.data) + return undefined + return query.data.pages.flatMap(page => page.plugins) + }, [queryParams, query.data]) + + const total = queryParams && query.data ? query.data.pages[0]?.total : undefined + + return { + plugins, + total, + isLoading: !!queryParams && query.isPending, + isFetchingNextPage: query.isFetchingNextPage, + hasNextPage: query.hasNextPage, + fetchNextPage: query.fetchNextPage, + page: query.data?.pages?.length || 0, + } +} + /** * Reactive hook that automatically fetches plugins based on current state */ @@ -228,7 +270,9 @@ export function useMarketplacePluginsData() { const filterPluginTags = urlFilters.tags const activePluginType = urlFilters.category - const isSearchMode = !!searchPluginText || filterPluginTags.length > 0 + const isSearchMode = !!searchPluginText + || filterPluginTags.length > 0 + || (activePluginType !== PLUGIN_TYPE_SEARCH_MAP.all && activePluginType !== PLUGIN_TYPE_SEARCH_MAP.tool) // Compute query params reactively - TanStack Query will auto-refetch when this changes const queryParams = useMemo((): PluginsSearchParams | undefined => { @@ -251,7 +295,7 @@ export function useMarketplacePluginsData() { fetchNextPage, hasNextPage, page: pluginsPage, - } = useMarketplacePlugins(queryParams) + } = useMarketplacePluginsReactive(queryParams) const handleSortChange = useCallback((newSort: typeof sort) => { setSort(newSort) From 38d5f224fb5855ff30f17f46e890243fc375c636 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Thu, 8 Jan 2026 14:22:46 +0800 Subject: [PATCH 05/58] update --- web/app/components/plugins/marketplace/hooks.ts | 4 +++- .../plugins/marketplace/list/list-with-collection.tsx | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/web/app/components/plugins/marketplace/hooks.ts b/web/app/components/plugins/marketplace/hooks.ts index 831e8db4ed57b0..0d2099cdd933cc 100644 --- a/web/app/components/plugins/marketplace/hooks.ts +++ b/web/app/components/plugins/marketplace/hooks.ts @@ -326,7 +326,9 @@ export function useMarketplaceMoreClick() { const [, setUrlFilters] = useMarketplaceFilters() const [, setSort] = useMarketplaceSort() - return useCallback((searchParams: { query?: string, sort_by?: string, sort_order?: string }) => { + return useCallback((searchParams?: { query?: string, sort_by?: string, sort_order?: string }) => { + if (!searchParams) + return const newQuery = searchParams?.query || '' const newSort = { sortBy: searchParams?.sort_by || DEFAULT_SORT.sortBy, diff --git a/web/app/components/plugins/marketplace/list/list-with-collection.tsx b/web/app/components/plugins/marketplace/list/list-with-collection.tsx index 6673abcbd37bc8..be1b29d8844d4a 100644 --- a/web/app/components/plugins/marketplace/list/list-with-collection.tsx +++ b/web/app/components/plugins/marketplace/list/list-with-collection.tsx @@ -25,7 +25,7 @@ const ListWithCollection = ({ }: ListWithCollectionProps) => { const { t } = useTranslation() const locale = useLocale() - const handleMoreClick = useMarketplaceMoreClick() + const onMoreClick = useMarketplaceMoreClick() return ( <> @@ -46,7 +46,7 @@ const ListWithCollection = ({ collection.searchable && (
handleMoreClick(collection.search_params || {})} + onClick={() => onMoreClick(collection.search_params)} > {t('marketplace.viewMore', { ns: 'plugin' })} From afec4f780547ea3bb62d52f25b1b0c2070a1d804 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Thu, 8 Jan 2026 14:24:55 +0800 Subject: [PATCH 06/58] update --- .../marketplace/search-box/search-box-wrapper.tsx | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/web/app/components/plugins/marketplace/search-box/search-box-wrapper.tsx b/web/app/components/plugins/marketplace/search-box/search-box-wrapper.tsx index a129dd3357cc7f..050ac451bc5205 100644 --- a/web/app/components/plugins/marketplace/search-box/search-box-wrapper.tsx +++ b/web/app/components/plugins/marketplace/search-box/search-box-wrapper.tsx @@ -1,22 +1,13 @@ 'use client' import { useTranslation } from '#i18n' -import { useCallback } from 'react' import { useMarketplaceSearchQuery, useMarketplaceTags } from '@/hooks/use-query-params' import SearchBox from './index' const SearchBoxWrapper = () => { const { t } = useTranslation() - const [searchPluginText, setSearchPluginText] = useMarketplaceSearchQuery() - const [filterPluginTags, setFilterPluginTags] = useMarketplaceTags() - - const handleSearchPluginTextChange = useCallback((text: string) => { - setSearchPluginText(text) - }, [setSearchPluginText]) - - const handleFilterPluginTagsChange = useCallback((tags: string[]) => { - setFilterPluginTags(tags) - }, [setFilterPluginTags]) + const [searchPluginText, handleSearchPluginTextChange] = useMarketplaceSearchQuery() + const [filterPluginTags, handleFilterPluginTagsChange] = useMarketplaceTags() return ( Date: Thu, 8 Jan 2026 14:27:47 +0800 Subject: [PATCH 07/58] update --- .../components/plugins/marketplace/sort-dropdown/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/app/components/plugins/marketplace/sort-dropdown/index.tsx b/web/app/components/plugins/marketplace/sort-dropdown/index.tsx index 1aef04428e30a0..1f7bab1005a6f6 100644 --- a/web/app/components/plugins/marketplace/sort-dropdown/index.tsx +++ b/web/app/components/plugins/marketplace/sort-dropdown/index.tsx @@ -10,7 +10,7 @@ import { PortalToFollowElemContent, PortalToFollowElemTrigger, } from '@/app/components/base/portal-to-follow-elem' -import { useMarketplaceData } from '../hooks' +import { useMarketplaceSort } from '../atoms' const SortDropdown = () => { const { t } = useTranslation() @@ -36,7 +36,7 @@ const SortDropdown = () => { text: t('marketplace.sortOption.firstReleased', { ns: 'plugin' }), }, ] - const { sort, handleSortChange } = useMarketplaceData() + const [sort, handleSortChange] = useMarketplaceSort() const [open, setOpen] = useState(false) const selectedOption = options.find(option => option.value === sort.sortBy && option.order === sort.sortOrder) ?? options[0] From 8feb8787f86607b12e26e2994a16479244251ae4 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Thu, 8 Jan 2026 14:31:17 +0800 Subject: [PATCH 08/58] update --- .../plugins/marketplace/sticky-search-and-switch-wrapper.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/app/components/plugins/marketplace/sticky-search-and-switch-wrapper.tsx b/web/app/components/plugins/marketplace/sticky-search-and-switch-wrapper.tsx index 475e17992b843f..4da3844c0a7baa 100644 --- a/web/app/components/plugins/marketplace/sticky-search-and-switch-wrapper.tsx +++ b/web/app/components/plugins/marketplace/sticky-search-and-switch-wrapper.tsx @@ -8,9 +8,9 @@ type StickySearchAndSwitchWrapperProps = { pluginTypeSwitchClassName?: string } -function StickySearchAndSwitchWrapper({ +const StickySearchAndSwitchWrapper = ({ pluginTypeSwitchClassName, -}: StickySearchAndSwitchWrapperProps) { +}: StickySearchAndSwitchWrapperProps) => { const hasCustomTopClass = pluginTypeSwitchClassName?.includes('top-') return ( From 10a4d7b3f36ffdbf81dd9962a3d23f22d232ec0f Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Thu, 8 Jan 2026 14:42:24 +0800 Subject: [PATCH 09/58] update --- .../components/plugins/marketplace/atoms.ts | 6 +- .../components/plugins/marketplace/hooks.ts | 17 +----- .../plugins/marketplace/list/list-wrapper.tsx | 59 +++++++++---------- 3 files changed, 35 insertions(+), 47 deletions(-) diff --git a/web/app/components/plugins/marketplace/atoms.ts b/web/app/components/plugins/marketplace/atoms.ts index 035a887ada97eb..d70332d8513cb8 100644 --- a/web/app/components/plugins/marketplace/atoms.ts +++ b/web/app/components/plugins/marketplace/atoms.ts @@ -1,5 +1,5 @@ import type { PluginsSort } from './types' -import { atom, useAtom } from 'jotai' +import { atom, useAtom, useAtomValue } from 'jotai' import { DEFAULT_SORT } from './constants' // Sort state - not persisted in URL @@ -8,3 +8,7 @@ const marketplaceSortAtom = atom(DEFAULT_SORT) export function useMarketplaceSort() { return useAtom(marketplaceSortAtom) } + +export function useMarketplaceSortValue() { + return useAtomValue(marketplaceSortAtom) +} diff --git a/web/app/components/plugins/marketplace/hooks.ts b/web/app/components/plugins/marketplace/hooks.ts index 0d2099cdd933cc..cee58ec2a5a113 100644 --- a/web/app/components/plugins/marketplace/hooks.ts +++ b/web/app/components/plugins/marketplace/hooks.ts @@ -20,7 +20,7 @@ import { } from 'react' import { useMarketplaceFilters } from '@/hooks/use-query-params' import { postMarketplace } from '@/service/base' -import { useMarketplaceSort } from './atoms' +import { useMarketplaceSort, useMarketplaceSortValue } from './atoms' import { DEFAULT_SORT, SCROLL_BOTTOM_THRESHOLD } from './constants' import { PLUGIN_TYPE_SEARCH_MAP } from './plugin-type-switch' import { marketplaceKeys } from './query-keys' @@ -264,7 +264,7 @@ export function useMarketplacePluginsReactive(queryParams?: PluginsSearchParams) */ export function useMarketplacePluginsData() { const [urlFilters] = useMarketplaceFilters() - const [sort, setSort] = useMarketplaceSort() + const sort = useMarketplaceSortValue() const searchPluginText = urlFilters.q const filterPluginTags = urlFilters.tags @@ -297,10 +297,6 @@ export function useMarketplacePluginsData() { page: pluginsPage, } = useMarketplacePluginsReactive(queryParams) - const handleSortChange = useCallback((newSort: typeof sort) => { - setSort(newSort) - }, [setSort]) - const handlePageChange = useCallback(() => { if (hasNextPage) fetchNextPage() @@ -314,8 +310,6 @@ export function useMarketplacePluginsData() { pluginsTotal, page: Math.max(pluginsPage, 1), isLoading: isPluginsLoading, - sort, - handleSortChange, } } @@ -347,20 +341,13 @@ export function useMarketplaceData() { const pluginsData = useMarketplacePluginsData() return { - // Collections data marketplaceCollections: collectionsData.marketplaceCollections, marketplaceCollectionPluginsMap: collectionsData.marketplaceCollectionPluginsMap, - // Plugins data plugins: pluginsData.plugins, pluginsTotal: pluginsData.pluginsTotal, page: pluginsData.page, - // Sort - sort: pluginsData.sort, - handleSortChange: pluginsData.handleSortChange, - - // Loading state isLoading: collectionsData.isLoading || pluginsData.isLoading, } } diff --git a/web/app/components/plugins/marketplace/list/list-wrapper.tsx b/web/app/components/plugins/marketplace/list/list-wrapper.tsx index 9d1a848682f8a2..7fe2e14a4bb4c0 100644 --- a/web/app/components/plugins/marketplace/list/list-wrapper.tsx +++ b/web/app/components/plugins/marketplace/list/list-wrapper.tsx @@ -1,5 +1,4 @@ 'use client' - import { useTranslation } from '#i18n' import Loading from '@/app/components/base/loading' import { useMarketplaceData } from '../hooks' @@ -9,8 +8,9 @@ import List from './index' type ListWrapperProps = { showInstallButton?: boolean } - -function ListWrapper({ showInstallButton }: ListWrapperProps) { +const ListWrapper = ({ + showInstallButton, +}: ListWrapperProps) => { const { t } = useTranslation() const { @@ -22,40 +22,37 @@ function ListWrapper({ showInstallButton }: ListWrapperProps) { page, } = useMarketplaceData() - // Show loading spinner only on initial load (page 1) - if (isLoading && page === 1) { - return ( -
-
- -
-
- ) - } - return (
- {plugins && ( -
-
- {t('marketplace.pluginsResult', { ns: 'plugin', num: pluginsTotal })} + { + plugins && ( +
+
{t('marketplace.pluginsResult', { ns: 'plugin', num: pluginsTotal })}
+
+ +
+ ) + } + { + isLoading && page === 1 && ( +
+
-
- -
- )} - + ) + } + { + (!isLoading || page > 1) && ( + + ) + }
) } From 98aff7e7b6ab70e529c5c7cb73030f501db3a576 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Thu, 8 Jan 2026 14:44:24 +0800 Subject: [PATCH 10/58] update --- web/app/components/plugins/marketplace/list/list-wrapper.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/app/components/plugins/marketplace/list/list-wrapper.tsx b/web/app/components/plugins/marketplace/list/list-wrapper.tsx index 7fe2e14a4bb4c0..f5eb73977976a2 100644 --- a/web/app/components/plugins/marketplace/list/list-wrapper.tsx +++ b/web/app/components/plugins/marketplace/list/list-wrapper.tsx @@ -14,10 +14,10 @@ const ListWrapper = ({ const { t } = useTranslation() const { - marketplaceCollections, - marketplaceCollectionPluginsMap, plugins, pluginsTotal, + marketplaceCollections, + marketplaceCollectionPluginsMap, isLoading, page, } = useMarketplaceData() From 2ba0adb92cbd74f6a236b641370ab24143da6927 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Thu, 8 Jan 2026 15:01:57 +0800 Subject: [PATCH 11/58] update --- .../components/plugins/marketplace/hooks.ts | 120 +++++++++--------- .../marketplace/plugin-type-switch.tsx | 53 ++++---- 2 files changed, 84 insertions(+), 89 deletions(-) diff --git a/web/app/components/plugins/marketplace/hooks.ts b/web/app/components/plugins/marketplace/hooks.ts index cee58ec2a5a113..a7447e8c94708b 100644 --- a/web/app/components/plugins/marketplace/hooks.ts +++ b/web/app/components/plugins/marketplace/hooks.ts @@ -1,6 +1,8 @@ 'use client' -import type { Plugin } from '../types' +import type { + Plugin, +} from '../types' import type { CollectionsAndPluginsSearchParams, MarketplaceCollection, @@ -87,27 +89,29 @@ export function useMarketplaceCollectionsData() { } } -/** - * Fetches plugins for a specific collection - */ -export function useMarketplacePluginsByCollectionId( +export const useMarketplacePluginsByCollectionId = ( collectionId?: string, - params?: CollectionsAndPluginsSearchParams, -) { - const query = useQuery({ - queryKey: marketplaceKeys.collectionPlugins(collectionId || '', params), + query?: CollectionsAndPluginsSearchParams, +) => { + const { + data, + isFetching, + isSuccess, + isPending, + } = useQuery({ + queryKey: marketplaceKeys.collectionPlugins(collectionId || '', query), queryFn: ({ signal }) => { if (!collectionId) return Promise.resolve([]) - return getMarketplacePluginsByCollectionId(collectionId, params, { signal }) + return getMarketplacePluginsByCollectionId(collectionId, query, { signal }) }, enabled: !!collectionId, }) return { - plugins: query.data || [], - isLoading: query.isLoading, - isSuccess: query.isSuccess, + plugins: data || [], + isLoading: !!collectionId && (isFetching || isPending), + isSuccess, } } @@ -259,6 +263,35 @@ export function useMarketplacePluginsReactive(queryParams?: PluginsSearchParams) } } +export const useMarketplaceContainerScroll = ( + callback: () => void, + scrollContainerId = 'marketplace-container', +) => { + const handleScroll = useCallback((e: Event) => { + const target = e.target as HTMLDivElement + const { + scrollTop, + scrollHeight, + clientHeight, + } = target + if (scrollTop + clientHeight >= scrollHeight - SCROLL_BOTTOM_THRESHOLD && scrollTop > 0) + callback() + }, [callback]) + + useEffect(() => { + const container = document.getElementById(scrollContainerId) + if (container) + container.addEventListener('scroll', handleScroll) + + return () => { + if (container) + container.removeEventListener('scroll', handleScroll) + } + }, [handleScroll]) +} + +export type { MarketplaceCollection, PluginsSearchParams } + /** * Reactive hook that automatically fetches plugins based on current state */ @@ -313,29 +346,6 @@ export function useMarketplacePluginsData() { } } -/** - * Hook for handling "More" click in collection headers - */ -export function useMarketplaceMoreClick() { - const [, setUrlFilters] = useMarketplaceFilters() - const [, setSort] = useMarketplaceSort() - - return useCallback((searchParams?: { query?: string, sort_by?: string, sort_order?: string }) => { - if (!searchParams) - return - const newQuery = searchParams?.query || '' - const newSort = { - sortBy: searchParams?.sort_by || DEFAULT_SORT.sortBy, - sortOrder: searchParams?.sort_order || DEFAULT_SORT.sortOrder, - } - setUrlFilters({ q: newQuery }) - setSort(newSort) - }, [setUrlFilters, setSort]) -} - -/** - * Combined hook for marketplace data - */ export function useMarketplaceData() { const collectionsData = useMarketplaceCollectionsData() const pluginsData = useMarketplacePluginsData() @@ -352,31 +362,19 @@ export function useMarketplaceData() { } } -/** - * Handles scroll-based pagination - */ -export function useMarketplaceContainerScroll( - callback: () => void, - scrollContainerId = 'marketplace-container', -) { - const handleScroll = useCallback((e: Event) => { - const target = e.target as HTMLDivElement - const { scrollTop, scrollHeight, clientHeight } = target - if (scrollTop + clientHeight >= scrollHeight - SCROLL_BOTTOM_THRESHOLD && scrollTop > 0) - callback() - }, [callback]) - - useEffect(() => { - const container = document.getElementById(scrollContainerId) - if (container) - container.addEventListener('scroll', handleScroll) +export function useMarketplaceMoreClick() { + const [, setUrlFilters] = useMarketplaceFilters() + const [, setSort] = useMarketplaceSort() - return () => { - if (container) - container.removeEventListener('scroll', handleScroll) + return useCallback((searchParams?: { query?: string, sort_by?: string, sort_order?: string }) => { + if (!searchParams) + return + const newQuery = searchParams?.query || '' + const newSort = { + sortBy: searchParams?.sort_by || DEFAULT_SORT.sortBy, + sortOrder: searchParams?.sort_order || DEFAULT_SORT.sortOrder, } - }, [handleScroll, scrollContainerId]) + setUrlFilters({ q: newQuery }) + setSort(newSort) + }, [setUrlFilters, setSort]) } - -// Re-export for external usage (workflow block selector, etc.) -export type { MarketplaceCollection, PluginsSearchParams } diff --git a/web/app/components/plugins/marketplace/plugin-type-switch.tsx b/web/app/components/plugins/marketplace/plugin-type-switch.tsx index b0d9b796aa007f..daa62dad0a27d1 100644 --- a/web/app/components/plugins/marketplace/plugin-type-switch.tsx +++ b/web/app/components/plugins/marketplace/plugin-type-switch.tsx @@ -1,5 +1,4 @@ 'use client' - import { useTranslation } from '#i18n' import { RiArchive2Line, @@ -9,7 +8,6 @@ import { RiPuzzle2Line, RiSpeakAiLine, } from '@remixicon/react' -import { useCallback } from 'react' import { Trigger as TriggerIcon } from '@/app/components/base/icons/src/vender/plugin' import { useMarketplaceCategory } from '@/hooks/use-query-params' import { cn } from '@/utils/classnames' @@ -25,18 +23,14 @@ export const PLUGIN_TYPE_SEARCH_MAP = { trigger: PluginCategoryEnum.trigger, bundle: 'bundle', } - type PluginTypeSwitchProps = { className?: string } - -function PluginTypeSwitch({ className }: PluginTypeSwitchProps) { +const PluginTypeSwitch = ({ + className, +}: PluginTypeSwitchProps) => { const { t } = useTranslation() - const [activePluginType, setCategory] = useMarketplaceCategory() - - const handleActivePluginTypeChange = useCallback((type: string) => { - setCategory(type, { history: 'push' }) - }, [setCategory]) + const [activePluginType, handleActivePluginTypeChange] = useMarketplaceCategory() const options = [ { @@ -82,25 +76,28 @@ function PluginTypeSwitch({ className }: PluginTypeSwitchProps) { ] return ( -
- {options.map(option => ( -
handleActivePluginTypeChange(option.value)} - > - {option.icon} - {option.text} -
- ))} + { + options.map(option => ( +
{ + handleActivePluginTypeChange(option.value) + }} + > + {option.icon} + {option.text} +
+ )) + }
) } From 8acd17d6bab23953cd84d87921c90e492b2f5d30 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Thu, 8 Jan 2026 15:05:04 +0800 Subject: [PATCH 12/58] update --- web/app/components/plugins/marketplace/query-keys.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/web/app/components/plugins/marketplace/query-keys.ts b/web/app/components/plugins/marketplace/query-keys.ts index 24a2de147667ea..9308c766fe207f 100644 --- a/web/app/components/plugins/marketplace/query-keys.ts +++ b/web/app/components/plugins/marketplace/query-keys.ts @@ -1,12 +1,8 @@ import type { CollectionsAndPluginsSearchParams, PluginsSearchParams } from './types' -// Query key factory for consistent cache keys export const marketplaceKeys = { all: ['marketplace'] as const, - collections: (params?: CollectionsAndPluginsSearchParams) => - [...marketplaceKeys.all, 'collections', params] as const, - collectionPlugins: (collectionId: string, params?: CollectionsAndPluginsSearchParams) => - [...marketplaceKeys.all, 'collectionPlugins', collectionId, params] as const, - plugins: (params?: PluginsSearchParams) => - [...marketplaceKeys.all, 'plugins', params] as const, + collections: (params?: CollectionsAndPluginsSearchParams) => [...marketplaceKeys.all, 'collections', params] as const, + collectionPlugins: (collectionId: string, params?: CollectionsAndPluginsSearchParams) => [...marketplaceKeys.all, 'collectionPlugins', collectionId, params] as const, + plugins: (params?: PluginsSearchParams) => [...marketplaceKeys.all, 'plugins', params] as const, } From ee8f6c2d0d4e67d777ec62a7ba76d14a05daa554 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Thu, 8 Jan 2026 15:26:34 +0800 Subject: [PATCH 13/58] update --- .../components/plugins/marketplace/hooks.ts | 85 ++++++------------- 1 file changed, 25 insertions(+), 60 deletions(-) diff --git a/web/app/components/plugins/marketplace/hooks.ts b/web/app/components/plugins/marketplace/hooks.ts index a7447e8c94708b..b1668608412795 100644 --- a/web/app/components/plugins/marketplace/hooks.ts +++ b/web/app/components/plugins/marketplace/hooks.ts @@ -20,7 +20,7 @@ import { useMemo, useState, } from 'react' -import { useMarketplaceFilters } from '@/hooks/use-query-params' +import { useMarketplaceCategory, useMarketplaceFilters } from '@/hooks/use-query-params' import { postMarketplace } from '@/service/base' import { useMarketplaceSort, useMarketplaceSortValue } from './atoms' import { DEFAULT_SORT, SCROLL_BOTTOM_THRESHOLD } from './constants' @@ -34,38 +34,20 @@ import { getMarketplacePluginsByCollectionId, } from './utils' -export { marketplaceKeys } - -// Stable empty object for query key matching with server prefetch const EMPTY_PARAMS = {} -/** - * Fetches marketplace collections and their plugins - */ -export function useMarketplaceCollectionsAndPlugins( - params?: CollectionsAndPluginsSearchParams, - options?: { enabled?: boolean }, -) { +export const useMarketplaceCollectionsAndPlugins = (queryParams?: CollectionsAndPluginsSearchParams) => { return useQuery({ - queryKey: marketplaceKeys.collections(params), - queryFn: ({ signal }) => getMarketplaceCollectionsAndPlugins(params, { signal }), - enabled: options?.enabled ?? true, + queryKey: marketplaceKeys.collections(queryParams), + queryFn: ({ signal }) => getMarketplaceCollectionsAndPlugins(queryParams, { signal }), + enabled: queryParams !== undefined, }) } -/** - * Reactive hook that automatically fetches collections based on current state - */ export function useMarketplaceCollectionsData() { - const [urlFilters] = useMarketplaceFilters() - - const activePluginType = urlFilters.category - const searchPluginText = urlFilters.q - const filterPluginTags = urlFilters.tags - - const isSearchMode = !!searchPluginText || filterPluginTags.length > 0 + const [activePluginType] = useMarketplaceCategory() - const collectionsParams = useMemo(() => { + const collectionsParams: CollectionsAndPluginsSearchParams = useMemo(() => { if (activePluginType === PLUGIN_TYPE_SEARCH_MAP.all) { return EMPTY_PARAMS } @@ -76,16 +58,12 @@ export function useMarketplaceCollectionsData() { } }, [activePluginType]) - const collectionsQuery = useMarketplaceCollectionsAndPlugins( - collectionsParams, - { enabled: !isSearchMode }, - ) + const { data, isLoading } = useMarketplaceCollectionsAndPlugins(collectionsParams) return { - marketplaceCollections: collectionsQuery.data?.marketplaceCollections, - marketplaceCollectionPluginsMap: collectionsQuery.data?.marketplaceCollectionPluginsMap, - isLoading: collectionsQuery.isLoading, - isSearchMode, + marketplaceCollections: data?.marketplaceCollections, + marketplaceCollectionPluginsMap: data?.marketplaceCollectionPluginsMap, + isLoading, } } @@ -115,8 +93,6 @@ export const useMarketplacePluginsByCollectionId = ( } } -const DEFAULT_PAGE_SIZE = 40 - async function fetchMarketplacePlugins( queryParams: PluginsSearchParams | undefined, pageParam: number, @@ -127,7 +103,7 @@ async function fetchMarketplacePlugins( plugins: [] as Plugin[], total: 0, page: 1, - pageSize: DEFAULT_PAGE_SIZE, + pageSize: 40, } } @@ -138,27 +114,24 @@ async function fetchMarketplacePlugins( category, tags, type, - pageSize = DEFAULT_PAGE_SIZE, + pageSize = 40, } = queryParams const pluginOrBundle = type === 'bundle' ? 'bundles' : 'plugins' try { - const res = await postMarketplace<{ data: PluginsFromMarketplaceResponse }>( - `/${pluginOrBundle}/search/advanced`, - { - body: { - page: pageParam, - page_size: pageSize, - query, - sort_by: sortBy, - sort_order: sortOrder, - category: category !== 'all' ? category : '', - tags, - type, - }, - signal, + const res = await postMarketplace<{ data: PluginsFromMarketplaceResponse }>(`/${pluginOrBundle}/search/advanced`, { + body: { + page: pageParam, + page_size: pageSize, + query, + sort_by: sortBy, + sort_order: sortOrder, + category: category !== 'all' ? category : '', + tags, + type, }, - ) + signal, + }) const resPlugins = res.data.bundles || res.data.plugins || [] return { @@ -178,10 +151,6 @@ async function fetchMarketplacePlugins( } } -/** - * Fetches plugins with infinite scroll support - imperative version - * Used by external components (workflow block selectors, etc.) - */ export function useMarketplacePlugins(initialParams?: PluginsSearchParams) { const [queryParams, setQueryParams] = useState(initialParams) @@ -227,10 +196,6 @@ export function useMarketplacePlugins(initialParams?: PluginsSearchParams) { } } -/** - * Fetches plugins with infinite scroll support - reactive version - * Automatically refetches when queryParams changes - */ export function useMarketplacePluginsReactive(queryParams?: PluginsSearchParams) { const query = useInfiniteQuery({ queryKey: marketplaceKeys.plugins(queryParams), From 21c647bc238c011cf64cbc70dd88efaee0fdf9e6 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Thu, 8 Jan 2026 15:46:52 +0800 Subject: [PATCH 14/58] update --- .../components/plugins/marketplace/hooks.ts | 86 +++++++++++-------- 1 file changed, 50 insertions(+), 36 deletions(-) diff --git a/web/app/components/plugins/marketplace/hooks.ts b/web/app/components/plugins/marketplace/hooks.ts index b1668608412795..94d30d96869b6d 100644 --- a/web/app/components/plugins/marketplace/hooks.ts +++ b/web/app/components/plugins/marketplace/hooks.ts @@ -12,6 +12,7 @@ import type { PluginsFromMarketplaceResponse } from '@/app/components/plugins/ty import { useInfiniteQuery, useQuery, + useQueryClient, } from '@tanstack/react-query' import { useDebounceFn } from 'ahooks' import { @@ -152,9 +153,10 @@ async function fetchMarketplacePlugins( } export function useMarketplacePlugins(initialParams?: PluginsSearchParams) { - const [queryParams, setQueryParams] = useState(initialParams) + const queryClient = useQueryClient() + const [queryParams, handleUpdatePlugins] = useState(initialParams) - const query = useInfiniteQuery({ + const marketplacePluginsQuery = useInfiniteQuery({ queryKey: marketplaceKeys.plugins(queryParams), queryFn: ({ pageParam = 1, signal }) => fetchMarketplacePlugins(queryParams, pageParam, signal), getNextPageParam: (lastPage) => { @@ -166,38 +168,47 @@ export function useMarketplacePlugins(initialParams?: PluginsSearchParams) { enabled: !!queryParams, }) - const { run: queryPluginsDebounced, cancel: cancelDebounced } = useDebounceFn( - (params: PluginsSearchParams) => setQueryParams(params), - { wait: 500 }, - ) + const resetPlugins = useCallback(() => { + handleUpdatePlugins(undefined) + queryClient.removeQueries({ + queryKey: ['marketplacePlugins'], + }) + }, [queryClient]) - const plugins = useMemo(() => { - if (!queryParams || !query.data) - return undefined - return query.data.pages.flatMap(page => page.plugins) - }, [queryParams, query.data]) + const { run: queryPluginsWithDebounced, cancel: cancelQueryPluginsWithDebounced } = useDebounceFn((pluginsSearchParams: PluginsSearchParams) => { + handleUpdatePlugins(pluginsSearchParams) + }, { + wait: 500, + }) - const total = queryParams && query.data ? query.data.pages[0]?.total : undefined + const hasQuery = !!queryParams + const hasData = marketplacePluginsQuery.data !== undefined + const plugins = hasQuery && hasData + ? marketplacePluginsQuery.data.pages.flatMap(page => page.plugins) + : undefined + const total = hasQuery && hasData ? marketplacePluginsQuery.data.pages?.[0]?.total : undefined + const isPluginsLoading = hasQuery && ( + marketplacePluginsQuery.isPending + || (marketplacePluginsQuery.isFetching && !marketplacePluginsQuery.data) + ) return { plugins, total, - queryPlugins: setQueryParams, - queryPluginsDebounced, - queryPluginsWithDebounced: queryPluginsDebounced, - cancelDebounced, - cancelQueryPluginsWithDebounced: cancelDebounced, - resetPlugins: useCallback(() => setQueryParams(undefined), []), - isLoading: !!queryParams && query.isPending, - isFetchingNextPage: query.isFetchingNextPage, - hasNextPage: query.hasNextPage, - fetchNextPage: query.fetchNextPage, - page: query.data?.pages?.length || 0, + resetPlugins, + queryPlugins: handleUpdatePlugins, + queryPluginsWithDebounced, + cancelQueryPluginsWithDebounced, + isLoading: isPluginsLoading, + isFetchingNextPage: marketplacePluginsQuery.isFetchingNextPage, + hasNextPage: marketplacePluginsQuery.hasNextPage, + fetchNextPage: marketplacePluginsQuery.fetchNextPage, + page: marketplacePluginsQuery.data?.pages?.length || (marketplacePluginsQuery.isPending && hasQuery ? 1 : 0), } } export function useMarketplacePluginsReactive(queryParams?: PluginsSearchParams) { - const query = useInfiniteQuery({ + const marketplacePluginsQuery = useInfiniteQuery({ queryKey: marketplaceKeys.plugins(queryParams), queryFn: ({ pageParam = 1, signal }) => fetchMarketplacePlugins(queryParams, pageParam, signal), getNextPageParam: (lastPage) => { @@ -209,22 +220,25 @@ export function useMarketplacePluginsReactive(queryParams?: PluginsSearchParams) enabled: !!queryParams, }) - const plugins = useMemo(() => { - if (!queryParams || !query.data) - return undefined - return query.data.pages.flatMap(page => page.plugins) - }, [queryParams, query.data]) - - const total = queryParams && query.data ? query.data.pages[0]?.total : undefined + const hasQuery = !!queryParams + const hasData = marketplacePluginsQuery.data !== undefined + const plugins = hasQuery && hasData + ? marketplacePluginsQuery.data.pages.flatMap(page => page.plugins) + : undefined + const total = hasQuery && hasData ? marketplacePluginsQuery.data.pages?.[0]?.total : undefined + const isPluginsLoading = hasQuery && ( + marketplacePluginsQuery.isPending + || (marketplacePluginsQuery.isFetching && !marketplacePluginsQuery.data) + ) return { plugins, total, - isLoading: !!queryParams && query.isPending, - isFetchingNextPage: query.isFetchingNextPage, - hasNextPage: query.hasNextPage, - fetchNextPage: query.fetchNextPage, - page: query.data?.pages?.length || 0, + isLoading: isPluginsLoading, + isFetchingNextPage: marketplacePluginsQuery.isFetchingNextPage, + hasNextPage: marketplacePluginsQuery.hasNextPage, + fetchNextPage: marketplacePluginsQuery.fetchNextPage, + page: marketplacePluginsQuery.data?.pages?.length || (marketplacePluginsQuery.isPending && hasQuery ? 1 : 0), } } From 5614fc84c074a85f757bb024ffccde10c6fb21ac Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Thu, 8 Jan 2026 16:02:07 +0800 Subject: [PATCH 15/58] update --- .../components/plugins/marketplace/atoms.ts | 7 ++++-- .../components/plugins/marketplace/hooks.ts | 23 ++++++++----------- 2 files changed, 14 insertions(+), 16 deletions(-) diff --git a/web/app/components/plugins/marketplace/atoms.ts b/web/app/components/plugins/marketplace/atoms.ts index d70332d8513cb8..2611081d51005a 100644 --- a/web/app/components/plugins/marketplace/atoms.ts +++ b/web/app/components/plugins/marketplace/atoms.ts @@ -1,8 +1,7 @@ import type { PluginsSort } from './types' -import { atom, useAtom, useAtomValue } from 'jotai' +import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai' import { DEFAULT_SORT } from './constants' -// Sort state - not persisted in URL const marketplaceSortAtom = atom(DEFAULT_SORT) export function useMarketplaceSort() { @@ -12,3 +11,7 @@ export function useMarketplaceSort() { export function useMarketplaceSortValue() { return useAtomValue(marketplaceSortAtom) } + +export function useSetMarketplaceSort() { + return useSetAtom(marketplaceSortAtom) +} diff --git a/web/app/components/plugins/marketplace/hooks.ts b/web/app/components/plugins/marketplace/hooks.ts index 94d30d96869b6d..f95758523b99b8 100644 --- a/web/app/components/plugins/marketplace/hooks.ts +++ b/web/app/components/plugins/marketplace/hooks.ts @@ -21,9 +21,9 @@ import { useMemo, useState, } from 'react' -import { useMarketplaceCategory, useMarketplaceFilters } from '@/hooks/use-query-params' +import { useMarketplaceCategory, useMarketplaceSearchQuery, useMarketplaceTags } from '@/hooks/use-query-params' import { postMarketplace } from '@/service/base' -import { useMarketplaceSort, useMarketplaceSortValue } from './atoms' +import { useMarketplaceSortValue, useSetMarketplaceSort } from './atoms' import { DEFAULT_SORT, SCROLL_BOTTOM_THRESHOLD } from './constants' import { PLUGIN_TYPE_SEARCH_MAP } from './plugin-type-switch' import { marketplaceKeys } from './query-keys' @@ -271,22 +271,17 @@ export const useMarketplaceContainerScroll = ( export type { MarketplaceCollection, PluginsSearchParams } -/** - * Reactive hook that automatically fetches plugins based on current state - */ export function useMarketplacePluginsData() { - const [urlFilters] = useMarketplaceFilters() const sort = useMarketplaceSortValue() - const searchPluginText = urlFilters.q - const filterPluginTags = urlFilters.tags - const activePluginType = urlFilters.category + const [searchPluginText] = useMarketplaceSearchQuery() + const [filterPluginTags] = useMarketplaceTags() + const [activePluginType] = useMarketplaceCategory() const isSearchMode = !!searchPluginText || filterPluginTags.length > 0 || (activePluginType !== PLUGIN_TYPE_SEARCH_MAP.all && activePluginType !== PLUGIN_TYPE_SEARCH_MAP.tool) - // Compute query params reactively - TanStack Query will auto-refetch when this changes const queryParams = useMemo((): PluginsSearchParams | undefined => { if (!isSearchMode) return undefined @@ -342,8 +337,8 @@ export function useMarketplaceData() { } export function useMarketplaceMoreClick() { - const [, setUrlFilters] = useMarketplaceFilters() - const [, setSort] = useMarketplaceSort() + const [,setQ] = useMarketplaceSearchQuery() + const setSort = useSetMarketplaceSort() return useCallback((searchParams?: { query?: string, sort_by?: string, sort_order?: string }) => { if (!searchParams) @@ -353,7 +348,7 @@ export function useMarketplaceMoreClick() { sortBy: searchParams?.sort_by || DEFAULT_SORT.sortBy, sortOrder: searchParams?.sort_order || DEFAULT_SORT.sortOrder, } - setUrlFilters({ q: newQuery }) + setQ(newQuery) setSort(newSort) - }, [setUrlFilters, setSort]) + }, [setQ, setSort]) } From 5ab08b14dd6d55bcd6e5a7ad6771b4120b339633 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Thu, 8 Jan 2026 16:25:40 +0800 Subject: [PATCH 16/58] search mode --- .../components/plugins/marketplace/atoms.ts | 20 ++++++++++++++++++- .../components/plugins/marketplace/hooks.ts | 7 +++---- .../marketplace/plugin-type-switch.tsx | 4 ++++ 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/web/app/components/plugins/marketplace/atoms.ts b/web/app/components/plugins/marketplace/atoms.ts index 2611081d51005a..90fb78a490c21c 100644 --- a/web/app/components/plugins/marketplace/atoms.ts +++ b/web/app/components/plugins/marketplace/atoms.ts @@ -1,5 +1,6 @@ import type { PluginsSort } from './types' -import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai' +import { atom, getDefaultStore, useAtom, useAtomValue, useSetAtom } from 'jotai' +import { useMarketplaceSearchQuery, useMarketplaceTags } from '@/hooks/use-query-params' import { DEFAULT_SORT } from './constants' const marketplaceSortAtom = atom(DEFAULT_SORT) @@ -15,3 +16,20 @@ export function useMarketplaceSortValue() { export function useSetMarketplaceSort() { return useSetAtom(marketplaceSortAtom) } + +const searchModeAtom = atom(false) + +export function useMarketplaceSearchMode() { + const [searchPluginText] = useMarketplaceSearchQuery() + const [filterPluginTags] = useMarketplaceTags() + + const searchMode = useAtomValue(searchModeAtom) + const isSearchMode = !!searchPluginText + || filterPluginTags.length > 0 + || searchMode + return isSearchMode +} + +export function setSearchMode(mode: boolean) { + getDefaultStore().set(searchModeAtom, mode) +} diff --git a/web/app/components/plugins/marketplace/hooks.ts b/web/app/components/plugins/marketplace/hooks.ts index f95758523b99b8..cacf5b16eba933 100644 --- a/web/app/components/plugins/marketplace/hooks.ts +++ b/web/app/components/plugins/marketplace/hooks.ts @@ -23,7 +23,7 @@ import { } from 'react' import { useMarketplaceCategory, useMarketplaceSearchQuery, useMarketplaceTags } from '@/hooks/use-query-params' import { postMarketplace } from '@/service/base' -import { useMarketplaceSortValue, useSetMarketplaceSort } from './atoms' +import { setSearchMode, useMarketplaceSearchMode, useMarketplaceSortValue, useSetMarketplaceSort } from './atoms' import { DEFAULT_SORT, SCROLL_BOTTOM_THRESHOLD } from './constants' import { PLUGIN_TYPE_SEARCH_MAP } from './plugin-type-switch' import { marketplaceKeys } from './query-keys' @@ -278,9 +278,7 @@ export function useMarketplacePluginsData() { const [filterPluginTags] = useMarketplaceTags() const [activePluginType] = useMarketplaceCategory() - const isSearchMode = !!searchPluginText - || filterPluginTags.length > 0 - || (activePluginType !== PLUGIN_TYPE_SEARCH_MAP.all && activePluginType !== PLUGIN_TYPE_SEARCH_MAP.tool) + const isSearchMode = useMarketplaceSearchMode() const queryParams = useMemo((): PluginsSearchParams | undefined => { if (!isSearchMode) @@ -350,5 +348,6 @@ export function useMarketplaceMoreClick() { } setQ(newQuery) setSort(newSort) + setSearchMode(true) }, [setQ, setSort]) } diff --git a/web/app/components/plugins/marketplace/plugin-type-switch.tsx b/web/app/components/plugins/marketplace/plugin-type-switch.tsx index daa62dad0a27d1..a15cd25b0001c2 100644 --- a/web/app/components/plugins/marketplace/plugin-type-switch.tsx +++ b/web/app/components/plugins/marketplace/plugin-type-switch.tsx @@ -12,6 +12,7 @@ import { Trigger as TriggerIcon } from '@/app/components/base/icons/src/vender/p import { useMarketplaceCategory } from '@/hooks/use-query-params' import { cn } from '@/utils/classnames' import { PluginCategoryEnum } from '../types' +import { setSearchMode } from './atoms' export const PLUGIN_TYPE_SEARCH_MAP = { all: 'all', @@ -91,6 +92,9 @@ const PluginTypeSwitch = ({ )} onClick={() => { handleActivePluginTypeChange(option.value) + if (option.value === PLUGIN_TYPE_SEARCH_MAP.all || option.value === PLUGIN_TYPE_SEARCH_MAP.tool) { + setSearchMode(false) + } }} > {option.icon} From 0ded49648ca8f401b033106ee331233037491d0e Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Thu, 8 Jan 2026 18:22:21 +0800 Subject: [PATCH 17/58] search mode default --- web/app/components/plugins/marketplace/atoms.ts | 14 ++++++++------ web/app/components/plugins/marketplace/hooks.ts | 5 +++-- .../plugins/marketplace/plugin-type-switch.tsx | 5 +++-- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/web/app/components/plugins/marketplace/atoms.ts b/web/app/components/plugins/marketplace/atoms.ts index 90fb78a490c21c..fc8a9004549047 100644 --- a/web/app/components/plugins/marketplace/atoms.ts +++ b/web/app/components/plugins/marketplace/atoms.ts @@ -1,7 +1,8 @@ import type { PluginsSort } from './types' -import { atom, getDefaultStore, useAtom, useAtomValue, useSetAtom } from 'jotai' -import { useMarketplaceSearchQuery, useMarketplaceTags } from '@/hooks/use-query-params' +import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai' +import { useMarketplaceCategory, useMarketplaceSearchQuery, useMarketplaceTags } from '@/hooks/use-query-params' import { DEFAULT_SORT } from './constants' +import { PLUGIN_TYPE_SEARCH_MAP } from './plugin-type-switch' const marketplaceSortAtom = atom(DEFAULT_SORT) @@ -17,19 +18,20 @@ export function useSetMarketplaceSort() { return useSetAtom(marketplaceSortAtom) } -const searchModeAtom = atom(false) +const searchModeAtom = atom(null) export function useMarketplaceSearchMode() { const [searchPluginText] = useMarketplaceSearchQuery() const [filterPluginTags] = useMarketplaceTags() + const [activePluginType] = useMarketplaceCategory() const searchMode = useAtomValue(searchModeAtom) const isSearchMode = !!searchPluginText || filterPluginTags.length > 0 - || searchMode + || (searchMode ?? (activePluginType !== PLUGIN_TYPE_SEARCH_MAP.all && activePluginType !== PLUGIN_TYPE_SEARCH_MAP.tool)) return isSearchMode } -export function setSearchMode(mode: boolean) { - getDefaultStore().set(searchModeAtom, mode) +export function useSetSearchMode() { + return useSetAtom(searchModeAtom) } diff --git a/web/app/components/plugins/marketplace/hooks.ts b/web/app/components/plugins/marketplace/hooks.ts index cacf5b16eba933..a4d2555f279c2b 100644 --- a/web/app/components/plugins/marketplace/hooks.ts +++ b/web/app/components/plugins/marketplace/hooks.ts @@ -23,7 +23,7 @@ import { } from 'react' import { useMarketplaceCategory, useMarketplaceSearchQuery, useMarketplaceTags } from '@/hooks/use-query-params' import { postMarketplace } from '@/service/base' -import { setSearchMode, useMarketplaceSearchMode, useMarketplaceSortValue, useSetMarketplaceSort } from './atoms' +import { useMarketplaceSearchMode, useMarketplaceSortValue, useSetMarketplaceSort, useSetSearchMode } from './atoms' import { DEFAULT_SORT, SCROLL_BOTTOM_THRESHOLD } from './constants' import { PLUGIN_TYPE_SEARCH_MAP } from './plugin-type-switch' import { marketplaceKeys } from './query-keys' @@ -337,6 +337,7 @@ export function useMarketplaceData() { export function useMarketplaceMoreClick() { const [,setQ] = useMarketplaceSearchQuery() const setSort = useSetMarketplaceSort() + const setSearchMode = useSetSearchMode() return useCallback((searchParams?: { query?: string, sort_by?: string, sort_order?: string }) => { if (!searchParams) @@ -349,5 +350,5 @@ export function useMarketplaceMoreClick() { setQ(newQuery) setSort(newSort) setSearchMode(true) - }, [setQ, setSort]) + }, [setQ, setSort, setSearchMode]) } diff --git a/web/app/components/plugins/marketplace/plugin-type-switch.tsx b/web/app/components/plugins/marketplace/plugin-type-switch.tsx index a15cd25b0001c2..f8525428682d71 100644 --- a/web/app/components/plugins/marketplace/plugin-type-switch.tsx +++ b/web/app/components/plugins/marketplace/plugin-type-switch.tsx @@ -12,7 +12,7 @@ import { Trigger as TriggerIcon } from '@/app/components/base/icons/src/vender/p import { useMarketplaceCategory } from '@/hooks/use-query-params' import { cn } from '@/utils/classnames' import { PluginCategoryEnum } from '../types' -import { setSearchMode } from './atoms' +import { useSetSearchMode } from './atoms' export const PLUGIN_TYPE_SEARCH_MAP = { all: 'all', @@ -32,6 +32,7 @@ const PluginTypeSwitch = ({ }: PluginTypeSwitchProps) => { const { t } = useTranslation() const [activePluginType, handleActivePluginTypeChange] = useMarketplaceCategory() + const setSearchMode = useSetSearchMode() const options = [ { @@ -93,7 +94,7 @@ const PluginTypeSwitch = ({ onClick={() => { handleActivePluginTypeChange(option.value) if (option.value === PLUGIN_TYPE_SEARCH_MAP.all || option.value === PLUGIN_TYPE_SEARCH_MAP.tool) { - setSearchMode(false) + setSearchMode(null) } }} > From 7b05cc154705237aefcce80bf1c76bb3edcd5278 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Thu, 8 Jan 2026 18:42:19 +0800 Subject: [PATCH 18/58] better search mode logic --- .../components/plugins/marketplace/atoms.ts | 5 ++--- .../plugins/marketplace/constants.ts | 20 +++++++++++++++++++ .../components/plugins/marketplace/hooks.ts | 3 +-- .../plugins/marketplace/index.spec.tsx | 4 ++-- .../marketplace/plugin-type-switch.tsx | 14 ++----------- .../components/plugins/marketplace/utils.ts | 2 +- .../components/plugins/plugin-page/index.tsx | 2 +- .../auto-update-setting/index.spec.tsx | 2 +- .../auto-update-setting/tool-picker.tsx | 2 +- 9 files changed, 31 insertions(+), 23 deletions(-) diff --git a/web/app/components/plugins/marketplace/atoms.ts b/web/app/components/plugins/marketplace/atoms.ts index fc8a9004549047..2eadc2e80a4d46 100644 --- a/web/app/components/plugins/marketplace/atoms.ts +++ b/web/app/components/plugins/marketplace/atoms.ts @@ -1,8 +1,7 @@ import type { PluginsSort } from './types' import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai' import { useMarketplaceCategory, useMarketplaceSearchQuery, useMarketplaceTags } from '@/hooks/use-query-params' -import { DEFAULT_SORT } from './constants' -import { PLUGIN_TYPE_SEARCH_MAP } from './plugin-type-switch' +import { DEFAULT_SORT, PLUGIN_CATEGORY_WITH_COLLECTIONS } from './constants' const marketplaceSortAtom = atom(DEFAULT_SORT) @@ -28,7 +27,7 @@ export function useMarketplaceSearchMode() { const searchMode = useAtomValue(searchModeAtom) const isSearchMode = !!searchPluginText || filterPluginTags.length > 0 - || (searchMode ?? (activePluginType !== PLUGIN_TYPE_SEARCH_MAP.all && activePluginType !== PLUGIN_TYPE_SEARCH_MAP.tool)) + || (searchMode ?? (!PLUGIN_CATEGORY_WITH_COLLECTIONS.has(activePluginType))) return isSearchMode } diff --git a/web/app/components/plugins/marketplace/constants.ts b/web/app/components/plugins/marketplace/constants.ts index 92c3e7278faeac..e6f005948bb827 100644 --- a/web/app/components/plugins/marketplace/constants.ts +++ b/web/app/components/plugins/marketplace/constants.ts @@ -1,6 +1,26 @@ +import { PluginCategoryEnum } from '../types' + export const DEFAULT_SORT = { sortBy: 'install_count', sortOrder: 'DESC', } export const SCROLL_BOTTOM_THRESHOLD = 100 + +export const PLUGIN_TYPE_SEARCH_MAP = { + all: 'all', + model: PluginCategoryEnum.model, + tool: PluginCategoryEnum.tool, + agent: PluginCategoryEnum.agent, + extension: PluginCategoryEnum.extension, + datasource: PluginCategoryEnum.datasource, + trigger: PluginCategoryEnum.trigger, + bundle: 'bundle', +} + +export const PLUGIN_CATEGORY_WITH_COLLECTIONS = new Set( + [ + PLUGIN_TYPE_SEARCH_MAP.all, + PLUGIN_TYPE_SEARCH_MAP.tool, + ], +) diff --git a/web/app/components/plugins/marketplace/hooks.ts b/web/app/components/plugins/marketplace/hooks.ts index a4d2555f279c2b..40a12670800cfe 100644 --- a/web/app/components/plugins/marketplace/hooks.ts +++ b/web/app/components/plugins/marketplace/hooks.ts @@ -24,8 +24,7 @@ import { import { useMarketplaceCategory, useMarketplaceSearchQuery, useMarketplaceTags } from '@/hooks/use-query-params' import { postMarketplace } from '@/service/base' import { useMarketplaceSearchMode, useMarketplaceSortValue, useSetMarketplaceSort, useSetSearchMode } from './atoms' -import { DEFAULT_SORT, SCROLL_BOTTOM_THRESHOLD } from './constants' -import { PLUGIN_TYPE_SEARCH_MAP } from './plugin-type-switch' +import { DEFAULT_SORT, PLUGIN_TYPE_SEARCH_MAP, SCROLL_BOTTOM_THRESHOLD } from './constants' import { marketplaceKeys } from './query-keys' import { getFormattedPlugin, diff --git a/web/app/components/plugins/marketplace/index.spec.tsx b/web/app/components/plugins/marketplace/index.spec.tsx index b3b1d58dd48846..ed901ac0f79df9 100644 --- a/web/app/components/plugins/marketplace/index.spec.tsx +++ b/web/app/components/plugins/marketplace/index.spec.tsx @@ -9,9 +9,9 @@ import { PluginCategoryEnum } from '@/app/components/plugins/types' // ================================ // Note: Import after mocks are set up -import { DEFAULT_SORT, SCROLL_BOTTOM_THRESHOLD } from './constants' +import { DEFAULT_SORT, PLUGIN_TYPE_SEARCH_MAP, SCROLL_BOTTOM_THRESHOLD } from './constants' import { MarketplaceContext, MarketplaceContextProvider, useMarketplaceContext } from './context' -import PluginTypeSwitch, { PLUGIN_TYPE_SEARCH_MAP } from './plugin-type-switch' +import PluginTypeSwitch from './plugin-type-switch' import StickySearchAndSwitchWrapper from './sticky-search-and-switch-wrapper' import { getFormattedPlugin, diff --git a/web/app/components/plugins/marketplace/plugin-type-switch.tsx b/web/app/components/plugins/marketplace/plugin-type-switch.tsx index f8525428682d71..fb35915a92195c 100644 --- a/web/app/components/plugins/marketplace/plugin-type-switch.tsx +++ b/web/app/components/plugins/marketplace/plugin-type-switch.tsx @@ -11,19 +11,9 @@ import { import { Trigger as TriggerIcon } from '@/app/components/base/icons/src/vender/plugin' import { useMarketplaceCategory } from '@/hooks/use-query-params' import { cn } from '@/utils/classnames' -import { PluginCategoryEnum } from '../types' import { useSetSearchMode } from './atoms' +import { PLUGIN_CATEGORY_WITH_COLLECTIONS, PLUGIN_TYPE_SEARCH_MAP } from './constants' -export const PLUGIN_TYPE_SEARCH_MAP = { - all: 'all', - model: PluginCategoryEnum.model, - tool: PluginCategoryEnum.tool, - agent: PluginCategoryEnum.agent, - extension: PluginCategoryEnum.extension, - datasource: PluginCategoryEnum.datasource, - trigger: PluginCategoryEnum.trigger, - bundle: 'bundle', -} type PluginTypeSwitchProps = { className?: string } @@ -93,7 +83,7 @@ const PluginTypeSwitch = ({ )} onClick={() => { handleActivePluginTypeChange(option.value) - if (option.value === PLUGIN_TYPE_SEARCH_MAP.all || option.value === PLUGIN_TYPE_SEARCH_MAP.tool) { + if (PLUGIN_CATEGORY_WITH_COLLECTIONS.has(option.value)) { setSearchMode(null) } }} diff --git a/web/app/components/plugins/marketplace/utils.ts b/web/app/components/plugins/marketplace/utils.ts index e51c9b76a60787..683792394a3321 100644 --- a/web/app/components/plugins/marketplace/utils.ts +++ b/web/app/components/plugins/marketplace/utils.ts @@ -10,7 +10,7 @@ import { MARKETPLACE_API_PREFIX, } from '@/config' import { getMarketplaceUrl } from '@/utils/var' -import { PLUGIN_TYPE_SEARCH_MAP } from './plugin-type-switch' +import { PLUGIN_TYPE_SEARCH_MAP } from './constants' type MarketplaceFetchOptions = { signal?: AbortSignal diff --git a/web/app/components/plugins/plugin-page/index.tsx b/web/app/components/plugins/plugin-page/index.tsx index b8fc8912541f78..1f88f691efa456 100644 --- a/web/app/components/plugins/plugin-page/index.tsx +++ b/web/app/components/plugins/plugin-page/index.tsx @@ -27,7 +27,7 @@ import { cn } from '@/utils/classnames' import { PLUGIN_PAGE_TABS_MAP } from '../hooks' import InstallFromLocalPackage from '../install-plugin/install-from-local-package' import InstallFromMarketplace from '../install-plugin/install-from-marketplace' -import { PLUGIN_TYPE_SEARCH_MAP } from '../marketplace/plugin-type-switch' +import { PLUGIN_TYPE_SEARCH_MAP } from '../marketplace/constants' import { PluginPageContextProvider, usePluginPageContext, diff --git a/web/app/components/plugins/reference-setting-modal/auto-update-setting/index.spec.tsx b/web/app/components/plugins/reference-setting-modal/auto-update-setting/index.spec.tsx index d65b0b7957ef25..1008ef461d59cb 100644 --- a/web/app/components/plugins/reference-setting-modal/auto-update-setting/index.spec.tsx +++ b/web/app/components/plugins/reference-setting-modal/auto-update-setting/index.spec.tsx @@ -262,7 +262,7 @@ vi.mock('@/app/components/base/icons/src/vender/other', () => ({ })) // Mock PLUGIN_TYPE_SEARCH_MAP -vi.mock('../../marketplace/plugin-type-switch', () => ({ +vi.mock('../../marketplace/constants', () => ({ PLUGIN_TYPE_SEARCH_MAP: { all: 'all', model: 'model', diff --git a/web/app/components/plugins/reference-setting-modal/auto-update-setting/tool-picker.tsx b/web/app/components/plugins/reference-setting-modal/auto-update-setting/tool-picker.tsx index a91df6c7932482..61260e6d1be21a 100644 --- a/web/app/components/plugins/reference-setting-modal/auto-update-setting/tool-picker.tsx +++ b/web/app/components/plugins/reference-setting-modal/auto-update-setting/tool-picker.tsx @@ -12,7 +12,7 @@ import { import SearchBox from '@/app/components/plugins/marketplace/search-box' import { useInstalledPluginList } from '@/service/use-plugins' import { cn } from '@/utils/classnames' -import { PLUGIN_TYPE_SEARCH_MAP } from '../../marketplace/plugin-type-switch' +import { PLUGIN_TYPE_SEARCH_MAP } from '../../marketplace/constants' import { PluginSource } from '../../types' import NoDataPlaceholder from './no-data-placeholder' import ToolItem from './tool-item' From 38507d88e6167596077c2f67a2e71b0b4250b38b Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Thu, 8 Jan 2026 19:03:45 +0800 Subject: [PATCH 19/58] prefetch --- web/app/components/plugins/marketplace/hooks.ts | 15 ++------------- web/app/components/plugins/marketplace/index.tsx | 15 +++++++-------- web/app/components/plugins/marketplace/utils.ts | 11 +++++++++++ 3 files changed, 20 insertions(+), 21 deletions(-) diff --git a/web/app/components/plugins/marketplace/hooks.ts b/web/app/components/plugins/marketplace/hooks.ts index 40a12670800cfe..4aaa723ca410d4 100644 --- a/web/app/components/plugins/marketplace/hooks.ts +++ b/web/app/components/plugins/marketplace/hooks.ts @@ -27,15 +27,13 @@ import { useMarketplaceSearchMode, useMarketplaceSortValue, useSetMarketplaceSor import { DEFAULT_SORT, PLUGIN_TYPE_SEARCH_MAP, SCROLL_BOTTOM_THRESHOLD } from './constants' import { marketplaceKeys } from './query-keys' import { + getCollectionsParams, getFormattedPlugin, getMarketplaceCollectionsAndPlugins, - getMarketplaceListCondition, getMarketplaceListFilterType, getMarketplacePluginsByCollectionId, } from './utils' -const EMPTY_PARAMS = {} - export const useMarketplaceCollectionsAndPlugins = (queryParams?: CollectionsAndPluginsSearchParams) => { return useQuery({ queryKey: marketplaceKeys.collections(queryParams), @@ -47,16 +45,7 @@ export const useMarketplaceCollectionsAndPlugins = (queryParams?: CollectionsAnd export function useMarketplaceCollectionsData() { const [activePluginType] = useMarketplaceCategory() - const collectionsParams: CollectionsAndPluginsSearchParams = useMemo(() => { - if (activePluginType === PLUGIN_TYPE_SEARCH_MAP.all) { - return EMPTY_PARAMS - } - return { - category: activePluginType === PLUGIN_TYPE_SEARCH_MAP.all ? undefined : activePluginType, - condition: getMarketplaceListCondition(activePluginType), - type: getMarketplaceListFilterType(activePluginType), - } - }, [activePluginType]) + const collectionsParams = useMemo(() => getCollectionsParams(activePluginType), [activePluginType]) const { data, isLoading } = useMarketplaceCollectionsAndPlugins(collectionsParams) diff --git a/web/app/components/plugins/marketplace/index.tsx b/web/app/components/plugins/marketplace/index.tsx index 39e91ebb4a0daf..d21c70730ea3e5 100644 --- a/web/app/components/plugins/marketplace/index.tsx +++ b/web/app/components/plugins/marketplace/index.tsx @@ -2,7 +2,7 @@ import { dehydrate } from '@tanstack/react-query' import { getQueryClient } from '@/context/query-client-server' import { MarketplaceClient } from './marketplace-client' import { marketplaceKeys } from './query-keys' -import { getMarketplaceCollectionsAndPlugins } from './utils' +import { getCollectionsParams, getMarketplaceCollectionsAndPlugins } from './utils' type MarketplaceProps = { showInstallButton?: boolean @@ -13,19 +13,18 @@ async function Marketplace({ showInstallButton = true, pluginTypeSwitchClassName, }: MarketplaceProps) { - // const queryClient = getQueryClient() + const queryClient = getQueryClient() - // // Prefetch collections and plugins for the default view (all categories) - // await queryClient.prefetchQuery({ - // queryKey: marketplaceKeys.collections({}), - // queryFn: () => getMarketplaceCollectionsAndPlugins({}), - // }) + await queryClient.prefetchQuery({ + queryKey: marketplaceKeys.collections(getCollectionsParams('all')), + queryFn: () => getMarketplaceCollectionsAndPlugins(getCollectionsParams('all')), + }) return ( ) } diff --git a/web/app/components/plugins/marketplace/utils.ts b/web/app/components/plugins/marketplace/utils.ts index 683792394a3321..462747ae0ba8ec 100644 --- a/web/app/components/plugins/marketplace/utils.ts +++ b/web/app/components/plugins/marketplace/utils.ts @@ -151,3 +151,14 @@ export const getMarketplaceListFilterType = (category: string) => { return 'plugin' } + +export function getCollectionsParams(category: string): CollectionsAndPluginsSearchParams { + if (category === PLUGIN_TYPE_SEARCH_MAP.all) { + return {} + } + return { + category: category === PLUGIN_TYPE_SEARCH_MAP.all ? undefined : category, + condition: getMarketplaceListCondition(category), + type: getMarketplaceListFilterType(category), + } +} From 8549f51d4b7067264ee76d3c94ea3fbb9266e671 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Thu, 8 Jan 2026 19:18:12 +0800 Subject: [PATCH 20/58] prefetch for all category --- web/app/(commonLayout)/plugins/page.tsx | 15 +++++++++++++-- web/app/components/plugins/marketplace/index.tsx | 10 ++++++++-- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/web/app/(commonLayout)/plugins/page.tsx b/web/app/(commonLayout)/plugins/page.tsx index 8b954a37b59a81..3119ad2f79a910 100644 --- a/web/app/(commonLayout)/plugins/page.tsx +++ b/web/app/(commonLayout)/plugins/page.tsx @@ -1,12 +1,23 @@ +import type { SearchParams } from 'nuqs' +import { createLoader, parseAsArrayOf, parseAsString } from 'nuqs/server' import Marketplace from '@/app/components/plugins/marketplace' import PluginPage from '@/app/components/plugins/plugin-page' import PluginsPanel from '@/app/components/plugins/plugin-page/plugins-panel' -const PluginList = async () => { +const marketplaceSearchParams = { + q: parseAsString.withDefault(''), + category: parseAsString.withDefault('all'), + tags: parseAsArrayOf(parseAsString).withDefault([]), +} + +const loadSearchParams = createLoader(marketplaceSearchParams) + +const PluginList = async ({ searchParams}: { searchParams: Promise }) => { + const params = await loadSearchParams(searchParams) return ( } - marketplace={} + marketplace={} /> ) } diff --git a/web/app/components/plugins/marketplace/index.tsx b/web/app/components/plugins/marketplace/index.tsx index d21c70730ea3e5..8b467c8e9e6a3f 100644 --- a/web/app/components/plugins/marketplace/index.tsx +++ b/web/app/components/plugins/marketplace/index.tsx @@ -7,17 +7,23 @@ import { getCollectionsParams, getMarketplaceCollectionsAndPlugins } from './uti type MarketplaceProps = { showInstallButton?: boolean pluginTypeSwitchClassName?: string + params: { + q: string + category: string + tags: string[] + } } async function Marketplace({ showInstallButton = true, pluginTypeSwitchClassName, + params, }: MarketplaceProps) { const queryClient = getQueryClient() await queryClient.prefetchQuery({ - queryKey: marketplaceKeys.collections(getCollectionsParams('all')), - queryFn: () => getMarketplaceCollectionsAndPlugins(getCollectionsParams('all')), + queryKey: marketplaceKeys.collections(getCollectionsParams(params.category)), + queryFn: () => getMarketplaceCollectionsAndPlugins(getCollectionsParams(params.category)), }) return ( From 385d3fc2ace89651e5e338ede4c4d47083628db8 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Thu, 8 Jan 2026 19:31:21 +0800 Subject: [PATCH 21/58] share params --- web/app/(commonLayout)/plugins/page.tsx | 12 +----- .../components/plugins/marketplace/atoms.ts | 2 +- .../plugins/marketplace/constants.ts | 8 ++++ .../components/plugins/marketplace/hooks.ts | 15 +++++++- .../components/plugins/marketplace/index.tsx | 14 ++++--- .../marketplace/plugin-type-switch.tsx | 2 +- .../search-box/search-box-wrapper.tsx | 2 +- web/hooks/use-query-params.ts | 38 ------------------- 8 files changed, 33 insertions(+), 60 deletions(-) diff --git a/web/app/(commonLayout)/plugins/page.tsx b/web/app/(commonLayout)/plugins/page.tsx index 3119ad2f79a910..0b95de8dab9a6c 100644 --- a/web/app/(commonLayout)/plugins/page.tsx +++ b/web/app/(commonLayout)/plugins/page.tsx @@ -1,23 +1,13 @@ import type { SearchParams } from 'nuqs' -import { createLoader, parseAsArrayOf, parseAsString } from 'nuqs/server' import Marketplace from '@/app/components/plugins/marketplace' import PluginPage from '@/app/components/plugins/plugin-page' import PluginsPanel from '@/app/components/plugins/plugin-page/plugins-panel' -const marketplaceSearchParams = { - q: parseAsString.withDefault(''), - category: parseAsString.withDefault('all'), - tags: parseAsArrayOf(parseAsString).withDefault([]), -} - -const loadSearchParams = createLoader(marketplaceSearchParams) - const PluginList = async ({ searchParams}: { searchParams: Promise }) => { - const params = await loadSearchParams(searchParams) return ( } - marketplace={} + marketplace={} /> ) } diff --git a/web/app/components/plugins/marketplace/atoms.ts b/web/app/components/plugins/marketplace/atoms.ts index 2eadc2e80a4d46..3a6f000fb1a6b7 100644 --- a/web/app/components/plugins/marketplace/atoms.ts +++ b/web/app/components/plugins/marketplace/atoms.ts @@ -1,7 +1,7 @@ import type { PluginsSort } from './types' import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai' -import { useMarketplaceCategory, useMarketplaceSearchQuery, useMarketplaceTags } from '@/hooks/use-query-params' import { DEFAULT_SORT, PLUGIN_CATEGORY_WITH_COLLECTIONS } from './constants' +import { useMarketplaceCategory, useMarketplaceSearchQuery, useMarketplaceTags } from './hooks' const marketplaceSortAtom = atom(DEFAULT_SORT) diff --git a/web/app/components/plugins/marketplace/constants.ts b/web/app/components/plugins/marketplace/constants.ts index e6f005948bb827..f17b2161724c4b 100644 --- a/web/app/components/plugins/marketplace/constants.ts +++ b/web/app/components/plugins/marketplace/constants.ts @@ -1,3 +1,5 @@ +import { parseAsArrayOf, parseAsString } from 'nuqs/server' + import { PluginCategoryEnum } from '../types' export const DEFAULT_SORT = { @@ -24,3 +26,9 @@ export const PLUGIN_CATEGORY_WITH_COLLECTIONS = new Set( PLUGIN_TYPE_SEARCH_MAP.tool, ], ) + +export const marketplaceSearchParams = { + q: parseAsString.withDefault('').withOptions({ history: 'replace' }), + category: parseAsString.withDefault('all').withOptions({ history: 'replace', clearOnDefault: false }), + tags: parseAsArrayOf(parseAsString).withDefault([]).withOptions({ history: 'replace' }), +} diff --git a/web/app/components/plugins/marketplace/hooks.ts b/web/app/components/plugins/marketplace/hooks.ts index 4aaa723ca410d4..fe82cf3f70b5db 100644 --- a/web/app/components/plugins/marketplace/hooks.ts +++ b/web/app/components/plugins/marketplace/hooks.ts @@ -15,16 +15,17 @@ import { useQueryClient, } from '@tanstack/react-query' import { useDebounceFn } from 'ahooks' +import { useQueryState } from 'nuqs' + import { useCallback, useEffect, useMemo, useState, } from 'react' -import { useMarketplaceCategory, useMarketplaceSearchQuery, useMarketplaceTags } from '@/hooks/use-query-params' import { postMarketplace } from '@/service/base' import { useMarketplaceSearchMode, useMarketplaceSortValue, useSetMarketplaceSort, useSetSearchMode } from './atoms' -import { DEFAULT_SORT, PLUGIN_TYPE_SEARCH_MAP, SCROLL_BOTTOM_THRESHOLD } from './constants' +import { DEFAULT_SORT, marketplaceSearchParams, PLUGIN_TYPE_SEARCH_MAP, SCROLL_BOTTOM_THRESHOLD } from './constants' import { marketplaceKeys } from './query-keys' import { getCollectionsParams, @@ -34,6 +35,16 @@ import { getMarketplacePluginsByCollectionId, } from './utils' +export function useMarketplaceSearchQuery() { + return useQueryState('q', marketplaceSearchParams.q) +} +export function useMarketplaceCategory() { + return useQueryState('category', marketplaceSearchParams.category) +} +export function useMarketplaceTags() { + return useQueryState('tags', marketplaceSearchParams.tags) +} + export const useMarketplaceCollectionsAndPlugins = (queryParams?: CollectionsAndPluginsSearchParams) => { return useQuery({ queryKey: marketplaceKeys.collections(queryParams), diff --git a/web/app/components/plugins/marketplace/index.tsx b/web/app/components/plugins/marketplace/index.tsx index 8b467c8e9e6a3f..7f3d044285d0dc 100644 --- a/web/app/components/plugins/marketplace/index.tsx +++ b/web/app/components/plugins/marketplace/index.tsx @@ -1,5 +1,8 @@ +import type { SearchParams } from 'nuqs' import { dehydrate } from '@tanstack/react-query' +import { createLoader } from 'nuqs/server' import { getQueryClient } from '@/context/query-client-server' +import { marketplaceSearchParams } from './constants' import { MarketplaceClient } from './marketplace-client' import { marketplaceKeys } from './query-keys' import { getCollectionsParams, getMarketplaceCollectionsAndPlugins } from './utils' @@ -7,18 +10,17 @@ import { getCollectionsParams, getMarketplaceCollectionsAndPlugins } from './uti type MarketplaceProps = { showInstallButton?: boolean pluginTypeSwitchClassName?: string - params: { - q: string - category: string - tags: string[] - } + searchParams: Promise } +const loadSearchParams = createLoader(marketplaceSearchParams) + async function Marketplace({ showInstallButton = true, pluginTypeSwitchClassName, - params, + searchParams, }: MarketplaceProps) { + const params = await loadSearchParams(searchParams) const queryClient = getQueryClient() await queryClient.prefetchQuery({ diff --git a/web/app/components/plugins/marketplace/plugin-type-switch.tsx b/web/app/components/plugins/marketplace/plugin-type-switch.tsx index fb35915a92195c..935ab162f23417 100644 --- a/web/app/components/plugins/marketplace/plugin-type-switch.tsx +++ b/web/app/components/plugins/marketplace/plugin-type-switch.tsx @@ -9,10 +9,10 @@ import { RiSpeakAiLine, } from '@remixicon/react' import { Trigger as TriggerIcon } from '@/app/components/base/icons/src/vender/plugin' -import { useMarketplaceCategory } from '@/hooks/use-query-params' import { cn } from '@/utils/classnames' import { useSetSearchMode } from './atoms' import { PLUGIN_CATEGORY_WITH_COLLECTIONS, PLUGIN_TYPE_SEARCH_MAP } from './constants' +import { useMarketplaceCategory } from './hooks' type PluginTypeSwitchProps = { className?: string diff --git a/web/app/components/plugins/marketplace/search-box/search-box-wrapper.tsx b/web/app/components/plugins/marketplace/search-box/search-box-wrapper.tsx index 050ac451bc5205..78560d582a9e6c 100644 --- a/web/app/components/plugins/marketplace/search-box/search-box-wrapper.tsx +++ b/web/app/components/plugins/marketplace/search-box/search-box-wrapper.tsx @@ -1,7 +1,7 @@ 'use client' import { useTranslation } from '#i18n' -import { useMarketplaceSearchQuery, useMarketplaceTags } from '@/hooks/use-query-params' +import { useMarketplaceSearchQuery, useMarketplaceTags } from '../hooks' import SearchBox from './index' const SearchBoxWrapper = () => { diff --git a/web/hooks/use-query-params.ts b/web/hooks/use-query-params.ts index 5be72a40716436..0b632d01c5cbbf 100644 --- a/web/hooks/use-query-params.ts +++ b/web/hooks/use-query-params.ts @@ -94,44 +94,6 @@ export function useAccountSettingModal() { return [{ isOpen, payload: currentTab }, setState] as const } -export function useMarketplaceSearchQuery() { - return useQueryState('q', parseAsString.withDefault('').withOptions({ history: 'replace' })) -} -export function useMarketplaceCategory() { - return useQueryState('category', parseAsString.withDefault('all').withOptions({ history: 'replace', clearOnDefault: false })) -} -export function useMarketplaceTags() { - return useQueryState('tags', parseAsArrayOf(parseAsString).withDefault([]).withOptions({ history: 'replace' })) -} -export function useMarketplaceFilters() { - const [q, setQ] = useMarketplaceSearchQuery() - const [category, setCategory] = useMarketplaceCategory() - const [tags, setTags] = useMarketplaceTags() - - const setFilters = useCallback( - ( - updates: Partial<{ q: string, category: string, tags: string[] }> | null, - options?: Options, - ) => { - if (updates === null) { - setQ(null, options) - setCategory(null, options) - setTags(null, options) - return - } - if ('q' in updates) - setQ(updates.q!, options) - if ('category' in updates) - setCategory(updates.category!, options) - if ('tags' in updates) - setTags(updates.tags!, options) - }, - [setQ, setCategory, setTags], - ) - - return [{ q, category, tags }, setFilters] as const -} - /** * Plugin Installation Query Parameters */ From 817ed91c2041e170de79c4a8dd0ea713886ec5b1 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Thu, 8 Jan 2026 19:42:24 +0800 Subject: [PATCH 22/58] no prefetch for app --- web/app/(commonLayout)/plugins/page.tsx | 5 ++-- .../components/plugins/marketplace/index.tsx | 28 ++++++++++++------- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/web/app/(commonLayout)/plugins/page.tsx b/web/app/(commonLayout)/plugins/page.tsx index 0b95de8dab9a6c..f366200cf936c5 100644 --- a/web/app/(commonLayout)/plugins/page.tsx +++ b/web/app/(commonLayout)/plugins/page.tsx @@ -1,13 +1,12 @@ -import type { SearchParams } from 'nuqs' import Marketplace from '@/app/components/plugins/marketplace' import PluginPage from '@/app/components/plugins/plugin-page' import PluginsPanel from '@/app/components/plugins/plugin-page/plugins-panel' -const PluginList = async ({ searchParams}: { searchParams: Promise }) => { +const PluginList = () => { return ( } - marketplace={} + marketplace={} /> ) } diff --git a/web/app/components/plugins/marketplace/index.tsx b/web/app/components/plugins/marketplace/index.tsx index 7f3d044285d0dc..639e0a349f28ca 100644 --- a/web/app/components/plugins/marketplace/index.tsx +++ b/web/app/components/plugins/marketplace/index.tsx @@ -1,3 +1,4 @@ +import type { DehydratedState } from '@tanstack/react-query' import type { SearchParams } from 'nuqs' import { dehydrate } from '@tanstack/react-query' import { createLoader } from 'nuqs/server' @@ -10,29 +11,36 @@ import { getCollectionsParams, getMarketplaceCollectionsAndPlugins } from './uti type MarketplaceProps = { showInstallButton?: boolean pluginTypeSwitchClassName?: string - searchParams: Promise + /** + * Pass the search params from the request to prefetch data on the server + */ + searchParams?: Promise } -const loadSearchParams = createLoader(marketplaceSearchParams) - async function Marketplace({ showInstallButton = true, pluginTypeSwitchClassName, searchParams, }: MarketplaceProps) { - const params = await loadSearchParams(searchParams) - const queryClient = getQueryClient() + let dehydratedState: DehydratedState | undefined + + if (searchParams) { + const loadSearchParams = createLoader(marketplaceSearchParams) + const params = await loadSearchParams(searchParams) + const queryClient = getQueryClient() - await queryClient.prefetchQuery({ - queryKey: marketplaceKeys.collections(getCollectionsParams(params.category)), - queryFn: () => getMarketplaceCollectionsAndPlugins(getCollectionsParams(params.category)), - }) + await queryClient.prefetchQuery({ + queryKey: marketplaceKeys.collections(getCollectionsParams(params.category)), + queryFn: () => getMarketplaceCollectionsAndPlugins(getCollectionsParams(params.category)), + }) + dehydratedState = dehydrate(queryClient) + } return ( ) } From f47ff7368fe377f8be269c8846de243e82698bca Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Thu, 8 Jan 2026 21:51:54 +0800 Subject: [PATCH 23/58] type --- web/app/components/plugins/marketplace/utils.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/web/app/components/plugins/marketplace/utils.ts b/web/app/components/plugins/marketplace/utils.ts index 462747ae0ba8ec..428747bd4349c8 100644 --- a/web/app/components/plugins/marketplace/utils.ts +++ b/web/app/components/plugins/marketplace/utils.ts @@ -26,12 +26,13 @@ export const getPluginIconInMarketplace = (plugin: Plugin) => { return `${MARKETPLACE_API_PREFIX}/plugins/${plugin.org}/${plugin.name}/icon` } -export const getFormattedPlugin = (bundle: any) => { +export const getFormattedPlugin = (bundle: Plugin): Plugin => { if (bundle.type === 'bundle') { return { ...bundle, icon: getPluginIconInMarketplace(bundle), brief: bundle.description, + // @ts-expect-error I do not have enough information label: bundle.labels, } } From e55640f656ee50e7b3318b7bfc004af4a65b3f4d Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Thu, 8 Jan 2026 22:08:02 +0800 Subject: [PATCH 24/58] rename --- web/app/components/plugins/marketplace/constants.ts | 8 -------- web/app/components/plugins/marketplace/hooks.ts | 9 +++++---- web/app/components/plugins/marketplace/index.tsx | 4 ++-- web/hooks/use-query-params.ts | 2 -- 4 files changed, 7 insertions(+), 16 deletions(-) diff --git a/web/app/components/plugins/marketplace/constants.ts b/web/app/components/plugins/marketplace/constants.ts index f17b2161724c4b..e6f005948bb827 100644 --- a/web/app/components/plugins/marketplace/constants.ts +++ b/web/app/components/plugins/marketplace/constants.ts @@ -1,5 +1,3 @@ -import { parseAsArrayOf, parseAsString } from 'nuqs/server' - import { PluginCategoryEnum } from '../types' export const DEFAULT_SORT = { @@ -26,9 +24,3 @@ export const PLUGIN_CATEGORY_WITH_COLLECTIONS = new Set( PLUGIN_TYPE_SEARCH_MAP.tool, ], ) - -export const marketplaceSearchParams = { - q: parseAsString.withDefault('').withOptions({ history: 'replace' }), - category: parseAsString.withDefault('all').withOptions({ history: 'replace', clearOnDefault: false }), - tags: parseAsArrayOf(parseAsString).withDefault([]).withOptions({ history: 'replace' }), -} diff --git a/web/app/components/plugins/marketplace/hooks.ts b/web/app/components/plugins/marketplace/hooks.ts index fe82cf3f70b5db..214c113f7a1f3b 100644 --- a/web/app/components/plugins/marketplace/hooks.ts +++ b/web/app/components/plugins/marketplace/hooks.ts @@ -25,8 +25,9 @@ import { } from 'react' import { postMarketplace } from '@/service/base' import { useMarketplaceSearchMode, useMarketplaceSortValue, useSetMarketplaceSort, useSetSearchMode } from './atoms' -import { DEFAULT_SORT, marketplaceSearchParams, PLUGIN_TYPE_SEARCH_MAP, SCROLL_BOTTOM_THRESHOLD } from './constants' +import { DEFAULT_SORT, PLUGIN_TYPE_SEARCH_MAP, SCROLL_BOTTOM_THRESHOLD } from './constants' import { marketplaceKeys } from './query-keys' +import { marketplaceSearchParamsParsers } from './search-params' import { getCollectionsParams, getFormattedPlugin, @@ -36,13 +37,13 @@ import { } from './utils' export function useMarketplaceSearchQuery() { - return useQueryState('q', marketplaceSearchParams.q) + return useQueryState('q', marketplaceSearchParamsParsers.q) } export function useMarketplaceCategory() { - return useQueryState('category', marketplaceSearchParams.category) + return useQueryState('category', marketplaceSearchParamsParsers.category) } export function useMarketplaceTags() { - return useQueryState('tags', marketplaceSearchParams.tags) + return useQueryState('tags', marketplaceSearchParamsParsers.tags) } export const useMarketplaceCollectionsAndPlugins = (queryParams?: CollectionsAndPluginsSearchParams) => { diff --git a/web/app/components/plugins/marketplace/index.tsx b/web/app/components/plugins/marketplace/index.tsx index 639e0a349f28ca..f988dc503595e8 100644 --- a/web/app/components/plugins/marketplace/index.tsx +++ b/web/app/components/plugins/marketplace/index.tsx @@ -3,9 +3,9 @@ import type { SearchParams } from 'nuqs' import { dehydrate } from '@tanstack/react-query' import { createLoader } from 'nuqs/server' import { getQueryClient } from '@/context/query-client-server' -import { marketplaceSearchParams } from './constants' import { MarketplaceClient } from './marketplace-client' import { marketplaceKeys } from './query-keys' +import { marketplaceSearchParamsParsers } from './search-params' import { getCollectionsParams, getMarketplaceCollectionsAndPlugins } from './utils' type MarketplaceProps = { @@ -25,7 +25,7 @@ async function Marketplace({ let dehydratedState: DehydratedState | undefined if (searchParams) { - const loadSearchParams = createLoader(marketplaceSearchParams) + const loadSearchParams = createLoader(marketplaceSearchParamsParsers) const params = await loadSearchParams(searchParams) const queryClient = getQueryClient() diff --git a/web/hooks/use-query-params.ts b/web/hooks/use-query-params.ts index 0b632d01c5cbbf..73798a4a4ff743 100644 --- a/web/hooks/use-query-params.ts +++ b/web/hooks/use-query-params.ts @@ -13,10 +13,8 @@ * - Use shallow routing to avoid unnecessary re-renders */ -import type { Options } from 'nuqs' import { createParser, - parseAsArrayOf, parseAsString, useQueryState, useQueryStates, From 237ee02277dcd36396fcd93f6f8aa1d3f17193f7 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Thu, 8 Jan 2026 22:08:13 +0800 Subject: [PATCH 25/58] rename --- web/app/components/plugins/marketplace/search-params.ts | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 web/app/components/plugins/marketplace/search-params.ts diff --git a/web/app/components/plugins/marketplace/search-params.ts b/web/app/components/plugins/marketplace/search-params.ts new file mode 100644 index 00000000000000..1cedf08551838c --- /dev/null +++ b/web/app/components/plugins/marketplace/search-params.ts @@ -0,0 +1,7 @@ +import { parseAsArrayOf, parseAsString } from 'nuqs/server' + +export const marketplaceSearchParamsParsers = { + category: parseAsString.withDefault('all').withOptions({ history: 'replace', clearOnDefault: false }), + q: parseAsString.withDefault('').withOptions({ history: 'replace' }), + tags: parseAsArrayOf(parseAsString).withDefault([]).withOptions({ history: 'replace' }), +} From 9a80174b2a57f0d716022b1f47ba5b202ab4e2ed Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Thu, 8 Jan 2026 22:15:52 +0800 Subject: [PATCH 26/58] update --- .../plugins/marketplace/marketplace-client.tsx | 6 +++--- web/context/query-client-server.ts | 4 ---- web/context/query-client.tsx | 9 +-------- 3 files changed, 4 insertions(+), 15 deletions(-) diff --git a/web/app/components/plugins/marketplace/marketplace-client.tsx b/web/app/components/plugins/marketplace/marketplace-client.tsx index a744b6a1a275dd..35682cee7dec8d 100644 --- a/web/app/components/plugins/marketplace/marketplace-client.tsx +++ b/web/app/components/plugins/marketplace/marketplace-client.tsx @@ -2,7 +2,7 @@ import type { DehydratedState } from '@tanstack/react-query' import { HydrationBoundary } from '@tanstack/react-query' -import { TanstackQueryInner } from '@/context/query-client' +import { TanstackQueryInitializer } from '@/context/query-client' import Description from './description' import ListWrapper from './list/list-wrapper' import StickySearchAndSwitchWrapper from './sticky-search-and-switch-wrapper' @@ -19,7 +19,7 @@ export function MarketplaceClient({ dehydratedState, }: MarketplaceClientProps) { return ( - + - + ) } diff --git a/web/context/query-client-server.ts b/web/context/query-client-server.ts index bc46529e6660cf..124a50cd74dfa5 100644 --- a/web/context/query-client-server.ts +++ b/web/context/query-client-server.ts @@ -13,8 +13,4 @@ export function makeQueryClient() { }) } -/** - * Get QueryClient for server components - * Uses React cache() to ensure the same instance is reused within a single request - */ export const getQueryClient = cache(makeQueryClient) diff --git a/web/context/query-client.tsx b/web/context/query-client.tsx index bec6e11c861c2b..0968439580c02d 100644 --- a/web/context/query-client.tsx +++ b/web/context/query-client.tsx @@ -11,16 +11,14 @@ let browserQueryClient: QueryClient | undefined function getQueryClient() { if (typeof window === 'undefined') { - // Server: always make a new query client return makeQueryClient() } - // Browser: make a new query client if we don't already have one if (!browserQueryClient) browserQueryClient = makeQueryClient() return browserQueryClient } -export const TanstackQueryInner: FC = ({ children }) => { +export const TanstackQueryInitializer: FC = ({ children }) => { // Use useState to ensure stable QueryClient across re-renders const [queryClient] = useState(getQueryClient) return ( @@ -30,8 +28,3 @@ export const TanstackQueryInner: FC = ({ children }) => { ) } - -/** - * @deprecated Use TanstackQueryInner instead for new code - */ -export const TanstackQueryInitializer = TanstackQueryInner From 1b7d182c8c9e2e50b7134ad4efca624791c3ad53 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Thu, 8 Jan 2026 22:16:56 +0800 Subject: [PATCH 27/58] update --- web/app/components/plugins/marketplace/index.tsx | 4 ++-- web/context/query-client-server.ts | 2 +- web/context/query-client.tsx | 1 - 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/web/app/components/plugins/marketplace/index.tsx b/web/app/components/plugins/marketplace/index.tsx index f988dc503595e8..4bbe7d29fb6c7b 100644 --- a/web/app/components/plugins/marketplace/index.tsx +++ b/web/app/components/plugins/marketplace/index.tsx @@ -2,7 +2,7 @@ import type { DehydratedState } from '@tanstack/react-query' import type { SearchParams } from 'nuqs' import { dehydrate } from '@tanstack/react-query' import { createLoader } from 'nuqs/server' -import { getQueryClient } from '@/context/query-client-server' +import { getQueryClientServer } from '@/context/query-client-server' import { MarketplaceClient } from './marketplace-client' import { marketplaceKeys } from './query-keys' import { marketplaceSearchParamsParsers } from './search-params' @@ -27,7 +27,7 @@ async function Marketplace({ if (searchParams) { const loadSearchParams = createLoader(marketplaceSearchParamsParsers) const params = await loadSearchParams(searchParams) - const queryClient = getQueryClient() + const queryClient = getQueryClientServer() await queryClient.prefetchQuery({ queryKey: marketplaceKeys.collections(getCollectionsParams(params.category)), diff --git a/web/context/query-client-server.ts b/web/context/query-client-server.ts index 124a50cd74dfa5..3650e30f52c924 100644 --- a/web/context/query-client-server.ts +++ b/web/context/query-client-server.ts @@ -13,4 +13,4 @@ export function makeQueryClient() { }) } -export const getQueryClient = cache(makeQueryClient) +export const getQueryClientServer = cache(makeQueryClient) diff --git a/web/context/query-client.tsx b/web/context/query-client.tsx index 0968439580c02d..a72393490c27ab 100644 --- a/web/context/query-client.tsx +++ b/web/context/query-client.tsx @@ -19,7 +19,6 @@ function getQueryClient() { } export const TanstackQueryInitializer: FC = ({ children }) => { - // Use useState to ensure stable QueryClient across re-renders const [queryClient] = useState(getQueryClient) return ( From e509a9b44205a2a657e83562fbbf75d5f14751f9 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Thu, 8 Jan 2026 22:33:45 +0800 Subject: [PATCH 28/58] update --- .../components/plugins/marketplace/index.tsx | 30 +++++++++++------ .../marketplace/marketplace-client.tsx | 32 ------------------- 2 files changed, 21 insertions(+), 41 deletions(-) delete mode 100644 web/app/components/plugins/marketplace/marketplace-client.tsx diff --git a/web/app/components/plugins/marketplace/index.tsx b/web/app/components/plugins/marketplace/index.tsx index 4bbe7d29fb6c7b..0564c57a82ab3a 100644 --- a/web/app/components/plugins/marketplace/index.tsx +++ b/web/app/components/plugins/marketplace/index.tsx @@ -1,11 +1,14 @@ import type { DehydratedState } from '@tanstack/react-query' import type { SearchParams } from 'nuqs' -import { dehydrate } from '@tanstack/react-query' +import { dehydrate, HydrationBoundary } from '@tanstack/react-query' import { createLoader } from 'nuqs/server' +import { TanstackQueryInitializer } from '@/context/query-client' import { getQueryClientServer } from '@/context/query-client-server' -import { MarketplaceClient } from './marketplace-client' +import Description from './description' +import ListWrapper from './list/list-wrapper' import { marketplaceKeys } from './query-keys' import { marketplaceSearchParamsParsers } from './search-params' +import StickySearchAndSwitchWrapper from './sticky-search-and-switch-wrapper' import { getCollectionsParams, getMarketplaceCollectionsAndPlugins } from './utils' type MarketplaceProps = { @@ -17,11 +20,14 @@ type MarketplaceProps = { searchParams?: Promise } -async function Marketplace({ +/** + * TODO: This server component should move to marketplace's codebase so that we can get rid of Next.js + */ +const Marketplace = async ({ showInstallButton = true, pluginTypeSwitchClassName, searchParams, -}: MarketplaceProps) { +}: MarketplaceProps) => { let dehydratedState: DehydratedState | undefined if (searchParams) { @@ -37,11 +43,17 @@ async function Marketplace({ } return ( - + + + + + + + ) } diff --git a/web/app/components/plugins/marketplace/marketplace-client.tsx b/web/app/components/plugins/marketplace/marketplace-client.tsx deleted file mode 100644 index 35682cee7dec8d..00000000000000 --- a/web/app/components/plugins/marketplace/marketplace-client.tsx +++ /dev/null @@ -1,32 +0,0 @@ -'use client' - -import type { DehydratedState } from '@tanstack/react-query' -import { HydrationBoundary } from '@tanstack/react-query' -import { TanstackQueryInitializer } from '@/context/query-client' -import Description from './description' -import ListWrapper from './list/list-wrapper' -import StickySearchAndSwitchWrapper from './sticky-search-and-switch-wrapper' - -export type MarketplaceClientProps = { - showInstallButton?: boolean - pluginTypeSwitchClassName?: string - dehydratedState?: DehydratedState -} - -export function MarketplaceClient({ - showInstallButton = true, - pluginTypeSwitchClassName, - dehydratedState, -}: MarketplaceClientProps) { - return ( - - - - - - - - ) -} From bd73c33b1aeb71f43cfffee50d747949667a5921 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Thu, 8 Jan 2026 22:36:52 +0800 Subject: [PATCH 29/58] update --- web/app/components/plugins/marketplace/atoms.ts | 8 ++++---- web/app/components/plugins/marketplace/hooks.ts | 16 ++++++++-------- .../plugins/marketplace/plugin-type-switch.tsx | 4 ++-- .../search-box/search-box-wrapper.tsx | 6 +++--- 4 files changed, 17 insertions(+), 17 deletions(-) diff --git a/web/app/components/plugins/marketplace/atoms.ts b/web/app/components/plugins/marketplace/atoms.ts index 3a6f000fb1a6b7..24bb50d6a57fd0 100644 --- a/web/app/components/plugins/marketplace/atoms.ts +++ b/web/app/components/plugins/marketplace/atoms.ts @@ -1,7 +1,7 @@ import type { PluginsSort } from './types' import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai' import { DEFAULT_SORT, PLUGIN_CATEGORY_WITH_COLLECTIONS } from './constants' -import { useMarketplaceCategory, useMarketplaceSearchQuery, useMarketplaceTags } from './hooks' +import { useActivePluginType, useFilterPluginTags, useSearchPluginText } from './hooks' const marketplaceSortAtom = atom(DEFAULT_SORT) @@ -20,9 +20,9 @@ export function useSetMarketplaceSort() { const searchModeAtom = atom(null) export function useMarketplaceSearchMode() { - const [searchPluginText] = useMarketplaceSearchQuery() - const [filterPluginTags] = useMarketplaceTags() - const [activePluginType] = useMarketplaceCategory() + const [searchPluginText] = useSearchPluginText() + const [filterPluginTags] = useFilterPluginTags() + const [activePluginType] = useActivePluginType() const searchMode = useAtomValue(searchModeAtom) const isSearchMode = !!searchPluginText diff --git a/web/app/components/plugins/marketplace/hooks.ts b/web/app/components/plugins/marketplace/hooks.ts index 214c113f7a1f3b..377891cfee180f 100644 --- a/web/app/components/plugins/marketplace/hooks.ts +++ b/web/app/components/plugins/marketplace/hooks.ts @@ -36,13 +36,13 @@ import { getMarketplacePluginsByCollectionId, } from './utils' -export function useMarketplaceSearchQuery() { +export function useSearchPluginText() { return useQueryState('q', marketplaceSearchParamsParsers.q) } -export function useMarketplaceCategory() { +export function useActivePluginType() { return useQueryState('category', marketplaceSearchParamsParsers.category) } -export function useMarketplaceTags() { +export function useFilterPluginTags() { return useQueryState('tags', marketplaceSearchParamsParsers.tags) } @@ -55,7 +55,7 @@ export const useMarketplaceCollectionsAndPlugins = (queryParams?: CollectionsAnd } export function useMarketplaceCollectionsData() { - const [activePluginType] = useMarketplaceCategory() + const [activePluginType] = useActivePluginType() const collectionsParams = useMemo(() => getCollectionsParams(activePluginType), [activePluginType]) @@ -274,9 +274,9 @@ export type { MarketplaceCollection, PluginsSearchParams } export function useMarketplacePluginsData() { const sort = useMarketplaceSortValue() - const [searchPluginText] = useMarketplaceSearchQuery() - const [filterPluginTags] = useMarketplaceTags() - const [activePluginType] = useMarketplaceCategory() + const [searchPluginText] = useSearchPluginText() + const [filterPluginTags] = useFilterPluginTags() + const [activePluginType] = useActivePluginType() const isSearchMode = useMarketplaceSearchMode() @@ -335,7 +335,7 @@ export function useMarketplaceData() { } export function useMarketplaceMoreClick() { - const [,setQ] = useMarketplaceSearchQuery() + const [,setQ] = useSearchPluginText() const setSort = useSetMarketplaceSort() const setSearchMode = useSetSearchMode() diff --git a/web/app/components/plugins/marketplace/plugin-type-switch.tsx b/web/app/components/plugins/marketplace/plugin-type-switch.tsx index 935ab162f23417..635d0a946b9b19 100644 --- a/web/app/components/plugins/marketplace/plugin-type-switch.tsx +++ b/web/app/components/plugins/marketplace/plugin-type-switch.tsx @@ -12,7 +12,7 @@ import { Trigger as TriggerIcon } from '@/app/components/base/icons/src/vender/p import { cn } from '@/utils/classnames' import { useSetSearchMode } from './atoms' import { PLUGIN_CATEGORY_WITH_COLLECTIONS, PLUGIN_TYPE_SEARCH_MAP } from './constants' -import { useMarketplaceCategory } from './hooks' +import { useActivePluginType } from './hooks' type PluginTypeSwitchProps = { className?: string @@ -21,7 +21,7 @@ const PluginTypeSwitch = ({ className, }: PluginTypeSwitchProps) => { const { t } = useTranslation() - const [activePluginType, handleActivePluginTypeChange] = useMarketplaceCategory() + const [activePluginType, handleActivePluginTypeChange] = useActivePluginType() const setSearchMode = useSetSearchMode() const options = [ diff --git a/web/app/components/plugins/marketplace/search-box/search-box-wrapper.tsx b/web/app/components/plugins/marketplace/search-box/search-box-wrapper.tsx index 78560d582a9e6c..801ff5b5fbdd6f 100644 --- a/web/app/components/plugins/marketplace/search-box/search-box-wrapper.tsx +++ b/web/app/components/plugins/marketplace/search-box/search-box-wrapper.tsx @@ -1,13 +1,13 @@ 'use client' import { useTranslation } from '#i18n' -import { useMarketplaceSearchQuery, useMarketplaceTags } from '../hooks' +import { useFilterPluginTags, useSearchPluginText } from '../hooks' import SearchBox from './index' const SearchBoxWrapper = () => { const { t } = useTranslation() - const [searchPluginText, handleSearchPluginTextChange] = useMarketplaceSearchQuery() - const [filterPluginTags, handleFilterPluginTagsChange] = useMarketplaceTags() + const [searchPluginText, handleSearchPluginTextChange] = useSearchPluginText() + const [filterPluginTags, handleFilterPluginTagsChange] = useFilterPluginTags() return ( Date: Thu, 8 Jan 2026 22:38:20 +0800 Subject: [PATCH 30/58] note --- web/app/components/plugins/marketplace/atoms.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/web/app/components/plugins/marketplace/atoms.ts b/web/app/components/plugins/marketplace/atoms.ts index 24bb50d6a57fd0..279ca662d762c4 100644 --- a/web/app/components/plugins/marketplace/atoms.ts +++ b/web/app/components/plugins/marketplace/atoms.ts @@ -17,6 +17,10 @@ export function useSetMarketplaceSort() { return useSetAtom(marketplaceSortAtom) } +/** + * Not all categories have collections, so we need to + * force the search mode for those categories. + */ const searchModeAtom = atom(null) export function useMarketplaceSearchMode() { From e04a45b8bae0fcf2d82589649f3e943f8bd750c4 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Thu, 8 Jan 2026 22:42:55 +0800 Subject: [PATCH 31/58] parseAsStringEnum --- web/app/components/plugins/marketplace/constants.ts | 2 ++ web/app/components/plugins/marketplace/search-params.ts | 6 ++++-- web/app/components/plugins/marketplace/utils.ts | 3 ++- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/web/app/components/plugins/marketplace/constants.ts b/web/app/components/plugins/marketplace/constants.ts index e6f005948bb827..a36881bba1d6b5 100644 --- a/web/app/components/plugins/marketplace/constants.ts +++ b/web/app/components/plugins/marketplace/constants.ts @@ -18,6 +18,8 @@ export const PLUGIN_TYPE_SEARCH_MAP = { bundle: 'bundle', } +export type ActivePluginType = keyof typeof PLUGIN_TYPE_SEARCH_MAP + export const PLUGIN_CATEGORY_WITH_COLLECTIONS = new Set( [ PLUGIN_TYPE_SEARCH_MAP.all, diff --git a/web/app/components/plugins/marketplace/search-params.ts b/web/app/components/plugins/marketplace/search-params.ts index 1cedf08551838c..ad0b16977f4f32 100644 --- a/web/app/components/plugins/marketplace/search-params.ts +++ b/web/app/components/plugins/marketplace/search-params.ts @@ -1,7 +1,9 @@ -import { parseAsArrayOf, parseAsString } from 'nuqs/server' +import type { ActivePluginType } from './constants' +import { parseAsArrayOf, parseAsString, parseAsStringEnum } from 'nuqs/server' +import { PLUGIN_TYPE_SEARCH_MAP } from './constants' export const marketplaceSearchParamsParsers = { - category: parseAsString.withDefault('all').withOptions({ history: 'replace', clearOnDefault: false }), + category: parseAsStringEnum(Object.values(PLUGIN_TYPE_SEARCH_MAP) as ActivePluginType[]).withDefault('all').withOptions({ history: 'replace', clearOnDefault: false }), q: parseAsString.withDefault('').withOptions({ history: 'replace' }), tags: parseAsArrayOf(parseAsString).withDefault([]).withOptions({ history: 'replace' }), } diff --git a/web/app/components/plugins/marketplace/utils.ts b/web/app/components/plugins/marketplace/utils.ts index 428747bd4349c8..087502125ec504 100644 --- a/web/app/components/plugins/marketplace/utils.ts +++ b/web/app/components/plugins/marketplace/utils.ts @@ -1,3 +1,4 @@ +import type { ActivePluginType } from './constants' import type { CollectionsAndPluginsSearchParams, MarketplaceCollection, @@ -153,7 +154,7 @@ export const getMarketplaceListFilterType = (category: string) => { return 'plugin' } -export function getCollectionsParams(category: string): CollectionsAndPluginsSearchParams { +export function getCollectionsParams(category: ActivePluginType): CollectionsAndPluginsSearchParams { if (category === PLUGIN_TYPE_SEARCH_MAP.all) { return {} } From df183b71ee4cc02fa0849e85e0317c507d31e9d3 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Thu, 8 Jan 2026 22:51:27 +0800 Subject: [PATCH 32/58] upda --- .../components/plugins/marketplace/index.tsx | 38 +++++++++++-------- .../components/plugins/marketplace/utils.ts | 2 +- 2 files changed, 24 insertions(+), 16 deletions(-) diff --git a/web/app/components/plugins/marketplace/index.tsx b/web/app/components/plugins/marketplace/index.tsx index 0564c57a82ab3a..b7b67731ab045c 100644 --- a/web/app/components/plugins/marketplace/index.tsx +++ b/web/app/components/plugins/marketplace/index.tsx @@ -1,9 +1,9 @@ -import type { DehydratedState } from '@tanstack/react-query' import type { SearchParams } from 'nuqs' import { dehydrate, HydrationBoundary } from '@tanstack/react-query' import { createLoader } from 'nuqs/server' import { TanstackQueryInitializer } from '@/context/query-client' import { getQueryClientServer } from '@/context/query-client-server' +import { PLUGIN_CATEGORY_WITH_COLLECTIONS } from './constants' import Description from './description' import ListWrapper from './list/list-wrapper' import { marketplaceKeys } from './query-keys' @@ -21,26 +21,14 @@ type MarketplaceProps = { } /** - * TODO: This server component should move to marketplace's codebase so that we can get rid of Next.js + * TODO: The server side logic should move to marketplace's codebase so that we can get rid of Next.js */ const Marketplace = async ({ showInstallButton = true, pluginTypeSwitchClassName, searchParams, }: MarketplaceProps) => { - let dehydratedState: DehydratedState | undefined - - if (searchParams) { - const loadSearchParams = createLoader(marketplaceSearchParamsParsers) - const params = await loadSearchParams(searchParams) - const queryClient = getQueryClientServer() - - await queryClient.prefetchQuery({ - queryKey: marketplaceKeys.collections(getCollectionsParams(params.category)), - queryFn: () => getMarketplaceCollectionsAndPlugins(getCollectionsParams(params.category)), - }) - dehydratedState = dehydrate(queryClient) - } + const dehydratedState = await getDehydratedState(searchParams) return ( @@ -58,3 +46,23 @@ const Marketplace = async ({ } export default Marketplace + +async function getDehydratedState(searchParams?: Promise) { + if (!searchParams) { + return + } + const loadSearchParams = createLoader(marketplaceSearchParamsParsers) + const params = await loadSearchParams(searchParams) + + if (!PLUGIN_CATEGORY_WITH_COLLECTIONS.has(params.category)) { + return + } + + const queryClient = getQueryClientServer() + + await queryClient.prefetchQuery({ + queryKey: marketplaceKeys.collections(getCollectionsParams(params.category)), + queryFn: () => getMarketplaceCollectionsAndPlugins(getCollectionsParams(params.category)), + }) + return dehydrate(queryClient) +} diff --git a/web/app/components/plugins/marketplace/utils.ts b/web/app/components/plugins/marketplace/utils.ts index 087502125ec504..79e06b9637b737 100644 --- a/web/app/components/plugins/marketplace/utils.ts +++ b/web/app/components/plugins/marketplace/utils.ts @@ -144,7 +144,7 @@ export const getMarketplaceListCondition = (pluginType: string) => { return '' } -export const getMarketplaceListFilterType = (category: string) => { +export const getMarketplaceListFilterType = (category: ActivePluginType) => { if (category === PLUGIN_TYPE_SEARCH_MAP.all) return undefined From 908d649927b1bdf68cd547e1403162b632bbdc95 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Thu, 8 Jan 2026 23:01:03 +0800 Subject: [PATCH 33/58] value of --- web/app/components/plugins/marketplace/constants.ts | 8 +++++--- .../components/plugins/marketplace/plugin-type-switch.tsx | 7 ++++++- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/web/app/components/plugins/marketplace/constants.ts b/web/app/components/plugins/marketplace/constants.ts index a36881bba1d6b5..6613fbe3de12f5 100644 --- a/web/app/components/plugins/marketplace/constants.ts +++ b/web/app/components/plugins/marketplace/constants.ts @@ -16,11 +16,13 @@ export const PLUGIN_TYPE_SEARCH_MAP = { datasource: PluginCategoryEnum.datasource, trigger: PluginCategoryEnum.trigger, bundle: 'bundle', -} +} as const + +type ValueOf = T[keyof T] -export type ActivePluginType = keyof typeof PLUGIN_TYPE_SEARCH_MAP +export type ActivePluginType = ValueOf -export const PLUGIN_CATEGORY_WITH_COLLECTIONS = new Set( +export const PLUGIN_CATEGORY_WITH_COLLECTIONS = new Set( [ PLUGIN_TYPE_SEARCH_MAP.all, PLUGIN_TYPE_SEARCH_MAP.tool, diff --git a/web/app/components/plugins/marketplace/plugin-type-switch.tsx b/web/app/components/plugins/marketplace/plugin-type-switch.tsx index 635d0a946b9b19..d86aecbd2525fe 100644 --- a/web/app/components/plugins/marketplace/plugin-type-switch.tsx +++ b/web/app/components/plugins/marketplace/plugin-type-switch.tsx @@ -1,4 +1,5 @@ 'use client' +import type { ActivePluginType } from './constants' import { useTranslation } from '#i18n' import { RiArchive2Line, @@ -24,7 +25,11 @@ const PluginTypeSwitch = ({ const [activePluginType, handleActivePluginTypeChange] = useActivePluginType() const setSearchMode = useSetSearchMode() - const options = [ + const options: Array<{ + value: ActivePluginType + text: string + icon: React.ReactNode | null + }> = [ { value: PLUGIN_TYPE_SEARCH_MAP.all, text: t('category.all', { ns: 'plugin' }), From ff97344e70428dc1ad721d28a648e833ff01001d Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Thu, 8 Jan 2026 23:03:31 +0800 Subject: [PATCH 34/58] update --- web/app/components/plugins/marketplace/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/app/components/plugins/marketplace/utils.ts b/web/app/components/plugins/marketplace/utils.ts index 79e06b9637b737..65d2c3804e9d3e 100644 --- a/web/app/components/plugins/marketplace/utils.ts +++ b/web/app/components/plugins/marketplace/utils.ts @@ -159,7 +159,7 @@ export function getCollectionsParams(category: ActivePluginType): CollectionsAnd return {} } return { - category: category === PLUGIN_TYPE_SEARCH_MAP.all ? undefined : category, + category, condition: getMarketplaceListCondition(category), type: getMarketplaceListFilterType(category), } From 16b7ae9bae22d1943ccdccd9482c57da57df1463 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Thu, 8 Jan 2026 23:13:56 +0800 Subject: [PATCH 35/58] deprecated --- .../components/plugins/marketplace/hooks.ts | 58 ++++++++++++--- web/app/components/tools/marketplace/hooks.ts | 74 +++++++++---------- 2 files changed, 83 insertions(+), 49 deletions(-) diff --git a/web/app/components/plugins/marketplace/hooks.ts b/web/app/components/plugins/marketplace/hooks.ts index 377891cfee180f..3da8f9284fb71b 100644 --- a/web/app/components/plugins/marketplace/hooks.ts +++ b/web/app/components/plugins/marketplace/hooks.ts @@ -36,17 +36,42 @@ import { getMarketplacePluginsByCollectionId, } from './utils' -export function useSearchPluginText() { - return useQueryState('q', marketplaceSearchParamsParsers.q) -} -export function useActivePluginType() { - return useQueryState('category', marketplaceSearchParamsParsers.category) -} -export function useFilterPluginTags() { - return useQueryState('tags', marketplaceSearchParamsParsers.tags) +/** + * @deprecated use useMarketplaceCollectionsAndPluginsReactive instead + */ +export const useMarketplaceCollectionsAndPlugins = () => { + const [queryParams, setQueryParams] = useState() + const [marketplaceCollectionsOverride, setMarketplaceCollections] = useState() + const [marketplaceCollectionPluginsMapOverride, setMarketplaceCollectionPluginsMap] = useState>() + + const { + data, + isFetching, + isSuccess, + isPending, + } = useQuery({ + queryKey: marketplaceKeys.collections(queryParams), + queryFn: ({ signal }) => getMarketplaceCollectionsAndPlugins(queryParams, { signal }), + enabled: queryParams !== undefined, + }) + + const queryMarketplaceCollectionsAndPlugins = useCallback((query?: CollectionsAndPluginsSearchParams) => { + setQueryParams(query ? { ...query } : {}) + }, []) + const isLoading = !!queryParams && (isFetching || isPending) + + return { + marketplaceCollections: marketplaceCollectionsOverride ?? data?.marketplaceCollections, + setMarketplaceCollections, + marketplaceCollectionPluginsMap: marketplaceCollectionPluginsMapOverride ?? data?.marketplaceCollectionPluginsMap, + setMarketplaceCollectionPluginsMap, + queryMarketplaceCollectionsAndPlugins, + isLoading, + isSuccess, + } } -export const useMarketplaceCollectionsAndPlugins = (queryParams?: CollectionsAndPluginsSearchParams) => { +export const useMarketplaceCollectionsAndPluginsReactive = (queryParams?: CollectionsAndPluginsSearchParams) => { return useQuery({ queryKey: marketplaceKeys.collections(queryParams), queryFn: ({ signal }) => getMarketplaceCollectionsAndPlugins(queryParams, { signal }), @@ -59,7 +84,7 @@ export function useMarketplaceCollectionsData() { const collectionsParams = useMemo(() => getCollectionsParams(activePluginType), [activePluginType]) - const { data, isLoading } = useMarketplaceCollectionsAndPlugins(collectionsParams) + const { data, isLoading } = useMarketplaceCollectionsAndPluginsReactive(collectionsParams) return { marketplaceCollections: data?.marketplaceCollections, @@ -152,6 +177,9 @@ async function fetchMarketplacePlugins( } } +/** + * @deprecated use useMarketplacePluginsReactive instead + */ export function useMarketplacePlugins(initialParams?: PluginsSearchParams) { const queryClient = useQueryClient() const [queryParams, handleUpdatePlugins] = useState(initialParams) @@ -352,3 +380,13 @@ export function useMarketplaceMoreClick() { setSearchMode(true) }, [setQ, setSort, setSearchMode]) } + +export function useSearchPluginText() { + return useQueryState('q', marketplaceSearchParamsParsers.q) +} +export function useActivePluginType() { + return useQueryState('category', marketplaceSearchParamsParsers.category) +} +export function useFilterPluginTags() { + return useQueryState('tags', marketplaceSearchParamsParsers.tags) +} diff --git a/web/app/components/tools/marketplace/hooks.ts b/web/app/components/tools/marketplace/hooks.ts index c9edfd22153ff0..6db444f012a1d8 100644 --- a/web/app/components/tools/marketplace/hooks.ts +++ b/web/app/components/tools/marketplace/hooks.ts @@ -13,28 +13,18 @@ import { getMarketplaceListCondition } from '@/app/components/plugins/marketplac import { PluginCategoryEnum } from '@/app/components/plugins/types' import { useAllToolProviders } from '@/service/use-tools' -export function useMarketplace(searchPluginText: string, filterPluginTags: string[]) { +export const useMarketplace = (searchPluginText: string, filterPluginTags: string[]) => { const { data: toolProvidersData, isSuccess } = useAllToolProviders() const exclude = useMemo(() => { if (isSuccess) return toolProvidersData?.filter(toolProvider => !!toolProvider.plugin_id).map(toolProvider => toolProvider.plugin_id!) - return undefined }, [isSuccess, toolProvidersData]) - - const isSearchMode = !!searchPluginText || filterPluginTags.length > 0 - - // Collections query (only when not searching) - const collectionsQuery = useMarketplaceCollectionsAndPlugins( - { - category: PluginCategoryEnum.tool, - condition: getMarketplaceListCondition(PluginCategoryEnum.tool), - exclude, - type: 'plugin', - }, - { enabled: !isSearchMode && isSuccess }, - ) - - // Plugins search + const { + isLoading, + marketplaceCollections, + marketplaceCollectionPluginsMap, + queryMarketplaceCollectionsAndPlugins, + } = useMarketplaceCollectionsAndPlugins() const { plugins, resetPlugins, @@ -45,7 +35,6 @@ export function useMarketplace(searchPluginText: string, filterPluginTags: strin hasNextPage, page: pluginsPage, } = useMarketplacePlugins() - const searchPluginTextRef = useRef(searchPluginText) const filterPluginTagsRef = useRef(filterPluginTags) @@ -53,12 +42,8 @@ export function useMarketplace(searchPluginText: string, filterPluginTags: strin searchPluginTextRef.current = searchPluginText filterPluginTagsRef.current = filterPluginTags }, [searchPluginText, filterPluginTags]) - useEffect(() => { - if (!isSuccess) - return - - if (isSearchMode) { + if ((searchPluginText || filterPluginTags.length) && isSuccess) { if (searchPluginText) { queryPluginsWithDebounced({ category: PluginCategoryEnum.tool, @@ -67,37 +52,48 @@ export function useMarketplace(searchPluginText: string, filterPluginTags: strin exclude, type: 'plugin', }) + return } - else { - queryPlugins({ + queryPlugins({ + category: PluginCategoryEnum.tool, + query: searchPluginText, + tags: filterPluginTags, + exclude, + type: 'plugin', + }) + } + else { + if (isSuccess) { + queryMarketplaceCollectionsAndPlugins({ category: PluginCategoryEnum.tool, - query: searchPluginText, - tags: filterPluginTags, + condition: getMarketplaceListCondition(PluginCategoryEnum.tool), exclude, type: 'plugin', }) + resetPlugins() } } - else { - resetPlugins() - } - }, [searchPluginText, filterPluginTags, queryPlugins, queryPluginsWithDebounced, resetPlugins, exclude, isSuccess, isSearchMode]) + }, [searchPluginText, filterPluginTags, queryPlugins, queryMarketplaceCollectionsAndPlugins, queryPluginsWithDebounced, resetPlugins, exclude, isSuccess]) const handleScroll = useCallback((e: Event) => { const target = e.target as HTMLDivElement - const { scrollTop, scrollHeight, clientHeight } = target + const { + scrollTop, + scrollHeight, + clientHeight, + } = target if (scrollTop + clientHeight >= scrollHeight - SCROLL_BOTTOM_THRESHOLD && scrollTop > 0) { - const searchText = searchPluginTextRef.current - const tags = filterPluginTagsRef.current - if (hasNextPage && (!!searchText || !!tags.length)) + const searchPluginText = searchPluginTextRef.current + const filterPluginTags = filterPluginTagsRef.current + if (hasNextPage && (!!searchPluginText || !!filterPluginTags.length)) fetchNextPage() } - }, [fetchNextPage, hasNextPage]) + }, [exclude, fetchNextPage, hasNextPage, plugins, queryPlugins]) return { - isLoading: collectionsQuery.isLoading || isPluginsLoading, - marketplaceCollections: collectionsQuery.data?.marketplaceCollections, - marketplaceCollectionPluginsMap: collectionsQuery.data?.marketplaceCollectionPluginsMap, + isLoading: isLoading || isPluginsLoading, + marketplaceCollections, + marketplaceCollectionPluginsMap, plugins, handleScroll, page: Math.max(pluginsPage || 0, 1), From 200b0283c8230bf5a4613eddabf48358ab31edbd Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Thu, 8 Jan 2026 23:25:16 +0800 Subject: [PATCH 36/58] update --- .../components/plugins/marketplace/hooks.ts | 108 ++++++++---------- 1 file changed, 50 insertions(+), 58 deletions(-) diff --git a/web/app/components/plugins/marketplace/hooks.ts b/web/app/components/plugins/marketplace/hooks.ts index 3da8f9284fb71b..05e08902200d10 100644 --- a/web/app/components/plugins/marketplace/hooks.ts +++ b/web/app/components/plugins/marketplace/hooks.ts @@ -36,9 +36,6 @@ import { getMarketplacePluginsByCollectionId, } from './utils' -/** - * @deprecated use useMarketplaceCollectionsAndPluginsReactive instead - */ export const useMarketplaceCollectionsAndPlugins = () => { const [queryParams, setQueryParams] = useState() const [marketplaceCollectionsOverride, setMarketplaceCollections] = useState() @@ -71,28 +68,6 @@ export const useMarketplaceCollectionsAndPlugins = () => { } } -export const useMarketplaceCollectionsAndPluginsReactive = (queryParams?: CollectionsAndPluginsSearchParams) => { - return useQuery({ - queryKey: marketplaceKeys.collections(queryParams), - queryFn: ({ signal }) => getMarketplaceCollectionsAndPlugins(queryParams, { signal }), - enabled: queryParams !== undefined, - }) -} - -export function useMarketplaceCollectionsData() { - const [activePluginType] = useActivePluginType() - - const collectionsParams = useMemo(() => getCollectionsParams(activePluginType), [activePluginType]) - - const { data, isLoading } = useMarketplaceCollectionsAndPluginsReactive(collectionsParams) - - return { - marketplaceCollections: data?.marketplaceCollections, - marketplaceCollectionPluginsMap: data?.marketplaceCollectionPluginsMap, - isLoading, - } -} - export const useMarketplacePluginsByCollectionId = ( collectionId?: string, query?: CollectionsAndPluginsSearchParams, @@ -177,9 +152,6 @@ async function fetchMarketplacePlugins( } } -/** - * @deprecated use useMarketplacePluginsReactive instead - */ export function useMarketplacePlugins(initialParams?: PluginsSearchParams) { const queryClient = useQueryClient() const [queryParams, handleUpdatePlugins] = useState(initialParams) @@ -235,7 +207,56 @@ export function useMarketplacePlugins(initialParams?: PluginsSearchParams) { } } -export function useMarketplacePluginsReactive(queryParams?: PluginsSearchParams) { +export const useMarketplaceContainerScroll = ( + callback: () => void, + scrollContainerId = 'marketplace-container', +) => { + const handleScroll = useCallback((e: Event) => { + const target = e.target as HTMLDivElement + const { + scrollTop, + scrollHeight, + clientHeight, + } = target + if (scrollTop + clientHeight >= scrollHeight - SCROLL_BOTTOM_THRESHOLD && scrollTop > 0) + callback() + }, [callback]) + + useEffect(() => { + const container = document.getElementById(scrollContainerId) + if (container) + container.addEventListener('scroll', handleScroll) + + return () => { + if (container) + container.removeEventListener('scroll', handleScroll) + } + }, [handleScroll]) +} + +const useMarketplaceCollectionsAndPluginsReactive = (queryParams?: CollectionsAndPluginsSearchParams) => { + return useQuery({ + queryKey: marketplaceKeys.collections(queryParams), + queryFn: ({ signal }) => getMarketplaceCollectionsAndPlugins(queryParams, { signal }), + enabled: queryParams !== undefined, + }) +} + +function useMarketplaceCollectionsData() { + const [activePluginType] = useActivePluginType() + + const collectionsParams = useMemo(() => getCollectionsParams(activePluginType), [activePluginType]) + + const { data, isLoading } = useMarketplaceCollectionsAndPluginsReactive(collectionsParams) + + return { + marketplaceCollections: data?.marketplaceCollections, + marketplaceCollectionPluginsMap: data?.marketplaceCollectionPluginsMap, + isLoading, + } +} + +function useMarketplacePluginsReactive(queryParams?: PluginsSearchParams) { const marketplacePluginsQuery = useInfiniteQuery({ queryKey: marketplaceKeys.plugins(queryParams), queryFn: ({ pageParam = 1, signal }) => fetchMarketplacePlugins(queryParams, pageParam, signal), @@ -270,35 +291,6 @@ export function useMarketplacePluginsReactive(queryParams?: PluginsSearchParams) } } -export const useMarketplaceContainerScroll = ( - callback: () => void, - scrollContainerId = 'marketplace-container', -) => { - const handleScroll = useCallback((e: Event) => { - const target = e.target as HTMLDivElement - const { - scrollTop, - scrollHeight, - clientHeight, - } = target - if (scrollTop + clientHeight >= scrollHeight - SCROLL_BOTTOM_THRESHOLD && scrollTop > 0) - callback() - }, [callback]) - - useEffect(() => { - const container = document.getElementById(scrollContainerId) - if (container) - container.addEventListener('scroll', handleScroll) - - return () => { - if (container) - container.removeEventListener('scroll', handleScroll) - } - }, [handleScroll]) -} - -export type { MarketplaceCollection, PluginsSearchParams } - export function useMarketplacePluginsData() { const sort = useMarketplaceSortValue() From 3d2950e29ee9b8fcdb09a7e3aa0443808a760b58 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Thu, 8 Jan 2026 23:29:06 +0800 Subject: [PATCH 37/58] update --- .../components/plugins/marketplace/hooks.ts | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/web/app/components/plugins/marketplace/hooks.ts b/web/app/components/plugins/marketplace/hooks.ts index 05e08902200d10..33d16e25631f8b 100644 --- a/web/app/components/plugins/marketplace/hooks.ts +++ b/web/app/components/plugins/marketplace/hooks.ts @@ -94,6 +94,15 @@ export const useMarketplacePluginsByCollectionId = ( } } +const normalizeParams = (pluginsSearchParams: PluginsSearchParams) => { + const pageSize = pluginsSearchParams.pageSize || 40 + + return { + ...pluginsSearchParams, + pageSize, + } +} + async function fetchMarketplacePlugins( queryParams: PluginsSearchParams | undefined, pageParam: number, @@ -108,6 +117,7 @@ async function fetchMarketplacePlugins( } } + const params = normalizeParams(queryParams) const { query, sortBy, @@ -115,8 +125,8 @@ async function fetchMarketplacePlugins( category, tags, type, - pageSize = 40, - } = queryParams + pageSize, + } = params const pluginOrBundle = type === 'bundle' ? 'bundles' : 'plugins' try { @@ -152,9 +162,9 @@ async function fetchMarketplacePlugins( } } -export function useMarketplacePlugins(initialParams?: PluginsSearchParams) { +export function useMarketplacePlugins() { const queryClient = useQueryClient() - const [queryParams, handleUpdatePlugins] = useState(initialParams) + const [queryParams, setQueryParams] = useState() const marketplacePluginsQuery = useInfiniteQuery({ queryKey: marketplaceKeys.plugins(queryParams), @@ -169,12 +179,16 @@ export function useMarketplacePlugins(initialParams?: PluginsSearchParams) { }) const resetPlugins = useCallback(() => { - handleUpdatePlugins(undefined) + setQueryParams(undefined) queryClient.removeQueries({ queryKey: ['marketplacePlugins'], }) }, [queryClient]) + const handleUpdatePlugins = (pluginsSearchParams: PluginsSearchParams) => { + setQueryParams(normalizeParams(pluginsSearchParams)) + } + const { run: queryPluginsWithDebounced, cancel: cancelQueryPluginsWithDebounced } = useDebounceFn((pluginsSearchParams: PluginsSearchParams) => { handleUpdatePlugins(pluginsSearchParams) }, { From 0f46207c4d3616d6cc38bff5aa4f408f124598a2 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Thu, 8 Jan 2026 23:32:02 +0800 Subject: [PATCH 38/58] update --- .../components/plugins/marketplace/hooks.ts | 162 +----------------- .../marketplace/list/list-with-collection.tsx | 2 +- .../plugins/marketplace/list/list-wrapper.tsx | 2 +- 3 files changed, 5 insertions(+), 161 deletions(-) diff --git a/web/app/components/plugins/marketplace/hooks.ts b/web/app/components/plugins/marketplace/hooks.ts index 33d16e25631f8b..18bfd22c2fe24a 100644 --- a/web/app/components/plugins/marketplace/hooks.ts +++ b/web/app/components/plugins/marketplace/hooks.ts @@ -15,24 +15,17 @@ import { useQueryClient, } from '@tanstack/react-query' import { useDebounceFn } from 'ahooks' -import { useQueryState } from 'nuqs' - import { useCallback, useEffect, - useMemo, useState, } from 'react' import { postMarketplace } from '@/service/base' -import { useMarketplaceSearchMode, useMarketplaceSortValue, useSetMarketplaceSort, useSetSearchMode } from './atoms' -import { DEFAULT_SORT, PLUGIN_TYPE_SEARCH_MAP, SCROLL_BOTTOM_THRESHOLD } from './constants' +import { SCROLL_BOTTOM_THRESHOLD } from './constants' import { marketplaceKeys } from './query-keys' -import { marketplaceSearchParamsParsers } from './search-params' import { - getCollectionsParams, getFormattedPlugin, getMarketplaceCollectionsAndPlugins, - getMarketplaceListFilterType, getMarketplacePluginsByCollectionId, } from './utils' @@ -103,11 +96,11 @@ const normalizeParams = (pluginsSearchParams: PluginsSearchParams) => { } } -async function fetchMarketplacePlugins( +export const fetchMarketplacePlugins = async ( queryParams: PluginsSearchParams | undefined, pageParam: number, signal?: AbortSignal, -) { +) => { if (!queryParams) { return { plugins: [] as Plugin[], @@ -247,152 +240,3 @@ export const useMarketplaceContainerScroll = ( } }, [handleScroll]) } - -const useMarketplaceCollectionsAndPluginsReactive = (queryParams?: CollectionsAndPluginsSearchParams) => { - return useQuery({ - queryKey: marketplaceKeys.collections(queryParams), - queryFn: ({ signal }) => getMarketplaceCollectionsAndPlugins(queryParams, { signal }), - enabled: queryParams !== undefined, - }) -} - -function useMarketplaceCollectionsData() { - const [activePluginType] = useActivePluginType() - - const collectionsParams = useMemo(() => getCollectionsParams(activePluginType), [activePluginType]) - - const { data, isLoading } = useMarketplaceCollectionsAndPluginsReactive(collectionsParams) - - return { - marketplaceCollections: data?.marketplaceCollections, - marketplaceCollectionPluginsMap: data?.marketplaceCollectionPluginsMap, - isLoading, - } -} - -function useMarketplacePluginsReactive(queryParams?: PluginsSearchParams) { - const marketplacePluginsQuery = useInfiniteQuery({ - queryKey: marketplaceKeys.plugins(queryParams), - queryFn: ({ pageParam = 1, signal }) => fetchMarketplacePlugins(queryParams, pageParam, signal), - getNextPageParam: (lastPage) => { - const nextPage = lastPage.page + 1 - const loaded = lastPage.page * lastPage.pageSize - return loaded < (lastPage.total || 0) ? nextPage : undefined - }, - initialPageParam: 1, - enabled: !!queryParams, - }) - - const hasQuery = !!queryParams - const hasData = marketplacePluginsQuery.data !== undefined - const plugins = hasQuery && hasData - ? marketplacePluginsQuery.data.pages.flatMap(page => page.plugins) - : undefined - const total = hasQuery && hasData ? marketplacePluginsQuery.data.pages?.[0]?.total : undefined - const isPluginsLoading = hasQuery && ( - marketplacePluginsQuery.isPending - || (marketplacePluginsQuery.isFetching && !marketplacePluginsQuery.data) - ) - - return { - plugins, - total, - isLoading: isPluginsLoading, - isFetchingNextPage: marketplacePluginsQuery.isFetchingNextPage, - hasNextPage: marketplacePluginsQuery.hasNextPage, - fetchNextPage: marketplacePluginsQuery.fetchNextPage, - page: marketplacePluginsQuery.data?.pages?.length || (marketplacePluginsQuery.isPending && hasQuery ? 1 : 0), - } -} - -export function useMarketplacePluginsData() { - const sort = useMarketplaceSortValue() - - const [searchPluginText] = useSearchPluginText() - const [filterPluginTags] = useFilterPluginTags() - const [activePluginType] = useActivePluginType() - - const isSearchMode = useMarketplaceSearchMode() - - const queryParams = useMemo((): PluginsSearchParams | undefined => { - if (!isSearchMode) - return undefined - return { - query: searchPluginText, - category: activePluginType === PLUGIN_TYPE_SEARCH_MAP.all ? undefined : activePluginType, - tags: filterPluginTags, - sortBy: sort.sortBy, - sortOrder: sort.sortOrder, - type: getMarketplaceListFilterType(activePluginType), - } - }, [isSearchMode, searchPluginText, activePluginType, filterPluginTags, sort]) - - const { - plugins, - total: pluginsTotal, - isLoading: isPluginsLoading, - fetchNextPage, - hasNextPage, - page: pluginsPage, - } = useMarketplacePluginsReactive(queryParams) - - const handlePageChange = useCallback(() => { - if (hasNextPage) - fetchNextPage() - }, [fetchNextPage, hasNextPage]) - - // Scroll pagination - useMarketplaceContainerScroll(handlePageChange) - - return { - plugins, - pluginsTotal, - page: Math.max(pluginsPage, 1), - isLoading: isPluginsLoading, - } -} - -export function useMarketplaceData() { - const collectionsData = useMarketplaceCollectionsData() - const pluginsData = useMarketplacePluginsData() - - return { - marketplaceCollections: collectionsData.marketplaceCollections, - marketplaceCollectionPluginsMap: collectionsData.marketplaceCollectionPluginsMap, - - plugins: pluginsData.plugins, - pluginsTotal: pluginsData.pluginsTotal, - page: pluginsData.page, - - isLoading: collectionsData.isLoading || pluginsData.isLoading, - } -} - -export function useMarketplaceMoreClick() { - const [,setQ] = useSearchPluginText() - const setSort = useSetMarketplaceSort() - const setSearchMode = useSetSearchMode() - - return useCallback((searchParams?: { query?: string, sort_by?: string, sort_order?: string }) => { - if (!searchParams) - return - const newQuery = searchParams?.query || '' - const newSort = { - sortBy: searchParams?.sort_by || DEFAULT_SORT.sortBy, - sortOrder: searchParams?.sort_order || DEFAULT_SORT.sortOrder, - } - setQ(newQuery) - setSort(newSort) - setSearchMode(true) - }, [setQ, setSort, setSearchMode]) -} - -export function useSearchPluginText() { - return useQueryState('q', marketplaceSearchParamsParsers.q) -} -export function useActivePluginType() { - return useQueryState('category', marketplaceSearchParamsParsers.category) -} -export function useFilterPluginTags() { - return useQueryState('tags', marketplaceSearchParamsParsers.tags) -} diff --git a/web/app/components/plugins/marketplace/list/list-with-collection.tsx b/web/app/components/plugins/marketplace/list/list-with-collection.tsx index be1b29d8844d4a..f7424cc440bd7d 100644 --- a/web/app/components/plugins/marketplace/list/list-with-collection.tsx +++ b/web/app/components/plugins/marketplace/list/list-with-collection.tsx @@ -6,7 +6,7 @@ import { useLocale, useTranslation } from '#i18n' import { RiArrowRightSLine } from '@remixicon/react' import { getLanguage } from '@/i18n-config/language' import { cn } from '@/utils/classnames' -import { useMarketplaceMoreClick } from '../hooks' +import { useMarketplaceMoreClick } from '../state' import CardWrapper from './card-wrapper' type ListWithCollectionProps = { diff --git a/web/app/components/plugins/marketplace/list/list-wrapper.tsx b/web/app/components/plugins/marketplace/list/list-wrapper.tsx index f5eb73977976a2..a1b0c2529adca7 100644 --- a/web/app/components/plugins/marketplace/list/list-wrapper.tsx +++ b/web/app/components/plugins/marketplace/list/list-wrapper.tsx @@ -1,8 +1,8 @@ 'use client' import { useTranslation } from '#i18n' import Loading from '@/app/components/base/loading' -import { useMarketplaceData } from '../hooks' import SortDropdown from '../sort-dropdown' +import { useMarketplaceData } from '../state' import List from './index' type ListWrapperProps = { From 147d6e4589357787205b6e954b10d78e64ceda49 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Thu, 8 Jan 2026 23:32:08 +0800 Subject: [PATCH 39/58] update --- .../components/plugins/marketplace/state.ts | 159 ++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 web/app/components/plugins/marketplace/state.ts diff --git a/web/app/components/plugins/marketplace/state.ts b/web/app/components/plugins/marketplace/state.ts new file mode 100644 index 00000000000000..c6504159a574c1 --- /dev/null +++ b/web/app/components/plugins/marketplace/state.ts @@ -0,0 +1,159 @@ +import type { CollectionsAndPluginsSearchParams, PluginsSearchParams } from './types' +import { useInfiniteQuery, useQuery } from '@tanstack/react-query' +import { useQueryState } from 'nuqs' +import { useCallback, useMemo } from 'react' +import { useMarketplaceSearchMode, useMarketplaceSortValue, useSetMarketplaceSort, useSetSearchMode } from './atoms' +import { DEFAULT_SORT, PLUGIN_TYPE_SEARCH_MAP } from './constants' +import { fetchMarketplacePlugins, useMarketplaceContainerScroll } from './hooks' +import { marketplaceKeys } from './query-keys' +import { marketplaceSearchParamsParsers } from './search-params' +import { getCollectionsParams, getMarketplaceCollectionsAndPlugins, getMarketplaceListFilterType } from './utils' + +function useMarketplaceCollectionsAndPluginsReactive(queryParams?: CollectionsAndPluginsSearchParams) { + return useQuery({ + queryKey: marketplaceKeys.collections(queryParams), + queryFn: ({ signal }) => getMarketplaceCollectionsAndPlugins(queryParams, { signal }), + enabled: queryParams !== undefined, + }) +} + +function useMarketplaceCollectionsData() { + const [activePluginType] = useActivePluginType() + + const collectionsParams = useMemo(() => getCollectionsParams(activePluginType), [activePluginType]) + + const { data, isLoading } = useMarketplaceCollectionsAndPluginsReactive(collectionsParams) + + return { + marketplaceCollections: data?.marketplaceCollections, + marketplaceCollectionPluginsMap: data?.marketplaceCollectionPluginsMap, + isLoading, + } +} + +function useMarketplacePluginsReactive(queryParams?: PluginsSearchParams) { + const marketplacePluginsQuery = useInfiniteQuery({ + queryKey: marketplaceKeys.plugins(queryParams), + queryFn: ({ pageParam = 1, signal }) => fetchMarketplacePlugins(queryParams, pageParam, signal), + getNextPageParam: (lastPage) => { + const nextPage = lastPage.page + 1 + const loaded = lastPage.page * lastPage.pageSize + return loaded < (lastPage.total || 0) ? nextPage : undefined + }, + initialPageParam: 1, + enabled: !!queryParams, + }) + + const hasQuery = !!queryParams + const hasData = marketplacePluginsQuery.data !== undefined + const plugins = hasQuery && hasData + ? marketplacePluginsQuery.data.pages.flatMap(page => page.plugins) + : undefined + const total = hasQuery && hasData ? marketplacePluginsQuery.data.pages?.[0]?.total : undefined + const isPluginsLoading = hasQuery && ( + marketplacePluginsQuery.isPending + || (marketplacePluginsQuery.isFetching && !marketplacePluginsQuery.data) + ) + + return { + plugins, + total, + isLoading: isPluginsLoading, + isFetchingNextPage: marketplacePluginsQuery.isFetchingNextPage, + hasNextPage: marketplacePluginsQuery.hasNextPage, + fetchNextPage: marketplacePluginsQuery.fetchNextPage, + page: marketplacePluginsQuery.data?.pages?.length || (marketplacePluginsQuery.isPending && hasQuery ? 1 : 0), + } +} + +function useMarketplacePluginsData() { + const sort = useMarketplaceSortValue() + + const [searchPluginText] = useSearchPluginText() + const [filterPluginTags] = useFilterPluginTags() + const [activePluginType] = useActivePluginType() + + const isSearchMode = useMarketplaceSearchMode() + + const queryParams = useMemo((): PluginsSearchParams | undefined => { + if (!isSearchMode) + return undefined + return { + query: searchPluginText, + category: activePluginType === PLUGIN_TYPE_SEARCH_MAP.all ? undefined : activePluginType, + tags: filterPluginTags, + sortBy: sort.sortBy, + sortOrder: sort.sortOrder, + type: getMarketplaceListFilterType(activePluginType), + } + }, [isSearchMode, searchPluginText, activePluginType, filterPluginTags, sort]) + + const { + plugins, + total: pluginsTotal, + isLoading: isPluginsLoading, + fetchNextPage, + hasNextPage, + page: pluginsPage, + } = useMarketplacePluginsReactive(queryParams) + + const handlePageChange = useCallback(() => { + if (hasNextPage) + fetchNextPage() + }, [fetchNextPage, hasNextPage]) + + // Scroll pagination + useMarketplaceContainerScroll(handlePageChange) + + return { + plugins, + pluginsTotal, + page: Math.max(pluginsPage, 1), + isLoading: isPluginsLoading, + } +} + +export function useMarketplaceData() { + const collectionsData = useMarketplaceCollectionsData() + const pluginsData = useMarketplacePluginsData() + + return { + marketplaceCollections: collectionsData.marketplaceCollections, + marketplaceCollectionPluginsMap: collectionsData.marketplaceCollectionPluginsMap, + + plugins: pluginsData.plugins, + pluginsTotal: pluginsData.pluginsTotal, + page: pluginsData.page, + + isLoading: collectionsData.isLoading || pluginsData.isLoading, + } +} + +export function useMarketplaceMoreClick() { + const [,setQ] = useSearchPluginText() + const setSort = useSetMarketplaceSort() + const setSearchMode = useSetSearchMode() + + return useCallback((searchParams?: { query?: string, sort_by?: string, sort_order?: string }) => { + if (!searchParams) + return + const newQuery = searchParams?.query || '' + const newSort = { + sortBy: searchParams?.sort_by || DEFAULT_SORT.sortBy, + sortOrder: searchParams?.sort_order || DEFAULT_SORT.sortOrder, + } + setQ(newQuery) + setSort(newSort) + setSearchMode(true) + }, [setQ, setSort, setSearchMode]) +} + +export function useSearchPluginText() { + return useQueryState('q', marketplaceSearchParamsParsers.q) +} +export function useActivePluginType() { + return useQueryState('category', marketplaceSearchParamsParsers.category) +} +export function useFilterPluginTags() { + return useQueryState('tags', marketplaceSearchParamsParsers.tags) +} From effdcf9753d0dafd98ab4f42b74fce980ad28cff Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Thu, 8 Jan 2026 23:33:49 +0800 Subject: [PATCH 40/58] update --- web/app/components/plugins/marketplace/hooks.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/web/app/components/plugins/marketplace/hooks.ts b/web/app/components/plugins/marketplace/hooks.ts index 18bfd22c2fe24a..45cd547e2387ad 100644 --- a/web/app/components/plugins/marketplace/hooks.ts +++ b/web/app/components/plugins/marketplace/hooks.ts @@ -1,5 +1,3 @@ -'use client' - import type { Plugin, } from '../types' From 2f0cafbbf3d22650646a7771a9358d66ea03509c Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Thu, 8 Jan 2026 23:35:58 +0800 Subject: [PATCH 41/58] update --- web/app/components/plugins/marketplace/atoms.ts | 2 +- web/app/components/plugins/marketplace/plugin-type-switch.tsx | 2 +- .../plugins/marketplace/search-box/search-box-wrapper.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/web/app/components/plugins/marketplace/atoms.ts b/web/app/components/plugins/marketplace/atoms.ts index 279ca662d762c4..dce676d880f700 100644 --- a/web/app/components/plugins/marketplace/atoms.ts +++ b/web/app/components/plugins/marketplace/atoms.ts @@ -1,7 +1,7 @@ import type { PluginsSort } from './types' import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai' import { DEFAULT_SORT, PLUGIN_CATEGORY_WITH_COLLECTIONS } from './constants' -import { useActivePluginType, useFilterPluginTags, useSearchPluginText } from './hooks' +import { useActivePluginType, useFilterPluginTags, useSearchPluginText } from './state' const marketplaceSortAtom = atom(DEFAULT_SORT) diff --git a/web/app/components/plugins/marketplace/plugin-type-switch.tsx b/web/app/components/plugins/marketplace/plugin-type-switch.tsx index d86aecbd2525fe..319148c0f1f1c1 100644 --- a/web/app/components/plugins/marketplace/plugin-type-switch.tsx +++ b/web/app/components/plugins/marketplace/plugin-type-switch.tsx @@ -13,7 +13,7 @@ import { Trigger as TriggerIcon } from '@/app/components/base/icons/src/vender/p import { cn } from '@/utils/classnames' import { useSetSearchMode } from './atoms' import { PLUGIN_CATEGORY_WITH_COLLECTIONS, PLUGIN_TYPE_SEARCH_MAP } from './constants' -import { useActivePluginType } from './hooks' +import { useActivePluginType } from './state' type PluginTypeSwitchProps = { className?: string diff --git a/web/app/components/plugins/marketplace/search-box/search-box-wrapper.tsx b/web/app/components/plugins/marketplace/search-box/search-box-wrapper.tsx index 801ff5b5fbdd6f..516d9672749dac 100644 --- a/web/app/components/plugins/marketplace/search-box/search-box-wrapper.tsx +++ b/web/app/components/plugins/marketplace/search-box/search-box-wrapper.tsx @@ -1,7 +1,7 @@ 'use client' import { useTranslation } from '#i18n' -import { useFilterPluginTags, useSearchPluginText } from '../hooks' +import { useFilterPluginTags, useSearchPluginText } from '../state' import SearchBox from './index' const SearchBoxWrapper = () => { From d04a8dd22540dd1df2559fccef59965d1c4adee0 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Thu, 8 Jan 2026 23:38:20 +0800 Subject: [PATCH 42/58] update --- web/app/components/plugins/marketplace/state.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/web/app/components/plugins/marketplace/state.ts b/web/app/components/plugins/marketplace/state.ts index c6504159a574c1..605473452b4629 100644 --- a/web/app/components/plugins/marketplace/state.ts +++ b/web/app/components/plugins/marketplace/state.ts @@ -1,4 +1,4 @@ -import type { CollectionsAndPluginsSearchParams, PluginsSearchParams } from './types' +import type { CollectionsAndPluginsSearchParams, PluginsSearchParams, SearchParamsFromCollection } from './types' import { useInfiniteQuery, useQuery } from '@tanstack/react-query' import { useQueryState } from 'nuqs' import { useCallback, useMemo } from 'react' @@ -134,16 +134,14 @@ export function useMarketplaceMoreClick() { const setSort = useSetMarketplaceSort() const setSearchMode = useSetSearchMode() - return useCallback((searchParams?: { query?: string, sort_by?: string, sort_order?: string }) => { + return useCallback((searchParams?: SearchParamsFromCollection) => { if (!searchParams) return - const newQuery = searchParams?.query || '' - const newSort = { + setQ(searchParams?.query || '') + setSort({ sortBy: searchParams?.sort_by || DEFAULT_SORT.sortBy, sortOrder: searchParams?.sort_order || DEFAULT_SORT.sortOrder, - } - setQ(newQuery) - setSort(newSort) + }) setSearchMode(true) }, [setQ, setSort, setSearchMode]) } From 3d356d9987675515841cacc788b94fc75084f5f9 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Thu, 8 Jan 2026 23:41:38 +0800 Subject: [PATCH 43/58] update --- .../components/plugins/marketplace/hooks.ts | 74 +------------------ .../components/plugins/marketplace/state.ts | 4 +- .../components/plugins/marketplace/utils.ts | 62 +++++++++++++++- 3 files changed, 65 insertions(+), 75 deletions(-) diff --git a/web/app/components/plugins/marketplace/hooks.ts b/web/app/components/plugins/marketplace/hooks.ts index 45cd547e2387ad..2df107e707b371 100644 --- a/web/app/components/plugins/marketplace/hooks.ts +++ b/web/app/components/plugins/marketplace/hooks.ts @@ -6,7 +6,6 @@ import type { MarketplaceCollection, PluginsSearchParams, } from './types' -import type { PluginsFromMarketplaceResponse } from '@/app/components/plugins/types' import { useInfiniteQuery, useQuery, @@ -18,11 +17,10 @@ import { useEffect, useState, } from 'react' -import { postMarketplace } from '@/service/base' import { SCROLL_BOTTOM_THRESHOLD } from './constants' import { marketplaceKeys } from './query-keys' import { - getFormattedPlugin, + fetchMarketplacePlugins, getMarketplaceCollectionsAndPlugins, getMarketplacePluginsByCollectionId, } from './utils' @@ -85,74 +83,6 @@ export const useMarketplacePluginsByCollectionId = ( } } -const normalizeParams = (pluginsSearchParams: PluginsSearchParams) => { - const pageSize = pluginsSearchParams.pageSize || 40 - - return { - ...pluginsSearchParams, - pageSize, - } -} - -export const fetchMarketplacePlugins = async ( - queryParams: PluginsSearchParams | undefined, - pageParam: number, - signal?: AbortSignal, -) => { - if (!queryParams) { - return { - plugins: [] as Plugin[], - total: 0, - page: 1, - pageSize: 40, - } - } - - const params = normalizeParams(queryParams) - const { - query, - sortBy, - sortOrder, - category, - tags, - type, - pageSize, - } = params - const pluginOrBundle = type === 'bundle' ? 'bundles' : 'plugins' - - try { - const res = await postMarketplace<{ data: PluginsFromMarketplaceResponse }>(`/${pluginOrBundle}/search/advanced`, { - body: { - page: pageParam, - page_size: pageSize, - query, - sort_by: sortBy, - sort_order: sortOrder, - category: category !== 'all' ? category : '', - tags, - type, - }, - signal, - }) - const resPlugins = res.data.bundles || res.data.plugins || [] - - return { - plugins: resPlugins.map(plugin => getFormattedPlugin(plugin)), - total: res.data.total, - page: pageParam, - pageSize, - } - } - catch { - return { - plugins: [], - total: 0, - page: pageParam, - pageSize, - } - } -} - export function useMarketplacePlugins() { const queryClient = useQueryClient() const [queryParams, setQueryParams] = useState() @@ -177,7 +107,7 @@ export function useMarketplacePlugins() { }, [queryClient]) const handleUpdatePlugins = (pluginsSearchParams: PluginsSearchParams) => { - setQueryParams(normalizeParams(pluginsSearchParams)) + setQueryParams(pluginsSearchParams) } const { run: queryPluginsWithDebounced, cancel: cancelQueryPluginsWithDebounced } = useDebounceFn((pluginsSearchParams: PluginsSearchParams) => { diff --git a/web/app/components/plugins/marketplace/state.ts b/web/app/components/plugins/marketplace/state.ts index 605473452b4629..3b22d9d956c017 100644 --- a/web/app/components/plugins/marketplace/state.ts +++ b/web/app/components/plugins/marketplace/state.ts @@ -4,10 +4,10 @@ import { useQueryState } from 'nuqs' import { useCallback, useMemo } from 'react' import { useMarketplaceSearchMode, useMarketplaceSortValue, useSetMarketplaceSort, useSetSearchMode } from './atoms' import { DEFAULT_SORT, PLUGIN_TYPE_SEARCH_MAP } from './constants' -import { fetchMarketplacePlugins, useMarketplaceContainerScroll } from './hooks' +import { useMarketplaceContainerScroll } from './hooks' import { marketplaceKeys } from './query-keys' import { marketplaceSearchParamsParsers } from './search-params' -import { getCollectionsParams, getMarketplaceCollectionsAndPlugins, getMarketplaceListFilterType } from './utils' +import { fetchMarketplacePlugins, getCollectionsParams, getMarketplaceCollectionsAndPlugins, getMarketplaceListFilterType } from './utils' function useMarketplaceCollectionsAndPluginsReactive(queryParams?: CollectionsAndPluginsSearchParams) { return useQuery({ diff --git a/web/app/components/plugins/marketplace/utils.ts b/web/app/components/plugins/marketplace/utils.ts index 65d2c3804e9d3e..8066e852bd4372 100644 --- a/web/app/components/plugins/marketplace/utils.ts +++ b/web/app/components/plugins/marketplace/utils.ts @@ -2,14 +2,16 @@ import type { ActivePluginType } from './constants' import type { CollectionsAndPluginsSearchParams, MarketplaceCollection, + PluginsSearchParams, } from '@/app/components/plugins/marketplace/types' -import type { Plugin } from '@/app/components/plugins/types' +import type { Plugin, PluginsFromMarketplaceResponse } from '@/app/components/plugins/types' import { PluginCategoryEnum } from '@/app/components/plugins/types' import { APP_VERSION, IS_MARKETPLACE, MARKETPLACE_API_PREFIX, } from '@/config' +import { postMarketplace } from '@/service/base' import { getMarketplaceUrl } from '@/utils/var' import { PLUGIN_TYPE_SEARCH_MAP } from './constants' @@ -131,6 +133,64 @@ export const getMarketplaceCollectionsAndPlugins = async ( } } +export const fetchMarketplacePlugins = async ( + queryParams: PluginsSearchParams | undefined, + pageParam: number, + signal?: AbortSignal, +) => { + if (!queryParams) { + return { + plugins: [] as Plugin[], + total: 0, + page: 1, + pageSize: 40, + } + } + + const { + query, + sortBy, + sortOrder, + category, + tags, + type, + pageSize = 40, + } = queryParams + const pluginOrBundle = type === 'bundle' ? 'bundles' : 'plugins' + + try { + const res = await postMarketplace<{ data: PluginsFromMarketplaceResponse }>(`/${pluginOrBundle}/search/advanced`, { + body: { + page: pageParam, + page_size: pageSize, + query, + sort_by: sortBy, + sort_order: sortOrder, + category: category !== 'all' ? category : '', + tags, + type, + }, + signal, + }) + const resPlugins = res.data.bundles || res.data.plugins || [] + + return { + plugins: resPlugins.map(plugin => getFormattedPlugin(plugin)), + total: res.data.total, + page: pageParam, + pageSize, + } + } + catch { + return { + plugins: [], + total: 0, + page: pageParam, + pageSize, + } + } +} + export const getMarketplaceListCondition = (pluginType: string) => { if ([PluginCategoryEnum.tool, PluginCategoryEnum.agent, PluginCategoryEnum.model, PluginCategoryEnum.datasource, PluginCategoryEnum.trigger].includes(pluginType as PluginCategoryEnum)) return `category=${pluginType}` From 15cdafc8d1fe6538a3b18cd33ca2ccd922f61151 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Thu, 8 Jan 2026 23:43:16 +0800 Subject: [PATCH 44/58] update --- web/app/components/plugins/marketplace/hooks.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/app/components/plugins/marketplace/hooks.ts b/web/app/components/plugins/marketplace/hooks.ts index 2df107e707b371..b5de83e5474eec 100644 --- a/web/app/components/plugins/marketplace/hooks.ts +++ b/web/app/components/plugins/marketplace/hooks.ts @@ -83,7 +83,7 @@ export const useMarketplacePluginsByCollectionId = ( } } -export function useMarketplacePlugins() { +export const useMarketplacePlugins = () => { const queryClient = useQueryClient() const [queryParams, setQueryParams] = useState() @@ -106,9 +106,9 @@ export function useMarketplacePlugins() { }) }, [queryClient]) - const handleUpdatePlugins = (pluginsSearchParams: PluginsSearchParams) => { + const handleUpdatePlugins = useCallback((pluginsSearchParams: PluginsSearchParams) => { setQueryParams(pluginsSearchParams) - } + }, []) const { run: queryPluginsWithDebounced, cancel: cancelQueryPluginsWithDebounced } = useDebounceFn((pluginsSearchParams: PluginsSearchParams) => { handleUpdatePlugins(pluginsSearchParams) From 0115251df4f20b3dc69eebec3178ea29aed675ca Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Thu, 8 Jan 2026 23:44:36 +0800 Subject: [PATCH 45/58] update --- web/app/components/plugins/marketplace/hooks.ts | 4 ++-- web/app/components/plugins/marketplace/state.ts | 4 ++-- web/app/components/plugins/marketplace/utils.ts | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/web/app/components/plugins/marketplace/hooks.ts b/web/app/components/plugins/marketplace/hooks.ts index b5de83e5474eec..3a9e0a9c316d5d 100644 --- a/web/app/components/plugins/marketplace/hooks.ts +++ b/web/app/components/plugins/marketplace/hooks.ts @@ -20,8 +20,8 @@ import { import { SCROLL_BOTTOM_THRESHOLD } from './constants' import { marketplaceKeys } from './query-keys' import { - fetchMarketplacePlugins, getMarketplaceCollectionsAndPlugins, + getMarketplacePlugins, getMarketplacePluginsByCollectionId, } from './utils' @@ -89,7 +89,7 @@ export const useMarketplacePlugins = () => { const marketplacePluginsQuery = useInfiniteQuery({ queryKey: marketplaceKeys.plugins(queryParams), - queryFn: ({ pageParam = 1, signal }) => fetchMarketplacePlugins(queryParams, pageParam, signal), + queryFn: ({ pageParam = 1, signal }) => getMarketplacePlugins(queryParams, pageParam, signal), getNextPageParam: (lastPage) => { const nextPage = lastPage.page + 1 const loaded = lastPage.page * lastPage.pageSize diff --git a/web/app/components/plugins/marketplace/state.ts b/web/app/components/plugins/marketplace/state.ts index 3b22d9d956c017..91321fc34a9777 100644 --- a/web/app/components/plugins/marketplace/state.ts +++ b/web/app/components/plugins/marketplace/state.ts @@ -7,7 +7,7 @@ import { DEFAULT_SORT, PLUGIN_TYPE_SEARCH_MAP } from './constants' import { useMarketplaceContainerScroll } from './hooks' import { marketplaceKeys } from './query-keys' import { marketplaceSearchParamsParsers } from './search-params' -import { fetchMarketplacePlugins, getCollectionsParams, getMarketplaceCollectionsAndPlugins, getMarketplaceListFilterType } from './utils' +import { getCollectionsParams, getMarketplaceCollectionsAndPlugins, getMarketplaceListFilterType, getMarketplacePlugins } from './utils' function useMarketplaceCollectionsAndPluginsReactive(queryParams?: CollectionsAndPluginsSearchParams) { return useQuery({ @@ -34,7 +34,7 @@ function useMarketplaceCollectionsData() { function useMarketplacePluginsReactive(queryParams?: PluginsSearchParams) { const marketplacePluginsQuery = useInfiniteQuery({ queryKey: marketplaceKeys.plugins(queryParams), - queryFn: ({ pageParam = 1, signal }) => fetchMarketplacePlugins(queryParams, pageParam, signal), + queryFn: ({ pageParam = 1, signal }) => getMarketplacePlugins(queryParams, pageParam, signal), getNextPageParam: (lastPage) => { const nextPage = lastPage.page + 1 const loaded = lastPage.page * lastPage.pageSize diff --git a/web/app/components/plugins/marketplace/utils.ts b/web/app/components/plugins/marketplace/utils.ts index 8066e852bd4372..eaf299314c7371 100644 --- a/web/app/components/plugins/marketplace/utils.ts +++ b/web/app/components/plugins/marketplace/utils.ts @@ -133,7 +133,7 @@ export const getMarketplaceCollectionsAndPlugins = async ( } } -export const fetchMarketplacePlugins = async ( +export const getMarketplacePlugins = async ( queryParams: PluginsSearchParams | undefined, pageParam: number, signal?: AbortSignal, From cfb7e8fb06f94b4ff64ea61913028154d2e0db41 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Fri, 9 Jan 2026 00:10:37 +0800 Subject: [PATCH 46/58] update --- .../components/plugins/marketplace/hooks.ts | 9 +- .../components/plugins/marketplace/index.tsx | 2 +- .../plugins/marketplace/query-keys.ts | 8 -- .../components/plugins/marketplace/state.ts | 122 ++++-------------- 4 files changed, 31 insertions(+), 110 deletions(-) delete mode 100644 web/app/components/plugins/marketplace/query-keys.ts diff --git a/web/app/components/plugins/marketplace/hooks.ts b/web/app/components/plugins/marketplace/hooks.ts index 3a9e0a9c316d5d..b49a5ba82a752e 100644 --- a/web/app/components/plugins/marketplace/hooks.ts +++ b/web/app/components/plugins/marketplace/hooks.ts @@ -18,13 +18,16 @@ import { useState, } from 'react' import { SCROLL_BOTTOM_THRESHOLD } from './constants' -import { marketplaceKeys } from './query-keys' +import { marketplaceKeys } from './query' import { getMarketplaceCollectionsAndPlugins, getMarketplacePlugins, getMarketplacePluginsByCollectionId, } from './utils' +/** + * @deprecated Use useMarketplaceCollectionsAndPlugins from query.ts instead + */ export const useMarketplaceCollectionsAndPlugins = () => { const [queryParams, setQueryParams] = useState() const [marketplaceCollectionsOverride, setMarketplaceCollections] = useState() @@ -82,7 +85,9 @@ export const useMarketplacePluginsByCollectionId = ( isSuccess, } } - +/** + * @deprecated Use useMarketplacePlugins from query.ts instead + */ export const useMarketplacePlugins = () => { const queryClient = useQueryClient() const [queryParams, setQueryParams] = useState() diff --git a/web/app/components/plugins/marketplace/index.tsx b/web/app/components/plugins/marketplace/index.tsx index b7b67731ab045c..766f7b7ee7c299 100644 --- a/web/app/components/plugins/marketplace/index.tsx +++ b/web/app/components/plugins/marketplace/index.tsx @@ -6,7 +6,7 @@ import { getQueryClientServer } from '@/context/query-client-server' import { PLUGIN_CATEGORY_WITH_COLLECTIONS } from './constants' import Description from './description' import ListWrapper from './list/list-wrapper' -import { marketplaceKeys } from './query-keys' +import { marketplaceKeys } from './query' import { marketplaceSearchParamsParsers } from './search-params' import StickySearchAndSwitchWrapper from './sticky-search-and-switch-wrapper' import { getCollectionsParams, getMarketplaceCollectionsAndPlugins } from './utils' diff --git a/web/app/components/plugins/marketplace/query-keys.ts b/web/app/components/plugins/marketplace/query-keys.ts deleted file mode 100644 index 9308c766fe207f..00000000000000 --- a/web/app/components/plugins/marketplace/query-keys.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { CollectionsAndPluginsSearchParams, PluginsSearchParams } from './types' - -export const marketplaceKeys = { - all: ['marketplace'] as const, - collections: (params?: CollectionsAndPluginsSearchParams) => [...marketplaceKeys.all, 'collections', params] as const, - collectionPlugins: (collectionId: string, params?: CollectionsAndPluginsSearchParams) => [...marketplaceKeys.all, 'collectionPlugins', collectionId, params] as const, - plugins: (params?: PluginsSearchParams) => [...marketplaceKeys.all, 'plugins', params] as const, -} diff --git a/web/app/components/plugins/marketplace/state.ts b/web/app/components/plugins/marketplace/state.ts index 91321fc34a9777..11490e581eb266 100644 --- a/web/app/components/plugins/marketplace/state.ts +++ b/web/app/components/plugins/marketplace/state.ts @@ -1,80 +1,34 @@ -import type { CollectionsAndPluginsSearchParams, PluginsSearchParams, SearchParamsFromCollection } from './types' -import { useInfiniteQuery, useQuery } from '@tanstack/react-query' +import type { PluginsSearchParams, SearchParamsFromCollection } from './types' import { useQueryState } from 'nuqs' import { useCallback, useMemo } from 'react' import { useMarketplaceSearchMode, useMarketplaceSortValue, useSetMarketplaceSort, useSetSearchMode } from './atoms' import { DEFAULT_SORT, PLUGIN_TYPE_SEARCH_MAP } from './constants' import { useMarketplaceContainerScroll } from './hooks' -import { marketplaceKeys } from './query-keys' +import { useMarketplaceCollectionsAndPlugins, useMarketplacePlugins } from './query' import { marketplaceSearchParamsParsers } from './search-params' -import { getCollectionsParams, getMarketplaceCollectionsAndPlugins, getMarketplaceListFilterType, getMarketplacePlugins } from './utils' +import { getCollectionsParams, getMarketplaceListFilterType } from './utils' -function useMarketplaceCollectionsAndPluginsReactive(queryParams?: CollectionsAndPluginsSearchParams) { - return useQuery({ - queryKey: marketplaceKeys.collections(queryParams), - queryFn: ({ signal }) => getMarketplaceCollectionsAndPlugins(queryParams, { signal }), - enabled: queryParams !== undefined, - }) +export function useSearchPluginText() { + return useQueryState('q', marketplaceSearchParamsParsers.q) } - -function useMarketplaceCollectionsData() { - const [activePluginType] = useActivePluginType() - - const collectionsParams = useMemo(() => getCollectionsParams(activePluginType), [activePluginType]) - - const { data, isLoading } = useMarketplaceCollectionsAndPluginsReactive(collectionsParams) - - return { - marketplaceCollections: data?.marketplaceCollections, - marketplaceCollectionPluginsMap: data?.marketplaceCollectionPluginsMap, - isLoading, - } +export function useActivePluginType() { + return useQueryState('category', marketplaceSearchParamsParsers.category) } - -function useMarketplacePluginsReactive(queryParams?: PluginsSearchParams) { - const marketplacePluginsQuery = useInfiniteQuery({ - queryKey: marketplaceKeys.plugins(queryParams), - queryFn: ({ pageParam = 1, signal }) => getMarketplacePlugins(queryParams, pageParam, signal), - getNextPageParam: (lastPage) => { - const nextPage = lastPage.page + 1 - const loaded = lastPage.page * lastPage.pageSize - return loaded < (lastPage.total || 0) ? nextPage : undefined - }, - initialPageParam: 1, - enabled: !!queryParams, - }) - - const hasQuery = !!queryParams - const hasData = marketplacePluginsQuery.data !== undefined - const plugins = hasQuery && hasData - ? marketplacePluginsQuery.data.pages.flatMap(page => page.plugins) - : undefined - const total = hasQuery && hasData ? marketplacePluginsQuery.data.pages?.[0]?.total : undefined - const isPluginsLoading = hasQuery && ( - marketplacePluginsQuery.isPending - || (marketplacePluginsQuery.isFetching && !marketplacePluginsQuery.data) - ) - - return { - plugins, - total, - isLoading: isPluginsLoading, - isFetchingNextPage: marketplacePluginsQuery.isFetchingNextPage, - hasNextPage: marketplacePluginsQuery.hasNextPage, - fetchNextPage: marketplacePluginsQuery.fetchNextPage, - page: marketplacePluginsQuery.data?.pages?.length || (marketplacePluginsQuery.isPending && hasQuery ? 1 : 0), - } +export function useFilterPluginTags() { + return useQueryState('tags', marketplaceSearchParamsParsers.tags) } -function useMarketplacePluginsData() { - const sort = useMarketplaceSortValue() - +export function useMarketplaceData() { const [searchPluginText] = useSearchPluginText() const [filterPluginTags] = useFilterPluginTags() const [activePluginType] = useActivePluginType() - const isSearchMode = useMarketplaceSearchMode() + const collectionsQuery = useMarketplaceCollectionsAndPlugins( + getCollectionsParams(activePluginType), + ) + const sort = useMarketplaceSortValue() + const isSearchMode = useMarketplaceSearchMode() const queryParams = useMemo((): PluginsSearchParams | undefined => { if (!isSearchMode) return undefined @@ -88,14 +42,8 @@ function useMarketplacePluginsData() { } }, [isSearchMode, searchPluginText, activePluginType, filterPluginTags, sort]) - const { - plugins, - total: pluginsTotal, - isLoading: isPluginsLoading, - fetchNextPage, - hasNextPage, - page: pluginsPage, - } = useMarketplacePluginsReactive(queryParams) + const pluginsQuery = useMarketplacePlugins(queryParams) + const { hasNextPage, fetchNextPage } = pluginsQuery const handlePageChange = useCallback(() => { if (hasNextPage) @@ -106,26 +54,12 @@ function useMarketplacePluginsData() { useMarketplaceContainerScroll(handlePageChange) return { - plugins, - pluginsTotal, - page: Math.max(pluginsPage, 1), - isLoading: isPluginsLoading, - } -} - -export function useMarketplaceData() { - const collectionsData = useMarketplaceCollectionsData() - const pluginsData = useMarketplacePluginsData() - - return { - marketplaceCollections: collectionsData.marketplaceCollections, - marketplaceCollectionPluginsMap: collectionsData.marketplaceCollectionPluginsMap, - - plugins: pluginsData.plugins, - pluginsTotal: pluginsData.pluginsTotal, - page: pluginsData.page, - - isLoading: collectionsData.isLoading || pluginsData.isLoading, + marketplaceCollections: collectionsQuery.data?.marketplaceCollections, + marketplaceCollectionPluginsMap: collectionsQuery.data?.marketplaceCollectionPluginsMap, + plugins: pluginsQuery.data?.pages.flatMap(page => page.plugins) || [], + pluginsTotal: pluginsQuery.data?.pages[0]?.total || 0, + page: pluginsQuery.data?.pages.length || 0, + isLoading: collectionsQuery.isLoading || pluginsQuery.isLoading, } } @@ -145,13 +79,3 @@ export function useMarketplaceMoreClick() { setSearchMode(true) }, [setQ, setSort, setSearchMode]) } - -export function useSearchPluginText() { - return useQueryState('q', marketplaceSearchParamsParsers.q) -} -export function useActivePluginType() { - return useQueryState('category', marketplaceSearchParamsParsers.category) -} -export function useFilterPluginTags() { - return useQueryState('tags', marketplaceSearchParamsParsers.tags) -} From fe248599c40d97b0b51869166708977aef98778a Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Fri, 9 Jan 2026 00:10:45 +0800 Subject: [PATCH 47/58] update --- .../components/plugins/marketplace/query.ts | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 web/app/components/plugins/marketplace/query.ts diff --git a/web/app/components/plugins/marketplace/query.ts b/web/app/components/plugins/marketplace/query.ts new file mode 100644 index 00000000000000..d2ad0b86d31157 --- /dev/null +++ b/web/app/components/plugins/marketplace/query.ts @@ -0,0 +1,35 @@ +import type { CollectionsAndPluginsSearchParams, PluginsSearchParams } from './types' +import { useInfiniteQuery, useQuery } from '@tanstack/react-query' +import { getMarketplaceCollectionsAndPlugins, getMarketplacePlugins } from './utils' + +export const marketplaceKeys = { + all: ['marketplace'] as const, + collections: (params?: CollectionsAndPluginsSearchParams) => [...marketplaceKeys.all, 'collections', params] as const, + collectionPlugins: (collectionId: string, params?: CollectionsAndPluginsSearchParams) => [...marketplaceKeys.all, 'collectionPlugins', collectionId, params] as const, + plugins: (params?: PluginsSearchParams) => [...marketplaceKeys.all, 'plugins', params] as const, +} + +export function useMarketplaceCollectionsAndPlugins( + collectionsParams: CollectionsAndPluginsSearchParams, +) { + return useQuery({ + queryKey: marketplaceKeys.collections(collectionsParams), + queryFn: ({ signal }) => getMarketplaceCollectionsAndPlugins(collectionsParams, { signal }), + }) +} + +export function useMarketplacePlugins( + queryParams: PluginsSearchParams | undefined, +) { + return useInfiniteQuery({ + queryKey: marketplaceKeys.plugins(queryParams), + queryFn: ({ pageParam = 1, signal }) => getMarketplacePlugins(queryParams, pageParam, signal), + getNextPageParam: (lastPage) => { + const nextPage = lastPage.page + 1 + const loaded = lastPage.page * lastPage.pageSize + return loaded < (lastPage.total || 0) ? nextPage : undefined + }, + initialPageParam: 1, + enabled: queryParams !== undefined, + }) +} From 369b982c31ccc1d5967fa8ec060f69337cc22d37 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Fri, 9 Jan 2026 00:13:17 +0800 Subject: [PATCH 48/58] update --- web/app/components/plugins/marketplace/state.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/app/components/plugins/marketplace/state.ts b/web/app/components/plugins/marketplace/state.ts index 11490e581eb266..ec196928e8368d 100644 --- a/web/app/components/plugins/marketplace/state.ts +++ b/web/app/components/plugins/marketplace/state.ts @@ -56,9 +56,9 @@ export function useMarketplaceData() { return { marketplaceCollections: collectionsQuery.data?.marketplaceCollections, marketplaceCollectionPluginsMap: collectionsQuery.data?.marketplaceCollectionPluginsMap, - plugins: pluginsQuery.data?.pages.flatMap(page => page.plugins) || [], - pluginsTotal: pluginsQuery.data?.pages[0]?.total || 0, - page: pluginsQuery.data?.pages.length || 0, + plugins: pluginsQuery.data?.pages.flatMap(page => page.plugins), + pluginsTotal: pluginsQuery.data?.pages[0]?.total, + page: pluginsQuery.data?.pages.length || 1, isLoading: collectionsQuery.isLoading || pluginsQuery.isLoading, } } From b8955c6d0bb75e81fccf3ffcee2192a363bd8660 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Fri, 9 Jan 2026 00:16:09 +0800 Subject: [PATCH 49/58] deprecated only --- .../components/plugins/marketplace/hooks.ts | 90 +++++++++++++++++-- 1 file changed, 82 insertions(+), 8 deletions(-) diff --git a/web/app/components/plugins/marketplace/hooks.ts b/web/app/components/plugins/marketplace/hooks.ts index b49a5ba82a752e..b1e4f5076734c8 100644 --- a/web/app/components/plugins/marketplace/hooks.ts +++ b/web/app/components/plugins/marketplace/hooks.ts @@ -6,6 +6,7 @@ import type { MarketplaceCollection, PluginsSearchParams, } from './types' +import type { PluginsFromMarketplaceResponse } from '@/app/components/plugins/types' import { useInfiniteQuery, useQuery, @@ -17,11 +18,11 @@ import { useEffect, useState, } from 'react' +import { postMarketplace } from '@/service/base' import { SCROLL_BOTTOM_THRESHOLD } from './constants' -import { marketplaceKeys } from './query' import { + getFormattedPlugin, getMarketplaceCollectionsAndPlugins, - getMarketplacePlugins, getMarketplacePluginsByCollectionId, } from './utils' @@ -39,9 +40,12 @@ export const useMarketplaceCollectionsAndPlugins = () => { isSuccess, isPending, } = useQuery({ - queryKey: marketplaceKeys.collections(queryParams), + queryKey: ['marketplaceCollectionsAndPlugins', queryParams], queryFn: ({ signal }) => getMarketplaceCollectionsAndPlugins(queryParams, { signal }), enabled: queryParams !== undefined, + staleTime: 1000 * 60 * 5, + gcTime: 1000 * 60 * 10, + retry: false, }) const queryMarketplaceCollectionsAndPlugins = useCallback((query?: CollectionsAndPluginsSearchParams) => { @@ -70,13 +74,16 @@ export const useMarketplacePluginsByCollectionId = ( isSuccess, isPending, } = useQuery({ - queryKey: marketplaceKeys.collectionPlugins(collectionId || '', query), + queryKey: ['marketplaceCollectionPlugins', collectionId, query], queryFn: ({ signal }) => { if (!collectionId) return Promise.resolve([]) return getMarketplacePluginsByCollectionId(collectionId, query, { signal }) }, enabled: !!collectionId, + staleTime: 1000 * 60 * 5, + gcTime: 1000 * 60 * 10, + retry: false, }) return { @@ -92,9 +99,73 @@ export const useMarketplacePlugins = () => { const queryClient = useQueryClient() const [queryParams, setQueryParams] = useState() + const normalizeParams = useCallback((pluginsSearchParams: PluginsSearchParams) => { + const pageSize = pluginsSearchParams.pageSize || 40 + + return { + ...pluginsSearchParams, + pageSize, + } + }, []) + const marketplacePluginsQuery = useInfiniteQuery({ - queryKey: marketplaceKeys.plugins(queryParams), - queryFn: ({ pageParam = 1, signal }) => getMarketplacePlugins(queryParams, pageParam, signal), + queryKey: ['marketplacePlugins', queryParams], + queryFn: async ({ pageParam = 1, signal }) => { + if (!queryParams) { + return { + plugins: [] as Plugin[], + total: 0, + page: 1, + pageSize: 40, + } + } + + const params = normalizeParams(queryParams) + const { + query, + sortBy, + sortOrder, + category, + tags, + exclude, + type, + pageSize, + } = params + const pluginOrBundle = type === 'bundle' ? 'bundles' : 'plugins' + + try { + const res = await postMarketplace<{ data: PluginsFromMarketplaceResponse }>(`/${pluginOrBundle}/search/advanced`, { + body: { + page: pageParam, + page_size: pageSize, + query, + sort_by: sortBy, + sort_order: sortOrder, + category: category !== 'all' ? category : '', + tags, + exclude, + type, + }, + signal, + }) + const resPlugins = res.data.bundles || res.data.plugins || [] + + return { + plugins: resPlugins.map(plugin => getFormattedPlugin(plugin)), + total: res.data.total, + page: pageParam, + pageSize, + } + } + catch { + return { + plugins: [], + total: 0, + page: pageParam, + pageSize, + } + } + }, getNextPageParam: (lastPage) => { const nextPage = lastPage.page + 1 const loaded = lastPage.page * lastPage.pageSize @@ -102,6 +173,9 @@ export const useMarketplacePlugins = () => { }, initialPageParam: 1, enabled: !!queryParams, + staleTime: 1000 * 60 * 5, + gcTime: 1000 * 60 * 10, + retry: false, }) const resetPlugins = useCallback(() => { @@ -112,8 +186,8 @@ export const useMarketplacePlugins = () => { }, [queryClient]) const handleUpdatePlugins = useCallback((pluginsSearchParams: PluginsSearchParams) => { - setQueryParams(pluginsSearchParams) - }, []) + setQueryParams(normalizeParams(pluginsSearchParams)) + }, [normalizeParams]) const { run: queryPluginsWithDebounced, cancel: cancelQueryPluginsWithDebounced } = useDebounceFn((pluginsSearchParams: PluginsSearchParams) => { handleUpdatePlugins(pluginsSearchParams) From 53af2ff6600fe04caa4387453760b75688f1a11d Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Fri, 9 Jan 2026 10:26:17 +0800 Subject: [PATCH 50/58] todo --- web/app/components/plugins/marketplace/query.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/web/app/components/plugins/marketplace/query.ts b/web/app/components/plugins/marketplace/query.ts index d2ad0b86d31157..c5a1421146e9f6 100644 --- a/web/app/components/plugins/marketplace/query.ts +++ b/web/app/components/plugins/marketplace/query.ts @@ -2,6 +2,9 @@ import type { CollectionsAndPluginsSearchParams, PluginsSearchParams } from './t import { useInfiniteQuery, useQuery } from '@tanstack/react-query' import { getMarketplaceCollectionsAndPlugins, getMarketplacePlugins } from './utils' +// TODO: Avoid manual maintenance of query keys and better service management, +// https://github.com/langgenius/dify/issues/30342 + export const marketplaceKeys = { all: ['marketplace'] as const, collections: (params?: CollectionsAndPluginsSearchParams) => [...marketplaceKeys.all, 'collections', params] as const, From 18853eba93cf1027eea41c8e551fb6f22f65af5d Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Fri, 9 Jan 2026 10:55:36 +0800 Subject: [PATCH 51/58] fix: remove obsolete context-based tests and fix type errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove tests that used the old MarketplaceContext (now using nuqs/atoms) - Update mocks from ../context to ../state and ../atoms - Fix ListWithCollection tests to not pass onMoreClick prop - Fix ListWrapper tests to use mockMarketplaceData instead of props - Delete useMarketplaceFilters tests (hook no longer exists) - Fix ActivePluginType useState type in tool-picker.tsx πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../plugins/marketplace/index.spec.tsx | 1337 +---------------- .../plugins/marketplace/list/index.spec.tsx | 422 ++---- .../marketplace/search-box/index.spec.tsx | 44 +- .../marketplace/sort-dropdown/index.spec.tsx | 15 +- .../auto-update-setting/tool-picker.tsx | 3 +- web/hooks/use-query-params.spec.tsx | 169 --- 6 files changed, 134 insertions(+), 1856 deletions(-) diff --git a/web/app/components/plugins/marketplace/index.spec.tsx b/web/app/components/plugins/marketplace/index.spec.tsx index ed901ac0f79df9..1a3cd15b6bc91c 100644 --- a/web/app/components/plugins/marketplace/index.spec.tsx +++ b/web/app/components/plugins/marketplace/index.spec.tsx @@ -1,6 +1,6 @@ -import type { MarketplaceCollection, SearchParams, SearchParamsFromCollection } from './types' +import type { MarketplaceCollection } from './types' import type { Plugin } from '@/app/components/plugins/types' -import { act, fireEvent, render, renderHook, screen } from '@testing-library/react' +import { act, render, renderHook } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { PluginCategoryEnum } from '@/app/components/plugins/types' @@ -10,9 +10,6 @@ import { PluginCategoryEnum } from '@/app/components/plugins/types' // Note: Import after mocks are set up import { DEFAULT_SORT, PLUGIN_TYPE_SEARCH_MAP, SCROLL_BOTTOM_THRESHOLD } from './constants' -import { MarketplaceContext, MarketplaceContextProvider, useMarketplaceContext } from './context' -import PluginTypeSwitch from './plugin-type-switch' -import StickySearchAndSwitchWrapper from './sticky-search-and-switch-wrapper' import { getFormattedPlugin, getMarketplaceListCondition, @@ -62,7 +59,7 @@ vi.mock('@/service/use-plugins', () => ({ // Mock tanstack query const mockFetchNextPage = vi.fn() -let mockHasNextPage = false +const mockHasNextPage = false let mockInfiniteQueryData: { pages: Array<{ plugins: unknown[], total: number, page: number, pageSize: number }> } | undefined let capturedInfiniteQueryFn: ((ctx: { pageParam: number, signal: AbortSignal }) => Promise) | null = null let capturedQueryFn: ((ctx: { signal: AbortSignal }) => Promise) | null = null @@ -176,7 +173,7 @@ vi.mock('@/i18n-config/server', () => ({ })) // Mock useTheme hook -let mockTheme = 'light' +const mockTheme = 'light' vi.mock('@/hooks/use-theme', () => ({ default: () => ({ theme: mockTheme, @@ -367,47 +364,6 @@ const createMockCollection = (overrides?: Partial): Marke ...overrides, }) -// ================================ -// Shared Test Components -// ================================ - -// Search input test component - used in multiple tests -const SearchInputTestComponent = () => { - const searchText = useMarketplaceContext(v => v.searchPluginText) - const handleChange = useMarketplaceContext(v => v.handleSearchPluginTextChange) - - return ( -
- handleChange(e.target.value)} - /> -
{searchText}
-
- ) -} - -// Plugin type change test component -const PluginTypeChangeTestComponent = () => { - const handleChange = useMarketplaceContext(v => v.handleActivePluginTypeChange) - return ( - - ) -} - -// Page change test component -const PageChangeTestComponent = () => { - const handlePageChange = useMarketplaceContext(v => v.handlePageChange) - return ( - - ) -} - // ================================ // Constants Tests // ================================ @@ -490,7 +446,7 @@ describe('utils', () => { org: 'test-org', name: 'test-plugin', tags: [{ name: 'search' }], - } + } as unknown as Plugin const formatted = getFormattedPlugin(rawPlugin) @@ -504,7 +460,7 @@ describe('utils', () => { name: 'test-bundle', description: 'Bundle description', labels: { 'en-US': 'Test Bundle' }, - } + } as unknown as Plugin const formatted = getFormattedPlugin(rawBundle) @@ -1514,955 +1470,6 @@ describe('flatMap Coverage', () => { }) }) -// ================================ -// Context Tests -// ================================ -describe('MarketplaceContext', () => { - beforeEach(() => { - vi.clearAllMocks() - mockPortalOpenState = false - }) - - describe('MarketplaceContext default values', () => { - it('should have correct default context values', () => { - expect(MarketplaceContext).toBeDefined() - }) - }) - - describe('useMarketplaceContext', () => { - it('should return selected value from context', () => { - const TestComponent = () => { - const searchText = useMarketplaceContext(v => v.searchPluginText) - return
{searchText}
- } - - render( - - - , - ) - - expect(screen.getByTestId('search-text')).toHaveTextContent('') - }) - }) - - describe('MarketplaceContextProvider', () => { - it('should render children', () => { - render( - -
Test Child
-
, - ) - - expect(screen.getByTestId('child')).toBeInTheDocument() - }) - - it('should initialize with default values', () => { - // Reset mock data before this test - mockInfiniteQueryData = undefined - - const TestComponent = () => { - const activePluginType = useMarketplaceContext(v => v.activePluginType) - const filterPluginTags = useMarketplaceContext(v => v.filterPluginTags) - const sort = useMarketplaceContext(v => v.sort) - const page = useMarketplaceContext(v => v.page) - - return ( -
-
{activePluginType}
-
{filterPluginTags.join(',')}
-
{sort.sortBy}
-
{page}
-
- ) - } - - render( - - - , - ) - - expect(screen.getByTestId('active-type')).toHaveTextContent('all') - expect(screen.getByTestId('tags')).toHaveTextContent('') - expect(screen.getByTestId('sort')).toHaveTextContent('install_count') - // Page depends on mock data, could be 0 or 1 depending on query state - expect(screen.getByTestId('page')).toBeInTheDocument() - }) - - it('should initialize with searchParams from props', () => { - const searchParams: SearchParams = { - q: 'test query', - category: 'tool', - } - - const TestComponent = () => { - const searchText = useMarketplaceContext(v => v.searchPluginText) - return
{searchText}
- } - - render( - - - , - ) - - expect(screen.getByTestId('search')).toHaveTextContent('test query') - }) - - it('should provide handleSearchPluginTextChange function', () => { - render( - - - , - ) - - const input = screen.getByTestId('search-input') - fireEvent.change(input, { target: { value: 'new search' } }) - - expect(screen.getByTestId('search-display')).toHaveTextContent('new search') - }) - - it('should provide handleFilterPluginTagsChange function', () => { - const TestComponent = () => { - const tags = useMarketplaceContext(v => v.filterPluginTags) - const handleChange = useMarketplaceContext(v => v.handleFilterPluginTagsChange) - - return ( -
- -
{tags.join(',')}
-
- ) - } - - render( - - - , - ) - - fireEvent.click(screen.getByTestId('add-tag')) - - expect(screen.getByTestId('tags-display')).toHaveTextContent('search,image') - }) - - it('should provide handleActivePluginTypeChange function', () => { - const TestComponent = () => { - const activeType = useMarketplaceContext(v => v.activePluginType) - const handleChange = useMarketplaceContext(v => v.handleActivePluginTypeChange) - - return ( -
- -
{activeType}
-
- ) - } - - render( - - - , - ) - - fireEvent.click(screen.getByTestId('change-type')) - - expect(screen.getByTestId('type-display')).toHaveTextContent('tool') - }) - - it('should provide handleSortChange function', () => { - const TestComponent = () => { - const sort = useMarketplaceContext(v => v.sort) - const handleChange = useMarketplaceContext(v => v.handleSortChange) - - return ( -
- -
{`${sort.sortBy}-${sort.sortOrder}`}
-
- ) - } - - render( - - - , - ) - - fireEvent.click(screen.getByTestId('change-sort')) - - expect(screen.getByTestId('sort-display')).toHaveTextContent('created_at-ASC') - }) - - it('should provide handleMoreClick function', () => { - const TestComponent = () => { - const searchText = useMarketplaceContext(v => v.searchPluginText) - const sort = useMarketplaceContext(v => v.sort) - const handleMoreClick = useMarketplaceContext(v => v.handleMoreClick) - - const searchParams: SearchParamsFromCollection = { - query: 'more query', - sort_by: 'version_updated_at', - sort_order: 'DESC', - } - - return ( -
- -
{searchText}
-
{`${sort.sortBy}-${sort.sortOrder}`}
-
- ) - } - - render( - - - , - ) - - fireEvent.click(screen.getByTestId('more-click')) - - expect(screen.getByTestId('search-display')).toHaveTextContent('more query') - expect(screen.getByTestId('sort-display')).toHaveTextContent('version_updated_at-DESC') - }) - - it('should provide resetPlugins function', () => { - const TestComponent = () => { - const resetPlugins = useMarketplaceContext(v => v.resetPlugins) - const plugins = useMarketplaceContext(v => v.plugins) - - return ( -
- -
{plugins ? 'has plugins' : 'no plugins'}
-
- ) - } - - render( - - - , - ) - - fireEvent.click(screen.getByTestId('reset-plugins')) - - // Plugins should remain undefined after reset - expect(screen.getByTestId('plugins-display')).toHaveTextContent('no plugins') - }) - - it('should accept shouldExclude prop', () => { - const TestComponent = () => { - const isLoading = useMarketplaceContext(v => v.isLoading) - return
{isLoading.toString()}
- } - - render( - - - , - ) - - expect(screen.getByTestId('loading')).toBeInTheDocument() - }) - - it('should accept scrollContainerId prop', () => { - render( - -
Child
-
, - ) - - expect(screen.getByTestId('child')).toBeInTheDocument() - }) - - it('should accept showSearchParams prop', () => { - render( - -
Child
-
, - ) - - expect(screen.getByTestId('child')).toBeInTheDocument() - }) - }) -}) - -// ================================ -// PluginTypeSwitch Tests -// ================================ -describe('PluginTypeSwitch', () => { - // Mock context values for PluginTypeSwitch - const mockContextValues = { - activePluginType: 'all', - handleActivePluginTypeChange: vi.fn(), - } - - beforeEach(() => { - vi.clearAllMocks() - mockContextValues.activePluginType = 'all' - mockContextValues.handleActivePluginTypeChange = vi.fn() - - vi.doMock('./context', () => ({ - useMarketplaceContext: (selector: (v: typeof mockContextValues) => unknown) => selector(mockContextValues), - })) - }) - - // Note: PluginTypeSwitch uses internal context, so we test within the provider - describe('Rendering', () => { - it('should render without crashing', () => { - const TestComponent = () => { - const activeType = useMarketplaceContext(v => v.activePluginType) - const handleChange = useMarketplaceContext(v => v.handleActivePluginTypeChange) - - return ( -
-
handleChange('all')} - data-testid="all-option" - > - All -
-
handleChange('tool')} - data-testid="tool-option" - > - Tools -
-
- ) - } - - render( - - - , - ) - - expect(screen.getByTestId('all-option')).toBeInTheDocument() - expect(screen.getByTestId('tool-option')).toBeInTheDocument() - }) - - it('should highlight active plugin type', () => { - const TestComponent = () => { - const activeType = useMarketplaceContext(v => v.activePluginType) - const handleChange = useMarketplaceContext(v => v.handleActivePluginTypeChange) - - return ( -
-
handleChange('all')} - data-testid="all-option" - > - All -
-
- ) - } - - render( - - - , - ) - - expect(screen.getByTestId('all-option')).toHaveClass('active') - }) - }) - - describe('User Interactions', () => { - it('should call handleActivePluginTypeChange when option is clicked', () => { - const TestComponent = () => { - const handleChange = useMarketplaceContext(v => v.handleActivePluginTypeChange) - const activeType = useMarketplaceContext(v => v.activePluginType) - - return ( -
-
handleChange('tool')} - data-testid="tool-option" - > - Tools -
-
{activeType}
-
- ) - } - - render( - - - , - ) - - fireEvent.click(screen.getByTestId('tool-option')) - expect(screen.getByTestId('active-type')).toHaveTextContent('tool') - }) - - it('should update active type when different option is selected', () => { - const TestComponent = () => { - const activeType = useMarketplaceContext(v => v.activePluginType) - const handleChange = useMarketplaceContext(v => v.handleActivePluginTypeChange) - - return ( -
-
handleChange('model')} - data-testid="model-option" - > - Models -
-
{activeType}
-
- ) - } - - render( - - - , - ) - - fireEvent.click(screen.getByTestId('model-option')) - - expect(screen.getByTestId('active-display')).toHaveTextContent('model') - }) - }) - - describe('Props', () => { - it('should accept locale prop', () => { - const TestComponent = () => { - const activeType = useMarketplaceContext(v => v.activePluginType) - return
{activeType}
- } - - render( - - - , - ) - - expect(screen.getByTestId('type')).toBeInTheDocument() - }) - - it('should accept className prop', () => { - const { container } = render( - -
- Content -
-
, - ) - - expect(container.querySelector('.custom-class')).toBeInTheDocument() - }) - }) -}) - -// ================================ -// StickySearchAndSwitchWrapper Tests -// ================================ -describe('StickySearchAndSwitchWrapper', () => { - beforeEach(() => { - vi.clearAllMocks() - mockPortalOpenState = false - }) - - describe('Rendering', () => { - it('should render without crashing', () => { - const { container } = render( - - - , - ) - - expect(container.firstChild).toBeInTheDocument() - }) - - it('should apply default styling', () => { - const { container } = render( - - - , - ) - - const wrapper = container.querySelector('.mt-4.bg-background-body') - expect(wrapper).toBeInTheDocument() - }) - - it('should apply sticky positioning when pluginTypeSwitchClassName contains top-', () => { - const { container } = render( - - - , - ) - - const wrapper = container.querySelector('.sticky.z-10') - expect(wrapper).toBeInTheDocument() - }) - - it('should not apply sticky positioning without top- class', () => { - const { container } = render( - - - , - ) - - const wrapper = container.querySelector('.sticky') - expect(wrapper).toBeNull() - }) - }) - - describe('Props', () => { - it('should accept showSearchParams prop', () => { - render( - - - , - ) - - expect(screen.getByTestId('portal-elem')).toBeInTheDocument() - }) - - it('should pass pluginTypeSwitchClassName to wrapper', () => { - const { container } = render( - - - , - ) - - const wrapper = container.querySelector('.top-16.custom-style') - expect(wrapper).toBeInTheDocument() - }) - }) -}) - -// ================================ -// Integration Tests -// ================================ -describe('Marketplace Integration', () => { - beforeEach(() => { - vi.clearAllMocks() - mockPortalOpenState = false - mockTheme = 'light' - }) - - describe('Context with child components', () => { - it('should share state between multiple consumers', () => { - const SearchDisplay = () => { - const searchText = useMarketplaceContext(v => v.searchPluginText) - return
{searchText || 'empty'}
- } - - const SearchInput = () => { - const handleChange = useMarketplaceContext(v => v.handleSearchPluginTextChange) - return ( - handleChange(e.target.value)} - /> - ) - } - - render( - - - - , - ) - - expect(screen.getByTestId('search-display')).toHaveTextContent('empty') - - fireEvent.change(screen.getByTestId('search-input'), { target: { value: 'test' } }) - - expect(screen.getByTestId('search-display')).toHaveTextContent('test') - }) - - it('should update tags and reset plugins when search criteria changes', () => { - const TestComponent = () => { - const tags = useMarketplaceContext(v => v.filterPluginTags) - const handleTagsChange = useMarketplaceContext(v => v.handleFilterPluginTagsChange) - const resetPlugins = useMarketplaceContext(v => v.resetPlugins) - - const handleAddTag = () => { - handleTagsChange(['search']) - } - - const handleReset = () => { - handleTagsChange([]) - resetPlugins() - } - - return ( -
- - -
{tags.join(',') || 'none'}
-
- ) - } - - render( - - - , - ) - - expect(screen.getByTestId('tags')).toHaveTextContent('none') - - fireEvent.click(screen.getByTestId('add-tag')) - expect(screen.getByTestId('tags')).toHaveTextContent('search') - - fireEvent.click(screen.getByTestId('reset')) - expect(screen.getByTestId('tags')).toHaveTextContent('none') - }) - }) - - describe('Sort functionality', () => { - it('should update sort and trigger query', () => { - const TestComponent = () => { - const sort = useMarketplaceContext(v => v.sort) - const handleSortChange = useMarketplaceContext(v => v.handleSortChange) - - return ( -
- - -
{sort.sortBy}
-
- ) - } - - render( - - - , - ) - - expect(screen.getByTestId('current-sort')).toHaveTextContent('install_count') - - fireEvent.click(screen.getByTestId('sort-recent')) - expect(screen.getByTestId('current-sort')).toHaveTextContent('version_updated_at') - - fireEvent.click(screen.getByTestId('sort-popular')) - expect(screen.getByTestId('current-sort')).toHaveTextContent('install_count') - }) - }) - - describe('Plugin type switching', () => { - it('should filter by plugin type', () => { - const TestComponent = () => { - const activeType = useMarketplaceContext(v => v.activePluginType) - const handleTypeChange = useMarketplaceContext(v => v.handleActivePluginTypeChange) - - return ( -
- {Object.entries(PLUGIN_TYPE_SEARCH_MAP).map(([key, value]) => ( - - ))} -
{activeType}
-
- ) - } - - render( - - - , - ) - - expect(screen.getByTestId('active-type')).toHaveTextContent('all') - - fireEvent.click(screen.getByTestId('type-tool')) - expect(screen.getByTestId('active-type')).toHaveTextContent('tool') - - fireEvent.click(screen.getByTestId('type-model')) - expect(screen.getByTestId('active-type')).toHaveTextContent('model') - - fireEvent.click(screen.getByTestId('type-bundle')) - expect(screen.getByTestId('active-type')).toHaveTextContent('bundle') - }) - }) -}) - -// ================================ -// Edge Cases Tests -// ================================ -describe('Edge Cases', () => { - beforeEach(() => { - vi.clearAllMocks() - mockPortalOpenState = false - }) - - describe('Empty states', () => { - it('should handle empty search text', () => { - const TestComponent = () => { - const searchText = useMarketplaceContext(v => v.searchPluginText) - return
{searchText || 'empty'}
- } - - render( - - - , - ) - - expect(screen.getByTestId('search')).toHaveTextContent('empty') - }) - - it('should handle empty tags array', () => { - const TestComponent = () => { - const tags = useMarketplaceContext(v => v.filterPluginTags) - return
{tags.length === 0 ? 'no tags' : tags.join(',')}
- } - - render( - - - , - ) - - expect(screen.getByTestId('tags')).toHaveTextContent('no tags') - }) - - it('should handle undefined plugins', () => { - const TestComponent = () => { - const plugins = useMarketplaceContext(v => v.plugins) - return
{plugins === undefined ? 'undefined' : 'defined'}
- } - - render( - - - , - ) - - expect(screen.getByTestId('plugins')).toHaveTextContent('undefined') - }) - }) - - describe('Special characters in search', () => { - it('should handle special characters in search text', () => { - render( - - - , - ) - - const input = screen.getByTestId('search-input') - - // Test with special characters - fireEvent.change(input, { target: { value: 'test@#$%^&*()' } }) - expect(screen.getByTestId('search-display')).toHaveTextContent('test@#$%^&*()') - - // Test with unicode characters - fireEvent.change(input, { target: { value: 'ζ΅‹θ―•δΈ­ζ–‡' } }) - expect(screen.getByTestId('search-display')).toHaveTextContent('ζ΅‹θ―•δΈ­ζ–‡') - - // Test with emojis - fireEvent.change(input, { target: { value: 'πŸ” search' } }) - expect(screen.getByTestId('search-display')).toHaveTextContent('πŸ” search') - }) - }) - - describe('Rapid state changes', () => { - it('should handle rapid search text changes', async () => { - render( - - - , - ) - - const input = screen.getByTestId('search-input') - - // Rapidly change values - fireEvent.change(input, { target: { value: 'a' } }) - fireEvent.change(input, { target: { value: 'ab' } }) - fireEvent.change(input, { target: { value: 'abc' } }) - fireEvent.change(input, { target: { value: 'abcd' } }) - fireEvent.change(input, { target: { value: 'abcde' } }) - - // Final value should be the last one - expect(screen.getByTestId('search-display')).toHaveTextContent('abcde') - }) - - it('should handle rapid type changes', () => { - const TestComponent = () => { - const activeType = useMarketplaceContext(v => v.activePluginType) - const handleChange = useMarketplaceContext(v => v.handleActivePluginTypeChange) - - return ( -
- - - -
{activeType}
-
- ) - } - - render( - - - , - ) - - // Rapidly click different types - fireEvent.click(screen.getByTestId('type-tool')) - fireEvent.click(screen.getByTestId('type-model')) - fireEvent.click(screen.getByTestId('type-all')) - fireEvent.click(screen.getByTestId('type-tool')) - - expect(screen.getByTestId('active-type')).toHaveTextContent('tool') - }) - }) - - describe('Boundary conditions', () => { - it('should handle very long search text', () => { - const longText = 'a'.repeat(1000) - - const TestComponent = () => { - const searchText = useMarketplaceContext(v => v.searchPluginText) - const handleChange = useMarketplaceContext(v => v.handleSearchPluginTextChange) - - return ( -
- handleChange(e.target.value)} - /> -
{searchText.length}
-
- ) - } - - render( - - - , - ) - - fireEvent.change(screen.getByTestId('search-input'), { target: { value: longText } }) - - expect(screen.getByTestId('search-length')).toHaveTextContent('1000') - }) - - it('should handle large number of tags', () => { - const manyTags = Array.from({ length: 100 }, (_, i) => `tag-${i}`) - - const TestComponent = () => { - const tags = useMarketplaceContext(v => v.filterPluginTags) - const handleChange = useMarketplaceContext(v => v.handleFilterPluginTagsChange) - - return ( -
- -
{tags.length}
-
- ) - } - - render( - - - , - ) - - fireEvent.click(screen.getByTestId('add-many-tags')) - - expect(screen.getByTestId('tags-count')).toHaveTextContent('100') - }) - }) - - describe('Sort edge cases', () => { - it('should handle same sort selection', () => { - const TestComponent = () => { - const sort = useMarketplaceContext(v => v.sort) - const handleSortChange = useMarketplaceContext(v => v.handleSortChange) - - return ( -
- -
{`${sort.sortBy}-${sort.sortOrder}`}
-
- ) - } - - render( - - - , - ) - - // Initial sort should be install_count-DESC - expect(screen.getByTestId('sort-display')).toHaveTextContent('install_count-DESC') - - // Click same sort - should not cause issues - fireEvent.click(screen.getByTestId('select-same-sort')) - - expect(screen.getByTestId('sort-display')).toHaveTextContent('install_count-DESC') - }) - }) -}) - // ================================ // Async Utils Tests // ================================ @@ -2685,338 +1692,6 @@ describe('useMarketplaceContainerScroll', () => { }) }) -// ================================ -// Plugin Type Switch Component Tests -// ================================ -describe('PluginTypeSwitch Component', () => { - beforeEach(() => { - vi.clearAllMocks() - mockPortalOpenState = false - }) - - describe('Rendering actual component', () => { - it('should render all plugin type options', () => { - render( - - - , - ) - - // Note: The global mock returns the key with namespace prefix (plugin.) - expect(screen.getByText('plugin.category.all')).toBeInTheDocument() - expect(screen.getByText('plugin.category.models')).toBeInTheDocument() - expect(screen.getByText('plugin.category.tools')).toBeInTheDocument() - expect(screen.getByText('plugin.category.datasources')).toBeInTheDocument() - expect(screen.getByText('plugin.category.triggers')).toBeInTheDocument() - expect(screen.getByText('plugin.category.agents')).toBeInTheDocument() - expect(screen.getByText('plugin.category.extensions')).toBeInTheDocument() - expect(screen.getByText('plugin.category.bundles')).toBeInTheDocument() - }) - - it('should apply className prop', () => { - const { container } = render( - - - , - ) - - expect(container.querySelector('.custom-class')).toBeInTheDocument() - }) - - it('should call handleActivePluginTypeChange on option click', () => { - const TestWrapper = () => { - const activeType = useMarketplaceContext(v => v.activePluginType) - return ( -
- -
{activeType}
-
- ) - } - - render( - - - , - ) - - fireEvent.click(screen.getByText('plugin.category.tools')) - expect(screen.getByTestId('active-type-display')).toHaveTextContent('tool') - }) - - it('should highlight active option with correct classes', () => { - const TestWrapper = () => { - const handleChange = useMarketplaceContext(v => v.handleActivePluginTypeChange) - return ( -
- - -
- ) - } - - render( - - - , - ) - - fireEvent.click(screen.getByTestId('set-model')) - const modelOption = screen.getByText('plugin.category.models').closest('div') - expect(modelOption).toHaveClass('shadow-xs') - }) - }) - - describe('Popstate handling', () => { - it('should handle popstate event when showSearchParams is true', () => { - const originalHref = window.location.href - - const TestWrapper = () => { - const activeType = useMarketplaceContext(v => v.activePluginType) - return ( -
- -
{activeType}
-
- ) - } - - render( - - - , - ) - - const popstateEvent = new PopStateEvent('popstate') - window.dispatchEvent(popstateEvent) - - expect(screen.getByTestId('active-type')).toBeInTheDocument() - expect(window.location.href).toBe(originalHref) - }) - - it('should not handle popstate when showSearchParams is false', () => { - const TestWrapper = () => { - const activeType = useMarketplaceContext(v => v.activePluginType) - return ( -
- -
{activeType}
-
- ) - } - - render( - - - , - ) - - expect(screen.getByTestId('active-type')).toHaveTextContent('all') - - const popstateEvent = new PopStateEvent('popstate') - window.dispatchEvent(popstateEvent) - - expect(screen.getByTestId('active-type')).toHaveTextContent('all') - }) - }) -}) - -// ================================ -// Context Advanced Tests -// ================================ -describe('Context Advanced', () => { - beforeEach(() => { - vi.clearAllMocks() - mockPortalOpenState = false - mockSetUrlFilters.mockClear() - mockHasNextPage = false - }) - - describe('URL filter synchronization', () => { - it('should update URL filters when showSearchParams is true and type changes', () => { - render( - - - , - ) - - fireEvent.click(screen.getByTestId('change-type')) - expect(mockSetUrlFilters).toHaveBeenCalled() - }) - - it('should not update URL filters when showSearchParams is false', () => { - render( - - - , - ) - - fireEvent.click(screen.getByTestId('change-type')) - expect(mockSetUrlFilters).not.toHaveBeenCalled() - }) - }) - - describe('handlePageChange', () => { - it('should invoke fetchNextPage when hasNextPage is true', () => { - mockHasNextPage = true - - render( - - - , - ) - - fireEvent.click(screen.getByTestId('next-page')) - expect(mockFetchNextPage).toHaveBeenCalled() - }) - - it('should not invoke fetchNextPage when hasNextPage is false', () => { - mockHasNextPage = false - - render( - - - , - ) - - fireEvent.click(screen.getByTestId('next-page')) - expect(mockFetchNextPage).not.toHaveBeenCalled() - }) - }) - - describe('setMarketplaceCollectionsFromClient', () => { - it('should provide setMarketplaceCollectionsFromClient function', () => { - const TestComponent = () => { - const setCollections = useMarketplaceContext(v => v.setMarketplaceCollectionsFromClient) - - return ( -
- -
- ) - } - - render( - - - , - ) - - expect(screen.getByTestId('set-collections')).toBeInTheDocument() - // The function should be callable without throwing - expect(() => fireEvent.click(screen.getByTestId('set-collections'))).not.toThrow() - }) - }) - - describe('setMarketplaceCollectionPluginsMapFromClient', () => { - it('should provide setMarketplaceCollectionPluginsMapFromClient function', () => { - const TestComponent = () => { - const setPluginsMap = useMarketplaceContext(v => v.setMarketplaceCollectionPluginsMapFromClient) - - return ( -
- -
- ) - } - - render( - - - , - ) - - expect(screen.getByTestId('set-plugins-map')).toBeInTheDocument() - // The function should be callable without throwing - expect(() => fireEvent.click(screen.getByTestId('set-plugins-map'))).not.toThrow() - }) - }) - - describe('handleQueryPlugins', () => { - it('should provide handleQueryPlugins function that can be called', () => { - const TestComponent = () => { - const handleQueryPlugins = useMarketplaceContext(v => v.handleQueryPlugins) - return ( - - ) - } - - render( - - - , - ) - - expect(screen.getByTestId('query-plugins')).toBeInTheDocument() - fireEvent.click(screen.getByTestId('query-plugins')) - expect(screen.getByTestId('query-plugins')).toBeInTheDocument() - }) - }) - - describe('isLoading state', () => { - it('should expose isLoading state', () => { - const TestComponent = () => { - const isLoading = useMarketplaceContext(v => v.isLoading) - return
{isLoading.toString()}
- } - - render( - - - , - ) - - expect(screen.getByTestId('loading')).toHaveTextContent('false') - }) - }) - - describe('isSuccessCollections state', () => { - it('should expose isSuccessCollections state', () => { - const TestComponent = () => { - const isSuccess = useMarketplaceContext(v => v.isSuccessCollections) - return
{isSuccess.toString()}
- } - - render( - - - , - ) - - expect(screen.getByTestId('success')).toHaveTextContent('false') - }) - }) - - describe('pluginsTotal', () => { - it('should expose plugins total count', () => { - const TestComponent = () => { - const total = useMarketplaceContext(v => v.pluginsTotal) - return
{total || 0}
- } - - render( - - - , - ) - - expect(screen.getByTestId('total')).toHaveTextContent('0') - }) - }) -}) - // ================================ // Test Data Factory Tests // ================================ diff --git a/web/app/components/plugins/marketplace/list/index.spec.tsx b/web/app/components/plugins/marketplace/list/index.spec.tsx index c8fc6309a4b9cf..aa7bb83c630373 100644 --- a/web/app/components/plugins/marketplace/list/index.spec.tsx +++ b/web/app/components/plugins/marketplace/list/index.spec.tsx @@ -1,6 +1,6 @@ import type { MarketplaceCollection, SearchParamsFromCollection } from '../types' import type { Plugin } from '@/app/components/plugins/types' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { PluginCategoryEnum } from '@/app/components/plugins/types' import List from './index' @@ -30,23 +30,24 @@ vi.mock('#i18n', () => ({ useLocale: () => 'en-US', })) -// Mock useMarketplaceContext with controllable values -const mockContextValues = { - plugins: undefined as Plugin[] | undefined, - pluginsTotal: 0, - marketplaceCollectionsFromClient: undefined as MarketplaceCollection[] | undefined, - marketplaceCollectionPluginsMapFromClient: undefined as Record | undefined, - isLoading: false, - isSuccessCollections: false, - handleQueryPlugins: vi.fn(), - searchPluginText: '', - filterPluginTags: [] as string[], - page: 1, - handleMoreClick: vi.fn(), -} - -vi.mock('../context', () => ({ - useMarketplaceContext: (selector: (v: typeof mockContextValues) => unknown) => selector(mockContextValues), +// Mock marketplace state hooks with controllable values +const { mockMarketplaceData, mockMoreClick } = vi.hoisted(() => { + return { + mockMarketplaceData: { + plugins: undefined as Plugin[] | undefined, + pluginsTotal: 0, + marketplaceCollections: undefined as MarketplaceCollection[] | undefined, + marketplaceCollectionPluginsMap: undefined as Record | undefined, + isLoading: false, + page: 1, + }, + mockMoreClick: vi.fn(), + } +}) + +vi.mock('../state', () => ({ + useMarketplaceData: () => mockMarketplaceData, + useMarketplaceMoreClick: () => mockMoreClick, })) // Mock useLocale context @@ -578,7 +579,7 @@ describe('ListWithCollection', () => { // View More Button Tests // ================================ describe('View More Button', () => { - it('should render View More button when collection is searchable and onMoreClick is provided', () => { + it('should render View More button when collection is searchable', () => { const collections = [createMockCollection({ name: 'collection-0', searchable: true, @@ -587,14 +588,12 @@ describe('ListWithCollection', () => { const pluginsMap: Record = { 'collection-0': createMockPluginList(1), } - const onMoreClick = vi.fn() render( , ) @@ -609,42 +608,19 @@ describe('ListWithCollection', () => { const pluginsMap: Record = { 'collection-0': createMockPluginList(1), } - const onMoreClick = vi.fn() render( , ) expect(screen.queryByText('View More')).not.toBeInTheDocument() }) - it('should not render View More button when onMoreClick is not provided', () => { - const collections = [createMockCollection({ - name: 'collection-0', - searchable: true, - })] - const pluginsMap: Record = { - 'collection-0': createMockPluginList(1), - } - - render( - , - ) - - expect(screen.queryByText('View More')).not.toBeInTheDocument() - }) - - it('should call onMoreClick with search_params when View More is clicked', () => { + it('should call moreClick hook with search_params when View More is clicked', () => { const searchParams: SearchParamsFromCollection = { query: 'test-query', sort_by: 'install_count' } const collections = [createMockCollection({ name: 'collection-0', @@ -654,21 +630,19 @@ describe('ListWithCollection', () => { const pluginsMap: Record = { 'collection-0': createMockPluginList(1), } - const onMoreClick = vi.fn() render( , ) fireEvent.click(screen.getByText('View More')) - expect(onMoreClick).toHaveBeenCalledTimes(1) - expect(onMoreClick).toHaveBeenCalledWith(searchParams) + expect(mockMoreClick).toHaveBeenCalledTimes(1) + expect(mockMoreClick).toHaveBeenCalledWith(searchParams) }) }) @@ -802,24 +776,15 @@ describe('ListWithCollection', () => { // ListWrapper Component Tests // ================================ describe('ListWrapper', () => { - const defaultProps = { - marketplaceCollections: [] as MarketplaceCollection[], - marketplaceCollectionPluginsMap: {} as Record, - showInstallButton: false, - } - beforeEach(() => { vi.clearAllMocks() - // Reset context values - mockContextValues.plugins = undefined - mockContextValues.pluginsTotal = 0 - mockContextValues.marketplaceCollectionsFromClient = undefined - mockContextValues.marketplaceCollectionPluginsMapFromClient = undefined - mockContextValues.isLoading = false - mockContextValues.isSuccessCollections = false - mockContextValues.searchPluginText = '' - mockContextValues.filterPluginTags = [] - mockContextValues.page = 1 + // Reset mock data + mockMarketplaceData.plugins = undefined + mockMarketplaceData.pluginsTotal = 0 + mockMarketplaceData.marketplaceCollections = undefined + mockMarketplaceData.marketplaceCollectionPluginsMap = undefined + mockMarketplaceData.isLoading = false + mockMarketplaceData.page = 1 }) // ================================ @@ -827,32 +792,32 @@ describe('ListWrapper', () => { // ================================ describe('Rendering', () => { it('should render without crashing', () => { - render() + render() expect(document.body).toBeInTheDocument() }) it('should render with scrollbarGutter style', () => { - const { container } = render() + const { container } = render() const wrapper = container.firstChild as HTMLElement expect(wrapper).toHaveStyle({ scrollbarGutter: 'stable' }) }) it('should render Loading component when isLoading is true and page is 1', () => { - mockContextValues.isLoading = true - mockContextValues.page = 1 + mockMarketplaceData.isLoading = true + mockMarketplaceData.page = 1 - render() + render() expect(screen.getByTestId('loading-component')).toBeInTheDocument() }) it('should not render Loading component when page > 1', () => { - mockContextValues.isLoading = true - mockContextValues.page = 2 + mockMarketplaceData.isLoading = true + mockMarketplaceData.page = 2 - render() + render() expect(screen.queryByTestId('loading-component')).not.toBeInTheDocument() }) @@ -863,26 +828,26 @@ describe('ListWrapper', () => { // ================================ describe('Plugins Header', () => { it('should render plugins result count when plugins are present', () => { - mockContextValues.plugins = createMockPluginList(5) - mockContextValues.pluginsTotal = 5 + mockMarketplaceData.plugins = createMockPluginList(5) + mockMarketplaceData.pluginsTotal = 5 - render() + render() expect(screen.getByText('5 plugins found')).toBeInTheDocument() }) it('should render SortDropdown when plugins are present', () => { - mockContextValues.plugins = createMockPluginList(1) + mockMarketplaceData.plugins = createMockPluginList(1) - render() + render() expect(screen.getByTestId('sort-dropdown')).toBeInTheDocument() }) it('should not render plugins header when plugins is undefined', () => { - mockContextValues.plugins = undefined + mockMarketplaceData.plugins = undefined - render() + render() expect(screen.queryByTestId('sort-dropdown')).not.toBeInTheDocument() }) @@ -892,197 +857,60 @@ describe('ListWrapper', () => { // List Rendering Logic Tests // ================================ describe('List Rendering Logic', () => { - it('should render List when not loading', () => { - mockContextValues.isLoading = false - const collections = createMockCollectionList(1) - const pluginsMap: Record = { + it('should render collections when not loading', () => { + mockMarketplaceData.isLoading = false + mockMarketplaceData.marketplaceCollections = createMockCollectionList(1) + mockMarketplaceData.marketplaceCollectionPluginsMap = { 'collection-0': createMockPluginList(1), } - render( - , - ) + render() expect(screen.getByText('Collection 0')).toBeInTheDocument() }) it('should render List when loading but page > 1', () => { - mockContextValues.isLoading = true - mockContextValues.page = 2 - const collections = createMockCollectionList(1) - const pluginsMap: Record = { + mockMarketplaceData.isLoading = true + mockMarketplaceData.page = 2 + mockMarketplaceData.marketplaceCollections = createMockCollectionList(1) + mockMarketplaceData.marketplaceCollectionPluginsMap = { 'collection-0': createMockPluginList(1), } - render( - , - ) + render() expect(screen.getByText('Collection 0')).toBeInTheDocument() }) - - it('should use client collections when available', () => { - const serverCollections = createMockCollectionList(1) - serverCollections[0].label = { 'en-US': 'Server Collection' } - const clientCollections = createMockCollectionList(1) - clientCollections[0].label = { 'en-US': 'Client Collection' } - - const serverPluginsMap: Record = { - 'collection-0': createMockPluginList(1), - } - const clientPluginsMap: Record = { - 'collection-0': createMockPluginList(1), - } - - mockContextValues.marketplaceCollectionsFromClient = clientCollections - mockContextValues.marketplaceCollectionPluginsMapFromClient = clientPluginsMap - - render( - , - ) - - expect(screen.getByText('Client Collection')).toBeInTheDocument() - expect(screen.queryByText('Server Collection')).not.toBeInTheDocument() - }) - - it('should use server collections when client collections are not available', () => { - const serverCollections = createMockCollectionList(1) - serverCollections[0].label = { 'en-US': 'Server Collection' } - const serverPluginsMap: Record = { - 'collection-0': createMockPluginList(1), - } - - mockContextValues.marketplaceCollectionsFromClient = undefined - mockContextValues.marketplaceCollectionPluginsMapFromClient = undefined - - render( - , - ) - - expect(screen.getByText('Server Collection')).toBeInTheDocument() - }) }) // ================================ - // Context Integration Tests + // Data Integration Tests // ================================ - describe('Context Integration', () => { - it('should pass plugins from context to List', () => { - const plugins = createMockPluginList(2) - mockContextValues.plugins = plugins + describe('Data Integration', () => { + it('should pass plugins from state to List', () => { + mockMarketplaceData.plugins = createMockPluginList(2) - render() + render() expect(screen.getByTestId('card-plugin-0')).toBeInTheDocument() expect(screen.getByTestId('card-plugin-1')).toBeInTheDocument() }) - it('should pass handleMoreClick from context to List', () => { - const mockHandleMoreClick = vi.fn() - mockContextValues.handleMoreClick = mockHandleMoreClick - - const collections = [createMockCollection({ + it('should show View More button and call moreClick hook', () => { + mockMarketplaceData.marketplaceCollections = [createMockCollection({ name: 'collection-0', searchable: true, search_params: { query: 'test' }, })] - const pluginsMap: Record = { + mockMarketplaceData.marketplaceCollectionPluginsMap = { 'collection-0': createMockPluginList(1), } - render( - , - ) + render() fireEvent.click(screen.getByText('View More')) - expect(mockHandleMoreClick).toHaveBeenCalled() - }) - }) - - // ================================ - // Effect Tests (handleQueryPlugins) - // ================================ - describe('handleQueryPlugins Effect', () => { - it('should call handleQueryPlugins when conditions are met', async () => { - const mockHandleQueryPlugins = vi.fn() - mockContextValues.handleQueryPlugins = mockHandleQueryPlugins - mockContextValues.isSuccessCollections = true - mockContextValues.marketplaceCollectionsFromClient = undefined - mockContextValues.searchPluginText = '' - mockContextValues.filterPluginTags = [] - - render() - - await waitFor(() => { - expect(mockHandleQueryPlugins).toHaveBeenCalled() - }) - }) - - it('should not call handleQueryPlugins when client collections exist', async () => { - const mockHandleQueryPlugins = vi.fn() - mockContextValues.handleQueryPlugins = mockHandleQueryPlugins - mockContextValues.isSuccessCollections = true - mockContextValues.marketplaceCollectionsFromClient = createMockCollectionList(1) - mockContextValues.searchPluginText = '' - mockContextValues.filterPluginTags = [] - - render() - - // Give time for effect to run - await waitFor(() => { - expect(mockHandleQueryPlugins).not.toHaveBeenCalled() - }) - }) - - it('should not call handleQueryPlugins when search text exists', async () => { - const mockHandleQueryPlugins = vi.fn() - mockContextValues.handleQueryPlugins = mockHandleQueryPlugins - mockContextValues.isSuccessCollections = true - mockContextValues.marketplaceCollectionsFromClient = undefined - mockContextValues.searchPluginText = 'search text' - mockContextValues.filterPluginTags = [] - - render() - - await waitFor(() => { - expect(mockHandleQueryPlugins).not.toHaveBeenCalled() - }) - }) - - it('should not call handleQueryPlugins when filter tags exist', async () => { - const mockHandleQueryPlugins = vi.fn() - mockContextValues.handleQueryPlugins = mockHandleQueryPlugins - mockContextValues.isSuccessCollections = true - mockContextValues.marketplaceCollectionsFromClient = undefined - mockContextValues.searchPluginText = '' - mockContextValues.filterPluginTags = ['tag1'] - - render() - - await waitFor(() => { - expect(mockHandleQueryPlugins).not.toHaveBeenCalled() - }) + expect(mockMoreClick).toHaveBeenCalled() }) }) @@ -1090,32 +918,32 @@ describe('ListWrapper', () => { // Edge Cases Tests // ================================ describe('Edge Cases', () => { - it('should handle empty plugins array from context', () => { - mockContextValues.plugins = [] - mockContextValues.pluginsTotal = 0 + it('should handle empty plugins array', () => { + mockMarketplaceData.plugins = [] + mockMarketplaceData.pluginsTotal = 0 - render() + render() expect(screen.getByText('0 plugins found')).toBeInTheDocument() expect(screen.getByTestId('empty-component')).toBeInTheDocument() }) it('should handle large pluginsTotal', () => { - mockContextValues.plugins = createMockPluginList(10) - mockContextValues.pluginsTotal = 10000 + mockMarketplaceData.plugins = createMockPluginList(10) + mockMarketplaceData.pluginsTotal = 10000 - render() + render() expect(screen.getByText('10000 plugins found')).toBeInTheDocument() }) it('should handle both loading and has plugins', () => { - mockContextValues.isLoading = true - mockContextValues.page = 2 - mockContextValues.plugins = createMockPluginList(5) - mockContextValues.pluginsTotal = 50 + mockMarketplaceData.isLoading = true + mockMarketplaceData.page = 2 + mockMarketplaceData.plugins = createMockPluginList(5) + mockMarketplaceData.pluginsTotal = 50 - render() + render() // Should show plugins header and list expect(screen.getByText('50 plugins found')).toBeInTheDocument() @@ -1428,106 +1256,72 @@ describe('CardWrapper (via List integration)', () => { describe('Combined Workflows', () => { beforeEach(() => { vi.clearAllMocks() - mockContextValues.plugins = undefined - mockContextValues.pluginsTotal = 0 - mockContextValues.isLoading = false - mockContextValues.page = 1 - mockContextValues.marketplaceCollectionsFromClient = undefined - mockContextValues.marketplaceCollectionPluginsMapFromClient = undefined + mockMarketplaceData.plugins = undefined + mockMarketplaceData.pluginsTotal = 0 + mockMarketplaceData.isLoading = false + mockMarketplaceData.page = 1 + mockMarketplaceData.marketplaceCollections = undefined + mockMarketplaceData.marketplaceCollectionPluginsMap = undefined }) it('should transition from loading to showing collections', async () => { - mockContextValues.isLoading = true - mockContextValues.page = 1 + mockMarketplaceData.isLoading = true + mockMarketplaceData.page = 1 - const { rerender } = render( - , - ) + const { rerender } = render() expect(screen.getByTestId('loading-component')).toBeInTheDocument() // Simulate loading complete - mockContextValues.isLoading = false - const collections = createMockCollectionList(1) - const pluginsMap: Record = { + mockMarketplaceData.isLoading = false + mockMarketplaceData.marketplaceCollections = createMockCollectionList(1) + mockMarketplaceData.marketplaceCollectionPluginsMap = { 'collection-0': createMockPluginList(1), } - mockContextValues.marketplaceCollectionsFromClient = collections - mockContextValues.marketplaceCollectionPluginsMapFromClient = pluginsMap - rerender( - , - ) + rerender() expect(screen.queryByTestId('loading-component')).not.toBeInTheDocument() expect(screen.getByText('Collection 0')).toBeInTheDocument() }) it('should transition from collections to search results', async () => { - const collections = createMockCollectionList(1) - const pluginsMap: Record = { + mockMarketplaceData.marketplaceCollections = createMockCollectionList(1) + mockMarketplaceData.marketplaceCollectionPluginsMap = { 'collection-0': createMockPluginList(1), } - mockContextValues.marketplaceCollectionsFromClient = collections - mockContextValues.marketplaceCollectionPluginsMapFromClient = pluginsMap - const { rerender } = render( - , - ) + const { rerender } = render() expect(screen.getByText('Collection 0')).toBeInTheDocument() // Simulate search results - mockContextValues.plugins = createMockPluginList(5) - mockContextValues.pluginsTotal = 5 + mockMarketplaceData.plugins = createMockPluginList(5) + mockMarketplaceData.pluginsTotal = 5 - rerender( - , - ) + rerender() expect(screen.queryByText('Collection 0')).not.toBeInTheDocument() expect(screen.getByText('5 plugins found')).toBeInTheDocument() }) it('should handle empty search results', () => { - mockContextValues.plugins = [] - mockContextValues.pluginsTotal = 0 + mockMarketplaceData.plugins = [] + mockMarketplaceData.pluginsTotal = 0 - render( - , - ) + render() expect(screen.getByTestId('empty-component')).toBeInTheDocument() expect(screen.getByText('0 plugins found')).toBeInTheDocument() }) it('should support pagination (page > 1)', () => { - mockContextValues.plugins = createMockPluginList(40) - mockContextValues.pluginsTotal = 80 - mockContextValues.isLoading = true - mockContextValues.page = 2 + mockMarketplaceData.plugins = createMockPluginList(40) + mockMarketplaceData.pluginsTotal = 80 + mockMarketplaceData.isLoading = true + mockMarketplaceData.page = 2 - render( - , - ) + render() // Should show existing results while loading more expect(screen.getByText('80 plugins found')).toBeInTheDocument() @@ -1542,9 +1336,9 @@ describe('Combined Workflows', () => { describe('Accessibility', () => { beforeEach(() => { vi.clearAllMocks() - mockContextValues.plugins = undefined - mockContextValues.isLoading = false - mockContextValues.page = 1 + mockMarketplaceData.plugins = undefined + mockMarketplaceData.isLoading = false + mockMarketplaceData.page = 1 }) it('should have semantic structure with collections', () => { @@ -1573,13 +1367,11 @@ describe('Accessibility', () => { const pluginsMap: Record = { 'collection-0': createMockPluginList(1), } - const onMoreClick = vi.fn() render( , ) diff --git a/web/app/components/plugins/marketplace/search-box/index.spec.tsx b/web/app/components/plugins/marketplace/search-box/index.spec.tsx index 3e9cc40be05527..745fb988f6526b 100644 --- a/web/app/components/plugins/marketplace/search-box/index.spec.tsx +++ b/web/app/components/plugins/marketplace/search-box/index.spec.tsx @@ -26,16 +26,19 @@ vi.mock('#i18n', () => ({ }), })) -// Mock useMarketplaceContext -const mockContextValues = { - searchPluginText: '', - handleSearchPluginTextChange: vi.fn(), - filterPluginTags: [] as string[], - handleFilterPluginTagsChange: vi.fn(), -} - -vi.mock('../context', () => ({ - useMarketplaceContext: (selector: (v: typeof mockContextValues) => unknown) => selector(mockContextValues), +// Mock marketplace state hooks +const { mockSearchPluginText, mockHandleSearchPluginTextChange, mockFilterPluginTags, mockHandleFilterPluginTagsChange } = vi.hoisted(() => { + return { + mockSearchPluginText: '', + mockHandleSearchPluginTextChange: vi.fn(), + mockFilterPluginTags: [] as string[], + mockHandleFilterPluginTagsChange: vi.fn(), + } +}) + +vi.mock('../state', () => ({ + useSearchPluginText: () => [mockSearchPluginText, mockHandleSearchPluginTextChange], + useFilterPluginTags: () => [mockFilterPluginTags, mockHandleFilterPluginTagsChange], })) // Mock useTags hook @@ -430,9 +433,6 @@ describe('SearchBoxWrapper', () => { beforeEach(() => { vi.clearAllMocks() mockPortalOpenState = false - // Reset context values - mockContextValues.searchPluginText = '' - mockContextValues.filterPluginTags = [] }) describe('Rendering', () => { @@ -456,28 +456,14 @@ describe('SearchBoxWrapper', () => { }) }) - describe('Context Integration', () => { - it('should use searchPluginText from context', () => { - mockContextValues.searchPluginText = 'context search' - render() - - expect(screen.getByDisplayValue('context search')).toBeInTheDocument() - }) - + describe('Hook Integration', () => { it('should call handleSearchPluginTextChange when search changes', () => { render() const input = screen.getByRole('textbox') fireEvent.change(input, { target: { value: 'new search' } }) - expect(mockContextValues.handleSearchPluginTextChange).toHaveBeenCalledWith('new search') - }) - - it('should use filterPluginTags from context', () => { - mockContextValues.filterPluginTags = ['agent', 'rag'] - render() - - expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + expect(mockHandleSearchPluginTextChange).toHaveBeenCalledWith('new search') }) }) diff --git a/web/app/components/plugins/marketplace/sort-dropdown/index.spec.tsx b/web/app/components/plugins/marketplace/sort-dropdown/index.spec.tsx index 3ed7d78b07cd80..f91c7ba4d317c9 100644 --- a/web/app/components/plugins/marketplace/sort-dropdown/index.spec.tsx +++ b/web/app/components/plugins/marketplace/sort-dropdown/index.spec.tsx @@ -1,4 +1,3 @@ -import type { MarketplaceContextValue } from '../context' import { fireEvent, render, screen, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -28,18 +27,12 @@ vi.mock('#i18n', () => ({ }), })) -// Mock marketplace context with controllable values -let mockSort = { sortBy: 'install_count', sortOrder: 'DESC' } +// Mock marketplace atoms with controllable values +let mockSort: { sortBy: string, sortOrder: string } = { sortBy: 'install_count', sortOrder: 'DESC' } const mockHandleSortChange = vi.fn() -vi.mock('../context', () => ({ - useMarketplaceContext: (selector: (value: MarketplaceContextValue) => unknown) => { - const contextValue = { - sort: mockSort, - handleSortChange: mockHandleSortChange, - } as unknown as MarketplaceContextValue - return selector(contextValue) - }, +vi.mock('../atoms', () => ({ + useMarketplaceSort: () => [mockSort, mockHandleSortChange], })) // Mock portal component with controllable open state diff --git a/web/app/components/plugins/reference-setting-modal/auto-update-setting/tool-picker.tsx b/web/app/components/plugins/reference-setting-modal/auto-update-setting/tool-picker.tsx index 61260e6d1be21a..4e681a6b67b449 100644 --- a/web/app/components/plugins/reference-setting-modal/auto-update-setting/tool-picker.tsx +++ b/web/app/components/plugins/reference-setting-modal/auto-update-setting/tool-picker.tsx @@ -1,5 +1,6 @@ 'use client' import type { FC } from 'react' +import type { ActivePluginType } from '../../marketplace/constants' import * as React from 'react' import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -73,7 +74,7 @@ const ToolPicker: FC = ({ }, ] - const [pluginType, setPluginType] = useState(PLUGIN_TYPE_SEARCH_MAP.all) + const [pluginType, setPluginType] = useState(PLUGIN_TYPE_SEARCH_MAP.all) const [query, setQuery] = useState('') const [tags, setTags] = useState([]) const { data, isLoading } = useInstalledPluginList() diff --git a/web/hooks/use-query-params.spec.tsx b/web/hooks/use-query-params.spec.tsx index 2aa6b7998f496d..b187471809df15 100644 --- a/web/hooks/use-query-params.spec.tsx +++ b/web/hooks/use-query-params.spec.tsx @@ -8,7 +8,6 @@ import { PRICING_MODAL_QUERY_PARAM, PRICING_MODAL_QUERY_VALUE, useAccountSettingModal, - useMarketplaceFilters, usePluginInstallation, usePricingModal, } from './use-query-params' @@ -302,174 +301,6 @@ describe('useQueryParams hooks', () => { }) }) - // Marketplace filters query behavior. - describe('useMarketplaceFilters', () => { - it('should return default filters when query params are missing', () => { - // Arrange - const { result } = renderWithAdapter(() => useMarketplaceFilters()) - - // Act - const [filters] = result.current - - // Assert - expect(filters.q).toBe('') - expect(filters.category).toBe('all') - expect(filters.tags).toEqual([]) - }) - - it('should parse filters when query params are present', () => { - // Arrange - const { result } = renderWithAdapter( - () => useMarketplaceFilters(), - '?q=prompt&category=tool&tags=ai,ml', - ) - - // Act - const [filters] = result.current - - // Assert - expect(filters.q).toBe('prompt') - expect(filters.category).toBe('tool') - expect(filters.tags).toEqual(['ai', 'ml']) - }) - - it('should treat empty tags param as empty array', () => { - // Arrange - const { result } = renderWithAdapter( - () => useMarketplaceFilters(), - '?tags=', - ) - - // Act - const [filters] = result.current - - // Assert - expect(filters.tags).toEqual([]) - }) - - it('should preserve other filters when updating a single field', async () => { - // Arrange - const { result } = renderWithAdapter( - () => useMarketplaceFilters(), - '?category=tool&tags=ai,ml', - ) - - // Act - act(() => { - result.current[1]({ q: 'search' }) - }) - - // Assert - await waitFor(() => expect(result.current[0].q).toBe('search')) - expect(result.current[0].category).toBe('tool') - expect(result.current[0].tags).toEqual(['ai', 'ml']) - }) - - it('should clear q param when q is empty', async () => { - // Arrange - const { result, onUrlUpdate } = renderWithAdapter( - () => useMarketplaceFilters(), - '?q=search', - ) - - // Act - act(() => { - result.current[1]({ q: '' }) - }) - - // Assert - await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()) - const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0] - expect(update.searchParams.has('q')).toBe(false) - }) - - it('should serialize tags as comma-separated values', async () => { - // Arrange - const { result, onUrlUpdate } = renderWithAdapter(() => useMarketplaceFilters()) - - // Act - act(() => { - result.current[1]({ tags: ['ai', 'ml'] }) - }) - - // Assert - await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()) - const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0] - expect(update.searchParams.get('tags')).toBe('ai,ml') - }) - - it('should remove tags param when list is empty', async () => { - // Arrange - const { result, onUrlUpdate } = renderWithAdapter( - () => useMarketplaceFilters(), - '?tags=ai,ml', - ) - - // Act - act(() => { - result.current[1]({ tags: [] }) - }) - - // Assert - await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()) - const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0] - expect(update.searchParams.has('tags')).toBe(false) - }) - - it('should keep category in the URL when set to default', async () => { - // Arrange - const { result, onUrlUpdate } = renderWithAdapter( - () => useMarketplaceFilters(), - '?category=tool', - ) - - // Act - act(() => { - result.current[1]({ category: 'all' }) - }) - - // Assert - await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()) - const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0] - expect(update.searchParams.get('category')).toBe('all') - }) - - it('should clear all marketplace filters when set to null', async () => { - // Arrange - const { result, onUrlUpdate } = renderWithAdapter( - () => useMarketplaceFilters(), - '?q=search&category=tool&tags=ai,ml', - ) - - // Act - act(() => { - result.current[1](null) - }) - - // Assert - await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()) - const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0] - expect(update.searchParams.has('q')).toBe(false) - expect(update.searchParams.has('category')).toBe(false) - expect(update.searchParams.has('tags')).toBe(false) - }) - - it('should use replace history when updating filters', async () => { - // Arrange - const { result, onUrlUpdate } = renderWithAdapter(() => useMarketplaceFilters()) - - // Act - act(() => { - result.current[1]({ q: 'search' }) - }) - - // Assert - await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()) - const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0] - expect(update.options.history).toBe('replace') - }) - }) - // Plugin installation query behavior. describe('usePluginInstallation', () => { it('should parse package ids from JSON arrays', () => { From 77c531b1a33698012616136a56cf8e134d16ac66 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Fri, 9 Jan 2026 11:27:44 +0800 Subject: [PATCH 52/58] no preserve in app --- .../components/plugins/marketplace/atoms.ts | 46 ++++++++++------ .../plugins/marketplace/hydration-client.tsx | 9 ++++ .../plugins/marketplace/hydration-server.tsx | 45 ++++++++++++++++ .../components/plugins/marketplace/index.tsx | 54 +++++-------------- .../marketplace/plugin-type-switch.tsx | 6 +-- .../search-box/search-box-wrapper.tsx | 2 +- .../marketplace/sort-dropdown/index.tsx | 5 +- .../components/plugins/marketplace/state.ts | 21 ++------ .../plugins/plugin-page/context.tsx | 2 +- 9 files changed, 110 insertions(+), 80 deletions(-) create mode 100644 web/app/components/plugins/marketplace/hydration-client.tsx create mode 100644 web/app/components/plugins/marketplace/hydration-server.tsx diff --git a/web/app/components/plugins/marketplace/atoms.ts b/web/app/components/plugins/marketplace/atoms.ts index dce676d880f700..1fb774fd33016c 100644 --- a/web/app/components/plugins/marketplace/atoms.ts +++ b/web/app/components/plugins/marketplace/atoms.ts @@ -1,27 +1,45 @@ +import type { ActivePluginType } from './constants' import type { PluginsSort } from './types' -import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai' +import { atom, useAtom, useAtomValue } from 'jotai' +import { useQueryState } from 'nuqs' import { DEFAULT_SORT, PLUGIN_CATEGORY_WITH_COLLECTIONS } from './constants' -import { useActivePluginType, useFilterPluginTags, useSearchPluginText } from './state' +import { marketplaceSearchParamsParsers } from './search-params' -const marketplaceSortAtom = atom(DEFAULT_SORT) +export const marketplaceSortAtom = atom(DEFAULT_SORT) -export function useMarketplaceSort() { - return useAtom(marketplaceSortAtom) -} +/** + * Preserve the state for marketplace + */ +export const preserveSearchStateInQueryAtom = atom(false) -export function useMarketplaceSortValue() { - return useAtomValue(marketplaceSortAtom) -} +export const searchPluginTextAtom = atom('') +export const activePluginTypeAtom = atom('all') +export const filterPluginTagsAtom = atom([]) -export function useSetMarketplaceSort() { - return useSetAtom(marketplaceSortAtom) +export function useSearchPluginText() { + const preserveSearchStateInQuery = useAtomValue(preserveSearchStateInQueryAtom) + const queryState = useQueryState('q', marketplaceSearchParamsParsers.q) + const atomState = useAtom(searchPluginTextAtom) + return preserveSearchStateInQuery ? queryState : atomState +} +export function useActivePluginType() { + const preserveSearchStateInQuery = useAtomValue(preserveSearchStateInQueryAtom) + const queryState = useQueryState('category', marketplaceSearchParamsParsers.category) + const atomState = useAtom(activePluginTypeAtom) + return preserveSearchStateInQuery ? queryState : atomState +} +export function useFilterPluginTags() { + const preserveSearchStateInQuery = useAtomValue(preserveSearchStateInQueryAtom) + const queryState = useQueryState('tags', marketplaceSearchParamsParsers.tags) + const atomState = useAtom(filterPluginTagsAtom) + return preserveSearchStateInQuery ? queryState : atomState } /** * Not all categories have collections, so we need to * force the search mode for those categories. */ -const searchModeAtom = atom(null) +export const searchModeAtom = atom(null) export function useMarketplaceSearchMode() { const [searchPluginText] = useSearchPluginText() @@ -34,7 +52,3 @@ export function useMarketplaceSearchMode() { || (searchMode ?? (!PLUGIN_CATEGORY_WITH_COLLECTIONS.has(activePluginType))) return isSearchMode } - -export function useSetSearchMode() { - return useSetAtom(searchModeAtom) -} diff --git a/web/app/components/plugins/marketplace/hydration-client.tsx b/web/app/components/plugins/marketplace/hydration-client.tsx new file mode 100644 index 00000000000000..5bf9454279c6c8 --- /dev/null +++ b/web/app/components/plugins/marketplace/hydration-client.tsx @@ -0,0 +1,9 @@ +'use client' + +import { useHydrateAtoms } from 'jotai/utils' +import { preserveSearchStateInQueryAtom } from './atoms' + +export function HydrateMarketplaceAtoms({ enable, children }: { enable: boolean, children: React.ReactNode }) { + useHydrateAtoms([[preserveSearchStateInQueryAtom, enable]]) + return <>{children} +} diff --git a/web/app/components/plugins/marketplace/hydration-server.tsx b/web/app/components/plugins/marketplace/hydration-server.tsx new file mode 100644 index 00000000000000..0aa544cff1c9f2 --- /dev/null +++ b/web/app/components/plugins/marketplace/hydration-server.tsx @@ -0,0 +1,45 @@ +import type { SearchParams } from 'nuqs' +import { dehydrate, HydrationBoundary } from '@tanstack/react-query' +import { createLoader } from 'nuqs/server' +import { getQueryClientServer } from '@/context/query-client-server' +import { PLUGIN_CATEGORY_WITH_COLLECTIONS } from './constants' +import { marketplaceKeys } from './query' +import { marketplaceSearchParamsParsers } from './search-params' +import { getCollectionsParams, getMarketplaceCollectionsAndPlugins } from './utils' + +// The server side logic should move to marketplace's codebase so that we can get rid of Next.js + +async function getDehydratedState(searchParams?: Promise) { + if (!searchParams) { + return + } + const loadSearchParams = createLoader(marketplaceSearchParamsParsers) + const params = await loadSearchParams(searchParams) + + if (!PLUGIN_CATEGORY_WITH_COLLECTIONS.has(params.category)) { + return + } + + const queryClient = getQueryClientServer() + + await queryClient.prefetchQuery({ + queryKey: marketplaceKeys.collections(getCollectionsParams(params.category)), + queryFn: () => getMarketplaceCollectionsAndPlugins(getCollectionsParams(params.category)), + }) + return dehydrate(queryClient) +} + +export async function HydrateQueryClient({ + searchParams, + children, +}: { + searchParams: Promise | undefined + children: React.ReactNode +}) { + const dehydratedState = await getDehydratedState(searchParams) + return ( + + {children} + + ) +} diff --git a/web/app/components/plugins/marketplace/index.tsx b/web/app/components/plugins/marketplace/index.tsx index 766f7b7ee7c299..29ff812d36cbc6 100644 --- a/web/app/components/plugins/marketplace/index.tsx +++ b/web/app/components/plugins/marketplace/index.tsx @@ -1,15 +1,10 @@ import type { SearchParams } from 'nuqs' -import { dehydrate, HydrationBoundary } from '@tanstack/react-query' -import { createLoader } from 'nuqs/server' import { TanstackQueryInitializer } from '@/context/query-client' -import { getQueryClientServer } from '@/context/query-client-server' -import { PLUGIN_CATEGORY_WITH_COLLECTIONS } from './constants' import Description from './description' +import { HydrateMarketplaceAtoms } from './hydration-client' +import { HydrateQueryClient } from './hydration-server' import ListWrapper from './list/list-wrapper' -import { marketplaceKeys } from './query' -import { marketplaceSearchParamsParsers } from './search-params' import StickySearchAndSwitchWrapper from './sticky-search-and-switch-wrapper' -import { getCollectionsParams, getMarketplaceCollectionsAndPlugins } from './utils' type MarketplaceProps = { showInstallButton?: boolean @@ -20,49 +15,26 @@ type MarketplaceProps = { searchParams?: Promise } -/** - * TODO: The server side logic should move to marketplace's codebase so that we can get rid of Next.js - */ const Marketplace = async ({ showInstallButton = true, pluginTypeSwitchClassName, searchParams, }: MarketplaceProps) => { - const dehydratedState = await getDehydratedState(searchParams) - return ( - - - - - + + + + + + + ) } export default Marketplace - -async function getDehydratedState(searchParams?: Promise) { - if (!searchParams) { - return - } - const loadSearchParams = createLoader(marketplaceSearchParamsParsers) - const params = await loadSearchParams(searchParams) - - if (!PLUGIN_CATEGORY_WITH_COLLECTIONS.has(params.category)) { - return - } - - const queryClient = getQueryClientServer() - - await queryClient.prefetchQuery({ - queryKey: marketplaceKeys.collections(getCollectionsParams(params.category)), - queryFn: () => getMarketplaceCollectionsAndPlugins(getCollectionsParams(params.category)), - }) - return dehydrate(queryClient) -} diff --git a/web/app/components/plugins/marketplace/plugin-type-switch.tsx b/web/app/components/plugins/marketplace/plugin-type-switch.tsx index 319148c0f1f1c1..6e56a288d84acc 100644 --- a/web/app/components/plugins/marketplace/plugin-type-switch.tsx +++ b/web/app/components/plugins/marketplace/plugin-type-switch.tsx @@ -9,11 +9,11 @@ import { RiPuzzle2Line, RiSpeakAiLine, } from '@remixicon/react' +import { useSetAtom } from 'jotai' import { Trigger as TriggerIcon } from '@/app/components/base/icons/src/vender/plugin' import { cn } from '@/utils/classnames' -import { useSetSearchMode } from './atoms' +import { searchModeAtom, useActivePluginType } from './atoms' import { PLUGIN_CATEGORY_WITH_COLLECTIONS, PLUGIN_TYPE_SEARCH_MAP } from './constants' -import { useActivePluginType } from './state' type PluginTypeSwitchProps = { className?: string @@ -23,7 +23,7 @@ const PluginTypeSwitch = ({ }: PluginTypeSwitchProps) => { const { t } = useTranslation() const [activePluginType, handleActivePluginTypeChange] = useActivePluginType() - const setSearchMode = useSetSearchMode() + const setSearchMode = useSetAtom(searchModeAtom) const options: Array<{ value: ActivePluginType diff --git a/web/app/components/plugins/marketplace/search-box/search-box-wrapper.tsx b/web/app/components/plugins/marketplace/search-box/search-box-wrapper.tsx index 516d9672749dac..9957e9bc4299c9 100644 --- a/web/app/components/plugins/marketplace/search-box/search-box-wrapper.tsx +++ b/web/app/components/plugins/marketplace/search-box/search-box-wrapper.tsx @@ -1,7 +1,7 @@ 'use client' import { useTranslation } from '#i18n' -import { useFilterPluginTags, useSearchPluginText } from '../state' +import { useFilterPluginTags, useSearchPluginText } from '../atoms' import SearchBox from './index' const SearchBoxWrapper = () => { diff --git a/web/app/components/plugins/marketplace/sort-dropdown/index.tsx b/web/app/components/plugins/marketplace/sort-dropdown/index.tsx index 1f7bab1005a6f6..4caa208f031d9c 100644 --- a/web/app/components/plugins/marketplace/sort-dropdown/index.tsx +++ b/web/app/components/plugins/marketplace/sort-dropdown/index.tsx @@ -4,13 +4,14 @@ import { RiArrowDownSLine, RiCheckLine, } from '@remixicon/react' +import { useAtom } from 'jotai' import { useState } from 'react' import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger, } from '@/app/components/base/portal-to-follow-elem' -import { useMarketplaceSort } from '../atoms' +import { marketplaceSortAtom } from '../atoms' const SortDropdown = () => { const { t } = useTranslation() @@ -36,7 +37,7 @@ const SortDropdown = () => { text: t('marketplace.sortOption.firstReleased', { ns: 'plugin' }), }, ] - const [sort, handleSortChange] = useMarketplaceSort() + const [sort, handleSortChange] = useAtom(marketplaceSortAtom) const [open, setOpen] = useState(false) const selectedOption = options.find(option => option.value === sort.sortBy && option.order === sort.sortOrder) ?? options[0] diff --git a/web/app/components/plugins/marketplace/state.ts b/web/app/components/plugins/marketplace/state.ts index ec196928e8368d..4a8272a6783c27 100644 --- a/web/app/components/plugins/marketplace/state.ts +++ b/web/app/components/plugins/marketplace/state.ts @@ -1,23 +1,12 @@ import type { PluginsSearchParams, SearchParamsFromCollection } from './types' -import { useQueryState } from 'nuqs' +import { useAtomValue, useSetAtom } from 'jotai' import { useCallback, useMemo } from 'react' -import { useMarketplaceSearchMode, useMarketplaceSortValue, useSetMarketplaceSort, useSetSearchMode } from './atoms' +import { marketplaceSortAtom, searchModeAtom, useActivePluginType, useFilterPluginTags, useMarketplaceSearchMode, useSearchPluginText } from './atoms' import { DEFAULT_SORT, PLUGIN_TYPE_SEARCH_MAP } from './constants' import { useMarketplaceContainerScroll } from './hooks' import { useMarketplaceCollectionsAndPlugins, useMarketplacePlugins } from './query' -import { marketplaceSearchParamsParsers } from './search-params' import { getCollectionsParams, getMarketplaceListFilterType } from './utils' -export function useSearchPluginText() { - return useQueryState('q', marketplaceSearchParamsParsers.q) -} -export function useActivePluginType() { - return useQueryState('category', marketplaceSearchParamsParsers.category) -} -export function useFilterPluginTags() { - return useQueryState('tags', marketplaceSearchParamsParsers.tags) -} - export function useMarketplaceData() { const [searchPluginText] = useSearchPluginText() const [filterPluginTags] = useFilterPluginTags() @@ -27,7 +16,7 @@ export function useMarketplaceData() { getCollectionsParams(activePluginType), ) - const sort = useMarketplaceSortValue() + const sort = useAtomValue(marketplaceSortAtom) const isSearchMode = useMarketplaceSearchMode() const queryParams = useMemo((): PluginsSearchParams | undefined => { if (!isSearchMode) @@ -65,8 +54,8 @@ export function useMarketplaceData() { export function useMarketplaceMoreClick() { const [,setQ] = useSearchPluginText() - const setSort = useSetMarketplaceSort() - const setSearchMode = useSetSearchMode() + const setSort = useSetAtom(marketplaceSortAtom) + const setSearchMode = useSetAtom(searchModeAtom) return useCallback((searchParams?: SearchParamsFromCollection) => { if (!searchParams) diff --git a/web/app/components/plugins/plugin-page/context.tsx b/web/app/components/plugins/plugin-page/context.tsx index abc4408d628df2..fea78ae181eb99 100644 --- a/web/app/components/plugins/plugin-page/context.tsx +++ b/web/app/components/plugins/plugin-page/context.tsx @@ -68,7 +68,7 @@ export const PluginPageContextProvider = ({ const options = useMemo(() => { return enable_marketplace ? tabs : tabs.filter(tab => tab.value !== PLUGIN_PAGE_TABS_MAP.marketplace) }, [tabs, enable_marketplace]) - const [activeTab, setActiveTab] = useQueryState('tab', { + const [activeTab, setActiveTab] = useQueryState('category', { defaultValue: options[0].value, }) From b95129ce9053d489b420d6bd4fab6c94c76cb186 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Fri, 9 Jan 2026 11:40:41 +0800 Subject: [PATCH 53/58] refactor --- .../components/plugins/marketplace/atoms.ts | 39 ++++++++++++++++--- .../components/plugins/marketplace/index.tsx | 1 + .../plugins/marketplace/list/index.spec.tsx | 3 ++ .../marketplace/list/list-with-collection.tsx | 2 +- .../marketplace/sort-dropdown/index.tsx | 5 +-- .../components/plugins/marketplace/state.ts | 26 ++----------- 6 files changed, 44 insertions(+), 32 deletions(-) diff --git a/web/app/components/plugins/marketplace/atoms.ts b/web/app/components/plugins/marketplace/atoms.ts index 1fb774fd33016c..6ca9bd1c054807 100644 --- a/web/app/components/plugins/marketplace/atoms.ts +++ b/web/app/components/plugins/marketplace/atoms.ts @@ -1,20 +1,30 @@ import type { ActivePluginType } from './constants' -import type { PluginsSort } from './types' -import { atom, useAtom, useAtomValue } from 'jotai' +import type { PluginsSort, SearchParamsFromCollection } from './types' +import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai' import { useQueryState } from 'nuqs' +import { useCallback } from 'react' import { DEFAULT_SORT, PLUGIN_CATEGORY_WITH_COLLECTIONS } from './constants' import { marketplaceSearchParamsParsers } from './search-params' -export const marketplaceSortAtom = atom(DEFAULT_SORT) +const marketplaceSortAtom = atom(DEFAULT_SORT) +export function useMarketplaceSort() { + return useAtom(marketplaceSortAtom) +} +export function useMarketplaceSortValue() { + return useAtomValue(marketplaceSortAtom) +} +export function useSetMarketplaceSort() { + return useSetAtom(marketplaceSortAtom) +} /** * Preserve the state for marketplace */ export const preserveSearchStateInQueryAtom = atom(false) -export const searchPluginTextAtom = atom('') -export const activePluginTypeAtom = atom('all') -export const filterPluginTagsAtom = atom([]) +const searchPluginTextAtom = atom('') +const activePluginTypeAtom = atom('all') +const filterPluginTagsAtom = atom([]) export function useSearchPluginText() { const preserveSearchStateInQuery = useAtomValue(preserveSearchStateInQueryAtom) @@ -52,3 +62,20 @@ export function useMarketplaceSearchMode() { || (searchMode ?? (!PLUGIN_CATEGORY_WITH_COLLECTIONS.has(activePluginType))) return isSearchMode } + +export function useMarketplaceMoreClick() { + const [,setQ] = useSearchPluginText() + const setSort = useSetAtom(marketplaceSortAtom) + const setSearchMode = useSetAtom(searchModeAtom) + + return useCallback((searchParams?: SearchParamsFromCollection) => { + if (!searchParams) + return + setQ(searchParams?.query || '') + setSort({ + sortBy: searchParams?.sort_by || DEFAULT_SORT.sortBy, + sortOrder: searchParams?.sort_order || DEFAULT_SORT.sortOrder, + }) + setSearchMode(true) + }, [setQ, setSort, setSearchMode]) +} diff --git a/web/app/components/plugins/marketplace/index.tsx b/web/app/components/plugins/marketplace/index.tsx index 29ff812d36cbc6..912f2d3ab3625d 100644 --- a/web/app/components/plugins/marketplace/index.tsx +++ b/web/app/components/plugins/marketplace/index.tsx @@ -11,6 +11,7 @@ type MarketplaceProps = { pluginTypeSwitchClassName?: string /** * Pass the search params from the request to prefetch data on the server + * and preserve the search params in the URL. */ searchParams?: Promise } diff --git a/web/app/components/plugins/marketplace/list/index.spec.tsx b/web/app/components/plugins/marketplace/list/index.spec.tsx index aa7bb83c630373..81616f5958a3af 100644 --- a/web/app/components/plugins/marketplace/list/index.spec.tsx +++ b/web/app/components/plugins/marketplace/list/index.spec.tsx @@ -47,6 +47,9 @@ const { mockMarketplaceData, mockMoreClick } = vi.hoisted(() => { vi.mock('../state', () => ({ useMarketplaceData: () => mockMarketplaceData, +})) + +vi.mock('../atoms', () => ({ useMarketplaceMoreClick: () => mockMoreClick, })) diff --git a/web/app/components/plugins/marketplace/list/list-with-collection.tsx b/web/app/components/plugins/marketplace/list/list-with-collection.tsx index f7424cc440bd7d..264227b66638e1 100644 --- a/web/app/components/plugins/marketplace/list/list-with-collection.tsx +++ b/web/app/components/plugins/marketplace/list/list-with-collection.tsx @@ -6,7 +6,7 @@ import { useLocale, useTranslation } from '#i18n' import { RiArrowRightSLine } from '@remixicon/react' import { getLanguage } from '@/i18n-config/language' import { cn } from '@/utils/classnames' -import { useMarketplaceMoreClick } from '../state' +import { useMarketplaceMoreClick } from '../atoms' import CardWrapper from './card-wrapper' type ListWithCollectionProps = { diff --git a/web/app/components/plugins/marketplace/sort-dropdown/index.tsx b/web/app/components/plugins/marketplace/sort-dropdown/index.tsx index 4caa208f031d9c..1f7bab1005a6f6 100644 --- a/web/app/components/plugins/marketplace/sort-dropdown/index.tsx +++ b/web/app/components/plugins/marketplace/sort-dropdown/index.tsx @@ -4,14 +4,13 @@ import { RiArrowDownSLine, RiCheckLine, } from '@remixicon/react' -import { useAtom } from 'jotai' import { useState } from 'react' import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger, } from '@/app/components/base/portal-to-follow-elem' -import { marketplaceSortAtom } from '../atoms' +import { useMarketplaceSort } from '../atoms' const SortDropdown = () => { const { t } = useTranslation() @@ -37,7 +36,7 @@ const SortDropdown = () => { text: t('marketplace.sortOption.firstReleased', { ns: 'plugin' }), }, ] - const [sort, handleSortChange] = useAtom(marketplaceSortAtom) + const [sort, handleSortChange] = useMarketplaceSort() const [open, setOpen] = useState(false) const selectedOption = options.find(option => option.value === sort.sortBy && option.order === sort.sortOrder) ?? options[0] diff --git a/web/app/components/plugins/marketplace/state.ts b/web/app/components/plugins/marketplace/state.ts index 4a8272a6783c27..215351e2e577b3 100644 --- a/web/app/components/plugins/marketplace/state.ts +++ b/web/app/components/plugins/marketplace/state.ts @@ -1,8 +1,7 @@ -import type { PluginsSearchParams, SearchParamsFromCollection } from './types' -import { useAtomValue, useSetAtom } from 'jotai' +import type { PluginsSearchParams } from './types' import { useCallback, useMemo } from 'react' -import { marketplaceSortAtom, searchModeAtom, useActivePluginType, useFilterPluginTags, useMarketplaceSearchMode, useSearchPluginText } from './atoms' -import { DEFAULT_SORT, PLUGIN_TYPE_SEARCH_MAP } from './constants' +import { useActivePluginType, useFilterPluginTags, useMarketplaceSearchMode, useMarketplaceSortValue, useSearchPluginText } from './atoms' +import { PLUGIN_TYPE_SEARCH_MAP } from './constants' import { useMarketplaceContainerScroll } from './hooks' import { useMarketplaceCollectionsAndPlugins, useMarketplacePlugins } from './query' import { getCollectionsParams, getMarketplaceListFilterType } from './utils' @@ -16,7 +15,7 @@ export function useMarketplaceData() { getCollectionsParams(activePluginType), ) - const sort = useAtomValue(marketplaceSortAtom) + const sort = useMarketplaceSortValue() const isSearchMode = useMarketplaceSearchMode() const queryParams = useMemo((): PluginsSearchParams | undefined => { if (!isSearchMode) @@ -51,20 +50,3 @@ export function useMarketplaceData() { isLoading: collectionsQuery.isLoading || pluginsQuery.isLoading, } } - -export function useMarketplaceMoreClick() { - const [,setQ] = useSearchPluginText() - const setSort = useSetAtom(marketplaceSortAtom) - const setSearchMode = useSetAtom(searchModeAtom) - - return useCallback((searchParams?: SearchParamsFromCollection) => { - if (!searchParams) - return - setQ(searchParams?.query || '') - setSort({ - sortBy: searchParams?.sort_by || DEFAULT_SORT.sortBy, - sortOrder: searchParams?.sort_order || DEFAULT_SORT.sortOrder, - }) - setSearchMode(true) - }, [setQ, setSort, setSearchMode]) -} From 12e496d7c108d57939e4b9474b2c5d3cdb8ff461 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Fri, 9 Jan 2026 11:44:10 +0800 Subject: [PATCH 54/58] fix test --- .../components/plugins/marketplace/search-box/index.spec.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/app/components/plugins/marketplace/search-box/index.spec.tsx b/web/app/components/plugins/marketplace/search-box/index.spec.tsx index 745fb988f6526b..85be82cb331c54 100644 --- a/web/app/components/plugins/marketplace/search-box/index.spec.tsx +++ b/web/app/components/plugins/marketplace/search-box/index.spec.tsx @@ -36,7 +36,7 @@ const { mockSearchPluginText, mockHandleSearchPluginTextChange, mockFilterPlugin } }) -vi.mock('../state', () => ({ +vi.mock('../atoms', () => ({ useSearchPluginText: () => [mockSearchPluginText, mockHandleSearchPluginTextChange], useFilterPluginTags: () => [mockFilterPluginTags, mockHandleFilterPluginTagsChange], })) From c2cb75f7825ed23eda1ccd6783e81093cc3c0699 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Fri, 9 Jan 2026 12:49:30 +0800 Subject: [PATCH 55/58] rename --- .../plugins/marketplace/hydration-client.tsx | 10 ++++++++-- web/app/components/plugins/marketplace/index.tsx | 2 +- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/web/app/components/plugins/marketplace/hydration-client.tsx b/web/app/components/plugins/marketplace/hydration-client.tsx index 5bf9454279c6c8..5698db711f6e5a 100644 --- a/web/app/components/plugins/marketplace/hydration-client.tsx +++ b/web/app/components/plugins/marketplace/hydration-client.tsx @@ -3,7 +3,13 @@ import { useHydrateAtoms } from 'jotai/utils' import { preserveSearchStateInQueryAtom } from './atoms' -export function HydrateMarketplaceAtoms({ enable, children }: { enable: boolean, children: React.ReactNode }) { - useHydrateAtoms([[preserveSearchStateInQueryAtom, enable]]) +export function HydrateMarketplaceAtoms({ + preserveSearchStateInQuery, + children, +}: { + preserveSearchStateInQuery: boolean + children: React.ReactNode +}) { + useHydrateAtoms([[preserveSearchStateInQueryAtom, preserveSearchStateInQuery]]) return <>{children} } diff --git a/web/app/components/plugins/marketplace/index.tsx b/web/app/components/plugins/marketplace/index.tsx index 912f2d3ab3625d..1f32ee4d291e9e 100644 --- a/web/app/components/plugins/marketplace/index.tsx +++ b/web/app/components/plugins/marketplace/index.tsx @@ -24,7 +24,7 @@ const Marketplace = async ({ return ( - + Date: Fri, 9 Jan 2026 13:35:24 +0800 Subject: [PATCH 56/58] bring back debounce --- web/app/components/plugins/marketplace/state.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/web/app/components/plugins/marketplace/state.ts b/web/app/components/plugins/marketplace/state.ts index 215351e2e577b3..bfd30f34edc114 100644 --- a/web/app/components/plugins/marketplace/state.ts +++ b/web/app/components/plugins/marketplace/state.ts @@ -1,4 +1,5 @@ import type { PluginsSearchParams } from './types' +import { useDebounce } from 'ahooks' import { useCallback, useMemo } from 'react' import { useActivePluginType, useFilterPluginTags, useMarketplaceSearchMode, useMarketplaceSortValue, useSearchPluginText } from './atoms' import { PLUGIN_TYPE_SEARCH_MAP } from './constants' @@ -30,7 +31,8 @@ export function useMarketplaceData() { } }, [isSearchMode, searchPluginText, activePluginType, filterPluginTags, sort]) - const pluginsQuery = useMarketplacePlugins(queryParams) + const deferredQueryParams = useDebounce(queryParams, { wait: 500 }) + const pluginsQuery = useMarketplacePlugins(deferredQueryParams) const { hasNextPage, fetchNextPage } = pluginsQuery const handlePageChange = useCallback(() => { From aecee0c73d0d6c4460eee05248e81da7cdfb9a05 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Fri, 9 Jan 2026 13:49:41 +0800 Subject: [PATCH 57/58] debounce text only --- web/app/components/plugins/marketplace/state.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/app/components/plugins/marketplace/state.ts b/web/app/components/plugins/marketplace/state.ts index bfd30f34edc114..7eab3b26665772 100644 --- a/web/app/components/plugins/marketplace/state.ts +++ b/web/app/components/plugins/marketplace/state.ts @@ -8,7 +8,8 @@ import { useMarketplaceCollectionsAndPlugins, useMarketplacePlugins } from './qu import { getCollectionsParams, getMarketplaceListFilterType } from './utils' export function useMarketplaceData() { - const [searchPluginText] = useSearchPluginText() + const [searchPluginTextOriginal] = useSearchPluginText() + const searchPluginText = useDebounce(searchPluginTextOriginal, { wait: 500 }) const [filterPluginTags] = useFilterPluginTags() const [activePluginType] = useActivePluginType() @@ -31,8 +32,7 @@ export function useMarketplaceData() { } }, [isSearchMode, searchPluginText, activePluginType, filterPluginTags, sort]) - const deferredQueryParams = useDebounce(queryParams, { wait: 500 }) - const pluginsQuery = useMarketplacePlugins(deferredQueryParams) + const pluginsQuery = useMarketplacePlugins(queryParams) const { hasNextPage, fetchNextPage } = pluginsQuery const handlePageChange = useCallback(() => { From a85c811512cd938dc36d449eead07559defe6db3 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Fri, 9 Jan 2026 14:17:21 +0800 Subject: [PATCH 58/58] fix load next page --- web/app/components/plugins/marketplace/state.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/app/components/plugins/marketplace/state.ts b/web/app/components/plugins/marketplace/state.ts index 7eab3b26665772..1c1abfc0a16e30 100644 --- a/web/app/components/plugins/marketplace/state.ts +++ b/web/app/components/plugins/marketplace/state.ts @@ -33,12 +33,12 @@ export function useMarketplaceData() { }, [isSearchMode, searchPluginText, activePluginType, filterPluginTags, sort]) const pluginsQuery = useMarketplacePlugins(queryParams) - const { hasNextPage, fetchNextPage } = pluginsQuery + const { hasNextPage, fetchNextPage, isFetching } = pluginsQuery const handlePageChange = useCallback(() => { - if (hasNextPage) + if (hasNextPage && !isFetching) fetchNextPage() - }, [fetchNextPage, hasNextPage]) + }, [fetchNextPage, hasNextPage, isFetching]) // Scroll pagination useMarketplaceContainerScroll(handlePageChange)