From 95f5c9f17dd0b257c495bc229959748ee9b8955d Mon Sep 17 00:00:00 2001 From: Eldar Iusupzhanov Date: Thu, 10 Sep 2026 10:55:13 +0800 Subject: [PATCH 1/3] move branch loading logic to a separate util --- .../m_data_source_adapter.test.ts | 15 +- .../m_data_source_adapter.ts | 219 +++--------------- .../utils/create_id_filter.ts | 8 + .../utils/load_branches.ts | 216 +++++++++++++++++ 4 files changed, 270 insertions(+), 188 deletions(-) create mode 100644 packages/devextreme/js/__internal/grids/tree_list/data_source_adapter/utils/create_id_filter.ts create mode 100644 packages/devextreme/js/__internal/grids/tree_list/data_source_adapter/utils/load_branches.ts diff --git a/packages/devextreme/js/__internal/grids/tree_list/data_source_adapter/m_data_source_adapter.test.ts b/packages/devextreme/js/__internal/grids/tree_list/data_source_adapter/m_data_source_adapter.test.ts index 2209b98a012f..5b4f7f2f1f25 100644 --- a/packages/devextreme/js/__internal/grids/tree_list/data_source_adapter/m_data_source_adapter.test.ts +++ b/packages/devextreme/js/__internal/grids/tree_list/data_source_adapter/m_data_source_adapter.test.ts @@ -7,6 +7,8 @@ import CustomStore from '@js/data/custom_store'; import DataSource from '@js/data/data_source'; import { DataSourceAdapterTreeList } from './m_data_source_adapter'; +import type { LoadOperation } from './types'; +import { loadBranches } from './utils/load_branches'; describe('TreeList DataSourceAdapter - T1311885 Race Condition', () => { let dataSourceAdapter: DataSourceAdapterTreeList; @@ -95,7 +97,7 @@ describe('TreeList DataSourceAdapter - T1311885 Race Condition', () => { dataSourceAdapter = undefined as any; }); - test('T1311885 - _loadParentsOrChildren should NOT throw concat error when _cachedStoreData is cleared', async () => { + test('T1311885 - loading branches should NOT throw concat error when _cachedStoreData is cleared', async () => { let firstLoadDeferred: any = null; let errorMessage = ''; @@ -116,7 +118,7 @@ describe('TreeList DataSourceAdapter - T1311885 Race Condition', () => { storeLoadOptions: { sort: null }, loadOptions: { sort: null }, operationId: OPERATION_ID.FIRST, - }; + } as unknown as LoadOperation; // eslint-disable-next-line @typescript-eslint/no-unused-vars dataSourceAdapter.customLoader.loadFromStore = jest.fn((loadOptions) => { @@ -130,10 +132,11 @@ describe('TreeList DataSourceAdapter - T1311885 Race Condition', () => { return deferred.promise(); }); - (dataSourceAdapter as any)._loadParentsOrChildren( - childData, - options, - ); + // The context snapshots the dataSource and the customLoader, so it has to be + // built after the stubs above are in place. + const context = (dataSourceAdapter as any).createBranchLoaderContext(); + + loadBranches(context, childData, options, false); expect(dataSourceAdapter.customLoader.loadFromStore).toHaveBeenCalledTimes(1); expect(firstLoadDeferred).toBeDefined(); diff --git a/packages/devextreme/js/__internal/grids/tree_list/data_source_adapter/m_data_source_adapter.ts b/packages/devextreme/js/__internal/grids/tree_list/data_source_adapter/m_data_source_adapter.ts index 35ad2a94f1b2..074b4714be14 100644 --- a/packages/devextreme/js/__internal/grids/tree_list/data_source_adapter/m_data_source_adapter.ts +++ b/packages/devextreme/js/__internal/grids/tree_list/data_source_adapter/m_data_source_adapter.ts @@ -1,18 +1,14 @@ -import ArrayStore from '@js/common/data/array_store'; import { createObjectWithChanges } from '@js/common/data/array_utils'; import query from '@js/common/data/query'; import storeHelper from '@js/common/data/store_helper'; import { equalByValue } from '@js/core/utils/common'; import { compileGetter, compileSetter } from '@js/core/utils/data'; -import { Deferred, when } from '@js/core/utils/deferred'; -import { extend } from '@js/core/utils/extend'; -import { each } from '@js/core/utils/iterator'; +import { Deferred } from '@js/core/utils/deferred'; import { isDefined, isFunction } from '@js/core/utils/type'; import errors from '@js/ui/widget/ui.errors'; import type Store from '@ts/data/abstract_store'; import type { ChangingEvent } from '@ts/data/data_source/types'; import type { BeforePushEvent } from '@ts/data/types'; -import type { CustomLoadResult } from '@ts/grids/grid_core/data_source_adapter/custom_loader'; import DataSourceAdapter from '@ts/grids/grid_core/data_source_adapter/m_data_source_adapter'; import { createDataSourceAdapterProvider } from '@ts/grids/grid_core/data_source_adapter/provider'; import type { RawItemData } from '@ts/grids/grid_core/data_source_adapter/types'; @@ -20,6 +16,9 @@ import gridCoreUtils from '@ts/grids/grid_core/m_utils'; import treeListCore from '../m_core'; import type { LoadOperation, NodeByKey, TreeNode } from './types'; +import { createIdFilter } from './utils/create_id_filter'; +import type { LoadBranchesContext } from './utils/load_branches'; +import { loadBranches } from './utils/load_branches'; import type { NodesContext } from './utils/nodes'; import { convertItemToNode, createNodesByItems, fillNodes, getVisibleNodes, @@ -45,16 +44,7 @@ const getChildKeys = function (that, keys) { return childKeys; }; -const applySorting = (data: any[], sort: any): any => queryByOptions( - query(data), - { - sort, - }, -).toArray(); - export class DataSourceAdapterTreeList extends DataSourceAdapter { - private _indexByKey: any; - private _keyGetter: any; private _parentIdGetter: any; @@ -133,16 +123,6 @@ export class DataSourceAdapterTreeList extends DataSourceAdapter { return hasItemsExpr && compileSetter(hasItemsExpr); } - private _updateIndexByKeyObject(items) { - const that = this; - - that._indexByKey = {}; - - each(items, (index, item) => { - that._indexByKey[item.key] = index; - }); - } - private _getNodesContext(): NodesContext { return { rootValue: this.option('rootValue'), @@ -154,6 +134,24 @@ export class DataSourceAdapterTreeList extends DataSourceAdapter { }; } + private getLoadBranchesContext(): LoadBranchesContext { + return { + dataSource: this._dataSource, + customLoader: this.customLoader, + rootValue: this.option('rootValue'), + maxFilterLengthInRequest: this.option('maxFilterLengthInRequest'), + parentIdExpr: this.option('parentIdExpr'), + keyExpr: this.getKeyExpr(), + _parentIdGetter: this._parentIdGetter, + _keyGetter: this._keyGetter, + isRowExpanded: (key) => this.isRowExpanded(key), + getCachedData: () => this._cachedStoreData, + setCachedData: this.setCachedStoreData.bind(this), + getLastOperationId: () => this._lastOperationId, + getNodeByKey: this.getNodeByKey.bind(this), + }; + } + private _convertDataToPlainStructure(data, parentId?, result?) { let key; @@ -193,15 +191,6 @@ export class DataSourceAdapterTreeList extends DataSourceAdapter { return data; } - private _createIdFilter(field, keys) { - const parentIdFilters: any[] = []; - - for (let i = 0; i < keys.length; i++) { - parentIdFilters.push([field, '=', keys[i]]); - } - return gridCoreUtils.combineFilters(parentIdFilters, 'or'); - } - protected override _calculateOperationTypes(loadOptions, lastLoadOptions, isFullReload?: boolean) { const currentExpandedKeys = this.option('expandedRowKeys'); @@ -291,152 +280,9 @@ export class DataSourceAdapterTreeList extends DataSourceAdapter { } options.storeLoadOptions.parentIds = parentIdsToLoad; - options.storeLoadOptions.filter = this._createIdFilter(parentIdExpr, parentIdsToLoad); - } - } - } - - private _generateInfoToLoad(data, needChildren) { - const that = this; - let key; - const keyMap = {}; - const resultKeyMap = {}; - const resultKeys: any[] = []; - const rootValue = that.option('rootValue'); - let i; - - for (i = 0; i < data.length; i++) { - key = needChildren ? that._parentIdGetter(data[i]) : that._keyGetter(data[i]); - keyMap[key] = true; - } - - for (i = 0; i < data.length; i++) { - key = needChildren ? that._keyGetter(data[i]) : that._parentIdGetter(data[i]); - const needToLoad = needChildren ? that.isRowExpanded(key) : key !== rootValue; - - if (!keyMap[key] && !resultKeyMap[key] && needToLoad) { - resultKeyMap[key] = true; - resultKeys.push(key); + options.storeLoadOptions.filter = createIdFilter(parentIdExpr, parentIdsToLoad); } } - - return { - keyMap: resultKeyMap, - keys: resultKeys, - }; - } - - private _isOperationIdOutdated(operationId) { - return operationId !== undefined - && this._lastOperationId !== undefined - && operationId !== this._lastOperationId; - } - - private _loadParentsOrChildren(data, options, needChildren?) { - if (this._isOperationIdOutdated(options.operationId)) { - this._dataSource.cancel(options.operationId); - // @ts-expect-error - const rejectedDeferred = new Deferred(); - rejectedDeferred.reject(); - return rejectedDeferred; - } - - let filter; - let needLocalFiltering; - const { keys, keyMap } = this._generateInfoToLoad(data, needChildren); - // @ts-expect-error - const d = new Deferred(); - const isRemoteFiltering = options.remoteOperations.filtering; - const maxFilterLengthInRequest = this.option('maxFilterLengthInRequest'); - const sort = options.storeLoadOptions?.sort ?? options.loadOptions?.sort; - let loadOptions = isRemoteFiltering ? options.storeLoadOptions : options.loadOptions; - - const concatLoadedData = (loadedData): any => { - if (isRemoteFiltering) { - const updatedData = applySorting( - this._cachedStoreData.concat(loadedData), - sort, - ); - - this.setCachedStoreData(updatedData); - } - - return applySorting( - data.concat(loadedData), - sort, - ); - }; - - if (!keys.length) { - return d.resolve(data); - } - - let cachedNodes = keys - .map((id) => this.getNodeByKey(id)) - .filter((node) => node && node.data) as TreeNode[]; - - if (cachedNodes.length === keys.length) { - if (needChildren) { - cachedNodes = cachedNodes.reduce((result: TreeNode[], node) => result.concat(node.children), []); - } - - if (cachedNodes.length) { - return this._loadParentsOrChildren(concatLoadedData(cachedNodes.map((node) => node.data)), options, needChildren); - } - } - - const keyExpr = needChildren ? this.option('parentIdExpr') : this.getKeyExpr(); - filter = this._createIdFilter(keyExpr, keys); - const filterLength = encodeURI(JSON.stringify(filter)).length; - - if (filterLength > maxFilterLengthInRequest) { - filter = (itemData) => keyMap[needChildren ? this._parentIdGetter(itemData) : this._keyGetter(itemData)]; - - needLocalFiltering = isRemoteFiltering; - } - - loadOptions = extend({}, loadOptions, { - filter: !needLocalFiltering ? filter : null, - }); - - const loadBranchItemsDeferred = options.fullData - ? new ArrayStore(options.fullData).load(loadOptions) - : this.customLoader.loadFromStore(loadOptions); - - loadBranchItemsDeferred - .done((loadResult: CustomLoadResult | unknown[]) => { - let loadedData = Array.isArray(loadResult) ? loadResult : loadResult.data; - - if (this._isOperationIdOutdated(options.operationId)) { - d.reject(); - return; - } - - if (loadedData.length) { - if (needLocalFiltering) { - loadedData = query(loadedData).filter(filter).toArray(); - } - - this._loadParentsOrChildren(concatLoadedData(loadedData), options, needChildren).done(d.resolve).fail(d.reject); - } else { - d.resolve(data); - } - }) - .fail(d.reject); - - return d; - } - - private _loadParents(data, options) { - return this._loadParentsOrChildren(data, options); - } - - private _loadChildrenIfNeed(data, options) { - if (isFullBranchFilterMode(this)) { - return this._loadParentsOrChildren(data, options, true); - } - - return when(data); } private _updateHasItemsMap(options) { @@ -634,14 +480,23 @@ export class DataSourceAdapterTreeList extends DataSourceAdapter { visibleItems = data; } - return that._loadParents(data, options).done((data) => { - that._loadChildrenIfNeed(data, options).done((data) => { - options.data = data; + const needLoadChildren = isFullBranchFilterMode(this); + + loadBranches( + this.getLoadBranchesContext(), + data as RawItemData[], + options, + needLoadChildren, + ) + .done((loadedData) => { + options.data = loadedData; that._processTreeStructure(options, visibleItems); super.customizeLoadResultHandlerCore.call(that, options); d.resolve(options.data); - }); - }).fail(d.reject); + }) + .fail(d.reject); + + return; } that._processTreeStructure(options); } diff --git a/packages/devextreme/js/__internal/grids/tree_list/data_source_adapter/utils/create_id_filter.ts b/packages/devextreme/js/__internal/grids/tree_list/data_source_adapter/utils/create_id_filter.ts new file mode 100644 index 000000000000..c1422d7fe87f --- /dev/null +++ b/packages/devextreme/js/__internal/grids/tree_list/data_source_adapter/utils/create_id_filter.ts @@ -0,0 +1,8 @@ +import type { DataFilter } from '@ts/grids/grid_core/data_controller/types'; +import gridCoreUtils from '@ts/grids/grid_core/m_utils'; + +export const createIdFilter = (field: unknown, keys: unknown[]): DataFilter => gridCoreUtils + .combineFilters( + keys.map((key) => [field, '=', key]), + 'or', + ); diff --git a/packages/devextreme/js/__internal/grids/tree_list/data_source_adapter/utils/load_branches.ts b/packages/devextreme/js/__internal/grids/tree_list/data_source_adapter/utils/load_branches.ts new file mode 100644 index 000000000000..c981e94dbcbc --- /dev/null +++ b/packages/devextreme/js/__internal/grids/tree_list/data_source_adapter/utils/load_branches.ts @@ -0,0 +1,216 @@ +/* eslint-disable @typescript-eslint/no-shadow */ +/* eslint-disable @typescript-eslint/no-unsafe-return */ +/* eslint-disable @stylistic/comma-dangle */ +/* eslint-disable @stylistic/max-len */ +/* eslint-disable no-plusplus */ +/* eslint-disable @typescript-eslint/init-declarations */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import ArrayStore from '@js/common/data/array_store'; +import query from '@js/common/data/query'; +import storeHelper from '@js/common/data/store_helper'; +import type { DeferredObj } from '@js/core/utils/deferred'; +import { Deferred } from '@js/core/utils/deferred'; +import { extend } from '@js/core/utils/extend'; +import type { DataSource } from '@ts/data/data_source/data_source'; +import type { CustomLoader, CustomLoadResult } from '@ts/grids/grid_core/data_source_adapter/custom_loader'; +import type { RawItemData } from '@ts/grids/grid_core/data_source_adapter/types'; + +import type { LoadOperation, TreeNode } from '../types'; +import { createIdFilter } from './create_id_filter'; + +export interface LoadBranchesContext { + dataSource: DataSource, + customLoader: CustomLoader, + rootValue: any; + maxFilterLengthInRequest: any; + parentIdExpr: any; + keyExpr: any; + _parentIdGetter: (data: any) => any; + _keyGetter: (data: any) => any; + isRowExpanded: (data: any) => boolean; + getCachedData: () => any; + setCachedData: (data: any) => void; + getLastOperationId: () => any, + getNodeByKey: (key: any) => TreeNode | undefined, +} + +const { queryByOptions } = storeHelper; + +const applySorting = (data: any[], sort: any): any => queryByOptions( + query(data), + { + sort, + }, +).toArray(); + +const isOperationIdOutdated = (context: LoadBranchesContext, operationId): boolean => { + const lastOperationId = context.getLastOperationId(); + + return operationId !== undefined + && lastOperationId !== undefined + && operationId !== lastOperationId; +}; + +const generateInfoToLoad = (context: LoadBranchesContext, data, needChildren) => { + let key; + const keyMap = {}; + const resultKeyMap = {}; + const resultKeys: any[] = []; + const { rootValue } = context; + let i; + + for (i = 0; i < data.length; i++) { + key = needChildren ? context._parentIdGetter(data[i]) : context._keyGetter(data[i]); + keyMap[key] = true; + } + + for (i = 0; i < data.length; i++) { + key = needChildren ? context._keyGetter(data[i]) : context._parentIdGetter(data[i]); + const needToLoad = needChildren ? context.isRowExpanded(key) : key !== rootValue; + + if (!keyMap[key] && !resultKeyMap[key] && needToLoad) { + resultKeyMap[key] = true; + resultKeys.push(key); + } + } + + return { + keyMap: resultKeyMap, + keys: resultKeys, + }; +}; + +const loadParentsOrChildren = (context: LoadBranchesContext, data, options, needChildren?): any => { + if (isOperationIdOutdated(context, options.operationId)) { + context.dataSource.cancel(options.operationId); + const rejectedDeferred = Deferred(); + rejectedDeferred.reject(); + return rejectedDeferred; + } + + let filter; + let needLocalFiltering; + const { keys, keyMap } = generateInfoToLoad(context, data, needChildren); + // @ts-expect-error + const d = new Deferred(); + const isRemoteFiltering = options.remoteOperations.filtering; + const { maxFilterLengthInRequest } = context; + const sort = options.storeLoadOptions?.sort ?? options.loadOptions?.sort; + let loadOptions = isRemoteFiltering ? options.storeLoadOptions : options.loadOptions; + + const concatLoadedData = (loadedData): any => { + if (isRemoteFiltering) { + const updatedData = applySorting( + context.getCachedData().concat(loadedData), + sort, + ); + + context.setCachedData(updatedData); + } + + return applySorting( + data.concat(loadedData), + sort, + ); + }; + + if (!keys.length) { + return d.resolve(data); + } + + let cachedNodes = keys + .map((id) => context.getNodeByKey(id)) + .filter((node) => node?.data) as TreeNode[]; + + if (cachedNodes.length === keys.length) { + if (needChildren) { + cachedNodes = cachedNodes.reduce((result: TreeNode[], node) => result.concat(node.children), []); + } + + if (cachedNodes.length) { + return loadParentsOrChildren( + context, + concatLoadedData(cachedNodes.map((node) => node.data)), + options, + needChildren + ); + } + } + + const keyExpr = needChildren ? context.parentIdExpr : context.keyExpr; + filter = createIdFilter(keyExpr, keys); + const filterLength = encodeURI(JSON.stringify(filter)).length; + + if (filterLength > maxFilterLengthInRequest) { + filter = (itemData) => { + const key = needChildren + ? context._parentIdGetter(itemData) + : context._keyGetter(itemData); + return keyMap[key]; + }; + + needLocalFiltering = isRemoteFiltering; + } + + loadOptions = extend({}, loadOptions, { + filter: !needLocalFiltering ? filter : null, + }); + + const loadBranchItemsDeferred = options.fullData + ? new ArrayStore(options.fullData).load(loadOptions) + : context.customLoader.loadFromStore(loadOptions); + + loadBranchItemsDeferred + .done((loadResult: CustomLoadResult | unknown[]) => { + let loadedData = Array.isArray(loadResult) ? loadResult : loadResult.data; + + if (isOperationIdOutdated(context, options.operationId)) { + d.reject(); + return; + } + + if (loadedData.length) { + if (needLocalFiltering) { + loadedData = query(loadedData).filter(filter).toArray(); + } + + loadParentsOrChildren( + context, + concatLoadedData(loadedData), + options, + needChildren + ).done(d.resolve).fail(d.reject); + } else { + d.resolve(data); + } + }) + .fail(d.reject); + + return d; +}; + +export const loadBranches = ( + context: LoadBranchesContext, + data: RawItemData[], + options: LoadOperation, + needChildren: boolean, +): DeferredObj => { + const d = Deferred(); + + loadParentsOrChildren(context, data, options) + .done((data) => { + if (!needChildren) { + d.resolve(data); + return; + } + + loadParentsOrChildren(context, data, options, true) + .done(d.resolve) + .fail(d.reject); + }) + .fail(d.reject); + + return d; +}; From 3b52e98f52da8da746cf631f78522329d0464f4d Mon Sep 17 00:00:00 2001 From: Eldar Iusupzhanov Date: Thu, 10 Sep 2026 11:41:47 +0800 Subject: [PATCH 2/3] add types --- .../m_data_source_adapter.ts | 6 +- .../m_data_source_adapter.test.ts | 2 +- .../m_data_source_adapter.ts | 1 + .../tree_list/data_source_adapter/types.ts | 2 +- .../utils/load_branches.ts | 228 ++++++++++-------- .../data_source_adapter/utils/nodes.ts | 2 +- 6 files changed, 132 insertions(+), 109 deletions(-) diff --git a/packages/devextreme/js/__internal/grids/grid_core/data_source_adapter/m_data_source_adapter.ts b/packages/devextreme/js/__internal/grids/grid_core/data_source_adapter/m_data_source_adapter.ts index 10c8b496f377..d3e53d5fceec 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/data_source_adapter/m_data_source_adapter.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/data_source_adapter/m_data_source_adapter.ts @@ -46,7 +46,7 @@ export default class DataSourceAdapter extends modules.Controller { private _cachedData: any; - protected _cachedStoreData: any; + protected _cachedStoreData?: RawItemData[]; private _cachedPagingData: any; @@ -259,7 +259,7 @@ export default class DataSourceAdapter extends modules.Controller { this._totalCountCorrection = 0; } - protected setCachedStoreData(data): void { + protected setCachedStoreData(data: RawItemData[] | undefined): void { this._cachedStoreData = data; this._dataIndexByKey = undefined; } @@ -616,7 +616,7 @@ export default class DataSourceAdapter extends modules.Controller { if (!this._cachedStoreData) { this.setCachedStoreData(cloneItems(options.data, gridCoreUtils.normalizeSortingInfo(storeLoadOptions.group).length)); } else if (options.mergeStoreLoadData) { - this.setCachedStoreData(this._cachedStoreData.concat(options.data)); + this.setCachedStoreData(this._cachedStoreData.concat(options.data as RawItemData[])); options.data = this._cachedStoreData; } } diff --git a/packages/devextreme/js/__internal/grids/tree_list/data_source_adapter/m_data_source_adapter.test.ts b/packages/devextreme/js/__internal/grids/tree_list/data_source_adapter/m_data_source_adapter.test.ts index 5b4f7f2f1f25..8f7913c80060 100644 --- a/packages/devextreme/js/__internal/grids/tree_list/data_source_adapter/m_data_source_adapter.test.ts +++ b/packages/devextreme/js/__internal/grids/tree_list/data_source_adapter/m_data_source_adapter.test.ts @@ -134,7 +134,7 @@ describe('TreeList DataSourceAdapter - T1311885 Race Condition', () => { // The context snapshots the dataSource and the customLoader, so it has to be // built after the stubs above are in place. - const context = (dataSourceAdapter as any).createBranchLoaderContext(); + const context = (dataSourceAdapter as any).getLoadBranchesContext(); loadBranches(context, childData, options, false); diff --git a/packages/devextreme/js/__internal/grids/tree_list/data_source_adapter/m_data_source_adapter.ts b/packages/devextreme/js/__internal/grids/tree_list/data_source_adapter/m_data_source_adapter.ts index 074b4714be14..284d01f070d9 100644 --- a/packages/devextreme/js/__internal/grids/tree_list/data_source_adapter/m_data_source_adapter.ts +++ b/packages/devextreme/js/__internal/grids/tree_list/data_source_adapter/m_data_source_adapter.ts @@ -415,6 +415,7 @@ export class DataSourceAdapterTreeList extends DataSourceAdapter { } if (data.isConverted && this._cachedStoreData) { + // @ts-expect-error this._cachedStoreData.isConverted = true; } } diff --git a/packages/devextreme/js/__internal/grids/tree_list/data_source_adapter/types.ts b/packages/devextreme/js/__internal/grids/tree_list/data_source_adapter/types.ts index 443ab021fa11..bbefacbf3f78 100644 --- a/packages/devextreme/js/__internal/grids/tree_list/data_source_adapter/types.ts +++ b/packages/devextreme/js/__internal/grids/tree_list/data_source_adapter/types.ts @@ -12,7 +12,7 @@ export interface LoadOperation extends BaseLoadOperation { export interface TreeNode { key: unknown; children: TreeNode[]; - data?: unknown; + data?: RawItemData; parent?: TreeNode; level?: number; visible?: boolean; diff --git a/packages/devextreme/js/__internal/grids/tree_list/data_source_adapter/utils/load_branches.ts b/packages/devextreme/js/__internal/grids/tree_list/data_source_adapter/utils/load_branches.ts index c981e94dbcbc..5a6da0a57c5e 100644 --- a/packages/devextreme/js/__internal/grids/tree_list/data_source_adapter/utils/load_branches.ts +++ b/packages/devextreme/js/__internal/grids/tree_list/data_source_adapter/utils/load_branches.ts @@ -1,12 +1,3 @@ -/* eslint-disable @typescript-eslint/no-shadow */ -/* eslint-disable @typescript-eslint/no-unsafe-return */ -/* eslint-disable @stylistic/comma-dangle */ -/* eslint-disable @stylistic/max-len */ -/* eslint-disable no-plusplus */ -/* eslint-disable @typescript-eslint/init-declarations */ - -/* eslint-disable @typescript-eslint/no-explicit-any */ -/* eslint-disable @typescript-eslint/explicit-function-return-type */ import ArrayStore from '@js/common/data/array_store'; import query from '@js/common/data/query'; import storeHelper from '@js/common/data/store_helper'; @@ -14,38 +5,51 @@ import type { DeferredObj } from '@js/core/utils/deferred'; import { Deferred } from '@js/core/utils/deferred'; import { extend } from '@js/core/utils/extend'; import type { DataSource } from '@ts/data/data_source/data_source'; +import type { StoreLoadOptions } from '@ts/data/data_source/types'; +import type { DataFilter, DataFilterPredicate } from '@ts/grids/grid_core/data_controller/types'; import type { CustomLoader, CustomLoadResult } from '@ts/grids/grid_core/data_source_adapter/custom_loader'; import type { RawItemData } from '@ts/grids/grid_core/data_source_adapter/types'; import type { LoadOperation, TreeNode } from '../types'; import { createIdFilter } from './create_id_filter'; +const { queryByOptions } = storeHelper; + export interface LoadBranchesContext { - dataSource: DataSource, - customLoader: CustomLoader, - rootValue: any; - maxFilterLengthInRequest: any; - parentIdExpr: any; - keyExpr: any; - _parentIdGetter: (data: any) => any; - _keyGetter: (data: any) => any; - isRowExpanded: (data: any) => boolean; - getCachedData: () => any; - setCachedData: (data: any) => void; - getLastOperationId: () => any, - getNodeByKey: (key: any) => TreeNode | undefined, + dataSource: DataSource; + customLoader: CustomLoader; + rootValue: unknown; + maxFilterLengthInRequest: number; + parentIdExpr: unknown; + keyExpr: unknown; + _parentIdGetter: (data: unknown) => unknown; + _keyGetter: (data: unknown) => unknown; + isRowExpanded: (key: unknown) => boolean; + getCachedData: () => RawItemData[] | undefined; + setCachedData: (data: RawItemData[]) => void; + getLastOperationId: () => number | undefined; + getNodeByKey: (key: unknown) => TreeNode | undefined; } -const { queryByOptions } = storeHelper; +interface InfoToLoad { + keyMap: Record; + keys: unknown[]; +} -const applySorting = (data: any[], sort: any): any => queryByOptions( +const applySorting = ( + data: RawItemData[], + sort: StoreLoadOptions['sort'], +): RawItemData[] => queryByOptions( query(data), { sort, }, -).toArray(); +).toArray() as RawItemData[]; -const isOperationIdOutdated = (context: LoadBranchesContext, operationId): boolean => { +const isOperationIdOutdated = ( + context: LoadBranchesContext, + operationId: number | undefined, +): boolean => { const lastOperationId = context.getLastOperationId(); return operationId !== undefined @@ -53,25 +57,34 @@ const isOperationIdOutdated = (context: LoadBranchesContext, operationId): boole && operationId !== lastOperationId; }; -const generateInfoToLoad = (context: LoadBranchesContext, data, needChildren) => { - let key; - const keyMap = {}; - const resultKeyMap = {}; - const resultKeys: any[] = []; +const generateInfoToLoad = ( + context: LoadBranchesContext, + data: RawItemData[], + needChildren?: boolean, +): InfoToLoad => { + const keyMap: Record = {}; + const resultKeyMap: Record = {}; + const resultKeys: unknown[] = []; const { rootValue } = context; - let i; - for (i = 0; i < data.length; i++) { - key = needChildren ? context._parentIdGetter(data[i]) : context._keyGetter(data[i]); - keyMap[key] = true; + for (const item of data) { + const key = needChildren + ? context._parentIdGetter(item) + : context._keyGetter(item); + + keyMap[key as string] = true; } - for (i = 0; i < data.length; i++) { - key = needChildren ? context._keyGetter(data[i]) : context._parentIdGetter(data[i]); - const needToLoad = needChildren ? context.isRowExpanded(key) : key !== rootValue; + for (const item of data) { + const key = needChildren + ? context._keyGetter(item) + : context._parentIdGetter(item); + const needToLoad = needChildren + ? context.isRowExpanded(key) + : key !== rootValue; - if (!keyMap[key] && !resultKeyMap[key] && needToLoad) { - resultKeyMap[key] = true; + if (!keyMap[key as string] && !resultKeyMap[key as string] && needToLoad) { + resultKeyMap[key as string] = true; resultKeys.push(key); } } @@ -82,38 +95,33 @@ const generateInfoToLoad = (context: LoadBranchesContext, data, needChildren) => }; }; -const loadParentsOrChildren = (context: LoadBranchesContext, data, options, needChildren?): any => { +const loadParentsOrChildren = ( + context: LoadBranchesContext, + data: RawItemData[], + options: LoadOperation, + needChildren?: boolean, +): DeferredObj => { + const d = Deferred(); + if (isOperationIdOutdated(context, options.operationId)) { - context.dataSource.cancel(options.operationId); - const rejectedDeferred = Deferred(); - rejectedDeferred.reject(); - return rejectedDeferred; + context.dataSource.cancel(options.operationId as number); + return d.reject(); } - let filter; - let needLocalFiltering; const { keys, keyMap } = generateInfoToLoad(context, data, needChildren); - // @ts-expect-error - const d = new Deferred(); - const isRemoteFiltering = options.remoteOperations.filtering; - const { maxFilterLengthInRequest } = context; + + const isRemoteFiltering = !!options.remoteOperations?.filtering; const sort = options.storeLoadOptions?.sort ?? options.loadOptions?.sort; - let loadOptions = isRemoteFiltering ? options.storeLoadOptions : options.loadOptions; - const concatLoadedData = (loadedData): any => { + const concatLoadedData = (loadedData: RawItemData[]): RawItemData[] => { if (isRemoteFiltering) { - const updatedData = applySorting( - context.getCachedData().concat(loadedData), - sort, - ); + const cachedData = context.getCachedData() as RawItemData[]; + const sortedData = applySorting(cachedData.concat(loadedData), sort); - context.setCachedData(updatedData); + context.setCachedData(sortedData); } - return applySorting( - data.concat(loadedData), - sort, - ); + return applySorting(data.concat(loadedData), sort); }; if (!keys.length) { @@ -121,42 +129,49 @@ const loadParentsOrChildren = (context: LoadBranchesContext, data, options, need } let cachedNodes = keys - .map((id) => context.getNodeByKey(id)) - .filter((node) => node?.data) as TreeNode[]; + .map((key) => context.getNodeByKey(key)) + .filter((node): node is TreeNode => !!node?.data); if (cachedNodes.length === keys.length) { if (needChildren) { - cachedNodes = cachedNodes.reduce((result: TreeNode[], node) => result.concat(node.children), []); + cachedNodes = cachedNodes.flatMap((node) => node.children); } if (cachedNodes.length) { return loadParentsOrChildren( context, - concatLoadedData(cachedNodes.map((node) => node.data)), + concatLoadedData(cachedNodes.map((node) => node.data as RawItemData)), options, - needChildren + needChildren, ); } } const keyExpr = needChildren ? context.parentIdExpr : context.keyExpr; - filter = createIdFilter(keyExpr, keys); - const filterLength = encodeURI(JSON.stringify(filter)).length; - - if (filterLength > maxFilterLengthInRequest) { - filter = (itemData) => { - const key = needChildren - ? context._parentIdGetter(itemData) - : context._keyGetter(itemData); - return keyMap[key]; - }; - - needLocalFiltering = isRemoteFiltering; - } + const idFilter = createIdFilter(keyExpr, keys); + const filterLength = encodeURI(JSON.stringify(idFilter)).length; + const isFilterTooLong = filterLength > context.maxFilterLengthInRequest; + + const keyMapFilter: DataFilterPredicate = (itemData) => { + const key = needChildren + ? context._parentIdGetter(itemData) + : context._keyGetter(itemData); + + return !!keyMap[key as string]; + }; - loadOptions = extend({}, loadOptions, { - filter: !needLocalFiltering ? filter : null, - }); + const filter: DataFilter = isFilterTooLong ? keyMapFilter : idFilter; + // A remote store cannot run the predicate, so it loads unfiltered + // and the predicate is applied to the result below. + const needLocalFiltering = isFilterTooLong && isRemoteFiltering; + + const loadOptions = extend( + {}, + isRemoteFiltering ? options.storeLoadOptions : options.loadOptions, + { + filter: needLocalFiltering ? null : filter, + }, + ); const loadBranchItemsDeferred = options.fullData ? new ArrayStore(options.fullData).load(loadOptions) @@ -164,29 +179,31 @@ const loadParentsOrChildren = (context: LoadBranchesContext, data, options, need loadBranchItemsDeferred .done((loadResult: CustomLoadResult | unknown[]) => { - let loadedData = Array.isArray(loadResult) ? loadResult : loadResult.data; + const loadedData = Array.isArray(loadResult) + ? loadResult as RawItemData[] + : loadResult.data; if (isOperationIdOutdated(context, options.operationId)) { d.reject(); return; } - if (loadedData.length) { - if (needLocalFiltering) { - loadedData = query(loadedData).filter(filter).toArray(); - } - - loadParentsOrChildren( - context, - concatLoadedData(loadedData), - options, - needChildren - ).done(d.resolve).fail(d.reject); - } else { + if (!loadedData.length) { d.resolve(data); + return; } + + const branchData = needLocalFiltering + ? query(loadedData).filter(keyMapFilter).toArray() as RawItemData[] + : loadedData; + + loadParentsOrChildren(context, concatLoadedData(branchData), options, needChildren) + .done((nextLoadedData: RawItemData[]): void => { d.resolve(nextLoadedData); }) + // @ts-expect-error badly typed Deferred.fail + .fail((...args: unknown[]): void => { d.reject(...args); }); }) - .fail(d.reject); + // @ts-expect-error badly typed Deferred.fail + .fail((...args: unknown[]): void => { d.reject(...args); }); return d; }; @@ -198,19 +215,24 @@ export const loadBranches = ( needChildren: boolean, ): DeferredObj => { const d = Deferred(); + const resolve = (branchData: RawItemData[]): void => { d.resolve(branchData); }; + const reject = (...args: unknown[]): void => { + // @ts-expect-error badly typed Deferred.reject + d.reject(...args); + }; loadParentsOrChildren(context, data, options) - .done((data) => { + .done((parentsData) => { if (!needChildren) { - d.resolve(data); + resolve(parentsData); return; } - loadParentsOrChildren(context, data, options, true) - .done(d.resolve) - .fail(d.reject); + loadParentsOrChildren(context, parentsData, options, true) + .done(resolve) + .fail(reject); }) - .fail(d.reject); + .fail(reject); return d; }; diff --git a/packages/devextreme/js/__internal/grids/tree_list/data_source_adapter/utils/nodes.ts b/packages/devextreme/js/__internal/grids/tree_list/data_source_adapter/utils/nodes.ts index ffbb7bd84f30..6f0e4dfc5456 100644 --- a/packages/devextreme/js/__internal/grids/tree_list/data_source_adapter/utils/nodes.ts +++ b/packages/devextreme/js/__internal/grids/tree_list/data_source_adapter/utils/nodes.ts @@ -18,7 +18,7 @@ export type ConvertContext = Pick; export function convertItemToNode( - item: unknown, + item: RawItemData, nodeByKey: NodeByKey, context: ConvertContext, ): TreeNode { From efcf6b81e74f78506fba25584db172efd46246f4 Mon Sep 17 00:00:00 2001 From: Eldar Iusupzhanov Date: Thu, 10 Sep 2026 12:05:08 +0800 Subject: [PATCH 3/3] add tests --- .../utils/__tests__/load_branches.test.ts | 426 ++++++++++++++++++ .../utils/load_branches.ts | 2 - 2 files changed, 426 insertions(+), 2 deletions(-) create mode 100644 packages/devextreme/js/__internal/grids/tree_list/data_source_adapter/utils/__tests__/load_branches.test.ts diff --git a/packages/devextreme/js/__internal/grids/tree_list/data_source_adapter/utils/__tests__/load_branches.test.ts b/packages/devextreme/js/__internal/grids/tree_list/data_source_adapter/utils/__tests__/load_branches.test.ts new file mode 100644 index 000000000000..bfec6adf6618 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/tree_list/data_source_adapter/utils/__tests__/load_branches.test.ts @@ -0,0 +1,426 @@ +import { + describe, expect, it, jest, +} from '@jest/globals'; +import type { DeferredObj } from '@js/core/utils/deferred'; +import { Deferred } from '@js/core/utils/deferred'; +import type { DataSource } from '@ts/data/data_source/data_source'; +import type { StoreLoadOptions } from '@ts/data/data_source/types'; +import type { CustomLoader, CustomLoadResult } from '@ts/grids/grid_core/data_source_adapter/custom_loader'; +import type { RawItemData } from '@ts/grids/grid_core/data_source_adapter/types'; + +import type { LoadOperation, TreeNode } from '../../types'; +import type { LoadBranchesContext } from '../load_branches'; +import { loadBranches } from '../load_branches'; + +const ROOT = 0; + +const item = (id: number, parentId: number): RawItemData => ({ id, parentId }); + +const node = (id: number, parentId: number, children: TreeNode[] = []): TreeNode => ({ + key: id, + data: item(id, parentId), + children, +}); + +interface StoreCall { + loadOptions: StoreLoadOptions; + deferred: DeferredObj; +} + +interface SetupOptions { + cachedData?: RawItemData[]; + expandedKeys?: number[]; + nodes?: TreeNode[]; + maxFilterLengthInRequest?: number; +} + +interface Setup { + context: LoadBranchesContext; + /** The adapter state the context reads lazily, so a test can change it mid-load. */ + state: { + cachedData: RawItemData[] | undefined; + lastOperationId: number | undefined; + }; + storeCalls: StoreCall[]; + loadFromStore: jest.Mock<(loadOptions: StoreLoadOptions) => DeferredObj>; + cancel: jest.Mock<(operationId: number) => void>; + setCachedData: jest.Mock<(data: RawItemData[]) => void>; +} + +const setup = ({ + cachedData = [], + expandedKeys = [], + nodes = [], + maxFilterLengthInRequest = 1500, +}: SetupOptions = {}): Setup => { + const state: { + cachedData: RawItemData[] | undefined; + lastOperationId: number | undefined; + } = { cachedData, lastOperationId: 1 }; + + const storeCalls: StoreCall[] = []; + const loadFromStore = jest.fn((loadOptions: StoreLoadOptions) => { + const deferred = Deferred(); + + storeCalls.push({ loadOptions, deferred }); + + return deferred; + }); + + const cancel = jest.fn(); + const setCachedData = jest.fn((data: RawItemData[]) => { state.cachedData = data; }); + + const nodeByKey: Record = {}; + nodes.forEach((treeNode) => { nodeByKey[treeNode.key as string] = treeNode; }); + + const context: LoadBranchesContext = { + dataSource: { cancel } as unknown as DataSource, + customLoader: { loadFromStore } as unknown as CustomLoader, + rootValue: ROOT, + maxFilterLengthInRequest, + parentIdExpr: 'parentId', + keyExpr: 'id', + _parentIdGetter: (data) => (data as { parentId: unknown }).parentId, + _keyGetter: (data) => (data as { id: unknown }).id, + isRowExpanded: (key) => expandedKeys.includes(key as number), + getCachedData: () => state.cachedData, + setCachedData, + getLastOperationId: () => state.lastOperationId, + getNodeByKey: (key) => nodeByKey[key as string], + }; + + return { + context, state, storeCalls, loadFromStore, cancel, setCachedData, + }; +}; + +const createOptions = (overrides: Partial = {}): LoadOperation => ({ + operationId: 1, + remoteOperations: { filtering: true }, + storeLoadOptions: { sort: 'id' }, + loadOptions: {}, + ...overrides, +} as unknown as LoadOperation); + +interface Tracker { + done: jest.Mock<(branchData: RawItemData[]) => void>; + fail: jest.Mock<(error: unknown) => void>; +} + +/** Deferred callbacks fire synchronously, so handlers attached after the fact still run. */ +const track = (deferred: DeferredObj): Tracker => { + const done = jest.fn<(branchData: RawItemData[]) => void>(); + const fail = jest.fn<(error: unknown) => void>(); + + deferred.done(done).fail(fail); + + return { done, fail }; +}; + +describe('parents', () => { + it('resolves with the passed data when every parent is already loaded', () => { + const { context, loadFromStore } = setup(); + const data = [item(1, ROOT), item(2, 1)]; + + const { done } = track(loadBranches(context, data, createOptions(), false)); + + expect(loadFromStore).not.toHaveBeenCalled(); + expect(done).toHaveBeenCalledWith(data); + }); + + it('requests the missing parent and resolves with the sorted result', () => { + const { context, storeCalls } = setup(); + + const { done } = track(loadBranches(context, [item(2, 1)], createOptions(), false)); + + expect(storeCalls).toHaveLength(1); + expect(storeCalls[0].loadOptions.filter).toEqual(['id', '=', 1]); + + storeCalls[0].deferred.resolve({ data: [item(1, ROOT)] }); + + expect(done).toHaveBeenCalledWith([item(1, ROOT), item(2, 1)]); + }); + + it('walks up the whole ancestor chain, one request per level', () => { + const { context, storeCalls } = setup(); + + const { done } = track(loadBranches(context, [item(3, 2)], createOptions(), false)); + + expect(storeCalls[0].loadOptions.filter).toEqual(['id', '=', 2]); + storeCalls[0].deferred.resolve({ data: [item(2, 1)] }); + + expect(storeCalls).toHaveLength(2); + expect(storeCalls[1].loadOptions.filter).toEqual(['id', '=', 1]); + storeCalls[1].deferred.resolve({ data: [item(1, ROOT)] }); + + expect(done).toHaveBeenCalledWith([item(1, ROOT), item(2, 1), item(3, 2)]); + }); + + it('asks for each missing parent once and never for the root value', () => { + const { context, storeCalls } = setup(); + const data = [item(3, 1), item(4, 1), item(5, ROOT)]; + + track(loadBranches(context, data, createOptions(), false)); + + expect(storeCalls).toHaveLength(1); + expect(storeCalls[0].loadOptions.filter).toEqual(['id', '=', 1]); + }); + + it('combines several missing parents into one `or` filter', () => { + const { context, storeCalls } = setup(); + + track(loadBranches(context, [item(3, 1), item(4, 2)], createOptions(), false)); + + expect(storeCalls[0].loadOptions.filter).toEqual([['id', '=', 1], 'or', ['id', '=', 2]]); + }); + + it('stops and keeps the data it has when the store returns nothing', () => { + const { context, storeCalls } = setup(); + const data = [item(2, 1)]; + + const { done } = track(loadBranches(context, data, createOptions(), false)); + + storeCalls[0].deferred.resolve({ data: [] }); + + expect(storeCalls).toHaveLength(1); + expect(done).toHaveBeenCalledWith(data); + }); + + it('sorts by loadOptions.sort when storeLoadOptions has no sort', () => { + const { context, storeCalls } = setup(); + const options = createOptions({ + storeLoadOptions: {}, + loadOptions: { sort: 'id' }, + }); + + const { done } = track(loadBranches(context, [item(2, 1)], options, false)); + + storeCalls[0].deferred.resolve({ data: [item(1, ROOT)] }); + + expect(done).toHaveBeenCalledWith([item(1, ROOT), item(2, 1)]); + }); +}); + +describe('children', () => { + it('is skipped entirely when children are not needed', () => { + const { context, loadFromStore } = setup({ expandedKeys: [1] }); + const data = [item(1, ROOT), item(2, ROOT)]; + + const { done } = track(loadBranches(context, data, createOptions(), false)); + + expect(loadFromStore).not.toHaveBeenCalled(); + expect(done).toHaveBeenCalledWith(data); + }); + + it('requests the children of expanded rows only', () => { + const { context, storeCalls } = setup({ expandedKeys: [1] }); + const data = [item(1, ROOT), item(2, ROOT)]; + + const { done } = track(loadBranches(context, data, createOptions(), true)); + + expect(storeCalls).toHaveLength(1); + expect(storeCalls[0].loadOptions.filter).toEqual(['parentId', '=', 1]); + + storeCalls[0].deferred.resolve({ data: [item(10, 1)] }); + + expect(done).toHaveBeenCalledWith([item(1, ROOT), item(2, ROOT), item(10, 1)]); + }); + + it('loads the missing parents before the children', () => { + const { context, storeCalls } = setup({ expandedKeys: [3] }); + + const { done } = track(loadBranches(context, [item(3, 1)], createOptions(), true)); + + expect(storeCalls[0].loadOptions.filter).toEqual(['id', '=', 1]); + storeCalls[0].deferred.resolve({ data: [item(1, ROOT)] }); + + expect(storeCalls[1].loadOptions.filter).toEqual(['parentId', '=', 3]); + storeCalls[1].deferred.resolve({ data: [item(30, 3)] }); + + expect(done).toHaveBeenCalledWith([item(1, ROOT), item(3, 1), item(30, 3)]); + }); +}); + +describe('already built nodes', () => { + it('takes the missing parents from the nodes instead of the store', () => { + const { context, loadFromStore } = setup({ nodes: [node(1, ROOT)] }); + + const { done } = track(loadBranches(context, [item(2, 1)], createOptions(), false)); + + expect(loadFromStore).not.toHaveBeenCalled(); + expect(done).toHaveBeenCalledWith([item(1, ROOT), item(2, 1)]); + }); + + it('goes to the store when the nodes cover only part of the missing keys', () => { + const { context, storeCalls } = setup({ nodes: [node(1, ROOT)] }); + + track(loadBranches(context, [item(3, 1), item(4, 2)], createOptions(), false)); + + expect(storeCalls).toHaveLength(1); + expect(storeCalls[0].loadOptions.filter).toEqual([['id', '=', 1], 'or', ['id', '=', 2]]); + }); + + it('takes the children from the nodes instead of the store', () => { + const { context, loadFromStore } = setup({ + expandedKeys: [1], + nodes: [node(1, ROOT, [node(10, 1)])], + }); + + const { done } = track(loadBranches(context, [item(1, ROOT)], createOptions(), true)); + + expect(loadFromStore).not.toHaveBeenCalled(); + expect(done).toHaveBeenCalledWith([item(1, ROOT), item(10, 1)]); + }); + + // The node is fully cached, but has no children to contribute. + it('goes to the store when a cached expanded row has no child nodes', () => { + const { context, storeCalls } = setup({ + expandedKeys: [1], + nodes: [node(1, ROOT)], + }); + + track(loadBranches(context, [item(1, ROOT)], createOptions(), true)); + + expect(storeCalls).toHaveLength(1); + expect(storeCalls[0].loadOptions.filter).toEqual(['parentId', '=', 1]); + }); +}); + +describe('store data cache', () => { + it('appends the loaded rows to the cached store data, sorted', () => { + const { context, storeCalls, setCachedData } = setup({ cachedData: [item(5, ROOT)] }); + + track(loadBranches(context, [item(2, 1)], createOptions(), false)); + storeCalls[0].deferred.resolve({ data: [item(1, ROOT)] }); + + expect(setCachedData).toHaveBeenCalledWith([item(1, ROOT), item(5, ROOT)]); + }); + + it('leaves the cache alone when filtering is local', () => { + const { context, storeCalls, setCachedData } = setup({ cachedData: [item(5, ROOT)] }); + const options = createOptions({ remoteOperations: { filtering: false } }); + + track(loadBranches(context, [item(2, 1)], options, false)); + storeCalls[0].deferred.resolve({ data: [item(1, ROOT)] }); + + expect(setCachedData).not.toHaveBeenCalled(); + }); +}); + +describe('filter longer than maxFilterLengthInRequest', () => { + it('loads unfiltered from a remote store and filters the result locally', () => { + const { context, storeCalls } = setup({ maxFilterLengthInRequest: 0 }); + + const { done } = track(loadBranches(context, [item(2, 1)], createOptions(), false)); + + expect(storeCalls[0].loadOptions.filter).toBeNull(); + + storeCalls[0].deferred.resolve({ data: [item(1, ROOT), item(99, ROOT)] }); + + expect(done).toHaveBeenCalledWith([item(1, ROOT), item(2, 1)]); + }); + + it('hands the predicate to a local store and keeps the cache untouched', () => { + const { context, storeCalls, setCachedData } = setup({ maxFilterLengthInRequest: 0 }); + const options = createOptions({ remoteOperations: { filtering: false } }); + + track(loadBranches(context, [item(2, 1)], options, false)); + + const { filter } = storeCalls[0].loadOptions; + + expect(typeof filter).toBe('function'); + expect((filter as (data: RawItemData) => boolean)(item(1, ROOT))).toBe(true); + expect((filter as (data: RawItemData) => boolean)(item(99, ROOT))).toBe(false); + expect(setCachedData).not.toHaveBeenCalled(); + }); +}); + +describe('fullData', () => { + it('loads the branch from the passed data instead of the store', () => { + const { context, loadFromStore } = setup(); + const options = createOptions({ + remoteOperations: { filtering: false }, + fullData: [item(1, ROOT), item(99, 5)], + }); + + const { done } = track(loadBranches(context, [item(2, 1)], options, false)); + + expect(loadFromStore).not.toHaveBeenCalled(); + expect(done).toHaveBeenCalledWith([item(1, ROOT), item(2, 1)]); + }); +}); + +describe('outdated operation', () => { + it('cancels the operation and rejects without loading', () => { + const { + context, state, loadFromStore, cancel, + } = setup(); + + state.lastOperationId = 2; + + const { done, fail } = track(loadBranches(context, [item(2, 1)], createOptions(), false)); + + expect(cancel).toHaveBeenCalledWith(1); + expect(loadFromStore).not.toHaveBeenCalled(); + expect(done).not.toHaveBeenCalled(); + expect(fail).toHaveBeenCalled(); + }); + + it('proceeds while no other operation has started', () => { + const { context, state, storeCalls } = setup(); + + state.lastOperationId = undefined; + + track(loadBranches(context, [item(2, 1)], createOptions(), false)); + + expect(storeCalls).toHaveLength(1); + }); + + // T1311885: the guard has to run before the loaded rows are merged into the + // cache, which a newer operation has already cleared. + it('rejects and leaves the cache alone when the operation goes stale mid-load', () => { + const { + context, state, storeCalls, setCachedData, + } = setup(); + + const { done, fail } = track(loadBranches(context, [item(2, 1)], createOptions(), false)); + + state.lastOperationId = 2; + state.cachedData = undefined; + + expect(() => storeCalls[0].deferred.resolve({ data: [item(1, ROOT)] })).not.toThrow(); + + expect(setCachedData).not.toHaveBeenCalled(); + expect(done).not.toHaveBeenCalled(); + expect(fail).toHaveBeenCalled(); + }); +}); + +describe('store failure', () => { + it('rejects with the store error', () => { + const { context, storeCalls } = setup(); + const error = new Error('load failed'); + + const { done, fail } = track(loadBranches(context, [item(2, 1)], createOptions(), false)); + + // @ts-expect-error badly typed Deferred.reject + storeCalls[0].deferred.reject(error); + + expect(done).not.toHaveBeenCalled(); + expect(fail).toHaveBeenCalledWith(error); + }); + + it('rejects when a child request fails after the parents were loaded', () => { + const { context, storeCalls } = setup({ expandedKeys: [3] }); + const error = new Error('children failed'); + + const { done, fail } = track(loadBranches(context, [item(3, 1)], createOptions(), true)); + + storeCalls[0].deferred.resolve({ data: [item(1, ROOT)] }); + // @ts-expect-error badly typed Deferred.reject + storeCalls[1].deferred.reject(error); + + expect(done).not.toHaveBeenCalled(); + expect(fail).toHaveBeenCalledWith(error); + }); +}); diff --git a/packages/devextreme/js/__internal/grids/tree_list/data_source_adapter/utils/load_branches.ts b/packages/devextreme/js/__internal/grids/tree_list/data_source_adapter/utils/load_branches.ts index 5a6da0a57c5e..a92b6b29e77c 100644 --- a/packages/devextreme/js/__internal/grids/tree_list/data_source_adapter/utils/load_branches.ts +++ b/packages/devextreme/js/__internal/grids/tree_list/data_source_adapter/utils/load_branches.ts @@ -161,8 +161,6 @@ const loadParentsOrChildren = ( }; const filter: DataFilter = isFilterTooLong ? keyMapFilter : idFilter; - // A remote store cannot run the predicate, so it loads unfiltered - // and the predicate is applied to the result below. const needLocalFiltering = isFilterTooLong && isRemoteFiltering; const loadOptions = extend(