diff --git a/src/wp-includes/class-wp-hook.php b/src/wp-includes/class-wp-hook.php index cd6860c0f81f2..0d62917cba61d 100644 --- a/src/wp-includes/class-wp-hook.php +++ b/src/wp-includes/class-wp-hook.php @@ -150,6 +150,16 @@ private function resort_active_iterations( $new_priority = false, $priority_exis } } + /* + * If $current's bucket was emptied during iteration, the iterator now + * sits on the first remaining priority greater than $current. The + * trailing next() in ::apply_filters() would skip past it, so step + * back one so that next() lands on it instead. + */ + if ( false !== current( $iteration ) && ! isset( $this->callbacks[ $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/removeFilter.php b/tests/phpunit/tests/hooks/removeFilter.php index d3065bb9bd425..ef5ee717caa49 100644 --- a/tests/phpunit/tests/hooks/removeFilter.php +++ b/tests/phpunit/tests/hooks/removeFilter.php @@ -86,6 +86,45 @@ public function test_remove_filter_with_another_at_different_priority() { $this->check_priority_exists( $hook, $priority + 1, 'Should priority of 3' ); } + /** + * Removing the last callback at the currently iterating priority must not + * cause the next remaining priority to be silently skipped. + * + * @ticket 65167 + * + * @covers WP_Hook::remove_filter + * @covers WP_Hook::apply_filters + */ + public function test_remove_filter_during_iteration_does_not_skip_next_priority() { + $hook = new WP_Hook(); + $hook_name = __FUNCTION__; + $fired = array(); + + $early = static function ( $value ) use ( &$fired ) { + $fired[] = 'early'; + return $value; + }; + + $self_removing = static function ( $value ) use ( &$hook, $hook_name, &$self_removing, &$fired ) { + $fired[] = 'self_removing'; + $hook->remove_filter( $hook_name, $self_removing, 10 ); + return $value; + }; + + $later = static function ( $value ) use ( &$fired ) { + $fired[] = 'later'; + return $value; + }; + + $hook->add_filter( $hook_name, $early, 5, 1 ); + $hook->add_filter( $hook_name, $self_removing, 10, 1 ); + $hook->add_filter( $hook_name, $later, 20, 1 ); + + $hook->apply_filters( null, array( null ) ); + + $this->assertSame( array( 'early', 'self_removing', 'later' ), $fired ); + } + protected function check_priority_non_existent( $hook, $priority ) { $priorities = $this->get_priorities( $hook );