Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 54 additions & 3 deletions assets/dev/js/editor/editor-base.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' );
}

Expand All @@ -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 ) ) {
Expand All @@ -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 );
} );
Expand Down Expand Up @@ -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;
} );
}

Expand Down
56 changes: 55 additions & 1 deletion assets/dev/js/editor/elements/models/base-settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 ) {
Expand Down Expand Up @@ -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 = [];

Expand Down Expand Up @@ -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 ) {
Expand All @@ -228,6 +253,13 @@ BaseSettingsModel = Backbone.Model.extend( {
}
} );

if ( useCache ) {
this.__activeControlsCache = {
version: this.__activeControlsCacheVersion,
result: activeControls,
};
}

return activeControls;
},

Expand Down Expand Up @@ -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 || {};
Expand Down
9 changes: 9 additions & 0 deletions core/experiments/manager.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
] );
}

/**
Expand Down
91 changes: 91 additions & 0 deletions tests/playwright/sanity/editor-bootstrap-cpu-profile.test.ts
Original file line number Diff line number Diff line change
@@ -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<number> {
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<number> {
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();
} );
} );
102 changes: 102 additions & 0 deletions tests/playwright/sanity/editor-bootstrap-performance.test.ts
Original file line number Diff line number Diff line change
@@ -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<number> {
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<number> {
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 );
} );
} );