Speeding up the Appearance > Menus Screen in WordPress 3.6 / 3.7 / 3.8 / 3.9 / 4.0 / 4.1

Do you have a lot of menu items, and have you experienced a dramatic slow down in your Appearance > Menus admin screen after updating to WordPress 3.6? Here I’ll describe why that happens, how to improve it, and provide a plugin solution.

Written by

Chris Mavricos

Published on

August 16, 2013
BlogWordPress

Quick Synopsis

WordPress 3.6 introduced new accessibility functionality in the Appearance > Menus admin screen. This extra javascript processing creates inefficiencies when handling menus with large numbers of menu items, resulting in very slow and sometimes unresponsive interactions when managing menus in WordPress. By deferring this processing using “lazy” techniques, we can eliminate the bottleneck and have the menu management system run much faster for large numbers of menu items. I have provided a plugin which does just that.

Skip to Video Skip to plugin

Update April 2015

The patch submitted has finally been incorporated into the core in WordPress 4.2! See: core trac ticket. The Faster Appearance – Menus plugin should no longer be necessary with this update.

Update October 25, 2013

I have submitted this plugin/solution as a core trac ticket, and the patch is currently awaiting review to be included in a future version of WordPress core. If you’d like to test, head over to the track ticket and report your feedback 🙂

Background

As the author of several WordPress menu plugins which handle large numbers of menu items, I’ve recently been fielding a lot of support requests from concerned customers who have experienced a dramatic slow-down in their Appearance > Menus admin screen since they have upgraded to WordPress 3.6. For one user who was using about 300 menu items (note: definitely not recommended in any event), the Appearance > Menus page completely freezes and crashes. As I was worried that my code might be causing the issue, I began to investigate.

Stripping my installation down to just TwentyTwelve running on WordPress 3.6, without any plugins, I was surprise to find that the Appearance > Menus page “freezes” for about 5 seconds upon loading with a menu of about 100 menu items. That means no scrolling, no clicking – the average user might even think that the browser has crashed, as the only indication that anything is going on is that the progress spinner continues to spin indicating that the page has not completely loaded.

Testing with a much smaller menu, say 5 menu items, the page loads immediately.

When profiling the javascript, it became apparent that the majority of the processing was occurring in wp-admin/js/nav-menu.js (or the minified equivalent). Digging deeper into this file, I found that the bottleneck occurs with the refreshAdvancedAccessibility function. This function is responsible for adding buttons that allow the menu items’ positions to be moved without having to drag and drop.

Searching the issue in trac, I came across this ticket: Give the menus page an accessibility mode option, like the widgets screen, which confirms that this code was added in WordPress 3.6. The core team has done a nice job adding some useful accessibility features to the Appearance > Menus screen, which is great. See the relevant commit.

The code looks like this:

		refreshAdvancedAccessibility : function() {

			// Hide all links by default
			$( '.menu-item-settings .field-move a' ).hide();

			$( '.item-edit' ).each( function() {
				var $this = $(this),
					movement = [],
					availableMovement = '',
					menuItem = $this.parents( 'li.menu-item' ).first(),
					depth = menuItem.menuItemDepth(),
					isPrimaryMenuItem = ( 0 === depth ),
					itemName = $this.parents( '.menu-item-handle' ).find( '.menu-item-title' ).text(),
					position = parseInt( menuItem.index() ),
					prevItemDepth = ( isPrimaryMenuItem ) ? depth : parseInt( depth - 1 ),
					prevItemNameLeft = menuItem.prevAll('.menu-item-depth-' + prevItemDepth).first().find( '.menu-item-title' ).text(),
					prevItemNameRight = menuItem.prevAll('.menu-item-depth-' + depth).first().find( '.menu-item-title' ).text(),
					totalMenuItems = $('#menu-to-edit li').length,
					hasSameDepthSibling = menuItem.nextAll( '.menu-item-depth-' + depth ).length;

				// Where can they move this menu item?
				if ( 0 !== position ) {
					var thisLink = menuItem.find( '.menus-move-up' );
					thisLink.prop( 'title', menus.moveUp ).show();
				}

				if ( 0 !== position && isPrimaryMenuItem ) {
					var thisLink = menuItem.find( '.menus-move-top' );
					thisLink.prop( 'title', menus.moveToTop ).show();
				}

				if ( position + 1 !== totalMenuItems && 0 !== position ) {
					var thisLink = menuItem.find( '.menus-move-down' );
					thisLink.prop( 'title', menus.moveDown ).show();
				}

				if ( 0 === position && 0 !== hasSameDepthSibling ) {
					var thisLink = menuItem.find( '.menus-move-down' );
					thisLink.prop( 'title', menus.moveDown ).show();
				}

				if ( ! isPrimaryMenuItem ) {
					var thisLink = menuItem.find( '.menus-move-left' ),
						thisLinkText = menus.outFrom.replace( '%s', prevItemNameLeft );
					thisLink.prop( 'title', menus.moveOutFrom.replace( '%s', prevItemNameLeft ) ).html( thisLinkText ).show();
				}

				if ( 0 !== position ) {
					if ( menuItem.find( '.menu-item-data-parent-id' ).val() !== menuItem.prev().find( '.menu-item-data-db-id' ).val() ) {
						var thisLink = menuItem.find( '.menus-move-right' ),
							thisLinkText = menus.under.replace( '%s', prevItemNameRight );
						thisLink.prop( 'title', menus.moveUnder.replace( '%s', prevItemNameRight ) ).html( thisLinkText ).show();
					}
				}

				if ( isPrimaryMenuItem ) {
					var primaryItems = $( '.menu-item-depth-0' ),
						itemPosition = primaryItems.index( menuItem ) + 1,
						totalMenuItems = primaryItems.length,

						// String together help text for primary menu items
						title = menus.menuFocus.replace( '%1$s', itemName ).replace( '%2$d', itemPosition ).replace( '%3$d', totalMenuItems );
				} else {
					var parentItem = menuItem.prevAll( '.menu-item-depth-' + parseInt( depth - 1 ) ).first(),
						parentItemId = parentItem.find( '.menu-item-data-db-id' ).val(),
						parentItemName = parentItem.find( '.menu-item-title' ).text(),
						subItems = $( '.menu-item .menu-item-data-parent-id[value="' + parentItemId + '"]' ),
						itemPosition = $( subItems.parents('.menu-item').get().reverse() ).index( menuItem ) + 1;

						// String together help text for sub menu items
						title = menus.subMenuFocus.replace( '%1$s', itemName ).replace( '%2$d', itemPosition ).replace( '%3$s', parentItemName );
				}

				$this.prop('title', title).html( title );
			});
		},

