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.
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