Compare commits

...

1 Commits

Author SHA1 Message Date
yyh
e2546caa1f feat(web): improve marketplace SSR prefetch and route loading 2026-02-11 14:09:47 +08:00
7 changed files with 206 additions and 15 deletions

View File

@@ -0,0 +1,7 @@
import Loading from '@/app/components/base/loading'
const PluginsLoading = () => {
return <Loading type="app" />
}
export default PluginsLoading

View File

@@ -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<SearchParams>
}
const PluginList = ({ searchParams }: PluginListProps) => {
return (
<PluginPage
plugins={<PluginsPanel />}
marketplace={<Marketplace pluginTypeSwitchClassName="top-[60px]" />}
marketplace={<Marketplace pluginTypeSwitchClassName="top-[60px]" searchParams={searchParams} />}
/>
)
}

View File

@@ -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<unknown>
}) => options.queryFn({ pageParam: 1, signal: new AbortController().signal })),
mockPrefetchQuery: vi.fn(async (options: { queryFn: () => Promise<unknown> }) => 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: <div>children</div>,
})
}
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()
})
})

View File

@@ -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<SearchParams>) {
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<Promise<unknown>> = []
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)
}

View File

@@ -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,
})
}

View File

@@ -3,7 +3,7 @@ import { parseAsArrayOf, parseAsString, parseAsStringEnum } from 'nuqs/server'
import { PLUGIN_TYPE_SEARCH_MAP } from './constants'
export const marketplaceSearchParamsParsers = {
category: parseAsStringEnum<ActivePluginType>(Object.values(PLUGIN_TYPE_SEARCH_MAP) as ActivePluginType[]).withDefault('all').withOptions({ history: 'replace', clearOnDefault: false }),
category: parseAsStringEnum<ActivePluginType>(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' }),
}

View File

@@ -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,
}
}