You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ma...@apache.org on 2026/04/08 20:58:46 UTC

(superset) 01/01: perf(explore): replace TableView with GridTable in SamplesPane for virtualized rendering

This is an automated email from the ASF dual-hosted git repository.

maximebeauchemin pushed a commit to branch fix-explore-samples-perf
in repository https://gitbox.apache.org/repos/asf/superset.git

commit 6c718004365f443d1f96ba4b4e25a1cdaadbcb11
Author: Maxime Beauchemin <ma...@gmail.com>
AuthorDate: Wed Apr 8 20:58:24 2026 +0000

    perf(explore): replace TableView with GridTable in SamplesPane for virtualized rendering
    
    The Samples tab in Explore froze the browser for ~30s on datasets with many
    columns because TableView (react-table) renders all columns in the DOM without
    virtualization. Switch to GridTable (ag-grid) which provides both row and column
    virtualization out of the box, eliminating the freeze.
    
    Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
---
 .../DataTablesPane/components/SamplesPane.tsx      | 134 +++++++++++++++------
 .../DataTablesPane/test/SamplesPane.test.tsx       |  21 ++--
 2 files changed, 108 insertions(+), 47 deletions(-)

diff --git a/superset-frontend/src/explore/components/DataTablesPane/components/SamplesPane.tsx b/superset-frontend/src/explore/components/DataTablesPane/components/SamplesPane.tsx
index 1c58bd9ae69..7decf8b23cc 100644
--- a/superset-frontend/src/explore/components/DataTablesPane/components/SamplesPane.tsx
+++ b/superset-frontend/src/explore/components/DataTablesPane/components/SamplesPane.tsx
@@ -16,22 +16,23 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { useState, useEffect, useMemo, useCallback } from 'react';
+import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
 import { t } from '@apache-superset/core/translation';
-import { ensureIsArray } from '@superset-ui/core';
+import {
+  ensureIsArray,
+  getTimeFormatter,
+  safeHtmlSpan,
+  TimeFormats,
+} from '@superset-ui/core';
 import { styled } from '@apache-superset/core/theme';
 import {
-  TableView,
-  TableSize,
+  Constants,
   EmptyState,
   Loading,
-  EmptyWrapperType,
 } from '@superset-ui/core/components';
 import { GenericDataType } from '@apache-superset/core/common';
-import {
-  useFilteredTableData,
-  useTableColumns,
-} from 'src/explore/components/DataTableControl';
+import { GridTable } from 'src/components/GridTable';
+import { GridSize } from 'src/components/GridTable/constants';
 import { getDatasourceSamples } from 'src/components/Chart/chartAction';
 import { TableControls } from './DataTableControls';
 import { SamplesPaneProps } from '../types';
@@ -40,8 +41,15 @@ const Error = styled.pre`
   margin-top: ${({ theme }) => `${theme.sizeUnit * 4}px`};
 `;
 
+const GridContainer = styled.div`
+  flex: 1;
+  overflow: hidden;
+`;
+
 const cache = new WeakSet();
 
