diff --git a/apps/drive-web/package.json b/apps/drive-web/package.json index d5c6811..638e449 100644 --- a/apps/drive-web/package.json +++ b/apps/drive-web/package.json @@ -24,6 +24,7 @@ "@tanstack/react-query": "^5.87.4", "@tanstack/react-router": "^1.131.41", "@tanstack/react-table": "^8.21.3", + "@tanstack/react-virtual": "^3.13.13", "@tanstack/router-devtools": "^1.131.42", "arktype": "^2.1.28", "better-auth": "1.3.8", diff --git a/apps/drive-web/src/components/ui/table.tsx b/apps/drive-web/src/components/ui/table.tsx index d53ae85..fc5e571 100644 --- a/apps/drive-web/src/components/ui/table.tsx +++ b/apps/drive-web/src/components/ui/table.tsx @@ -3,17 +3,22 @@ import type * as React from "react" import { cn } from "@/lib/utils" function Table({ className, ...props }: React.ComponentProps<"table">) { + return ( + + ) +} + +function TableContainer({ className, ...props }: React.ComponentProps<"div">) { return (
-
- + className={cn("relative w-full overflow-x-auto", className)} + {...props} + /> ) } @@ -104,6 +109,7 @@ function TableCaption({ export { Table, + TableContainer, TableHeader, TableBody, TableFooter, diff --git a/apps/drive-web/src/directories/directory-page/directory-content-table-wrapper.tsx b/apps/drive-web/src/directories/directory-page/directory-content-table-wrapper.tsx new file mode 100644 index 0000000..4d9dbed --- /dev/null +++ b/apps/drive-web/src/directories/directory-page/directory-content-table-wrapper.tsx @@ -0,0 +1,65 @@ +/** + * Wrapper component that conditionally loads real or mock table. + * Only available in dev mode - mock table is never included in production builds. + */ + +import type { Row, Table as TableType } from "@tanstack/react-table" +import type { PrimitiveAtom } from "jotai" +import { atom, useAtomValue } from "jotai" +import { atomWithStorage } from "jotai/utils" +import { lazy, Suspense } from "react" +import type { FileDragInfo } from "@/files/use-file-drop" +import type { DirectoryInfo, DirectoryItem, FileInfo } from "@/vfs/vfs" +import { DirectoryContentTable } from "./directory-content-table" + +// Atom to control mock table usage +// In dev mode: uses atomWithStorage to persist in localStorage +// In production: uses regular atom (always false, tree-shaken) +// This ensures hooks are always called in the same order +const useMockTableAtom = import.meta.env.DEV + ? atomWithStorage("drexa:use-mock-directory-table", false) + : atom(false) + +// Conditional lazy import - Vite will tree-shake this entire import in production +// because import.meta.env.DEV is evaluated at build time +const MockDirectoryContentTable = import.meta.env.DEV + ? lazy(() => + import("./mock-directory-content-table").then((mod) => ({ + default: mod.MockDirectoryContentTable, + })), + ) + : null + +type DirectoryContentTableWrapperProps = { + directoryUrlFn: (directory: DirectoryInfo) => string + fileDragInfoAtom: PrimitiveAtom + onContextMenu: ( + row: Row, + table: TableType, + ) => void + onOpenFile: (file: FileInfo) => void +} + +export function DirectoryContentTableWrapper( + props: DirectoryContentTableWrapperProps, +) { + // Always call the hook - in production the atom always returns false + const useMock = useAtomValue(useMockTableAtom) + + // In production, MockDirectoryContentTable is null, so this always renders real table + if (import.meta.env.DEV && useMock && MockDirectoryContentTable) { + return ( + Loading mock table...}> + + + ) + } + + return +} + +/** + * Dev-only: Export the atom for use in toggle components. + * This is only available in dev mode and will be tree-shaken in production. + */ +export const mockTableAtom = useMockTableAtom diff --git a/apps/drive-web/src/directories/directory-page/directory-content-table.tsx b/apps/drive-web/src/directories/directory-page/directory-content-table.tsx index 6ef570f..3e42eaa 100644 --- a/apps/drive-web/src/directories/directory-page/directory-content-table.tsx +++ b/apps/drive-web/src/directories/directory-page/directory-content-table.tsx @@ -9,7 +9,9 @@ import { type Table as TableType, useReactTable, } from "@tanstack/react-table" +import { useVirtualizer, type VirtualItem } from "@tanstack/react-virtual" import { type PrimitiveAtom, useAtomValue, useSetAtom, useStore } from "jotai" +import type React from "react" import { useContext, useEffect, useMemo, useRef } from "react" import { DirectoryIcon } from "@/components/icons/directory-icon" import { TextFileIcon } from "@/components/icons/text-file-icon" @@ -18,6 +20,7 @@ import { Table, TableBody, TableCell, + TableContainer, TableHead, TableHeader, TableRow, @@ -158,8 +161,13 @@ export function DirectoryContentTable({ limit: 100, }), ) - const { data: directoryContent, isLoading: isLoadingDirectoryContent } = - useInfiniteQuery(directoryContentQuery) + const { + data: directoryContent, + isLoading: isLoadingDirectoryContent, + isFetchingNextPage: isFetchingMoreDirectoryItems, + fetchNextPage: fetchMoreDirectoryItems, + hasNextPage: hasMoreDirectoryItems, + } = useInfiniteQuery(directoryContentQuery) const store = useStore() const navigate = useNavigate() @@ -182,6 +190,34 @@ export function DirectoryContentTable({ ) => !filterValue.has(row.original.id), getRowId: (row) => row.id, }) + const { rows } = table.getRowModel() + + const containerRef = useRef(null) + const virtualizer = useVirtualizer({ + count: rows.length, + getScrollElement: () => containerRef.current, + estimateSize: () => 36, + overscan: 20, + }) + const virtualItems = virtualizer.getVirtualItems() + + useEffect(() => { + const lastVirtualItem = virtualItems.at(-1) + if ( + lastVirtualItem && + lastVirtualItem.index >= rows.length - 1 && + hasMoreDirectoryItems && + !isFetchingMoreDirectoryItems + ) { + fetchMoreDirectoryItems() + } + }, [ + virtualItems, + rows.length, + hasMoreDirectoryItems, + isFetchingMoreDirectoryItems, + fetchMoreDirectoryItems, + ]) useEffect( function escapeToClearSelections() { @@ -240,15 +276,41 @@ export function DirectoryContentTable({ } } + const renderRow = (virtualRow: VirtualItem, i: number) => { + const row = rows[virtualRow.index]! + return ( + selectRow(row)} + fileDragInfoAtom={fileDragInfoAtom} + onContextMenu={(e) => handleRowContextMenu(row, e)} + onDoubleClick={() => { + handleRowDoubleClick(row) + }} + /> + ) + } + return ( -
-
+ +
{table.getHeaderGroups().map((headerGroup) => ( - + {headerGroup.headers.map((header) => ( @@ -263,29 +325,15 @@ export function DirectoryContentTable({ ))} - - {table.getRowModel().rows?.length ? ( - table.getRowModel().rows.map((row) => ( - selectRow(row)} - fileDragInfoAtom={fileDragInfoAtom} - onContextMenu={(e) => - handleRowContextMenu(row, e) - } - onDoubleClick={() => { - handleRowDoubleClick(row) - }} - /> - )) + + {rows.length > 0 ? ( + virtualItems.map(renderRow) ) : ( )}
- + ) } @@ -306,7 +354,8 @@ function FileItemRow({ onContextMenu, onDoubleClick, fileDragInfoAtom, -}: { + ...rowProps +}: React.ComponentProps & { table: TableType row: Row onClick: () => void @@ -365,6 +414,7 @@ function FileItemRow({ onDragEnd={handleDragEnd} {...dropHandlers} className={cn({ "bg-muted": isDraggedOver })} + {...rowProps} > {row.getVisibleCells().map((cell) => ( { + // First sort by kind (directories before files) + if (a.kind !== b.kind) { + return a.kind.localeCompare(b.kind) // 'directory' < 'file' + } + // Then by name (default sort) + return a.name.localeCompare(b.name) + }, + ) + + return infiniteQueryOptions({ + queryKey: ["mock", "directories", "content", totalItems], + initialPageParam: { + orderBy: "name" as const, + direction: "asc" as const, + limit: itemsPerPage, + cursor: "", + }, + queryFn: async ({ pageParam }) => { + // Simulate network delay if specified + if (delayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, delayMs)) + } + + const cursor = pageParam.cursor || "" + const startIndex = cursor ? parseInt(cursor, 10) : 0 + const endIndex = Math.min(startIndex + itemsPerPage, allItems.length) + const pageItems = allItems.slice(startIndex, endIndex) + + // Items are already sorted globally, but we may need to re-sort per page + // based on the orderBy parameter (for now, we only support name sorting) + // The global sort already handles directories first, so we just return the slice + return { + items: pageItems, + nextCursor: + endIndex < allItems.length ? endIndex.toString() : undefined, + } + }, + getNextPageParam: (lastPage, _pages, lastPageParam) => + lastPage.nextCursor + ? { + ...lastPageParam, + cursor: lastPage.nextCursor, + } + : null, + }) +} + +/** + * Helper to log virtualization metrics for debugging. + */ +function logVirtualizationStats( + virtualItems: Array<{ index: number; start: number; size: number }>, + totalRows: number, +) { + if (virtualItems.length === 0) { + console.log("No virtual items rendered") + return + } + + const firstIndex = virtualItems[0]?.index ?? 0 + const lastIndex = virtualItems[virtualItems.length - 1]?.index ?? 0 + const renderedCount = virtualItems.length + + console.log("Virtualization Stats:", { + totalRows, + renderedRows: renderedCount, + firstRenderedIndex: firstIndex, + lastRenderedIndex: lastIndex, + coverage: `${((renderedCount / totalRows) * 100).toFixed(2)}%`, + viewportRange: `${firstIndex} - ${lastIndex}`, + }) +} + +type MockDirectoryContentTableProps = { + directoryUrlFn: (directory: DirectoryInfo) => string + fileDragInfoAtom: PrimitiveAtom + onContextMenu: ( + row: Row, + table: TableType, + ) => void + onOpenFile: (file: FileInfo) => void +} + +function formatFileSize(bytes: number): string { + if (bytes === 0) return "0 B" + + const k = 1024 + const sizes = ["B", "KB", "MB", "GB", "TB", "PB"] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + + return `${parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}` +} + +function useTableColumns( + onOpenFile: (file: FileInfo) => void, + directoryUrlFn: (directory: DirectoryInfo) => string, +): ColumnDef[] { + return useMemo( + () => [ + { + id: "select", + header: ({ table }) => ( + { + table.toggleAllPageRowsSelected(!!value) + }} + aria-label="Select all" + /> + ), + cell: ({ row }) => ( + { + e.stopPropagation() + }} + onCheckedChange={row.getToggleSelectedHandler()} + aria-label="Select row" + /> + ), + enableSorting: false, + enableHiding: false, + size: 24, + }, + { + header: "Name", + accessorKey: "doc.name", + cell: ({ row }) => { + switch (row.original.kind) { + case "file": + return ( + + ) + case "directory": + return ( + + ) + } + }, + size: 1000, + }, + { + header: "Size", + accessorKey: "size", + cell: ({ row }) => { + switch (row.original.kind) { + case "file": + return ( +
{formatFileSize(row.original.size)}
+ ) + case "directory": + return
-
+ } + }, + }, + { + header: "Created At", + accessorKey: "createdAt", + cell: ({ row }) => { + return ( +
+ {new Date(row.original.createdAt).toLocaleString()} +
+ ) + }, + }, + ], + [onOpenFile, directoryUrlFn], + ) +} + +export function MockDirectoryContentTable({ + directoryUrlFn, + onContextMenu, + fileDragInfoAtom, + onOpenFile, +}: MockDirectoryContentTableProps) { + // Use mock query instead of real API + const mockQuery = useMemo( + () => + createMockDirectoryContentQuery( + TOTAL_ITEMS, + ITEMS_PER_PAGE, + NETWORK_DELAY_MS, + ), + [], + ) + + const { + data: directoryContent, + isLoading: isLoadingDirectoryContent, + isFetchingNextPage: isFetchingMoreDirectoryItems, + fetchNextPage: fetchMoreDirectoryItems, + hasNextPage: hasMoreDirectoryItems, + } = useInfiniteQuery(mockQuery) + + const store = useStore() + const navigate = useNavigate() + + const table = useReactTable({ + data: useMemo( + () => directoryContent?.pages.flatMap((page) => page.items) || [], + [directoryContent], + ), + columns: useTableColumns(onOpenFile, directoryUrlFn), + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), + enableRowSelection: true, + enableGlobalFilter: true, + globalFilterFn: (row, _columnId, filterValue: Set, _addMeta) => + !filterValue.has(row.original.id), + getRowId: (row) => row.id, + }) + const { rows } = table.getRowModel() + + const containerRef = useRef(null) + const virtualizer = useVirtualizer({ + count: rows.length, + getScrollElement: () => containerRef.current, + estimateSize: () => 36, + overscan: 20, + }) + const virtualItems = virtualizer.getVirtualItems() + + // Log virtualization stats for debugging + useEffect(() => { + if (rows.length > 0 && virtualItems.length > 0) { + logVirtualizationStats(virtualItems, rows.length) + } + }, [virtualItems, rows.length]) + + useEffect(() => { + const lastVirtualItem = virtualItems.at(-1) + if ( + lastVirtualItem && + lastVirtualItem.index >= rows.length - 1 && + hasMoreDirectoryItems && + !isFetchingMoreDirectoryItems + ) { + fetchMoreDirectoryItems() + } + }, [ + virtualItems, + rows.length, + hasMoreDirectoryItems, + isFetchingMoreDirectoryItems, + fetchMoreDirectoryItems, + ]) + + useEffect( + function escapeToClearSelections() { + const handleEscape = (e: KeyboardEvent) => { + if (e.key === "Escape") { + table.setRowSelection({}) + } + } + window.addEventListener("keydown", handleEscape) + return () => window.removeEventListener("keydown", handleEscape) + }, + [table.setRowSelection], + ) + + if (isLoadingDirectoryContent) { + return ( +
+
+ Loading {TOTAL_ITEMS.toLocaleString()} mock items... +
+
+ ) + } + + const handleRowContextMenu = ( + row: Row, + _event: React.MouseEvent, + ) => { + if (!row.getIsSelected()) { + selectRow(row) + } + onContextMenu(row, table) + } + + const selectRow = (row: Row) => { + const keyboardModifiers = store.get(keyboardModifierAtom) + const isMultiSelectMode = isControlOrCommandKeyActive(keyboardModifiers) + const isRowSelected = row.getIsSelected() + if (isRowSelected && isMultiSelectMode) { + row.toggleSelected(false) + } else if (isRowSelected && !isMultiSelectMode) { + table.setRowSelection({ + [row.id]: true, + }) + row.toggleSelected(true) + } else if (!isRowSelected) { + if (isMultiSelectMode) { + row.toggleSelected(true) + } else { + table.setRowSelection({ + [row.id]: true, + }) + } + } + } + + const handleRowDoubleClick = (row: Row) => { + if (row.original.kind === "directory") { + navigate({ + to: `/directories/${row.original.id}`, + }) + } + } + + const renderRow = (virtualRow: VirtualItem, i: number) => { + const row = rows[virtualRow.index] + if (!row) return null + return ( + selectRow(row)} + fileDragInfoAtom={fileDragInfoAtom} + onContextMenu={(e) => handleRowContextMenu(row, e)} + onDoubleClick={() => { + handleRowDoubleClick(row) + }} + /> + ) + } + + return ( +
+ {/* Debug info banner */} +
+ Test Mode: Showing{" "} + {rows.length.toLocaleString()} of {TOTAL_ITEMS.toLocaleString()}{" "} + items | Rendered: {virtualItems.length} rows | Check console for + virtualization stats +
+ + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef + .header, + header.getContext(), + )} + + ))} + + ))} + + + {rows.length > 0 ? ( + virtualItems.map(renderRow) + ) : ( + + )} + +
+
+
+ ) +} + +function NoResultsRow() { + return ( + + + No results. + + + ) +} + +function FileItemRow({ + table, + row, + onClick, + onContextMenu, + onDoubleClick, + fileDragInfoAtom, + ...rowProps +}: React.ComponentProps & { + table: TableType + row: Row + onClick: () => void + onContextMenu: (e: React.MouseEvent) => void + onDoubleClick: () => void + fileDragInfoAtom: PrimitiveAtom +}) { + const ref = useRef(null) + const setFileDragInfo = useSetAtom(fileDragInfoAtom) + + const { isDraggedOver, dropHandlers } = useFileDrop({ + enabled: row.original.kind === "directory", + destDir: row.original.kind === "directory" ? row.original : undefined, + dragInfoAtom: fileDragInfoAtom, + }) + + const handleDragStart = (_e: React.DragEvent) => { + let draggedItems: DirectoryItem[] + if (row.getIsSelected()) { + draggedItems = [] + let currentRowFound = false + for (const { original: item } of table.getSelectedRowModel().rows) { + draggedItems.push(item) + if (item.id === row.original.id) { + currentRowFound = true + } + } + if (!currentRowFound) { + draggedItems.push(row.original) + } + } else { + draggedItems = [row.original] + } + + setFileDragInfo({ + source: row.original, + items: draggedItems, + }) + } + + const handleDragEnd = () => { + setFileDragInfo(null) + } + + return ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + ) +} + +function DirectoryNameCell({ + directory, +}: { + directory: DirectoryInfo + directoryUrlFn: (directory: DirectoryInfo) => string +}) { + return ( +
+ + {directory.name} +
+ ) +} + +function FileNameCell({ + file, + onOpenFile, +}: { + file: FileInfo + onOpenFile: (file: FileInfo) => void +}) { + return ( +
+ + +
+ ) +} + diff --git a/apps/drive-web/src/routes/_authenticated/_sidebar-layout.tsx b/apps/drive-web/src/routes/_authenticated/_sidebar-layout.tsx index a24d981..a4f8e57 100644 --- a/apps/drive-web/src/routes/_authenticated/_sidebar-layout.tsx +++ b/apps/drive-web/src/routes/_authenticated/_sidebar-layout.tsx @@ -11,7 +11,7 @@ function RouteComponent() {
- +
diff --git a/apps/drive-web/src/routes/_authenticated/_sidebar-layout/directories.$directoryId.tsx b/apps/drive-web/src/routes/_authenticated/_sidebar-layout/directories.$directoryId.tsx index 8883611..2fc2583 100644 --- a/apps/drive-web/src/routes/_authenticated/_sidebar-layout/directories.$directoryId.tsx +++ b/apps/drive-web/src/routes/_authenticated/_sidebar-layout/directories.$directoryId.tsx @@ -30,7 +30,10 @@ import { import { WithAtom } from "@/components/with-atom" import { backgroundTaskProgressAtom } from "@/dashboard/state" import { DirectoryPageContext } from "@/directories/directory-page/context" -import { DirectoryContentTable } from "@/directories/directory-page/directory-content-table" +import { + DirectoryContentTableWrapper, + mockTableAtom, +} from "@/directories/directory-page/directory-content-table-wrapper" import { DirectoryPageSkeleton } from "@/directories/directory-page/directory-page-skeleton" import { NewDirectoryDialog } from "@/directories/directory-page/new-directory-dialog" import { RenameFileDialog } from "@/directories/directory-page/rename-file-dialog" @@ -155,6 +158,7 @@ function RouteComponent() { fileDragInfoAtom={fileDragInfoAtom} />
+ {import.meta.env.DEV && }
@@ -162,8 +166,8 @@ function RouteComponent() { {/* DirectoryContentContextMenu must wrap div instead of DirectoryContentTable, otherwise radix will throw "event.preventDefault is not a function" error, idk why */} -
- + ) } + +function MockTableToggle() { + // Always call the hook - mockTableAtom is always defined + // (it's a regular atom in production, but we only render in dev mode) + const [isEnabled, setIsEnabled] = useAtom(mockTableAtom) + + if (!import.meta.env.DEV) { + return null + } + + const handleToggle = () => { + setIsEnabled((prev) => !prev) + } + + return ( + + ) +} diff --git a/apps/drive-web/src/vfs/api.ts b/apps/drive-web/src/vfs/api.ts index d7b7f19..e1b17fa 100644 --- a/apps/drive-web/src/vfs/api.ts +++ b/apps/drive-web/src/vfs/api.ts @@ -115,10 +115,13 @@ export const directoryContentQueryAtom = atomFamily( { returns: DirectoryContentResponse }, ).then(([_, result]) => result) : Promise.reject(new Error("No account selected")), - getNextPageParam: (lastPage, _pages, lastPageParam) => ({ - ...lastPageParam, - cursor: lastPage.nextCursor ?? "", - }), + getNextPageParam: (lastPage, _pages, lastPageParam) => + lastPage.nextCursor + ? { + ...lastPageParam, + cursor: lastPage.nextCursor, + } + : null, }) }), (paramsA, paramsB) => diff --git a/bun.lock b/bun.lock index a530d92..9182a2f 100644 --- a/bun.lock +++ b/bun.lock @@ -49,6 +49,7 @@ "@tanstack/react-query": "^5.87.4", "@tanstack/react-router": "^1.131.41", "@tanstack/react-table": "^8.21.3", + "@tanstack/react-virtual": "^3.13.13", "@tanstack/router-devtools": "^1.131.42", "arktype": "^2.1.28", "better-auth": "1.3.8", @@ -499,6 +500,8 @@ "@tanstack/react-table": ["@tanstack/react-table@8.21.3", "", { "dependencies": { "@tanstack/table-core": "8.21.3" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww=="], + "@tanstack/react-virtual": ["@tanstack/react-virtual@3.13.13", "", { "dependencies": { "@tanstack/virtual-core": "3.13.13" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-4o6oPMDvQv+9gMi8rE6gWmsOjtUZUYIJHv7EB+GblyYdi8U6OqLl8rhHWIUZSL1dUU2dPwTdTgybCKf9EjIrQg=="], + "@tanstack/router-cli": ["@tanstack/router-cli@1.133.12", "", { "dependencies": { "@tanstack/router-generator": "1.133.12", "chokidar": "^3.6.0", "yargs": "^17.7.2" }, "bin": { "tsr": "bin/tsr.cjs" } }, "sha512-5rBpY1yixbxtuLarXSTXK6mD2Wrluyqy9/LRS1k9o61dLiBi9L4HlYkkXkKtpvOXb4VhxlqgmSg2JwASYCi2ng=="], "@tanstack/router-core": ["@tanstack/router-core@1.133.13", "", { "dependencies": { "@tanstack/history": "1.133.3", "@tanstack/store": "^0.7.0", "cookie-es": "^2.0.0", "seroval": "^1.3.2", "seroval-plugins": "^1.3.2", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-zZptdlS/wSkqozb07Y3zX5gas2OapJdjEG6/Id0e/twNefVdR4EY2TK/mgvyhHtKIpCxIcnZz/3opypgeQi9bg=="], @@ -517,6 +520,8 @@ "@tanstack/table-core": ["@tanstack/table-core@8.21.3", "", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="], + "@tanstack/virtual-core": ["@tanstack/virtual-core@3.13.13", "", {}, "sha512-uQFoSdKKf5S8k51W5t7b2qpfkyIbdHMzAn+AMQvHPxKUPeo1SsGaA4JRISQT87jm28b7z8OEqPcg1IOZagQHcA=="], + "@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.133.3", "", {}, "sha512-6d2AP9hAjEi8mcIew2RkxBX+wClH1xedhfaYhs8fUiX+V2Cedk7RBD9E9ww2z6BGUYD8Es4fS0OIrzXZWHKGhw=="], "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], @@ -859,7 +864,7 @@ "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - "@drexa/auth/@types/bun": ["@types/bun@1.3.3", "", { "dependencies": { "bun-types": "1.3.3" } }, "sha512-ogrKbJ2X5N0kWLLFKeytG0eHDleBYtngtlbu9cyBKFtNL3cnpDZkNdQj8flVf6WTZUX5ulI9AY1oa7ljhSrp+g=="], + "@drexa/auth/@types/bun": ["@types/bun@1.3.4", "", { "dependencies": { "bun-types": "1.3.4" } }, "sha512-EEPTKXHP+zKGPkhRLv+HI0UEX8/o+65hqARxLy8Ov5rIxMBPNTjeZww00CIihrIQGEQBYg+0roO5qOnS/7boGA=="], "@fileone/web/arktype": ["arktype@2.1.28", "", { "dependencies": { "@ark/schema": "0.56.0", "@ark/util": "0.56.0", "arkregex": "0.0.4" } }, "sha512-LVZqXl2zWRpNFnbITrtFmqeqNkPPo+KemuzbGSY6jvJwCb4v8NsDzrWOLHnQgWl26TkJeWWcUNUeBpq2Mst1/Q=="], @@ -897,7 +902,7 @@ "tsyringe/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], - "@drexa/auth/@types/bun/bun-types": ["bun-types@1.3.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-z3Xwlg7j2l9JY27x5Qn3Wlyos8YAp0kKRlrePAOjgjMGS5IG6E7Jnlx736vH9UVI4wUICwwhC9anYL++XeOgTQ=="], + "@drexa/auth/@types/bun/bun-types": ["bun-types@1.3.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-5ua817+BZPZOlNaRgGBpZJOSAQ9RQ17pkwPD0yR7CfJg+r8DgIILByFifDTa+IPDDxzf5VNhtNlcKqFzDgJvlQ=="], "@fileone/web/arktype/@ark/schema": ["@ark/schema@0.56.0", "", { "dependencies": { "@ark/util": "0.56.0" } }, "sha512-ECg3hox/6Z/nLajxXqNhgPtNdHWC9zNsDyskwO28WinoFEnWow4IsERNz9AnXRhTZJnYIlAJ4uGn3nlLk65vZA=="], diff --git a/docs/virtualization-testing.md b/docs/virtualization-testing.md new file mode 100644 index 0000000..0a6cae4 --- /dev/null +++ b/docs/virtualization-testing.md @@ -0,0 +1,114 @@ +# Virtualization Testing Guide + +This guide explains how to test the virtualization of the `DirectoryContentTable` component with large datasets. + +## Quick Start + +The app includes a dev-mode-only toggle to switch between the real and mock table. No code changes needed! + +1. **Enable Dev Mode**: Make sure you're running in development mode (`bun dev` or `npm run dev`) + +2. **Toggle Mock Table**: + - Look for the "Mock: OFF/ON" button in the directory page header + - Click it to toggle between real and mock data + - The toggle state persists in localStorage + +3. **Adjust Test Parameters** (optional): Edit `apps/drive-web/src/directories/directory-page/mock-directory-content-table.tsx`: + ```tsx + const TOTAL_ITEMS = 10_000 // Total number of items to simulate + const ITEMS_PER_PAGE = 100 // Items per page (for pagination) + const NETWORK_DELAY_MS = 50 // Simulated network delay in milliseconds + ``` + +## How It Works + +- The `DirectoryContentTableWrapper` component automatically handles switching between real and mock tables +- Uses Jotai's `atomWithStorage` to persist the toggle state in localStorage +- The mock table code is completely tree-shaken in production builds +- No page reload needed - switching is instant + +## Test Configuration + +You can adjust these parameters in `mock-directory-content-table.tsx`: + +- **TOTAL_ITEMS**: Total number of items to simulate (default: 10,000) + - Try: 1,000 for quick tests, 50,000+ for stress testing + +- **ITEMS_PER_PAGE**: Number of items per page (default: 100) + - This simulates pagination behavior + +- **NETWORK_DELAY_MS**: Simulated network delay (default: 50ms) + - Set to 0 for instant loading, higher values to test loading states + +## What to Test + +1. **Scroll Performance**: Scroll through the list and verify smooth scrolling +2. **Infinite Loading**: Scroll to the bottom and verify more items load automatically +3. **Selection**: Select multiple items and verify performance +4. **Virtualization Stats**: Check the browser console for virtualization metrics +5. **Memory Usage**: Monitor browser DevTools to ensure memory stays reasonable + +## Debugging + +The mock table component includes: +- A debug banner showing current stats (total items, rendered rows) +- Console logging of virtualization metrics (check browser console) +- Visual indicators for rendered vs total items + +## Implementation Details + +### Files + +- `apps/drive-web/src/directories/directory-page/directory-content-table-wrapper.tsx` + - Wrapper component that conditionally loads real or mock table + - Uses Jotai atoms for state management + - Exports `mockTableAtom` for toggle components + +- `apps/drive-web/src/directories/directory-page/mock-directory-content-table.tsx` + - Mock table component with all utilities inlined + - Generates mock data matching API behavior (directories first, then files) + - Includes virtualization stats logging + +### Utilities + +The `mock-directory-content-table.tsx` file includes all utilities inlined: + +- `generateMockDirectoryItems()`: Generate mock data +- `createMockDirectoryContentQuery()`: Create a mock infinite query +- `logVirtualizationStats()`: Log virtualization metrics + +All utilities are defined within the same file for convenience. + +## Example Test Scenarios + +### Small Dataset (1,000 items) +```tsx +const TOTAL_ITEMS = 1_000 +const ITEMS_PER_PAGE = 100 +``` + +### Medium Dataset (10,000 items) +```tsx +const TOTAL_ITEMS = 10_000 +const ITEMS_PER_PAGE = 100 +``` + +### Large Dataset (100,000 items) +```tsx +const TOTAL_ITEMS = 100_000 +const ITEMS_PER_PAGE = 200 +``` + +### Stress Test (1,000,000 items) +```tsx +const TOTAL_ITEMS = 1_000_000 +const ITEMS_PER_PAGE = 500 +``` + +## Production Safety + +- The mock table code is completely excluded from production builds +- Vite's static analysis removes all dev-mode code paths +- The toggle button only appears in development mode +- No performance impact on production bundles +