diff --git a/web/app/components/plugins/marketplace/__tests__/server-entry.spec.tsx b/web/app/components/plugins/marketplace/__tests__/server-entry.spec.tsx
index 34e350ac773..f601155334e 100644
--- a/web/app/components/plugins/marketplace/__tests__/server-entry.spec.tsx
+++ b/web/app/components/plugins/marketplace/__tests__/server-entry.spec.tsx
@@ -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(
+
+ {element}
+ ,
+ )
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((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
+ })
})
diff --git a/web/app/components/plugins/marketplace/hydration-server.tsx b/web/app/components/plugins/marketplace/hydration-server.tsx
index a54da0b6e29..6c3c88a6d8c 100644
--- a/web/app/components/plugins/marketplace/hydration-server.tsx
+++ b/web/app/components/plugins/marketplace/hydration-server.tsx
@@ -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) {
+export async function prefetchMarketplaceDehydratedState(searchParams?: Promise) {
if (!searchParams) {
return
}
@@ -49,11 +50,16 @@ async function getDehydratedState(searchParams?: Promise) {
export async function HydrateQueryClient({
searchParams,
+ prefetchedState,
children,
}: {
searchParams: Promise | undefined
+ prefetchedState?: DehydratedState
children: React.ReactNode
}) {
- const dehydratedState = await getDehydratedState(searchParams)
+ const dehydratedState =
+ prefetchedState === undefined
+ ? await prefetchMarketplaceDehydratedState(searchParams)
+ : prefetchedState
return {children}
}
diff --git a/web/app/components/plugins/marketplace/index.tsx b/web/app/components/plugins/marketplace/index.tsx
index 59cc0b5aee1..475c41620cf 100644
--- a/web/app/components/plugins/marketplace/index.tsx
+++ b/web/app/components/plugins/marketplace/index.tsx
@@ -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 (
+
+
+
)
}
diff --git a/web/app/components/plugins/marketplace/server-budget.ts b/web/app/components/plugins/marketplace/server-budget.ts
index 290fecc3506..5e8c3627899 100644
--- a/web/app/components/plugins/marketplace/server-budget.ts
+++ b/web/app/components/plugins/marketplace/server-budget.ts
@@ -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
diff --git a/web/app/components/plugins/marketplace/standalone/__tests__/exports.spec.ts b/web/app/components/plugins/marketplace/standalone/__tests__/exports.spec.ts
index a94847cf151..43c4d1d4b6e 100644
--- a/web/app/components/plugins/marketplace/standalone/__tests__/exports.spec.ts
+++ b/web/app/components/plugins/marketplace/standalone/__tests__/exports.spec.ts
@@ -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')
})
diff --git a/web/app/components/plugins/marketplace/standalone/server.ts b/web/app/components/plugins/marketplace/standalone/server.ts
index 32237f491ae..f5022088d2b 100644
--- a/web/app/components/plugins/marketplace/standalone/server.ts
+++ b/web/app/components/plugins/marketplace/standalone/server.ts
@@ -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,