diff --git a/frontend/src/features/resources/resources-constants.ts b/frontend/src/features/resources/resources-constants.ts index 74d7fb1df..4d4af40c4 100644 --- a/frontend/src/features/resources/resources-constants.ts +++ b/frontend/src/features/resources/resources-constants.ts @@ -20,3 +20,13 @@ export const MAX_UPLOAD_TOTAL_SIZE_MB = 2 * 1024; /** Mirrors `resources.MaxUploadFiles`. */ export const MAX_UPLOAD_FILES_PER_REQUEST = 1000; + +/** + * Above this many entries, mounting the FileManager tree unfiltered risks + * freezing the page: the tree has no row virtualization, so building and + * rendering tens of thousands of DOM nodes on first paint blocks the main + * thread (see https://github.com/vxcontrol/pentagi/issues/403). Past this + * size, the Resources page prompts the user to search instead of eagerly + * rendering the whole library. + */ +export const LARGE_LIBRARY_PROMPT_THRESHOLD = 5000; diff --git a/frontend/src/features/resources/resources-utils.test.ts b/frontend/src/features/resources/resources-utils.test.ts new file mode 100644 index 000000000..038fcd1c8 --- /dev/null +++ b/frontend/src/features/resources/resources-utils.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; + +import { shouldPromptToSearchLargeLibrary } from './resources-utils'; + +describe('shouldPromptToSearchLargeLibrary', () => { + it('returns false when the count is at or below the threshold', () => { + expect(shouldPromptToSearchLargeLibrary(5000, false, 5000)).toBe(false); + expect(shouldPromptToSearchLargeLibrary(100, false, 5000)).toBe(false); + }); + + it('returns true when the count exceeds the threshold and no query is active', () => { + expect(shouldPromptToSearchLargeLibrary(5001, false, 5000)).toBe(true); + expect(shouldPromptToSearchLargeLibrary(100000, false, 5000)).toBe(true); + }); + + it('returns false once a search query is active, regardless of count', () => { + expect(shouldPromptToSearchLargeLibrary(100000, true, 5000)).toBe(false); + }); +}); diff --git a/frontend/src/features/resources/resources-utils.ts b/frontend/src/features/resources/resources-utils.ts index c4510eede..e037373c5 100644 --- a/frontend/src/features/resources/resources-utils.ts +++ b/frontend/src/features/resources/resources-utils.ts @@ -48,3 +48,14 @@ export const buildResourcesDownloadHref = (files: readonly FileNode[]): string = }; export const pluralizeItems = (count: number): string => (count === 1 ? 'item' : 'items'); + +/** + * True when the library is large enough that rendering it unfiltered risks + * freezing the page (see `LARGE_LIBRARY_PROMPT_THRESHOLD`), and no search + * query is active yet to narrow the result set down. + */ +export const shouldPromptToSearchLargeLibrary = ( + resourceCount: number, + hasActiveQuery: boolean, + threshold: number, +): boolean => !hasActiveQuery && resourceCount > threshold; diff --git a/frontend/src/pages/resources/resources.tsx b/frontend/src/pages/resources/resources.tsx index 7f0cc957f..b32b5c60d 100644 --- a/frontend/src/pages/resources/resources.tsx +++ b/frontend/src/pages/resources/resources.tsx @@ -44,10 +44,16 @@ import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@/ import { FileDropZone } from '@/components/ui/file-drop-zone'; import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from '@/components/ui/input-group'; import { Spinner } from '@/components/ui/spinner'; +import { LARGE_LIBRARY_PROMPT_THRESHOLD } from '@/features/resources/resources-constants'; import { ResourcesCopyDialog } from '@/features/resources/resources-copy-dialog'; import { ResourcesMkdirDialog } from '@/features/resources/resources-mkdir-dialog'; import { ResourcesMoveDialog } from '@/features/resources/resources-move-dialog'; -import { buildResourcesDownloadHref, pluralizeItems, toFileNode } from '@/features/resources/resources-utils'; +import { + buildResourcesDownloadHref, + pluralizeItems, + shouldPromptToSearchLargeLibrary, + toFileNode, +} from '@/features/resources/resources-utils'; import { useResourcesDelete } from '@/features/resources/use-resources-delete'; import { useResourcesMove } from '@/features/resources/use-resources-move'; import { useResourcesSearch } from '@/features/resources/use-resources-search'; @@ -433,6 +439,30 @@ function Resources() { ); + // Above LARGE_LIBRARY_PROMPT_THRESHOLD entries, the FileManager tree (which + // isn't virtualized) can freeze the page on first paint. Skip mounting it + // until the user narrows the list down with a search query. + const isLibraryTooLargeToBrowse = shouldPromptToSearchLargeLibrary( + resources.length, + Boolean(search.debouncedQuery.trim()), + LARGE_LIBRARY_PROMPT_THRESHOLD, + ); + + const largeLibraryState = ( + + + + + + Large resource library + + This library has {resources.length.toLocaleString()} entries — too many to browse at once. Search + above to narrow it down. + + + + ); + // Error surface only when there's no data — a failed background refetch must not blank a working list. if (error && !hasResources) { return ( @@ -544,25 +574,29 @@ function Resources() { - + {isLibraryTooLargeToBrowse ? ( + largeLibraryState + ) : ( + + )}