perf(web): virtualize the Web Apps sidebar list again (#41397)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: yyh <yuanyouhuilyz@gmail.com>
This commit is contained in:
Benjamin 2026-08-27 13:45:50 +00:00 committed by GitHub
parent 3ce262de05
commit 2a554ab2d1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 73 additions and 13 deletions

View File

@ -174,6 +174,8 @@ const mockProviderContextState = vi.hoisted(() => ({
} as Partial<ProviderContextState>,
}))
vi.mock('@tanstack/react-virtual')
vi.mock('@/features/agent-v2/feature-flag', () => ({
isAgentV2Enabled: () => mockIsAgentV2Enabled(),
}))

View File

@ -27,8 +27,9 @@ import {
} from '@langgenius/dify-ui/scroll-area'
import { toast } from '@langgenius/dify-ui/toast'
import { keepPreviousData, useInfiniteQuery, useMutation } from '@tanstack/react-query'
import { useVirtualizer } from '@tanstack/react-virtual'
import { useAtomValue } from 'jotai'
import { Fragment, useRef, useState } from 'react'
import { useCallback, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider'
import { InfiniteScrollSentinel } from '@/app/components/base/infinite-scroll-sentinel'
@ -43,12 +44,27 @@ import { hasPermission } from '@/utils/permission'
const emptyInstalledApps: InstalledAppResponse[] = []
const appNavItemHeight = 32
const appNavItemGap = 2
const appNavSeparatorHeight = 16.5
const getPreloadDistance = (scrollContainer: Element) =>
Math.max(160, Math.min(scrollContainer.clientHeight * 0.25, 320))
const selectInstalledApps = (data: InfiniteData<InstalledAppListResponse, string | undefined>) =>
data.pages.flatMap((page) => page.installed_apps)
type WebAppListRow =
| {
key: string
kind: 'app'
app: InstalledAppResponse
}
| {
key: string
kind: 'separator'
}
const WebAppsSectionContent = () => {
const { t } = useTranslation()
const pathname = usePathname()
@ -83,7 +99,34 @@ const WebAppsSectionContent = () => {
consoleQuery.installedApps.byInstalledAppId.patch.mutationOptions(),
)
const pinnedAppsCount = installedApps.filter(({ is_pinned }) => is_pinned).length
const webAppRows = useMemo<WebAppListRow[]>(() => {
const pinnedAppsCount = installedApps.filter(({ is_pinned }) => is_pinned).length
return installedApps.flatMap((app, index) => {
const rows: WebAppListRow[] = [{ key: app.id, kind: 'app', app }]
if (index === pinnedAppsCount - 1 && index !== installedApps.length - 1)
rows.push({ key: `${app.id}-separator`, kind: 'separator' })
return rows
})
}, [installedApps])
const getWebAppRowKey = useCallback(
(index: number) => webAppRows[index]?.key ?? index,
[webAppRows],
)
const rowVirtualizer = useVirtualizer({
count: webAppRows.length,
estimateSize: (index) =>
webAppRows[index]?.kind === 'separator' ? appNavSeparatorHeight : appNavItemHeight,
gap: appNavItemGap,
getItemKey: getWebAppRowKey,
getScrollElement: () => scrollRef.current,
overscan: 6,
paddingEnd: 8,
})
const canLoadMore = !installedAppsQuery.isFetching && !installedAppsQuery.error
const handleSearchTextChange = (value: string) => {
@ -132,7 +175,6 @@ const WebAppsSectionContent = () => {
const renderAppNavItem = (installedApp: (typeof installedApps)[number]) => (
<AppNavItem
key={installedApp.id}
app={installedApp}
ariaLabel={t(($) => $['mainNav.webApps.openApp'], {
ns: 'common',
@ -143,6 +185,11 @@ const WebAppsSectionContent = () => {
onDelete={setUninstallDialogAppId}
/>
)
const renderRow = (row: WebAppListRow) => {
if (row.kind === 'separator') return <Divider />
return renderAppNavItem(row.app)
}
return (
<Collapsible
open={appsExpanded && searchVisible}
@ -225,16 +272,27 @@ const WebAppsSectionContent = () => {
{t(($) => $['mainNav.webApps.noResults'], { ns: 'common' })}
</div>
)}
{installedApps.length > 0 && (
<div className="space-y-0.5 pb-2">
{installedApps.map((installedApp, index) => (
<Fragment key={installedApp.id}>
{renderAppNavItem(installedApp)}
{index === pinnedAppsCount - 1 && index !== installedApps.length - 1 && (
<Divider />
)}
</Fragment>
))}
{webAppRows.length > 0 && (
<div
className="relative w-full"
style={{ height: `${rowVirtualizer.getTotalSize()}px` }}
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const row = webAppRows[virtualRow.index]!
return (
<div
key={virtualRow.key}
className="absolute top-0 left-0 w-full"
style={{
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
{renderRow(row)}
</div>
)
})}
</div>
)}
{installedAppsQuery.hasNextPage && (