perf(web): defer offscreen marketplace content

This commit is contained in:
CodingOnStar 2026-08-04 17:39:35 +08:00
parent 02f41e145b
commit 00861ac5eb
18 changed files with 1126 additions and 150 deletions

View File

@ -3,7 +3,7 @@ import './scripts/env-register'
const hasCliTags = process.argv.some((arg) => arg === '--tags' || arg.startsWith('--tags='))
const defaultNonExternalTags =
'not @axe and not @prepared and not @external-model and not @external-tool'
'not @axe and not @prepared and not @external-model and not @external-tool and not @marketplace-performance'
const selectedTags =
process.env.E2E_CUCUMBER_TAGS || (hasCliTags ? undefined : defaultNonExternalTags)
const tags = selectedTags ? `(${selectedTags}) and not @skip` : 'not @skip'

View File

@ -0,0 +1,5 @@
@marketplace-performance
Feature: Embedded Marketplace performance budget
Scenario: The first Marketplace collection stays within the initial rendering budget
When I measure the embedded Marketplace under Fast 4G and 4x CPU throttling
Then the embedded Marketplace should meet its initial rendering budgets

View File

@ -0,0 +1,97 @@
import type { DifyWorld, MarketplacePerformanceMetrics } from '../support/world'
import { Then, When } from '@cucumber/cucumber'
import { expect } from '@playwright/test'
import { e2eBrowser } from '../../test-env'
const FIRST_CARD_BUDGET_MS = 1_500
const DOCUMENT_ELEMENT_BUDGET = 2_000
const LONG_TASK_BUDGET_MS = 200
const FAST_4G_DOWNLOAD_BYTES_PER_SECOND = 4_000_000 / 8
const FAST_4G_UPLOAD_BYTES_PER_SECOND = 3_000_000 / 8
type PerformanceWindow = Window & {
__marketplaceLongTaskDurations?: number[]
}
When(
'I measure the embedded Marketplace under Fast 4G and 4x CPU throttling',
async function (this: DifyWorld) {
if (e2eBrowser !== 'chromium')
throw new Error('The Marketplace performance benchmark requires E2E_BROWSER=chromium.')
if (!this.context)
throw new Error('Playwright context has not been initialized for this scenario.')
const page = this.getPage()
const cdpSession = await this.context.newCDPSession(page)
try {
await page.addInitScript(() => {
const performanceWindow = window as PerformanceWindow
performanceWindow.__marketplaceLongTaskDurations = []
if (!PerformanceObserver.supportedEntryTypes.includes('longtask')) return
const observer = new PerformanceObserver((entries) => {
performanceWindow.__marketplaceLongTaskDurations!.push(
...entries.getEntries().map((entry) => entry.duration),
)
})
observer.observe({ type: 'longtask', buffered: true })
})
await cdpSession.send('Network.enable')
await cdpSession.send('Network.emulateNetworkConditions', {
connectionType: 'cellular4g',
downloadThroughput: FAST_4G_DOWNLOAD_BYTES_PER_SECOND,
latency: 60,
offline: false,
uploadThroughput: FAST_4G_UPLOAD_BYTES_PER_SECOND,
})
await cdpSession.send('Emulation.setCPUThrottlingRate', { rate: 4 })
await page.goto('/marketplace', { waitUntil: 'domcontentloaded' })
await page.locator('[data-marketplace-card]').first().waitFor({
state: 'visible',
timeout: 30_000,
})
const firstCardVisibleMs = await page.evaluate(() => performance.now())
await page.evaluate(
() =>
new Promise<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
}),
)
this.marketplacePerformanceMetrics = await page.evaluate(
(visibleMs): MarketplacePerformanceMetrics => {
const performanceWindow = window as PerformanceWindow
const longTaskDurations = performanceWindow.__marketplaceLongTaskDurations ?? []
return {
firstCardVisibleMs: visibleMs,
documentElementCount: document.querySelectorAll('*').length,
longestTaskMs: Math.max(0, ...longTaskDurations),
}
},
firstCardVisibleMs,
)
} finally {
await cdpSession.detach()
}
},
)
Then(
'the embedded Marketplace should meet its initial rendering budgets',
async function (this: DifyWorld) {
const metrics = this.marketplacePerformanceMetrics
if (!metrics) throw new Error('Marketplace performance metrics were not captured.')
this.attach(JSON.stringify(metrics, null, 2), 'application/json')
expect(metrics.firstCardVisibleMs).toBeLessThanOrEqual(FIRST_CARD_BUDGET_MS)
expect(metrics.documentElementCount).toBeLessThanOrEqual(DOCUMENT_ELEMENT_BUDGET)
expect(metrics.longestTaskMs).toBeLessThanOrEqual(LONG_TASK_BUDGET_MS)
},
)

View File