+const timeFormatter = getTimeFormatter(TimeFormats.DATABASE_DATETIME);
+
 export const SamplesPane = ({
   isRequest,
   datasource,
@@ -58,6 +66,8 @@ export const SamplesPane = ({
   const [isLoading, setIsLoading] = useState<boolean>(false);
   const [rowcount, setRowCount] = useState<number>(0);
   const [responseError, setResponseError] = useState<string>('');
+  const [gridHeight, setGridHeight] = useState(300);
+  const gridContainerRef = useRef<HTMLDivElement>(null);
   const datasourceId = useMemo(
     () => `${datasource.id}__${datasource.type}`,
     [datasource],
@@ -94,18 +104,76 @@ export const SamplesPane = ({
     }
   }, [datasource, isRequest, queryForce]);
 
-  // this is to preserve the order of the columns, even if there are integer values,
-  // while also only grabbing the first column's keys
-  const columns = useTableColumns(
-    colnames,
-    coltypes,
-    data,
-    datasourceId,
-    isVisible,
-    {}, // moreConfig
-    true, // allowHTML
+  useEffect(() => {
+    const container = gridContainerRef.current;
+    if (!container) return undefined;
+    const observer = new ResizeObserver(entries => {
+      const entry = entries[0];
+      if (entry) {
+        setGridHeight(entry.contentRect.height);
+      }
+    });
+    observer.observe(container);
+    return () => observer.disconnect();
+  }, []);
+
+  const columns = useMemo(
+    () =>
+      colnames && data?.length
+        ? colnames
+            .filter((column: string) =>
+              Object.keys(data[0]).includes(column),
+            )
+            .map((key, index) => {
+              const colType = coltypes?.[index];
+              return {
+                label: key,
+                headerName: key,
+                render: ({ value }: { value: any }) => {
+                  if (value === true) {
+                    return Constants.BOOL_TRUE_DISPLAY;
+                  }
+                  if (value === false) {
+                    return Constants.BOOL_FALSE_DISPLAY;
+                  }
+                  if (value === null) {
+                    return (
+                      <span style={{ color: 'var(--ant-color-text-tertiary)' }}>
+                        {Constants.NULL_DISPLAY}
+                      </span>
+                    );
+                  }
+                  if (
+                    colType === GenericDataType.Temporal &&
+                    typeof value === 'number'
+                  ) {
+                    return timeFormatter(value);
+                  }
+                  if (typeof value === 'string') {
+                    return safeHtmlSpan(value);
+                  }
+                  return String(value);
+                },
+              };
+            })
+        : [],
+    [colnames, data, coltypes],
+  );
+
+  const keywordFilter = useCallback(
+    (node: any) => {
+      if (filterText && node.data) {
+        const lowerFilter = filterText.toLowerCase();
+        return Object.values(node.data).some(
+          (value: any) =>
+            value != null &&
+            String(value).toLowerCase().includes(lowerFilter),
+        );
+      }
+      return true;
+    },
+    [filterText],
   );
-  const filteredData = useFilteredTableData(filterText, data);
 
   const handleInputChange = useCallback(
     (input: string) => setFilterText(input),
@@ -120,7 +188,7 @@ export const SamplesPane = ({
     return (
       <>
         <TableControls
-          data={filteredData}
+          data={data}
           columnNames={colnames}
           columnTypes={coltypes}
           rowcount={rowcount}
@@ -142,7 +210,7 @@ export const SamplesPane = ({
   return (
     <>
       <TableControls
-        data={filteredData}
+        data={data}
         columnNames={colnames}
         columnTypes={coltypes}
         rowcount={rowcount}
@@ -151,18 +219,16 @@ export const SamplesPane = ({
         isLoading={isLoading}
         canDownload={canDownload}
       />
-      <TableView
-        columns={columns}
-        data={filteredData}
-        pageSize={dataSize}
-        noDataText={t('No results')}
-        emptyWrapperType={EmptyWrapperType.Small}
-        className="table-condensed"
-        isPaginationSticky
-        showRowCount={false}
-        size={TableSize.Small}
-        small
-      />
+      <GridContainer ref={gridContainerRef}>
+        <GridTable
+          data={data}
+          columns={columns}
+          height={gridHeight}
+          size={GridSize.Small}
+          externalFilter={keywordFilter}
+          showRowNumber
+        />
+      </GridContainer>
     </>
   );
 };
diff --git a/superset-frontend/src/explore/components/DataTablesPane/test/SamplesPane.test.tsx b/superset-frontend/src/explore/components/DataTablesPane/test/SamplesPane.test.tsx
index d5da57b6d3b..f3b7e036b95 100644
--- a/superset-frontend/src/explore/components/DataTablesPane/test/SamplesPane.test.tsx
+++ b/superset-frontend/src/explore/components/DataTablesPane/test/SamplesPane.test.tsx
@@ -17,15 +17,15 @@
  * under the License.
  */
 import fetchMock from 'fetch-mock';
-import {
-  render,
-  userEvent,
-  waitForElementToBeRemoved,
-  waitFor,
-} from 'spec/helpers/testing-library';
+import { render, waitFor } from 'spec/helpers/testing-library';
+import { setupAGGridModules } from '@superset-ui/core/components/ThemedAgGridReact';
 import { SamplesPane } from '../components';
 import { createSamplesPaneProps } from './fixture';
 
+beforeAll(() => {
+  setupAGGridModules();
+});
+
 // eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
 describe('SamplesPane', () => {
   fetchMock.post(
@@ -91,12 +91,12 @@ describe('SamplesPane', () => {
     expect(await findByText('Error: Bad request')).toBeVisible();
   });
 
-  test('force query, render and search', async () => {
+  test('force query, render', async () => {
     const props = createSamplesPaneProps({
       datasourceId: 35,
       queryForce: true,
     });
-    const { queryByText, getByPlaceholderText } = render(
+    const { queryByText } = render(
       <SamplesPane {...props} setForceQuery={setForceQuery} />,
       {
         useRedux: true,
@@ -109,10 +109,5 @@ describe('SamplesPane', () => {
     expect(queryByText('2 rows')).toBeVisible();
     expect(queryByText('Action')).toBeVisible();
     expect(queryByText('Horror')).toBeVisible();
-
-    userEvent.type(getByPlaceholderText('Search'), 'hor');
-    await waitForElementToBeRemoved(() => queryByText('Action'));
-    expect(queryByText('Horror')).toBeVisible();
-    expect(queryByText('Action')).not.toBeInTheDocument();
   });
 });