Edit screen freezes with many relationship connections (show() + n² options)

Support › Meta Box AIO › Edit screen freezes with many relationship connections (show() + n² options)

Viewing 3 posts - 1 through 3 (of 3 total)
  • Author
    Posts
  • #50252
    AdrienAdrien
    Participant

    Hi Meta Box team,

    On a post with many MB Relationships connections, the edit screen froze Chrome for about a minute ("Page Unresponsive").
    I profiled it and found two causes, both in Meta Box core. Details and a small fix for each are below.

    Environment: Meta Box AIO 3.13.1 (Meta Box 5.15.3, MB Relationships 1.14.0), WordPress 7.1.3, jQuery 3.7.1, Chrome 156.

    The post has 217 connections in one relationship, 77 in another and a few smaller ones, so 402 select_advanced dropdowns on the screen.

    What we measured:
    - Server time is fine (~1.7 s), but the HTML is 13 MB with 56,369 <option> tags.
    - mb_ready takes 54 s on the main thread: select-advanced.js init 46.5 s (the select2() calls themselves are only ~3.8 s of that) and clone.js init 7.7 s. Chrome reports 45 s of forced style/layout in that task.

    1) JS: one forced style recalc per field

    In select-advanced.js, transform() calls $this.show() for every field. jQuery's show() reads the computed display (isHiddenWithinTree() → getComputedStyle()) right after the previous field's select2 container was inserted, so every field forces a style recalc of the whole page. On a heavy admin page that's 50–100 ms per field. clone.js does the same for each .rwmb-input through .toggle( bool ) in toggleRemoveButtons() and toggleAddButton().

    Possible fix in transform():

    
    // Instead of $this.show(), which reads computed style:
    if ( this.style.display === 'none' ) {
    	this.style.display = '';
    }
    

    and the same idea (or a CSS class) for the toggles in clone.js.

    2) PHP: every clone gets the options of all clones (n² options)

    RWMB_Object_Choice_Field::show() flattens the values of all clones, runs one query and stores the full list in $field['options']. RWMB_Clone::html() then renders every clone with that list. MB Relationships fields are always cloneable, so n connections become n dropdowns × n options: 217 × 217 = 47,089 options (9.2 MB of HTML) for one relationship. The same happens with any cloneable post/taxonomy/user field using ajax.

    With ajax on, select2 only needs each clone's own selected option; the rest comes from the ajax search. Something like this in RWMB_Object_Choice_Field::html() would fix it (the post, taxonomy and user queries all key options by ID):

    
    if ( $field['clone'] && $field['ajax'] ) {
    	$ids              = wp_parse_id_list( (array) $meta );
    	$field['options'] = array_intersect_key( $field['options'], array_flip( $ids ) );
    }
    

    Results with both changes on our site

    We applied both as temporary site-side patches. JS: $.fn.show swapped during mb_ready, for the dropdowns and clone buttons only. PHP: unselected options removed from each clone through the rwmb_html filter. On that post, mb_ready went from 54 s to 752 ms. Dropdowns, ajax search, adding/removing/sorting rows and saving all work as before, and no connection was lost on save.

    To reproduce: create a relationship, connect ~200 posts to one post, then open that post's edit screen in Chrome.

    Happy to share our patches or profiling details, or to test a fix.

    Thanks!

    Adrien — Stellar Web Solutions

    #50254
    PeterPeter
    Moderator

    Hello Adrien,

    Thanks for the detailed report and the profiling. It made both problems straightforward to confirm.

    I reproduced it with Meta Box AIO 3.13.1 (Meta Box 5.15.3, MB Relationships 1.14.0). On a test post with 217 connections, the edit screen has 218 clone dropdowns and 47,564

    I've forwarded both issues to the development team for an upcoming update. You offered to share your patches and test a fix, and I'd welcome both. Please post the patches here and I will let you know when there's a build to try.

    #50255
    AdrienAdrien
    Participant

    Hi Peter,

    Thanks for confirming so quickly. Here are the two patches we run on our site. Both are temporary and will be removed once your fix ships. They are plain PHP, so they can go in a small plugin or a theme's functions.php.

    1) JS: no forced style recalc during mb_ready

    
    <?php
    /**
     * Temporary site-side patch: Meta Box start-up (mb_ready) without a forced style recalc per field.
     * Only while the document-level "mb_ready" runs, show() on select_advanced dropdowns and on the
     * clone add/remove buttons just clears an inline display:none (these elements are visible by
     * default in Meta Box's CSS). Every other show() call keeps jQuery's behaviour, and show() is
     * restored right after "mb_ready".
     */
    add_action( 'admin_enqueue_scripts', function () {
    	if ( ! wp_script_is( 'rwmb', 'enqueued' ) ) {
    		return;
    	}
    
    	$js = <<<'JS'
    ( function ( $, rwmb ) {
    	'use strict';
    	if ( ! $ || ! rwmb || ! rwmb.$document ) {
    		return;
    	}
    	const selector = 'select.rwmb-select_advanced, select.rwmb-icon, .rwmb-button.remove-clone, .rwmb-button.add-clone';
    	const $document = rwmb.$document;
    	const originalTrigger = $document.trigger;
    
    	$document.trigger = function ( event ) {
    		const name = typeof event === 'string' ? event : ( event && event.type );
    		if ( name !== 'mb_ready' ) {
    			return originalTrigger.apply( this, arguments );
    		}
    
    		const originalShow = $.fn.show;
    		$.fn.show = function () {
    			const onlyFastTargets = arguments.length === 0 && this.length > 0
    				&& Array.prototype.every.call( this, el => el.nodeType === 1 && el.matches( selector ) );
    			if ( ! onlyFastTargets ) {
    				return originalShow.apply( this, arguments );
    			}
    			for ( const el of this ) {
    				if ( el.style.display === 'none' ) {
    					el.style.display = '';
    				}
    			}
    			return this;
    		};
    
    		try {
    			return originalTrigger.apply( this, arguments );
    		} finally {
    			$.fn.show = originalShow;
    		}
    	};
    } )( window.jQuery, window.rwmb );
    JS;
    
    	wp_add_inline_script( 'rwmb', $js, 'after' );
    }, PHP_INT_MAX );
    

    We kept it narrow on purpose. Other show() calls during mb_ready (tabs, file and media fields) still go through jQuery, because we didn't check whether their elements are hidden by CSS. In your code, the direct fix is still the one from my first post, in transform() and in the clone.js toggles.

    2) PHP: each clone only renders its own option(s)

    
    <?php
    /**
     * Temporary site-side patch: each row of a cloneable ajax object field only renders its own option(s).
     * RWMB_Clone::html() runs the "rwmb_html" filter on each clone with ( $html, $sub_field, $sub_meta ).
     */
    add_filter( 'rwmb_html', function ( $html, $field, $meta ) {
    	if ( ! is_admin() || ! is_string( $html ) || empty( $field['clone'] ) || empty( $field['ajax'] ) ) {
    		return $html;
    	}
    	if ( ! in_array( $field['type'] ?? '', [ 'post', 'taxonomy', 'taxonomy_advanced', 'user' ], true )
    		|| 'select_advanced' !== ( $field['field_type'] ?? '' ) ) {
    		return $html;
    	}
    
    	// The row's value: one ID, an array of IDs (multiple) or a comma list (taxonomy_advanced).
    	$ids = [];
    	if ( is_array( $meta ) ) {
    		array_walk_recursive( $meta, function ( $value ) use ( &$ids ) {
    			if ( is_scalar( $value ) ) {
    				$ids[] = $value;
    			}
    		} );
    	} elseif ( is_scalar( $meta ) && '' !== (string) $meta ) {
    		$ids = (string) $meta;
    	}
    	$keep = array_flip( array_filter( wp_parse_id_list( $ids ) ) );
    
    	// RWMB_Walker_Select writes <option value="ID" ...>escaped label</option>; the placeholder (value="") stays.
    	$new = preg_replace_callback( '#<option value="(\d+)"[^>]*>.*?</option>#s', function ( $match ) use ( $keep ) {
    		return isset( $keep[ (int) $match[1] ] ) ? $match[0] : '';
    	}, $html );
    
    	return null === $new ? $html : $new;
    }, 99, 3 );
    

    This one works on the HTML because there is no per-clone filter on the options themselves. In your code, filtering $field['options'] per clone is cleaner. One detail: the one-liner in my first post uses (array) $meta, which doesn't split taxonomy_advanced comma lists. The patch above handles those, as well as multiple fields.

    Results on our post: mb_ready went from 54 s to 3.9 s with patch 1 alone, and to 752 ms with both. Saving keeps every connection; we checked the counts in wp_mb_relationships before and after.

    Happy to test the build on a staging copy as soon as it's ready.

    Thanks!

    Adrien — Stellar Web Solutions

Viewing 3 posts - 1 through 3 (of 3 total)
  • You must be logged in to reply to this topic.