From a68a30c834ef7a3dd11050b1397c331ecc9057f6 Mon Sep 17 00:00:00 2001 From: Alyar <> Date: Thu, 15 Oct 2026 18:50:53 +0400 Subject: [PATCH] DataGrid: Fix virtual rows rendered below all rows after the height is decreased and restored (T1336247) --- .../tests/dataGrid/common/scrolling.ts | 129 ++++++++++++++++++ .../__tests__/rows_view.integration.test.ts | 71 +++++++++- .../grids/grid_core/views/m_rows_view.ts | 30 ++-- .../__internal/grids/grid_core/views/utils.ts | 18 +++ ...scrolling_data_controller.viewport.test.ts | 106 ++++++++++++++ .../virtual_scrolling_data_controller.ts | 23 ++++ .../m_virtual_scrolling_core.ts | 5 +- .../virtualScrolling.integration.tests.js | 2 +- 8 files changed, 367 insertions(+), 17 deletions(-) diff --git a/e2e/testcafe-devextreme/tests/dataGrid/common/scrolling.ts b/e2e/testcafe-devextreme/tests/dataGrid/common/scrolling.ts index 776cf1409c40..a5e6b7c5765a 100644 --- a/e2e/testcafe-devextreme/tests/dataGrid/common/scrolling.ts +++ b/e2e/testcafe-devextreme/tests/dataGrid/common/scrolling.ts @@ -1560,6 +1560,135 @@ test.meta({ browserSize: [800, 800] })('The page should not be changed when hidi delete (window as any).testLoadCount; })()); +test('New virtual mode. Virtual rows should not be rendered after the height is decreased and restored (T1336247)', async (t) => { + const dataGrid = new DataGrid('#container'); + const getViewportSize = ClientFunction( + () => (window as any).widget.getController('data').viewportSize() as number, + ); + + await t.expect(dataGrid.isReady()).ok(); + + const initialViewportSize = await getViewportSize(); + + await dataGrid.option('height', 150); + await t.expect(getViewportSize()).lt(initialViewportSize); + + await dataGrid.option('height', 600); + await t.expect(getViewportSize()).eql(initialViewportSize); + + const visibleRows = await dataGrid.apiGetVisibleRows(); + + await t + .expect(visibleRows.length) + .eql(10) + .expect(dataGrid.getRowsView().find(`.${ClassNames.virtualRow}`).count) + .eql(0); +}).before(async () => createWidget('dxDataGrid', { + dataSource: getData(10, 2), + height: 600, + scrolling: { + mode: 'virtual', + }, +})); + +test('New virtual mode. Virtual rows should not be rendered after the container height is decreased and restored (T1336247)', async (t) => { + const dataGrid = new DataGrid('#container'); + const setContainerHeight = ClientFunction((height: number) => { + $('#container').css('height', height); + }); + + await t.expect(dataGrid.isReady()).ok(); + + await setContainerHeight(150); + await dataGrid.apiUpdateDimensions(); + await setContainerHeight(600); + await dataGrid.apiUpdateDimensions(); + + await t + .expect(dataGrid.getRowsView().find(`.${ClassNames.virtualRow}`).count) + .eql(0); +}).before(async () => { + await ClientFunction(() => { + $('#container').css('height', 600); + })(); + + return createWidget('dxDataGrid', { + dataSource: getData(10, 2), + scrolling: { + mode: 'virtual', + }, + }); +}); + +test('New virtual mode. Virtual rows should not be rendered after a row is selected while the height is decreased (T1336247)', async (t) => { + const dataGrid = new DataGrid('#container'); + const getViewportSize = ClientFunction( + () => (window as any).widget.getController('data').viewportSize() as number, + ); + + await t.expect(dataGrid.isReady()).ok(); + + const initialViewportSize = await getViewportSize(); + + await dataGrid.option('height', 150); + await t.expect(getViewportSize()).lt(initialViewportSize); + + await t + .click(dataGrid.getDataRow(0).element) + .expect(dataGrid.getDataRow(0).isSelected) + .ok(); + + await dataGrid.option('height', 600); + await t.expect(getViewportSize()).eql(initialViewportSize); + + await t + .expect(dataGrid.getRowsView().find(`.${ClassNames.virtualRow}`).count) + .eql(0); +}).before(async () => createWidget('dxDataGrid', { + dataSource: getData(10, 2), + height: 600, + selection: { + mode: 'single', + }, + scrolling: { + mode: 'virtual', + }, +})); + +test('New virtual mode. Virtual rows should not be rendered after a row is focused while the height is decreased (T1336247)', async (t) => { + const dataGrid = new DataGrid('#container'); + const getViewportSize = ClientFunction( + () => (window as any).widget.getController('data').viewportSize() as number, + ); + + await t.expect(dataGrid.isReady()).ok(); + + const initialViewportSize = await getViewportSize(); + + await dataGrid.option('height', 150); + await t.expect(getViewportSize()).lt(initialViewportSize); + + await t + .click(dataGrid.getDataRow(0).element) + .expect(dataGrid.getDataRow(0).isFocusedRow) + .ok(); + + await dataGrid.option('height', 600); + await t.expect(getViewportSize()).eql(initialViewportSize); + + await t + .expect(dataGrid.getRowsView().find(`.${ClassNames.virtualRow}`).count) + .eql(0); +}).before(async () => createWidget('dxDataGrid', { + dataSource: getData(10, 2).map((item, index) => ({ ...item, id: index })), + keyExpr: 'id', + height: 600, + focusedRowEnabled: true, + scrolling: { + mode: 'virtual', + }, +})); + fixture.disablePageReloads`Remote Scrolling` .page(url(__dirname, '../../container.html')); diff --git a/packages/devextreme/js/__internal/grids/grid_core/views/__tests__/rows_view.integration.test.ts b/packages/devextreme/js/__internal/grids/grid_core/views/__tests__/rows_view.integration.test.ts index 7d605345d194..5ddee1ff718b 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/views/__tests__/rows_view.integration.test.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/views/__tests__/rows_view.integration.test.ts @@ -1,11 +1,16 @@ import { afterEach, beforeEach, describe, expect, it, jest, } from '@jest/globals'; +import eventsEngine from '@js/common/core/events/core/events_engine'; +import { removeEvent } from '@js/common/core/events/remove'; import type { dxElementWrapper } from '@js/core/renderer'; -import type { Properties as DataGridProperties } from '@js/ui/data_grid'; +import type { Properties as DataGridProperties, RowPreparedEvent } from '@js/ui/data_grid'; import { - afterTest, beforeTest, createDataGrid, + afterTest, + beforeTest, + createDataGrid, + flushAsync, } from '../../__tests__/__mock__/helpers/utils'; interface TemplateArgs { @@ -96,3 +101,65 @@ describe('RowsView renderCompleted with async templates', () => { expect(rowKeysOnRenderCompleted).toEqual([FIRST_PAGE_ROW_KEYS]); }); }); + +describe('RowsView row changes', () => { + beforeEach(beforeTest); + afterEach(afterTest); + + const CONTENT_HEIGHT = 500; + + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + const createGrid = async () => { + const dataSource = ROWS.map((row) => ({ ...row })); + const contentMinHeightsOnRowRemove: string[] = []; + + const grid = await createDataGrid({ + dataSource, + repaintChangesOnly: true, + onRowPrepared: ({ rowElement }: RowPreparedEvent) => { + eventsEngine.on(rowElement, removeEvent, () => { + const content = rowElement.closest('.dx-datagrid-rowsview .dx-datagrid-content'); + + if (content) { + contentMinHeightsOnRowRemove.push(content.style.minHeight); + } + }); + }, + }); + const content = (grid.$container.get(0) as HTMLElement) + .querySelector('.dx-datagrid-rowsview .dx-datagrid-content') as HTMLElement; + + content.style.height = `${CONTENT_HEIGHT}px`; + contentMinHeightsOnRowRemove.length = 0; + + const removeFirstRow = async (): Promise => { + dataSource.shift(); + const refreshing = grid.instance.refresh(true); + await flushAsync(); + await refreshing; + }; + + return { + ...grid, content, contentMinHeightsOnRowRemove, removeFirstRow, + }; + }; + + describe('when a rendered row is removed', () => { + it('should keep the content height while the row is removed', async () => { + const { contentMinHeightsOnRowRemove, removeFirstRow } = await createGrid(); + + await removeFirstRow(); + + expect(contentMinHeightsOnRowRemove).toEqual([`${CONTENT_HEIGHT}px`]); + }); + + it('should restore the content min-height after the row is removed', async () => { + const { content, removeFirstRow } = await createGrid(); + content.style.minHeight = '100px'; + + await removeFirstRow(); + + expect(content.style.minHeight).toBe('100px'); + }); + }); +}); diff --git a/packages/devextreme/js/__internal/grids/grid_core/views/m_rows_view.ts b/packages/devextreme/js/__internal/grids/grid_core/views/m_rows_view.ts index 248410a9baee..f2576ffa8de0 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/views/m_rows_view.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/views/m_rows_view.ts @@ -80,6 +80,7 @@ import { insertRowElement, isRowElementVisible, isSameColumnLayout, + keepContentHeight, } from './utils'; const ROWS_VIEW_CLASS = 'rowsview'; @@ -558,21 +559,26 @@ export class RowsView extends ColumnsView { return this.waitAsyncTemplates().done(() => { const contentChanges = this._contentChanges; + const hasRowChanges = contentChanges.some( + ({ change: contentChange }) => contentChange?.changeType === 'update', + ); this._contentChanges = []; - contentChanges.forEach(({ - newTableElement: $newTable, - change: contentChange, - isFixedTableRendering: isFixedTable, - }) => { - const contentElement = this._findContentElement(isFixedTable); - - if (contentChange?.changeType === 'update') { - this.applyRowChanges(contentChange, $newTable, isFixedTable); - } else { - this.replaceTable(contentElement, $newTable, isFixedTable); - } + keepContentHeight(hasRowChanges ? this._findContentElement() : undefined, () => { + contentChanges.forEach(({ + newTableElement: $newTable, + change: contentChange, + isFixedTableRendering: isFixedTable, + }) => { + const contentElement = this._findContentElement(isFixedTable); + + if (contentChange?.changeType === 'update') { + this.applyRowChanges(contentChange, $newTable, isFixedTable); + } else { + this.replaceTable(contentElement, $newTable, isFixedTable); + } + }); }); }).fail(() => { this._contentChanges = []; diff --git a/packages/devextreme/js/__internal/grids/grid_core/views/utils.ts b/packages/devextreme/js/__internal/grids/grid_core/views/utils.ts index 558ffbcafddc..8b3907000d8b 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/views/utils.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/views/utils.ts @@ -1,4 +1,5 @@ import type { dxElementWrapper } from '@js/core/renderer'; +import { getOuterHeight } from '@js/core/utils/size'; import { columnHasValue } from '@ts/grids/grid_core/columns_controller/columns_controller_utils'; import type { Column } from '@ts/grids/grid_core/columns_controller/types'; @@ -50,3 +51,20 @@ export const insertRowElement = ( $newRow.prependTo($newRow.is('tbody') ? $table : $table.children('tbody')); } }; + +export const keepContentHeight = ( + $content: dxElementWrapper | undefined, + callback: () => void, +): void => { + if (!$content) { + callback(); + + return; + } + + const { minHeight } = ($content.get(0) as HTMLElement).style; + + $content.css('minHeight', getOuterHeight($content)); + callback(); + $content.css('minHeight', minHeight); +}; diff --git a/packages/devextreme/js/__internal/grids/grid_core/virtual_scrolling/extenders/__tests__/virtual_scrolling_data_controller.viewport.test.ts b/packages/devextreme/js/__internal/grids/grid_core/virtual_scrolling/extenders/__tests__/virtual_scrolling_data_controller.viewport.test.ts index 9cf566426739..4378b62ec821 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/virtual_scrolling/extenders/__tests__/virtual_scrolling_data_controller.viewport.test.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/virtual_scrolling/extenders/__tests__/virtual_scrolling_data_controller.viewport.test.ts @@ -8,6 +8,13 @@ import { createDataGrid, flushAsync, } from '@ts/grids/grid_core/__tests__/__mock__/helpers/utils'; +import type { VirtualItemsCount } from '@ts/grids/grid_core/virtual_data_loader/types'; + +interface VirtualScrollingDataController { + viewportSize: (size?: number) => number; + virtualItemsCount: () => VirtualItemsCount | undefined; + loadViewport: () => void; +} const DATA = Array.from({ length: 40 }, (_, i) => ({ id: i + 1, value: `row ${i + 1}` })); @@ -91,6 +98,105 @@ describe('Virtual scrolling data controller — viewport reads', () => { }); }); + describe('virtual items count after the viewport size changes (T1336247)', () => { + const allRowsOnOnePage: Properties = { + dataSource: DATA.slice(0, 12), + paging: { pageSize: 20 }, + }; + + const getDataController = ( + instance: Awaited>['instance'], + ): VirtualScrollingDataController => instance + .getController('data') as unknown as VirtualScrollingDataController; + + it('describes the rendered rows when the viewport shrinks without re-rendering', async () => { + const { instance } = await createVirtualGrid('virtual', allRowsOnOnePage); + const dataController = getDataController(instance); + + dataController.viewportSize(4); + + expect(instance.getVisibleRows()).toHaveLength(12); + expect(dataController.virtualItemsCount()).toEqual({ begin: 0, end: 0 }); + }); + + it('keeps the bottom virtual items for the rows that are not rendered', async () => { + const { instance } = await createVirtualGrid(); + const dataController = getDataController(instance); + const renderedRowCount = instance.getVisibleRows().length; + + dataController.viewportSize(4); + + expect(dataController.virtualItemsCount()).toEqual({ + begin: 0, + end: DATA.length - renderedRowCount, + }); + }); + + it('describes the rendered rows of a non-first page when only rows are rendered virtually', async () => { + const { instance } = await createVirtualGrid('standard', { + paging: { pageSize: 20 }, + scrolling: { mode: 'standard', rowRenderingMode: 'virtual' }, + }); + const dataController = getDataController(instance); + + const paging = instance.pageIndex(1); + await flushAsync(); + await paging; + + const renderedRowCount = instance.getVisibleRows().length; + + expect(instance.getVisibleRows()[0].key).toBe(21); + expect(dataController.virtualItemsCount()).toEqual({ begin: 0, end: 20 - renderedRowCount }); + }); + + it('follows the viewport after the rows are re-rendered', async () => { + const { instance } = await createVirtualGrid('virtual', allRowsOnOnePage); + const dataController = getDataController(instance); + + dataController.viewportSize(4); + dataController.loadViewport(); + await flushAsync(); + + const renderedRowCount = instance.getVisibleRows().length; + + expect(renderedRowCount).toBeLessThan(12); + expect(dataController.virtualItemsCount()).toEqual({ begin: 0, end: 12 - renderedRowCount }); + }); + + it('describes the rendered rows after a row is selected while the viewport is shrunk', async () => { + const { instance } = await createVirtualGrid('virtual', { + ...allRowsOnOnePage, + selection: { mode: 'single' }, + }); + const dataController = getDataController(instance); + + dataController.viewportSize(4); + const selecting = instance.selectRows([1], false); + await flushAsync(); + await selecting; + + expect(instance.isRowSelected(1)).toBe(true); + expect(instance.getVisibleRows()).toHaveLength(12); + expect(dataController.virtualItemsCount()).toEqual({ begin: 0, end: 0 }); + }); + + it('describes the rendered rows after the focused row changes while the viewport is shrunk', async () => { + const { instance } = await createVirtualGrid('virtual', { + ...allRowsOnOnePage, + focusedRowEnabled: true, + }); + const dataController = getDataController(instance); + + dataController.viewportSize(4); + instance.option('focusedRowKey', 1); + await flushAsync(); + + expect(instance.option('focusedRowIndex')).toBe(0); + expect(instance.getVisibleRows()).toHaveLength(12); + expect(dataController.virtualItemsCount()).toEqual({ begin: 0, end: 0 }); + }); + }); + describe('standard mode is unaffected', () => { it('pages normally', async () => { const { instance } = await createVirtualGrid('standard'); diff --git a/packages/devextreme/js/__internal/grids/grid_core/virtual_scrolling/extenders/virtual_scrolling_data_controller.ts b/packages/devextreme/js/__internal/grids/grid_core/virtual_scrolling/extenders/virtual_scrolling_data_controller.ts index 1723e0de0a86..7f5d56797f4f 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/virtual_scrolling/extenders/virtual_scrolling_data_controller.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/virtual_scrolling/extenders/virtual_scrolling_data_controller.ts @@ -198,6 +198,9 @@ export const virtualScrollingDataControllerExtender = ( loadedItemCount() { return that._itemCount; }, + renderedItemsRange() { + return that.getRenderedItemsRange(); + }, totalItemsCount() { if (isVirtualPaging(that)) { return that.dataSourceController.totalItemsCount(); @@ -365,6 +368,26 @@ export const virtualScrollingDataControllerExtender = ( this._loadViewportParams = viewportParams; } + private getRenderedItemsRange(): { skip: number; take: number } | undefined { + const renderedItems = this._items; + const loadedItems: ProcessedItem[] | null = this._allItems; + + if (!renderedItems.length || !loadedItems?.length) { + return undefined; + } + + const loadedItemIndex = loadedItems[0].loadIndex ?? 0; + const renderedItemIndex = renderedItems[0].loadIndex ?? 0; + const pageStartItemIndex = isVirtualPaging(this) ? 0 : loadedItemIndex; + const firstItemIndex = renderedItemIndex - pageStartItemIndex; + const lastItemIndex = (renderedItems[renderedItems.length - 1].loadIndex ?? 0) - pageStartItemIndex; + + return { + skip: firstItemIndex, + take: lastItemIndex - firstItemIndex + 1, + }; + } + protected _processItems(items: RawItemData[], change: DataChange): ProcessedItem[] { const processedItems = super._processItems(items, change); diff --git a/packages/devextreme/js/__internal/grids/grid_core/virtual_scrolling/m_virtual_scrolling_core.ts b/packages/devextreme/js/__internal/grids/grid_core/virtual_scrolling/m_virtual_scrolling_core.ts index 12b19302c20d..323031f94396 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/virtual_scrolling/m_virtual_scrolling_core.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/virtual_scrolling/m_virtual_scrolling_core.ts @@ -179,12 +179,13 @@ class VirtualScrollController { const dataOptions = this._dataOptions; const totalItemsCount = dataOptions.totalItemsCount(); if (this.option(LEGACY_SCROLLING_MODE) === false && totalItemsCount !== -1) { + const renderedItemsRange = dataOptions.renderedItemsRange?.(); const viewportParams = this.getViewportParams(); const loadedOffset = dataOptions.loadedOffset(); const loadedItemCount = dataOptions.loadedItemCount(); - const skip = Math.max(viewportParams.skip, loadedOffset); - const take = Math.min(viewportParams.take, loadedItemCount); + const skip = renderedItemsRange?.skip ?? Math.max(viewportParams.skip, loadedOffset); + const take = renderedItemsRange?.take ?? Math.min(viewportParams.take, loadedItemCount); const endItemsCount = Math.max(totalItemsCount - (skip + take), 0); return { diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets.dataGrid/virtualScrolling.integration.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets.dataGrid/virtualScrolling.integration.tests.js index a7800df432bc..8a5402f88cd9 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets.dataGrid/virtualScrolling.integration.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets.dataGrid/virtualScrolling.integration.tests.js @@ -5900,7 +5900,7 @@ QUnit.module('Virtual Scrolling', baseModuleConfig, () => { this.clock.tick(300); - for(let i = 0; i < 7; i++) { + for(let i = 0; i < 10; i++) { dataGrid.getScrollable().scrollTo({ top: 16000000 }); this.clock.tick(1000); }