fix(web): overlap homepage banners with catalog prefetch

Banners and collection prefetch no longer run back-to-back 2.5s waits.
The home variant starts both under one server budget and reuses the
dehydrated state so a slow banner cannot add a second document stall.
This commit is contained in:
CodingOnStar 2026-09-03 11:56:21 +08:00
parent aa40a404d9
commit ebccd86338
6 changed files with 76 additions and 21 deletions

View File

@ -1,5 +1,6 @@
import type { PluginBanner } from '@dify/contracts/marketplace'
import type { ReactNode } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@ -23,6 +24,7 @@ vi.mock('../home/banners', async (importOriginal) => {
vi.mock('../hydration-server', () => ({
HydrateQueryClient: ({ children }: { children: ReactNode }) => children,
prefetchMarketplaceDehydratedState: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('../view', () => ({
@ -56,10 +58,38 @@ describe('Marketplace server entry', () => {
const { default: Marketplace } = await import('../index')
const element = await Marketplace({ variant: 'home' })
render(element)
render(
<QueryClientProvider client={new QueryClient()}>
{element}
</QueryClientProvider>,
)
expect(screen.getByText('Server banners: 1')).toBeInTheDocument()
expect(mockGetLocaleOnServer).toHaveBeenCalledOnce()
expect(mockFetchPluginBanners).toHaveBeenCalledWith('en-US')
})
it('starts catalog prefetch without waiting for banners to finish', async () => {
const { prefetchMarketplaceDehydratedState } = await import('../hydration-server')
let resolveBanners: (banners: PluginBanner[]) => void = () => {}
mockGetLocaleOnServer.mockResolvedValue('en-US')
mockFetchPluginBanners.mockImplementation(
() =>
new Promise<PluginBanner[]>((resolve) => {
resolveBanners = resolve
}),
)
vi.mocked(prefetchMarketplaceDehydratedState).mockResolvedValue(undefined)
const { default: Marketplace } = await import('../index')
const renderPromise = Marketplace({ variant: 'home', searchParams: Promise.resolve({}) })
await vi.waitFor(() => {
expect(prefetchMarketplaceDehydratedState).toHaveBeenCalled()
})
expect(mockFetchPluginBanners).toHaveBeenCalledWith('en-US')
resolveBanners([])
await renderPromise
})
})

View File

@ -1,5 +1,6 @@
import type { SearchParams } from 'nuqs/server'
import type { MarketplaceSearchParams } from './search-params'
import type { DehydratedState } from '@tanstack/react-query'
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
import { createLoader } from 'nuqs/server'
import { getQueryClient } from '@/app/get-query-client'
@ -16,7 +17,7 @@ import { getCollectionsParams, getMarketplaceCollectionsAndPlugins } from './uti
// The server side logic should move to marketplace's codebase so that we can get rid of Next.js
async function getDehydratedState(searchParams?: Promise<SearchParams>) {
export async function prefetchMarketplaceDehydratedState(searchParams?: Promise<SearchParams>) {
if (!searchParams) {
return
}
@ -49,11 +50,16 @@ async function getDehydratedState(searchParams?: Promise<SearchParams>) {
export async function HydrateQueryClient({
searchParams,
prefetchedState,
children,
}: {
searchParams: Promise<SearchParams> | undefined
prefetchedState?: DehydratedState
children: React.ReactNode
}) {
const dehydratedState = await getDehydratedState(searchParams)
const dehydratedState =
prefetchedState === undefined
? await prefetchMarketplaceDehydratedState(searchParams)
: prefetchedState
return <HydrationBoundary state={dehydratedState}>{children}</HydrationBoundary>
}

View File

@ -3,7 +3,7 @@ import type { SearchParams } from 'nuqs'
import type { MarketplaceViewProps } from './view'
import { getLocaleOnServer } from '@/i18n-config/server'
import { fetchPluginBanners } from './home/banners'
import { HydrateQueryClient } from './hydration-server'
import { HydrateQueryClient, prefetchMarketplaceDehydratedState } from './hydration-server'
import { withinServerBudget } from './server-budget'
import { MarketplaceView } from './view'
@ -25,18 +25,33 @@ const Marketplace = async ({
if (variant === 'home') {
const locale = language ?? (await getLocaleOnServer())
const prefetch = prefetchMarketplaceDehydratedState(searchParams)
// Banners are decoration on a page whose point is the catalog, so the same
// budget that keeps the prefetch from holding the document applies here.
// A late resolution just misses this render; nothing waits on it.
// Banners are decoration on a page whose point is the catalog. Overlap
// them with the catalog prefetch so the document waits at most one budget.
// A late banner resolution just misses this render; nothing waits on it.
await withinServerBudget(
fetchPluginBanners(locale)
.then((banners) => {
trendingBanners = banners
})
.catch(() => {
// Keep the homepage available if Marketplace banner delivery is down.
}),
Promise.all([
fetchPluginBanners(locale)
.then((banners) => {
trendingBanners = banners
})
.catch(() => {
// Keep the homepage available if Marketplace banner delivery is down.
}),
prefetch,
]),
)
return (
<HydrateQueryClient searchParams={undefined} prefetchedState={await prefetch}>
<MarketplaceView
{...viewProps}
banners={trendingBanners}
language={language}
variant={variant}
/>
</HydrateQueryClient>
)
}

View File

@ -15,11 +15,8 @@
* blocking. Server rendering degrades exactly when it is too slow to be worth
* waiting for.
*
* Known limitation: the catalog spends this budget twice in sequence banners
* in `index.tsx`, then the prefetch in `hydration-server.tsx` so the worst
* case is 2x. Overlapping them means handing the started prefetch promise down
* instead of letting `HydrateQueryClient` own it, which is a wider change than
* bounding the waits.
* Homepage (`variant="home"`) overlaps banners and catalog prefetch under one
* budget so a slow banner cannot add a second 2.5s onto the catalog wait.
*/
export const SERVER_PREFETCH_BUDGET_MS = 2_500

View File

@ -3,7 +3,10 @@ import { standaloneMarketplaceClient } from '../client'
import { standaloneMarketplaceServer } from '../server'
vi.mock('../../index', () => ({ default: () => null }))
vi.mock('../../hydration-server', () => ({ HydrateQueryClient: () => null }))
vi.mock('../../hydration-server', () => ({
HydrateQueryClient: () => null,
prefetchMarketplaceDehydratedState: vi.fn(),
}))
describe('standalone Marketplace host entry', () => {
it('exports the client search surface', () => {
@ -13,6 +16,9 @@ describe('standalone Marketplace host entry', () => {
it('exports the server prefetch helpers and creator model', () => {
expect(standaloneMarketplaceServer.withinServerBudget).toEqual(expect.any(Function))
expect(standaloneMarketplaceServer.prefetchMarketplaceDehydratedState).toEqual(
expect.any(Function),
)
expect(standaloneMarketplaceServer.SERVER_PREFETCH_BUDGET_MS).toBeGreaterThan(0)
expect(standaloneMarketplaceServer.parseCreatorSortField('popularity')).toBe('popularity')
})

View File

@ -13,13 +13,14 @@ import {
sortCreatorCreations,
toPublisherSortQuery,
} from '../creator-profile/model'
import { HydrateQueryClient } from '../hydration-server'
import { HydrateQueryClient, prefetchMarketplaceDehydratedState } from '../hydration-server'
import Marketplace from '../index'
import { SERVER_PREFETCH_BUDGET_MS, withinServerBudget } from '../server-budget'
export const standaloneMarketplaceServer = {
Marketplace,
HydrateQueryClient,
prefetchMarketplaceDehydratedState,
SERVER_PREFETCH_BUDGET_MS,
withinServerBudget,
adaptCreatorProfile,