diff --git a/assets/dev/js/editor/editor-base.js b/assets/dev/js/editor/editor-base.js index 567e9910da6d..8b83784584de 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' ); } @@ -363,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 ) ) { @@ -374,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 ); } ); @@ -1465,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; } ); } diff --git a/assets/dev/js/editor/elements/models/base-settings.js b/assets/dev/js/editor/elements/models/base-settings.js index 0ff12bf51b1c..bf3b9eae5873 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 ) { @@ -127,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 = []; @@ -210,6 +227,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 +253,13 @@ BaseSettingsModel = Backbone.Model.extend( { } } ); + if ( useCache ) { + this.__activeControlsCache = { + version: this.__activeControlsCacheVersion, + result: activeControls, + }; + } + return activeControls; }, @@ -257,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 || {}; 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 ); + } ); +} );