From 9ca834c5f2de5cb9ed069277948017e6cf00892f Mon Sep 17 00:00:00 2001 From: Vlad <2430296+mrcasual@users.noreply.github.com> Date: Mon, 16 Feb 2026 19:27:48 -0500 Subject: [PATCH] Hooks: Fix `resort_active_iterations()` skipping next priority on self-removal. When a callback removes itself during `do_action()`/`apply_filters()` and is the sole callback at its priority, `resort_active_iterations()` repositions the internal array pointer one position too far. The main loop's `next()` call then skips the following priority entirely. The fix detects when the current priority was removed (the pointer advanced past it) and calls `prev()` so that `next()` in the calling loop lands on the correct priority. Fixes #64653. --- src/wp-includes/class-wp-hook.php | 5 ++ tests/phpunit/tests/hooks/doAction.php | 67 ++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/src/wp-includes/class-wp-hook.php b/src/wp-includes/class-wp-hook.php index cd6860c0f81f2..a88ca0ed01c61 100644 --- a/src/wp-includes/class-wp-hook.php +++ b/src/wp-includes/class-wp-hook.php @@ -150,6 +150,11 @@ private function resort_active_iterations( $new_priority = false, $priority_exis } } + // If the current priority was removed, step back so the next() call in the main loop lands correctly. + if ( false !== current( $iteration ) && current( $iteration ) !== $current ) { + prev( $iteration ); + } + // If we have a new priority that didn't exist, but ::apply_filters() or ::do_action() thinks it's the current priority... if ( $new_priority === $this->current_priority[ $index ] && ! $priority_existed ) { /* diff --git a/tests/phpunit/tests/hooks/doAction.php b/tests/phpunit/tests/hooks/doAction.php index c9767f865df26..06eb197ec5289 100644 --- a/tests/phpunit/tests/hooks/doAction.php +++ b/tests/phpunit/tests/hooks/doAction.php @@ -291,6 +291,73 @@ public function _filter_do_action_doesnt_change_value3( $value ) { return 'x3'; } + /** + * Verify that a callback removing itself during execution does not cause + * the next priority to be skipped. + * + * When a callback is the sole entry at its priority and removes itself + * mid-iteration, resort_active_iterations() repositions the internal + * array pointer. Before the fix, the pointer ended up one position too + * far, causing apply_filters()'s next() call to skip the following + * priority entirely. + * + * @ticket 64653 + */ + public function test_self_removing_callback_does_not_skip_next_priority() { + $hook = new WP_Hook(); + $hook_name = __FUNCTION__; + $log = array(); + + $callback_10 = function () use ( &$log ) { + $log[] = 10; + }; + + // Callback that removes itself -- the only callback at priority 50. + $self_removing = function () use ( &$log, &$self_removing, $hook, $hook_name ) { + $hook->remove_filter( $hook_name, $self_removing, 50 ); + $log[] = 50; + }; + + $callback_100 = function () use ( &$log ) { + $log[] = 100; + }; + + $hook->add_filter( $hook_name, $callback_10, 10, 0 ); + $hook->add_filter( $hook_name, $self_removing, 50, 0 ); + $hook->add_filter( $hook_name, $callback_100, 100, 0 ); + + $hook->do_action( array() ); + + $this->assertSame( array( 10, 50, 100 ), $log, 'Priority 100 should not be skipped when priority 50 removes itself during iteration.' ); + } + + /** + * Verify the fix when the self-removing callback is at the first priority. + * + * @ticket 64653 + */ + public function test_self_removing_callback_at_lowest_priority() { + $hook = new WP_Hook(); + $hook_name = __FUNCTION__; + $log = array(); + + $self_removing = function () use ( &$log, &$self_removing, $hook, $hook_name ) { + $hook->remove_filter( $hook_name, $self_removing, 10 ); + $log[] = 10; + }; + + $callback_50 = function () use ( &$log ) { + $log[] = 50; + }; + + $hook->add_filter( $hook_name, $self_removing, 10, 0 ); + $hook->add_filter( $hook_name, $callback_50, 50, 0 ); + + $hook->do_action( array() ); + + $this->assertSame( array( 10, 50 ), $log, 'Priority 50 should execute when priority 10 removes itself.' ); + } + /** * Use this rather than MockAction so we can test callbacks with no args *