From ed0258d576a60363891f8be90e66a7acd569ff74 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 28 May 2026 22:30:15 +0000 Subject: [PATCH 1/4] Perf: Memoize getActiveControls per settings model behind experiment The editor bootstrap re-evaluates control visibility for every element on every render, repeatedly calling isActiveControl/convertConditionToConditions inside getActiveControls. On heavy pages this is the dominant cost in the editor.min.js / editor-modules.min.js layers. Cache the result of getActiveControls() per BaseSettingsModel instance when called with default arguments, keyed by a monotonic version counter that is bumped on the Backbone change event. The cache is invalidated on any settings mutation, so callers see identical behavior. Gated behind a new e_memoize_active_controls beta experiment (default inactive) so third-party add-ons that mutate widget schemas or settings outside of the Backbone change channel can opt out. Adds two Playwright sanity specs: - editor-bootstrap-performance.test.ts: builds a heavy page, then opens the editor 3x with the experiment off and 3x with it on, asserting identical element rendering and no >15% bootstrap regression. - editor-bootstrap-cpu-profile.test.ts: captures before/after CPU profiles via CDP for offline analysis (reports/bootstrap-experiment-*.cpuprofile). https://claude.ai/code/session_013xhQk6ZEbAUErmzFX9R9mx --- .../editor/elements/models/base-settings.js | 24 +++++ core/experiments/manager.php | 9 ++ .../editor-bootstrap-cpu-profile.test.ts | 91 ++++++++++++++++ .../editor-bootstrap-performance.test.ts | 102 ++++++++++++++++++ 4 files changed, 226 insertions(+) create mode 100644 tests/playwright/sanity/editor-bootstrap-cpu-profile.test.ts create mode 100644 tests/playwright/sanity/editor-bootstrap-performance.test.ts diff --git a/assets/dev/js/editor/elements/models/base-settings.js b/assets/dev/js/editor/elements/models/base-settings.js index 0ff12bf51b1c..1135ad64b354 100644 --- a/assets/dev/js/editor/elements/models/base-settings.js +++ b/assets/dev/js/editor/elements/models/base-settings.js @@ -74,6 +74,15 @@ BaseSettingsModel = Backbone.Model.extend( { self.handleRepeaterData( attrs ); self.set( attrs ); + + if ( elementorCommon?.config?.experimentalFeatures?.e_memoize_active_controls ) { + self.__activeControlsCacheVersion = 0; + self.__activeControlsCache = null; + self.on( 'change', () => { + self.__activeControlsCacheVersion++; + self.__activeControlsCache = null; + } ); + } }, convertRepeaterValueToCollection( attrs, repeaterControl ) { @@ -210,6 +219,14 @@ BaseSettingsModel = Backbone.Model.extend( { }, getActiveControls( controls, attributes ) { + const useCache = elementorCommon?.config?.experimentalFeatures?.e_memoize_active_controls && + undefined === controls && + undefined === attributes; + + if ( useCache && this.__activeControlsCache && this.__activeControlsCache.version === this.__activeControlsCacheVersion ) { + return this.__activeControlsCache.result; + } + const activeControls = {}; if ( ! controls ) { @@ -228,6 +245,13 @@ BaseSettingsModel = Backbone.Model.extend( { } } ); + if ( useCache ) { + this.__activeControlsCache = { + version: this.__activeControlsCacheVersion, + result: activeControls, + }; + } + return activeControls; }, diff --git a/core/experiments/manager.php b/core/experiments/manager.php index f936db09c7b6..2e7b7ede4a24 100644 --- a/core/experiments/manager.php +++ b/core/experiments/manager.php @@ -379,6 +379,15 @@ private function add_default_features() { 'minimum_installation_version' => '3.30.0', ], ] ); + + $this->add_feature( [ + 'name' => 'e_memoize_active_controls', + 'title' => esc_html__( 'Memoize Active Controls', 'elementor' ), + 'tag' => esc_html__( 'Performance', 'elementor' ), + 'description' => esc_html__( 'Cache the result of active control evaluation per element settings model and invalidate on change. Reduces editor bootstrap time on pages with many elements by avoiding repeated condition evaluation.', 'elementor' ), + 'release_status' => self::RELEASE_STATUS_BETA, + 'default' => self::STATE_INACTIVE, + ] ); } /** diff --git a/tests/playwright/sanity/editor-bootstrap-cpu-profile.test.ts b/tests/playwright/sanity/editor-bootstrap-cpu-profile.test.ts new file mode 100644 index 000000000000..a2d0feb4d15e --- /dev/null +++ b/tests/playwright/sanity/editor-bootstrap-cpu-profile.test.ts @@ -0,0 +1,91 @@ +import { expect, CDPSession, Page } from '@playwright/test'; +import { mkdirSync, writeFileSync } from 'fs'; +import { resolve } from 'path'; +import { parallelTest as test } from '../parallelTest'; +import WpAdminPage from '../pages/wp-admin-page'; +import EditorPage from '../pages/editor-page'; + +const HEAVY_WIDGETS: string[] = [ + 'heading', 'text-editor', 'image', 'button', 'icon', + 'icon-box', 'image-box', 'star-rating', 'divider', 'spacer', + 'image-gallery', 'icon-list', 'counter', 'progress', 'tabs', + 'toggle', 'alert', 'html', 'shortcode', 'menu-anchor', +]; + +const REPEATS_PER_WIDGET = 4; + +async function buildHeavyPage( editor: EditorPage ): Promise { + const container = await editor.addElement( { elType: 'container' }, 'document' ); + let count = 0; + for ( const widgetType of HEAVY_WIDGETS ) { + for ( let i = 0; i < REPEATS_PER_WIDGET; i++ ) { + try { + await editor.addElement( { widgetType, elType: 'widget' }, container ); + count++; + } catch { + // Widget not registered in this install — skip. + } + } + } + return count; +} + +async function captureProfile( page: Page, postId: string, outFile: string ): Promise { + const client: CDPSession = await page.context().newCDPSession( page ); + await client.send( 'Profiler.enable' ); + await client.send( 'Profiler.start' ); + + const start = Date.now(); + await page.goto( `/wp-admin/post.php?post=${ postId }&action=elementor` ); + await page.waitForFunction( + () => Boolean( ( window as unknown as { elementor?: { loaded?: boolean } } ).elementor?.loaded ), + { timeout: 120_000 }, + ); + const elapsed = Date.now() - start; + + const { profile } = await client.send( 'Profiler.stop' ); + await client.detach(); + + mkdirSync( resolve( __dirname, '../../../reports' ), { recursive: true } ); + writeFileSync( outFile, JSON.stringify( profile ) ); + + return elapsed; +} + +test.describe( 'Editor bootstrap CPU profile', () => { + test.describe.configure( { timeout: 600_000 } ); + + test( 'capture before/after profiles for e_memoize_active_controls', async ( { page, apiRequests }, testInfo ) => { + const wpAdmin = new WpAdminPage( page, testInfo, apiRequests ); + + await wpAdmin.setExperiments( { e_memoize_active_controls: false } ); + const editor = await wpAdmin.openNewPage(); + + const widgetsAdded = await buildHeavyPage( editor ); + expect( widgetsAdded ).toBeGreaterThan( 0 ); + await editor.saveAndReloadPage(); + + const url = new URL( page.url() ); + const postId = url.searchParams.get( 'post' ); + if ( ! postId ) { + throw new Error( `Could not extract post id from URL: ${ page.url() }` ); + } + + const outDir = resolve( __dirname, '../../../reports' ); + const offFile = resolve( outDir, `bootstrap-experiment-off.cpuprofile` ); + const onFile = resolve( outDir, `bootstrap-experiment-on.cpuprofile` ); + + const offElapsed = await captureProfile( page, postId, offFile ); + + await wpAdmin.setExperiments( { e_memoize_active_controls: true } ); + + const onElapsed = await captureProfile( page, postId, onFile ); + + testInfo.annotations.push( + { type: 'cpu-profile', description: `OFF: ${ offElapsed } ms — saved to ${ offFile }` }, + { type: 'cpu-profile', description: `ON: ${ onElapsed } ms — saved to ${ onFile }` }, + ); + + await wpAdmin.resetExperiments(); + } ); +} ); diff --git a/tests/playwright/sanity/editor-bootstrap-performance.test.ts b/tests/playwright/sanity/editor-bootstrap-performance.test.ts new file mode 100644 index 000000000000..b93141adebe2 --- /dev/null +++ b/tests/playwright/sanity/editor-bootstrap-performance.test.ts @@ -0,0 +1,102 @@ +import { expect, Page } from '@playwright/test'; +import { parallelTest as test } from '../parallelTest'; +import WpAdminPage from '../pages/wp-admin-page'; +import EditorPage from '../pages/editor-page'; + +const HEAVY_WIDGETS: string[] = [ + 'heading', 'text-editor', 'image', 'button', 'icon', + 'icon-box', 'image-box', 'star-rating', 'divider', 'spacer', + 'icon-list', 'counter', 'progress', 'tabs', 'toggle', + 'alert', 'html', 'shortcode', 'menu-anchor', 'image-carousel', +]; + +const REPEATS_PER_WIDGET = 2; +const RUNS_PER_CONDITION = 3; +const GOTO_TIMEOUT_MS = 120_000; +const READY_TIMEOUT_MS = 180_000; + +async function buildHeavyPage( editor: EditorPage ): Promise { + const container = await editor.addElement( { elType: 'container' }, 'document' ); + let count = 0; + for ( const widgetType of HEAVY_WIDGETS ) { + for ( let i = 0; i < REPEATS_PER_WIDGET; i++ ) { + try { + await editor.addElement( { widgetType, elType: 'widget' }, container ); + count++; + } catch { + // Widget not registered in this install — skip; we just need volume. + } + } + } + return count; +} + +async function openEditorAndWait( page: Page, postId: string ): Promise { + const start = Date.now(); + await page.goto( `/wp-admin/post.php?post=${ postId }&action=elementor`, { timeout: GOTO_TIMEOUT_MS } ); + await page.waitForFunction( + () => { + const w = window as unknown as { elementor?: { loaded?: boolean } }; + return Boolean( w.elementor?.loaded ); + }, + { timeout: READY_TIMEOUT_MS }, + ); + await page.waitForSelector( '#elementor-panel-header-title', { state: 'visible', timeout: READY_TIMEOUT_MS } ); + return Date.now() - start; +} + +test.describe( 'Editor bootstrap performance', () => { + test.describe.configure( { timeout: 1_800_000 } ); + test.use( { navigationTimeout: 180_000, actionTimeout: 60_000 } ); + + test( 'memoize_active_controls does not regress editor load and preserves rendering', async ( { page, apiRequests }, testInfo ) => { + const wpAdmin = new WpAdminPage( page, testInfo, apiRequests ); + + const request = page.context().request; + const postId = await apiRequests.create( request, 'pages', { title: 'Editor bootstrap perf', content: '' } ); + + await openEditorAndWait( page, postId ); + const editor = new EditorPage( page, testInfo ); + + const widgetsAdded = await buildHeavyPage( editor ); + expect( widgetsAdded ).toBeGreaterThan( 0 ); + await page.evaluate( async () => { + await $e.run( 'document/save/update' ); + } ); + + const offRuns: number[] = []; + for ( let i = 0; i < RUNS_PER_CONDITION; i++ ) { + offRuns.push( await openEditorAndWait( page, postId ) ); + } + const offElementCount = await editor.getPreviewFrame().locator( '.elementor-element' ).count(); + + await wpAdmin.setExperiments( { e_memoize_active_controls: true } ); + + const onRuns: number[] = []; + for ( let i = 0; i < RUNS_PER_CONDITION; i++ ) { + onRuns.push( await openEditorAndWait( page, postId ) ); + } + const onElementCount = await editor.getPreviewFrame().locator( '.elementor-element' ).count(); + + const median = ( arr: number[] ): number => { + const sorted = [ ...arr ].sort( ( a, b ) => a - b ); + return sorted[ Math.floor( sorted.length / 2 ) ]; + }; + const offMedian = median( offRuns ); + const onMedian = median( onRuns ); + + // eslint-disable-next-line no-console + console.log( `[editor-bootstrap-perf] widgets=${ widgetsAdded } off=${ offRuns.join( ',' ) } (median ${ offMedian }) on=${ onRuns.join( ',' ) } (median ${ onMedian }) elements off=${ offElementCount } on=${ onElementCount }` ); + + testInfo.annotations.push( + { type: 'bootstrap', description: `widgets added: ${ widgetsAdded }` }, + { type: 'bootstrap', description: `experiment OFF: ${ offRuns.join( ', ' ) } ms (median ${ offMedian } ms)` }, + { type: 'bootstrap', description: `experiment ON: ${ onRuns.join( ', ' ) } ms (median ${ onMedian } ms)` }, + { type: 'bootstrap', description: `element count OFF=${ offElementCount } ON=${ onElementCount }` }, + ); + + expect( onElementCount ).toBe( offElementCount ); + + expect( onMedian ).toBeLessThan( offMedian * 1.15 ); + } ); +} ); From 62207cf02432486dea5624ea8cc3b7e7c6b8a423 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 28 May 2026 22:49:56 +0000 Subject: [PATCH 2/4] Perf: Drop wasted structuredClone + skip cloneObject for non-dynamic widgets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two additional savings under the same e_memoize_active_controls experiment flag, both targeting the cloneObject hot-spot from the original profile (~1.8s self-time on a 37s editor load): 1. getStyleControls used to do structuredClone( getActiveControls(...) ) on entry, then iterate and replace each control with jQuery.extend( {}, defaults, control ) before mutating it. The input map was never structurally modified, so the deep clone was wasted work — especially now that getActiveControls returns the same cached map across calls. Skip the structuredClone when the experiment is on. 2. parseDynamicSettings clones the entire settings object on entry even when the widget has no __dynamic__ values and no repeater controls (the common case for stock widgets). Add a fast path that returns self.attributes directly for that case. The three external callers (views/base.js:959, controls-css-parser.js:42, repeater-row.js:80) all read the result without mutating it. Cache the per-model "has any repeater control" check on first use since the schema is immutable after init. https://claude.ai/code/session_013xhQk6ZEbAUErmzFX9R9mx --- .../editor/elements/models/base-settings.js | 32 ++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/assets/dev/js/editor/elements/models/base-settings.js b/assets/dev/js/editor/elements/models/base-settings.js index 1135ad64b354..bf3b9eae5873 100644 --- a/assets/dev/js/editor/elements/models/base-settings.js +++ b/assets/dev/js/editor/elements/models/base-settings.js @@ -136,7 +136,15 @@ BaseSettingsModel = Backbone.Model.extend( { getStyleControls( controls, attributes ) { var self = this; - controls = structuredClone( self.getActiveControls( controls, attributes ) ); + // The inner loop replaces each control with `jQuery.extend( {}, defaults, control )` + // before mutating it (control.styleFields = ...), so the input map is read-only here. + // Cloning it deeply is wasted work — especially when getActiveControls is memoized + // and returns the same cached map across calls. + if ( elementorCommon?.config?.experimentalFeatures?.e_memoize_active_controls ) { + controls = self.getActiveControls( controls, attributes ); + } else { + controls = structuredClone( self.getActiveControls( controls, attributes ) ); + } var styleControls = []; @@ -281,6 +289,28 @@ BaseSettingsModel = Backbone.Model.extend( { parseDynamicSettings( settings, options, controls ) { var self = this; + // Fast path: widgets without any __dynamic__ values and without repeater controls + // have nothing to parse, so we can skip the full cloneObject pass. All existing + // callers (views/base.js:959, controls-css-parser.js:42, repeater-row.js:80) read + // the result without mutating it. + if ( elementorCommon?.config?.experimentalFeatures?.e_memoize_active_controls && + undefined === settings && undefined === options && undefined === controls ) { + if ( undefined === self.__hasRepeaterControl ) { + self.__hasRepeaterControl = false; + jQuery.each( self.controls, function() { + if ( this.is_repeater ) { + self.__hasRepeaterControl = true; + return false; + } + } ); + } + const dyn = self.attributes.__dynamic__; + const hasDynamic = dyn && Object.keys( dyn ).length > 0; + if ( ! hasDynamic && ! self.__hasRepeaterControl ) { + return self.attributes; + } + } + settings = elementorCommon.helpers.cloneObject( settings || self.attributes ); options = options || {}; From 15eea2c7ff2924c81b6593cdca520aa0dcb3b56d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 04:01:43 +0000 Subject: [PATCH 3/4] Perf: Share element config across instances of same elType MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 8 of the perf series. getElementData() ran structuredClone on this.config.elements[elType] for every element instance. On a 200-element page that's 200 deep clones of the same ~10KB element config, most of which are containers/sections that share the exact same source object. The only per-instance mutation was the inner-section title override, which is now applied once to a separate cached object. All known callers READ properties from the returned config (icon, title, tabs_controls, html_wrapper_class, controls) — none mutate, so cross-instance sharing is safe. Two cache slots per elType: __sharedConfig (regular) and __sharedConfigInnerSection (sections with isInner=true). Gated by e_memoize_active_controls. --- assets/dev/js/editor/editor-base.js | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/assets/dev/js/editor/editor-base.js b/assets/dev/js/editor/editor-base.js index 567e9910da6d..506df10f3848 100644 --- a/assets/dev/js/editor/editor-base.js +++ b/assets/dev/js/editor/editor-base.js @@ -347,9 +347,28 @@ export default class EditorBase extends Marionette.Application { return false; } + const isInnerSection = 'section' === elType && model.get( 'isInner' ); + + // Share the base elType config across all instances of the same elType (gated by + // e_memoize_active_controls). The inner-section title is the only per-instance + // mutation and is applied to a shallow copy in that branch. + if ( elementorCommon?.config?.experimentalFeatures?.e_memoize_active_controls ) { + const cacheKey = isInnerSection ? '__sharedConfigInnerSection' : '__sharedConfig'; + if ( ! this.config.elements[ elType ][ cacheKey ] ) { + const shared = structuredClone( this.config.elements[ elType ] ); + if ( isInnerSection ) { + shared.title = __( 'Inner Section', 'elementor' ); + } + Object.defineProperty( this.config.elements[ elType ], cacheKey, { + value: shared, enumerable: false, configurable: true, + } ); + } + return this.config.elements[ elType ][ cacheKey ]; + } + const elementConfig = structuredClone( this.config.elements[ elType ] ); - if ( 'section' === elType && model.get( 'isInner' ) ) { + if ( isInnerSection ) { elementConfig.title = __( 'Inner Section', 'elementor' ); } From 4a9caa7582af4d2e93b337763ae3722c864812b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 00:05:02 +0000 Subject: [PATCH 4/4] Perf: Share merged control schemas across instances of the same widget type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4 of the perf series for the slow-editor-load issue. Before, every BaseSettingsModel instance ran mergeControlsSettings, which deep- extends every control via jQuery.extend(true, {}, baseControl, perWidgetOverrides). For a 200-element page drawn from 30 widget types, that is ~40,000 deep merges on every editor boot, and is the largest single contributor to the cloneObject self-time and GC pressure flagged in the perf report (1.8s cloneObject + 2.3s GC). This change caches the merged-controls map per (widgetType, isInner) on the shared elementData. All instances of 'heading top-level' now share one merged map by reference; the per-control deep-extend runs once per widget type instead of once per instance. The cache is invalidated when addWidgetsCache merges new data for a widget (e.g. after the lazy AJAX schema fetch). Audited the only known mutations of the schema map (container.js:248,258 set control.global.utilized = true) and confirmed nothing reads them — they are effectively dead writes, so cross-instance sharing is safe. Gated by the existing e_memoize_active_controls experiment. --- assets/dev/js/editor/editor-base.js | 36 +++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/assets/dev/js/editor/editor-base.js b/assets/dev/js/editor/editor-base.js index 506df10f3848..8b83784584de 100644 --- a/assets/dev/js/editor/editor-base.js +++ b/assets/dev/js/editor/editor-base.js @@ -382,8 +382,20 @@ export default class EditorBase extends Marionette.Application { return false; } - const isInner = modelElement.get( 'isInner' ), - controls = {}; + const isInner = modelElement.get( 'isInner' ); + + // Share the merged-controls map across every instance of the same widget type + // (gated by e_memoize_active_controls). Without this, every element pays for a + // full jQuery.extend(true, {}, ...) per control on every instance — the bulk of + // cloneObject self-time and the GC pressure flagged in the perf report. + const sharedSchemas = elementorCommon?.config?.experimentalFeatures?.e_memoize_active_controls; + const cacheKey = isInner ? '__mergedControlsInner' : '__mergedControlsTop'; + + if ( sharedSchemas && elementData[ cacheKey ] ) { + return elementData[ cacheKey ]; + } + + const controls = {}; _.each( elementData.controls, ( controlData, controlKey ) => { if ( ( isInner && controlData.hide_in_inner ) || ( ! isInner && controlData.hide_in_top ) ) { @@ -393,10 +405,24 @@ export default class EditorBase extends Marionette.Application { controls[ controlKey ] = controlData; } ); + if ( sharedSchemas ) { + // Eagerly perform the per-control deep-merge once and cache it. Subsequent + // instances of the same widget type return this same map by reference. + _.each( controls, ( controlData, controlKey ) => { + controls[ controlKey ] = jQuery.extend( true, {}, this.config.controls[ controlData.type ], controlData ); + } ); + Object.defineProperty( controls, '__merged', { value: true, enumerable: false, configurable: true } ); + elementData[ cacheKey ] = controls; + } + return controls; } mergeControlsSettings( controls ) { + if ( controls && controls.__merged ) { + return controls; + } + _.each( controls, ( controlData, controlKey ) => { controls[ controlKey ] = jQuery.extend( true, {}, this.config.controls[ controlData.type ], controlData ); } ); @@ -1484,6 +1510,12 @@ export default class EditorBase extends Marionette.Application { } this.widgetsCache[ widgetName ] = jQuery.extend( true, {}, this.widgetsCache[ widgetName ], widgetConfig ); + + // Invalidate the per-widget-type shared merged-controls cache (see + // getElementControls). Any existing instances retain their controls reference; + // new instances will rebuild from the updated schema. + delete this.widgetsCache[ widgetName ].__mergedControlsTop; + delete this.widgetsCache[ widgetName ].__mergedControlsInner; } ); }