@ -77,6 +77,12 @@ export const createAgentBuilderWorldState = () => ({
export type AgentBuilderWorldState = ReturnType<typeof createAgentBuilderWorldState>
export type MarketplacePerformanceMetrics = {
firstCardVisibleMs: number
documentElementCount: number
longestTaskMs: number
}
export class DifyWorld extends World {
context: BrowserContext | undefined
consoleRequestContext: APIRequestContext | undefined
@ -102,6 +108,7 @@ export class DifyWorld extends World {
capturedDownloads: Download[] = []
shareURL: string | undefined
sharedAppPage: Page | undefined
marketplacePerformanceMetrics: MarketplacePerformanceMetrics | undefined
constructor(options: IWorldOptions) {
super(options)
@ -127,6 +134,7 @@ export class DifyWorld extends World {
this.capturedDownloads = []
this.shareURL = undefined
this.sharedAppPage = undefined
this.marketplacePerformanceMetrics = undefined
}
async startSession(browser: Browser, authenticated: boolean) {

View File

@ -15,6 +15,7 @@
"e2e:install": "playwright install --with-deps chromium webkit",
"e2e:install:ci": "playwright install --with-deps --only-shell chromium webkit",
"e2e:install:ci:chromium": "playwright install --with-deps --only-shell chromium",
"e2e:marketplace-performance": "tsx ./scripts/run-cucumber.ts --tags @marketplace-performance",
"e2e:middleware:down": "tsx ./scripts/setup.ts middleware-down",
"e2e:middleware:up": "tsx ./scripts/setup.ts middleware-up",
"e2e:post-merge": "tsx ./scripts/run-post-merge.ts",

View File

@ -16,7 +16,7 @@ const hasCustomTags = (forwardArgs: string[]) =>
forwardArgs.some((arg) => arg === '--tags' || arg.startsWith('--tags='))
const fullNonExternalTags =
'not @axe and not @prepared and not @external-model and not @external-tool'
'not @axe and not @prepared and not @external-model and not @external-tool and not @marketplace-performance'
const seedCeleryQueues = 'dataset,priority_dataset,workflow_based_app_execution'
const readLogTail = async (logFilePath: string) => {

View File

@ -2856,14 +2856,6 @@
"count": 1
}
},
"web/app/components/plugins/marketplace/list/list-with-collection.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 1
},
"jsx_a11y/no-static-element-interactions": {
"count": 1
}
},
"web/app/components/plugins/plugin-auth/authorized/index.tsx": {
"no-restricted-imports": {
"count": 1

View File

@ -1,7 +1,7 @@
import type { PluginBanner } from '../banners'
import { render, screen, within } from '@testing-library/react'
import { act, fireEvent, render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import HomeTrending from '../home-trending'
vi.mock('#i18n', async () => {
@ -98,6 +98,10 @@ const banners: PluginBanner[] = [
},
]
afterEach(() => {
vi.unstubAllGlobals()
})
describe('HomeTrending', () => {
it('renders and switches between the three API-backed banner layouts', async () => {
const user = userEvent.setup()
@ -196,6 +200,150 @@ describe('HomeTrending', () => {
matchMedia.mockRestore()
})
it('keeps embedded autoplay paused until every pause reason is cleared', () => {
const pause = vi.fn()
const play = vi.fn()
const cancel = vi.fn()
const progressAnimation = {
cancel,
onfinish: null,
pause,
play,
} as unknown as Animation
const originalAnimate = Element.prototype.animate
Object.defineProperty(Element.prototype, 'animate', {
configurable: true,
value: vi.fn(() => progressAnimation),
})
const intersectionObservers: {
callback: IntersectionObserverCallback
options?: IntersectionObserverInit
}[] = []
class MockIntersectionObserver {
disconnect = vi.fn()
observe = vi.fn()
root: Element | Document | null
rootMargin: string
takeRecords = vi.fn(() => [])
thresholds: readonly number[]
unobserve = vi.fn()
constructor(callback: IntersectionObserverCallback, options?: IntersectionObserverInit) {
this.root = options?.root ?? null
this.rootMargin = options?.rootMargin ?? '0px'
this.thresholds = Array.isArray(options?.threshold)
? options.threshold
: [options?.threshold ?? 0]
intersectionObservers.push({ callback, options })
}
}
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver)
let reducedMotion = false
let reducedMotionListener: (() => void) | undefined
vi.stubGlobal('matchMedia', () => ({
get matches() {
return reducedMotion
},
media: '(prefers-reduced-motion: reduce)',
onchange: null,
addEventListener: (_event: string, listener: () => void) => {
reducedMotionListener = listener
},
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
}))
const marketplaceContainer = document.createElement('div')
marketplaceContainer.id = 'marketplace-container'
document.body.appendChild(marketplaceContainer)
const { unmount } = render(<HomeTrending banners={banners} isMarketplacePlatform={false} />, {
container: marketplaceContainer,
})
const carouselRoot = marketplaceContainer.querySelector('[data-home-trending-carousel-root]')!
const viewportObserver = intersectionObservers.find(
(observer) => observer.options?.threshold === 0.25,
)
const setIntersectionRatio = (intersectionRatio: number) => {
act(() => {
viewportObserver?.callback(
[
{
intersectionRatio,
isIntersecting: intersectionRatio > 0,
} as IntersectionObserverEntry,
],
{} as IntersectionObserver,
)
})
}
expect(pause).toHaveBeenCalled()
setIntersectionRatio(0.25)
expect(play).toHaveBeenCalledOnce()
fireEvent.mouseEnter(carouselRoot)
setIntersectionRatio(0)
fireEvent.mouseLeave(carouselRoot)
expect(play).toHaveBeenCalledOnce()
setIntersectionRatio(0.25)
expect(play).toHaveBeenCalledTimes(2)
const playsBeforeFocus = play.mock.calls.length
const focusTarget = carouselRoot.querySelector('a')!
fireEvent.focusIn(focusTarget)
setIntersectionRatio(0)
setIntersectionRatio(0.25)
expect(play).toHaveBeenCalledTimes(playsBeforeFocus)
fireEvent.focusOut(focusTarget, { relatedTarget: null })
expect(play.mock.calls.length).toBeGreaterThan(playsBeforeFocus)
const playsBeforeUserPause = play.mock.calls.length
fireEvent.click(screen.getByRole('button', { name: 'plugin.marketplace.home.trendingPause' }))
setIntersectionRatio(0)
setIntersectionRatio(0.25)
expect(play).toHaveBeenCalledTimes(playsBeforeUserPause)
fireEvent.click(screen.getByRole('button', { name: 'plugin.marketplace.home.trendingPlay' }))
expect(play.mock.calls.length).toBeGreaterThan(playsBeforeUserPause)
const playsBeforeVisibilityPause = play.mock.calls.length
Object.defineProperty(document, 'visibilityState', {
configurable: true,
value: 'hidden',
})
fireEvent(document, new Event('visibilitychange'))
setIntersectionRatio(0.25)
expect(play).toHaveBeenCalledTimes(playsBeforeVisibilityPause)
Object.defineProperty(document, 'visibilityState', {
configurable: true,
value: 'visible',
})
fireEvent(document, new Event('visibilitychange'))
expect(play.mock.calls.length).toBeGreaterThan(playsBeforeVisibilityPause)
const playsBeforeReducedMotion = play.mock.calls.length
reducedMotion = true
reducedMotionListener?.()
setIntersectionRatio(0)
setIntersectionRatio(0.25)
expect(play).toHaveBeenCalledTimes(playsBeforeReducedMotion)
reducedMotion = false
reducedMotionListener?.()
expect(play.mock.calls.length).toBeGreaterThan(playsBeforeReducedMotion)
unmount()
marketplaceContainer.remove()
Object.defineProperty(Element.prototype, 'animate', {
configurable: true,
value: originalAnimate,
})
})
it('renders no carousel when the API returns no banners', () => {
render(<HomeTrending banners={[]} isMarketplacePlatform />)

View File

@ -30,7 +30,7 @@ const PAGINATION_ACTIVE_SHIFT = PAGINATION_ACTIVE_WIDTH - PAGINATION_DOT_SIZE
const getPaginationItemOffset = (index: number, selectedIndex: number) =>
index * PAGINATION_STEP + (index > selectedIndex ? PAGINATION_ACTIVE_SHIFT : 0)
type AutoplayPauseReason = 'focus' | 'hover' | 'reduced-motion' | 'user' | 'visibility'
type AutoplayPauseReason = 'focus' | 'hover' | 'reduced-motion' | 'user' | 'viewport' | 'visibility'
function TrendingCopy({
banner,
@ -331,19 +331,23 @@ function TrendingNavigation({
banners,
selectedIndex,
carouselRootRef,
pauseWhenOffscreen,
onSelect,
onNext,
}: {
banners: PluginBanner[]
selectedIndex: number
carouselRootRef: RefObject<HTMLDivElement | null>
pauseWhenOffscreen: boolean
onSelect: (index: number) => void
onNext: () => void
}) {
const { t } = useTranslation('plugin')
const progressRef = useRef<HTMLSpanElement>(null)
const progressAnimationRef = useRef<Animation | null>(null)
const pauseReasonsRef = useRef(new Set<AutoplayPauseReason>())
const pauseReasonsRef = useRef(
new Set<AutoplayPauseReason>(pauseWhenOffscreen ? ['viewport'] : []),
)
const [isUserPaused, setIsUserPaused] = useState(false)
const [isReducedMotionPaused, setIsReducedMotionPaused] = useState(false)
const isExplicitlyPaused = isUserPaused || isReducedMotionPaused
@ -404,6 +408,7 @@ function TrendingNavigation({
carouselRoot.addEventListener('focusin', handleFocusIn)
carouselRoot.addEventListener('focusout', handleFocusOut)
document.addEventListener('visibilitychange', handleVisibilityChange)
handleVisibilityChange()
return () => {
carouselRoot.removeEventListener('mouseenter', handleMouseEnter)
@ -414,6 +419,36 @@ function TrendingNavigation({
}
}, [carouselRootRef, setPauseReason])
useEffect(() => {
if (!pauseWhenOffscreen) {
setPauseReason('viewport', false)
return
}
const carouselRoot = carouselRootRef.current
if (!carouselRoot) return
if (typeof IntersectionObserver === 'undefined') {
setPauseReason('viewport', false)
return
}
const observer = new IntersectionObserver(
([entry]) => {
const isVisible = !!entry?.isIntersecting && entry.intersectionRatio >= 0.25
setPauseReason('viewport', !isVisible)
},
{
root: document.getElementById('marketplace-container'),
threshold: 0.25,
},
)
observer.observe(carouselRoot)
return () => observer.disconnect()
}, [carouselRootRef, pauseWhenOffscreen, setPauseReason])
useEffect(() => {
const reducedMotionQuery = window.matchMedia('(prefers-reduced-motion: reduce)')
const syncReducedMotion = () => {
@ -569,10 +604,15 @@ function HomeTrending({
banners={banners}
selectedIndex={selectedIndex}
carouselRootRef={carouselRootRef}
pauseWhenOffscreen={!isMarketplacePlatform}
onSelect={selectSlide}
onNext={selectNextSlide}
/>
<div ref={carouselRootRef} className="h-full overflow-hidden rounded-2xl">
<div
ref={carouselRootRef}
className="h-full overflow-hidden rounded-2xl"
data-home-trending-carousel-root
>
<div
aria-live="polite"
className={cn(styles.contentTrack, 'flex h-full')}

View File

@ -68,6 +68,7 @@ const MarketplaceHome = ({
<ListWrapper
activePluginType={activePluginType}
className={styles.catalogContent}
deferOffscreenCollections={!isMarketplacePlatform}
showInstallButton={showInstallButton}
linkToMarketplaceDetail={linkToMarketplaceDetail}
/>

View File

@ -115,6 +115,7 @@ describe('CardWrapper', () => {
renderCardWrapper()
expect(screen.queryByRole('link')).not.toBeInTheDocument()
expect(document.querySelector('[data-marketplace-card="plugin-a"]')).toBeInTheDocument()
expect(screen.getByTestId('card-more-info')).toHaveTextContent('42:tag:search|tag:agent')
})

View File

@ -0,0 +1,266 @@
import type { CarouselPage } from '../carousel'
import { act, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import Carousel from '../carousel'
const mocks = vi.hoisted(() => {
const listeners = new Map<string, Set<() => void>>()
const carouselState = { selectedIndex: 0 }
const api = {
off: vi.fn((event: string, listener: () => void) => {
listeners.get(event)?.delete(listener)
}),
on: vi.fn((event: string, listener: () => void) => {
const eventListeners = listeners.get(event) ?? new Set()
eventListeners.add(listener)
listeners.set(event, eventListeners)
}),
scrollNext: vi.fn(),
scrollPrev: vi.fn(),
scrollSnapList: vi.fn(() => [0, 1, 2, 3, 4]),
scrollTo: vi.fn(),
selectedScrollSnap: vi.fn(() => carouselState.selectedIndex),
}
const autoplayInstances: { play: ReturnType<typeof vi.fn>; stop: ReturnType<typeof vi.fn> }[] = []
const autoplayOptions: Record<string, unknown>[] = []
return {
api,
autoplayInstances,
autoplayOptions,
carouselState,
emit: (event: string) => listeners.get(event)?.forEach((listener) => listener()),
listeners,
}
})
vi.mock('embla-carousel-react', () => ({
default: () => [vi.fn(), mocks.api],
}))
vi.mock('embla-carousel-autoplay', () => ({
default: (options: Record<string, unknown>) => {
const instance = { play: vi.fn(), stop: vi.fn() }
mocks.autoplayOptions.push(options)
mocks.autoplayInstances.push(instance)
return instance
},
}))
const pages: CarouselPage[] = Array.from({ length: 5 }, (_, index) => ({
id: `page-${index + 1}`,
content: <div>Page content {index + 1}</div>,
}))
type IntersectionObserverRecord = {
callback: IntersectionObserverCallback
disconnect: ReturnType<typeof vi.fn>
observe: ReturnType<typeof vi.fn>
options?: IntersectionObserverInit
}
const intersectionObservers: IntersectionObserverRecord[] = []
class MockIntersectionObserver {
callback: IntersectionObserverCallback
disconnect = vi.fn()
observe = vi.fn()
options?: IntersectionObserverInit
root: Element | Document | null
rootMargin: string
takeRecords = vi.fn(() => [])
thresholds: readonly number[]
unobserve = vi.fn()
constructor(callback: IntersectionObserverCallback, options?: IntersectionObserverInit) {
this.callback = callback
this.options = options
this.root = options?.root ?? null
this.rootMargin = options?.rootMargin ?? '0px'
this.thresholds = Array.isArray(options?.threshold)
? options.threshold
: [options?.threshold ?? 0]
intersectionObservers.push(this)
}
}
const installIntersectionObserver = () => {
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver)
}
const triggerIntersection = (record: IntersectionObserverRecord, intersectionRatio: number) => {
act(() => {
record.callback(
[
{
intersectionRatio,
isIntersecting: intersectionRatio > 0,
} as IntersectionObserverEntry,
],
record as unknown as IntersectionObserver,
)
})
}
describe('Marketplace Carousel', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.listeners.clear()
mocks.autoplayInstances.length = 0
mocks.autoplayOptions.length = 0
mocks.carouselState.selectedIndex = 0
intersectionObservers.length = 0
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
callback(0)
return 1
})
Object.defineProperty(document, 'visibilityState', {
configurable: true,
value: 'visible',
})
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('keeps every slide shell while mounting only the current and adjacent pages', () => {
const { rerender } = render(<Carousel pages={pages} deferMountPages />)
expect(document.querySelectorAll('[data-carousel-page]')).toHaveLength(5)
expect(document.querySelectorAll('[data-carousel-page-mounted="true"]')).toHaveLength(3)
expect(screen.getByText('Page content 1')).toBeInTheDocument()
expect(screen.getByText('Page content 2')).toBeInTheDocument()
expect(screen.getByText('Page content 5')).toBeInTheDocument()
expect(screen.queryByText('Page content 3')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Go to page 4' }))
expect(screen.getByText('Page content 3')).toBeInTheDocument()
expect(screen.getByText('Page content 4')).toBeInTheDocument()
expect(mocks.api.scrollTo).toHaveBeenCalledWith(3)
mocks.carouselState.selectedIndex = 3
act(() => mocks.emit('select'))
mocks.carouselState.selectedIndex = 0
act(() => mocks.emit('select'))
expect(screen.getByText('Page content 4')).toBeInTheDocument()
rerender(<Carousel pages={pages.slice(0, 3)} deferMountPages />)
expect(document.querySelectorAll('[data-carousel-page]')).toHaveLength(3)
expect(screen.getByText('Page content 3')).toBeInTheDocument()
})
it('keeps eager consumers fully mounted and preserves loop navigation', () => {
render(<Carousel pages={pages} />)
expect(document.querySelectorAll('[data-carousel-page-mounted="true"]')).toHaveLength(5)
fireEvent.click(screen.getByRole('button', { name: 'Scroll left' }))
fireEvent.click(screen.getByRole('button', { name: 'Scroll right' }))
expect(mocks.api.scrollPrev).toHaveBeenCalledOnce()
expect(mocks.api.scrollNext).toHaveBeenCalledOnce()
})
it('plays managed autoplay only while the carousel is visible and motion is allowed', () => {
installIntersectionObserver()
const marketplaceContainer = document.createElement('div')
marketplaceContainer.id = 'marketplace-container'
document.body.appendChild(marketplaceContainer)
let reducedMotion = false
let reducedMotionListener: (() => void) | undefined
vi.stubGlobal('matchMedia', () => ({
get matches() {
return reducedMotion
},
media: '(prefers-reduced-motion: reduce)',
onchange: null,
addEventListener: (_event: string, listener: () => void) => {
reducedMotionListener = listener
},
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
}))
const { unmount } = render(
<Carousel pages={pages} autoPlay deferMountPages pauseWhenOffscreen />,
{ container: marketplaceContainer },
)
const autoplay = mocks.autoplayInstances[0]!
const carousel = screen.getByRole('region')
expect(mocks.autoplayOptions[0]).toMatchObject({
playOnInit: false,
stopOnInteraction: false,
stopOnMouseEnter: false,
})
expect(intersectionObservers[0]!.options).toEqual({
root: marketplaceContainer,
threshold: 0.25,
})
expect(autoplay.stop).toHaveBeenCalled()
triggerIntersection(intersectionObservers[0]!, 0.24)
triggerIntersection(intersectionObservers[0]!, 0.25)
expect(autoplay.play).toHaveBeenCalledOnce()
fireEvent.mouseEnter(carousel)
expect(autoplay.stop).toHaveBeenCalled()
Object.defineProperty(document, 'visibilityState', {
configurable: true,
value: 'hidden',
})
fireEvent(document, new Event('visibilitychange'))
fireEvent.mouseLeave(carousel)
expect(autoplay.play).toHaveBeenCalledOnce()
Object.defineProperty(document, 'visibilityState', {
configurable: true,
value: 'visible',
})
fireEvent(document, new Event('visibilitychange'))
expect(autoplay.play).toHaveBeenCalledTimes(2)
reducedMotion = true
reducedMotionListener?.()
expect(autoplay.stop).toHaveBeenCalled()
Object.defineProperty(document, 'visibilityState', {
configurable: true,
value: 'hidden',
})
fireEvent(document, new Event('visibilitychange'))
Object.defineProperty(document, 'visibilityState', {
configurable: true,
value: 'visible',
})
fireEvent(document, new Event('visibilitychange'))
expect(autoplay.play).toHaveBeenCalledTimes(2)
reducedMotion = false
reducedMotionListener?.()
expect(autoplay.play).toHaveBeenCalledTimes(3)
triggerIntersection(intersectionObservers[0]!, 0)
expect(autoplay.stop).toHaveBeenCalled()
unmount()
marketplaceContainer.remove()
})
it('preserves standalone autoplay initialization', () => {
render(<Carousel pages={pages} autoPlay />)
expect(mocks.autoplayOptions[0]).toMatchObject({
playOnInit: true,
stopOnMouseEnter: true,
})
expect(intersectionObservers).toHaveLength(0)
})
})

View File

@ -1,7 +1,7 @@
import type { MarketplaceCollection } from '@dify/contracts/marketplace'
import type { Plugin } from '@/app/components/plugins/types'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { act, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import ListWithCollection from '../list-with-collection'
const mockMoreClick = vi.fn()
@ -48,9 +48,80 @@ const pluginsMap: Record<string, Plugin[]> = {
empty: [],
}
type IntersectionObserverRecord = {
callback: IntersectionObserverCallback
disconnect: ReturnType<typeof vi.fn>
observe: ReturnType<typeof vi.fn>
options?: IntersectionObserverInit
}
const intersectionObservers: IntersectionObserverRecord[] = []
class MockIntersectionObserver {
callback: IntersectionObserverCallback
disconnect = vi.fn()
observe = vi.fn()
options?: IntersectionObserverInit
root: Element | Document | null
rootMargin: string
takeRecords = vi.fn(() => [])
thresholds: readonly number[]
unobserve = vi.fn()
constructor(callback: IntersectionObserverCallback, options?: IntersectionObserverInit) {
this.callback = callback
this.options = options
this.root = options?.root ?? null
this.rootMargin = options?.rootMargin ?? '0px'
this.thresholds = Array.isArray(options?.threshold)
? options.threshold
: [options?.threshold ?? 0]
intersectionObservers.push(this)
}
}
const installIntersectionObserver = () => {
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver)
}
const triggerIntersection = (
observer: IntersectionObserverRecord,
{ intersectionRatio, isIntersecting }: { intersectionRatio: number; isIntersecting: boolean },
) => {
act(() => {
observer.callback(
[{ intersectionRatio, isIntersecting } as IntersectionObserverEntry],
observer as unknown as IntersectionObserver,
)
})
}
const buildPerformanceFixture = () => {
const pluginCounts = [61, 8, 8, 8, 8, 8, 8]
const fixtureCollections = pluginCounts.map((_, collectionIndex) => ({
...collections[0]!,
name: `collection-${collectionIndex}`,
label: { 'en-US': `Collection ${collectionIndex}` },
description: { 'en-US': `Description ${collectionIndex}` },
})) as MarketplaceCollection[]
const fixturePluginsMap = Object.fromEntries(
pluginCounts.map((pluginCount, collectionIndex) => [
`collection-${collectionIndex}`,
Array.from({ length: pluginCount }, (_, pluginIndex) => ({
plugin_id: `collection-${collectionIndex}-plugin-${pluginIndex}`,
name: `Collection ${collectionIndex} Plugin ${pluginIndex}`,
})) as Plugin[],
]),
)
return { fixtureCollections, fixturePluginsMap }
}
describe('ListWithCollection', () => {
beforeEach(() => {
vi.clearAllMocks()
intersectionObservers.length = 0
installIntersectionObserver()
Object.defineProperty(window, 'innerWidth', {
configurable: true,
writable: true,
@ -58,6 +129,10 @@ describe('ListWithCollection', () => {
})
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('renders only collections that contain plugins', () => {
render(
<ListWithCollection
@ -209,4 +284,86 @@ describe('ListWithCollection', () => {
expect(carouselViewport).toHaveClass('overflow-hidden', 'rounded-[inherit]')
expect(carouselContent).toHaveStyle({ columnGap: '12px' })
})
it('defers all real cards until a collection enters the preload range', () => {
const { fixtureCollections, fixturePluginsMap } = buildPerformanceFixture()
const marketplaceContainer = document.createElement('div')
marketplaceContainer.id = 'marketplace-container'
document.body.appendChild(marketplaceContainer)
const { unmount } = render(
<ListWithCollection
marketplaceCollections={fixtureCollections}
marketplaceCollectionPluginsMap={fixturePluginsMap}
deferOffscreenCollections
/>,
{ container: marketplaceContainer },
)
expect(screen.getAllByText(/Collection \d$/)).toHaveLength(7)
expect(document.querySelectorAll('[data-marketplace-collection]')).toHaveLength(7)
expect(
document.querySelectorAll('[data-marketplace-collection-placeholder] > div'),
).toHaveLength(56)
expect(screen.queryAllByTestId('card-wrapper')).toHaveLength(0)
expect(intersectionObservers).toHaveLength(7)
expect(intersectionObservers[0]!.options).toEqual({
root: marketplaceContainer,
rootMargin: '320px 0px',
threshold: 0.01,
})
triggerIntersection(intersectionObservers[0]!, {
intersectionRatio: 0.01,
isIntersecting: true,
})
expect(intersectionObservers[0]!.disconnect).toHaveBeenCalled()
expect(screen.getAllByTestId('card-wrapper')).toHaveLength(21)
expect(document.querySelectorAll('[data-carousel-page]')).toHaveLength(8)
expect(document.querySelectorAll('[data-carousel-page-mounted="true"]')).toHaveLength(3)
triggerIntersection(intersectionObservers[0]!, {
intersectionRatio: 0,
isIntersecting: false,
})
expect(screen.getAllByTestId('card-wrapper')).toHaveLength(21)
unmount()
marketplaceContainer.remove()
})
it('mounts deferred collections after hydration when IntersectionObserver is unavailable', () => {
vi.stubGlobal('IntersectionObserver', undefined)
render(
<ListWithCollection
marketplaceCollections={collections}
marketplaceCollectionPluginsMap={pluginsMap}
deferOffscreenCollections
/>,
)
expect(screen.getAllByTestId('card-wrapper')).toHaveLength(2)
expect(
document.querySelector('[data-marketplace-collection-placeholder]'),
).not.toBeInTheDocument()
})
it('keeps standalone collections eager for SSR-compatible rendering', () => {
const { fixtureCollections, fixturePluginsMap } = buildPerformanceFixture()
render(
<ListWithCollection
marketplaceCollections={fixtureCollections}
marketplaceCollectionPluginsMap={fixturePluginsMap}
/>,
)
expect(screen.getAllByTestId('card-wrapper')).toHaveLength(109)
expect(
intersectionObservers.some((observer) => observer.options?.rootMargin === '320px 0px'),
).toBe(false)
})
})

View File

@ -51,7 +51,10 @@ const CardWrapperComponent = ({
if (showInstallAction) {
return (
<div className="group relative cursor-pointer rounded-xl">
<div
className="group relative cursor-pointer rounded-xl"
data-marketplace-card={plugin.plugin_id}
>
<Card
key={plugin.name}
payload={plugin}
@ -102,7 +105,7 @@ const CardWrapperComponent = ({
}
const card = (
<div className="group relative rounded-xl">
<div className="group relative rounded-xl" data-marketplace-card={plugin.plugin_id}>
<Card
key={plugin.name}
payload={plugin}

View File

@ -1,20 +1,27 @@
'use client'
/* oxlint-disable eslint-react/set-state-in-effect */
import type { ReactNode } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import Autoplay from 'embla-carousel-autoplay'
import useEmblaCarousel from 'embla-carousel-react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { CAROUSEL_PAGE_CLASS } from './collection-constants'
type CarouselApi = ReturnType<typeof useEmblaCarousel>[1]
export type CarouselPage = {
id: string
content: ReactNode
}
type CarouselProps = {
children: React.ReactNode
pages: CarouselPage[]
className?: string
showNavigation?: boolean
showPagination?: boolean
autoPlay?: boolean
autoPlayInterval?: number
deferMountPages?: boolean
pauseWhenOffscreen?: boolean
}
type NavButtonProps = {
@ -42,21 +49,21 @@ const NavButton = ({ direction, disabled, onClick, iconClassName }: NavButtonPro
)
type CarouselControlsProps = {
api: CarouselApi
showPagination: boolean
selectedIndex: number
scrollNext: () => void
scrollPrev: () => void
scrollSnaps: number[]
scrollTo: (index: number) => void
}
const CarouselControls = ({
api,
showPagination,
selectedIndex,
scrollNext,
scrollPrev,
scrollSnaps,
scrollTo,
}: CarouselControlsProps) => {
const paginationItems = scrollSnaps.map((snap, index) => ({
id: `${snap}-${index}`,
@ -79,7 +86,7 @@ const CarouselControls = ({
? 'w-4 bg-components-button-primary-bg'
: 'bg-components-button-secondary-border hover:bg-components-button-secondary-border-hover',
)}
onClick={() => api?.scrollTo(index)}
onClick={() => scrollTo(index)}
aria-label={`Go to page ${index + 1}`}
/>
))}
@ -103,44 +110,97 @@ const CarouselControls = ({
)
}
const normalizePageIndex = (index: number, pageCount: number) =>
((index % pageCount) + pageCount) % pageCount
const getPageWindowIds = (pages: CarouselPage[], centerIndex: number) => {
if (!pages.length) return []
return [-1, 0, 1].map(
(offset) => pages[normalizePageIndex(centerIndex + offset, pages.length)]!.id,
)
}
const Carousel = ({
children,
pages,
className,
showNavigation = true,
showPagination = true,
autoPlay = false,
autoPlayInterval = 5000,
deferMountPages = false,
pauseWhenOffscreen = false,
}: CarouselProps) => {
const plugins = useMemo(() => {
if (!autoPlay) return []
const carouselRootRef = useRef<HTMLDivElement>(null)
const autoplay = useMemo(() => {
if (!autoPlay) return undefined
return [
Autoplay({
delay: autoPlayInterval,
stopOnInteraction: false,
stopOnMouseEnter: true,
}),
]
}, [autoPlay, autoPlayInterval])
return Autoplay({
delay: autoPlayInterval,
playOnInit: !pauseWhenOffscreen,
stopOnInteraction: false,
stopOnMouseEnter: !pauseWhenOffscreen,
})
}, [autoPlay, autoPlayInterval, pauseWhenOffscreen])
const plugins = useMemo(() => (autoplay ? [autoplay] : []), [autoplay])
const [carouselRef, api] = useEmblaCarousel(
{ align: 'start', containScroll: 'trimSnaps', loop: true },
plugins,
)
const [selectedIndex, setSelectedIndex] = useState(0)
const [scrollSnaps, setScrollSnaps] = useState<number[]>([])
const [mountedPageIds, setMountedPageIds] = useState(
() => new Set(deferMountPages ? getPageWindowIds(pages, 0) : pages.map((page) => page.id)),
)
const mountPageWindow = useCallback(
(centerIndex: number) => {
if (!deferMountPages || !pages.length) return
const pageIds = getPageWindowIds(pages, centerIndex)
setMountedPageIds((currentPageIds) => {
if (pageIds.every((pageId) => currentPageIds.has(pageId))) return currentPageIds
return new Set([...currentPageIds, ...pageIds])
})
},
[deferMountPages, pages],
)
const scheduleScroll = useCallback((scroll: () => void) => {
window.requestAnimationFrame(scroll)
}, [])
const scrollTo = useCallback(
(index: number) => {
mountPageWindow(index)
scheduleScroll(() => api?.scrollTo(index))
},
[api, mountPageWindow, scheduleScroll],
)
const scrollPrev = useCallback(() => {
api?.scrollPrev()
}, [api])
mountPageWindow(selectedIndex - 1)
scheduleScroll(() => api?.scrollPrev())
}, [api, mountPageWindow, scheduleScroll, selectedIndex])
const scrollNext = useCallback(() => {
api?.scrollNext()
}, [api])
mountPageWindow(selectedIndex + 1)
scheduleScroll(() => api?.scrollNext())
}, [api, mountPageWindow, scheduleScroll, selectedIndex])
useEffect(() => {
if (!deferMountPages) return
mountPageWindow(selectedIndex)
}, [deferMountPages, mountPageWindow, pages, selectedIndex])
useEffect(() => {
if (!api) return
const handleSelect = () => {
setSelectedIndex(api.selectedScrollSnap())
const nextSelectedIndex = api.selectedScrollSnap()
setSelectedIndex(nextSelectedIndex)
setScrollSnaps(api.scrollSnapList())
mountPageWindow(nextSelectedIndex)
}
handleSelect()
@ -151,23 +211,108 @@ const Carousel = ({
api.off('reInit', handleSelect)
api.off('select', handleSelect)
}
}, [api])
}, [api, mountPageWindow])
useEffect(() => {
if (!pauseWhenOffscreen || !autoplay || !api) return
const carouselRoot = carouselRootRef.current
if (!carouselRoot) return
let isInViewport = false
let isHovered = false
let isDocumentVisible = document.visibilityState === 'visible'
const reducedMotionQuery = window.matchMedia?.('(prefers-reduced-motion: reduce)')
let isReducedMotion = reducedMotionQuery?.matches ?? false
const syncAutoplay = () => {
if (isInViewport && isDocumentVisible && !isReducedMotion && !isHovered) autoplay.play()
else autoplay.stop()
}
const handleVisibilityChange = () => {
isDocumentVisible = document.visibilityState === 'visible'
syncAutoplay()
}
const handleReducedMotionChange = () => {
isReducedMotion = reducedMotionQuery?.matches ?? false
syncAutoplay()
}
const handleMouseEnter = () => {
isHovered = true
syncAutoplay()
}
const handleMouseLeave = () => {
isHovered = false
syncAutoplay()
}
const observer =
typeof IntersectionObserver === 'undefined'
? undefined
: new IntersectionObserver(
([entry]) => {
isInViewport = !!entry?.isIntersecting && entry.intersectionRatio >= 0.25
syncAutoplay()
},
{
root: document.getElementById('marketplace-container'),
threshold: 0.25,
},
)
if (observer) observer.observe(carouselRoot)
else isInViewport = true
document.addEventListener('visibilitychange', handleVisibilityChange)
reducedMotionQuery?.addEventListener('change', handleReducedMotionChange)
carouselRoot.addEventListener('mouseenter', handleMouseEnter)
carouselRoot.addEventListener('mouseleave', handleMouseLeave)
syncAutoplay()
return () => {
observer?.disconnect()
document.removeEventListener('visibilitychange', handleVisibilityChange)
reducedMotionQuery?.removeEventListener('change', handleReducedMotionChange)
carouselRoot.removeEventListener('mouseenter', handleMouseEnter)
carouselRoot.removeEventListener('mouseleave', handleMouseLeave)
autoplay.stop()
}
}, [api, autoplay, pauseWhenOffscreen])
return (
<div className={cn('relative', className)} role="region" aria-roledescription="carousel">
<div
ref={carouselRootRef}
className={cn('relative', className)}
role="region"
aria-roledescription="carousel"
>
{showNavigation && (
<CarouselControls
api={api}
showPagination={showPagination}
selectedIndex={selectedIndex}
scrollNext={scrollNext}
scrollPrev={scrollPrev}
scrollSnaps={scrollSnaps}
scrollTo={scrollTo}
/>
)}
<div ref={carouselRef} className="overflow-hidden rounded-[inherit]">
<div className="flex" style={{ columnGap: '12px' }}>
{children}
{pages.map((page) => {
const isMounted = !deferMountPages || mountedPageIds.has(page.id)
return (
<div
key={page.id}
className={CAROUSEL_PAGE_CLASS}
data-carousel-page={page.id}
data-carousel-page-mounted={isMounted ? 'true' : 'false'}
style={{ scrollSnapAlign: 'start' }}
>
{isMounted ? page.content : null}
</div>
)
})}
</div>
</div>
</div>

View File

@ -20,6 +20,7 @@ type ListProps = {
cardRender?: (plugin: Plugin) => React.JSX.Element | null
emptyClassName?: string
onCollectionMoreClick?: (searchParams?: SearchParamsFromCollection) => void
deferOffscreenCollections?: boolean
}
const List = ({
marketplaceCollections,
@ -31,6 +32,7 @@ const List = ({
cardRender,
emptyClassName,
onCollectionMoreClick,
deferOffscreenCollections,
}: ListProps) => {
const { canInstallPlugin } = useOptionalPluginInstallPermission()
const pluginIds = useMemo(() => {
@ -69,6 +71,7 @@ const List = ({
cardRender={cardRender}
onCollectionMoreClick={onCollectionMoreClick}
installedPluginIds={installedPluginIds}
deferOffscreenCollections={deferOffscreenCollections}
/>
)}
{plugins && !!plugins.length && (

View File

@ -3,22 +3,20 @@
import type { MarketplaceCollection, SearchParamsFromCollection } from '@dify/contracts/marketplace'
import type { Plugin } from '@/app/components/plugins/types'
import { cn } from '@langgenius/dify-ui/cn'
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useLocale, useTranslation } from '#i18n'
import { getLanguage } from '@/i18n-config/language'
import { useMarketplaceMoreClick } from '../atoms'
import { buildCarouselPages } from '../utils'
import CardWrapper from './card-wrapper'
import Carousel from './carousel'
import {
CAROUSEL_BREAKPOINTS,
CAROUSEL_PAGE_CLASS,
CAROUSEL_PAGE_SIZE,
GRID_CLASS,
} from './collection-constants'
import { CAROUSEL_BREAKPOINTS, CAROUSEL_PAGE_SIZE, GRID_CLASS } from './collection-constants'
const BECOME_PARTNER_URL = 'https://share-na2.hsforms.com/1NiS4r9lsSqGcuNBB77DeEQ40s9fk'
const PARTNERS_COLLECTION_NAMES = new Set(['partners', 'partner-template', 'Partner Template'])
const COLLECTION_PRELOAD_MARGIN = '320px 0px'
const COLLECTION_INTERSECTION_THRESHOLD = 0.01
const MAX_PLACEHOLDER_CARDS = 8
const getViewportWidth = () =>
typeof window === 'undefined' ? CAROUSEL_BREAKPOINTS.xl : window.innerWidth
@ -40,6 +38,7 @@ type ListWithCollectionProps = {
cardRender?: (plugin: Plugin) => React.JSX.Element | null
onCollectionMoreClick?: (searchParams?: SearchParamsFromCollection) => void
installedPluginIds?: ReadonlySet<string>
deferOffscreenCollections?: boolean
}
type PluginCardProps = {
@ -69,6 +68,193 @@ const PluginCard = ({
)
}
type CollectionSectionProps = {
collection: MarketplaceCollection
plugins: Plugin[]
itemsPerPage: number
showInstallButton?: boolean
linkToMarketplaceDetail?: boolean
cardContainerClassName?: string
cardRender?: (plugin: Plugin) => React.JSX.Element | null
onMoreClick: (searchParams?: SearchParamsFromCollection) => void
installedPluginIds?: ReadonlySet<string>
deferMount: boolean
}
const CollectionPlaceholder = ({
cardContainerClassName,
count,
}: {
cardContainerClassName?: string
count: number
}) => (
<div
aria-hidden
className={cn('mt-2', GRID_CLASS, cardContainerClassName)}
data-marketplace-collection-placeholder
>
{Array.from({ length: count }, (_, index) => (
<div
key={index}
className="h-[148px] min-w-0 rounded-xl border border-components-panel-border-subtle bg-background-default-subtle"
/>
))}
</div>
)
const CollectionSection = ({
collection,
plugins,
itemsPerPage,
showInstallButton,
linkToMarketplaceDetail,
cardContainerClassName,
cardRender,
onMoreClick,
installedPluginIds,
deferMount,
}: CollectionSectionProps) => {
const { t } = useTranslation()
const locale = useLocale()
const sectionRef = useRef<HTMLDivElement>(null)
const [isMounted, setIsMounted] = useState(!deferMount)
const pages = useMemo(() => buildCarouselPages(plugins, itemsPerPage), [itemsPerPage, plugins])
const hasMultiplePages = pages.length > 1
const isPartnersCollection = PARTNERS_COLLECTION_NAMES.has(collection.name)
useEffect(() => {
if (!deferMount || isMounted) return
const section = sectionRef.current
if (!section) return
if (typeof IntersectionObserver === 'undefined') {
// oxlint-disable-next-line eslint-react/set-state-in-effect -- This is the hydration fallback for browsers without IntersectionObserver.
setIsMounted(true)
return
}
const observer = new IntersectionObserver(
([entry]) => {
if (!entry?.isIntersecting) return
setIsMounted(true)
observer.disconnect()
},
{
root: document.getElementById('marketplace-container'),
rootMargin: COLLECTION_PRELOAD_MARGIN,
threshold: COLLECTION_INTERSECTION_THRESHOLD,
},
)
observer.observe(section)
return () => observer.disconnect()
}, [deferMount, isMounted])
const carouselPages = useMemo(
() =>
pages.map((pageItems, pageIndex) => ({
id: `${collection.name}-${itemsPerPage}-${pageIndex}`,
content: (
<div className={cn(GRID_CLASS, cardContainerClassName)}>
{pageItems.map((plugin) => (
<div key={plugin.plugin_id} className="min-w-0 *:w-full">
<PluginCard
plugin={plugin}
showInstallButton={showInstallButton}
linkToMarketplaceDetail={linkToMarketplaceDetail}
cardRender={cardRender}
isInstalled={installedPluginIds?.has(plugin.plugin_id)}
/>
</div>
))}
</div>
),
})),
[
cardContainerClassName,
cardRender,
collection.name,
installedPluginIds,
itemsPerPage,
linkToMarketplaceDetail,
pages,
showInstallButton,
],
)
return (
<div ref={sectionRef} className="py-3" data-marketplace-collection={collection.name}>
<div className="flex items-end justify-between">
<div>
<div className="title-xl-semi-bold text-text-primary">
{collection.label[getLanguage(locale)]}
</div>
<div className="flex items-center gap-x-2 system-xs-regular text-text-tertiary">
{collection.description[getLanguage(locale)]}
{isPartnersCollection && (
<>
<span className="text-divider-regular">|</span>
<a
href={BECOME_PARTNER_URL}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-x-0.5 text-text-accent hover:underline"
>
<span>{t(($) => $['marketplace.becomePartner'], { ns: 'plugin' })}</span>
<span aria-hidden className="i-ri-external-link-line size-3" />
</a>
</>
)}
</div>
</div>
{collection.searchable && !hasMultiplePages && (
<button
type="button"
className="flex cursor-pointer items-center system-xs-medium text-text-accent"
onClick={() => onMoreClick(collection.search_params)}
>
{t(($) => $['marketplace.viewMore'], { ns: 'plugin' })}
<span aria-hidden className="i-ri-arrow-right-s-line size-4" />
</button>
)}
</div>
{!isMounted ? (
<CollectionPlaceholder
cardContainerClassName={cardContainerClassName}
count={Math.min(plugins.length, itemsPerPage, MAX_PLACEHOLDER_CARDS)}
/>
) : hasMultiplePages ? (
<Carousel
pages={carouselPages}
className="mt-2"
showNavigation
showPagination
autoPlay
autoPlayInterval={5000}
deferMountPages={deferMount}
pauseWhenOffscreen={deferMount}
/>
) : (
<div className={cn('mt-2', GRID_CLASS, cardContainerClassName)}>
{plugins.map((plugin) => (
<PluginCard
key={plugin.plugin_id}
plugin={plugin}
showInstallButton={showInstallButton}
linkToMarketplaceDetail={linkToMarketplaceDetail}
cardRender={cardRender}
isInstalled={installedPluginIds?.has(plugin.plugin_id)}
/>
))}
</div>
)}
</div>
)
}
const ListWithCollection = ({
marketplaceCollections,
marketplaceCollectionPluginsMap,
@ -78,9 +264,8 @@ const ListWithCollection = ({
cardRender,
onCollectionMoreClick,
installedPluginIds,
deferOffscreenCollections = false,
}: ListWithCollectionProps) => {
const { t } = useTranslation()
const locale = useLocale()
const defaultOnMoreClick = useMarketplaceMoreClick()
const handleMoreClick = onCollectionMoreClick ?? defaultOnMoreClick
const [viewportWidth, setViewportWidth] = useState(getViewportWidth)
@ -94,102 +279,23 @@ const ListWithCollection = ({
return () => window.removeEventListener('resize', handleResize)
}, [])
return (
<>
{marketplaceCollections
.filter((collection) => {
return marketplaceCollectionPluginsMap[collection.name]?.length
})
.map((collection) => {
const plugins = marketplaceCollectionPluginsMap[collection.name]!
const pages = buildCarouselPages(plugins, itemsPerPage)
const hasMultiplePages = pages.length > 1
const isPartnersCollection = PARTNERS_COLLECTION_NAMES.has(collection.name)
return (
<div key={collection.name} className="py-3">
<div className="flex items-end justify-between">
<div>
<div className="title-xl-semi-bold text-text-primary">
{collection.label[getLanguage(locale)]}
</div>
<div className="flex items-center gap-x-2 system-xs-regular text-text-tertiary">
{collection.description[getLanguage(locale)]}
{isPartnersCollection && (
<>
<span className="text-divider-regular">|</span>
<a
href={BECOME_PARTNER_URL}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-x-0.5 text-text-accent hover:underline"
>
<span>{t(($) => $['marketplace.becomePartner'], { ns: 'plugin' })}</span>
<span aria-hidden className="i-ri-external-link-line size-3" />
</a>
</>
)}
</div>
</div>
{collection.searchable && !hasMultiplePages && (
<div
className="flex cursor-pointer items-center system-xs-medium text-text-accent"
onClick={() => handleMoreClick(collection.search_params)}
>
{t(($) => $['marketplace.viewMore'], { ns: 'plugin' })}
<span aria-hidden className="i-ri-arrow-right-s-line size-4" />
</div>
)}
</div>
{hasMultiplePages ? (
<Carousel
className="mt-2"
showNavigation
showPagination
autoPlay
autoPlayInterval={5000}
>
{pages.map((pageItems) => (
<div
key={pageItems.map((plugin) => plugin.plugin_id).join('-')}
className={CAROUSEL_PAGE_CLASS}
style={{ scrollSnapAlign: 'start' }}
>
<div className={cn(GRID_CLASS, cardContainerClassName)}>
{pageItems.map((plugin) => (
<div key={plugin.plugin_id} className="min-w-0 *:w-full">
<PluginCard
plugin={plugin}
showInstallButton={showInstallButton}
linkToMarketplaceDetail={linkToMarketplaceDetail}
cardRender={cardRender}
isInstalled={installedPluginIds?.has(plugin.plugin_id)}
/>
</div>
))}
</div>
</div>
))}
</Carousel>
) : (
<div className={cn('mt-2', GRID_CLASS, cardContainerClassName)}>
{plugins.map((plugin) => (
<PluginCard
key={plugin.plugin_id}
plugin={plugin}
showInstallButton={showInstallButton}
linkToMarketplaceDetail={linkToMarketplaceDetail}
cardRender={cardRender}
isInstalled={installedPluginIds?.has(plugin.plugin_id)}
/>
))}
</div>
)}
</div>
)
})}
</>
)
return marketplaceCollections
.filter((collection) => marketplaceCollectionPluginsMap[collection.name]?.length)
.map((collection) => (
<CollectionSection
key={collection.name}
collection={collection}
plugins={marketplaceCollectionPluginsMap[collection.name]!}
itemsPerPage={itemsPerPage}
showInstallButton={showInstallButton}
linkToMarketplaceDetail={linkToMarketplaceDetail}
cardContainerClassName={cardContainerClassName}
cardRender={cardRender}
onMoreClick={handleMoreClick}
installedPluginIds={installedPluginIds}
deferMount={deferOffscreenCollections}
/>
))
}
export default ListWithCollection

View File

@ -10,12 +10,14 @@ import List from './index'
type ListWrapperProps = {
activePluginType?: ActivePluginType
className?: string
deferOffscreenCollections?: boolean
showInstallButton?: boolean
linkToMarketplaceDetail?: boolean
}
const ListWrapper = ({
activePluginType,
className,
deferOffscreenCollections,
showInstallButton,
linkToMarketplaceDetail,
}: ListWrapperProps) => {
@ -57,6 +59,7 @@ const ListWrapper = ({
marketplaceCollections={marketplaceCollections || []}
marketplaceCollectionPluginsMap={marketplaceCollectionPluginsMap || {}}
plugins={plugins}
deferOffscreenCollections={deferOffscreenCollections}
showInstallButton={showInstallButton}
linkToMarketplaceDetail={linkToMarketplaceDetail}
/>