diff --git a/web/app/(commonLayout)/plugins/loading.tsx b/web/app/(commonLayout)/plugins/loading.tsx new file mode 100644 index 0000000000..f58f96a49c --- /dev/null +++ b/web/app/(commonLayout)/plugins/loading.tsx @@ -0,0 +1,7 @@ +import Loading from '@/app/components/base/loading' + +const PluginsLoading = () => { + return +} + +export default PluginsLoading diff --git a/web/app/(commonLayout)/plugins/page.tsx b/web/app/(commonLayout)/plugins/page.tsx index f366200cf9..c7dc6344e0 100644 --- a/web/app/(commonLayout)/plugins/page.tsx +++ b/web/app/(commonLayout)/plugins/page.tsx @@ -1,12 +1,17 @@ +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 = () => { +type PluginListProps = { + searchParams: Promise +} + +const PluginList = ({ searchParams }: PluginListProps) => { return ( } - marketplace={} + marketplace={} /> ) } diff --git a/web/app/components/plugins/marketplace/hydration-server.spec.tsx b/web/app/components/plugins/marketplace/hydration-server.spec.tsx new file mode 100644 index 0000000000..279c948eea --- /dev/null +++ b/web/app/components/plugins/marketplace/hydration-server.spec.tsx @@ -0,0 +1,134 @@ +import type { SearchParams } from 'nuqs' +import type { ReactNode } from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockDehydrate, + mockGetCollectionsParams, + mockGetMarketplaceCollectionsAndPlugins, + mockGetMarketplaceListFilterType, + mockGetMarketplacePlugins, + mockPrefetchInfiniteQuery, + mockPrefetchQuery, +} = vi.hoisted(() => ({ + mockDehydrate: vi.fn(() => ({ dehydrated: true })), + mockGetCollectionsParams: vi.fn((category: string) => ({ category })), + mockGetMarketplaceCollectionsAndPlugins: vi.fn(async () => ({ marketplaceCollections: [], marketplaceCollectionPluginsMap: {} })), + mockGetMarketplaceListFilterType: vi.fn((category: string) => (category === 'bundle' ? 'bundle' : undefined)), + mockGetMarketplacePlugins: vi.fn(async () => ({ plugins: [], total: 0, page: 1, page_size: 40 })), + mockPrefetchInfiniteQuery: vi.fn(async (options: { + queryFn: (context: { pageParam: number, signal: AbortSignal }) => Promise + }) => options.queryFn({ pageParam: 1, signal: new AbortController().signal })), + mockPrefetchQuery: vi.fn(async (options: { queryFn: () => Promise }) => options.queryFn()), +})) + +vi.mock('@tanstack/react-query', () => ({ + dehydrate: mockDehydrate, + HydrationBoundary: ({ children }: { children: ReactNode }) => <>{children}, +})) + +vi.mock('@/context/query-client-server', () => ({ + getQueryClientServer: () => ({ + prefetchQuery: mockPrefetchQuery, + prefetchInfiniteQuery: mockPrefetchInfiniteQuery, + }), +})) + +vi.mock('@/service/client', () => ({ + marketplaceQuery: { + collections: { + queryKey: vi.fn((input: unknown) => ['collections', input]), + }, + searchAdvanced: { + queryKey: vi.fn((input: unknown) => ['searchAdvanced', input]), + }, + }, +})) + +vi.mock('./utils', () => ({ + getCollectionsParams: mockGetCollectionsParams, + getMarketplaceCollectionsAndPlugins: mockGetMarketplaceCollectionsAndPlugins, + getMarketplaceListFilterType: mockGetMarketplaceListFilterType, + getMarketplacePlugins: mockGetMarketplacePlugins, +})) + +const renderHydration = async (searchParams?: SearchParams) => { + const { HydrateQueryClient } = await import('./hydration-server') + return HydrateQueryClient({ + searchParams: searchParams ? Promise.resolve(searchParams) : undefined, + children:
children
, + }) +} + +describe('HydrateQueryClient', () => { + beforeEach(() => { + mockDehydrate.mockClear() + mockGetCollectionsParams.mockClear() + mockGetMarketplaceCollectionsAndPlugins.mockClear() + mockGetMarketplaceListFilterType.mockClear() + mockGetMarketplacePlugins.mockClear() + mockPrefetchInfiniteQuery.mockClear() + mockPrefetchQuery.mockClear() + }) + + it('should prefetch collections query for default non-search mode', async () => { + await renderHydration({ category: 'all' }) + + expect(mockPrefetchQuery).toHaveBeenCalledTimes(1) + expect(mockPrefetchInfiniteQuery).not.toHaveBeenCalled() + expect(mockGetCollectionsParams).toHaveBeenCalledWith('all') + expect(mockGetMarketplaceCollectionsAndPlugins).toHaveBeenCalledTimes(1) + expect(mockGetMarketplacePlugins).not.toHaveBeenCalled() + expect(mockDehydrate).toHaveBeenCalledTimes(1) + }) + + it('should prefetch searchAdvanced query when query text exists', async () => { + await renderHydration({ category: 'all', q: 'search-term' }) + + expect(mockPrefetchQuery).not.toHaveBeenCalled() + expect(mockPrefetchInfiniteQuery).toHaveBeenCalledTimes(1) + expect(mockGetMarketplaceCollectionsAndPlugins).not.toHaveBeenCalled() + expect(mockGetMarketplacePlugins).toHaveBeenCalledWith( + { + query: 'search-term', + category: undefined, + tags: [], + sort_by: 'install_count', + sort_order: 'DESC', + type: undefined, + }, + 1, + expect.any(AbortSignal), + ) + expect(mockDehydrate).toHaveBeenCalledTimes(1) + }) + + it('should prefetch searchAdvanced query for non-collection category', async () => { + await renderHydration({ category: 'model' }) + + expect(mockPrefetchQuery).not.toHaveBeenCalled() + expect(mockPrefetchInfiniteQuery).toHaveBeenCalledTimes(1) + expect(mockGetMarketplaceCollectionsAndPlugins).not.toHaveBeenCalled() + expect(mockGetMarketplaceListFilterType).toHaveBeenCalledWith('model') + expect(mockGetMarketplacePlugins).toHaveBeenCalledWith( + { + query: '', + category: 'model', + tags: [], + sort_by: 'install_count', + sort_order: 'DESC', + type: undefined, + }, + 1, + expect.any(AbortSignal), + ) + }) + + it('should skip prefetch when search params are missing', async () => { + await renderHydration() + + expect(mockPrefetchQuery).not.toHaveBeenCalled() + expect(mockPrefetchInfiniteQuery).not.toHaveBeenCalled() + expect(mockDehydrate).not.toHaveBeenCalled() + }) +}) diff --git a/web/app/components/plugins/marketplace/hydration-server.tsx b/web/app/components/plugins/marketplace/hydration-server.tsx index b01f4dd463..62e4fef626 100644 --- a/web/app/components/plugins/marketplace/hydration-server.tsx +++ b/web/app/components/plugins/marketplace/hydration-server.tsx @@ -1,11 +1,17 @@ import type { SearchParams } from 'nuqs' +import type { PluginsSearchParams } from './types' import { dehydrate, HydrationBoundary } from '@tanstack/react-query' import { createLoader } from 'nuqs/server' import { getQueryClientServer } from '@/context/query-client-server' import { marketplaceQuery } from '@/service/client' -import { PLUGIN_CATEGORY_WITH_COLLECTIONS } from './constants' +import { DEFAULT_SORT, PLUGIN_CATEGORY_WITH_COLLECTIONS, PLUGIN_TYPE_SEARCH_MAP } from './constants' import { marketplaceSearchParamsParsers } from './search-params' -import { getCollectionsParams, getMarketplaceCollectionsAndPlugins } from './utils' +import { + getCollectionsParams, + getMarketplaceCollectionsAndPlugins, + getMarketplaceListFilterType, + getMarketplacePlugins, +} from './utils' // The server side logic should move to marketplace's codebase so that we can get rid of Next.js @@ -13,19 +19,49 @@ async function getDehydratedState(searchParams?: Promise) { if (!searchParams) { return } + const loadSearchParams = createLoader(marketplaceSearchParamsParsers) const params = await loadSearchParams(searchParams) + const queryClient = getQueryClientServer() + const isSearchMode = !!params.q + || params.tags.length > 0 + || !PLUGIN_CATEGORY_WITH_COLLECTIONS.has(params.category) + const prefetchTasks: Array> = [] - if (!PLUGIN_CATEGORY_WITH_COLLECTIONS.has(params.category)) { + if (!isSearchMode && PLUGIN_CATEGORY_WITH_COLLECTIONS.has(params.category)) { + prefetchTasks.push(queryClient.prefetchQuery({ + queryKey: marketplaceQuery.collections.queryKey({ input: { query: getCollectionsParams(params.category) } }), + queryFn: () => getMarketplaceCollectionsAndPlugins(getCollectionsParams(params.category)), + })) + } + + if (isSearchMode) { + const queryParams: PluginsSearchParams = { + query: params.q, + category: params.category === PLUGIN_TYPE_SEARCH_MAP.all ? undefined : params.category, + tags: params.tags, + sort_by: DEFAULT_SORT.sortBy, + sort_order: DEFAULT_SORT.sortOrder, + type: getMarketplaceListFilterType(params.category), + } + + prefetchTasks.push(queryClient.prefetchInfiniteQuery({ + queryKey: marketplaceQuery.searchAdvanced.queryKey({ + input: { + body: queryParams, + params: { kind: queryParams.type === 'bundle' ? 'bundles' : 'plugins' }, + }, + }), + queryFn: ({ pageParam = 1, signal }) => getMarketplacePlugins(queryParams, Number(pageParam), signal), + initialPageParam: 1, + })) + } + + if (!prefetchTasks.length) { return } - const queryClient = getQueryClientServer() - - await queryClient.prefetchQuery({ - queryKey: marketplaceQuery.collections.queryKey({ input: { query: getCollectionsParams(params.category) } }), - queryFn: () => getMarketplaceCollectionsAndPlugins(getCollectionsParams(params.category)), - }) + await Promise.all(prefetchTasks) return dehydrate(queryClient) } diff --git a/web/app/components/plugins/marketplace/query.ts b/web/app/components/plugins/marketplace/query.ts index 35d99a2bd5..6114f9fd1f 100644 --- a/web/app/components/plugins/marketplace/query.ts +++ b/web/app/components/plugins/marketplace/query.ts @@ -4,12 +4,18 @@ import { useInfiniteQuery, useQuery } from '@tanstack/react-query' import { marketplaceQuery } from '@/service/client' import { getMarketplaceCollectionsAndPlugins, getMarketplacePlugins } from './utils' +type CollectionsQueryOptions = { + enabled?: boolean +} + export function useMarketplaceCollectionsAndPlugins( collectionsParams: MarketPlaceInputs['collections']['query'], + options?: CollectionsQueryOptions, ) { return useQuery({ queryKey: marketplaceQuery.collections.queryKey({ input: { query: collectionsParams } }), queryFn: ({ signal }) => getMarketplaceCollectionsAndPlugins(collectionsParams, { signal }), + enabled: options?.enabled ?? true, }) } diff --git a/web/app/components/plugins/marketplace/search-params.ts b/web/app/components/plugins/marketplace/search-params.ts index ad0b16977f..2715deb8a1 100644 --- a/web/app/components/plugins/marketplace/search-params.ts +++ b/web/app/components/plugins/marketplace/search-params.ts @@ -3,7 +3,7 @@ import { parseAsArrayOf, parseAsString, parseAsStringEnum } from 'nuqs/server' import { PLUGIN_TYPE_SEARCH_MAP } from './constants' export const marketplaceSearchParamsParsers = { - category: parseAsStringEnum(Object.values(PLUGIN_TYPE_SEARCH_MAP) as ActivePluginType[]).withDefault('all').withOptions({ history: 'replace', clearOnDefault: false }), + category: parseAsStringEnum(Object.values(PLUGIN_TYPE_SEARCH_MAP) as ActivePluginType[]).withDefault('all').withOptions({ history: 'replace' }), q: parseAsString.withDefault('').withOptions({ history: 'replace' }), tags: parseAsArrayOf(parseAsString).withDefault([]).withOptions({ history: 'replace' }), } diff --git a/web/app/components/plugins/marketplace/state.ts b/web/app/components/plugins/marketplace/state.ts index 4954acd60c..8fef17bba4 100644 --- a/web/app/components/plugins/marketplace/state.ts +++ b/web/app/components/plugins/marketplace/state.ts @@ -2,7 +2,7 @@ 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' +import { PLUGIN_CATEGORY_WITH_COLLECTIONS, PLUGIN_TYPE_SEARCH_MAP } from './constants' import { useMarketplaceContainerScroll } from './hooks' import { useMarketplaceCollectionsAndPlugins, useMarketplacePlugins } from './query' import { getCollectionsParams, getMarketplaceListFilterType } from './utils' @@ -12,13 +12,16 @@ export function useMarketplaceData() { const searchPluginText = useDebounce(searchPluginTextOriginal, { wait: 500 }) const [filterPluginTags] = useFilterPluginTags() const [activePluginType] = useActivePluginType() + const isSearchMode = useMarketplaceSearchMode() const collectionsQuery = useMarketplaceCollectionsAndPlugins( getCollectionsParams(activePluginType), + { + enabled: !isSearchMode && PLUGIN_CATEGORY_WITH_COLLECTIONS.has(activePluginType), + }, ) const sort = useMarketplaceSortValue() - const isSearchMode = useMarketplaceSearchMode() const queryParams = useMemo((): PluginsSearchParams | undefined => { if (!isSearchMode) return undefined @@ -49,7 +52,7 @@ export function useMarketplaceData() { 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, + isLoading: (isSearchMode ? false : collectionsQuery.isLoading) || pluginsQuery.isLoading, isFetchingNextPage, } }