diff --git a/src/components/PaginatedTable/PaginatedTable.tsx b/src/components/PaginatedTable/PaginatedTable.tsx index 07e5c325a..670f907d0 100644 --- a/src/components/PaginatedTable/PaginatedTable.tsx +++ b/src/components/PaginatedTable/PaginatedTable.tsx @@ -1,7 +1,7 @@ import React from 'react'; import {usePaginatedTableState} from './PaginatedTableContext'; -import {TableChunk} from './TableChunk'; +import {TableChunksRenderer} from './TableChunksRenderer'; import {TableHead} from './TableHead'; import {DEFAULT_TABLE_ROW_HEIGHT} from './constants'; import {b} from './shared'; @@ -14,7 +14,7 @@ import type { RenderEmptyDataMessage, RenderErrorMessage, } from './types'; -import {useScrollBasedChunks} from './useScrollBasedChunks'; +import {calculateElementOffsetTop} from './utils'; import './PaginatedTable.scss'; @@ -62,14 +62,7 @@ export const PaginatedTable = ({ const {sortParams, foundEntities} = tableState; const tableRef = React.useRef(null); - - const activeChunks = useScrollBasedChunks({ - scrollContainerRef, - tableRef, - totalItems: foundEntities, - rowHeight, - chunkSize, - }); + const [tableOffset, setTableOffset] = React.useState(0); // this prevent situation when filters are new, but active chunks is not yet recalculated (it will be done to the next rendrer, so we bring filters change on the next render too) const [filters, setFilters] = React.useState(rawFilters); @@ -78,15 +71,6 @@ export const PaginatedTable = ({ setFilters(rawFilters); }, [rawFilters]); - const lastChunkSize = React.useMemo(() => { - // If foundEntities = 0, there will only first chunk - // Display it with 1 row, to display empty data message - if (!foundEntities) { - return 1; - } - return foundEntities % chunkSize || chunkSize; - }, [foundEntities, chunkSize]); - const handleDataFetched = React.useCallback( (data?: PaginatedTableData) => { if (data) { @@ -99,6 +83,25 @@ export const PaginatedTable = ({ [onDataFetched, setFoundEntities, setIsInitialLoad, setTotalEntities], ); + React.useLayoutEffect(() => { + const scrollContainer = scrollContainerRef.current; + const table = tableRef.current; + if (table && scrollContainer) { + setTableOffset(calculateElementOffsetTop(table, scrollContainer)); + } + }, [scrollContainerRef.current, tableRef.current, foundEntities]); + + // Set will-change: transform on scroll container if not already set + React.useLayoutEffect(() => { + const scrollContainer = scrollContainerRef.current; + if (scrollContainer) { + const computedStyle = window.getComputedStyle(scrollContainer); + if (computedStyle.willChange !== 'transform') { + scrollContainer.style.willChange = 'transform'; + } + } + }, [scrollContainerRef.current]); + // Reset table on initialization and filters change React.useLayoutEffect(() => { const defaultTotal = initialEntitiesCount || 0; @@ -109,33 +112,29 @@ export const PaginatedTable = ({ setIsInitialLoad(true); }, [initialEntitiesCount, setTotalEntities, setFoundEntities, setIsInitialLoad]); - const renderChunks = () => { - return activeChunks.map((isActive, index) => ( - - key={index} - id={index} - calculatedCount={index === activeChunks.length - 1 ? lastChunkSize : chunkSize} - chunkSize={chunkSize} - rowHeight={rowHeight} - columns={columns} - fetchData={fetchData} - filters={filters} - tableName={tableName} - sortParams={sortParams} - getRowClassName={getRowClassName} - renderErrorMessage={renderErrorMessage} - renderEmptyDataMessage={renderEmptyDataMessage} - onDataFetched={handleDataFetched} - isActive={isActive} - keepCache={keepCache} - /> - )); - }; - const renderTable = () => ( - {renderChunks()} + + +
); diff --git a/src/components/PaginatedTable/TableChunk.tsx b/src/components/PaginatedTable/TableChunk.tsx index 0a4085a7e..bc122bd04 100644 --- a/src/components/PaginatedTable/TableChunk.tsx +++ b/src/components/PaginatedTable/TableChunk.tsx @@ -29,7 +29,8 @@ interface TableChunkProps { columns: Column[]; filters?: F; sortParams?: SortParams; - isActive: boolean; + shouldFetch: boolean; + shouldRender: boolean; tableName: string; fetchData: FetchData; @@ -56,7 +57,8 @@ export const TableChunk = typedMemo(function TableChunk({ renderErrorMessage, renderEmptyDataMessage, onDataFetched, - isActive, + shouldFetch, + shouldRender, keepCache, }: TableChunkProps) { const [isTimeoutActive, setIsTimeoutActive] = React.useState(true); @@ -75,7 +77,7 @@ export const TableChunk = typedMemo(function TableChunk({ }; tableDataApi.useFetchTableChunkQuery(queryParams, { - skip: isTimeoutActive || !isActive, + skip: isTimeoutActive || !shouldFetch, pollingInterval: autoRefreshInterval, refetchOnMountOrArgChange: !keepCache, }); @@ -85,7 +87,7 @@ export const TableChunk = typedMemo(function TableChunk({ React.useEffect(() => { let timeout = 0; - if (isActive && isTimeoutActive) { + if (shouldFetch && isTimeoutActive) { timeout = window.setTimeout(() => { setIsTimeoutActive(false); }, DEBOUNCE_TIMEOUT); @@ -94,10 +96,10 @@ export const TableChunk = typedMemo(function TableChunk({ return () => { window.clearTimeout(timeout); }; - }, [isActive, isTimeoutActive]); + }, [shouldFetch, isTimeoutActive]); React.useEffect(() => { - if (currentData && isActive) { + if (currentData) { onDataFetched({ ...currentData, data: currentData.data as T[], @@ -105,20 +107,16 @@ export const TableChunk = typedMemo(function TableChunk({ total: currentData.total || 0, }); } - }, [currentData, isActive, onDataFetched]); + }, [currentData, onDataFetched]); const dataLength = currentData?.data?.length || calculatedCount; const renderContent = () => { - if (!isActive) { - return null; - } - if (!currentData) { if (error) { const errorData = error as IResponseError; return ( - + {renderErrorMessage ? ( renderErrorMessage(errorData) ) : ( @@ -136,7 +134,7 @@ export const TableChunk = typedMemo(function TableChunk({ // Data is loaded, but there are no entities in the chunk if (!currentData.data?.length) { return ( - + {renderEmptyDataMessage ? renderEmptyDataMessage() : i18n('empty')} ); @@ -153,18 +151,5 @@ export const TableChunk = typedMemo(function TableChunk({ )); }; - return ( - - {renderContent()} - - ); + return shouldRender ? renderContent() : null; }); diff --git a/src/components/PaginatedTable/TableChunksRenderer.tsx b/src/components/PaginatedTable/TableChunksRenderer.tsx new file mode 100644 index 000000000..c819fb348 --- /dev/null +++ b/src/components/PaginatedTable/TableChunksRenderer.tsx @@ -0,0 +1,191 @@ +import React from 'react'; + +import {TableChunk} from './TableChunk'; +import {b} from './shared'; +import type { + Column, + FetchData, + GetRowClassName, + PaginatedTableData, + RenderEmptyDataMessage, + RenderErrorMessage, + SortParams, +} from './types'; +import {useScrollBasedChunks} from './useScrollBasedChunks'; + +export interface TableChunksRendererProps { + scrollContainerRef: React.RefObject; + tableRef: React.RefObject; + foundEntities: number; + tableOffset: number; + chunkSize: number; + rowHeight: number; + columns: Column[]; + fetchData: FetchData; + filters?: F; + tableName: string; + sortParams?: SortParams; + getRowClassName?: GetRowClassName; + renderErrorMessage?: RenderErrorMessage; + renderEmptyDataMessage?: RenderEmptyDataMessage; + onDataFetched: (data?: PaginatedTableData) => void; + keepCache: boolean; +} + +export const TableChunksRenderer = ({ + scrollContainerRef, + tableRef, + foundEntities, + tableOffset, + chunkSize, + rowHeight, + columns, + fetchData, + filters, + tableName, + sortParams, + getRowClassName, + renderErrorMessage, + renderEmptyDataMessage, + onDataFetched, + keepCache, +}: TableChunksRendererProps) => { + const chunkStates = useScrollBasedChunks({ + scrollContainerRef, + tableRef, + totalItems: foundEntities || 1, + rowHeight, + chunkSize, + tableOffset, + }); + + const lastChunkSize = React.useMemo(() => { + // If foundEntities = 0, there will only first chunk + // Display it with 1 row, to display empty data message + if (!foundEntities) { + return 1; + } + return foundEntities % chunkSize || chunkSize; + }, [foundEntities, chunkSize]); + + const findRenderChunkRange = React.useCallback(() => { + const firstRenderIndex = chunkStates.findIndex((state) => state.shouldRender); + const lastRenderIndex = chunkStates.findLastIndex((state) => state.shouldRender); + return {firstRenderIndex, lastRenderIndex}; + }, [chunkStates]); + + const findFetchChunkRange = React.useCallback(() => { + const firstFetchIndex = chunkStates.findIndex((state) => state.shouldFetch); + const lastFetchIndex = chunkStates.findLastIndex((state) => state.shouldFetch); + return {firstFetchIndex, lastFetchIndex}; + }, [chunkStates]); + + const calculateSeparatorHeight = React.useCallback( + (startIndex: number, endIndex: number) => { + let totalHeight = 0; + for (let i = startIndex; i < endIndex; i++) { + const currentChunkSize = i === chunkStates.length - 1 ? lastChunkSize : chunkSize; + totalHeight += currentChunkSize * rowHeight; + } + return totalHeight; + }, + [chunkSize, chunkStates.length, lastChunkSize, rowHeight], + ); + + const createSeparator = React.useCallback( + (startIndex: number, endIndex: number, key: string) => { + const height = calculateSeparatorHeight(startIndex, endIndex); + return ( + + + + ); + }, + [calculateSeparatorHeight, columns.length], + ); + + const createChunk = React.useCallback( + (chunkIndex: number) => { + const chunkState = chunkStates[chunkIndex]; + return ( + + key={chunkIndex} + id={chunkIndex} + calculatedCount={ + chunkIndex === chunkStates.length - 1 ? lastChunkSize : chunkSize + } + chunkSize={chunkSize} + rowHeight={rowHeight} + columns={columns} + fetchData={fetchData} + filters={filters} + tableName={tableName} + sortParams={sortParams} + getRowClassName={getRowClassName} + renderErrorMessage={renderErrorMessage} + renderEmptyDataMessage={renderEmptyDataMessage} + onDataFetched={onDataFetched} + shouldFetch={chunkState.shouldFetch} + shouldRender={chunkState.shouldRender} + keepCache={keepCache} + /> + ); + }, + [ + chunkSize, + chunkStates, + columns, + fetchData, + filters, + getRowClassName, + keepCache, + lastChunkSize, + onDataFetched, + renderEmptyDataMessage, + renderErrorMessage, + rowHeight, + sortParams, + tableName, + ], + ); + + const renderChunks = React.useCallback(() => { + // Chunk states are distrubuted like [null, null, fetch, fetch, render+fetch, render+fetch, fetch, fetch, null, null] + // i.e. fetched chunks include rendered chunks + const {firstFetchIndex, lastFetchIndex} = findFetchChunkRange(); + const {firstRenderIndex, lastRenderIndex} = findRenderChunkRange(); + const elements: React.ReactElement[] = []; + + // No fetch chunks found + if (firstFetchIndex === -1) { + return elements; + } + + // Beginning separator (for chunks before first render chunk) + if (firstRenderIndex > 0) { + elements.push(createSeparator(0, firstRenderIndex, 'separator-beginning')); + } + + // All fetch chunks (shouldFetch = true) get rendered as TableChunk components + for (let i = firstFetchIndex; i <= lastFetchIndex; i++) { + elements.push(createChunk(i)); + } + + // End separator (for chunks after last render chunk) + if (lastRenderIndex < chunkStates.length - 1) { + elements.push( + createSeparator(lastRenderIndex + 1, chunkStates.length, 'separator-end'), + ); + } + + return elements; + }, [ + chunkStates.length, + createChunk, + createSeparator, + findFetchChunkRange, + findRenderChunkRange, + ]); + + return {renderChunks()}; +}; diff --git a/src/components/PaginatedTable/TableRow.tsx b/src/components/PaginatedTable/TableRow.tsx index 7ce9987b2..28d3d3cb1 100644 --- a/src/components/PaginatedTable/TableRow.tsx +++ b/src/components/PaginatedTable/TableRow.tsx @@ -44,7 +44,7 @@ interface LoadingTableRowProps { export const LoadingTableRow = typedMemo(function ({columns, height}: LoadingTableRowProps) { return ( - + {columns.map((column) => { const resizeable = column.resizeable ?? DEFAULT_RESIZEABLE; @@ -79,7 +79,7 @@ export const TableRow = ({row, columns, getRowClassName, height}: TableRowPr const additionalClassName = getRowClassName?.(row); return ( - + {columns.map((column) => { const resizeable = column.resizeable ?? DEFAULT_RESIZEABLE; @@ -103,11 +103,12 @@ export const TableRow = ({row, columns, getRowClassName, height}: TableRowPr interface EmptyTableRowProps { columns: Column[]; children?: React.ReactNode; + height: number; } -export const EmptyTableRow = ({columns, children}: EmptyTableRowProps) => { +export const EmptyTableRow = ({columns, children, height}: EmptyTableRowProps) => { return ( - + {children} diff --git a/src/components/PaginatedTable/requestBatcher.ts b/src/components/PaginatedTable/requestBatcher.ts new file mode 100644 index 000000000..60cdadfc1 --- /dev/null +++ b/src/components/PaginatedTable/requestBatcher.ts @@ -0,0 +1,182 @@ +import type {FetchData, PaginatedTableData, SortParams} from './types'; + +interface PaginatedTableParams { + offset: number; + fetchData: FetchData; + filters: F; + limit: number; + sortParams?: SortParams; + columnsIds: string[]; + tableName: string; +} + +interface QueuedRequest { + params: PaginatedTableParams; + resolve: (result: {data: PaginatedTableData} | {error: unknown}) => void; + reject: (error: unknown) => void; + signal?: AbortSignal; +} + +interface BatchGroup { + requests: QueuedRequest[]; + batchKey: string; + minOffset: number; + maxOffset: number; + totalLimit: number; +} + +class RequestBatcher { + private requestQueue = new Map[]>(); + private batchTimeout: NodeJS.Timeout | null = null; + private readonly BATCH_DELAY = 50; // ms + + queueRequest( + params: PaginatedTableParams, + signal?: AbortSignal, + ): Promise<{data: PaginatedTableData} | {error: unknown}> { + return new Promise((resolve, reject) => { + const batchKey = this.createBatchKey(params); + + if (!this.requestQueue.has(batchKey)) { + this.requestQueue.set(batchKey, []); + } + + this.requestQueue.get(batchKey)!.push({ + params, + resolve, + reject, + signal, + }); + + // Reset the batch timeout + if (this.batchTimeout) { + clearTimeout(this.batchTimeout); + } + + this.batchTimeout = setTimeout(() => { + this.processBatch(); + }, this.BATCH_DELAY); + }); + } + + private createBatchKey(params: PaginatedTableParams): string { + return JSON.stringify({ + tableName: params.tableName, + filters: params.filters, + sortParams: params.sortParams, + columnsIds: params.columnsIds, + limit: params.limit, + }); + } + + private groupConsecutiveRequests(requests: QueuedRequest[]): BatchGroup[] { + if (requests.length === 0) { + return []; + } + + const sorted = requests.sort((a, b) => a.params.offset - b.params.offset); + const groups: BatchGroup[] = []; + let currentGroup: QueuedRequest[] = [sorted[0]]; + + const limit = sorted[0].params.limit; + + for (let i = 1; i < sorted.length; i++) { + const expectedOffset = currentGroup[currentGroup.length - 1].params.offset + limit; + + if (sorted[i].params.offset === expectedOffset) { + // Consecutive request + currentGroup.push(sorted[i]); + } else { + // Non-consecutive, create a new group + groups.push(this.createBatchGroup(currentGroup)); + currentGroup = [sorted[i]]; + } + } + + // Add the last group + groups.push(this.createBatchGroup(currentGroup)); + + return groups; + } + + private createBatchGroup(requests: QueuedRequest[]): BatchGroup { + const minOffset = Math.min(...requests.map((r) => r.params.offset)); + const maxOffset = Math.max(...requests.map((r) => r.params.offset)); + const limit = requests[0].params.limit; + const totalLimit = requests.length * limit; + + return { + requests, + batchKey: this.createBatchKey(requests[0].params), + minOffset, + maxOffset, + totalLimit, + }; + } + + private async executeBatch(group: BatchGroup): Promise { + const firstRequest = group.requests[0]; + const batchParams = { + ...firstRequest.params, + offset: group.minOffset, + limit: group.totalLimit, + }; + + try { + const response = await firstRequest.params.fetchData({ + limit: batchParams.limit, + offset: batchParams.offset, + filters: batchParams.filters, + sortParams: batchParams.sortParams, + columnsIds: batchParams.columnsIds, + signal: firstRequest.signal, + }); + + // Split the response data among individual requests + this.splitAndDistributeResponse(group, response); + } catch (error) { + // If batch fails, reject all requests in the group + group.requests.forEach((request) => { + request.resolve({error}); + }); + } + } + + private splitAndDistributeResponse( + group: BatchGroup, + batchResponse: PaginatedTableData, + ): void { + const limit = group.requests[0].params.limit; + + group.requests.forEach((request, index) => { + const startIndex = index * limit; + const endIndex = startIndex + limit; + const chunkData = batchResponse.data.slice(startIndex, endIndex); + + const chunkResponse: PaginatedTableData = { + ...batchResponse, + data: chunkData, + total: batchResponse.total, + found: batchResponse.found, + }; + + request.resolve({data: chunkResponse}); + }); + } + + private async processBatch(): Promise { + const allQueues = Array.from(this.requestQueue.entries()); + this.requestQueue.clear(); + this.batchTimeout = null; + + for (const [_batchKey, requests] of allQueues) { + const groups = this.groupConsecutiveRequests(requests); + + // Execute each group (consecutive chunks) as a separate batch + await Promise.all(groups.map((group) => this.executeBatch(group))); + } + } +} + +// Singleton instance +export const requestBatcher = new RequestBatcher(); diff --git a/src/components/PaginatedTable/useScrollBasedChunks.ts b/src/components/PaginatedTable/useScrollBasedChunks.ts index b05b5d458..ea82a61ed 100644 --- a/src/components/PaginatedTable/useScrollBasedChunks.ts +++ b/src/components/PaginatedTable/useScrollBasedChunks.ts @@ -1,6 +1,8 @@ import React from 'react'; -import {calculateElementOffsetTop, rafThrottle} from './utils'; +import {throttle} from 'lodash'; + +import {rafThrottle} from './utils'; interface UseScrollBasedChunksProps { scrollContainerRef: React.RefObject; @@ -8,10 +10,22 @@ interface UseScrollBasedChunksProps { totalItems: number; rowHeight: number; chunkSize: number; - overscanCount?: number; + renderOverscan?: number; + fetchOverscan?: number; + tableOffset: number; +} + +interface ChunkState { + shouldRender: boolean; + shouldFetch: boolean; } -const DEFAULT_OVERSCAN_COUNT = 1; +const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent); + +// Bad performance in Safari - reduce overscan counts +const DEFAULT_RENDER_OVERSCAN = isSafari ? 1 : 2; +const DEFAULT_FETCH_OVERSCAN = 4; +const THROTTLE_DELAY = 200; export const useScrollBasedChunks = ({ scrollContainerRef, @@ -19,17 +33,17 @@ export const useScrollBasedChunks = ({ totalItems, rowHeight, chunkSize, - overscanCount = DEFAULT_OVERSCAN_COUNT, -}: UseScrollBasedChunksProps): boolean[] => { + tableOffset, + renderOverscan = DEFAULT_RENDER_OVERSCAN, + fetchOverscan = DEFAULT_FETCH_OVERSCAN, +}: UseScrollBasedChunksProps): ChunkState[] => { const chunksCount = React.useMemo( () => Math.ceil(totalItems / chunkSize), [chunkSize, totalItems], ); - const [startChunk, setStartChunk] = React.useState(0); - const [endChunk, setEndChunk] = React.useState( - Math.min(overscanCount, Math.max(chunksCount - 1, 0)), - ); + const [visibleStartChunk, setVisibleStartChunk] = React.useState(0); + const [visibleEndChunk, setVisibleEndChunk] = React.useState(0); const calculateVisibleRange = React.useCallback(() => { const container = scrollContainerRef?.current; @@ -38,24 +52,23 @@ export const useScrollBasedChunks = ({ return null; } - const tableOffset = calculateElementOffsetTop(table, container); const containerScroll = container.scrollTop; const visibleStart = Math.max(containerScroll - tableOffset, 0); const visibleEnd = visibleStart + container.clientHeight; - const start = Math.max(Math.floor(visibleStart / rowHeight / chunkSize) - overscanCount, 0); + const start = Math.max(Math.floor(visibleStart / rowHeight / chunkSize), 0); const end = Math.min( - Math.floor(visibleEnd / rowHeight / chunkSize) + overscanCount, + Math.floor(visibleEnd / rowHeight / chunkSize), Math.max(chunksCount - 1, 0), ); return {start, end}; - }, [scrollContainerRef, tableRef, rowHeight, chunkSize, overscanCount, chunksCount]); + }, [scrollContainerRef, tableRef, tableOffset, rowHeight, chunkSize, chunksCount]); const updateVisibleChunks = React.useCallback(() => { const newRange = calculateVisibleRange(); if (newRange) { - setStartChunk(newRange.start); - setEndChunk(newRange.end); + setVisibleStartChunk(newRange.start); + setVisibleEndChunk(newRange.end); } }, [calculateVisibleRange]); @@ -85,20 +98,40 @@ export const useScrollBasedChunks = ({ return undefined; } - const throttledHandleScroll = rafThrottle(handleScroll); + const throttledHandleScroll = throttle(handleScroll, THROTTLE_DELAY, { + trailing: true, + leading: true, + }); - container.addEventListener('scroll', throttledHandleScroll); + container.addEventListener('scroll', throttledHandleScroll, {passive: true}); return () => { container.removeEventListener('scroll', throttledHandleScroll); }; }, [handleScroll, scrollContainerRef]); return React.useMemo(() => { - // boolean array that represents active chunks - const activeChunks = Array(chunksCount).fill(false); - for (let i = startChunk; i <= endChunk; i++) { - activeChunks[i] = true; - } - return activeChunks; - }, [chunksCount, startChunk, endChunk]); + // Calculate render range (visible + render overscan) + const renderStartChunk = Math.max(visibleStartChunk - renderOverscan, 0); + const renderEndChunk = Math.min( + visibleEndChunk + renderOverscan, + Math.max(chunksCount - 1, 0), + ); + + // Calculate fetch range (visible + fetch overscan) + const fetchStartChunk = Math.max(visibleStartChunk - fetchOverscan, 0); + const fetchEndChunk = Math.min( + visibleEndChunk + fetchOverscan, + Math.max(chunksCount - 1, 0), + ); + + // Create chunk states array + const chunkStates: ChunkState[] = Array(chunksCount) + .fill(null) + .map((_, index) => ({ + shouldRender: index >= renderStartChunk && index <= renderEndChunk, + shouldFetch: index >= fetchStartChunk && index <= fetchEndChunk, + })); + + return chunkStates; + }, [chunksCount, visibleStartChunk, visibleEndChunk, renderOverscan, fetchOverscan]); }; diff --git a/src/store/reducers/tableData.ts b/src/store/reducers/tableData.ts index a22379f32..50517f9cc 100644 --- a/src/store/reducers/tableData.ts +++ b/src/store/reducers/tableData.ts @@ -1,6 +1,7 @@ import type {BaseQueryFn, EndpointBuilder} from '@reduxjs/toolkit/query'; import type {FetchData, PaginatedTableData, SortParams} from '../../components/PaginatedTable'; +import {requestBatcher} from '../../components/PaginatedTable/requestBatcher'; import {api} from './api'; @@ -18,19 +19,29 @@ function endpoints(build: EndpointBuilder) { return { fetchTableChunk: build.query, PaginatedTableParams>({ queryFn: async ( - {offset, limit, sortParams, filters, columnsIds, fetchData}, + {offset, limit, sortParams, filters, columnsIds, fetchData, tableName}, {signal}, ) => { try { - const response = await fetchData({ - limit, - offset, - filters, - sortParams, - columnsIds, + // Use the request batcher for potential merging + const result = await requestBatcher.queueRequest( + { + offset, + limit, + sortParams, + filters, + columnsIds, + fetchData, + tableName, + }, signal, - }); - return {data: response}; + ); + + if ('error' in result) { + return {error: result.error}; + } + + return result; } catch (error) { return {error: error}; } diff --git a/tests/suites/paginatedTable/paginatedTable.test.ts b/tests/suites/paginatedTable/paginatedTable.test.ts index 7bbdd8d70..fca5975e4 100644 --- a/tests/suites/paginatedTable/paginatedTable.test.ts +++ b/tests/suites/paginatedTable/paginatedTable.test.ts @@ -20,7 +20,10 @@ test.describe('PaginatedTable', () => { // Get initial row count (should be first chunk) const initialVisibleRows = await paginatedTable.getRowCount(); - expect(initialVisibleRows).toEqual(40); // Should not show all rows initially + + // Safari shows 40 rows initially (1 + 1 overscan), other browsers show 60 (1 + 2 overscan) + const expectedRows = page.context().browser()?.browserType().name() === 'webkit' ? 40 : 60; + expect(initialVisibleRows).toEqual(expectedRows); // Should not show all rows initially // Get data from first visible row to verify initial chunk const firstRowData = await paginatedTable.getRowData(0);