From 090ffda2ddabcf82e36d49270bbea2142a9ef2bf Mon Sep 17 00:00:00 2001 From: Artur Arseniev Date: Fri, 31 Jul 2026 16:03:12 +0400 Subject: [PATCH] Improve data source binding performance for direct record-based resolvers (#6802) * Add context cache to data sources * Enhance DataResolverListener * Add test for data source id change * Allow nested extend --- packages/core/src/data_sources/index.ts | 48 +++++++++++---- .../core/src/data_sources/model/DataRecord.ts | 7 +++ .../src/data_sources/model/DataRecords.ts | 18 ++++++ .../model/DataResolverListener.ts | 52 +++++++++++----- .../core/src/data_sources/model/DataSource.ts | 20 +++++- .../src/data_sources/model/DataSources.ts | 4 +- .../core/src/style_manager/model/Sector.ts | 19 +++--- .../data_sources/dynamic_values/styles.ts | 32 ++++++++++ .../core/test/specs/data_sources/index.ts | 28 +++++++++ .../model/ComponentDataVariable.ts | 34 +++++++++++ .../test/specs/style_manager/model/Models.ts | 61 +++++++++++++++++++ 11 files changed, 285 insertions(+), 38 deletions(-) diff --git a/packages/core/src/data_sources/index.ts b/packages/core/src/data_sources/index.ts index 0c0c69c96..a23e50709 100644 --- a/packages/core/src/data_sources/index.ts +++ b/packages/core/src/data_sources/index.ts @@ -58,6 +58,7 @@ export default class DataSourceManager extends ItemManagerModule }) { - return get(opts?.context || this.getContext(), path, defValue); + if (opts?.context) return get(opts.context, path, defValue); + + const value = this.getValueFromDataSources(path); + + return value === undefined ? defValue : value; } /** @@ -140,18 +145,15 @@ export default class DataSourceManager extends ItemManagerModule { - acc[ds.id] = ds.records.reduce((accR, dr, i) => { - const dataRecord = dr; + if (!this.contextCache) { + this.contextCache = this.all.reduce((acc, ds) => { + acc[ds.id] = ds.getContext(); - const attributes = { ...dataRecord.attributes }; - delete attributes.__p; - accR[dataRecord.id || i] = attributes; - - return accR; + return acc; }, {} as ObjectAny); - return acc; - }, {} as ObjectAny); + } + + return this.contextCache; } /** @@ -182,7 +184,7 @@ export default class DataSourceManager extends ItemManagerModule { + this.contextCache = undefined; + }; + + private getValueFromDataSources(path: string | string[]) { + const pathParts = Array.isArray(path) ? path : stringToPath(path || ''); + if (!pathParts.length) return undefined; + + const [dsId, drId, ...resPath] = pathParts; + const dataSource = this.get(dsId); + + if (!dataSource) return undefined; + if (drId === undefined) return dataSource.getContext(); + + const dataRecord = dataSource.getRecord(drId); + if (!dataRecord) return undefined; + + const recordContext = dataRecord.getContext(); + + return resPath.length ? get(recordContext, resPath) : recordContext; + } + /** * Store data sources to a JSON object. * @returns {Array} Stored data sources. diff --git a/packages/core/src/data_sources/model/DataRecord.ts b/packages/core/src/data_sources/model/DataRecord.ts index d0ce687e3..a5f94de8b 100644 --- a/packages/core/src/data_sources/model/DataRecord.ts +++ b/packages/core/src/data_sources/model/DataRecord.ts @@ -56,6 +56,13 @@ export default class DataRecord ext return this.cl.indexOf(this); } + getContext() { + const attributes = { ...this.attributes }; + delete attributes.__p; + + return attributes; + } + /** * Handles changes to the record's attributes. * This method triggers a change event for each property that has been altered. diff --git a/packages/core/src/data_sources/model/DataRecords.ts b/packages/core/src/data_sources/model/DataRecords.ts index 1eadfcc22..08952501c 100644 --- a/packages/core/src/data_sources/model/DataRecords.ts +++ b/packages/core/src/data_sources/model/DataRecords.ts @@ -10,6 +10,24 @@ export default class DataRecords ex super(models, options); this.dataSource = options.dataSource; } + + getRecord(id: string | number): DataRecord | undefined { + return this.get(id) || this.getRecordByIndex(id); + } + + isIndexKey(id: string | number) { + return !this.get(id) && !!this.getRecordByIndex(id); + } + + private getRecordByIndex(id: string | number) { + const index = this.getIndex(id); + return index === undefined ? undefined : this.at(index); + } + + private getIndex(id: string | number) { + const index = typeof id === 'number' ? id : Number(id); + return Number.isInteger(index) && `${index}` === `${id}` && index >= 0 ? index : undefined; + } } DataRecords.prototype.model = DataRecord; diff --git a/packages/core/src/data_sources/model/DataResolverListener.ts b/packages/core/src/data_sources/model/DataResolverListener.ts index f2a211904..ef72be56e 100644 --- a/packages/core/src/data_sources/model/DataResolverListener.ts +++ b/packages/core/src/data_sources/model/DataResolverListener.ts @@ -1,9 +1,11 @@ -import { DataSourcesEvents, DataSourceListener } from '../types'; +import { DataSourceListener } from '../types'; import { stringToPath } from '../../utils/mixins'; import { Model } from '../../common'; import EditorModel from '../../editor/model/Editor'; import DataVariable, { DataVariableType } from './DataVariable'; import { DataResolver } from '../types'; +import DataRecord from './DataRecord'; +import DataSource from './DataSource'; import { DataCondition, DataConditionOutputChangedEvent, @@ -88,34 +90,52 @@ export default class DataResolverListener { const path = dataVariable.getResolverPath(); if (!path) return dataListeners; - const normPath = stringToPath(path || '').join('.'); + const dsAll = em.DataSources.all; + const [dsId, drKey, ...propPathParts] = stringToPath(path || ''); const [ds, dr] = em.DataSources.fromPath(path!); + const isIndexPath = !!ds && drKey !== undefined && ds.isRecordIndex(drKey); + const onMatchingSourceChange = (ds: DataSource) => ds.id === dsId && onChangeAndRewatch(); - if (ds) { - dataListeners.push(this.createListener(ds.records, 'add remove reset', onChangeAndRewatch)); + dataListeners.push( + this.createListener(dsAll, 'add remove', onMatchingSourceChange), + this.createListener(dsAll, 'reset', onChangeAndRewatch), + ); + + if (!ds) return dataListeners; + + if (drKey === undefined) { + dataListeners.push(this.createListener(ds.records, 'add remove reset change', onChangeAndRewatch)); + return dataListeners; } if (dr) { - dataListeners.push(this.createListener(dr, 'change')); + const onRecordChange = (record: DataRecord) => this.onRecordChange(record, propPathParts); + dataListeners.push(this.createListener(dr, 'change', onRecordChange)); } + if (isIndexPath) { + dataListeners.push(this.createListener(ds.records, 'add remove reset', onChangeAndRewatch)); + return dataListeners; + } + + const onMatchingRecordChange = (record: DataRecord) => `${record.id}` === `${drKey}` && onChangeAndRewatch(); dataListeners.push( - this.createListener(em.DataSources.all, 'add remove reset', onChangeAndRewatch), - this.createListener(em, `${DataSourcesEvents.path}:${normPath}`), - this.createListener(em, DataSourcesEvents.path, ({ path: eventPath }: { path: string }) => { - if ( - // Skip same path as it's already handled be the listener above - eventPath !== path && - eventPath.startsWith(path) - ) { - this.onChange(); - } - }), + this.createListener(ds.records, 'add remove', onMatchingRecordChange), + this.createListener(ds.records, 'reset', onChangeAndRewatch), ); return dataListeners; } + private onRecordChange(record: DataRecord, propPath: string[]) { + const changed = Object.keys(record.changedAttributes() || {}); + const [rootProp] = propPath; + + if (!changed.length || changed.includes('id') || !propPath.length || (rootProp && changed.includes(rootProp))) { + this.onChange(); + } + } + private removeListeners() { this.listeners.forEach((ls) => this.model.stopListening(ls.obj, ls.event, ls.callback)); this.listeners = []; diff --git a/packages/core/src/data_sources/model/DataSource.ts b/packages/core/src/data_sources/model/DataSource.ts index 9d9909738..e70823014 100644 --- a/packages/core/src/data_sources/model/DataSource.ts +++ b/packages/core/src/data_sources/model/DataSource.ts @@ -171,7 +171,11 @@ export default class DataSource this.getRecord(record.id)!); } + getContext() { + return this.records.reduce( + (acc, dataRecord, index) => { + acc[dataRecord.id || index] = dataRecord.getContext(); + return acc; + }, + {} as Record>, + ); + } + /** * Retrieves all records from the data source with resolved relations based on the schema. */ @@ -317,7 +331,9 @@ export default class DataSource { this.model = (props: DataSourceProps, opts = {}) => { return new DataSource(props, { ...opts, em }); }; + + this.on(collectionEvents, () => em.DataSources.invalidateContextCache()); } } diff --git a/packages/core/src/style_manager/model/Sector.ts b/packages/core/src/style_manager/model/Sector.ts index bf4f76ba8..fca5d90ac 100644 --- a/packages/core/src/style_manager/model/Sector.ts +++ b/packages/core/src/style_manager/model/Sector.ts @@ -200,14 +200,19 @@ export default class Sector extends Model { checkExtend(prop: any): PropertyTypes { const { extend, ...rest } = (isString(prop) ? { extend: prop } : prop) || {}; - if (extend) { - return { - ...(this.buildProperties([extend])[0] || {}), - ...rest, - }; - } else { - return prop; + const result = extend + ? { + ...(this.buildProperties([extend])[0] || {}), + ...rest, + } + : prop; + const properties = result?.properties; + + if (properties?.length) { + result.properties = properties.map((nestedProp: PropertyTypes) => this.checkExtend(nestedProp)); } + + return result; } /** diff --git a/packages/core/test/specs/data_sources/dynamic_values/styles.ts b/packages/core/test/specs/data_sources/dynamic_values/styles.ts index 39f2ce4a3..d21e6daa8 100644 --- a/packages/core/test/specs/data_sources/dynamic_values/styles.ts +++ b/packages/core/test/specs/data_sources/dynamic_values/styles.ts @@ -158,6 +158,38 @@ describe('StyleDataVariable', () => { expect(updatedStyle).toHaveProperty('color', 'blue'); }); + test('component style binding ignores unrelated record add/remove churn', () => { + dsm.add({ + id: 'style-churn', + records: [ + { id: 'bound-record', color: 'red' }, + { id: 'other-record', color: 'blue' }, + ], + }); + + const cmp = cmpRoot.append({ + tagName: 'h1', + type: 'text', + content: 'Hello World', + style: { + color: { + type: DataVariableType, + defaultValue: 'black', + path: 'style-churn.bound-record.color', + }, + }, + })[0]; + + const addStyleSpy = jest.spyOn(cmp, 'addStyle'); + const ds = dsm.get('style-churn'); + + ds.addRecord({ id: 'new-record', color: 'green' }); + ds.removeRecord('other-record'); + + expect(addStyleSpy).not.toHaveBeenCalled(); + expect(cmp.getStyle()).toHaveProperty('color', 'red'); + }); + describe('Component style manipulations', () => { test('adding a new dynamic style with addStyle', () => { dsm.add({ id: 'data1', records: [{ id: 'rec1', color: 'red' }] }); diff --git a/packages/core/test/specs/data_sources/index.ts b/packages/core/test/specs/data_sources/index.ts index 9584753ef..b9132f9e0 100644 --- a/packages/core/test/specs/data_sources/index.ts +++ b/packages/core/test/specs/data_sources/index.ts @@ -82,6 +82,34 @@ describe('DataSourceManager', () => { expect(dsm.getValue(`ds1.id4.metadata.roles`)).toEqual(roles); expect(dsm.getValue(`ds1.id4.metadata.roles[1]`)).toEqual(roles[1]); }); + + test('with index-based record path', () => { + dsm.add({ + id: 'recordsByIndex', + records: [{ name: 'First item' }, { name: 'Second item' }] as any, + }); + + expect(dsm.getValue('recordsByIndex.0.name')).toBe('First item'); + expect(dsm.getValue('recordsByIndex.1.name')).toBe('Second item'); + }); + }); + + describe('getContext', () => { + test('memoizes until data changes', () => { + const ds = addDataSource(); + + const contextA = dsm.getContext(); + const contextB = dsm.getContext(); + + expect(contextA).toBe(contextB); + + ds.getRecord('id1')?.set({ name: 'Name1 updated' }); + + const contextC = dsm.getContext(); + + expect(contextC).not.toBe(contextA); + expect(contextC.ds1.id1.name).toBe('Name1 updated'); + }); }); describe('setValue', () => { diff --git a/packages/core/test/specs/data_sources/model/ComponentDataVariable.ts b/packages/core/test/specs/data_sources/model/ComponentDataVariable.ts index 64b0cb543..4637b32a9 100644 --- a/packages/core/test/specs/data_sources/model/ComponentDataVariable.ts +++ b/packages/core/test/specs/data_sources/model/ComponentDataVariable.ts @@ -165,6 +165,40 @@ describe('ComponentDataVariable', () => { expect(cmp.getInnerHTML()).toContain('default'); }); + test('component updates on record id rename and rebinds when the original id returns', () => { + dsm.add({ + id: 'dsRename', + records: [{ id: 'id1', name: 'Name1' }], + }); + + const cmp = cmpRoot.append({ + tagName: 'div', + type: 'default', + components: [ + { + type: DataVariableType, + dataResolver: { defaultValue: 'default', path: 'dsRename.id1.name' }, + }, + ], + })[0]; + + expect(cmp.getEl()?.innerHTML).toContain('Name1'); + expect(cmp.getInnerHTML()).toContain('Name1'); + + const ds = dsm.get('dsRename'); + const record = ds.getRecord('id1'); + + record?.set({ id: 'id2' as any }); + + expect(cmp.getEl()?.innerHTML).toContain('default'); + expect(cmp.getInnerHTML()).toContain('default'); + + ds.addRecord({ id: 'id1', name: 'Name1 rebound' }); + + expect(cmp.getEl()?.innerHTML).toContain('Name1 rebound'); + expect(cmp.getInnerHTML()).toContain('Name1 rebound'); + }); + test('component initializes and updates with data-variable for nested object', () => { const dataSource = { id: 'dsNestedObject', diff --git a/packages/core/test/specs/style_manager/model/Models.ts b/packages/core/test/specs/style_manager/model/Models.ts index 798e05234..1f700eada 100644 --- a/packages/core/test/specs/style_manager/model/Models.ts +++ b/packages/core/test/specs/style_manager/model/Models.ts @@ -111,6 +111,67 @@ describe('Sector', () => { expect(propTop.get('name')).toEqual('Top'); expect(propTop.get('type')).toEqual('number'); }); + + test('Extend nested properties on extended composite properties', () => { + obj = sm.addSector('test', { + name: 'test', + properties: [ + { + extend: 'border-radius', + // @ts-ignore + properties: [ + { + extend: 'border-top-left-radius', + id: 'border-top-left-radius-custom', + }, + { + extend: 'border-bottom-left-radius', + }, + ], + }, + ], + }); + const prop0 = obj.getProperties()[0]; + const propProps = prop0.get('properties' as any); + + expect(propProps.length).toEqual(2); + expect(propProps.at(0).get('id')).toEqual('border-top-left-radius-custom'); + expect(propProps.at(0).get('type')).toEqual('number'); + expect(propProps.at(1).get('property')).toEqual('border-bottom-left-radius'); + expect(propProps.at(1).get('type')).toEqual('number'); + }); + + test('Extend nested properties on stack properties', () => { + obj = sm.addSector('test', { + name: 'test', + properties: [ + { + type: 'stack', + property: 'my-shadow', + // @ts-ignore + properties: [ + { + extend: 'text-shadow-h', + property: 'my-shadow-h', + }, + { + extend: 'text-shadow-v', + property: 'my-shadow-v', + }, + ], + }, + ], + }); + const prop0 = obj.getProperties()[0]; + const propProps = prop0.get('properties' as any); + + expect(propProps.length).toEqual(2); + expect(propProps.at(0).get('property')).toEqual('my-shadow-h'); + expect(propProps.at(0).get('type')).toEqual('number'); + expect(propProps.at(0).get('units')).toEqual(['px', 'em', 'rem', 'vh', 'vw']); + expect(propProps.at(1).get('property')).toEqual('my-shadow-v'); + expect(propProps.at(1).get('type')).toEqual('number'); + }); }); describe('Property', () => {