Skip to content

Commit 21d27b9

Browse files
authored
perf(storage menu): improve bucket list for users with many thousand buckets (supabase#39826)
* perf(storage menu): virtualize and memoize bucket rows The Storage Menu fails to render properly when the user has thousands of buckets. Virtualizing and memoizing BucketRows to reduce rendering work. * refactor(bucket list): only virtualize bucket list when more than 50 buckets * style(storage menu): improve styling for virtualized and unvirtualized lists
1 parent ebf9db5 commit 21d27b9

File tree

3 files changed

+165
-28
lines changed

3 files changed

+165
-28
lines changed

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

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { PermissionAction } from '@supabase/shared-types/out/constants'
22
import { Columns3, Edit2, MoreVertical, Trash, XCircle } from 'lucide-react'
33
import Link from 'next/link'
4+
import type { CSSProperties } from 'react'
45
import { useState } from 'react'
56

67
import { DeleteBucketModal } from 'components/interfaces/Storage/DeleteBucketModal'
@@ -26,19 +27,29 @@ export interface BucketRowProps {
2627
bucket: Bucket
2728
projectRef?: string
2829
isSelected: boolean
30+
style?: CSSProperties
31+
className?: string
2932
}
3033

31-
export const BucketRow = ({ bucket, projectRef = '', isSelected = false }: BucketRowProps) => {
34+
export const BucketRow = ({
35+
bucket,
36+
projectRef = '',
37+
isSelected = false,
38+
style,
39+
className,
40+
}: BucketRowProps) => {
3241
const { can: canUpdateBuckets } = useAsyncCheckPermissions(PermissionAction.STORAGE_WRITE, '*')
3342
const [modal, setModal] = useState<string | null>(null)
3443
const onClose = () => setModal(null)
3544

3645
return (
3746
<div
3847
key={bucket.id}
48+
style={style}
3949
className={cn(
4050
'group flex items-center justify-between rounded-md',
41-
isSelected && 'text-foreground bg-surface-100'
51+
isSelected && 'text-foreground bg-surface-100',
52+
className
4253
)}
4354
>
4455
{/* Even though we trim whitespaces from bucket names, there may be some existing buckets with trailing whitespaces. */}
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
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'
5+
6+
import type { Bucket } from 'data/storage/buckets-query'
7+
import { BucketRow } from './BucketRow'
8+
9+
type BucketListProps = {
10+
buckets: Bucket[]
11+
selectedBucketId?: string
12+
projectRef?: string
13+
}
14+
15+
const BUCKET_ROW_HEIGHT = 'h-7'
16+
17+
const VirtualizedBucketRow = memo(
18+
({ index, style, data }: ListChildComponentProps<BucketListProps>) => {
19+
const bucket = data.buckets[index]
20+
const isSelected = data.selectedBucketId === bucket.id
21+
22+
return (
23+
<BucketRow
24+
bucket={bucket}
25+
isSelected={isSelected}
26+
projectRef={data.projectRef}
27+
style={style as CSSProperties}
28+
className={BUCKET_ROW_HEIGHT}
29+
/>
30+
)
31+
},
32+
(prev, next) => {
33+
if (!areEqual(prev, next)) return false
34+
35+
const prevBucket = prev.data.buckets[prev.index]
36+
const nextBucket = next.data.buckets[next.index]
37+
38+
if (prevBucket !== nextBucket) return false
39+
40+
const wasSelected = prev.data.selectedBucketId === prevBucket.id
41+
const isSelected = next.data.selectedBucketId === nextBucket.id
42+
43+
return wasSelected === isSelected
44+
}
45+
)
46+
VirtualizedBucketRow.displayName = 'VirtualizedBucketRow'
47+
48+
const BucketListVirtualized = ({ buckets, selectedBucketId, projectRef = '' }: BucketListProps) => {
49+
const [listHeight, setListHeight] = useState(500)
50+
const sizerRef = useRef<HTMLDivElement>(null)
51+
52+
useLayoutEffect(() => {
53+
if (sizerRef.current) {
54+
const resizeObserver = new ResizeObserver(([entry]) => {
55+
const { height } = entry.contentRect
56+
setListHeight(height)
57+
})
58+
59+
resizeObserver.observe(sizerRef.current)
60+
setListHeight(sizerRef.current.getBoundingClientRect().height)
61+
62+
return () => {
63+
resizeObserver.disconnect()
64+
}
65+
}
66+
}, [])
67+
68+
const itemData = useMemo<BucketListProps>(
69+
() => ({
70+
buckets,
71+
projectRef,
72+
selectedBucketId,
73+
}),
74+
[buckets, projectRef, selectedBucketId]
75+
)
76+
77+
return (
78+
<div ref={sizerRef} className="flex-grow">
79+
<List
80+
itemCount={buckets.length}
81+
itemData={itemData}
82+
itemKey={(index) => buckets[index].id}
83+
height={listHeight}
84+
// itemSize should match the height of BucketRow + any gap/margin
85+
itemSize={28}
86+
width="100%"
87+
>
88+
{VirtualizedBucketRow}
89+
</List>
90+
</div>
91+
)
92+
}
93+
94+
export const BucketList = ({ buckets, selectedBucketId, projectRef = '' }: BucketListProps) => {
95+
const numBuckets = buckets.length
96+
97+
if (numBuckets <= 50) {
98+
return (
99+
<div className="mr-3 mb-6">
100+
{buckets.map((bucket) => (
101+
<BucketRow
102+
key={bucket.id}
103+
bucket={bucket}
104+
isSelected={selectedBucketId === bucket.id}
105+
projectRef={projectRef}
106+
className={BUCKET_ROW_HEIGHT}
107+
/>
108+
))}
109+
</div>
110+
)
111+
}
112+
113+
return (
114+
<BucketListVirtualized
115+
buckets={buckets}
116+
selectedBucketId={selectedBucketId}
117+
projectRef={projectRef}
118+
/>
119+
)
120+
}

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

Lines changed: 32 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import Link from 'next/link'
22
import { useRouter } from 'next/router'
3-
import { useState } from 'react'
3+
import { useMemo, useState } from 'react'
44

55
import { useFlag, useParams } from 'common'
66
import { CreateBucketModal } from 'components/interfaces/Storage/CreateBucketModal'
@@ -19,7 +19,7 @@ import {
1919
InnerSideBarFilterSortDropdown,
2020
InnerSideBarFilterSortDropdownItem,
2121
} from 'ui-patterns/InnerSideMenu'
22-
import { BucketRow } from './BucketRow'
22+
import { BucketList } from './StorageMenu.BucketList'
2323

2424
export const StorageMenu = () => {
2525
const router = useRouter()
@@ -53,21 +53,31 @@ export const StorageMenu = () => {
5353
isError,
5454
isSuccess,
5555
} = useBucketsQuery({ projectRef: ref })
56-
const sortedBuckets =
57-
snap.sortBucket === 'alphabetical'
58-
? buckets.sort((a, b) =>
59-
a.name.toLowerCase().trim().localeCompare(b.name.toLowerCase().trim())
60-
)
61-
: buckets.sort((a, b) => (new Date(b.created_at) > new Date(a.created_at) ? -1 : 1))
62-
const filteredBuckets =
63-
searchText.length > 1
64-
? sortedBuckets.filter((bucket) => bucket.name.includes(searchText.trim()))
65-
: sortedBuckets
56+
const sortedBuckets = useMemo(
57+
() =>
58+
snap.sortBucket === 'alphabetical'
59+
? buckets.sort((a, b) =>
60+
a.name.toLowerCase().trim().localeCompare(b.name.toLowerCase().trim())
61+
)
62+
: buckets.sort((a, b) => (new Date(b.created_at) > new Date(a.created_at) ? -1 : 1)),
63+
[buckets, snap.sortBucket]
64+
)
65+
const filteredBuckets = useMemo(
66+
() =>
67+
searchText.length > 1
68+
? sortedBuckets.filter((bucket) => bucket.name.includes(searchText.trim()))
69+
: sortedBuckets,
70+
[sortedBuckets, searchText]
71+
)
6672
const tempNotSupported = error?.message.includes('Tenant config') && isBranch
6773

6874
return (
6975
<>
70-
<Menu type="pills" className="mt-6 flex flex-grow flex-col">
76+
<Menu
77+
type="pills"
78+
className="pt-6 h-full flex flex-col"
79+
ulClassName="flex flex-col flex-grow"
80+
>
7181
<div className="mb-6 mx-5 flex flex-col gap-y-1.5">
7282
<CreateBucketModal />
7383

@@ -100,8 +110,8 @@ export const StorageMenu = () => {
100110
</InnerSideBarFilters>
101111
</div>
102112

103-
<div className="space-y-6">
104-
<div className="mx-3">
113+
<div className="flex flex-col flex-grow">
114+
<div className="flex-grow ml-3 flex flex-col">
105115
<Menu.Group title={<span className="uppercase font-mono">All buckets</span>} />
106116

107117
{isLoading && (
@@ -145,17 +155,13 @@ export const StorageMenu = () => {
145155
description={`Your search for "${searchText}" did not return any results`}
146156
/>
147157
)}
148-
{filteredBuckets.map((bucket, idx: number) => {
149-
const isSelected = bucketId === bucket.id
150-
return (
151-
<BucketRow
152-
key={`${idx}_${bucket.id}`}
153-
bucket={bucket}
154-
projectRef={ref}
155-
isSelected={isSelected}
156-
/>
157-
)
158-
})}
158+
{filteredBuckets.length > 0 && (
159+
<BucketList
160+
buckets={filteredBuckets}
161+
selectedBucketId={bucketId}
162+
projectRef={ref}
163+
/>
164+
)}
159165
</>
160166
)}
161167
</div>

0 commit comments

Comments
 (0)