From 48be01b8602a17447a589fb811a7ba11e58543da Mon Sep 17 00:00:00 2001 From: Artur Arseniev Date: Tue, 28 Apr 2026 14:02:14 +0400 Subject: [PATCH] Improve loading performance (#6755) * Cache list counter * Skip model resolver when possible * Clean up Component.getView * Cleanup SelectComponent * Update Component getView * Check in CommandAbstract --- .../core/src/commands/view/CommandAbstract.ts | 2 +- .../core/src/commands/view/SelectComponent.ts | 7 ++- .../src/dom_components/model/Component.ts | 50 ++++++++++++++----- .../src/dom_components/model/Components.ts | 3 +- .../model/ModelDataResolverWatchers.ts | 48 ++++++++++++++---- .../model/ModelResolverWatcher.ts | 4 ++ .../domain_abstract/model/StyleableModel.ts | 11 ++-- packages/core/src/undo_manager/index.ts | 9 +++- packages/core/test/specs/commands/index.ts | 21 ++++++++ .../core/test/specs/undo_manager/index.ts | 16 ++++++ 10 files changed, 142 insertions(+), 29 deletions(-) diff --git a/packages/core/src/commands/view/CommandAbstract.ts b/packages/core/src/commands/view/CommandAbstract.ts index c9e9356a4..1f1e78961 100644 --- a/packages/core/src/commands/view/CommandAbstract.ts +++ b/packages/core/src/commands/view/CommandAbstract.ts @@ -160,7 +160,7 @@ export default class CommandAbstract extends Model { * Stop current command */ stopCommand(opts?: any) { - this.em.Commands.stop(this.id as string, opts); + this.em?.Commands?.stop?.(this.id as string, opts); } /** diff --git a/packages/core/src/commands/view/SelectComponent.ts b/packages/core/src/commands/view/SelectComponent.ts index e22849fc8..1d9ee978f 100644 --- a/packages/core/src/commands/view/SelectComponent.ts +++ b/packages/core/src/commands/view/SelectComponent.ts @@ -69,6 +69,10 @@ export default { * */ stopSelectComponent() { this.toggleSelectComponent(); + this.onContainerChange.cancel?.(); + this.onSelect.cancel?.(); + this.updateAttached.cancel?.(); + this._upToolbar.cancel?.(); }, /** @@ -708,7 +712,6 @@ export default { this.stopSelectComponent(); !opts.preserveSelected && em.setSelected(); this.toggleToolsEl(); - this.updateAttached.cancel(); - editor && editor.stopCommand('resize'); + editor?.stopCommand('resize'); }, } as CommandObject; diff --git a/packages/core/src/dom_components/model/Component.ts b/packages/core/src/dom_components/model/Component.ts index dcf5d69db..fe82d30ea 100644 --- a/packages/core/src/dom_components/model/Component.ts +++ b/packages/core/src/dom_components/model/Component.ts @@ -95,6 +95,8 @@ type GetComponentStyleOpts = GetStyleOpts & { inline?: boolean; }; +const idsMapCounter = Symbol('idsMapCounter'); + /** * The Component object represents a single node of our template structure, so when you update its properties the changes are * immediately reflected on the canvas and in the code to export (indeed, when you ask to export the code we just go through all @@ -412,11 +414,17 @@ export default class Component extends StyleableModel { const { em } = this; const um = em?.UndoManager; const comps = this.components(); - if (um && !this.__hasUm) { + + if (!um || this.__hasUm) { + return; + } + + if (um) { um.add(comps); um.add(this.getSelectors()); this.__hasUm = true; } + opts.recursive && comps.map((c) => c.__postAdd(opts)); } @@ -1754,14 +1762,7 @@ export default class Component extends StyleableModel { * @return {ComponentView} */ getView(frame?: Frame) { - let { view, views, em } = this; - const frm = frame || em?.getCurrentFrameModel(); - - if (frm) { - view = views.filter((view) => view.frameView === frm.view)[0]; - } - - return view; + return super.getView(frame) as ComponentView | undefined; } getCurrentView() { @@ -2072,7 +2073,7 @@ export default class Component extends StyleableModel { const current = list[id]; if (!current) { - list[id] = model; + Component.setListId(list, id, model); } else if (current !== model) { const keepIdsCrossPages = model.em?.Components.config.keepAttributeIdsCrossPages; const currentPage = current.page; @@ -2132,12 +2133,12 @@ export default class Component extends StyleableModel { idMap[currentId] = nextId; } - list[nextId] = model; + Component.setListId(list, nextId, model); return nextId; } static getNewId(list: ObjectAny) { - const count = Object.keys(list).length; + const count = Component.getListCount(list); const ilen = count.toString().length + 2; const uid = (Math.random() + 1.1).toString(36).slice(-ilen); let newId = `i${uid}`; @@ -2149,6 +2150,31 @@ export default class Component extends StyleableModel { return newId; } + static getListCount(list: ObjectAny) { + const listWithCounter = list as ObjectAny & { [idsMapCounter]?: number }; + let count = listWithCounter[idsMapCounter]; + + if (isUndefined(count)) { + count = Object.keys(list).length; + Object.defineProperty(list, idsMapCounter, { + configurable: true, + writable: true, + value: count, + }); + } + + return count; + } + + static setListId(list: ObjectAny, id: string, model: Component) { + if (!list[id]) { + const listWithCounter = list as ObjectAny & { [idsMapCounter]?: number }; + listWithCounter[idsMapCounter] = Component.getListCount(list) + 1; + } + + list[id] = model; + } + static getIncrementId(id: string, list: ObjectAny, opts: { keepIds?: string[] } = {}) { const { keepIds = [] } = opts; let counter = 1; diff --git a/packages/core/src/dom_components/model/Components.ts b/packages/core/src/dom_components/model/Components.ts index 9492f3265..ce45676f5 100644 --- a/packages/core/src/dom_components/model/Components.ts +++ b/packages/core/src/dom_components/model/Components.ts @@ -405,7 +405,8 @@ Component> { onAdd(model: Component, c?: any, opts: { temporary?: boolean } = {}) { const { domc, em } = this; const avoidInline = em.config.avoidInlineStyle; - domc && domc.Component.ensureInList(model); + const allById = domc?.allById(); + allById?.[model.getId()] !== model && domc?.Component.ensureInList(model); if (!avoidInline && em.config.forceClass && !opts.temporary) { const style = model.getStyle(); diff --git a/packages/core/src/dom_components/model/ModelDataResolverWatchers.ts b/packages/core/src/dom_components/model/ModelDataResolverWatchers.ts index 4748da4b3..907465dea 100644 --- a/packages/core/src/dom_components/model/ModelDataResolverWatchers.ts +++ b/packages/core/src/dom_components/model/ModelDataResolverWatchers.ts @@ -9,6 +9,7 @@ import { getSymbolsToUpdate, isSymbol } from './SymbolUtils'; import Component, { keySymbolOvrd } from './Component'; import { StyleableModelProperties } from '../../domain_abstract/model/StyleableModel'; import { isEmpty, isObject } from 'underscore'; +import { isDataResolverProps } from '../../data_sources/utils'; export const updateFromWatcher = { fromDataSource: true, avoidStore: true }; export const keyDataValues = '__data_values'; @@ -63,6 +64,25 @@ export class ModelDataResolverWatchers { return evaluatedProps; } + shouldResolveProps(props: ObjectAny) { + if (this.hasDataResolvers()) return true; + if (this.hasDataValues(props[keyDataValues])) return true; + if (this.hasResolverValue(props.attributes) || this.hasResolverValue(props.style)) return true; + + const { excludedFromEvaluation } = this; + const propKeys = Object.keys(props); + + for (let index = 0; index < propKeys.length; index++) { + const key = propKeys[index]; + + if (!excludedFromEvaluation.includes(key) && isDataResolverProps(props[key])) { + return true; + } + } + + return false; + } + getProps(data: ObjectAny): ObjectAny { const resolvedProps = this.getValueOrResolver('props', data); const result = { @@ -137,6 +157,24 @@ export class ModelDataResolverWatchers { return [this.propertyWatcher, this.styleWatcher, this.attributeWatcher]; } + private get excludedFromEvaluation() { + return ['components', 'dataResolver', 'status', 'state', 'open', keySymbolOvrd, keyDataValues]; + } + + private hasDataResolvers() { + return this.watchers.some((watcher) => watcher.hasDataResolvers()); + } + + private hasDataValues(dataValues: ObjectAny | undefined) { + return Object.values(dataValues || {}).some((value) => isObject(value) && !isEmpty(value)); + } + + private hasResolverValue(values: ObjectAny | string | undefined) { + if (!isObject(values)) return false; + + return Object.values(values).some(isDataResolverProps); + } + private isComponent(model: any): model is Component { return model instanceof Component; } @@ -187,15 +225,7 @@ export class ModelDataResolverWatchers { } private filterProps(props: ObjectAny) { - const excludedFromEvaluation = [ - 'components', - 'dataResolver', - 'status', - 'state', - 'open', - keySymbolOvrd, - keyDataValues, - ]; + const { excludedFromEvaluation } = this; const filteredProps = Object.fromEntries( Object.entries(props).filter(([key]) => !excludedFromEvaluation.includes(key)), ); diff --git a/packages/core/src/dom_components/model/ModelResolverWatcher.ts b/packages/core/src/dom_components/model/ModelResolverWatcher.ts index 2f9ad8848..af683204b 100644 --- a/packages/core/src/dom_components/model/ModelResolverWatcher.ts +++ b/packages/core/src/dom_components/model/ModelResolverWatcher.ts @@ -245,6 +245,10 @@ export class ModelResolverWatcher { return serializableValues; } + hasDataResolvers() { + return Object.keys(this.resolverListeners).length > 0; + } + getValuesResolvingFromCollections() { const keys = Object.keys(this.resolverListeners).filter((key: string) => { return this.resolverListeners[key].resolver.resolvesFromCollection(); diff --git a/packages/core/src/domain_abstract/model/StyleableModel.ts b/packages/core/src/domain_abstract/model/StyleableModel.ts index 36005186f..86cdc0005 100644 --- a/packages/core/src/domain_abstract/model/StyleableModel.ts +++ b/packages/core/src/domain_abstract/model/StyleableModel.ts @@ -47,6 +47,7 @@ type WithDataResolvers = { export default class StyleableModel extends Model { em?: EditorModel; views: StyleableView[] = []; + view?: StyleableView; dataResolverWatchers: ModelDataResolverWatchers; collectionsStateMap: DataCollectionStateMap = {}; opt: { em?: EditorModel }; @@ -98,7 +99,10 @@ export default class StyleableModel ex } this.dataResolverWatchers = this.dataResolverWatchers ?? options.dataResolverWatchers; - const evaluatedValues = this.dataResolverWatchers.addProps(attributes, options) as Partial; + const shouldResolveProps = this.dataResolverWatchers.shouldResolveProps(attributes); + const evaluatedValues: Partial = shouldResolveProps + ? (this.dataResolverWatchers.addProps(attributes, options) as Partial) + : (attributes as Partial); return super.set(evaluatedValues, options); } @@ -213,9 +217,10 @@ export default class StyleableModel ex } getView(frame?: Frame) { - let { views, em } = this; + let { em, view } = this; + const views = this.views || []; const frm = frame || em?.getCurrentFrameModel(); - return frm ? views.find((v) => v.frameView === frm.view) : views[0]; + return frm ? views.find((v) => v.frameView === frm.view) : views[0] || view; } setView(view: StyleableView) { diff --git a/packages/core/src/undo_manager/index.ts b/packages/core/src/undo_manager/index.ts index 07deafeaa..88ec5a96b 100644 --- a/packages/core/src/undo_manager/index.ts +++ b/packages/core/src/undo_manager/index.ts @@ -148,9 +148,13 @@ export default class UndoManagerModule extends Module this.um.on(ev, () => em.trigger(ev))); } + get isDisabled() { + return !!this.config._disable; + } + postLoad() { const { config, em } = this; - config.trackSelection && em && this.add(em.get('selected')); + config.trackSelection && !this.isDisabled && em && this.add(em.get('selected')); } /** @@ -169,6 +173,7 @@ export default class UndoManagerModule extends Module { expect(obj.get('select-comp')).not.toBeUndefined(); }); + test('Select component command cancels pending debounced callbacks on teardown', () => { + const command = obj.get('select-comp') as any; + const cancelOnContainerChange = jest.fn(); + const cancelOnSelect = jest.fn(); + const cancelUpdateAttached = jest.fn(); + const cancelToolbar = jest.fn(); + command.toggleSelectComponent = jest.fn(); + command.onContainerChange = { cancel: cancelOnContainerChange }; + command.onSelect = { cancel: cancelOnSelect }; + command.updateAttached = { cancel: cancelUpdateAttached }; + command._upToolbar = { cancel: cancelToolbar }; + + command.stopSelectComponent(); + + expect(command.toggleSelectComponent).toHaveBeenCalledWith(); + expect(cancelOnContainerChange).toHaveBeenCalledTimes(1); + expect(cancelOnSelect).toHaveBeenCalledTimes(1); + expect(cancelUpdateAttached).toHaveBeenCalledTimes(1); + expect(cancelToolbar).toHaveBeenCalledTimes(1); + }); + test('Commands module should not have toLoad property', () => { // @ts-ignore expect(obj.toLoad).toBeUndefined(); diff --git a/packages/core/test/specs/undo_manager/index.ts b/packages/core/test/specs/undo_manager/index.ts index 060fcafdb..a29c97d6c 100644 --- a/packages/core/test/specs/undo_manager/index.ts +++ b/packages/core/test/specs/undo_manager/index.ts @@ -25,6 +25,22 @@ describe('Undo Manager', () => { expect(um.getStack()).toHaveLength(0); }); + test('Disabled undo manager does not track changes', () => { + const { editor, um } = setupTestEditor({ + withCanvas: true, + config: { undoManager: false }, + }); + const wrapper = editor.getWrapper()!; + + wrapper.append('
'); + + expect(um.isDisabled).toBe(true); + expect(um.hasUndo()).toBe(false); + expect(um.getStack()).toHaveLength(0); + + editor.destroy(); + }); + describe('Component changes', () => { test('Add component', () => { expect(wrapper.components()).toHaveLength(0);