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();
});
});