From 00861ac5ebb496cfb0cbba4dbe234048fc82240a Mon Sep 17 00:00:00 2001 From: CodingOnStar Date: Tue, 4 Aug 2026 17:39:35 +0800 Subject: [PATCH] perf(web): defer offscreen marketplace content --- e2e/cucumber.config.ts | 2 +- e2e/features/marketplace-performance.feature | 5 + .../marketplace-performance.steps.ts | 97 ++++++ e2e/features/support/world.ts | 8 + e2e/package.json | 1 + e2e/scripts/run-cucumber.ts | 2 +- oxlint-suppressions.json | 8 - .../home/__tests__/home-trending.spec.tsx | 152 ++++++++- .../marketplace/home/home-trending.tsx | 46 ++- .../plugins/marketplace/home/index.tsx | 1 + .../list/__tests__/card-wrapper.spec.tsx | 1 + .../list/__tests__/carousel.spec.tsx | 266 +++++++++++++++ .../__tests__/list-with-collection.spec.tsx | 161 ++++++++- .../plugins/marketplace/list/card-wrapper.tsx | 7 +- .../plugins/marketplace/list/carousel.tsx | 197 +++++++++-- .../plugins/marketplace/list/index.tsx | 3 + .../marketplace/list/list-with-collection.tsx | 316 ++++++++++++------ .../plugins/marketplace/list/list-wrapper.tsx | 3 + 18 files changed, 1126 insertions(+), 150 deletions(-) create mode 100644 e2e/features/marketplace-performance.feature create mode 100644 e2e/features/step-definitions/marketplace-performance.steps.ts create mode 100644 web/app/components/plugins/marketplace/list/__tests__/carousel.spec.tsx diff --git a/e2e/cucumber.config.ts b/e2e/cucumber.config.ts index b7768c36d7b..3f443ba9faa 100644 --- a/e2e/cucumber.config.ts +++ b/e2e/cucumber.config.ts @@ -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' diff --git a/e2e/features/marketplace-performance.feature b/e2e/features/marketplace-performance.feature new file mode 100644 index 00000000000..36c8ddca28f --- /dev/null +++ b/e2e/features/marketplace-performance.feature @@ -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 diff --git a/e2e/features/step-definitions/marketplace-performance.steps.ts b/e2e/features/step-definitions/marketplace-performance.steps.ts new file mode 100644 index 00000000000..5cd8045f98a --- /dev/null +++ b/e2e/features/step-definitions/marketplace-performance.steps.ts @@ -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((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) + }, +) diff --git a/e2e/features/support/world.ts b/e2e/features/support/world.ts index 3cbabd84542..4fcf02d6edf 100644 --- a/e2e/features/support/world.ts +++ b/e2e/features/support/world.ts @@ -77,6 +77,12 @@ export const createAgentBuilderWorldState = () => ({ export type AgentBuilderWorldState = ReturnType +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) { diff --git a/e2e/package.json b/e2e/package.json index f936f76f383..f88a06420e5 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -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", diff --git a/e2e/scripts/run-cucumber.ts b/e2e/scripts/run-cucumber.ts index 74f93b6d2b3..04d1bfe630a 100644 --- a/e2e/scripts/run-cucumber.ts +++ b/e2e/scripts/run-cucumber.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) => { diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index 63f631fe99f..7e9acb137b6 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -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 diff --git a/web/app/components/plugins/marketplace/home/__tests__/home-trending.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/home-trending.spec.tsx index 9b1cbc34254..5a22808efb3 100644 --- a/web/app/components/plugins/marketplace/home/__tests__/home-trending.spec.tsx +++ b/web/app/components/plugins/marketplace/home/__tests__/home-trending.spec.tsx @@ -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(, { + 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() diff --git a/web/app/components/plugins/marketplace/home/home-trending.tsx b/web/app/components/plugins/marketplace/home/home-trending.tsx index ab7d6509b49..7d141a59d53 100644 --- a/web/app/components/plugins/marketplace/home/home-trending.tsx +++ b/web/app/components/plugins/marketplace/home/home-trending.tsx @@ -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 + pauseWhenOffscreen: boolean onSelect: (index: number) => void onNext: () => void }) { const { t } = useTranslation('plugin') const progressRef = useRef(null) const progressAnimationRef = useRef(null) - const pauseReasonsRef = useRef(new Set()) + const pauseReasonsRef = useRef( + new Set(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} /> -
+
diff --git a/web/app/components/plugins/marketplace/list/__tests__/card-wrapper.spec.tsx b/web/app/components/plugins/marketplace/list/__tests__/card-wrapper.spec.tsx index 0dcfe7c7202..fe925bd3db6 100644 --- a/web/app/components/plugins/marketplace/list/__tests__/card-wrapper.spec.tsx +++ b/web/app/components/plugins/marketplace/list/__tests__/card-wrapper.spec.tsx @@ -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') }) diff --git a/web/app/components/plugins/marketplace/list/__tests__/carousel.spec.tsx b/web/app/components/plugins/marketplace/list/__tests__/carousel.spec.tsx new file mode 100644 index 00000000000..3ebf15d845e --- /dev/null +++ b/web/app/components/plugins/marketplace/list/__tests__/carousel.spec.tsx @@ -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 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; stop: ReturnType }[] = [] + const autoplayOptions: Record[] = [] + + 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) => { + 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:
Page content {index + 1}
, +})) + +type IntersectionObserverRecord = { + callback: IntersectionObserverCallback + disconnect: ReturnType + observe: ReturnType + 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() + + 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() + + 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() + + 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( + , + { 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() + + expect(mocks.autoplayOptions[0]).toMatchObject({ + playOnInit: true, + stopOnMouseEnter: true, + }) + expect(intersectionObservers).toHaveLength(0) + }) +}) diff --git a/web/app/components/plugins/marketplace/list/__tests__/list-with-collection.spec.tsx b/web/app/components/plugins/marketplace/list/__tests__/list-with-collection.spec.tsx index 1b38816afcc..69b3a082057 100644 --- a/web/app/components/plugins/marketplace/list/__tests__/list-with-collection.spec.tsx +++ b/web/app/components/plugins/marketplace/list/__tests__/list-with-collection.spec.tsx @@ -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 = { empty: [], } +type IntersectionObserverRecord = { + callback: IntersectionObserverCallback + disconnect: ReturnType + observe: ReturnType + 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( { 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( + , + { 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( + , + ) + + 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( + , + ) + + expect(screen.getAllByTestId('card-wrapper')).toHaveLength(109) + expect( + intersectionObservers.some((observer) => observer.options?.rootMargin === '320px 0px'), + ).toBe(false) + }) }) diff --git a/web/app/components/plugins/marketplace/list/card-wrapper.tsx b/web/app/components/plugins/marketplace/list/card-wrapper.tsx index bd18003c16f..559798d0af2 100644 --- a/web/app/components/plugins/marketplace/list/card-wrapper.tsx +++ b/web/app/components/plugins/marketplace/list/card-wrapper.tsx @@ -51,7 +51,10 @@ const CardWrapperComponent = ({ if (showInstallAction) { return ( -
+
+
[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(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([]) + 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 ( -
+
{showNavigation && ( )}
- {children} + {pages.map((page) => { + const isMounted = !deferMountPages || mountedPageIds.has(page.id) + + return ( +
+ {isMounted ? page.content : null} +
+ ) + })}
diff --git a/web/app/components/plugins/marketplace/list/index.tsx b/web/app/components/plugins/marketplace/list/index.tsx index 6d6c227b56e..1fce89f7194 100644 --- a/web/app/components/plugins/marketplace/list/index.tsx +++ b/web/app/components/plugins/marketplace/list/index.tsx @@ -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 && ( diff --git a/web/app/components/plugins/marketplace/list/list-with-collection.tsx b/web/app/components/plugins/marketplace/list/list-with-collection.tsx index 13fbcc9c98f..c650fa29b34 100644 --- a/web/app/components/plugins/marketplace/list/list-with-collection.tsx +++ b/web/app/components/plugins/marketplace/list/list-with-collection.tsx @@ -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 + 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 + deferMount: boolean +} + +const CollectionPlaceholder = ({ + cardContainerClassName, + count, +}: { + cardContainerClassName?: string + count: number +}) => ( +
+ {Array.from({ length: count }, (_, index) => ( +
+ ))} +
+) + +const CollectionSection = ({ + collection, + plugins, + itemsPerPage, + showInstallButton, + linkToMarketplaceDetail, + cardContainerClassName, + cardRender, + onMoreClick, + installedPluginIds, + deferMount, +}: CollectionSectionProps) => { + const { t } = useTranslation() + const locale = useLocale() + const sectionRef = useRef(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: ( +
+ {pageItems.map((plugin) => ( +
+ +
+ ))} +
+ ), + })), + [ + cardContainerClassName, + cardRender, + collection.name, + installedPluginIds, + itemsPerPage, + linkToMarketplaceDetail, + pages, + showInstallButton, + ], + ) + + return ( +
+
+
+
+ {collection.label[getLanguage(locale)]} +
+
+ {collection.description[getLanguage(locale)]} + {isPartnersCollection && ( + <> + | + + {t(($) => $['marketplace.becomePartner'], { ns: 'plugin' })} + + + + )} +
+
+ {collection.searchable && !hasMultiplePages && ( + + )} +
+ {!isMounted ? ( + + ) : hasMultiplePages ? ( + + ) : ( +
+ {plugins.map((plugin) => ( + + ))} +
+ )} +
+ ) +} + 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 ( -
-
-
-
- {collection.label[getLanguage(locale)]} -
-
- {collection.description[getLanguage(locale)]} - {isPartnersCollection && ( - <> - | - - {t(($) => $['marketplace.becomePartner'], { ns: 'plugin' })} - - - - )} -
-
- {collection.searchable && !hasMultiplePages && ( -
handleMoreClick(collection.search_params)} - > - {t(($) => $['marketplace.viewMore'], { ns: 'plugin' })} - -
- )} -
- {hasMultiplePages ? ( - - {pages.map((pageItems) => ( -
plugin.plugin_id).join('-')} - className={CAROUSEL_PAGE_CLASS} - style={{ scrollSnapAlign: 'start' }} - > -
- {pageItems.map((plugin) => ( -
- -
- ))} -
-
- ))} -
- ) : ( -
- {plugins.map((plugin) => ( - - ))} -
- )} -
- ) - })} - - ) + return marketplaceCollections + .filter((collection) => marketplaceCollectionPluginsMap[collection.name]?.length) + .map((collection) => ( + + )) } export default ListWithCollection diff --git a/web/app/components/plugins/marketplace/list/list-wrapper.tsx b/web/app/components/plugins/marketplace/list/list-wrapper.tsx index 73c2695caeb..c424c116f83 100644 --- a/web/app/components/plugins/marketplace/list/list-wrapper.tsx +++ b/web/app/components/plugins/marketplace/list/list-wrapper.tsx @@ -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} />