Users: Make wp_dropdown_users() scalable via autocomplete on large sites - #11447
Users: Make wp_dropdown_users() scalable via autocomplete on large sites#11447sanketio wants to merge 5 commits into
Conversation
|
The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the Core Committers: Use this line as a base for the props when committing in SVN: To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook. |
Test using WordPress PlaygroundThe changes in this pull request can previewed and tested using a WordPress Playground instance. WordPress Playground is an experimental project that creates a full WordPress instance entirely within the browser. Some things to be aware of
For more details about these limitations and more, check out the Limitations page in the WordPress Playground documentation. |
# Conflicts: # src/wp-admin/users.php
There was a problem hiding this comment.
Pull request overview
This PR makes wp_dropdown_users() scalable on large sites by adding an optional autocomplete mode that avoids loading all users, and extends the existing wp_ajax_autocomplete_user() endpoint (and related JS/tests) to support the new flow.
Changes:
- Add an
'autocomplete'mode towp_dropdown_users()with a newwp_dropdown_users_autocompletefilter and a hidden “helper” input that submits the selected user ID. - Extend
wp_ajax_autocomplete_user()to work on single-site, adduser_idas a return field, support label templates/tokens, cap results, and add new filters. - Add/extend PHPUnit coverage for both
wp_dropdown_users()autocomplete rendering and the AJAX handler behavior.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/phpunit/tests/user/wpDropdownUsers.php | Adds unit tests for the new autocomplete mode and related filters/attributes. |
| tests/phpunit/tests/ajax/wpAjaxAutocompleteUser.php | Adds a new AJAX test suite covering capability checks, term length, field selection, labels, filters, and result caps. |
| src/wp-includes/user.php | Implements autocomplete mode in wp_dropdown_users() and the new wp_dropdown_users_autocomplete filter. |
| src/wp-admin/includes/ajax-actions.php | Updates wp_ajax_autocomplete_user() to support single-site, user_id, label templates, new filters, and JSON responses. |
| src/js/_enqueues/lib/user-suggest.js | Extends the client-side autocomplete to pass label templates and support the helper-input pattern for user_id. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| $requested_field = isset( $_REQUEST['autocomplete_field'] ) ? $_REQUEST['autocomplete_field'] : ''; | ||
| if ( 'user_email' === $requested_field ) { | ||
| $field = 'user_email'; | ||
| } elseif ( 'user_id' === $requested_field ) { | ||
| $field = 'ID'; | ||
| } else { | ||
| $field = 'user_login'; | ||
| } |
There was a problem hiding this comment.
Good catch — fixed in 8c1157a. The edit_users gate was applied to the label and search columns but not to the returned value; on multisite a site admin (has list_users, not edit_users) could request autocomplete_field=user_email and read emails. Selecting the user_email field now also requires edit_users, otherwise it falls back to user_login. Added a regression test (test_user_email_field_not_disclosed_without_edit_users).
| $type = ( isset( $_REQUEST['autocomplete_type'] ) && 'search' === $_REQUEST['autocomplete_type'] ) | ||
| ? 'search' | ||
| : 'add'; |
There was a problem hiding this comment.
$type is only consulted inside if ( is_multisite() && 'add' === $type ). On single-site is_multisite() is false, so the search/else branch always runs regardless of $type — there's no behavioral divergence on single-site. Left as-is.
| $return[] = array( | ||
| /* translators: 1: User login, 2: User email address. */ | ||
| 'label' => sprintf( _x( '%1$s (%2$s)', 'user autocomplete result' ), $user->user_login, $user->user_email ), | ||
| 'value' => $user->$field, | ||
| 'label' => esc_html( $label ), | ||
| 'value' => ( 'ID' === $field ) ? $user->ID : $user->$field, |
There was a problem hiding this comment.
Fixed in 8c1157a. user-suggest.js uses jQuery UI's default item renderer (.text(item.label)), so esc_html() made names like Tom & Jerry render as Tom & Jerry. Since .text() is already XSS-safe, the escaping was both wrong and unnecessary — switched to wp_strip_all_tags().
| } | ||
|
|
||
| if ( $use_autocomplete ) { | ||
| wp_enqueue_script( 'user-suggest' ); |
There was a problem hiding this comment.
Fixed in 8c1157a. Since autocomplete is a public arg and can auto-activate, I added a wp_add_inline_script( ..., 'before' ) that sets window.ajaxurl = window.ajaxurl || <admin-ajax URL> when the script is enqueued — a no-op in wp-admin (where ajaxurl is already defined) and a working fallback on the front end.
| if ( $use_autocomplete ) { | ||
| // Users are fetched on demand via the AJAX handler; skip the bulk query. | ||
| $users = array(); | ||
| } else { | ||
| $users = get_users( $query_args ); | ||
| } |
There was a problem hiding this comment.
Fixed in 8c1157a. When hide_if_only_one_author is set, autocomplete mode now runs a bounded query (fields => ID, number => 2); if there's a single author (or none) it falls back to the classic rendering, which emits nothing — restoring the legacy behavior without loading every user.
| add_filter( | ||
| 'autocomplete_user_results', | ||
| static function () { | ||
| return array( | ||
| array( | ||
| 'label' => 'Overridden', | ||
| 'value' => 'overridden', | ||
| ), | ||
| ); | ||
| } | ||
| ); | ||
|
|
||
| try { | ||
| $this->_handleAjax( 'autocomplete-user' ); | ||
| } catch ( WPAjaxDieContinueException $e ) { | ||
| unset( $e ); | ||
| } | ||
|
|
||
| remove_all_filters( 'autocomplete_user_results' ); | ||
|
|
There was a problem hiding this comment.
Fixed in 8c1157a. Stored the callback in a variable and switched to remove_filter() so only this test's filter is removed.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (3)
src/wp-admin/includes/ajax-actions.php:397
- In the multisite
searchbranch, this prefetches all user IDs on the current site just to populateinclude, but the subsequentget_users()call already passesblog_id, andWP_User_Queryenforces site membership on multisite whenblog_idis set. On large sites this extra query/array build is expensive and undermines the scalability goal; it can be removed.
if ( is_multisite() ) {
// Restrict results to users already on this site.
$include_blog_users = get_users(
array(
'blog_id' => $blog_id,
src/wp-admin/includes/ajax-actions.php:302
- The surrounding comments/docs say the minimum term length is enforced/mirrored in JS, but
user-suggest.jshard-codesminLength: 2. If the newautocomplete_term_lengthfilter changes the minimum, the JS and PHP will diverge (requests will still be sent for 2–N-1 chars and the server willwp_die(-1)). Either wire the min length through to JS, or update these comments so they don’t claim the JS stays in sync automatically.
/*
* Validate the minimum search term length before anything else.
* The same minimum is enforced in JS via the minLength option.
*/
$term = isset( $_REQUEST['term'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['term'] ) ) : '';
tests/phpunit/tests/ajax/wpAjaxAutocompleteUser.php:199
- This test cleans up by calling
remove_all_filters( 'autocomplete_term_length' ), which can unintentionally wipe callbacks added elsewhere (and make test order matter). Prefer removing only the callback added in this test viaremove_filter().
add_filter(
'autocomplete_term_length',
static function () {
return 5;
}
|
Thanks for the follow-up review. All three low-confidence notes were valid and are applied in 822092f:
While verifying the prefetch removal under multisite I also caught that |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (3)
tests/phpunit/tests/ajax/wpAjaxAutocompleteUser.php:475
- This filter callback is declared with zero parameters, but add_filter() defaults to accepted_args=1, so WP will call it with 1 argument. On PHP 8+ this triggers an ArgumentCountError and breaks the test.
$filter = static function () {
return array(
array(
'label' => 'Overridden',
'value' => 'overridden',
tests/phpunit/tests/ajax/wpAjaxAutocompleteUser.php:185
- Filter callbacks added in tests must accept at least the number of arguments WordPress will pass (default accepted args is 1). These closures declare zero parameters, so WP will pass 1 arg and PHP 8+ will throw an ArgumentCountError, failing the test run.
This issue also appears on line 471 of the same file.
$filter = static function () {
return 5;
};
add_filter( 'autocomplete_term_length', $filter );
src/wp-includes/user.php:1911
- In autocomplete mode, the pre-filled visible input uses
$selected_user->$showdirectly. This can make the prefilled value inconsistent with the limiteddata-autocomplete-labeltemplates (unknown$showvalues) and can expose the selected user’s email in the UI even though the AJAX handler strips{{user_email}}for users who lackedit_users. Consider normalizing the display field to supported values and applying the sameedit_usersgate foruser_email.
} elseif ( ! empty( $selected_user->$show ) ) {
$display = $selected_user->$show;
} else {
$display = '(' . $selected_user->user_login . ')';
|
Thanks for the follow-up. These three don't need changes: Zero-parameter filter closures (test lines 185, 471, 475) — these are valid. PHP only throws $f = static function(){ return 'ok'; };
call_user_func_array( $f, array( 'extra' ) ); // 'ok', no errorThese tests also already pass on the PHP 8.3 CI container (18/18), so there's no ArgumentCountError in practice. Prefilled autocomplete display ( |
Summary
wp_dropdown_users()loads every user in the database unconditionally. On sites with tens of thousands of users this causes fatal memory exhaustion and browser hangs. This ticket has been open since 2012. This patch implements the approach originally proposed by Helen Hou-Sandi in 19867.3.diff, modernised for the current codebase.What changed
wp_dropdown_users()—wp-includes/user.php'autocomplete' => falseargument. Whentrue, renders a jQuery UI autocomplete text input backed by an AJAX user search instead of a<select>.wp_is_large_user_count()returnstrue(> 10,000 users), unless the caller passes an explicit'include'list or'show_option_all'.<input class="wp-suggest-user">— the user types into this; jQuery UI shows suggestions.<input class="wp-suggest-user-helper" name="...">— stores the selected user ID for form submission.data-autocomplete-labelattribute is derived from the'show'argument so suggestion items match what<option>text would have shown (e.g.display_name_with_login→{{display_name}} ({{user_login}})).wp_dropdown_users_autocomplete— allows overriding the autocomplete decision.wp_dropdown_users_argsnow fires unconditionally, before the autocomplete/select branch, so existing hooks continue to work regardless of mode.wp_ajax_autocomplete_user()—wp-admin/includes/ajax-actions.php-1on any non-multisite install.list_userscapability. Email search column is only included for users withedit_users.user_idas a supportedautocomplete_fieldvalue, returning the numeric user ID as the suggestion value.{{user_login}},{{user_email}},{{display_name}},{{user_id}}. The{{user_email}}token is silently stripped for users withoutedit_users.'number' => 20inget_users().wp_send_json()(correctContent-Type: application/jsonheader).autocomplete_term_length— minimum search term length (default 2).autocomplete_user_results— allows modifying the final results array.autocomplete_users_for_site_adminspreserved.user-suggest.js—js/_enqueues/lib/user-suggest.jsdata-autocomplete-labelattribute and passes it to the AJAX source URL asautocomplete_label.hasHelperpattern (user_idfield + sibling.wp-suggest-user-helperinput).focushandler: showsui.item.label(the display text) while keyboard-navigating, not the raw ID.selecthandler: writes the user ID to the hidden helper input, the label to the visible input.users.php—wp-admin/users.php$_REQUEST['reassign_user']is now passed throughabsint()and validated (> 0and!== $id) before being forwarded towp_delete_user(). Previously, if the autocomplete visible input was partially typed and the hidden helper remained0, content would be silently deleted instead of reassigned.Tests
tests/phpunit/tests/user/wpDropdownUsers.php— 10 new test methods:<input>elements, not<select>nameattributewp_dropdown_users_autocompletefilter can force-enable or force-disablewp_dropdown_users_argsfires in both modeswp_dropdown_usersHTML filter fires in both modesincludeorshow_option_allis setdata-autocomplete-labelmatches theshowargument (data-provider: 5 cases)tests/phpunit/tests/ajax/wpAjaxAutocompleteUser.php— new file, 14 test methods:autocomplete_term_lengthfilterautocomplete_fieldselection (user_login,user_id,user_email){{user_email}}stripping for non-edit_usersautocomplete_user_resultsfilter fires and can modify resultslabel+value)Backward compatibility
All existing filters fire unconditionally. The
<select>path is fully preserved and is the default on small sites. No existing callers are broken:export.phpcalls withinclude+show_option_allsuppress auto-enable; the Quick Edit path has its ownwp_is_large_user_count()guard.Trac ticket: https://core.trac.wordpress.org/ticket/19867
Use of AI Tools
AI assistance: Yes
Tool(s): GitHub Copilot
Model(s): Claude Sonnet 4.6
Used for: Initial code skeleton, test suggestions, and bug diagnosis; final implementation, logic, and all tests were reviewed and edited by me.
This Pull Request is for code review only. Please keep all other discussion in the Trac ticket. Do not merge this Pull Request. See GitHub Pull Requests for Code Review in the Core Handbook for more details.