It uses jQuery’s .each() function to loop through every menu item and add in accessibility functionality. This is some relatively intensive processing, but for only a few menu items it’s not a big deal.

Unfortunately, for menus with a large number of menu items, this code creates a significant bottleneck as the amount of processing compounds with each menu item. Every item in the menu needs to be processed at page load, as well as when the menu item order changes. As a result, we get long pauses while the browser executes this javascript over and over. This creates user experience issues as the browser seems to seize up every time a change is made.

Demonstration

Here is a video demonstration of the issue. Best viewed in 720p.

A Potential Solution

The main problem here is that when all the items must be processed at once, this creates a severe bottleneck when working with large numbers of menu items. Processing the items one at a time, only when necessary, would alleviate this issue.

Before this accessibility functionality can be used, the items will need to be either hovered, focused, or touched. Therefore we can defer processing until those events occur on a per-item basis. Here’s what I’ve done:

First, we move all of the processing to a function called refreshAdvancedAccessibilityLazy. This function will be passed an individual menu item for processing, and will only be processed if it hasn’t already been. The rest of the processing is identical, however, we’ll bind this function to certain events as a callback, rather than looping over everything at once.

		/* This function takes all code from refreshAdvancedAccessibility() and executes it only on a single 
			menu item, to be used as a callback */
		refreshAdvancedAccessibilityLazy : function( $itemEdit ){

			//don't reprocess
			if( $itemEdit.data( 'accessibility_refreshed' ) ) return;

			//mark as processed
			$itemEdit.data( 'accessibility_refreshed' , true );

			//do the accessibility processing
			var $this = $itemEdit,
				movement = [],
				availableMovement = '',
				menuItem = $this.parents( 'li.menu-item' ).first(),
				depth = menuItem.menuItemDepth(),
				isPrimaryMenuItem = ( 0 === depth ),
				itemName = $this.parents( '.menu-item-handle' ).find( '.menu-item-title' ).text(),
				position = parseInt( menuItem.index() ),
				prevItemDepth = ( isPrimaryMenuItem ) ? depth : parseInt( depth - 1 ),
				prevItemNameLeft = menuItem.prevAll('.menu-item-depth-' + prevItemDepth).first().find( '.menu-item-title' ).text(),
				prevItemNameRight = menuItem.prevAll('.menu-item-depth-' + depth).first().find( '.menu-item-title' ).text(),
				totalMenuItems = $('#menu-to-edit li').length,
				hasSameDepthSibling = menuItem.nextAll( '.menu-item-depth-' + depth ).length;

			// Where can they move this menu item?
			if ( 0 !== position ) {
				var thisLink = menuItem.find( '.menus-move-up' );
				thisLink.prop( 'title', menus.moveUp ).show();
			}

			if ( 0 !== position && isPrimaryMenuItem ) {
				var thisLink = menuItem.find( '.menus-move-top' );
				thisLink.prop( 'title', menus.moveToTop ).show();
			}

			if ( position + 1 !== totalMenuItems && 0 !== position ) {
				var thisLink = menuItem.find( '.menus-move-down' );
				thisLink.prop( 'title', menus.moveDown ).show();
			}

			if ( 0 === position && 0 !== hasSameDepthSibling ) {
				var thisLink = menuItem.find( '.menus-move-down' );
				thisLink.prop( 'title', menus.moveDown ).show();
			}

			if ( ! isPrimaryMenuItem ) {
				var thisLink = menuItem.find( '.menus-move-left' ),
					thisLinkText = menus.outFrom.replace( '%s', prevItemNameLeft );
				thisLink.prop( 'title', menus.moveOutFrom.replace( '%s', prevItemNameLeft ) ).html( thisLinkText ).show();
			}

			if ( 0 !== position ) {
				if ( menuItem.find( '.menu-item-data-parent-id' ).val() !== menuItem.prev().find( '.menu-item-data-db-id' ).val() ) {
					var thisLink = menuItem.find( '.menus-move-right' ),
						thisLinkText = menus.under.replace( '%s', prevItemNameRight );
					thisLink.prop( 'title', menus.moveUnder.replace( '%s', prevItemNameRight ) ).html( thisLinkText ).show();
				}
			}

			if ( isPrimaryMenuItem ) {
				var primaryItems = $( '.menu-item-depth-0' ),
					itemPosition = primaryItems.index( menuItem ) + 1,
					totalMenuItems = primaryItems.length,

					// String together help text for primary menu items
					title = menus.menuFocus.replace( '%1$s', itemName ).replace( '%2$d', itemPosition ).replace( '%3$d', totalMenuItems );
			} else {
				var parentItem = menuItem.prevAll( '.menu-item-depth-' + parseInt( depth - 1 ) ).first(),
					parentItemId = parentItem.find( '.menu-item-data-db-id' ).val(),
					parentItemName = parentItem.find( '.menu-item-title' ).text(),
					subItems = $( '.menu-item .menu-item-data-parent-id[value="' + parentItemId + '"]' ),
					itemPosition = $( subItems.parents('.menu-item').get().reverse() ).index( menuItem ) + 1;

					// String together help text for sub menu items
					title = menus.subMenuFocus.replace( '%1$s', itemName ).replace( '%2$d', itemPosition ).replace( '%3$s', parentItemName );
			}

			$this.prop('title', title).html( title );

			//console.log( 'refresh ' + title );
		},

