From 83c943bc219afe97ea2f8f02b486376d0ae12f0f Mon Sep 17 00:00:00 2001 From: yyh Date: Wed, 27 May 2026 20:35:12 +0800 Subject: [PATCH] fix: virtualize main nav web apps --- .../explore/sidebar/app-nav-item/index.tsx | 6 +- .../main-nav/__tests__/index.spec.tsx | 40 ++++- .../main-nav/components/web-apps-section.tsx | 155 ++++++++++++++---- 3 files changed, 163 insertions(+), 38 deletions(-) diff --git a/web/app/components/explore/sidebar/app-nav-item/index.tsx b/web/app/components/explore/sidebar/app-nav-item/index.tsx index 42a2d35ba8..703908589c 100644 --- a/web/app/components/explore/sidebar/app-nav-item/index.tsx +++ b/web/app/components/explore/sidebar/app-nav-item/index.tsx @@ -53,10 +53,10 @@ export default function AppNavItem({ title={isMainNav ? name : undefined} className={cn( isMainNav - ? 'group text-components-main-nav-text flex cursor-pointer items-center justify-between gap-2 rounded-lg py-0.5 pr-0.5 pl-2 transition-colors' + ? 'group flex h-8 cursor-pointer items-center justify-between gap-2 rounded-lg py-0.5 pr-0.5 pl-2 transition-colors' : 'flex h-8 items-center justify-between rounded-lg px-2 system-sm-medium text-sm font-normal text-components-menu-item-text mobile:justify-center mobile:px-1', isMainNav - ? (isSelected ? 'text-components-main-nav-text bg-state-base-hover' : 'hover:text-components-main-nav-text hover:bg-state-base-hover') + ? (isSelected ? 'bg-state-base-hover' : 'hover:bg-state-base-hover') : (isSelected ? 'bg-state-base-active text-components-menu-item-text-active' : 'hover:bg-state-base-hover hover:text-components-menu-item-text-hover'), )} onClick={() => { @@ -68,7 +68,7 @@ export default function AppNavItem({ <>
-
{name}
+
{name}
e.stopPropagation()}> = {}): InstalledApp => ({ id: overrides.id ?? 'installed-1', @@ -180,6 +181,7 @@ describe('MainNav', () => { localStorage.clear() mockPathname = '/apps' mockInstalledApps = [] + mockInstalledAppsPending = false ;(usePathname as Mock).mockImplementation(() => mockPathname) ;(useRouter as Mock).mockReturnValue({ @@ -209,7 +211,7 @@ describe('MainNav', () => { ], }) ;(useGetInstalledApps as Mock).mockImplementation(() => ({ - isPending: false, + isPending: mockInstalledAppsPending, data: { installed_apps: mockInstalledApps }, })) ;(useUninstallApp as Mock).mockReturnValue({ @@ -546,6 +548,42 @@ describe('MainNav', () => { expect(mockPush).toHaveBeenCalledWith('/explore/installed/installed-2') }) + it('renders web app skeleton rows while installed apps are loading', () => { + mockInstalledAppsPending = true + + renderMainNav() + + expect(screen.getByTestId('web-apps-skeleton')).toBeInTheDocument() + expect(screen.getByRole('region', { name: 'explore.sidebar.webApps' })).toHaveAttribute('aria-busy', 'true') + expect(screen.queryByText('common.loading')).not.toBeInTheDocument() + }) + + it('virtualizes large installed web app lists', async () => { + const offsetHeightSpy = vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(320) + const offsetWidthSpy = vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(240) + mockInstalledApps = Array.from({ length: 100 }, (_, index) => ( + createInstalledApp({ + id: `installed-${index}`, + app: { + ...createInstalledApp().app, + id: `app-${index}`, + name: `Web App ${index}`, + }, + }) + )) + + try { + renderMainNav() + + expect(await screen.findByText('Web App 0')).toBeInTheDocument() + expect(screen.queryByText('Web App 99')).not.toBeInTheDocument() + } + finally { + offsetHeightSpy.mockRestore() + offsetWidthSpy.mockRestore() + } + }) + it('collapses and expands installed web apps from the section arrow', () => { mockInstalledApps = [createInstalledApp()] diff --git a/web/app/components/main-nav/components/web-apps-section.tsx b/web/app/components/main-nav/components/web-apps-section.tsx index 78ef91eb72..49c7e770ed 100644 --- a/web/app/components/main-nav/components/web-apps-section.tsx +++ b/web/app/components/main-nav/components/web-apps-section.tsx @@ -10,17 +10,48 @@ import { AlertDialogTitle, } from '@langgenius/dify-ui/alert-dialog' import { cn } from '@langgenius/dify-ui/cn' +import { + ScrollAreaContent, + ScrollAreaRoot, + ScrollAreaScrollbar, + ScrollAreaThumb, + ScrollAreaViewport, +} from '@langgenius/dify-ui/scroll-area' import { toast } from '@langgenius/dify-ui/toast' -import { useMemo, useState } from 'react' +import { useVirtualizer } from '@tanstack/react-virtual' +import { useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import SearchInput from '@/app/components/base/search-input' import AppNavItem from '@/app/components/explore/sidebar/app-nav-item' import { usePathname } from '@/next/navigation' import { useGetInstalledApps, useUninstallApp, useUpdateAppPinStatus } from '@/service/use-explore' +const appNavItemHeight = 32 +const appNavItemGap = 2 +const virtualizationThreshold = 50 +const webAppSkeletonClassName = 'animate-pulse rounded bg-text-quaternary opacity-20 motion-reduce:animate-none' +const webAppSkeletonWidths = ['w-24', 'w-32', 'w-20', 'w-28', 'w-36'] + +function WebAppsSkeleton() { + return ( +