From 8956ef5409e2aa505821c2b5f9d3851d6d96c762 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 21 May 2026 02:01:15 -0700 Subject: [PATCH] feat(tables): virtualize data grid with bounded copy and chunked delete --- .../components/table-grid/table-grid.tsx | 511 ++++++++++-------- .../tables/[tableId]/hooks/use-table.ts | 46 ++ apps/sim/hooks/queries/tables.ts | 24 +- apps/sim/lib/table/constants.ts | 2 + apps/sim/package.json | 1 + bun.lock | 5 + 6 files changed, 359 insertions(+), 230 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx index 698c6b31e4d..ea5f3542960 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx @@ -1,8 +1,9 @@ 'use client' import type React from 'react' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { createLogger } from '@sim/logger' +import { useVirtualizer } from '@tanstack/react-virtual' import { useParams } from 'next/navigation' import { usePostHog } from 'posthog-js/react' import { Skeleton, toast } from '@/components/emcn' @@ -194,6 +195,12 @@ interface TableGridProps { > } +/** Serialize a cell value to its tab-separated clipboard representation. */ +function cellToText(value: unknown): string { + if (value === null || value === undefined) return '' + return typeof value === 'object' ? JSON.stringify(value) : String(value) +} + /** * Split updates into chunks bounded by the server batch-size limit, dispatching * up to 3 chunks concurrently. Throws on first failure — `Promise.all` rejects @@ -283,6 +290,8 @@ export function TableGrid({ const metadataSeededRef = useRef(false) const containerRef = useRef(null) const scrollRef = useRef(null) + const theadRef = useRef(null) + const tbodyRef = useRef(null) const isDraggingRef = useRef(false) const suppressFocusScrollRef = useRef(false) @@ -300,6 +309,7 @@ export function TableGrid({ workflowStates, columnSourceInfo, ensureAllRowsLoaded, + ensureRowsLoadedUpTo, } = useTable({ workspaceId, tableId, queryOptions }) const { data: tableRunState } = useTableRunState(tableId) @@ -315,8 +325,52 @@ export function TableGrid({ isFetchingNextPageRef.current = isFetchingNextPage const ensureAllRowsLoadedRef = useRef(ensureAllRowsLoaded) ensureAllRowsLoadedRef.current = ensureAllRowsLoaded + const ensureRowsLoadedUpToRef = useRef(ensureRowsLoadedUpTo) + ensureRowsLoadedUpToRef.current = ensureRowsLoadedUpTo const isAppendingRowRef = useRef(false) + /** + * Row windowing. The native `` is preserved; only the visible slice + * (+ overscan) of ``s is rendered, with spacer rows sizing the off-screen + * remainder. `scrollMargin` accounts for the sticky `` that sits above + * the rows inside the same scroll container. Rows are fixed-height by design + * (see `CELL_CONTENT`), so a measured constant gives drift-free scrolling + * without per-row measurement. + */ + const [headerHeight, setHeaderHeight] = useState(0) + const [rowHeight, setRowHeight] = useState(ROW_HEIGHT_ESTIMATE) + + const rowVirtualizer = useVirtualizer({ + count: rows.length, + getScrollElement: () => scrollRef.current, + estimateSize: () => rowHeight, + overscan: 12, + scrollMargin: headerHeight, + getItemKey: (index) => rows[index]?.id ?? index, + }) + + useEffect(() => { + rowVirtualizer.measure() + }, [rowHeight, rowVirtualizer]) + + useLayoutEffect(() => { + const el = theadRef.current + if (!el) return + const measure = () => setHeaderHeight(el.offsetHeight) + measure() + const observer = new ResizeObserver(measure) + observer.observe(el) + return () => observer.disconnect() + }, []) + + useLayoutEffect(() => { + if (isLoadingTable || isLoadingRows) return + const cell = tbodyRef.current?.querySelector('td[data-row]') + if (!cell) return + const measured = cell.getBoundingClientRect().height + if (measured > 0 && Math.abs(measured - rowHeight) >= 0.5) setRowHeight(measured) + }, [isLoadingTable, isLoadingRows, rowHeight]) + const userPermissions = useUserPermissionsContext() const canEditRef = useRef(userPermissions.canEdit) canEditRef.current = userPermissions.canEdit @@ -600,13 +654,27 @@ export function TableGrid({ const rowSel = rowSelectionRef.current const currentRows = rowsRef.current - let snapshots: DeletedRowSnapshot[] = [] const contextRowInRows = currentRows.some((r) => r.id === contextRow.id) + // Select-all delete covers every row matching the active filter, which may + // not all be loaded — drain pages first so the (chunked) delete spans the + // full set rather than only the loaded window. if (rowSel.kind === 'all' && contextRowInRows) { - snapshots = collectRowSnapshots(currentRows) - } else if (rowSel.kind === 'some' && rowSel.ids.has(contextRow.id)) { + closeContextMenu() + void (async () => { + const allRows = await ensureAllRowsLoadedRef.current() + const snapshots = collectRowSnapshots(allRows) + if (snapshots.length > 0) onRequestDeleteRows(snapshots) + })().catch((error) => { + logger.error('Failed to load rows for delete', { error }) + toast.error('Failed to delete rows — please try again') + }) + return + } + + let snapshots: DeletedRowSnapshot[] = [] + if (rowSel.kind === 'some' && rowSel.ids.has(contextRow.id)) { snapshots = collectRowSnapshots(currentRows.filter((r) => rowSel.ids.has(r.id))) } else { const sel = computeNormalizedSelection(selectionAnchorRef.current, selectionFocusRef.current) @@ -1414,14 +1482,27 @@ export function TableGrid({ const target = selectionFocus ?? selectionAnchor if (!target) return const { rowIndex, colIndex } = target + const selector = `[data-table-scroll] [data-row="${rowIndex}"][data-col="${colIndex}"]` + let secondRaf = 0 const rafId = requestAnimationFrame(() => { - const cell = document.querySelector( - `[data-table-scroll] [data-row="${rowIndex}"][data-col="${colIndex}"]` - ) as HTMLElement | null - cell?.scrollIntoView({ block: 'nearest', inline: 'nearest' }) + const cell = document.querySelector(selector) as HTMLElement | null + if (cell) { + cell.scrollIntoView({ block: 'nearest', inline: 'nearest' }) + return + } + // Target row is windowed out (large jump / PageUp-Down). Bring it into the + // virtualized range first, then align horizontally once it has rendered. + rowVirtualizer.scrollToIndex(rowIndex, { align: 'auto' }) + secondRaf = requestAnimationFrame(() => { + const rendered = document.querySelector(selector) as HTMLElement | null + rendered?.scrollIntoView({ block: 'nearest', inline: 'nearest' }) + }) }) - return () => cancelAnimationFrame(rafId) - }, [selectionAnchor, selectionFocus, isColumnSelection]) + return () => { + cancelAnimationFrame(rafId) + if (secondRaf) cancelAnimationFrame(secondRaf) + } + }, [selectionAnchor, selectionFocus, isColumnSelection, rowVirtualizer]) const handleCellClick = useCallback( (rowId: string, columnName: string, options?: { toggleBoolean?: boolean }) => { @@ -1878,6 +1959,84 @@ export function TableGrid({ } } + /** + * Copies/cuts a selection that may span every row by paging through the + * table (capped at {@link TABLE_LIMITS.MAX_COPY_ROWS}). The promise-based + * `ClipboardItem` is what makes this safe: `.write()` is invoked + * synchronously within the copy/cut gesture so its transient activation + * survives the async page load — a plain `await writeText(...)` after paging + * loses the gesture and is rejected. Past the cap, copies the first + * `MAX_COPY_ROWS` and points the user at Export CSV. + */ + const writeSelectionToClipboard = (opts: { + loadRows: () => Promise<{ rows: TableRowType[]; hasMore: boolean }> + selectRow: (row: TableRowType) => boolean + buildCells: (row: TableRowType) => string[] + verb: 'Copied' | 'Cut' + afterCopy?: (copiedRows: TableRowType[]) => Promise | void + }) => { + if (typeof ClipboardItem === 'undefined' || !navigator.clipboard) { + toast.error('Clipboard access is unavailable in this context') + return + } + toast({ message: `${opts.verb === 'Copied' ? 'Copying' : 'Cutting'}… loading rows` }) + let rowCount = 0 + let truncated = false + const copiedRows: TableRowType[] = [] + const blob = (async () => { + const { rows: loaded, hasMore } = await opts.loadRows() + const lines: string[] = [] + for (const row of loaded) { + if (!opts.selectRow(row)) continue + if (lines.length >= TABLE_LIMITS.MAX_COPY_ROWS) { + truncated = true + break + } + lines.push(opts.buildCells(row).join('\t')) + copiedRows.push(row) + } + truncated = truncated || hasMore + rowCount = lines.length + return new Blob([lines.join('\n')], { type: 'text/plain' }) + })() + void navigator.clipboard + .write([new ClipboardItem({ 'text/plain': blob })]) + .then(async () => { + await opts.afterCopy?.(copiedRows) + if (truncated) { + toast({ + message: `${opts.verb} first ${TABLE_LIMITS.MAX_COPY_ROWS.toLocaleString()} rows — export to CSV for the rest`, + }) + } else { + toast.success( + `${opts.verb} ${rowCount.toLocaleString()} ${rowCount === 1 ? 'row' : 'rows'}` + ) + } + }) + .catch((error) => { + logger.error(`Failed to ${opts.verb === 'Copied' ? 'copy' : 'cut'} rows`, { error }) + toast.error('Selection too large to copy — use Export CSV') + }) + } + + /** Clears `colNames` on `rowsToClear` (the cut tail) and records an undo entry. */ + const clearCutRows = async (rowsToClear: TableRowType[], colNames: string[]) => { + const undo: Array<{ rowId: string; data: Record }> = [] + const updates: Array<{ rowId: string; data: Record }> = [] + for (const row of rowsToClear) { + const previousData: Record = {} + const nextData: Record = {} + for (const name of colNames) { + previousData[name] = row.data[name] ?? null + nextData[name] = null + } + undo.push({ rowId: row.id, data: previousData }) + updates.push({ rowId: row.id, data: nextData }) + } + if (undo.length > 0) pushUndoRef.current({ type: 'clear-cells', cells: undo }) + if (updates.length > 0) await chunkBatchUpdates(updates, batchUpdateAsyncRef.current) + } + const handleCopy = (e: ClipboardEvent) => { const tag = (e.target as HTMLElement).tagName if (tag === 'INPUT' || tag === 'TEXTAREA') return @@ -1889,36 +2048,14 @@ export function TableGrid({ if (!rowSelectionIsEmpty(rowSel)) { e.preventDefault() - void (async () => { - const allRows = await ensureAllRowsLoadedRef.current() - const lines: string[] = [] - for (const row of allRows) { - if (!rowSelectionIncludes(rowSel, row.id)) continue - const cells: string[] = cols.map((col) => { - const value: unknown = row.data[col.name] - if (value === null || value === undefined) return '' - return typeof value === 'object' ? JSON.stringify(value) : String(value) - }) - lines.push(cells.join('\t')) - } - if (!navigator.clipboard) { - toast.error('Clipboard access is unavailable in this context') - return - } - try { - await navigator.clipboard.writeText(lines.join('\n')) - } catch (err) { - if (err instanceof DOMException && err.name === 'NotAllowedError') { - toast.error( - 'Clipboard permission expired — press Cmd+C again immediately after selecting' - ) - } else { - throw err - } - } - })().catch((error) => { - logger.error('Failed to copy selected rows', { error }) - toast.error('Failed to copy — please try again') + writeSelectionToClipboard({ + loadRows: + rowSel.kind === 'all' + ? () => ensureRowsLoadedUpToRef.current(TABLE_LIMITS.MAX_COPY_ROWS) + : async () => ({ rows: rowsRef.current, hasMore: false }), + selectRow: (row) => rowSelectionIncludes(rowSel, row.id), + buildCells: (row) => cols.map((col) => cellToText(row.data[col.name])), + verb: 'Copied', }) return } @@ -1932,45 +2069,19 @@ export function TableGrid({ e.preventDefault() if (isColumnSelectionRef.current) { - // Column-header copy spans all rows — drain pages first, then use async - // clipboard so we don't block the event before the drain completes. - void (async () => { - const allRows = await ensureAllRowsLoadedRef.current() - const lines: string[] = [] - for (const row of allRows) { + writeSelectionToClipboard({ + loadRows: () => ensureRowsLoadedUpToRef.current(TABLE_LIMITS.MAX_COPY_ROWS), + selectRow: () => true, + buildCells: (row) => { const cells: string[] = [] for (let c = sel.startCol; c <= sel.endCol; c++) { const colName = cols[c]?.name if (!colName) continue - const value: unknown = row.data[colName] - cells.push( - value === null || value === undefined - ? '' - : typeof value === 'object' - ? JSON.stringify(value) - : String(value) - ) - } - lines.push(cells.join('\t')) - } - if (!navigator.clipboard) { - toast.error('Clipboard access is unavailable in this context') - return - } - try { - await navigator.clipboard.writeText(lines.join('\n')) - } catch (err) { - if (err instanceof DOMException && err.name === 'NotAllowedError') { - toast.error( - 'Clipboard permission expired — press Cmd+C again immediately after selecting' - ) - } else { - throw err + cells.push(cellToText(row.data[colName])) } - } - })().catch((error) => { - logger.error('Failed to copy column cells', { error }) - toast.error('Failed to copy — please try again') + return cells + }, + verb: 'Copied', }) return } @@ -1981,12 +2092,7 @@ export function TableGrid({ for (let c = sel.startCol; c <= sel.endCol; c++) { if (c >= cols.length) break const row = currentRows[r] - const value: unknown = row ? row.data[cols[c].name] : null - if (value === null || value === undefined) { - cells.push('') - } else { - cells.push(typeof value === 'object' ? JSON.stringify(value) : String(value)) - } + cells.push(row ? cellToText(row.data[cols[c].name]) : '') } lines.push(cells.join('\t')) } @@ -2005,52 +2111,19 @@ export function TableGrid({ if (!rowSelectionIsEmpty(rowSel)) { e.preventDefault() - void (async () => { - const allRows = await ensureAllRowsLoadedRef.current() - const lines: string[] = [] - const cutUpdates: Array<{ rowId: string; data: Record }> = [] - const cutUndo: Array<{ rowId: string; data: Record }> = [] - for (const row of allRows) { - if (!rowSelectionIncludes(rowSel, row.id)) continue - const cells: string[] = cols.map((col) => { - const value: unknown = row.data[col.name] - if (value === null || value === undefined) return '' - return typeof value === 'object' ? JSON.stringify(value) : String(value) - }) - lines.push(cells.join('\t')) - const updates: Record = {} - const previousData: Record = {} - for (const col of cols) { - previousData[col.name] = row.data[col.name] ?? null - updates[col.name] = null - } - cutUndo.push({ rowId: row.id, data: previousData }) - cutUpdates.push({ rowId: row.id, data: updates }) - } - if (!navigator.clipboard) { - toast.error('Clipboard access is unavailable in this context') - return - } - try { - await navigator.clipboard.writeText(lines.join('\n')) - } catch (err) { - if (err instanceof DOMException && err.name === 'NotAllowedError') { - toast.error( - 'Clipboard permission expired — press Cmd+X again immediately after selecting' - ) - return - } - throw err - } - if (cutUndo.length > 0) { - pushUndoRef.current({ type: 'clear-cells', cells: cutUndo }) - } - if (cutUpdates.length > 0) { - await chunkBatchUpdates(cutUpdates, batchUpdateAsyncRef.current) - } - })().catch((error) => { - logger.error('Failed to cut selected rows', { error }) - toast.error('Failed to cut — please try again') + writeSelectionToClipboard({ + loadRows: + rowSel.kind === 'all' + ? () => ensureRowsLoadedUpToRef.current(TABLE_LIMITS.MAX_COPY_ROWS) + : async () => ({ rows: rowsRef.current, hasMore: false }), + selectRow: (row) => rowSelectionIncludes(rowSel, row.id), + buildCells: (row) => cols.map((col) => cellToText(row.data[col.name])), + verb: 'Cut', + afterCopy: (copied) => + clearCutRows( + copied, + cols.map((c) => c.name) + ), }) return } @@ -2064,57 +2137,17 @@ export function TableGrid({ e.preventDefault() if (isColumnSelectionRef.current) { - // Column-header cut spans all rows — drain pages first, then use async - // clipboard so we don't block the event before the drain completes. - void (async () => { - const allRows = await ensureAllRowsLoadedRef.current() - const lines: string[] = [] - const undoCells: Array<{ rowId: string; data: Record }> = [] - const batchUpdates: Array<{ rowId: string; data: Record }> = [] - for (const row of allRows) { - const cells: string[] = [] - const updates: Record = {} - const previousData: Record = {} - for (let c = sel.startCol; c <= sel.endCol; c++) { - const colName = cols[c]?.name - if (!colName) continue - const value: unknown = row.data[colName] - cells.push( - value === null || value === undefined - ? '' - : typeof value === 'object' - ? JSON.stringify(value) - : String(value) - ) - previousData[colName] = row.data[colName] ?? null - updates[colName] = null - } - lines.push(cells.join('\t')) - undoCells.push({ rowId: row.id, data: previousData }) - batchUpdates.push({ rowId: row.id, data: updates }) - } - if (!navigator.clipboard) { - toast.error('Clipboard access is unavailable in this context') - return - } - try { - await navigator.clipboard.writeText(lines.join('\n')) - } catch (err) { - if (err instanceof DOMException && err.name === 'NotAllowedError') { - toast.error( - 'Clipboard permission expired — press Cmd+X again immediately after selecting' - ) - return - } - throw err - } - if (undoCells.length > 0) { - pushUndoRef.current({ type: 'clear-cells', cells: undoCells }) - } - await chunkBatchUpdates(batchUpdates, batchUpdateAsyncRef.current) - })().catch((error) => { - logger.error('Failed to cut column cells', { error }) - toast.error('Failed to cut — please try again') + const colNames: string[] = [] + for (let c = sel.startCol; c <= sel.endCol; c++) { + const name = cols[c]?.name + if (name) colNames.push(name) + } + writeSelectionToClipboard({ + loadRows: () => ensureRowsLoadedUpToRef.current(TABLE_LIMITS.MAX_COPY_ROWS), + selectRow: () => true, + buildCells: (row) => colNames.map((name) => cellToText(row.data[name])), + verb: 'Cut', + afterCopy: (copied) => clearCutRows(copied, colNames), }) return } @@ -2131,12 +2164,7 @@ export function TableGrid({ for (let c = sel.startCol; c <= sel.endCol; c++) { if (c < cols.length) { const colName = cols[c].name - const value: unknown = row.data[colName] - if (value === null || value === undefined) { - cells.push('') - } else { - cells.push(typeof value === 'object' ? JSON.stringify(value) : String(value)) - } + cells.push(cellToText(row.data[colName])) previousData[colName] = row.data[colName] ?? null updates[colName] = null } @@ -2986,7 +3014,7 @@ export function TableGrid({ checkboxColWidth={checkboxColWidth} /> )} - + {isLoadingTable ? ( - + {isLoadingTable || isLoadingRows ? ( ) : ( - <> - {rows.map((row, index) => ( - - ))} - + (() => { + const virtualItems = rowVirtualizer.getVirtualItems() + // `item.start`/`item.end` include `scrollMargin` (the sticky-header + // offset) but `getTotalSize()` already nets it out, so both spacer + // heights are computed relative to `scrollMargin`. + const scrollMargin = rowVirtualizer.options.scrollMargin + const paddingTop = + virtualItems.length > 0 ? virtualItems[0].start - scrollMargin : 0 + const paddingBottom = + virtualItems.length > 0 + ? rowVirtualizer.getTotalSize() - + (virtualItems[virtualItems.length - 1].end - scrollMargin) + : 0 + return ( + <> + {paddingTop > 0 && ( + + + )} + {virtualItems.map((virtualRow) => { + const index = virtualRow.index + const row = rows[index] + if (!row) return null + return ( + + ) + })} + {paddingBottom > 0 && ( + + + )} + + ) + })() )}
@@ -3128,47 +3156,86 @@ export function TableGrid({ )}
+
+
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table.ts index 2b36bda1a9b..f0d265c1d32 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table.ts @@ -57,6 +57,13 @@ export interface UseTableReturn { * need the complete row set behind this. */ ensureAllRowsLoaded: () => Promise + /** + * Pages until the cache holds at least `maxRows` rows (or no more pages + * exist), then returns the first `maxRows` from cache plus whether more + * remain. Unlike {@link ensureAllRowsLoaded} it stops early, so size-bound + * ops (clipboard copy) don't drain an entire large table. Filter/sort-aware. + */ + ensureRowsLoadedUpTo: (maxRows: number) => Promise<{ rows: TableRow[]; hasMore: boolean }> } /** @@ -119,6 +126,44 @@ export function useTable({ workspaceId, tableId, queryOptions }: UseTableParams) return queryClient.getQueryData(opts.queryKey)?.pages.flatMap((p) => p.rows) ?? [] }, [workspaceId, tableId, queryOptions.filter, queryOptions.sort, queryClient, fetchNextPage]) + const ensureRowsLoadedUpTo = useCallback( + async (maxRows: number): Promise<{ rows: TableRow[]; hasMore: boolean }> => { + if (!workspaceId || !tableId) return { rows: [], hasMore: false } + + const opts = tableRowsInfiniteOptions({ + workspaceId, + tableId, + pageSize: TABLE_LIMITS.MAX_QUERY_LIMIT, + filter: queryOptions.filter, + sort: queryOptions.sort, + }) + + const loadedCount = (): number => + queryClient.getQueryData(opts.queryKey)?.pages.reduce((sum, p) => sum + p.rows.length, 0) ?? + 0 + + while (loadedCount() < maxRows) { + const data = queryClient.getQueryData(opts.queryKey) + const lastPage = data?.pages[data.pages.length - 1] + if (!lastPage || lastPage.rows.length < TABLE_LIMITS.MAX_QUERY_LIMIT) break + const result = await fetchNextPage() + if (result.status === 'error') { + throw result.error ?? new Error('Failed to load table rows') + } + } + + const data = queryClient.getQueryData(opts.queryKey) + const all = data?.pages.flatMap((p) => p.rows) ?? [] + const lastPage = data?.pages[data.pages.length - 1] + const morePages = lastPage ? lastPage.rows.length === TABLE_LIMITS.MAX_QUERY_LIMIT : false + return { + rows: all.length > maxRows ? all.slice(0, maxRows) : all, + hasMore: morePages || all.length > maxRows, + } + }, + [workspaceId, tableId, queryOptions.filter, queryOptions.sort, queryClient, fetchNextPage] + ) + const fetchNextPageWrapped = useCallback(async () => { const result = await fetchNextPage() if (result.status === 'error') { @@ -176,5 +221,6 @@ export function useTable({ workspaceId, tableId, queryOptions }: UseTableParams) workflowStates, columnSourceInfo, ensureAllRowsLoaded, + ensureRowsLoadedUpTo, } } diff --git a/apps/sim/hooks/queries/tables.ts b/apps/sim/hooks/queries/tables.ts index da557057431..f325a632d4f 100644 --- a/apps/sim/hooks/queries/tables.ts +++ b/apps/sim/hooks/queries/tables.ts @@ -71,6 +71,7 @@ import type { WorkflowGroupDependencies, WorkflowGroupOutput, } from '@/lib/table' +import { TABLE_LIMITS } from '@/lib/table/constants' import { areGroupDepsSatisfied, areOutputsFilled, @@ -820,17 +821,24 @@ export function useDeleteTableRows({ workspaceId, tableId }: RowMutationContext) mutationFn: async (rowIds: string[]): Promise => { const uniqueRowIds = Array.from(new Set(rowIds)) - const response = await requestJson(deleteTableRowsContract, { - params: { tableId }, - body: { workspaceId, rowIds: uniqueRowIds }, - }) - - const deletedRowIds = response.data.deletedRowIds || [] - const missingRowIds = response.data.missingRowIds || [] + // The delete contract caps `rowIds` at MAX_BULK_OPERATION_SIZE, so large + // selections (e.g. "select all") are sent as sequential chunks. + const chunkSize = TABLE_LIMITS.MAX_BULK_OPERATION_SIZE + const deletedRowIds: string[] = [] + const missingRowIds: string[] = [] + for (let i = 0; i < uniqueRowIds.length; i += chunkSize) { + const chunk = uniqueRowIds.slice(i, i + chunkSize) + const response = await requestJson(deleteTableRowsContract, { + params: { tableId }, + body: { workspaceId, rowIds: chunk }, + }) + deletedRowIds.push(...(response.data.deletedRowIds || [])) + missingRowIds.push(...(response.data.missingRowIds || [])) + } if (missingRowIds.length > 0) { const failureCount = missingRowIds.length - const totalCount = response.data.requestedCount ?? uniqueRowIds.length + const totalCount = uniqueRowIds.length const successCount = deletedRowIds.length const firstMissing = missingRowIds[0] throw new Error( diff --git a/apps/sim/lib/table/constants.ts b/apps/sim/lib/table/constants.ts index a56ef2ca9e4..00597130b71 100644 --- a/apps/sim/lib/table/constants.ts +++ b/apps/sim/lib/table/constants.ts @@ -24,6 +24,8 @@ export const TABLE_LIMITS = { MAX_BATCH_INSERT_SIZE: 1000, /** Maximum rows per bulk update/delete operation */ MAX_BULK_OPERATION_SIZE: 1000, + /** Maximum rows a single clipboard copy/cut serializes; beyond this the user is steered to Export. */ + MAX_COPY_ROWS: 50000, } as const /** diff --git a/apps/sim/package.json b/apps/sim/package.json index 47e10e51490..a99174b285e 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -107,6 +107,7 @@ "@t3-oss/env-nextjs": "0.13.4", "@tanstack/react-query": "5.90.8", "@tanstack/react-query-devtools": "5.90.2", + "@tanstack/react-virtual": "3.13.24", "@trigger.dev/sdk": "4.4.3", "ajv": "8.18.0", "better-auth": "1.3.12", diff --git a/bun.lock b/bun.lock index d2dc430861b..8dd5cb897e4 100644 --- a/bun.lock +++ b/bun.lock @@ -161,6 +161,7 @@ "@t3-oss/env-nextjs": "0.13.4", "@tanstack/react-query": "5.90.8", "@tanstack/react-query-devtools": "5.90.2", + "@tanstack/react-virtual": "3.13.24", "@trigger.dev/sdk": "4.4.3", "ajv": "8.18.0", "better-auth": "1.3.12", @@ -1647,6 +1648,10 @@ "@tanstack/react-query-devtools": ["@tanstack/react-query-devtools@5.90.2", "", { "dependencies": { "@tanstack/query-devtools": "5.90.1" }, "peerDependencies": { "@tanstack/react-query": "^5.90.2", "react": "^18 || ^19" } }, "sha512-vAXJzZuBXtCQtrY3F/yUNJCV4obT/A/n81kb3+YqLbro5Z2+phdAbceO+deU3ywPw8B42oyJlp4FhO0SoivDFQ=="], + "@tanstack/react-virtual": ["@tanstack/react-virtual@3.13.24", "", { "dependencies": { "@tanstack/virtual-core": "3.14.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-aIJvz5OSkhNIhZIpYivrxrPTKYsjW9Uzy+sP/mx0S3sev2HyvPb7xmjbYvokzEpfgYHy/HjzJ2zFAETuUfgCpg=="], + + "@tanstack/virtual-core": ["@tanstack/virtual-core@3.14.0", "", {}, "sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q=="], + "@testing-library/jest-dom": ["@testing-library/jest-dom@6.9.1", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" } }, "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA=="], "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="],