Skip to content

Users: Make wp_dropdown_users() scalable via autocomplete on large sites - #11447

Open
sanketio wants to merge 5 commits into
WordPress:trunkfrom
sanketio:fix/19867
Open

Users: Make wp_dropdown_users() scalable via autocomplete on large sites#11447
sanketio wants to merge 5 commits into
WordPress:trunkfrom
sanketio:fix/19867

Conversation

@sanketio

@sanketio sanketio commented Apr 6, 2026

Copy link
Copy Markdown

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

  • Added 'autocomplete' => false argument. When true, renders a jQuery UI autocomplete text input backed by an AJAX user search instead of a <select>.
  • In wp-admin, autocomplete is automatically enabled when wp_is_large_user_count() returns true (> 10,000 users), unless the caller passes an explicit 'include' list or 'show_option_all'.
  • Two inputs are rendered in autocomplete mode:
    • Visible <input class="wp-suggest-user"> — the user types into this; jQuery UI shows suggestions.
    • Hidden <input class="wp-suggest-user-helper" name="..."> — stores the selected user ID for form submission.
  • Pre-selected user is resolved server-side and pre-populated into the visible input.
  • The data-autocomplete-label attribute 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}})).
  • New filter: wp_dropdown_users_autocomplete — allows overriding the autocomplete decision.
  • Existing filter wp_dropdown_users_args now 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

  • Extended to support single-site installs. Previously bailed with -1 on any non-multisite install.
  • Single-site search requires list_users capability. Email search column is only included for users with edit_users.
  • Added user_id as a supported autocomplete_field value, returning the numeric user ID as the suggestion value.
  • Added label template token resolution: {{user_login}}, {{user_email}}, {{display_name}}, {{user_id}}. The {{user_email}} token is silently stripped for users without edit_users.
  • Results capped at 20 via 'number' => 20 in get_users().
  • Response uses wp_send_json() (correct Content-Type: application/json header).
  • New filter: autocomplete_term_length — minimum search term length (default 2).
  • New filter: autocomplete_user_results — allows modifying the final results array.
  • Existing filter autocomplete_users_for_site_admins preserved.

user-suggest.jsjs/_enqueues/lib/user-suggest.js

  • Removed "multisite only" restriction from the file description.
  • Reads data-autocomplete-label attribute and passes it to the AJAX source URL as autocomplete_label.
  • Detects hasHelper pattern (user_id field + sibling .wp-suggest-user-helper input).
  • focus handler: shows ui.item.label (the display text) while keyboard-navigating, not the raw ID.
  • select handler: writes the user ID to the hidden helper input, the label to the visible input.

users.phpwp-admin/users.php

  • Hardened the delete/reassign handler: $_REQUEST['reassign_user'] is now passed through absint() and validated (> 0 and !== $id) before being forwarded to wp_delete_user(). Previously, if the autocomplete visible input was partially typed and the hidden helper remained 0, content would be silently deleted instead of reassigned.

Tests

  • tests/phpunit/tests/user/wpDropdownUsers.php — 10 new test methods:

    • Autocomplete mode renders <input> elements, not <select>
    • Hidden input carries the correct name attribute
    • Pre-selected user is pre-populated
    • wp_dropdown_users_autocomplete filter can force-enable or force-disable
    • wp_dropdown_users_args fires in both modes
    • wp_dropdown_users HTML filter fires in both modes
    • Auto-enable is suppressed when include or show_option_all is set
    • data-autocomplete-label matches the show argument (data-provider: 5 cases)
  • tests/phpunit/tests/ajax/wpAjaxAutocompleteUser.php — new file, 14 test methods:

    • Capability checks (subscriber rejected, editor/admin allowed)
    • Term length validation and autocomplete_term_length filter
    • autocomplete_field selection (user_login, user_id, user_email)
    • Label token resolution and {{user_email}} stripping for non-edit_users
    • autocomplete_user_results filter fires and can modify results
    • Response format (every result has label + value)
    • Result cap (≤ 20)

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.php calls with include + show_option_all suppress auto-enable; the Quick Edit path has its own wp_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.

@github-actions

github-actions Bot commented Apr 6, 2026

Copy link
Copy Markdown

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 props-bot label.

Core Committers: Use this line as a base for the props when committing in SVN:

Props sanketparmar.

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

@github-actions

github-actions Bot commented Apr 6, 2026

Copy link
Copy Markdown

Test using WordPress Playground

The 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

  • All changes will be lost when closing a tab with a Playground instance.
  • All changes will be lost when refreshing the page.
  • A fresh instance is created each time the link below is clicked.
  • Every time this pull request is updated, a new ZIP file containing all changes is created. If changes are not reflected in the Playground instance,
    it's possible that the most recent build failed, or has not completed. Check the list of workflow runs to be sure.

