Browse Source

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
pull/6803/head
Artur Arseniev 1 week ago
committed by GitHub
parent
commit
090ffda2dd
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 48
      packages/core/src/data_sources/index.ts
  2. 7
      packages/core/src/data_sources/model/DataRecord.ts
  3. 18
      packages/core/src/data_sources/model/DataRecords.ts
  4. 52
      packages/core/src/data_sources/model/DataResolverListener.ts
  5. 20
      packages/core/src/data_sources/model/DataSource.ts
  6. 4
      packages/core/src/data_sources/model/DataSources.ts
  7. 19
      packages/core/src/style_manager/model/Sector.ts
  8. 32
      packages/core/test/specs/data_sources/dynamic_values/styles.ts
  9. 28
      packages/core/test/specs/data_sources/index.ts
  10. 34
      packages/core/test/specs/data_sources/model/ComponentDataVariable.ts
  11. 61
      packages/core/test/specs/style_manager/model/Models.ts

48
packages/core/src/data_sources/index.ts

@ -58,6 +58,7 @@ export default class DataSourceManager extends ItemManagerModule<DataSourcesConf
number: NumberOperation,
string: StringOperation,
};
private contextCache?: ObjectAny;
destroy(): void {}
constructor(em: EditorModel) {
@ -114,7 +115,11 @@ export default class DataSourceManager extends ItemManagerModule<DataSourcesConf
* const value = dsm.getValue('ds_id.record_id.propName', 'defaultValue');
*/
getValue(path: string | string[], defValue?: any, opts?: { context?: Record<string, any> }) {
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<DataSourcesConf
}
getContext() {
return this.all.reduce((acc, ds) => {
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<DataSourcesConf
const result: [DataSource?, DataRecord?, string?] = [];
const [dsId, drId, ...resPath] = stringToPath(path || '');
const dataSource = this.get(dsId);
const dataRecord = dataSource?.records.get(drId);
const dataRecord = drId !== undefined && dataSource?.getRecord(drId);
dataSource && result.push(dataSource);
if (dataRecord) {
@ -193,6 +195,28 @@ export default class DataSourceManager extends ItemManagerModule<DataSourcesConf
return result;
}
invalidateContextCache = () => {
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.

7
packages/core/src/data_sources/model/DataRecord.ts

@ -56,6 +56,13 @@ export default class DataRecord<T extends DataRecordProps = DataRecordProps> 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.

18
packages/core/src/data_sources/model/DataRecords.ts

@ -10,6 +10,24 @@ export default class DataRecords<T extends DataRecordProps = DataRecordProps> ex
super(models, options);
this.dataSource = options.dataSource;
}
getRecord(id: string | number): DataRecord<T> | 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;

52
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 = [];

20
packages/core/src/data_sources/model/DataSource.ts

@ -171,7 +171,11 @@ export default class DataSource<DRProps extends DataRecordProps = DataRecordProp
* @name getRecord
*/
getRecord(id: string | number): DataRecord | undefined {
return this.records.get(id);
return this.records.getRecord(id);
}
isRecordIndex(id: string | number) {
return this.records.isIndexKey(id);
}
/**
@ -185,6 +189,16 @@ export default class DataSource<DRProps extends DataRecordProps = DataRecordProp
return [...this.records.models].map((record) => this.getRecord(record.id)!);
}
getContext() {
return this.records.reduce(
(acc, dataRecord, index) => {
acc[dataRecord.id || index] = dataRecord.getContext();
return acc;
},
{} as Record<string, Partial<DRProps>>,
);
}
/**
* Retrieves all records from the data source with resolved relations based on the schema.
*/
@ -317,7 +331,9 @@ export default class DataSource<DRProps extends DataRecordProps = DataRecordProp
}
private handleChanges(dataRecord: any, c: any, o: any) {
const { em } = this;
const options = o || c;
this.em.changesUp(options, { dataRecord, options });
em.DataSources.invalidateContextCache();
em.changesUp(options, { dataRecord, options });
}
}

4
packages/core/src/data_sources/model/DataSources.ts

@ -1,4 +1,4 @@
import { Collection } from '../../common';
import { Collection, collectionEvents } from '../../common';
import EditorModel from '../../editor/model/Editor';
import { DataRecordProps, DataSourceProps } from '../types';
import DataSource from './DataSource';
@ -14,5 +14,7 @@ export default class DataSources extends Collection<DataSource> {
this.model = (props: DataSourceProps, opts = {}) => {
return new DataSource(props, { ...opts, em });
};
this.on(collectionEvents, () => em.DataSources.invalidateContextCache());
}
}

19
packages/core/src/style_manager/model/Sector.ts

@ -200,14 +200,19 @@ export default class Sector extends Model<SectorProperties> {
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;
}
/**

32
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' }] });

28
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', () => {

34
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',

61
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', () => {

Loading…
Cancel
Save