feat: Implement infra code for infinite scroll implementation. (#39225)

## Description
Implements infinite scroll functionality for table widget using
react-window-infinite-loader. Introduces new components and hooks to
manage virtualized table rendering with dynamic loading of rows.


Fixes #39082  
_or_  
Fixes `Issue URL`
> [!WARNING]  
> _If no issue exists, please create an issue first, and check with the
maintainers if the issue is valid._

## Automation

/ok-to-test tags="@tag.Table, @tag.Sanity"

### 🔍 Cypress test results
<!-- This is an auto-generated comment: Cypress test results  -->
> [!TIP]
> 🟢 🟢 🟢 All cypress tests have passed! 🎉 🎉 🎉
> Workflow run:
<https://github.com/appsmithorg/appsmith/actions/runs/13329193341>
> Commit: 0c58fcf83dbfd520958c9989ffb607cf57d1fdb1
> <a
href="https://internal.appsmith.com/app/cypress-dashboard/rundetails-65890b3c81d7400d08fa9ee5?branch=master&workflowId=13329193341&attempt=1"
target="_blank">Cypress dashboard</a>.
> Tags: `@tag.Table, @tag.Sanity`
> Spec:
> <hr>Fri, 14 Feb 2025 13:18:37 UTC
<!-- end of auto-generated comment: Cypress test results  -->


## Communication
Should the DevRel and Marketing teams inform users about this change?
- [ ] Yes
- [ ] No


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

- **New Features**
- Added infinite scrolling support to table views, enabling seamless
data loading as you scroll.
- Enhanced table interfaces with improved loading indicators and
smoother virtualized rendering for large datasets.

- **Chores**
- Updated supporting libraries to underpin the improved scrolling and
data handling capabilities.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------
This commit is contained in:
Rahul Barwal 2025-02-17 14:29:40 +05:30 committed by GitHub
parent 22688f994f
commit 5b9153cb19
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 398 additions and 44 deletions

View File

@ -91,6 +91,7 @@
"@types/d3-geo": "^3.1.0",
"@types/google.maps": "^3.51.0",
"@types/react-page-visibility": "^6.4.1",
"@types/react-window-infinite-loader": "^1.0.9",
"@types/web": "^0.0.99",
"@uppy/core": "^1.16.0",
"@uppy/dashboard": "^1.16.0",
@ -203,6 +204,7 @@
"react-virtuoso": "^4.5.0",
"react-webcam": "^7.0.1",
"react-window": "^1.8.6",
"react-window-infinite-loader": "^1.0.10",
"react-zoom-pan-pinch": "^1.6.1",
"redux": "^4.0.1",
"redux-form": "^8.2.6",

View File

@ -42,6 +42,8 @@ type StaticTableProps = TableColumnHeaderProps & {
scrollContainerStyles: any;
useVirtual: boolean;
tableBodyRef?: React.MutableRefObject<HTMLDivElement | null>;
isLoading: boolean;
loadMoreFromEvaluations: () => void;
};
const StaticTable = (props: StaticTableProps, ref: React.Ref<SimpleBar>) => {
@ -81,6 +83,8 @@ const StaticTable = (props: StaticTableProps, ref: React.Ref<SimpleBar>) => {
getTableBodyProps={props.getTableBodyProps}
height={props.height}
isAddRowInProgress={props.isAddRowInProgress}
isLoading={props.isLoading}
loadMoreFromEvaluations={props.loadMoreFromEvaluations}
multiRowSelection={!!props.multiRowSelection}
pageSize={props.pageSize}
prepareRow={props.prepareRow}

View File

@ -473,8 +473,10 @@ export function Table(props: TableProps) {
headerGroups={headerGroups}
height={props.height}
isAddRowInProgress={props.isAddRowInProgress}
isLoading={props.isLoading}
isResizingColumn={isResizingColumn}
isSortable={props.isSortable}
loadMoreFromEvaluations={props.nextPageClick}
multiRowSelection={props?.multiRowSelection}
pageSize={props.pageSize}
prepareRow={prepareRow}
@ -512,8 +514,10 @@ export function Table(props: TableProps) {
height={props.height}
isAddRowInProgress={props.isAddRowInProgress}
isInfiniteScrollEnabled={props.isInfiniteScrollEnabled}
isLoading={props.isLoading}
isResizingColumn={isResizingColumn}
isSortable={props.isSortable}
loadMoreFromEvaluations={props.nextPageClick}
multiRowSelection={props?.multiRowSelection}
pageSize={props.pageSize}
prepareRow={prepareRow}

View File

@ -0,0 +1,59 @@
import React, { type Ref } from "react";
import type { Row as ReactTableRowType } from "react-table";
import { type ReactElementType } from "react-window";
import InfiniteLoader from "react-window-infinite-loader";
import type SimpleBar from "simplebar-react";
import type { TableSizes } from "../../Constants";
import { useInfiniteVirtualization } from "./useInfiniteVirtualization";
import { FixedInfiniteVirtualList } from "../VirtualList";
interface InfiniteScrollBodyProps {
rows: ReactTableRowType<Record<string, unknown>>[];
height: number;
tableSizes: TableSizes;
innerElementType?: ReactElementType;
isLoading: boolean;
totalRecordsCount?: number;
itemCount: number;
loadMoreFromEvaluations: () => void;
pageSize: number;
}
const InfiniteScrollBody = React.forwardRef(
(props: InfiniteScrollBodyProps, ref: Ref<SimpleBar>) => {
const { isLoading, loadMoreFromEvaluations, pageSize, rows } = props;
const { isItemLoaded, itemCount, loadMoreItems } =
useInfiniteVirtualization({
rows,
totalRecordsCount: rows.length,
isLoading,
loadMore: loadMoreFromEvaluations,
pageSize,
});
return (
<div className="simplebar-content-wrapper">
<InfiniteLoader
isItemLoaded={isItemLoaded}
itemCount={itemCount + 5}
loadMoreItems={loadMoreItems}
>
{({ onItemsRendered, ref: infiniteLoaderRef }) => (
<FixedInfiniteVirtualList
height={props.height}
infiniteLoaderListRef={infiniteLoaderRef}
innerElementType={props.innerElementType}
onItemsRendered={onItemsRendered}
outerRef={ref}
pageSize={props.pageSize}
rows={props.rows}
tableSizes={props.tableSizes}
/>
)}
</InfiniteLoader>
</div>
);
},
);
export default InfiniteScrollBody;

View File

@ -0,0 +1,152 @@
import { renderHook } from "@testing-library/react-hooks";
import { useInfiniteVirtualization } from "./useInfiniteVirtualization";
import { act } from "@testing-library/react";
import type { Row as ReactTableRowType } from "react-table";
describe("useInfiniteVirtualization", () => {
const mockRows: ReactTableRowType<Record<string, unknown>>[] = [
{
id: "1",
original: { id: 1, name: "Test 1" },
index: 0,
cells: [],
values: {},
getRowProps: jest.fn(),
allCells: [],
subRows: [],
isExpanded: false,
canExpand: false,
depth: 0,
toggleRowExpanded: jest.fn(),
state: {},
toggleRowSelected: jest.fn(),
getToggleRowExpandedProps: jest.fn(),
isSelected: false,
isSomeSelected: false,
isGrouped: false,
groupByID: "",
groupByVal: "",
leafRows: [],
getToggleRowSelectedProps: jest.fn(),
setState: jest.fn(),
},
{
id: "2",
original: { id: 2, name: "Test 2" },
index: 1,
cells: [],
values: {},
getRowProps: jest.fn(),
allCells: [],
subRows: [],
isExpanded: false,
canExpand: false,
depth: 0,
toggleRowExpanded: jest.fn(),
state: {},
toggleRowSelected: jest.fn(),
getToggleRowExpandedProps: jest.fn(),
isSelected: false,
isSomeSelected: false,
isGrouped: false,
groupByID: "",
groupByVal: "",
leafRows: [],
getToggleRowSelectedProps: jest.fn(),
setState: jest.fn(),
},
];
const defaultProps = {
rows: mockRows,
isLoading: false,
loadMore: jest.fn(),
pageSize: 10,
};
beforeEach(() => {
jest.clearAllMocks();
});
it("should return correct itemCount when totalRecordsCount is provided", () => {
const totalRecordsCount = 100;
const { result } = renderHook(() =>
useInfiniteVirtualization({
...defaultProps,
totalRecordsCount,
}),
);
expect(result.current.itemCount).toBe(totalRecordsCount);
});
it("should return rows length as itemCount when totalRecordsCount is not provided", () => {
const { result } = renderHook(() =>
useInfiniteVirtualization(defaultProps),
);
expect(result.current.itemCount).toBe(defaultProps.rows.length);
});
it("should call loadMore when loadMoreItems is called and not loading", async () => {
const { result } = renderHook(() =>
useInfiniteVirtualization(defaultProps),
);
await act(async () => {
await result.current.loadMoreItems(0, 10);
});
expect(defaultProps.loadMore).toHaveBeenCalledTimes(1);
});
it("should not call loadMore when loadMoreItems is called and is loading", async () => {
const { result } = renderHook(() =>
useInfiniteVirtualization({
...defaultProps,
isLoading: true,
}),
);
await act(async () => {
await result.current.loadMoreItems(0, 10);
});
expect(defaultProps.loadMore).not.toHaveBeenCalled();
});
it("should return correct isItemLoaded state for different scenarios", () => {
const { result } = renderHook(() =>
useInfiniteVirtualization(defaultProps),
);
// Index within rows length and not loading
expect(result.current.isItemLoaded(1)).toBe(true);
// Index beyond rows length and not loading
expect(result.current.isItemLoaded(5)).toBe(false);
});
it("should return false for isItemLoaded when loading", () => {
const { result } = renderHook(() =>
useInfiniteVirtualization({
...defaultProps,
isLoading: true,
}),
);
// Even for index within rows length, should return false when loading
expect(result.current.isItemLoaded(1)).toBe(false);
});
it("should return zero itemCount when there are no records", () => {
const { result } = renderHook(() =>
useInfiniteVirtualization({
...defaultProps,
rows: [],
}),
);
expect(result.current.itemCount).toBe(0);
});
});

View File

@ -0,0 +1,37 @@
import { useCallback } from "react";
import type { Row as ReactTableRowType } from "react-table";
interface InfiniteVirtualizationProps {
rows: ReactTableRowType<Record<string, unknown>>[];
totalRecordsCount?: number;
isLoading: boolean;
loadMore: () => void;
pageSize: number;
}
interface UseInfiniteVirtualizationReturn {
itemCount: number;
loadMoreItems: (startIndex: number, stopIndex: number) => void;
isItemLoaded: (index: number) => boolean;
}
export const useInfiniteVirtualization = ({
isLoading,
loadMore,
rows,
totalRecordsCount,
}: InfiniteVirtualizationProps): UseInfiniteVirtualizationReturn => {
const loadMoreItems = useCallback(async () => {
if (!isLoading) {
loadMore();
}
return Promise.resolve();
}, [isLoading, loadMore]);
return {
itemCount: totalRecordsCount ?? rows.length,
loadMoreItems,
isItemLoaded: (index) => !isLoading && index < rows.length,
};
};

View File

@ -0,0 +1,91 @@
import type { ListOnItemsRenderedProps, ReactElementType } from "react-window";
import { FixedSizeList, areEqual } from "react-window";
import React from "react";
import type { ListChildComponentProps } from "react-window";
import type { Row as ReactTableRowType } from "react-table";
import { WIDGET_PADDING } from "constants/WidgetConstants";
import { EmptyRow, Row } from "./Row";
import type { TableSizes } from "../Constants";
import type SimpleBar from "simplebar-react";
const rowRenderer = React.memo((rowProps: ListChildComponentProps) => {
const { data, index, style } = rowProps;
if (index < data.length) {
const row = data[index];
return (
<Row
className="t--virtual-row"
index={index}
key={index}
row={row}
style={style}
/>
);
} else {
return <EmptyRow style={style} />;
}
}, areEqual);
interface BaseVirtualListProps {
height: number;
tableSizes: TableSizes;
rows: ReactTableRowType<Record<string, unknown>>[];
pageSize: number;
innerElementType?: ReactElementType;
outerRef?: React.Ref<SimpleBar>;
onItemsRendered?: (props: ListOnItemsRenderedProps) => void;
infiniteLoaderListRef?: React.Ref<FixedSizeList>;
}
const BaseVirtualList = React.memo(function BaseVirtualList({
height,
infiniteLoaderListRef,
innerElementType,
onItemsRendered,
outerRef,
pageSize,
rows,
tableSizes,
}: BaseVirtualListProps) {
return (
<FixedSizeList
className="virtual-list simplebar-content"
height={
height -
tableSizes.TABLE_HEADER_HEIGHT -
2 * tableSizes.VERTICAL_PADDING
}
innerElementType={innerElementType}
itemCount={Math.max(rows.length, pageSize)}
itemData={rows}
itemSize={tableSizes.ROW_HEIGHT}
onItemsRendered={onItemsRendered}
outerRef={outerRef}
ref={infiniteLoaderListRef}
width={`calc(100% + ${2 * WIDGET_PADDING}px)`}
>
{rowRenderer}
</FixedSizeList>
);
});
/**
* The difference between next two components is in the number of arguments they expect.
*/
export const FixedInfiniteVirtualList = React.memo(
function FixedInfiniteVirtualList(props: BaseVirtualListProps) {
return <BaseVirtualList {...props} />;
},
);
type FixedVirtualListProps = Omit<
BaseVirtualListProps,
"onItemsRendered" | "infiniteLoaderListRef"
>;
export const FixedVirtualList = React.memo(function FixedVirtualList(
props: FixedVirtualListProps,
) {
return <BaseVirtualList {...props} />;
});

View File

@ -5,13 +5,13 @@ import type {
TableBodyPropGetter,
TableBodyProps,
} from "react-table";
import type { ListChildComponentProps, ReactElementType } from "react-window";
import { FixedSizeList, areEqual } from "react-window";
import { WIDGET_PADDING } from "constants/WidgetConstants";
import { EmptyRows, EmptyRow, Row } from "./Row";
import { type ReactElementType } from "react-window";
import type SimpleBar from "simplebar-react";
import type { ReactTableColumnProps, TableSizes } from "../Constants";
import type { HeaderComponentProps } from "../Table";
import type SimpleBar from "simplebar-react";
import InfiniteScrollBody from "./InifiniteScrollBody";
import { EmptyRows, Row } from "./Row";
import { FixedVirtualList } from "./VirtualList";
export type BodyContextType = {
accentColor: string;
@ -49,26 +49,6 @@ export const BodyContext = React.createContext<BodyContextType>({
totalColumnsWidth: 0,
});
const rowRenderer = React.memo((rowProps: ListChildComponentProps) => {
const { data, index, style } = rowProps;
if (index < data.length) {
const row = data[index];
return (
<Row
className="t--virtual-row"
index={index}
key={index}
row={row}
style={style}
/>
);
} else {
return <EmptyRow style={style} />;
}
}, areEqual);
interface BodyPropsType {
getTableBodyProps(
propGetter?: TableBodyPropGetter<Record<string, unknown>> | undefined,
@ -80,28 +60,22 @@ interface BodyPropsType {
tableSizes: TableSizes;
innerElementType?: ReactElementType;
isInfiniteScrollEnabled?: boolean;
isLoading: boolean;
loadMoreFromEvaluations: () => void;
}
const TableVirtualBodyComponent = React.forwardRef(
(props: BodyPropsType, ref: Ref<SimpleBar>) => {
return (
<div className="simplebar-content-wrapper">
<FixedSizeList
className="virtual-list simplebar-content"
height={
props.height -
props.tableSizes.TABLE_HEADER_HEIGHT -
2 * props.tableSizes.VERTICAL_PADDING
}
<FixedVirtualList
height={props.height}
innerElementType={props.innerElementType}
itemCount={Math.max(props.rows.length, props.pageSize)}
itemData={props.rows}
itemSize={props.tableSizes.ROW_HEIGHT}
outerRef={ref}
width={`calc(100% + ${2 * WIDGET_PADDING}px)`}
>
{rowRenderer}
</FixedSizeList>
pageSize={props.pageSize}
rows={props.rows}
tableSizes={props.tableSizes}
/>
</div>
);
},
@ -191,7 +165,12 @@ export const TableBody = React.forwardRef(
}}
>
{isInfiniteScrollEnabled ? (
<div>Infinite Scroll</div>
<InfiniteScrollBody
itemCount={rows.length}
ref={ref}
rows={rows}
{...restOfProps}
/>
) : useVirtual ? (
<TableVirtualBodyComponent
isInfiniteScrollEnabled={false}

View File

@ -38,6 +38,8 @@ type VirtualTableProps = TableColumnHeaderProps & {
scrollContainerStyles: any;
useVirtual: boolean;
isInfiniteScrollEnabled: boolean;
isLoading: boolean;
loadMoreFromEvaluations: () => void;
};
const VirtualTable = (props: VirtualTableProps, ref: React.Ref<SimpleBar>) => {
@ -61,8 +63,10 @@ const VirtualTable = (props: VirtualTableProps, ref: React.Ref<SimpleBar>) => {
innerElementType={VirtualTableInnerElement}
isAddRowInProgress={props.isAddRowInProgress}
isInfiniteScrollEnabled={props.isInfiniteScrollEnabled}
isLoading={props.isLoading}
isResizingColumn={props.isResizingColumn}
isSortable={props.isSortable}
loadMoreFromEvaluations={props.loadMoreFromEvaluations}
multiRowSelection={!!props.multiRowSelection}
pageSize={props.pageSize}
prepareRow={props.prepareRow}

View File

@ -11192,12 +11192,22 @@ __metadata:
languageName: node
linkType: hard
"@types/react-window@npm:^1.8.2":
version: 1.8.2
resolution: "@types/react-window@npm:1.8.2"
"@types/react-window-infinite-loader@npm:^1.0.9":
version: 1.0.9
resolution: "@types/react-window-infinite-loader@npm:1.0.9"
dependencies:
"@types/react": "*"
checksum: c127ed420d881510fe647539342e7c494802aab12fd6cb61f9f8ba47ef16d3683e632b7a6a07eb0d284ea8f0953ae7941eafa2c51c0bcb3b176d009eac09c79a
"@types/react-window": "*"
checksum: 9f2c27f24bfa726ceaef6612a4adbda745f3455c877193f68dfa48591274c670a6df4fa6870785cff5f948e289ceb9a247fb7cbf67e3cd555ab16d11866fd63f
languageName: node
linkType: hard
"@types/react-window@npm:*, @types/react-window@npm:^1.8.2":
version: 1.8.8
resolution: "@types/react-window@npm:1.8.8"
dependencies:
"@types/react": "*"
checksum: 253c9d6e0c942f34633edbddcbc369324403c42458ff004457c5bd5972961d8433a909c0cc1a89c918063d5eb85ecbdd774142af2555fae61f4ceb3ba9884b5a
languageName: node
linkType: hard
@ -13003,6 +13013,7 @@ __metadata:
"@types/react-tabs": ^2.3.1
"@types/react-test-renderer": ^17.0.1
"@types/react-window": ^1.8.2
"@types/react-window-infinite-loader": ^1.0.9
"@types/redux-form": ^8.1.9
"@types/redux-mock-store": ^1.0.2
"@types/shallowequal": ^1.1.5
@ -13206,6 +13217,7 @@ __metadata:
react-virtuoso: ^4.5.0
react-webcam: ^7.0.1
react-window: ^1.8.6
react-window-infinite-loader: ^1.0.10
react-zoom-pan-pinch: ^1.6.1
redux: ^4.0.1
redux-devtools-extension: ^2.13.8
@ -29019,6 +29031,16 @@ __metadata:
languageName: node
linkType: hard
"react-window-infinite-loader@npm:^1.0.10":
version: 1.0.10
resolution: "react-window-infinite-loader@npm:1.0.10"
peerDependencies:
react: ^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0
checksum: 3ee79ce325e45a7d4d9f92c13e7ff4c523578fa454de3a440980b286d964eb951095c012a7f43ca75e9d86ed2b052c81b08134dfa8827144f44b059cc56514c3
languageName: node
linkType: hard
"react-window@npm:^1.8.6":
version: 1.8.8
resolution: "react-window@npm:1.8.8"