From 4ae803c3d7ba50e42dd5295c43bada6f77bf3942 Mon Sep 17 00:00:00 2001 From: Charis <26616127+charislam@users.noreply.github.com> Date: Tue, 2 Jun 2026 16:41:52 -0400 Subject: [PATCH] feat(studio): paginate Tables list with server-side search (#46514) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 ## 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 --- .../interfaces/Database/Tables/TableList.tsx | 61 ++++++++++++------- .../SidePanelEditor/SidePanelEditor.tsx | 6 ++ e2e/studio/features/database.spec.ts | 9 ++- e2e/studio/utils/wait-for-response.ts | 2 +- 4 files changed, 51 insertions(+), 27 deletions(-) diff --git a/apps/studio/components/interfaces/Database/Tables/TableList.tsx b/apps/studio/components/interfaces/Database/Tables/TableList.tsx index 45b38e49c10..80a4b5317e7 100644 --- a/apps/studio/components/interfaces/Database/Tables/TableList.tsx +++ b/apps/studio/components/interfaces/Database/Tables/TableList.tsx @@ -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(Object.values(ENTITY_TYPE)) const [schemaSelectorOpen, setSchemaSelectorOpen] = useState(false) const searchInputRef = useRef(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 = ({ - - - {entities.length} {entities.length === 1 ? 'table' : 'tables'} + + + {isFetchingNextTablesPage + ? 'Loading more tables…' + : `${entities.length} ${entities.length === 1 ? 'table' : 'tables'}${ + hasNextTablesPage ? ' loaded' : '' + }`} diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx index 481f4a511b3..f8f08a40881 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx @@ -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), diff --git a/e2e/studio/features/database.spec.ts b/e2e/studio/features/database.spec.ts index 9b48dd84d4e..e0e756fd384 100644 --- a/e2e/studio/features/database.spec.ts +++ b/e2e/studio/features/database.spec.ts @@ -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`)) diff --git a/e2e/studio/utils/wait-for-response.ts b/e2e/studio/utils/wait-for-response.ts index c812aa26c9e..6bf30256934 100644 --- a/e2e/studio/utils/wait-for-response.ts +++ b/e2e/studio/utils/wait-for-response.ts @@ -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` ) }