Skip to content

Commit cfa59da

Browse files
authored
refactor: use @tanstack/react-virtual for virtualization (supabase#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
1 parent c3fb312 commit cfa59da

File tree

14 files changed

+528
-372
lines changed

14 files changed

+528
-372
lines changed

apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerColumn.tsx

Lines changed: 33 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@ import { Transition } from '@headlessui/react'
22
import { PermissionAction } from '@supabase/shared-types/out/constants'
33
import { get, noop, sum } from 'lodash'
44
import { Upload } from 'lucide-react'
5-
import { useEffect, useRef, useState } from 'react'
5+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
66
import { useContextMenu } from 'react-contexify'
77
import { toast } from 'sonner'
88

9-
import InfiniteList from 'components/ui/InfiniteList'
9+
import { InfiniteListDefault, LoaderForIconMenuItems } from 'components/ui/InfiniteList'
1010
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
1111
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
1212
import { BASE_PATH } from 'lib/constants'
@@ -146,6 +146,23 @@ export const FileExplorerColumn = ({
146146
/>
147147
)
148148

149+
const getItemKey = useCallback(
150+
(index: number) => {
151+
const item = columnItems[index]
152+
return item?.id || `file-explorer-item-${index}`
153+
},
154+
[columnItems]
155+
)
156+
157+
const itemProps = useMemo(
158+
() => ({
159+
view: snap.view,
160+
columnIndex: index,
161+
selectedItems,
162+
}),
163+
[snap.view, index, selectedItems]
164+
)
165+
149166
return (
150167
<div
151168
ref={fileExplorerColumnRef}
@@ -213,19 +230,20 @@ export const FileExplorerColumn = ({
213230
)}
214231

215232
{/* Column Interface */}
216-
<InfiniteList
217-
items={columnItems}
218-
itemProps={{
219-
view: snap.view,
220-
columnIndex: index,
221-
selectedItems,
222-
}}
223-
ItemComponent={FileExplorerRow}
224-
getItemSize={(index) => (index !== 0 && index === columnItems.length ? 85 : 37)}
225-
hasNextPage={column.status !== STORAGE_ROW_STATUS.LOADING && column.hasMoreItems}
226-
isLoadingNextPage={column.isLoadingMoreItems}
227-
onLoadNextPage={() => onColumnLoadMore(index, column)}
228-
/>
233+
{columnItems.length > 0 && (
234+
<InfiniteListDefault
235+
className="h-full"
236+
items={columnItems}
237+
itemProps={itemProps}
238+
getItemKey={getItemKey}
239+
getItemSize={(index) => (index !== 0 && index === columnItems.length ? 85 : 37)}
240+
ItemComponent={FileExplorerRow}
241+
LoaderComponent={LoaderForIconMenuItems}
242+
hasNextPage={column.status !== STORAGE_ROW_STATUS.LOADING && column.hasMoreItems}
243+
isLoadingNextPage={column.isLoadingMoreItems}
244+
onLoadNextPage={() => onColumnLoadMore(index, column)}
245+
/>
246+
)}
229247

230248
{/* Drag drop upload CTA for when column is empty */}
231249
{!(snap.isSearching && itemSearchString.length > 0) &&

apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerRow.tsx

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,10 @@ import { useContextMenu } from 'react-contexify'
1818
import SVG from 'react-inlinesvg'
1919

2020
import { useParams } from 'common'
21-
import type { ItemRenderer } from 'components/ui/InfiniteList'
2221
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
2322
import { BASE_PATH } from 'lib/constants'
2423
import { formatBytes } from 'lib/helpers'
24+
import type { CSSProperties } from 'react'
2525
import { useStorageExplorerStateSnapshot } from 'state/storage-explorer'
2626
import {
2727
Checkbox,
@@ -46,7 +46,7 @@ import {
4646
STORAGE_VIEWS,
4747
URL_EXPIRY_DURATION,
4848
} from '../Storage.constants'
49-
import { StorageItem, StorageItemWithColumn } from '../Storage.types'
49+
import { StorageItemWithColumn, type StorageItem } from '../Storage.types'
5050
import { FileExplorerRowEditing } from './FileExplorerRowEditing'
5151
import { copyPathToFolder, downloadFile } from './StorageExplorer.utils'
5252
import { useCopyUrl } from './useCopyUrl'
@@ -99,18 +99,22 @@ export const RowIcon = ({
9999
}
100100

101101
interface FileExplorerRowProps {
102+
index: number
103+
item: StorageItem
102104
view: STORAGE_VIEWS
103105
columnIndex: number
104106
selectedItems: StorageItemWithColumn[]
107+
style?: CSSProperties
105108
}
106109

107-
export const FileExplorerRow: ItemRenderer<StorageItem, FileExplorerRowProps> = ({
110+
export const FileExplorerRow = ({
108111
index: itemIndex,
109112
item,
110113
view = STORAGE_VIEWS.COLUMNS,
111114
columnIndex = 0,
112115
selectedItems = [],
113-
}) => {
116+
style,
117+
}: FileExplorerRowProps) => {
114118
const { ref: projectRef, bucketId } = useParams()
115119

116120
const {
@@ -141,7 +145,7 @@ export const FileExplorerRow: ItemRenderer<StorageItem, FileExplorerRowProps> =
141145
const isPreviewed = !isEmpty(selectedFilePreview) && isEqual(selectedFilePreview?.id, item.id)
142146
const { can: canUpdateFiles } = useAsyncCheckPermissions(PermissionAction.STORAGE_WRITE, '*')
143147

144-
const onSelectFile = async (columnIndex: number, file: StorageItem) => {
148+
const onSelectFile = async (columnIndex: number) => {
145149
popColumnAtIndex(columnIndex)
146150
popOpenedFoldersAtIndex(columnIndex - 1)
147151
setSelectedFilePreview(itemWithColumnIndex)
@@ -299,11 +303,14 @@ export const FileExplorerRow: ItemRenderer<StorageItem, FileExplorerRowProps> =
299303
: '100%'
300304

301305
if (item.status === STORAGE_ROW_STATUS.EDITING) {
302-
return <FileExplorerRowEditing view={view} item={item} columnIndex={columnIndex} />
306+
return (
307+
<FileExplorerRowEditing style={style} view={view} item={item} columnIndex={columnIndex} />
308+
)
303309
}
304310

305311
return (
306312
<div
313+
style={style}
307314
className="h-full border-b border-default"
308315
onContextMenu={(event) => {
309316
event.stopPropagation()
@@ -326,7 +333,7 @@ export const FileExplorerRow: ItemRenderer<StorageItem, FileExplorerRowProps> =
326333
if (item.status !== STORAGE_ROW_STATUS.LOADING && !isOpened && !isPreviewed) {
327334
item.type === STORAGE_ROW_TYPES.FOLDER || item.type === STORAGE_ROW_TYPES.BUCKET
328335
? openFolder(columnIndex, item)
329-
: onSelectFile(columnIndex, item)
336+
: onSelectFile(columnIndex)
330337
}
331338
}}
332339
>

apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerRowEditing.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { has } from 'lodash'
2-
import { useEffect, useRef, useState } from 'react'
2+
import { useEffect, useRef, useState, type CSSProperties } from 'react'
33

44
import { useStorageExplorerStateSnapshot } from 'state/storage-explorer'
55
import { STORAGE_ROW_STATUS, STORAGE_ROW_TYPES, STORAGE_VIEWS } from '../Storage.constants'
@@ -10,12 +10,14 @@ export interface FileExplorerRowEditingProps {
1010
item: StorageItem
1111
view: STORAGE_VIEWS
1212
columnIndex: number
13+
style?: CSSProperties
1314
}
1415

1516
export const FileExplorerRowEditing = ({
1617
item,
1718
view,
1819
columnIndex,
20+
style,
1921
}: FileExplorerRowEditingProps) => {
2022
const { renameFile, renameFolder, addNewFolder, updateRowStatus } =
2123
useStorageExplorerStateSnapshot()
@@ -86,7 +88,10 @@ export const FileExplorerRowEditing = ({
8688
}, [])
8789

8890
return (
89-
<div className="storage-row flex items-center justify-between rounded bg-gray-500">
91+
<div
92+
style={style}
93+
className="storage-row flex items-center justify-between rounded bg-gray-500"
94+
>
9095
<div className="flex h-full flex-grow items-center px-2.5">
9196
<div>
9297
<RowIcon

apps/studio/components/interfaces/Storage/StorageMenu.BucketList.tsx

Lines changed: 36 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,97 +1,74 @@
11
import type { CSSProperties } from 'react'
2-
import { memo, useLayoutEffect, useMemo, useRef, useState } from 'react'
3-
import type { ListChildComponentProps } from 'react-window'
4-
import { FixedSizeList as List, areEqual } from 'react-window'
2+
import { memo, useCallback, useMemo } from 'react'
53

4+
import { InfiniteListDefault } from 'components/ui/InfiniteList'
65
import type { Bucket } from 'data/storage/buckets-query'
76
import { cn } from 'ui'
87
import { BucketRow } from './BucketRow'
98

10-
type BucketListProps = {
11-
buckets: Bucket[]
12-
selectedBucketId?: string
9+
type VirtualizedBucketRowProps = {
10+
item: Bucket
1311
projectRef?: string
12+
selectedBucketId?: string
13+
style?: CSSProperties
1414
}
1515

1616
const BUCKET_ROW_HEIGHT = 'h-7'
1717

1818
const VirtualizedBucketRow = memo(
19-
({ index, style, data }: ListChildComponentProps<BucketListProps>) => {
20-
const bucket = data.buckets[index]
21-
const isSelected = data.selectedBucketId === bucket.id
19+
({ item, projectRef, selectedBucketId, style }: VirtualizedBucketRowProps) => {
20+
const isSelected = selectedBucketId === item.id
2221

2322
return (
2423
<BucketRow
25-
bucket={bucket}
24+
bucket={item}
2625
isSelected={isSelected}
27-
projectRef={data.projectRef}
26+
projectRef={projectRef}
2827
style={style as CSSProperties}
2928
className={cn(BUCKET_ROW_HEIGHT)}
3029
/>
3130
)
32-
},
33-
(prev, next) => {
34-
if (!areEqual(prev, next)) return false
35-
36-
const prevBucket = prev.data.buckets[prev.index]
37-
const nextBucket = next.data.buckets[next.index]
38-
39-
if (prevBucket !== nextBucket) return false
40-
41-
const wasSelected = prev.data.selectedBucketId === prevBucket.id
42-
const isSelected = next.data.selectedBucketId === nextBucket.id
43-
44-
return wasSelected === isSelected
4531
}
4632
)
4733
VirtualizedBucketRow.displayName = 'VirtualizedBucketRow'
4834

4935
const BucketListVirtualized = ({ buckets, selectedBucketId, projectRef = '' }: BucketListProps) => {
50-
const [listHeight, setListHeight] = useState(500)
51-
const sizerRef = useRef<HTMLDivElement>(null)
52-
53-
useLayoutEffect(() => {
54-
if (sizerRef.current) {
55-
const resizeObserver = new ResizeObserver(([entry]) => {
56-
const { height } = entry.contentRect
57-
setListHeight(height)
58-
})
59-
60-
resizeObserver.observe(sizerRef.current)
61-
setListHeight(sizerRef.current.getBoundingClientRect().height)
62-
63-
return () => {
64-
resizeObserver.disconnect()
65-
}
66-
}
67-
}, [])
68-
69-
const itemData = useMemo<BucketListProps>(
36+
const itemData = useMemo(
7037
() => ({
71-
buckets,
7238
projectRef,
7339
selectedBucketId,
7440
}),
75-
[buckets, projectRef, selectedBucketId]
41+
[projectRef, selectedBucketId]
42+
)
43+
44+
const getItemKey = useCallback(
45+
(index: number) => {
46+
const item = buckets[index]
47+
return item?.id || `bucket-${index}`
48+
},
49+
[buckets]
7650
)
7751

7852
return (
79-
<div ref={sizerRef} className="flex-grow">
80-
<List
81-
itemCount={buckets.length}
82-
itemData={itemData}
83-
itemKey={(index) => buckets[index].id}
84-
height={listHeight}
85-
// itemSize should match the height of BucketRow + any gap/margin
86-
itemSize={28}
87-
width="100%"
88-
>
89-
{VirtualizedBucketRow}
90-
</List>
91-
</div>
53+
<InfiniteListDefault
54+
items={buckets}
55+
itemProps={itemData}
56+
getItemKey={getItemKey}
57+
// Keep in tandem with BUCKET_ROW_HEIGHT
58+
getItemSize={() => 28}
59+
ItemComponent={VirtualizedBucketRow}
60+
// There is no loader because all buckets load from backend at once
61+
LoaderComponent={() => null}
62+
/>
9263
)
9364
}
9465

66+
type BucketListProps = {
67+
buckets: Bucket[]
68+
selectedBucketId?: string
69+
projectRef?: string
70+
}
71+
9572
export const BucketList = ({ buckets, selectedBucketId, projectRef = '' }: BucketListProps) => {
9673
const numBuckets = buckets.length
9774

apps/studio/components/interfaces/Storage/StorageMenu.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,11 @@ export const StorageMenu = () => {
7474

7575
return (
7676
<>
77-
<Menu type="pills" className="h-full flex flex-col" ulClassName="flex flex-col flex-grow">
77+
<Menu
78+
type="pills"
79+
className="h-full flex flex-col"
80+
ulClassName="flex flex-col flex-grow min-h-0"
81+
>
7882
<div className="flex flex-col gap-y-1.5 px-5 pb-6 pt-6 sticky top-0 bg-sidebar">
7983
<CreateBucketModal />
8084

@@ -107,12 +111,12 @@ export const StorageMenu = () => {
107111
</InnerSideBarFilters>
108112
</div>
109113

110-
<div className={cn('flex flex-col', isVirtualized && 'flex-grow')}>
114+
<div className={cn('flex flex-col', isVirtualized && 'flex-grow min-h-0')}>
111115
<div
112116
className={cn(
113117
'flex flex-col mx-3',
114118
isVirtualized
115-
? 'flex-grow'
119+
? 'flex-grow min-h-0'
116120
: isSuccess && filteredBuckets.length > 0
117121
? 'mb-3'
118122
: 'mb-5'

0 commit comments

Comments
 (0)