dify/web/app/components/plugins/marketplace/query-options.ts
L1nSn0w 66a3c9620a fix(web): stop Marketplace search jitter and indefinite loading
Three independent defects made catalog search feel unstable.

Stale results flash on every search. `isSearchMode` was derived from the raw
nuqs `q` while the request body used the 500ms-debounced value, so keystroke #1
flipped into search mode with an empty query. That fired a full empty-query
search whose generic top-plugins list rendered for the debounce window before
the real results replaced it. Both now read the same debounced value.

Grid unmount on every query change. The infinite query had no
`placeholderData`, so `data` went undefined whenever the key changed and
ListWrapper's `!isLoading` gate tore the whole result grid out, collapsing the
container and jumping the scroll position. Measured against a live catalog, a
sort change blanked the grid for 152 of 360 sampled frames; with
`keepPreviousData` and an `isRefreshing` dim it is 0 of 461.

Requests that never settle. The Marketplace oRPC link called `globalThis.fetch`
with no deadline, so a stalled connection left the query pending forever with
no error state and no retry — the reported permanent spinner. Requests now
carry a 15s deadline composed with (never replacing) react-query's abort
signal. The server prefetch and the banner fetch get their own 2.5s budget:
awaiting them unbounded held the whole RSC response, pushing time-to-first-byte
to ~7s against a 3s-delayed API while the browser sat on the previous page.

Supporting changes:

- `getMarketplacePlugins` and `getMarketplaceCollectionsAndPlugins` no longer
  swallow every rejection into a successful empty page. That turned outages
  and aborted keystrokes into "no plugins found", suppressed retries, cached
  the emptiness for the full staleTime, and killed `getNextPageParam`
  permanently via `total: 0`. ListWrapper now renders a retry affordance.
- The collections fan-out is bounded to 4 concurrent requests instead of
  firing one per collection at once, and per-collection failures degrade to an
  empty carousel rather than a blank catalog.
- Scroll pagination measures once per frame instead of per scroll event. Each
  threshold hit calls `fetchNextPage`, which defaults to `cancelRefetch: true`,
  so an unthrottled burst aborted and restarted the in-flight page request —
  and the backend counts those aborts against its search circuit breaker.
- Cross-page results are deduped by `org/name`; Meilisearch resolves
  `install_count` ties by document order and the sync task rewrites those
  documents every minute, so pages can overlap and collide on React keys.
- Search queries no longer inherit `retry: 3`; ~7s of backoff reads as a hang.
2026-08-27 16:27:42 +08:00

36 lines
1.7 KiB
TypeScript

import type { PluginsSearchParams } from '@dify/contracts/marketplace'
import { infiniteQueryOptions, keepPreviousData } from '@tanstack/react-query'
import { marketplaceQuery } from '@/service/client'
import { getMarketplacePlugins } from './utils'
export const getMarketplacePluginsInfiniteQueryOptions = (
queryParams: PluginsSearchParams | undefined,
) =>
infiniteQueryOptions({
queryKey: marketplaceQuery.searchAdvanced.queryKey({
input: {
body: queryParams ?? { query: '' },
params: { kind: queryParams?.type === 'bundle' ? 'bundles' : 'plugins' },
},
}),
queryFn: ({ pageParam = 1, signal }) => getMarketplacePlugins(queryParams, pageParam, signal),
getNextPageParam: (lastPage) => {
const nextPage = lastPage.page + 1
const loaded = lastPage.page * lastPage.page_size
return loaded < (lastPage.total || 0) ? nextPage : undefined
},
initialPageParam: 1,
enabled: queryParams !== undefined,
// Hold the previous term's results while the new query is in flight. Without
// this, `data` goes undefined on every keystroke that survives the debounce,
// the grid unmounts, the container collapses, and the scroll position jumps —
// the "jitter" the Marketplace search is reported for. Consumers show a
// quiet pending state off `isPlaceholderData` instead.
placeholderData: keepPreviousData,
// Matches the autocomplete queries. Now that the fetcher propagates
// failures, react-query's default of 3 retries would hold isFetching true
// through ~7s of backoff — indistinguishable from a hang. Failing fast and
// offering an explicit Retry is both honest and fewer requests to abort.
retry: false,
})