diff --git a/changelogs/fragments/8060.yml b/changelogs/fragments/8060.yml
new file mode 100644
index 000000000000..705e428a42a1
--- /dev/null
+++ b/changelogs/fragments/8060.yml
@@ -0,0 +1,2 @@
+fix:
+- Fix row rendering in Discover infinite scroll ([#8060](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/8060))
\ No newline at end of file
diff --git a/src/plugins/discover/public/application/components/default_discover_table/default_discover_table.test.tsx b/src/plugins/discover/public/application/components/default_discover_table/default_discover_table.test.tsx
new file mode 100644
index 000000000000..2cff704bbf67
--- /dev/null
+++ b/src/plugins/discover/public/application/components/default_discover_table/default_discover_table.test.tsx
@@ -0,0 +1,158 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import React from 'react';
+import { IntlProvider } from 'react-intl';
+import { act, render, waitFor, screen } from '@testing-library/react';
+import { DefaultDiscoverTable } from './default_discover_table';
+import { OpenSearchSearchHit } from '../../doc_views/doc_views_types';
+import { coreMock } from '../../../../../../core/public/mocks';
+import { getStubIndexPattern } from '../../../../../data/public/test_utils';
+
+jest.mock('../../../opensearch_dashboards_services', () => ({
+ getServices: jest.fn().mockReturnValue({
+ uiSettings: {
+ get: jest.fn().mockImplementation((key) => {
+ switch (key) {
+ case 'discover:sampleSize':
+ return 100;
+ case 'shortDots:enable':
+ return true;
+ case 'doc_table:hideTimeColumn':
+ return false;
+ case 'discover:sort:defaultOrder':
+ return 'desc';
+ default:
+ return null;
+ }
+ }),
+ },
+ }),
+}));
+
+describe('DefaultDiscoverTable', () => {
+ const indexPattern = getStubIndexPattern(
+ 'test-index-pattern',
+ (cfg) => cfg,
+ '@timestamp',
+ [
+ { name: 'textField', type: 'text' },
+ { name: 'longField', type: 'long' },
+ { name: '@timestamp', type: 'date' },
+ ],
+ coreMock.createSetup()
+ );
+
+ // Generate 50 hits with sample fields
+ const hits = [...Array(100).keys()].map((key) => {
+ return {
+ _id: key.toString(),
+ fields: {
+ textField: `value${key}`,
+ longField: key,
+ '@timestamp': new Date((1720000000 + key) * 1000),
+ },
+ };
+ });
+
+ const getDefaultDiscoverTable = (hitsOverride?: OpenSearchSearchHit[]) => (
+
+
+
+ );
+
+ let intersectionObserverCallback: (entries: IntersectionObserverEntry[]) => void = (_) => {};
+ const mockIntersectionObserver = jest.fn();
+
+ beforeEach(() => {
+ mockIntersectionObserver.mockImplementation((...args) => {
+ intersectionObserverCallback = args[0];
+ return {
+ observe: () => null,
+ unobserve: () => null,
+ disconnect: () => null,
+ };
+ });
+ window.IntersectionObserver = mockIntersectionObserver;
+ });
+
+ it('should render the correct number of rows initially', () => {
+ const { container } = render(getDefaultDiscoverTable());
+
+ const tableRows = container.querySelectorAll('tbody tr');
+ expect(tableRows.length).toBe(10);
+ });
+
+ it('should load more rows when scrolling to the bottom', async () => {
+ const { container } = render(getDefaultDiscoverTable());
+
+ const sentinel = container.querySelector('div[data-test-subj="discoverRenderedRowsProgress"]');
+ const mockScrollEntry = { isIntersecting: true, target: sentinel };
+ act(() => {
+ intersectionObserverCallback([mockScrollEntry] as IntersectionObserverEntry[]);
+ });
+
+ await waitFor(() => {
+ const tableRows = container.querySelectorAll('tbody tr');
+ expect(tableRows.length).toBe(20);
+ });
+ });
+
+ it('should display the sample size callout when all rows are rendered', async () => {
+ const { container } = render(getDefaultDiscoverTable());
+
+ let sentinel = container.querySelector('div[data-test-subj="discoverRenderedRowsProgress"]');
+
+ // Simulate scrolling to the bottom until all rows are rendered
+ while (sentinel) {
+ const mockScrollEntry = { isIntersecting: true, target: sentinel };
+ act(() => {
+ intersectionObserverCallback([mockScrollEntry] as IntersectionObserverEntry[]);
+ });
+ sentinel = container.querySelector('div[data-test-subj="discoverRenderedRowsProgress"]');
+ }
+
+ await waitFor(() => {
+ const callout = screen.getByTestId('discoverDocTableFooter');
+ expect(callout).toBeInTheDocument();
+ });
+ });
+
+ it('Should restart rendering when new data is available', async () => {
+ const truncHits = hits.slice(0, 35) as OpenSearchSearchHit[];
+ const { container, rerender } = render(getDefaultDiscoverTable(truncHits));
+
+ let sentinel = container.querySelector('div[data-test-subj="discoverRenderedRowsProgress"]');
+
+ // Keep scrolling until all the current rows are exhausted
+ while (sentinel) {
+ const mockScrollEntry = { isIntersecting: true, target: sentinel };
+ act(() => {
+ intersectionObserverCallback([mockScrollEntry] as IntersectionObserverEntry[]);
+ });
+ sentinel = container.querySelector('div[data-test-subj="discoverRenderedRowsProgress"]');
+ }
+
+ // Make the other rows available
+ rerender(getDefaultDiscoverTable(hits as OpenSearchSearchHit[]));
+
+ await waitFor(() => {
+ const progressSentinel = container.querySelector(
+ 'div[data-test-subj="discoverRenderedRowsProgress"]'
+ );
+ expect(progressSentinel).toBeInTheDocument();
+ });
+ });
+});
diff --git a/src/plugins/discover/public/application/components/default_discover_table/default_discover_table.tsx b/src/plugins/discover/public/application/components/default_discover_table/default_discover_table.tsx
index ae7e9632fbd7..1e92858157bc 100644
--- a/src/plugins/discover/public/application/components/default_discover_table/default_discover_table.tsx
+++ b/src/plugins/discover/public/application/components/default_discover_table/default_discover_table.tsx
@@ -41,6 +41,8 @@ export interface DefaultDiscoverTableProps {
// ToDo: These would need to be read from an upcoming config panel
const PAGINATED_PAGE_SIZE = 50;
const INFINITE_SCROLLED_PAGE_SIZE = 10;
+// How far to queue unrendered rows ahead of time during infinite scrolling
+const DESIRED_ROWS_LOOKAHEAD = 5 * INFINITE_SCROLLED_PAGE_SIZE;
const DefaultDiscoverTableUI = ({
columns,
@@ -86,7 +88,7 @@ const DefaultDiscoverTableUI = ({
*/
const [renderedRowCount, setRenderedRowCount] = useState(INFINITE_SCROLLED_PAGE_SIZE);
const [desiredRowCount, setDesiredRowCount] = useState(
- Math.min(rows.length, 5 * INFINITE_SCROLLED_PAGE_SIZE)
+ Math.min(rows.length, DESIRED_ROWS_LOOKAHEAD)
);
const [displayedRows, setDisplayedRows] = useState(rows.slice(0, PAGINATED_PAGE_SIZE));
const [currentRowCounts, setCurrentRowCounts] = useState({
@@ -118,10 +120,14 @@ const DefaultDiscoverTableUI = ({
if (entries[0].isIntersecting) {
// Load another batch of rows, some immediately and some lazily
setRenderedRowCount((prevRowCount) => prevRowCount + INFINITE_SCROLLED_PAGE_SIZE);
- setDesiredRowCount((prevRowCount) => prevRowCount + 5 * INFINITE_SCROLLED_PAGE_SIZE);
+ setDesiredRowCount((prevRowCount) => prevRowCount + DESIRED_ROWS_LOOKAHEAD);
}
},
- { threshold: 1.0 }
+ {
+ // Important that 0 < threshold < 1, since there OSD application div has a transparent
+ // fade at the bottom which causes the sentinel element to sometimes not be 100% visible
+ threshold: 0.1,
+ }
);
observerRef.current.observe(sentinelElement);
@@ -155,6 +161,10 @@ const DefaultDiscoverTableUI = ({
const lazyLoadRequestFrameRef = useRef(0);
const lazyLoadLastTimeRef = useRef(0);
+ // When doing infinite scrolling, the `rows` prop gets regularly updated from the outside: we only
+ // render the additional rows when we know the load isn't too high. To prevent overloading the
+ // renderer, we throttle by current framerate and only render if the frames are fast enough, then
+ // we increase the rendered row count and trigger a re-render.
React.useEffect(() => {
if (!showPagination) {
const loadMoreRows = (time: number) => {
@@ -254,7 +264,16 @@ const DefaultDiscoverTableUI = ({
{!showPagination && renderedRowCount < rows.length && (
-
+
)}
{!showPagination && rows.length === sampleSize && (