Next, we’ll strip everything from the original function. Rather than reprocessing everything every time something changes, we’ll simply reset all states to unprocessed, and then they will be reprocessed next time those items are interacted with

		/* Functionality stripped from this function and deferred to callback 
			function refreshAdvancedAccessibilityLazy() */
		refreshAdvancedAccessibility : function() {

			// Hide all links by default
			$( '.menu-item-settings .field-move a' ).hide();

			//Mark all items as unprocessed
			$( '.item-edit' ).data( 'accessibility_refreshed' , false );

			return;
		},

Finally, we’ll rewrite the accessibility initialization function to trigger our deferred processing on hover/focus/touch, and use on() with event delegation in order to ensure newly added items also work.

		initAccessibility : function() {
			api.refreshKeyboardAccessibility();
			api.refreshAdvancedAccessibility();

			//Setup the refresh on hover/focus/touch event
			$( '#menu-management' ).on( 'mouseenter.refreshAccessibility focus.refreshAccessibility touchstart.refreshAccessibility' , '.menu-item' , function(){
				api.refreshAdvancedAccessibilityLazy( $( this ).find( '.item-edit' ) );
			});

			//Modified events to use on() with event delegation so that newly added menu items work as well
			
			// Events
			$( '#menu-management' ).on( 'click', '.menus-move-up' , function ( e ) {
				api.moveMenuItem( $( this ).parents( 'li.menu-item' ).find( 'a.item-edit' ), 'up' );
				e.preventDefault();
			});
			$( '#menu-management' ).on( 'click', '.menus-move-down', function ( e ) {
				api.moveMenuItem( $( this ).parents( 'li.menu-item' ).find( 'a.item-edit' ), 'down' );
				e.preventDefault();
			});
			$( '#menu-management' ).on( 'click', '.menus-move-top', function ( e ) {
				api.moveMenuItem( $( this ).parents( 'li.menu-item' ).find( 'a.item-edit' ), 'top' );
				e.preventDefault();
			});
			$( '#menu-management' ).on( 'click', '.menus-move-left' , function ( e ) {
				api.moveMenuItem( $( this ).parents( 'li.menu-item' ).find( 'a.item-edit' ), 'left' );
				e.preventDefault();
			});
			$( '#menu-management' ).on( 'click', '.menus-move-right', function ( e ) {
				api.moveMenuItem( $( this ).parents( 'li.menu-item' ).find( 'a.item-edit' ), 'right' );
				e.preventDefault();
			});

		},

The Plugin

In order to make this new code work as a plugin rather than edit the Core, I’ve deregistered the standard nav-menu.js and re-registered this newly edited version from the plugin. Activating the plugin will immediately switch to the revised script. The code and plugin are available for download on GitHub

View Plugin on GitHub Download from WordPress Plugin Repository

Chris Mavricos

Hi, I'm Chris. I'm a web developer and founder of SevenSpark located in Boulder, CO. I've been developing websites since 2004, and have extensive experience in WordPress theme, plugin, and custom development over the last 15+ years.