For more details about these limitations and more, check out the Limitations page in the WordPress Playground documentation.

Test this pull request with WordPress Playground.

Copilot AI review requested due to automatic review settings July 29, 2026 06:33

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 to wp_dropdown_users() with a new wp_dropdown_users_autocomplete filter and a hidden “helper” input that submits the selected user ID.
  • Extend wp_ajax_autocomplete_user() to work on single-site, add user_id as 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.

Comment on lines +330 to 337
$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';
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment on lines +322 to +324
$type = ( isset( $_REQUEST['autocomplete_type'] ) && 'search' === $_REQUEST['autocomplete_type'] )
? 'search'
: 'add';

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment on lines 434 to +436
$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,

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 &amp; Jerry. Since .text() is already XSS-safe, the escaping was both wrong and unnecessary — switched to wp_strip_all_tags().

Comment thread src/wp-includes/user.php
}

if ( $use_autocomplete ) {
wp_enqueue_script( 'user-suggest' );

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/wp-includes/user.php
Comment on lines +1845 to +1850
if ( $use_autocomplete ) {
// Users are fetched on demand via the AJAX handler; skip the bulk query.
$users = array();
} else {
$users = get_users( $query_args );
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +432 to +451
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' );

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 8c1157a. Stored the callback in a variable and switched to remove_filter() so only this test's filter is removed.

Copilot AI review requested due to automatic review settings July 29, 2026 06:48

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 search branch, this prefetches all user IDs on the current site just to populate include, but the subsequent get_users() call already passes blog_id, and WP_User_Query enforces site membership on multisite when blog_id is 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.js hard-codes minLength: 2. If the new autocomplete_term_length filter changes the minimum, the JS and PHP will diverge (requests will still be sent for 2–N-1 chars and the server will wp_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 via remove_filter().
		add_filter(
			'autocomplete_term_length',
			static function () {
				return 5;
			}

Copilot AI review requested due to automatic review settings July 29, 2026 07:02
@sanketio

Copy link
Copy Markdown
Author

Thanks for the follow-up review. All three low-confidence notes were valid and are applied in 822092f:

  • Redundant multisite prefetch (great catch, and on-goal for this PR) — removed. On multisite, WP_User_Query already restricts results to members of the given site via the blog_id arg (it adds a {$prefix}capabilities EXISTS meta query when no roles are named — see class-wp-user-query.php), so prefetching every member ID into include was both redundant and exactly the kind of full-user load this PR aims to avoid.
  • Min-length comments — reworded so they no longer imply the JS stays in sync automatically: the server-side check is authoritative, the JS uses a fixed minLength of 2, and raising the minimum above 2 via autocomplete_term_length rejects shorter terms server-side rather than in the browser.
  • remove_all_filters() in the term-length test — switched to remove_filter() on the specific callback.

While verifying the prefetch removal under multisite I also caught that test_user_email_field_returns_email() needed the super-admin grant on multisite (a plain administrator lacks edit_users there, so the earlier email-value gate correctly falls back to user_login); updated it to mirror test_email_token_visible_for_administrator(). Both single-site and multisite AJAX suites now pass (18/18 each).

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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->$show directly. This can make the prefilled value inconsistent with the limited data-autocomplete-label templates (unknown $show values) and can expose the selected user’s email in the UI even though the AJAX handler strips {{user_email}} for users who lack edit_users. Consider normalizing the display field to supported values and applying the same edit_users gate for user_email.
				} elseif ( ! empty( $selected_user->$show ) ) {
					$display = $selected_user->$show;
				} else {
					$display = '(' . $selected_user->user_login . ')';

@sanketio

Copy link
Copy Markdown
Author

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 ArgumentCountError when a callable is invoked with fewer arguments than it has required parameters; passing extra arguments is always allowed and they're simply ignored. That's exactly why core's own zero-param filters (__return_true, __return_false, __return_empty_array, …) work fine with add_filter(). Confirmed on PHP 8.3.31:

$f = static function(){ return 'ok'; };
call_user_func_array( $f, array( 'extra' ) ); // 'ok', no error

These tests also already pass on the PHP 8.3 CI container (18/18), so there's no ArgumentCountError in practice.

Prefilled autocomplete display (user.php) — no new disclosure here. $show is set by the calling PHP code, not the client, matching the classic <select> mode's trust model. And autocomplete mode exposes less than the classic dropdown: the classic mode renders every matching user's email across all <option>s when show=user_email (with no capability gate, historically), whereas autocomplete only prefills the single already-selected user. Applying an edit_users gate to the prefill would make autocomplete diverge from the long-standing classic wp_dropdown_users() behavior, so I've left it consistent.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants