From 9771eca5d7e4a032bf71164bd1aab34e4aad5bd9 Mon Sep 17 00:00:00 2001 From: Eugen Zha Date: Wed, 15 Jul 2026 22:18:00 +0300 Subject: [PATCH 1/6] Refactor _synchronizeColumns method --- .../js/__internal/grids/grid_core/m_utils.ts | 20 ++- .../grids/grid_core/views/m_grid_view.ts | 161 +++++++++--------- .../js/__internal/ui/popover/popover.ts | 2 +- 3 files changed, 96 insertions(+), 87 deletions(-) diff --git a/packages/devextreme/js/__internal/grids/grid_core/m_utils.ts b/packages/devextreme/js/__internal/grids/grid_core/m_utils.ts index f03a205a92c4..af9ca4fd1b3b 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/m_utils.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/m_utils.ts @@ -70,6 +70,12 @@ export function isDateType(dataType: string | undefined): boolean { return dataType === 'date' || dataType === 'datetime'; } +export interface SelectionRange { + selectionStart: number; + selectionEnd: number; + selectionDirection?: 'forward' | 'backward' | 'none'; +} + const getIntervalSelector = function () { const data = arguments[1]; const value = this.calculateCellValue(data); @@ -562,23 +568,27 @@ export default { isDateType, - getSelectionRange(focusedElement) { + getSelectionRange(focusedElement): SelectionRange { try { if (focusedElement) { return { selectionStart: focusedElement.selectionStart, selectionEnd: focusedElement.selectionEnd, + selectionDirection: focusedElement.selectionDirection ?? undefined, }; } } catch (e) { /* empty */ } - return {}; + return { + selectionStart: -1, + selectionEnd: -1, + }; }, - setSelectionRange(focusedElement, selectionRange) { + setSelectionRange(focusedElement, selectionRange: SelectionRange): void { try { - if (focusedElement && focusedElement.setSelectionRange) { - focusedElement.setSelectionRange(selectionRange.selectionStart, selectionRange.selectionEnd); + if (focusedElement && focusedElement.setSelectionRange && selectionRange.selectionStart >= 0 && selectionRange.selectionEnd >= 0) { + focusedElement.setSelectionRange(selectionRange.selectionStart, selectionRange.selectionEnd, selectionRange.selectionDirection); } } catch (e) { /* empty */ } }, diff --git a/packages/devextreme/js/__internal/grids/grid_core/views/m_grid_view.ts b/packages/devextreme/js/__internal/grids/grid_core/views/m_grid_view.ts index b121904058a5..09460b162b88 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/views/m_grid_view.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/views/m_grid_view.ts @@ -23,7 +23,7 @@ import type { ColumnHeadersView } from '../column_headers/m_column_headers'; import type { ColumnsController } from '../columns_controller/m_columns_controller'; import type { DataController } from '../data_controller/m_data_controller'; import modules from '../m_modules'; -import gridCoreUtils from '../m_utils'; +import gridCoreUtils, { type SelectionRange } from '../m_utils'; import type { RowsView } from './m_rows_view'; const BORDERS_CLASS = 'borders'; @@ -78,11 +78,17 @@ const calculateFreeWidthWithCurrentMinWidth = function (that, columnIndex, curre return calculateFreeWidth(that, widths.map((width, index) => (index === columnIndex ? currentMinWidth : width))); }; -const restoreFocus = function (focusedElement, selectionRange) { +const restoreFocus = (focusedElement: Element, selectionRange: SelectionRange): void => { accessibility.hiddenFocus(focusedElement, true); gridCoreUtils.setSelectionRange(focusedElement, selectionRange); }; +interface MaxWidthController { + isModified: boolean; + set: (value: number) => void; + clear: () => void; +} + export class ResizingController extends modules.ViewController { private _refreshSizesHandler: any; @@ -100,8 +106,6 @@ export class ResizingController extends modules.ViewController { private _prevContentMinHeight: any; - private _maxWidth: any; - private _hasWidth: any; private _hasHeight: any; @@ -122,6 +126,26 @@ export class ResizingController extends modules.ViewController { public resizeCompleted!: Callback; + private readonly _maxWidth: MaxWidthController = { + isModified: false, + set: (value): void => { + const $element = this.component.$element(); + + this._maxWidth.isModified = true; + $element.css('maxWidth', value); + }, + clear: (): void => { + const $element = this.component.$element(); + + if (!this._maxWidth.isModified || !$element || !$element.get(0)) { + return; + } + + this._maxWidth.isModified = false; + $element[0].style.maxWidth = ''; + }, + }; + protected callbackNames() { return ['resizeCompleted']; } @@ -338,85 +362,55 @@ export class ResizingController extends modules.ViewController { } } - private _synchronizeColumns() { - const columnsController = this._columnsController; - const visibleColumns = columnsController.getVisibleColumns(); - const columnAutoWidth = this.option('columnAutoWidth'); - const hasUndefinedColumnWidth = visibleColumns.some((column) => !isDefined(column.width)); - let needBestFit = this._needBestFit(); - let hasMinWidth = false; - let resetBestFitMode; - let isColumnWidthsCorrected = false; - let resultWidths: any[] = []; - let focusedElement; - let selectionRange; + private _enableTemporaryBestFitMode(): () => void { + const $element = this.component.$element(); + const focusedElement = domAdapter.getActiveElement($element.get(0) as HTMLElement | null); + const selectionRange = gridCoreUtils.getSelectionRange(focusedElement); - const normalizeWidthsByExpandColumns = function () { - let expandColumnWidth; + this._toggleBestFitMode(true); - each(visibleColumns, (index, column) => { - if (column.type === 'groupExpand') { - expandColumnWidth = resultWidths[index]; - } - }); + return (): void => { + this._toggleBestFitMode(false); - each(visibleColumns, (index, column) => { - if (column.type === 'groupExpand' && expandColumnWidth) { - resultWidths[index] = expandColumnWidth; - } - }); - }; + if (focusedElement && focusedElement !== domAdapter.getActiveElement()) { + const isFocusOutsideWindow = getBoundingRect(focusedElement).bottom < 0; - !needBestFit && each(visibleColumns, (index, column) => { - if (column.width === 'auto') { - needBestFit = true; - return false; + if (!isFocusOutsideWindow) { + restoreFocus(focusedElement, selectionRange); + } } - return undefined; - }); + }; + } - each(visibleColumns, (index, column) => { - if (column.minWidth) { - hasMinWidth = true; - return false; - } - return undefined; - }); + private _synchronizeColumns():void { + const columnsController = this._columnsController; + const visibleColumns = columnsController.getVisibleColumns(); + const columnAutoWidth = this.option('columnAutoWidth') as boolean; + const hasUndefinedColumnWidth = visibleColumns.some((column) => !isDefined(column.width)); + const needBestFit = this._needBestFit() || visibleColumns.some((column) => column.width === 'auto'); + const hasMinWidth = visibleColumns.some((column) => !!column.minWidth); + // Prepare for measurement this._toggleContentMinHeight(this._hasHeight); // T1047239, T1270354 - this._setVisibleWidths(visibleColumns, []); - - const $element = this.component.$element(); - - if (needBestFit) { - // @ts-expect-error - focusedElement = domAdapter.getActiveElement($element.get(0)); - selectionRange = gridCoreUtils.getSelectionRange(focusedElement); - this._toggleBestFitMode(true); - resetBestFitMode = true; - } - - if ($element && $element.get(0) && this._maxWidth) { - delete this._maxWidth; - $element[0].style.maxWidth = ''; - } + const restoreAfterBestFitMode = needBestFit && this._enableTemporaryBestFitMode(); + this._maxWidth.clear(); // eslint-disable-next-line @typescript-eslint/no-floating-promises deferUpdate(() => { - if (needBestFit) { + let resultWidths: (number | string | undefined)[] = []; + + if (needBestFit || hasMinWidth) { resultWidths = this._getBestFitWidths(); + } - each(visibleColumns, (index, column) => { + each(visibleColumns, (index, column) => { + if (needBestFit) { const columnId = columnsController.getColumnId(column); columnsController.columnOption(columnId, 'bestFitWidth', resultWidths[index], true); - }); - } else if (hasMinWidth) { - resultWidths = this._getBestFitWidths(); - } + } - each(visibleColumns, function (index) { - const { width } = this; + const { width } = column; if (width !== 'auto') { if (isDefined(width)) { resultWidths[index] = isNumeric(width) || isPixelWidth(width) ? parseFloat(width) : width; @@ -426,21 +420,14 @@ export class ResizingController extends modules.ViewController { } }); - if (resetBestFitMode) { - this._toggleBestFitMode(false); - resetBestFitMode = false; - if (focusedElement && focusedElement !== domAdapter.getActiveElement()) { - const isFocusOutsideWindow = getBoundingRect(focusedElement).bottom < 0; - if (!isFocusOutsideWindow) { - restoreFocus(focusedElement, selectionRange); - } - } + if (restoreAfterBestFitMode) { + restoreAfterBestFitMode(); } - isColumnWidthsCorrected = this._correctColumnWidths(resultWidths, visibleColumns); + const isColumnWidthsCorrected = this._correctColumnWidths(resultWidths, visibleColumns); if (columnAutoWidth) { - normalizeWidthsByExpandColumns(); + this._normalizeWidthsByExpandColumns(resultWidths, visibleColumns); if (this._needStretch()) { this._processStretch(resultWidths, visibleColumns); } @@ -478,6 +465,21 @@ export class ResizingController extends modules.ViewController { return freeWidth / columnCountWithoutWidth; } + private readonly _normalizeWidthsByExpandColumns = (resultWidths, visibleColumns): void => { + const expandColumnIndex = visibleColumns.findIndex((column) => column.type === 'groupExpand'); + const expandColumnWidth = resultWidths[expandColumnIndex]; + + if (!isDefined(expandColumnWidth)) { + return; + } + + each(visibleColumns, (index, column) => { + if (column.type === 'groupExpand') { + resultWidths[index] = expandColumnWidth; + } + }); + }; + /** * @extended: adaptivity */ @@ -487,7 +489,6 @@ export class ResizingController extends modules.ViewController { let hasPercentWidth = false; let hasAutoWidth = false; let isColumnWidthsCorrected = false; - const $element = that.component.$element(); const hasWidth = that._hasWidth; for (i = 0; i < visibleColumns.length; i++) { @@ -540,9 +541,7 @@ export class ResizingController extends modules.ViewController { if (hasWidth === false && !hasPercentWidth) { const borderWidth = gridCoreUtils.getComponentBorderWidth(this, $rowsViewElement); - that._maxWidth = totalWidth + scrollbarWidth + borderWidth; - - $element.css('maxWidth', that._maxWidth); + that._maxWidth.set(totalWidth + scrollbarWidth + borderWidth); } } } diff --git a/packages/devextreme/js/__internal/ui/popover/popover.ts b/packages/devextreme/js/__internal/ui/popover/popover.ts index 6e5676cf2189..bf60b74ff3ab 100644 --- a/packages/devextreme/js/__internal/ui/popover/popover.ts +++ b/packages/devextreme/js/__internal/ui/popover/popover.ts @@ -200,7 +200,7 @@ class Popover< const { visible } = this.option(); const overlayStack = this._overlayStack(); - const isTopOverlay = overlayStack[overlayStack.length - 1] === this; + const isTopOverlay = overlayStack[overlayStack.length - 1] === this as unknown; if (normalizeKeyName(e) === ESC_KEY_NAME && visible && isTopOverlay) { // eslint-disable-next-line @typescript-eslint/no-floating-promises From 3d602d34cd330568736218100c8f7809f64f67b2 Mon Sep 17 00:00:00 2001 From: Eugen Zha Date: Wed, 15 Jul 2026 22:46:50 +0300 Subject: [PATCH 2/6] fix(popover): remove unnecessary type assertion for overlay stack check --- packages/devextreme/js/__internal/ui/popover/popover.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/devextreme/js/__internal/ui/popover/popover.ts b/packages/devextreme/js/__internal/ui/popover/popover.ts index bf60b74ff3ab..6e5676cf2189 100644 --- a/packages/devextreme/js/__internal/ui/popover/popover.ts +++ b/packages/devextreme/js/__internal/ui/popover/popover.ts @@ -200,7 +200,7 @@ class Popover< const { visible } = this.option(); const overlayStack = this._overlayStack(); - const isTopOverlay = overlayStack[overlayStack.length - 1] === this as unknown; + const isTopOverlay = overlayStack[overlayStack.length - 1] === this; if (normalizeKeyName(e) === ESC_KEY_NAME && visible && isTopOverlay) { // eslint-disable-next-line @typescript-eslint/no-floating-promises From 9957424bc2281f4889aacb059871ebe9ec82c7c6 Mon Sep 17 00:00:00 2001 From: Eugen Zha Date: Wed, 15 Jul 2026 22:56:12 +0300 Subject: [PATCH 3/6] fix(m_utils): handle non-numeric selectionStart and selectionEnd values --- packages/devextreme/js/__internal/grids/grid_core/m_utils.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/devextreme/js/__internal/grids/grid_core/m_utils.ts b/packages/devextreme/js/__internal/grids/grid_core/m_utils.ts index af9ca4fd1b3b..be482ce2fb82 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/m_utils.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/m_utils.ts @@ -572,8 +572,8 @@ export default { try { if (focusedElement) { return { - selectionStart: focusedElement.selectionStart, - selectionEnd: focusedElement.selectionEnd, + selectionStart: isNumeric(focusedElement.selectionStart) ? focusedElement.selectionStart : -1, + selectionEnd: isNumeric(focusedElement.selectionEnd) ? focusedElement.selectionEnd : -1, selectionDirection: focusedElement.selectionDirection ?? undefined, }; } From 32ad957e421a0b4a220a67ea863777e8349b93eb Mon Sep 17 00:00:00 2001 From: Eugen Zha Date: Wed, 15 Jul 2026 22:58:59 +0300 Subject: [PATCH 4/6] fix(grid_view): correct spacing in _synchronizeColumns method --- .../js/__internal/grids/grid_core/views/m_grid_view.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/devextreme/js/__internal/grids/grid_core/views/m_grid_view.ts b/packages/devextreme/js/__internal/grids/grid_core/views/m_grid_view.ts index 09460b162b88..88b9da1c35ed 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/views/m_grid_view.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/views/m_grid_view.ts @@ -382,7 +382,7 @@ export class ResizingController extends modules.ViewController { }; } - private _synchronizeColumns():void { + private _synchronizeColumns(): void { const columnsController = this._columnsController; const visibleColumns = columnsController.getVisibleColumns(); const columnAutoWidth = this.option('columnAutoWidth') as boolean; From dfde56ef47d38229c6256679306977e1dd56698c Mon Sep 17 00:00:00 2001 From: Eugen Zha Date: Tue, 21 Jul 2026 18:08:05 +0300 Subject: [PATCH 5/6] fix(m_utils): remove selectionDirection from SelectionRange interface and related logic --- packages/devextreme/js/__internal/grids/grid_core/m_utils.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/devextreme/js/__internal/grids/grid_core/m_utils.ts b/packages/devextreme/js/__internal/grids/grid_core/m_utils.ts index be482ce2fb82..8dec78937234 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/m_utils.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/m_utils.ts @@ -73,7 +73,6 @@ export function isDateType(dataType: string | undefined): boolean { export interface SelectionRange { selectionStart: number; selectionEnd: number; - selectionDirection?: 'forward' | 'backward' | 'none'; } const getIntervalSelector = function () { @@ -574,7 +573,6 @@ export default { return { selectionStart: isNumeric(focusedElement.selectionStart) ? focusedElement.selectionStart : -1, selectionEnd: isNumeric(focusedElement.selectionEnd) ? focusedElement.selectionEnd : -1, - selectionDirection: focusedElement.selectionDirection ?? undefined, }; } } catch (e) { /* empty */ } @@ -588,7 +586,7 @@ export default { setSelectionRange(focusedElement, selectionRange: SelectionRange): void { try { if (focusedElement && focusedElement.setSelectionRange && selectionRange.selectionStart >= 0 && selectionRange.selectionEnd >= 0) { - focusedElement.setSelectionRange(selectionRange.selectionStart, selectionRange.selectionEnd, selectionRange.selectionDirection); + focusedElement.setSelectionRange(selectionRange.selectionStart, selectionRange.selectionEnd); } } catch (e) { /* empty */ } }, From deca15aa0c44e51a7bb7b1d277cfbef14d648269 Mon Sep 17 00:00:00 2001 From: Eugen Zha Date: Tue, 21 Jul 2026 19:19:56 +0300 Subject: [PATCH 6/6] feat(columns): add handling for immediate column width changes without repaint --- .../T1329677_columnOptionWidthImmediate.ts | 44 +++++++++++++++++++ .../m_columns_controller_utils.ts | 21 +++++++++ 2 files changed, 65 insertions(+) create mode 100644 e2e/testcafe-devextreme/tests/dataGrid/common/columnResizing/T1329677_columnOptionWidthImmediate.ts diff --git a/e2e/testcafe-devextreme/tests/dataGrid/common/columnResizing/T1329677_columnOptionWidthImmediate.ts b/e2e/testcafe-devextreme/tests/dataGrid/common/columnResizing/T1329677_columnOptionWidthImmediate.ts new file mode 100644 index 000000000000..2f22cc001678 --- /dev/null +++ b/e2e/testcafe-devextreme/tests/dataGrid/common/columnResizing/T1329677_columnOptionWidthImmediate.ts @@ -0,0 +1,44 @@ +import DataGrid from 'devextreme-testcafe-models/dataGrid'; +import { createWidget } from '../../../../helpers/createWidget'; +import url from '../../../../helpers/getPageUrl'; + +fixture.disablePageReloads`DataGrid - columnOption width change should be applied immediately (T1329677)` + .page(url(__dirname, '../../../container.html')); + +const GRID_SELECTOR = '#container'; +const ASSIGNED_COLUMN_INDEX = 1; +const ASSIGNED_COLUMN_WIDTH = 700; +const COLUMN_WIDTH_TOLERANCE = 1; + +// T1329677 +test('DataGrid - column width changed via columnOption should be applied immediately, without a repaint', async (t) => { + const dataGrid = new DataGrid(GRID_SELECTOR); + + await t.expect(dataGrid.isReady()).ok(); + + // Called as a plain instance method after the grid's initial render/resize has already + // settled (isReady() resolved) - just like an external app calling columnOption from a click + // handler - rather than from inside onContentReady, which runs while the grid's own + // render/resize pipeline is still on the call stack and would not expose the bug. + await dataGrid.apiColumnOption('Task_Assigned_Employee_ID', 'width', ASSIGNED_COLUMN_WIDTH); + + const assignedColumnWidth = await dataGrid.getHeaders().getHeaderRow(0) + .getHeaderCell(ASSIGNED_COLUMN_INDEX).element.clientWidth; + + await t + .expect(assignedColumnWidth) + .within( + ASSIGNED_COLUMN_WIDTH - COLUMN_WIDTH_TOLERANCE, + ASSIGNED_COLUMN_WIDTH + COLUMN_WIDTH_TOLERANCE, + 'columnOption width should be applied immediately, without an explicit repaint', + ); +}).before(async () => { + await createWidget('dxDataGrid', { + dataSource: [{ Task_Subject: 'Test' }], + columnAutoWidth: true, + columns: [ + { dataField: 'Task_Subject' }, + { dataField: 'Task_Assigned_Employee_ID', caption: 'Assigned' }, + ], + }); +}); diff --git a/packages/devextreme/js/__internal/grids/grid_core/columns_controller/m_columns_controller_utils.ts b/packages/devextreme/js/__internal/grids/grid_core/columns_controller/m_columns_controller_utils.ts index 31660d62217c..3f8499e1762b 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/columns_controller/m_columns_controller_utils.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/columns_controller/m_columns_controller_utils.ts @@ -710,6 +710,23 @@ export const fireOptionChanged = function (that: ColumnsController, options) { } }; +const isVisibleWidthChangePendingForColumn = (that: ColumnsController, columnIndex): boolean => { + const columnChanges = that._columnChanges; + + if (!columnChanges?.optionNames?.visibleWidth) { + return false; + } + + return columnChanges.columnIndex === columnIndex + || !!columnChanges.columnIndices?.includes(columnIndex); +}; + +const invalidateStaleVisibleWidth = (that: ColumnsController, column): void => { + if (isDefined(column.visibleWidth) && !isVisibleWidthChangePendingForColumn(that, column.index)) { + column.visibleWidth = null; + } +}; + export const columnOptionCore = function (that: ColumnsController, column, optionName, value?, notFireEvent?) { const optionGetter = compileGetter(optionName); const columnIndex = column.index; @@ -735,6 +752,10 @@ export const columnOptionCore = function (that: ColumnsController, column, optio changeType = 'columns'; } + if (optionName === 'width') { + invalidateStaleVisibleWidth(that, column); + } + const optionSetter = compileSetter(optionName); // @ts-expect-error optionSetter(column, value, { functionsAsIs: true });