mirror of
https://github.com/langgenius/dify.git
synced 2026-09-04 16:07:08 +08:00
perf(web): defer offscreen marketplace content
This commit is contained in:
parent
02f41e145b
commit
00861ac5eb
@ -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'
|
||||
|
||||
5
e2e/features/marketplace-performance.feature
Normal file
5
e2e/features/marketplace-performance.feature
Normal 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
|
||||
@ -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)
|
||||
},
|
||||
)
|
||||
@ -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) {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 />)
|
||||
|
||||
|
||||
@ -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')}
|
||||
|
||||
@ -68,6 +68,7 @@ const MarketplaceHome = ({
|
||||
<ListWrapper
|
||||
activePluginType={activePluginType}
|
||||
className={styles.catalogContent}
|
||||
deferOffscreenCollections={!isMarketplacePlatform}
|
||||
showInstallButton={showInstallButton}
|
||||
linkToMarketplaceDetail={linkToMarketplaceDetail}
|
||||
/>
|
||||
|
||||
@ -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')
|
||||
})
|
||||
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user