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,