feat(studio): paginate Tables list with server-side search (#46514)

## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.

YES

## What kind of change does this PR introduce?

Feature / performance improvement.

## What is the current behavior?

The Database > Tables list renders every table for the selected schema
in a single `useTablesQuery({ includeColumns: true })` fetch.

## What is the new behavior?

- Database > Tables list uses `useInfiniteTablesQuery` with a 50-row
page size, streaming pages as the user scrolls.
- An `IntersectionObserver` sentinel attached to the footer status row
triggers `fetchNextPage()` while `hasNextPage` is true; the same row
doubles as the table count / loading indicator.
- Search is debounced (300 ms) and passed as `nameFilter` to the hook,
which forwards it to pg-meta's `getTablesPaginatedSql`. The backend
returns only matching rows, so search works across the whole schema
rather than only the loaded pages.

## Additional context

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
  * Debounced search filtering for tables
  * Infinite-scroll pagination with sentinel-driven loading
  * Footer shows “Loading more tables…” and dynamic table counts

* **Bug Fixes**
* Ensure table list refresh after create/duplicate by invalidating
infinite-list cache

* **Tests**
* Updated end-to-end waits and helpers to match the new paginated
table-loading API
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Charis authored and GitHub committed 2026-06-02 16:41:52 -04:00
1 parent 0da841321f
commit 4ae803c3d7
4 files changed
+51 -27

No files matched your search

@@ -1,11 +1,12 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useDebounce, useIntersectionObserver } from '@uidotdev/usehooks'
import { useParams } from 'common'
import { noop } from 'lodash'
import { Check, Copy, Edit, Eye, Filter, MoreVertical, Plus, Search, Trash, X } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { parseAsString, useQueryState } from 'nuqs'
import { useRef, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import {
Button,
Card,
@@ -47,7 +48,7 @@ import { ENTITY_TYPE } from '@/data/entity-types/entity-type-constants'
import { useForeignTablesQuery } from '@/data/foreign-tables/foreign-tables-query'
import { useMaterializedViewsQuery } from '@/data/materialized-views/materialized-views-query'
import { usePrefetchEditorTablePage } from '@/data/prefetchers/project.$ref.editor.$id'
import { useTablesQuery } from '@/data/tables/tables-query'
import { useInfiniteTablesQuery } from '@/data/tables/tables-query'
import { useViewsQuery } from '@/data/views/views-query'
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
import { useQuerySchemaState } from '@/hooks/misc/useSchemaQueryState'
@@ -80,6 +81,7 @@ export const TableList = ({
const { selectedSchema, setSelectedSchema } = useQuerySchemaState()
const [filterString, setFilterString] = useQueryState('search', parseAsString.withDefault(''))
const debouncedFilterString = useDebounce(filterString, 300)
const [visibleTypes, setVisibleTypes] = useState<string[]>(Object.values(ENTITY_TYPE))
const [schemaSelectorOpen, setSchemaSelectorOpen] = useState(false)
const searchInputRef = useRef<HTMLInputElement>(null)
@@ -90,27 +92,40 @@ export const TableList = ({
)
const {
data: tables,
data: tablesData,
error: tablesError,
isError: isErrorTables,
isPending: isLoadingTables,
isSuccess: isSuccessTables,
} = useTablesQuery(
{
projectRef: project?.ref,
connectionString: project?.connectionString,
schema: selectedSchema,
sortByProperty: 'name',
includeColumns: true,
},
{
select(tables) {
return filterString.length === 0
? tables
: tables.filter((table) => table.name.toLowerCase().includes(filterString.toLowerCase()))
},
hasNextPage: hasNextTablesPage,
isFetchingNextPage: isFetchingNextTablesPage,
fetchNextPage: fetchNextTablesPage,
} = useInfiniteTablesQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
schema: selectedSchema,
includeColumns: true,
pageSize: 50,
nameFilter: debouncedFilterString,
})
const tables = tablesData?.pages.flat() ?? []
const [sentinelRef, sentinelEntry] = useIntersectionObserver({
threshold: 0,
rootMargin: '200px 0px 200px 0px',
})
useEffect(() => {
if (sentinelEntry?.isIntersecting && hasNextTablesPage && !isFetchingNextTablesPage) {
fetchNextTablesPage()
}
)
}, [
sentinelEntry?.isIntersecting,
hasNextTablesPage,
isFetchingNextTablesPage,
fetchNextTablesPage,
])
const {
data: views,
@@ -589,9 +604,13 @@ export const TableList = ({
</>
</TableBody>
<TableFooter className="font-normal">
<TableRow className="border-b-0 [&>td]:hover:bg-inherit">
<TableCell colSpan={7} className="text-foreground-muted">
{entities.length} {entities.length === 1 ? 'table' : 'tables'}
<TableRow ref={sentinelRef} className="border-b-0">
<TableCell colSpan={7} className="text-foreground-muted hover:bg-inherit">
{isFetchingNextTablesPage
? 'Loading more tables…'
: `${entities.length} ${entities.length === 1 ? 'table' : 'tables'}${
hasNextTablesPage ? ' loaded' : ''
}`}
</TableCell>
</TableRow>
</TableFooter>
@@ -715,6 +715,9 @@ export const SidePanelEditor = ({
queryClient.invalidateQueries({
queryKey: tableKeys.list(project?.ref, table.schema, includeColumns),
}),
queryClient.invalidateQueries({
queryKey: tableKeys.infiniteListPrefix(project?.ref, table.schema),
}),
queryClient.invalidateQueries({
queryKey: entityTypeKeys.list(project?.ref),
}),
@@ -782,6 +785,9 @@ export const SidePanelEditor = ({
queryClient.invalidateQueries({
queryKey: tableKeys.list(project?.ref, table.schema, includeColumns),
}),
queryClient.invalidateQueries({
queryKey: tableKeys.infiniteListPrefix(project?.ref, table.schema),
}),
queryClient.invalidateQueries({ queryKey: entityTypeKeys.list(project?.ref) }),
queryClient.invalidateQueries({
queryKey: privilegeKeys.tablePrivilegesList(project?.ref),
+4 -5
View File
@@ -232,7 +232,7 @@ test.describe('Database', () => {
page,
'pg-meta',
ref,
'tables?include_columns=true&included_schemas=public'
'query?key=project:default-schema:public-infinite_tables'
)
await page.goto(toUrl(`/project/${env.PROJECT_REF}/database/tables?schema=public`))
await wait
@@ -256,7 +256,7 @@ test.describe('Database', () => {
page,
'pg-meta',
ref,
'tables?include_columns=true&included_schemas=auth'
'query?key=project:default-schema:auth-infinite_tables'
)
await page.getByRole('option', { name: 'auth' }).click()
await authSchemaWait
@@ -293,7 +293,7 @@ test.describe('Database', () => {
page,
'pg-meta',
ref,
'tables?include_columns=true&included_schemas=public'
'query?key=project:default-schema:public-infinite_tables'
)
await page.goto(toUrl(`/project/${env.PROJECT_REF}/database/tables?schema=public`))
// Wait for database tables to be populated
@@ -313,7 +313,6 @@ test.describe('Database', () => {
// validate table creation
await createTableWait
await waitForDatabaseToLoad(page, ref)
await expect(page.getByRole('dialog')).not.toBeVisible()
await expect(page.getByText(databaseTableNameNew, { exact: true })).toBeVisible()
@@ -435,7 +434,7 @@ test.describe('Database', () => {
page,
'pg-meta',
ref,
'tables?include_columns=true&included_schemas=public'
'query?key=project:default-schema:public-infinite_tables'
)
await page.goto(toUrl(`/project/${env.PROJECT_REF}/database/tables?schema=public`))
+1 -1
View File
@@ -109,7 +109,7 @@ export async function waitForDatabaseToLoad(page: Page, ref: string, schema?: st
page,
'pg-meta',
ref,
`tables?include_columns=true&included_schemas=${databaseSchema}`
`query?key=project:default-schema:${databaseSchema}-infinite_tables`
)
}