From cfa59dabc8dc7ea9a142d32816ec1c362cfeb566 Mon Sep 17 00:00:00 2001
From: Charis <26616127+charislam@users.noreply.github.com>
Date: Thu, 30 Oct 2025 14:27:53 -0400
Subject: [PATCH] refactor: use @tanstack/react-virtual for virtualization
(#39992)
* refactor(infinite list): use @tanstack/react-virtual for virtualization
Swapping virtualization libraries from `react-window` to
`@tanstack/react-virtual`.
Motivation: we need a completely headless library for maximum
flexibility. `react-window` injects extra DOM elements which makes it
hard to customize styling and placement on elements that are very picky
about their DOM structure, like tables.
* refactor(table editor menu): use new infinite list
* refactor(notifications): use new infinite list
* cleanup(infinite list): remove old infinite list
* refactor(storage menu): use new infinite list & remove react-window deps
---
.../StorageExplorer/FileExplorerColumn.tsx | 48 +-
.../StorageExplorer/FileExplorerRow.tsx | 21 +-
.../FileExplorerRowEditing.tsx | 9 +-
.../Storage/StorageMenu.BucketList.tsx | 95 ++--
.../interfaces/Storage/StorageMenu.tsx | 10 +-
.../NotificationRow.tsx | 26 +-
.../NotificationsPopover.tsx | 23 +-
.../TableEditorLayout/EntityListItem.tsx | 10 +-
.../TableEditorLayout/TableEditorMenu.tsx | 68 ++-
apps/studio/components/ui/InfiniteList.tsx | 482 +++++++++++++-----
apps/studio/lib/type-helpers.ts | 1 +
apps/studio/package.json | 7 +-
.../[ref]/storage/buckets/[bucketId].tsx | 2 +-
pnpm-lock.yaml | 98 +---
14 files changed, 528 insertions(+), 372 deletions(-)
create mode 100644 apps/studio/lib/type-helpers.ts
diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerColumn.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerColumn.tsx
index ebbaee5a516..0434db1514a 100644
--- a/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerColumn.tsx
+++ b/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerColumn.tsx
@@ -2,11 +2,11 @@ import { Transition } from '@headlessui/react'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { get, noop, sum } from 'lodash'
import { Upload } from 'lucide-react'
-import { useEffect, useRef, useState } from 'react'
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useContextMenu } from 'react-contexify'
import { toast } from 'sonner'
-import InfiniteList from 'components/ui/InfiniteList'
+import { InfiniteListDefault, LoaderForIconMenuItems } from 'components/ui/InfiniteList'
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { BASE_PATH } from 'lib/constants'
@@ -146,6 +146,23 @@ export const FileExplorerColumn = ({
/>
)
+ const getItemKey = useCallback(
+ (index: number) => {
+ const item = columnItems[index]
+ return item?.id || `file-explorer-item-${index}`
+ },
+ [columnItems]
+ )
+
+ const itemProps = useMemo(
+ () => ({
+ view: snap.view,
+ columnIndex: index,
+ selectedItems,
+ }),
+ [snap.view, index, selectedItems]
+ )
+
return (
(index !== 0 && index === columnItems.length ? 85 : 37)}
- hasNextPage={column.status !== STORAGE_ROW_STATUS.LOADING && column.hasMoreItems}
- isLoadingNextPage={column.isLoadingMoreItems}
- onLoadNextPage={() => onColumnLoadMore(index, column)}
- />
+ {columnItems.length > 0 && (
+
(index !== 0 && index === columnItems.length ? 85 : 37)}
+ ItemComponent={FileExplorerRow}
+ LoaderComponent={LoaderForIconMenuItems}
+ hasNextPage={column.status !== STORAGE_ROW_STATUS.LOADING && column.hasMoreItems}
+ isLoadingNextPage={column.isLoadingMoreItems}
+ onLoadNextPage={() => onColumnLoadMore(index, column)}
+ />
+ )}
{/* Drag drop upload CTA for when column is empty */}
{!(snap.isSearching && itemSearchString.length > 0) &&
diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerRow.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerRow.tsx
index 4634275532a..2db50f2dc88 100644
--- a/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerRow.tsx
+++ b/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerRow.tsx
@@ -18,10 +18,10 @@ import { useContextMenu } from 'react-contexify'
import SVG from 'react-inlinesvg'
import { useParams } from 'common'
-import type { ItemRenderer } from 'components/ui/InfiniteList'
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { BASE_PATH } from 'lib/constants'
import { formatBytes } from 'lib/helpers'
+import type { CSSProperties } from 'react'
import { useStorageExplorerStateSnapshot } from 'state/storage-explorer'
import {
Checkbox,
@@ -46,7 +46,7 @@ import {
STORAGE_VIEWS,
URL_EXPIRY_DURATION,
} from '../Storage.constants'
-import { StorageItem, StorageItemWithColumn } from '../Storage.types'
+import { StorageItemWithColumn, type StorageItem } from '../Storage.types'
import { FileExplorerRowEditing } from './FileExplorerRowEditing'
import { copyPathToFolder, downloadFile } from './StorageExplorer.utils'
import { useCopyUrl } from './useCopyUrl'
@@ -99,18 +99,22 @@ export const RowIcon = ({
}
interface FileExplorerRowProps {
+ index: number
+ item: StorageItem
view: STORAGE_VIEWS
columnIndex: number
selectedItems: StorageItemWithColumn[]
+ style?: CSSProperties
}
-export const FileExplorerRow: ItemRenderer = ({
+export const FileExplorerRow = ({
index: itemIndex,
item,
view = STORAGE_VIEWS.COLUMNS,
columnIndex = 0,
selectedItems = [],
-}) => {
+ style,
+}: FileExplorerRowProps) => {
const { ref: projectRef, bucketId } = useParams()
const {
@@ -141,7 +145,7 @@ export const FileExplorerRow: ItemRenderer =
const isPreviewed = !isEmpty(selectedFilePreview) && isEqual(selectedFilePreview?.id, item.id)
const { can: canUpdateFiles } = useAsyncCheckPermissions(PermissionAction.STORAGE_WRITE, '*')
- const onSelectFile = async (columnIndex: number, file: StorageItem) => {
+ const onSelectFile = async (columnIndex: number) => {
popColumnAtIndex(columnIndex)
popOpenedFoldersAtIndex(columnIndex - 1)
setSelectedFilePreview(itemWithColumnIndex)
@@ -299,11 +303,14 @@ export const FileExplorerRow: ItemRenderer =
: '100%'
if (item.status === STORAGE_ROW_STATUS.EDITING) {
- return
+ return (
+
+ )
}
return (
{
event.stopPropagation()
@@ -326,7 +333,7 @@ export const FileExplorerRow: ItemRenderer
=
if (item.status !== STORAGE_ROW_STATUS.LOADING && !isOpened && !isPreviewed) {
item.type === STORAGE_ROW_TYPES.FOLDER || item.type === STORAGE_ROW_TYPES.BUCKET
? openFolder(columnIndex, item)
- : onSelectFile(columnIndex, item)
+ : onSelectFile(columnIndex)
}
}}
>
diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerRowEditing.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerRowEditing.tsx
index 2ee1a7f02c3..9c666b337e7 100644
--- a/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerRowEditing.tsx
+++ b/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerRowEditing.tsx
@@ -1,5 +1,5 @@
import { has } from 'lodash'
-import { useEffect, useRef, useState } from 'react'
+import { useEffect, useRef, useState, type CSSProperties } from 'react'
import { useStorageExplorerStateSnapshot } from 'state/storage-explorer'
import { STORAGE_ROW_STATUS, STORAGE_ROW_TYPES, STORAGE_VIEWS } from '../Storage.constants'
@@ -10,12 +10,14 @@ export interface FileExplorerRowEditingProps {
item: StorageItem
view: STORAGE_VIEWS
columnIndex: number
+ style?: CSSProperties
}
export const FileExplorerRowEditing = ({
item,
view,
columnIndex,
+ style,
}: FileExplorerRowEditingProps) => {
const { renameFile, renameFolder, addNewFolder, updateRowStatus } =
useStorageExplorerStateSnapshot()
@@ -86,7 +88,10 @@ export const FileExplorerRowEditing = ({
}, [])
return (
-
+
) => {
- const bucket = data.buckets[index]
- const isSelected = data.selectedBucketId === bucket.id
+ ({ item, projectRef, selectedBucketId, style }: VirtualizedBucketRowProps) => {
+ const isSelected = selectedBucketId === item.id
return (
)
- },
- (prev, next) => {
- if (!areEqual(prev, next)) return false
-
- const prevBucket = prev.data.buckets[prev.index]
- const nextBucket = next.data.buckets[next.index]
-
- if (prevBucket !== nextBucket) return false
-
- const wasSelected = prev.data.selectedBucketId === prevBucket.id
- const isSelected = next.data.selectedBucketId === nextBucket.id
-
- return wasSelected === isSelected
}
)
VirtualizedBucketRow.displayName = 'VirtualizedBucketRow'
const BucketListVirtualized = ({ buckets, selectedBucketId, projectRef = '' }: BucketListProps) => {
- const [listHeight, setListHeight] = useState(500)
- const sizerRef = useRef(null)
-
- useLayoutEffect(() => {
- if (sizerRef.current) {
- const resizeObserver = new ResizeObserver(([entry]) => {
- const { height } = entry.contentRect
- setListHeight(height)
- })
-
- resizeObserver.observe(sizerRef.current)
- setListHeight(sizerRef.current.getBoundingClientRect().height)
-
- return () => {
- resizeObserver.disconnect()
- }
- }
- }, [])
-
- const itemData = useMemo(
+ const itemData = useMemo(
() => ({
- buckets,
projectRef,
selectedBucketId,
}),
- [buckets, projectRef, selectedBucketId]
+ [projectRef, selectedBucketId]
+ )
+
+ const getItemKey = useCallback(
+ (index: number) => {
+ const item = buckets[index]
+ return item?.id || `bucket-${index}`
+ },
+ [buckets]
)
return (
-
- buckets[index].id}
- height={listHeight}
- // itemSize should match the height of BucketRow + any gap/margin
- itemSize={28}
- width="100%"
- >
- {VirtualizedBucketRow}
-
-
+ 28}
+ ItemComponent={VirtualizedBucketRow}
+ // There is no loader because all buckets load from backend at once
+ LoaderComponent={() => null}
+ />
)
}
+type BucketListProps = {
+ buckets: Bucket[]
+ selectedBucketId?: string
+ projectRef?: string
+}
+
export const BucketList = ({ buckets, selectedBucketId, projectRef = '' }: BucketListProps) => {
const numBuckets = buckets.length
diff --git a/apps/studio/components/interfaces/Storage/StorageMenu.tsx b/apps/studio/components/interfaces/Storage/StorageMenu.tsx
index 0ee63944eb8..7b0c795f94d 100644
--- a/apps/studio/components/interfaces/Storage/StorageMenu.tsx
+++ b/apps/studio/components/interfaces/Storage/StorageMenu.tsx
@@ -74,7 +74,11 @@ export const StorageMenu = () => {
return (
<>
-