chore(deps): bump react-window and @types/react-window in /superset-frontend (#42528)

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
dependabot[bot]
2026-08-07 23:02:27 -07:00
committed by GitHub
co-authored by dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Amin Ghadersohi Claude Sonnet 5
parent 2a9c5acb01
commit 38727906a2
16 changed files with 516 additions and 311 deletions
+29 -40
View File
@@ -139,7 +139,7 @@
"react-table": "^7.8.0",
"react-transition-group": "^4.4.5",
"react-virtualized-auto-sizer": "^1.0.26",
"react-window": "^1.8.10",
"react-window": "^2.3.0",
"redux": "^4.2.1",
"redux-localstorage": "^0.4.1",
"redux-thunk": "^2.1.0",
@@ -207,7 +207,6 @@
"@types/react-redux": "^7.1.10",
"@types/react-router-dom": "^5.3.3",
"@types/react-transition-group": "^4.4.12",
"@types/react-window": "^1.8.8",
"@types/redux-localstorage": "^1.0.8",
"@types/redux-mock-store": "^1.0.6",
"@types/rison": "0.1.0",
@@ -12856,15 +12855,6 @@
"@types/react": "*"
}
},
"node_modules/@types/react-window": {
"version": "1.8.8",
"resolved": "https://registry.npmjs.org/@types/react-window/-/react-window-1.8.8.tgz",
"integrity": "sha512-8Ls660bHR1AUA2kuRvVG9D/4XpRC6wjAaPT9dil7Ckc76eP9TKWZwwmgfq8Q1LANX3QNDnoU4Zp48A3w+zK69Q==",
"license": "MIT",
"dependencies": {
"@types/react": "*"
}
},
"node_modules/@types/redux-localstorage": {
"version": "1.0.12",
"resolved": "https://registry.npmjs.org/@types/redux-localstorage/-/redux-localstorage-1.0.12.tgz",
@@ -34473,6 +34463,12 @@
"@babel/runtime": "^7.9.2"
}
},
"node_modules/react-arborist/node_modules/memoize-one": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==",
"license": "MIT"
},
"node_modules/react-arborist/node_modules/react-dnd": {
"version": "14.0.5",
"resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-14.0.5.tgz",
@@ -34512,6 +34508,23 @@
"dnd-core": "14.0.1"
}
},
"node_modules/react-arborist/node_modules/react-window": {
"version": "1.8.11",
"resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.11.tgz",
"integrity": "sha512-+SRbUVT2scadgFSWx+R1P754xHPEqvcfSfVX10QYg6POOz+WNgkN48pS+BtZNIMGiL1HYrSEiCkwsMS15QogEQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.0.0",
"memoize-one": ">=3.1.1 <6"
},
"engines": {
"node": ">8.0.0"
},
"peerDependencies": {
"react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-arborist/node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
@@ -35200,28 +35213,15 @@
}
},
"node_modules/react-window": {
"version": "1.8.11",
"resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.11.tgz",
"integrity": "sha512-+SRbUVT2scadgFSWx+R1P754xHPEqvcfSfVX10QYg6POOz+WNgkN48pS+BtZNIMGiL1HYrSEiCkwsMS15QogEQ==",
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/react-window/-/react-window-2.3.0.tgz",
"integrity": "sha512-FW6TIpaOH646k51X7yE+LSCWGkt5Pfsnc1fVyq/sCI9h0pTqmMiBXM04pzFKg3Bt7NGkeV6kqbU8d/QjmFS7Ug==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.0.0",
"memoize-one": ">=3.1.1 <6"
},
"engines": {
"node": ">8.0.0"
},
"peerDependencies": {
"react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
}
},
"node_modules/react-window/node_modules/memoize-one": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==",
"license": "MIT"
},
"node_modules/read": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/read/-/read-4.1.0.tgz",
@@ -42790,7 +42790,6 @@
"@types/lodash": "^4.17.24",
"@types/react": "*",
"@types/react-loadable": "*",
"@types/react-window": "^1.8.8",
"@types/tinycolor2": "*",
"typescript": "^5.0.0"
},
@@ -42916,7 +42915,6 @@
"@testing-library/user-event": "*",
"@types/react": "*",
"@types/react-loadable": "*",
"@types/react-window": "^1.8.8",
"@types/tinycolor2": "*",
"antd": "^6.0.0",
"nanoid": "*",
@@ -42972,15 +42970,6 @@
"node": ">=12"
}
},
"packages/superset-ui-core/node_modules/dompurify": {
"version": "3.4.13",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz",
"integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"packages/superset-ui-core/node_modules/react-ace": {
"version": "14.0.1",
"resolved": "https://registry.npmjs.org/react-ace/-/react-ace-14.0.1.tgz",
+1 -2
View File
@@ -216,7 +216,7 @@
"react-table": "^7.8.0",
"react-transition-group": "^4.4.5",
"react-virtualized-auto-sizer": "^1.0.26",
"react-window": "^1.8.10",
"react-window": "^2.3.0",
"redux": "^4.2.1",
"redux-localstorage": "^0.4.1",
"redux-thunk": "^2.1.0",
@@ -284,7 +284,6 @@
"@types/react-redux": "^7.1.10",
"@types/react-router-dom": "^5.3.3",
"@types/react-transition-group": "^4.4.12",
"@types/react-window": "^1.8.8",
"@types/redux-localstorage": "^1.0.8",
"@types/redux-mock-store": "^1.0.6",
"@types/rison": "0.1.0",
@@ -109,7 +109,6 @@
"@types/lodash": "^4.17.24",
"@types/react": "*",
"@types/react-loadable": "*",
"@types/react-window": "^1.8.8",
"@types/tinycolor2": "*",
"typescript": "^5.0.0"
},
@@ -123,7 +123,6 @@
"@testing-library/user-event": "*",
"@types/react": "*",
"@types/react-loadable": "*",
"@types/react-window": "^1.8.8",
"@types/tinycolor2": "*",
"antd": "^6.0.0",
"nanoid": "*",
@@ -0,0 +1,122 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen, waitFor } from '@superset-ui/core/spec';
import type { ColumnsType } from 'antd/es/table';
import { Table } from './index';
// These tests exercise VirtualTable's react-window v2 `Grid` wiring
// (`cellComponent`/`cellProps`/`gridRef`), which previously had no direct
// coverage - `Table.test.tsx` only exercises the non-virtualized code path.
interface BasicData {
columnName: string;
columnType: string;
}
const testData: BasicData[] = [
{ columnName: 'Number', columnType: 'Numerical' },
{ columnName: 'String', columnType: 'Physical' },
{ columnName: 'Date', columnType: 'Virtual' },
];
const testColumns: ColumnsType<BasicData> = [
{
title: 'Column Name',
dataIndex: 'columnName',
key: 'columnName',
width: 150,
},
{
title: 'Column Type',
dataIndex: 'columnType',
key: 'columnType',
width: 150,
},
];
test('virtualized table renders headers and row content through the react-window Grid', async () => {
render(
<Table
columns={testColumns}
data={testData}
virtualize
height={200}
usePagination={false}
/>,
);
await waitFor(() =>
testColumns.forEach(column =>
expect(
screen
.getAllByText(column.title as string)
.find(el => el.closest('th')),
).toBeInTheDocument(),
),
);
testData.forEach(row => {
expect(screen.getByText(row.columnName)).toBeInTheDocument();
});
});
test('virtualized table cells keep the DOM hooks other code (cypress, downloadAsImage) relies on', async () => {
const { container } = render(
<Table
columns={testColumns}
data={testData}
virtualize
height={200}
usePagination={false}
/>,
);
await waitFor(() => {
expect(container.querySelector('.virtual-grid')).toBeInTheDocument();
expect(
container.querySelectorAll('.virtual-table-cell').length,
).toBeGreaterThan(0);
});
});
test('cell render functions receive their row data via cellProps rather than a stale closure', async () => {
const columnsWithRender: ColumnsType<BasicData> = [
{
title: 'Column Name',
dataIndex: 'columnName',
key: 'columnName',
width: 150,
render: (value: string) => `rendered:${value}`,
},
];
render(
<Table
columns={columnsWithRender}
data={testData}
virtualize
height={200}
usePagination={false}
/>,
);
await waitFor(() => {
expect(screen.getByText('rendered:Number')).toBeInTheDocument();
});
});
@@ -24,10 +24,14 @@ import {
} from 'antd/es/table';
import classNames from 'classnames';
import { useResizeDetector } from 'react-resize-detector';
import { useEffect, useRef, useState, useCallback, CSSProperties } from 'react';
import { VariableSizeGrid as Grid } from 'react-window';
import { useRef, useState, useCallback, type UIEvent } from 'react';
import {
Grid,
type CellComponentProps,
type GridImperativeAPI,
} from 'react-window';
import { safeHtmlSpan } from '@superset-ui/core';
import { useTheme, styled } from '@apache-superset/core/theme';
import { useTheme, styled, SupersetTheme } from '@apache-superset/core/theme';
import { TableSize, ETableAction } from './index';
@@ -71,6 +75,59 @@ const StyledTable = styled(AntTable)(
const SMALL = 39;
const MIDDLE = 47;
interface VirtualGridCellProps {
mergedColumns: AntTableProps<any>['columns'];
rawData: readonly object[];
cellSize: number;
allowHTML: boolean;
theme: SupersetTheme;
}
// Rendered via `cellComponent`, so it must be a stable reference (module scope)
// rather than defined inline on every render of the enclosing table -
// otherwise react-window would treat it as a new component type each render
// and remount every cell. All the data it needs is threaded through
// `cellProps` instead of being closed over.
const VirtualGridCell = ({
columnIndex,
rowIndex,
style,
mergedColumns,
rawData,
cellSize,
allowHTML,
theme,
}: CellComponentProps<VirtualGridCellProps>) => {
const data: any = rawData?.[rowIndex];
// Set default content
let content = data?.[(mergedColumns as any)?.[columnIndex]?.dataIndex];
// Check if the column has a render function
const render = mergedColumns?.[columnIndex]?.render;
if (typeof render === 'function') {
// Use render function to generate formatted content using column's render function
content = render(content, data, rowIndex);
}
if (allowHTML && typeof content === 'string') {
content = safeHtmlSpan(content);
}
return (
<StyledCell
className={classNames('virtual-table-cell', {
'virtual-table-cell-last':
columnIndex === (mergedColumns?.length ?? 0) - 1,
})}
style={style}
title={typeof content === 'string' ? content : undefined}
theme={theme}
height={cellSize}
>
{content}
</StyledCell>
);
};
const VirtualTable = <RecordType extends object>(
props: VirtualTableProps<RecordType>,
) => {
@@ -126,19 +183,15 @@ const VirtualTable = <RecordType extends object>(
(lastColumn.width as number) + Math.floor(tableWidth - totalWidth);
}
const gridRef = useRef<any>();
const gridRef = useRef<GridImperativeAPI>(null);
const [connectObject] = useState<any>(() => {
const obj = {};
Object.defineProperty(obj, 'scrollLeft', {
get: () => {
if (gridRef.current) {
return gridRef.current?.state?.scrollLeft;
}
return 0;
},
get: () => gridRef.current?.element?.scrollLeft ?? 0,
set: (scrollLeft: number) => {
if (gridRef.current) {
gridRef.current.scrollTo({ scrollLeft });
const element = gridRef.current?.element;
if (element) {
element.scrollLeft = scrollLeft;
}
},
});
@@ -146,14 +199,11 @@ const VirtualTable = <RecordType extends object>(
return obj;
});
const resetVirtualGrid = () => {
gridRef.current?.resetAfterIndices({
columnIndex: 0,
shouldForceUpdate: true,
});
};
useEffect(() => resetVirtualGrid, [tableWidth, columns, size]);
// No manual cache-reset is needed here (react-window v2 has no
// `resetAfterIndices`-style API): `columnWidth` below is a fresh inline
// closure over `mergedColumns` on every render, so react-window's internal
// size cache - which is invalidated whenever the `columnWidth`/`rowHeight`
// function reference changes - recomputes automatically.
/*
* antd Table has a runtime error when it tries to fire the onChange event triggered from a pageChange
@@ -166,7 +216,10 @@ const VirtualTable = <RecordType extends object>(
* We intentionally leave horizontal scroll where it was so user can focus on
* specific range of columns as they page through data
*/
gridRef.current?.scrollTo?.({ scrollTop: 0 });
const element = gridRef.current?.element;
if (element) {
element.scrollTop = 0;
}
onChange?.(
{
@@ -192,61 +245,31 @@ const VirtualTable = <RecordType extends object>(
const cellSize = size === TableSize.Middle ? MIDDLE : SMALL;
return (
<Grid
ref={gridRef}
gridRef={gridRef}
className="virtual-grid"
columnCount={mergedColumns.length}
columnWidth={(index: number) => {
const { width = DEFAULT_COL_WIDTH } = mergedColumns[index];
return width as number;
}}
height={height || (scroll!.y as number)}
rowCount={rawData.length}
rowHeight={() => cellSize}
width={tableWidth}
onScroll={({ scrollLeft }: { scrollLeft: number }) => {
onScroll({ scrollLeft });
style={{
height: height || (scroll!.y as number),
width: tableWidth,
}}
>
{({
columnIndex,
rowIndex,
style,
}: {
columnIndex: number;
rowIndex: number;
style: CSSProperties;
}) => {
const data: any = rawData?.[rowIndex];
// Set default content
let content =
data?.[(mergedColumns as any)?.[columnIndex]?.dataIndex];
// Check if the column has a render function
const render = mergedColumns[columnIndex]?.render;
if (typeof render === 'function') {
// Use render function to generate formatted content using column's render function
content = render(content, data, rowIndex);
}
if (allowHTML && typeof content === 'string') {
content = safeHtmlSpan(content);
}
return (
<StyledCell
className={classNames('virtual-table-cell', {
'virtual-table-cell-last':
columnIndex === mergedColumns.length - 1,
})}
style={style}
title={typeof content === 'string' ? content : undefined}
theme={theme}
height={cellSize}
>
{content}
</StyledCell>
);
cellComponent={VirtualGridCell}
cellProps={{
mergedColumns,
rawData,
cellSize,
allowHTML,
theme,
}}
</Grid>
onScroll={(event: UIEvent<HTMLDivElement>) => {
onScroll({ scrollLeft: event.currentTarget.scrollLeft });
}}
/>
);
};
@@ -18,7 +18,6 @@
*/
import {
CSSProperties,
ReactNode,
useCallback,
useEffect,
@@ -44,7 +43,7 @@ import {
Icons,
} from '@superset-ui/core/components';
import { debounce } from 'lodash-es';
import { FixedSizeList as List } from 'react-window';
import { List, type RowComponentProps } from 'react-window';
import { InputRef } from 'antd';
import { MenuItemTooltip } from '../DisabledMenuItemTooltip';
import { VirtualizedMenuItem } from '../MenuItemWithTruncation';
@@ -53,6 +52,34 @@ import { Dataset } from '../types';
const SUBMENU_HEIGHT = 200;
const SHOW_COLUMNS_SEARCH_THRESHOLD = 10;
interface DrillByColumnRowProps {
columns: Column[];
onSelectColumn: (event: React.MouseEvent, column: Column) => void;
}
// Rendered via `rowComponent`, so it must be a stable reference (module
// scope) rather than defined inline on every render of the submenu -
// otherwise react-window would treat it as a new component type each
// render and remount every row. All the data it needs is threaded
// through `rowProps` instead of being closed over.
function DrillByColumnRow({
index,
style,
columns,
onSelectColumn,
}: RowComponentProps<DrillByColumnRowProps>) {
const column = columns[index];
return (
<VirtualizedMenuItem
tooltipText={column.verbose_name || column.column_name}
onClick={e => onSelectColumn(e, column)}
style={style}
>
{column.verbose_name || column.column_name}
</VirtualizedMenuItem>
);
}
export interface DrillBySubmenuProps {
drillByConfig?: ContextMenuFilters['drillBy'];
formData: BaseFormData & { [key: string]: any };
@@ -190,26 +217,9 @@ export const DrillBySubmenu = ({
const isDisabled = !handlesDimensionContextMenu || !hasDrillBy;
const Row = ({
index,
data,
style,
}: {
index: number;
data: { columns: Column[] };
style: CSSProperties;
}) => {
const { columns } = data;
const column = columns[index];
return (
<VirtualizedMenuItem
tooltipText={column.verbose_name || column.column_name}
onClick={e => handleSelection(e, column)}
style={style}
>
{column.verbose_name || column.column_name}
</VirtualizedMenuItem>
);
const listRowProps: DrillByColumnRowProps = {
columns: filteredColumns,
onSelectColumn: handleSelection,
};
const popoverContent = (
@@ -260,15 +270,13 @@ export const DrillBySubmenu = ({
</div>
) : filteredColumns.length ? (
<List
width="100%"
height={SUBMENU_HEIGHT}
itemSize={35}
itemCount={filteredColumns.length}
itemData={{ columns: filteredColumns }}
style={{ width: '100%', height: SUBMENU_HEIGHT }}
rowHeight={35}
rowCount={filteredColumns.length}
rowProps={listRowProps}
rowComponent={DrillByColumnRow}
overscanCount={20}
>
{Row}
</List>
/>
) : (
<div
css={css`
@@ -45,28 +45,26 @@ jest.mock(
children({ height: 500, width: 400 }),
);
// Mock react-window VariableSizeList to render all items for testing
// Mock react-window List to render all items for testing
jest.mock('react-window', () => ({
VariableSizeList: ({
children: Row,
itemCount,
itemData,
List: ({
rowComponent: Row,
rowCount,
rowProps,
}: {
children: React.ComponentType<{
index: number;
style: React.CSSProperties;
data: unknown;
}>;
itemCount: number;
itemData: unknown;
rowComponent: React.ComponentType<
{ index: number; style: React.CSSProperties } & Record<string, unknown>
>;
rowCount: number;
rowProps: Record<string, unknown>;
}) => (
<div data-testid="virtualized-list">
{Array.from({ length: itemCount }, (_, index) => (
{Array.from({ length: rowCount }, (_, index) => (
<Row
key={index}
index={index}
style={{ height: 'auto', position: 'relative' }}
data={itemData}
{...rowProps}
/>
))}
</div>
@@ -18,7 +18,7 @@
*/
import { CSSProperties, memo } from 'react';
import type { ListChildComponentProps } from 'react-window';
import type { RowComponentProps } from 'react-window';
import { useDroppable } from '@dnd-kit/core';
import type { UniqueIdentifier } from '@dnd-kit/core';
import type { Metric, ColumnMeta } from '@superset-ui/chart-controls';
@@ -135,32 +135,34 @@ const TreeItemWrapper = memo(function TreeItemWrapper({
);
});
function VirtualizedTreeItemComponent({
// Not wrapped in `memo()` here: react-window v2 already wraps `rowComponent`
// in its own `memo()` internally (comparing `ariaAttributes`/`style`/the
// rest of the row props), so an outer `memo()` would be redundant - and
// React 19's `memo()` typings widen the wrapped component's return type to
// `ReactNode`, which react-window v2's `rowComponent` type (expecting
// `ReactElement | null`) rejects.
export function VirtualizedTreeItem({
index,
style,
data,
}: ListChildComponentProps<VirtualizedTreeItemData>) {
const {
flattenedItems,
collapsedIds,
selectedItemIds,
editingFolderId,
folderChildCounts,
itemSeparatorInfo,
visibleItemIds,
searchTerm,
metricsMap,
columnsMap,
activeId,
draggedFolderChildIds,
forbiddenDropFolderIds,
currentDropTargetId,
onToggleCollapse,
onSelect,
onStartEdit,
onFinishEdit,
} = data;
flattenedItems,
collapsedIds,
selectedItemIds,
editingFolderId,
folderChildCounts,
itemSeparatorInfo,
visibleItemIds,
searchTerm,
metricsMap,
columnsMap,
activeId,
draggedFolderChildIds,
forbiddenDropFolderIds,
currentDropTargetId,
onToggleCollapse,
onSelect,
onStartEdit,
onFinishEdit,
}: RowComponentProps<VirtualizedTreeItemData>) {
const item = flattenedItems[index];
if (!item) {
@@ -224,5 +226,3 @@ function VirtualizedTreeItemComponent({
/>
);
}
export const VirtualizedTreeItem = memo(VirtualizedTreeItemComponent);
@@ -17,8 +17,8 @@
* under the License.
*/
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { VariableSizeList as List } from 'react-window';
import { useCallback, useMemo, useRef } from 'react';
import { List, type ListImperativeAPI } from 'react-window';
import type { UniqueIdentifier } from '@dnd-kit/core';
import type { Metric, ColumnMeta } from '@superset-ui/chart-controls';
import { FoldersEditorItemType } from '../types';
@@ -82,7 +82,7 @@ export function VirtualizedTreeList({
onStartEdit,
onFinishEdit,
}: VirtualizedTreeListProps) {
const listRef = useRef<List>(null);
const listRef = useRef<ListImperativeAPI>(null);
const containerRef = useRef<HTMLDivElement>(null);
// Custom auto-scroll during drag (replaces dnd-kit's auto-scroll which conflicts with virtualization)
@@ -93,20 +93,15 @@ export function VirtualizedTreeList({
listHeight: height,
});
// Reset list cache when items structure changes, but not during drag
// Resetting during drag causes jumping/flickering
useEffect(() => {
if (!isDragging) {
listRef.current?.resetAfterIndex(0);
}
}, [
flattenedItems,
collapsedIds,
folderChildCounts,
itemSeparatorInfo,
visibleItemIds,
isDragging,
]);
// No manual cache-reset is needed here (react-window v2 has no
// `resetAfterIndex`-style API): `getItemSize` below is a `useCallback`
// whose dependency list matches everything that can change row heights,
// so react-window's internal size cache - which is invalidated whenever
// the `rowHeight` function reference changes - recomputes automatically.
// Because `flattenedItems` is held stable during drag (see
// useDragHandlers), `getItemSize`'s reference - and therefore the cache -
// naturally stays stable during drag too, preserving the old "don't
// reset while dragging" behavior without needing an explicit gate.
// Calculate item size for react-window
const getItemSize = useCallback(
@@ -169,7 +164,7 @@ export function VirtualizedTreeList({
);
// Prepare item data for the row renderer
const itemData: VirtualizedTreeItemData = useMemo(
const rowProps: VirtualizedTreeItemData = useMemo(
() => ({
flattenedItems,
collapsedIds,
@@ -218,16 +213,14 @@ export function VirtualizedTreeList({
return (
<div ref={containerRef} style={{ width, height, position: 'relative' }}>
<List
ref={listRef}
width={width}
height={height}
itemCount={flattenedItems.length}
itemSize={getItemSize}
itemData={itemData}
listRef={listRef}
style={{ width, height }}
rowCount={flattenedItems.length}
rowHeight={getItemSize}
rowProps={rowProps}
rowComponent={VirtualizedTreeItem}
overscanCount={overscanCount}
>
{VirtualizedTreeItem}
</List>
/>
</div>
);
}
@@ -18,7 +18,7 @@
*/
import { useEffect, useRef } from 'react';
import type { VariableSizeList as List } from 'react-window';
import type { ListImperativeAPI } from 'react-window';
// Distance from edge where auto-scroll activates (in pixels)
const SCROLL_THRESHOLD = 80;
@@ -28,7 +28,7 @@ const BASE_SCROLL_SPEED = 8;
const MAX_SPEED_MULTIPLIER = 3;
interface UseAutoScrollOptions {
listRef: React.RefObject<List>;
listRef: React.RefObject<ListImperativeAPI | null>;
containerRef: React.RefObject<HTMLDivElement>;
isDragging: boolean;
listHeight: number;
@@ -110,7 +110,7 @@ export function useAutoScroll({
// Animation frame callback - uses time-based scrolling for consistent speed
const scrollFrame = (currentTime: number) => {
const list = listRef.current;
const outerElement = (list as any)?._outerRef;
const outerElement = list?.element;
if (!list || !outerElement || !state.direction) {
// Restore pointer events when scrolling stops
@@ -149,7 +149,7 @@ export function useAutoScroll({
if (Math.abs(newScroll - currentScroll) > 0.5) {
// Use direct DOM manipulation for smoother scrolling
// react-window's scrollTo triggers re-renders which can cause stutter
// react-window's scrollToRow triggers re-renders which can cause stutter
outerElement.scrollTop = newScroll;
}
@@ -25,7 +25,11 @@ import {
import { DatasourceType } from '@superset-ui/core';
import { sliceEntitiesForDashboard as mockSliceEntities } from 'spec/fixtures/mockSliceEntities';
import { configureStore } from '@reduxjs/toolkit';
import SliceAdder, { SliceAdderProps, sortByComparator } from './SliceAdder';
import SliceAdder, {
SliceAdderProps,
sortByComparator,
SliceListRow,
} from './SliceAdder';
// Mock the Select component to avoid debounce issues
jest.mock('@superset-ui/core', () => ({
@@ -236,4 +240,54 @@ describe('SliceAdder', () => {
// Verify the internal state was updated by checking if new charts are available
expect(screen.getByRole('checkbox')).toBeInTheDocument();
});
// Covers the react-window v2 `rowComponent`/`rowProps` wiring: SliceAdder
// hoists its row renderer to module scope (see SliceAdder.tsx) so
// react-window doesn't remount every row on each render. This verifies
// that hoisted row renders correctly from the flattened rowProps react-window
// passes it (index/style plus the custom row props), rather than through
// closures over component state.
test('SliceListRow renders a chart card for the slice at the given index', () => {
const filteredSlices = Object.values(mockSliceEntities.slices);
render(
<SliceListRow
index={0}
style={{}}
ariaAttributes={{
role: 'listitem',
'aria-posinset': 1,
'aria-setsize': filteredSlices.length,
}}
filteredSlices={filteredSlices}
selectedSliceIdsSet={new Set()}
editMode={false}
/>,
{ useDnd: true, store: mockStore },
);
expect(screen.getByTestId('chart-card')).toBeInTheDocument();
expect(screen.getByText(filteredSlices[0].slice_name)).toBeInTheDocument();
});
test('SliceListRow renders the slice at the row index passed via rowProps, not index 0', () => {
const filteredSlices = Object.values(mockSliceEntities.slices);
render(
<SliceListRow
index={1}
style={{}}
ariaAttributes={{
role: 'listitem',
'aria-posinset': 2,
'aria-setsize': filteredSlices.length,
}}
filteredSlices={filteredSlices}
selectedSliceIdsSet={new Set()}
editMode={false}
/>,
{ useDnd: true, store: mockStore },
);
expect(screen.getByText(filteredSlices[1].slice_name)).toBeInTheDocument();
expect(
screen.queryByText(filteredSlices[0].slice_name),
).not.toBeInTheDocument();
});
});
@@ -19,7 +19,7 @@
/* eslint-env browser */
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import AutoSizer from 'react-virtualized-auto-sizer';
import { FixedSizeList as List } from 'react-window';
import { List, type RowComponentProps } from 'react-window';
// @ts-expect-error
import { createFilter } from 'react-search-input';
import { t } from '@apache-superset/core/translation';
@@ -167,6 +167,71 @@ function getFilteredSortedSlices(
.sort(sortByComparator(sortBy));
}
export interface SliceListRowProps {
filteredSlices: Slice[];
selectedSliceIdsSet: Set<number>;
editMode: boolean;
}
// Rendered via `rowComponent`, so it must be a stable reference (module
// scope) rather than defined inline on every render of SliceAdder -
// otherwise react-window would treat it as a new component type each
// render and remount every row (losing in-flight drag state). All the
// data it needs is threaded through `rowProps` instead of being closed
// over.
export function SliceListRow({
index,
style,
filteredSlices,
selectedSliceIdsSet,
editMode,
}: RowComponentProps<SliceListRowProps>) {
const cellData = filteredSlices[index];
const isSelected = selectedSliceIdsSet.has(cellData.slice_id);
const type = CHART_TYPE;
const id = NEW_CHART_ID;
const meta = {
chartId: cellData.slice_id,
sliceName: cellData.slice_name,
};
return (
<DragDroppable
key={cellData.slice_id}
component={{ type, id, meta }}
parentComponent={{
id: NEW_COMPONENTS_SOURCE_ID,
type: NEW_COMPONENT_SOURCE_TYPE,
}}
index={index}
depth={0}
disableDragDrop={isSelected}
editMode={editMode}
// we must use a custom drag preview within the List because
// it does not seem to work within a fixed-position container
useEmptyDragPreview
// List library expect style props here
// actual style should be applied to nested AddSliceCard component
style={{}}
>
{({ dragSourceRef }: { dragSourceRef: ConnectDragSource }) => (
<AddSliceCard
innerRef={dragSourceRef}
style={style}
sliceName={cellData.slice_name}
lastModified={cellData.changed_on_humanized}
visType={cellData.viz_type}
datasourceUrl={cellData.datasource_url}
datasourceName={cellData.datasource_name}
thumbnailUrl={cellData.thumbnail_url}
isSelected={isSelected}
/>
)}
</DragDroppable>
);
}
function SliceAdder({
fetchSlices,
updateSlices,
@@ -321,53 +386,8 @@ function SliceAdder({
[fetchSlices, searchTerm, sortBy],
);
const rowRenderer = useCallback(
({ index, style }: { index: number; style: React.CSSProperties }) => {
const cellData = filteredSlices[index];
const isSelected = selectedSliceIdsSet.has(cellData.slice_id);
const type = CHART_TYPE;
const id = NEW_CHART_ID;
const meta = {
chartId: cellData.slice_id,
sliceName: cellData.slice_name,
};
return (
<DragDroppable
key={cellData.slice_id}
component={{ type, id, meta }}
parentComponent={{
id: NEW_COMPONENTS_SOURCE_ID,
type: NEW_COMPONENT_SOURCE_TYPE,
}}
index={index}
depth={0}
disableDragDrop={isSelected}
editMode={editMode}
// we must use a custom drag preview within the List because
// it does not seem to work within a fixed-position container
useEmptyDragPreview
// List library expect style props here
// actual style should be applied to nested AddSliceCard component
style={{}}
>
{({ dragSourceRef }: { dragSourceRef: ConnectDragSource }) => (
<AddSliceCard
innerRef={dragSourceRef}
style={style}
sliceName={cellData.slice_name}
lastModified={cellData.changed_on_humanized}
visType={cellData.viz_type}
datasourceUrl={cellData.datasource_url}
datasourceName={cellData.datasource_name}
thumbnailUrl={cellData.thumbnail_url}
isSelected={isSelected}
/>
)}
</DragDroppable>
);
},
const listRowProps = useMemo<SliceListRowProps>(
() => ({ filteredSlices, selectedSliceIdsSet, editMode }),
[filteredSlices, selectedSliceIdsSet, editMode],
);
@@ -448,14 +468,13 @@ function SliceAdder({
<AutoSizer>
{({ height, width }: { height: number; width: number }) => (
<List
width={width}
height={height}
itemCount={filteredSlices.length}
itemSize={DEFAULT_CELL_HEIGHT}
itemKey={index => filteredSlices[index].slice_id}
>
{rowRenderer}
</List>
style={{ width, height }}
rowCount={filteredSlices.length}
rowHeight={DEFAULT_CELL_HEIGHT}
rowProps={listRowProps}
rowComponent={SliceListRow}
rowKey={(index, data) => data.filteredSlices[index].slice_id}
/>
)}
</AutoSizer>
</ChartList>
@@ -16,8 +16,8 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { VariableSizeList as List } from 'react-window';
import { useCallback, useMemo, useState } from 'react';
import { List } from 'react-window';
import { FlattenedItem, Folder } from './types';
import DatasourcePanelItem from './DatasourcePanelItem';
@@ -98,7 +98,6 @@ export const DatasourceItems = ({
height,
folders,
}: DatasourceItemsProps) => {
const listRef = useRef<List>(null);
const [collapsedFolderIds, setCollapsedFolderIds] = useState<Set<string>>(
new Set(
folders.filter(folder => folder.isCollapsed).map(folder => folder.id),
@@ -122,17 +121,15 @@ export const DatasourceItems = ({
});
}, []);
useEffect(() => {
// reset the list cache when flattenedItems length changes to recalculate the heights
listRef.current?.resetAfterIndex(0);
}, [flattenedItems]);
const getItemSize = useCallback(
// No manual cache-reset is needed: `rowHeight` is a useCallback tied to
// `flattenedItems`, so react-window's internal size cache recomputes
// automatically whenever its reference changes.
const rowHeight = useCallback(
(index: number) => flattenedItems[index].height,
[flattenedItems],
);
const itemData = useMemo(
const rowProps = useMemo(
() => ({
flattenedItems,
folderMap,
@@ -151,15 +148,12 @@ export const DatasourceItems = ({
return (
<List
ref={listRef}
width={width - BORDER_WIDTH}
height={height}
itemSize={getItemSize}
itemCount={flattenedItems.length}
itemData={itemData}
style={{ width: width - BORDER_WIDTH, height }}
rowHeight={rowHeight}
rowCount={flattenedItems.length}
rowProps={rowProps}
rowComponent={DatasourcePanelItem}
overscanCount={5}
>
{DatasourcePanelItem}
</List>
/>
);
};
@@ -22,12 +22,12 @@ import {
} from 'src/explore/components/DatasourcePanel/fixtures';
import { screen, userEvent, render } from 'spec/helpers/testing-library';
import DatasourcePanelItem, {
DatasourcePanelItemProps,
DatasourcePanelItemRowProps,
} from './DatasourcePanelItem';
import { FoldersEditorItemType } from 'src/components/Datasource/types';
import { MetricItem, ColumnItem } from './types';
const mockData: DatasourcePanelItemProps['data'] = {
const mockData: DatasourcePanelItemRowProps = {
flattenedItems: [
{ type: 'header', depth: 0, folderId: '1', height: 50 },
...metrics.map((m, idx) => ({
@@ -82,11 +82,22 @@ const mockData: DatasourcePanelItemProps['data'] = {
collapsedFolderIds: new Set(),
};
const setup = (data: DatasourcePanelItemProps['data'] = mockData) =>
const setup = (data: DatasourcePanelItemRowProps = mockData) =>
render(
<>
{data.flattenedItems.map((_, index) => (
<DatasourcePanelItem index={index} data={data} style={{}} />
<DatasourcePanelItem
// eslint-disable-next-line react/no-array-index-key -- test fixture has no stable id
key={index}
index={index}
style={{}}
ariaAttributes={{
role: 'listitem',
'aria-posinset': index + 1,
'aria-setsize': data.flattenedItems.length,
}}
{...data}
/>
))}
</>,
{ useDnd: true, useRedux: true, initialState: { explore: {} } },
@@ -16,7 +16,8 @@
* specific language governing permissions and limitations
* under the License.
*/
import { CSSProperties, ReactNode, useCallback } from 'react';
import { ReactNode, useCallback } from 'react';
import type { RowComponentProps } from 'react-window';
import { t } from '@apache-superset/core/translation';
import { useCSSTextTruncation } from '@superset-ui/core';
@@ -112,30 +113,26 @@ const Divider = styled.div`
`}
`;
export interface DatasourcePanelItemProps {
index: number;
style: CSSProperties;
data: {
flattenedItems: FlattenedItem[];
folderMap: Map<string, Folder>;
width: number;
onToggleCollapse: (folderId: string) => void;
collapsedFolderIds: Set<string>;
};
export interface DatasourcePanelItemRowProps {
flattenedItems: FlattenedItem[];
folderMap: Map<string, Folder>;
width: number;
onToggleCollapse: (folderId: string) => void;
collapsedFolderIds: Set<string>;
}
export type DatasourcePanelItemProps =
RowComponentProps<DatasourcePanelItemRowProps>;
const DatasourcePanelItem = ({
index,
style,
data,
flattenedItems,
folderMap,
width,
onToggleCollapse,
collapsedFolderIds,
}: DatasourcePanelItemProps) => {
const {
flattenedItems,
folderMap,
width,
onToggleCollapse,
collapsedFolderIds,
} = data;
const item = flattenedItems[index];
const theme = useTheme();
const [labelRef, labelIsTruncated] = useCSSTextTruncation<HTMLSpanElement>({