From b7cb7fcfc9bc3d6dc089741b1f5bac829ac20dd6 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 24 Jun 2026 14:30:02 -0700 Subject: [PATCH 01/16] Comments: Add filter for comment types excluded from queries by default WP_Comment_Query hard-codes the exclusion of the 'note' comment type (introduced in 6.9) with no extension point. Plugins that add their own "private" comment types must instead rewrite query SQL through comments_clauses in every context and re-verify it on each release. Introduce a default_excluded_comment_types filter so extenders can contribute additional comment types to the default-excluded set, generalizing the existing 'note' handling. The default value of array( 'note' ) preserves current behavior exactly. See #65537. --- src/wp-includes/class-wp-comment-query.php | 38 +++- tests/phpunit/tests/comment/query.php | 213 +++++++++++++++++++++ 2 files changed, 244 insertions(+), 7 deletions(-) diff --git a/src/wp-includes/class-wp-comment-query.php b/src/wp-includes/class-wp-comment-query.php index cfabfd7e6b964..9185ddf367be8 100644 --- a/src/wp-includes/class-wp-comment-query.php +++ b/src/wp-includes/class-wp-comment-query.php @@ -537,6 +537,7 @@ public function get_comments() { * * @since 4.4.0 * @since 6.9.0 Excludes the 'note' comment type, unless 'all' or the 'note' types are requested. + * @since 6.10.0 The default-excluded comment types are filterable via {@see 'default_excluded_comment_types'}. * * @global wpdb $wpdb WordPress database abstraction object. * @@ -771,13 +772,36 @@ protected function get_comment_ids() { 'NOT IN' => (array) $this->query_vars['type__not_in'], ); - // Exclude the 'note' comment type, unless 'all' types or the 'note' type explicitly are requested. - if ( - ! in_array( 'all', $raw_types['IN'], true ) && - ! in_array( 'note', $raw_types['IN'], true ) && - ! in_array( 'note', $raw_types['NOT IN'], true ) - ) { - $raw_types['NOT IN'][] = 'note'; + /** + * Filters the comment types that are excluded from query results by default. + * + * Comment types in this list are omitted from `WP_Comment_Query` results + * unless the query explicitly requests the 'all' type, or requests the + * specific type via the 'type', 'type__in', or 'type__not_in' query + * variables. + * + * This allows plugins to register "private" comment types that should not + * surface in standard comment listings, counts, or feeds, without having + * to filter every query individually. The 'note' comment type, used by the + * editor, is excluded by default. + * + * @since 6.10.0 + * + * @param string[] $excluded_types Comment types excluded from query results by default. + * Default array contains the 'note' type. + * @param WP_Comment_Query $query The WP_Comment_Query instance (passed by reference). + */ + $excluded_types = apply_filters_ref_array( 'default_excluded_comment_types', array( array( 'note' ), &$this ) ); + + // Exclude the default-excluded comment types, unless 'all' types or that type explicitly are requested. + foreach ( array_unique( (array) $excluded_types ) as $excluded_type ) { + if ( + ! in_array( 'all', $raw_types['IN'], true ) && + ! in_array( $excluded_type, $raw_types['IN'], true ) && + ! in_array( $excluded_type, $raw_types['NOT IN'], true ) + ) { + $raw_types['NOT IN'][] = $excluded_type; + } } $comment_types = array(); diff --git a/tests/phpunit/tests/comment/query.php b/tests/phpunit/tests/comment/query.php index dc870a78ae494..07cabe2c64eb4 100644 --- a/tests/phpunit/tests/comment/query.php +++ b/tests/phpunit/tests/comment/query.php @@ -5534,4 +5534,217 @@ public function test_get_comment_count_excludes_note_type() { $this->assertSame( 1, $counts['all'] ); $this->assertSame( 1, $counts['total_comments'] ); } + + /** + * Helper method to create the standard set of comments used by the + * `default_excluded_comment_types` filter tests. + * + * Creates one comment of each of the 'comment', 'note', and 'private' types. + * + * @since 6.10.0 + * + * @return array<'comment'|'note'|'private', int> Array of created comment IDs keyed by type. + */ + protected function create_excluded_type_test_comments(): array { + return array( + 'comment' => self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'comment_approved' => '1', + ) + ), + 'note' => self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'comment_approved' => '1', + 'comment_type' => 'note', + ) + ), + 'private' => self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'comment_approved' => '1', + 'comment_type' => 'private', + ) + ), + ); + } + + /** + * Returns the comment types for a list of comment IDs. + * + * @param int[] $comment_ids Comment IDs. + * @return string[] Comment types. + */ + private function get_comment_types_for_ids( array $comment_ids ): array { + return array_map( + static function ( int $comment_id ): string { + return get_comment( $comment_id )->comment_type; + }, + $comment_ids + ); + } + + /** + * A custom comment type added through the filter is excluded by default, + * alongside the default-excluded 'note' type. + * + * @ticket 65537 + * @covers WP_Comment_Query::get_comment_ids + */ + public function test_default_excluded_comment_types_filter_excludes_custom_type() { + $this->create_excluded_type_test_comments(); + + add_filter( + 'default_excluded_comment_types', + static function ( array $types ): array { + $types[] = 'private'; + return $types; + } + ); + + $query = new WP_Comment_Query(); + $found = $query->query( array( 'fields' => 'ids' ) ); + + $this->assertSameSets( + array( 'comment' ), + $this->get_comment_types_for_ids( $found ), + 'The custom excluded type and the default note type should both be omitted.' + ); + } + + /** + * Removing 'note' from the filtered list makes notes appear in default queries, + * proving the default exclusion itself is filterable. + * + * @ticket 65537 + * @covers WP_Comment_Query::get_comment_ids + */ + public function test_default_excluded_comment_types_filter_can_remove_note() { + $this->create_excluded_type_test_comments(); + + add_filter( 'default_excluded_comment_types', '__return_empty_array' ); + + $query = new WP_Comment_Query(); + $found = $query->query( array( 'fields' => 'ids' ) ); + + $this->assertSameSets( + array( 'comment', 'note', 'private' ), + $this->get_comment_types_for_ids( $found ), + 'With an empty exclusion list, all comment types should be returned.' + ); + } + + /** + * A custom excluded type is still returned when explicitly requested. + * + * @ticket 65537 + * @covers WP_Comment_Query::get_comment_ids + * @dataProvider data_default_excluded_comment_types_explicit_request + * + * @param array $query_args Query arguments for WP_Comment_Query. + * @param string[] $expected_types Expected comment types. + */ + public function test_default_excluded_comment_types_filter_respects_explicit_request( array $query_args, array $expected_types ) { + $this->create_excluded_type_test_comments(); + + add_filter( + 'default_excluded_comment_types', + static function ( array $types ): array { + $types[] = 'private'; + return $types; + } + ); + + $query = new WP_Comment_Query(); + $found = $query->query( array_merge( $query_args, array( 'fields' => 'ids' ) ) ); + + $this->assertSameSets( $expected_types, $this->get_comment_types_for_ids( $found ) ); + } + + /** + * Data provider for explicit-request tests against a filtered excluded type. + * + * @since 6.10.0 + * + * @return array, expected_types: string[] }> + */ + public function data_default_excluded_comment_types_explicit_request(): array { + return array( + 'type all includes excluded types' => array( + 'query_args' => array( 'type' => 'all' ), + 'expected_types' => array( 'comment', 'note', 'private' ), + ), + 'explicit custom type' => array( + 'query_args' => array( 'type' => 'private' ), + 'expected_types' => array( 'private' ), + ), + 'custom type via type__in' => array( + 'query_args' => array( 'type__in' => array( 'private' ) ), + 'expected_types' => array( 'private' ), + ), + 'custom type with comment via type__in' => array( + 'query_args' => array( 'type__in' => array( 'private', 'comment' ) ), + 'expected_types' => array( 'private', 'comment' ), + ), + ); + } + + /** + * The filter receives the default 'note' type and the WP_Comment_Query instance. + * + * @ticket 65537 + * @covers WP_Comment_Query::get_comment_ids + */ + public function test_default_excluded_comment_types_filter_receives_default_and_instance() { + $filter_args = array(); + + add_filter( + 'default_excluded_comment_types', + static function ( $types, $query ) use ( &$filter_args ) { + $filter_args = array( $types, $query ); + return $types; + }, + 10, + 2 + ); + + $query = new WP_Comment_Query(); + $query->query( array( 'fields' => 'ids' ) ); + + $this->assertSame( array( 'note' ), $filter_args[0], 'The filter should receive the default note type.' ); + $this->assertInstanceOf( WP_Comment_Query::class, $filter_args[1], 'The filter should receive the query instance.' ); + } + + /** + * A custom excluded type is only added once to the query, even when a query + * already excludes it via type__not_in. + * + * @ticket 65537 + * @covers WP_Comment_Query::get_comment_ids + */ + public function test_default_excluded_comment_types_filter_not_duplicated_in_query() { + global $wpdb; + + $this->create_excluded_type_test_comments(); + + add_filter( + 'default_excluded_comment_types', + static function ( array $types ): array { + $types[] = 'private'; + return $types; + } + ); + + $query = new WP_Comment_Query(); + $query->query( + array( + 'type__not_in' => array( 'private' ), + 'fields' => 'ids', + ) + ); + + $private_count = substr_count( $wpdb->last_query, "'private'" ); + $this->assertSame( 1, $private_count, 'The private type should only appear once in the query.' ); + } } From 4b57b1a0cf8e1fce47a1b4089c09150e46f21772 Mon Sep 17 00:00:00 2001 From: Adam Silverstein Date: Wed, 24 Jun 2026 14:35:19 -0700 Subject: [PATCH 02/16] Apply suggestion from @adamsilverstein --- src/wp-includes/class-wp-comment-query.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/class-wp-comment-query.php b/src/wp-includes/class-wp-comment-query.php index 9185ddf367be8..c2dc946871972 100644 --- a/src/wp-includes/class-wp-comment-query.php +++ b/src/wp-includes/class-wp-comment-query.php @@ -537,7 +537,7 @@ public function get_comments() { * * @since 4.4.0 * @since 6.9.0 Excludes the 'note' comment type, unless 'all' or the 'note' types are requested. - * @since 6.10.0 The default-excluded comment types are filterable via {@see 'default_excluded_comment_types'}. + * @since 7.1.0 The default-excluded comment types are filterable via {@see 'default_excluded_comment_types'}. * * @global wpdb $wpdb WordPress database abstraction object. * From 9e9fb0b7a4e2edbbbd5331be10b8a15d334b4629 Mon Sep 17 00:00:00 2001 From: Adam Silverstein Date: Wed, 24 Jun 2026 14:35:42 -0700 Subject: [PATCH 03/16] Apply suggestion from @adamsilverstein --- src/wp-includes/class-wp-comment-query.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/class-wp-comment-query.php b/src/wp-includes/class-wp-comment-query.php index c2dc946871972..edc184f4404fb 100644 --- a/src/wp-includes/class-wp-comment-query.php +++ b/src/wp-includes/class-wp-comment-query.php @@ -785,7 +785,7 @@ protected function get_comment_ids() { * to filter every query individually. The 'note' comment type, used by the * editor, is excluded by default. * - * @since 6.10.0 + * @since 7.1.0 * * @param string[] $excluded_types Comment types excluded from query results by default. * Default array contains the 'note' type. From 6e67226feb3a1e783f1bc9a6c5c07f7830a8fc30 Mon Sep 17 00:00:00 2001 From: Adam Silverstein Date: Wed, 24 Jun 2026 14:37:13 -0700 Subject: [PATCH 04/16] Apply suggestion from @adamsilverstein --- tests/phpunit/tests/comment/query.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/phpunit/tests/comment/query.php b/tests/phpunit/tests/comment/query.php index 07cabe2c64eb4..98be9d976e658 100644 --- a/tests/phpunit/tests/comment/query.php +++ b/tests/phpunit/tests/comment/query.php @@ -5541,7 +5541,7 @@ public function test_get_comment_count_excludes_note_type() { * * Creates one comment of each of the 'comment', 'note', and 'private' types. * - * @since 6.10.0 + * @since 7.1.0 * * @return array<'comment'|'note'|'private', int> Array of created comment IDs keyed by type. */ From 237d916912bfa2f6526a11a737600c30b376bf76 Mon Sep 17 00:00:00 2001 From: Adam Silverstein Date: Wed, 24 Jun 2026 14:38:04 -0700 Subject: [PATCH 05/16] Apply suggestion from @adamsilverstein --- tests/phpunit/tests/comment/query.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/phpunit/tests/comment/query.php b/tests/phpunit/tests/comment/query.php index 98be9d976e658..e65ccab53b400 100644 --- a/tests/phpunit/tests/comment/query.php +++ b/tests/phpunit/tests/comment/query.php @@ -5665,7 +5665,7 @@ static function ( array $types ): array { /** * Data provider for explicit-request tests against a filtered excluded type. * - * @since 6.10.0 + * @since 7.1.0 * * @return array, expected_types: string[] }> */ From bb238e958a7027e6325f7a3a338bae316318c0f3 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 24 Jun 2026 22:21:55 -0700 Subject: [PATCH 06/16] Comments: Clarify the default_excluded_comment_types filter is not access control. The filter docblock described excluded types as "private", which could be read as a security boundary. The exclusion only governs default visibility; excluded types remain retrievable via explicit 'type' or 'all' requests, so document that callers must enforce capability checks wherever comment data is displayed or exposed. --- src/wp-includes/class-wp-comment-query.php | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/wp-includes/class-wp-comment-query.php b/src/wp-includes/class-wp-comment-query.php index edc184f4404fb..f6a5563e79e84 100644 --- a/src/wp-includes/class-wp-comment-query.php +++ b/src/wp-includes/class-wp-comment-query.php @@ -780,10 +780,16 @@ protected function get_comment_ids() { * specific type via the 'type', 'type__in', or 'type__not_in' query * variables. * - * This allows plugins to register "private" comment types that should not - * surface in standard comment listings, counts, or feeds, without having - * to filter every query individually. The 'note' comment type, used by the - * editor, is excluded by default. + * This allows plugins to keep comment types out of standard comment + * listings, counts, or feeds by default, without having to filter every + * query individually. The 'note' comment type, used by the editor, is + * excluded by default. + * + * This exclusion is a default-visibility convenience, not an access-control + * mechanism: callers can still retrieve excluded types explicitly (for + * example with 'type' => 'all'), so do not rely on this filter to keep + * comment data private. Enforce capability checks wherever the data is + * displayed or exposed (for example over REST). * * @since 7.1.0 * From 1a4bf12ffa602403635ba581bc02b03e5acbdb84 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Thu, 25 Jun 2026 09:48:11 -0700 Subject: [PATCH 07/16] Comments: Feed default_excluded_comment_types into the comment counter. wp_update_comment_count_now() recalculated a post's stored comment_count with a hard-coded 'AND comment_type != note', bypassing the default_excluded_comment_types filter that hides those types from WP_Comment_Query. A type that opted out of listings still inflated comment_count and therefore get_comments_number(). Apply the same filtered exclusion set to the counter so the listings and the stored count stay consistent, removing the need for plugins to also re-implement the count via pre_wp_update_comment_count_now. See #35214, #65537. --- src/wp-includes/class-wp-comment-query.php | 11 +-- src/wp-includes/comment.php | 23 ++++++- .../tests/comment/wpUpdateCommentCountNow.php | 69 +++++++++++++++++++ 3 files changed, 98 insertions(+), 5 deletions(-) diff --git a/src/wp-includes/class-wp-comment-query.php b/src/wp-includes/class-wp-comment-query.php index f6a5563e79e84..7018c10cb5e0a 100644 --- a/src/wp-includes/class-wp-comment-query.php +++ b/src/wp-includes/class-wp-comment-query.php @@ -783,7 +783,9 @@ protected function get_comment_ids() { * This allows plugins to keep comment types out of standard comment * listings, counts, or feeds by default, without having to filter every * query individually. The 'note' comment type, used by the editor, is - * excluded by default. + * excluded by default. The same set is applied when recalculating a + * post's stored comment count in wp_update_comment_count_now(), so an + * excluded type does not inflate get_comments_number(). * * This exclusion is a default-visibility convenience, not an access-control * mechanism: callers can still retrieve excluded types explicitly (for @@ -793,9 +795,10 @@ protected function get_comment_ids() { * * @since 7.1.0 * - * @param string[] $excluded_types Comment types excluded from query results by default. - * Default array contains the 'note' type. - * @param WP_Comment_Query $query The WP_Comment_Query instance (passed by reference). + * @param string[] $excluded_types Comment types excluded from query results by default. + * Default array contains the 'note' type. + * @param WP_Comment_Query|null $query The WP_Comment_Query instance (passed by reference), + * or null when recalculating a post's comment count. */ $excluded_types = apply_filters_ref_array( 'default_excluded_comment_types', array( array( 'note' ), &$this ) ); diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index b93908adc0519..acf634e2bab02 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -2876,7 +2876,28 @@ function wp_update_comment_count_now( $post_id ) { $new = apply_filters( 'pre_wp_update_comment_count_now', null, $old, $post_id ); if ( is_null( $new ) ) { - $new = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM $wpdb->comments WHERE comment_post_ID = %d AND comment_approved = '1' AND comment_type != 'note'", $post_id ) ); + /** This filter is documented in wp-includes/class-wp-comment-query.php */ + $excluded_types = apply_filters( 'default_excluded_comment_types', array( 'note' ), null ); + $excluded_types = array_unique( array_filter( array_map( 'strval', (array) $excluded_types ) ) ); + + if ( $excluded_types ) { + $new = (int) $wpdb->get_var( + $wpdb->prepare( + sprintf( + "SELECT COUNT(*) FROM $wpdb->comments WHERE comment_post_ID = %%d AND comment_approved = '1' AND comment_type NOT IN (%s)", + implode( ', ', array_fill( 0, count( $excluded_types ), '%s' ) ) + ), + array_merge( array( $post_id ), $excluded_types ) + ) + ); + } else { + $new = (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM $wpdb->comments WHERE comment_post_ID = %d AND comment_approved = '1'", + $post_id + ) + ); + } } else { $new = (int) $new; } diff --git a/tests/phpunit/tests/comment/wpUpdateCommentCountNow.php b/tests/phpunit/tests/comment/wpUpdateCommentCountNow.php index 9dbb1f244ccf8..5f48b89f95524 100644 --- a/tests/phpunit/tests/comment/wpUpdateCommentCountNow.php +++ b/tests/phpunit/tests/comment/wpUpdateCommentCountNow.php @@ -83,6 +83,75 @@ public function test_only_approved_regular_comments_are_counted() { $this->assertSame( '1', get_comments_number( $post_id ) ); } + /** + * A comment type excluded via the shared filter must not inflate the stored count. + * + * @ticket 65537 + */ + public function test_filtered_excluded_type_does_not_inflate_count() { + $post_id = self::factory()->post->create(); + + self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_approved' => 1, + ) + ); + self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'review', + 'comment_approved' => 1, + ) + ); + + // Without exclusion, both approved comments are counted. + $this->assertTrue( wp_update_comment_count_now( $post_id ) ); + $this->assertSame( '2', get_comments_number( $post_id ) ); + + // Excluding 'review' through the same filter that hides it from queries drops it from the count. + $filter = static function ( $types ) { + $types[] = 'review'; + return $types; + }; + add_filter( 'default_excluded_comment_types', $filter ); + $this->assertTrue( wp_update_comment_count_now( $post_id ) ); + remove_filter( 'default_excluded_comment_types', $filter ); + + $this->assertSame( '1', get_comments_number( $post_id ) ); + } + + /** + * The count is driven by the filtered set, not a hard-coded 'note' literal. + * + * Clearing the excluded set causes 'note' comments to be counted, proving the + * exclusion comes from the filter rather than an in-query literal. + * + * @ticket 65537 + */ + public function test_emptying_filter_counts_otherwise_excluded_types() { + $post_id = self::factory()->post->create(); + + self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'note', + 'comment_approved' => 1, + ) + ); + + // By default the 'note' type is excluded. + $this->assertTrue( wp_update_comment_count_now( $post_id ) ); + $this->assertSame( '0', get_comments_number( $post_id ) ); + + // A plugin that clears the excluded set causes notes to be counted. + add_filter( 'default_excluded_comment_types', '__return_empty_array' ); + $this->assertTrue( wp_update_comment_count_now( $post_id ) ); + remove_filter( 'default_excluded_comment_types', '__return_empty_array' ); + + $this->assertSame( '1', get_comments_number( $post_id ) ); + } + public function _return_100() { return 100; } From 2f75e55538cbf1ad3d85e9ccd32c24384c09da78 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Thu, 25 Jun 2026 11:16:46 -0700 Subject: [PATCH 08/16] Comments: Honor default_excluded_comment_types in pending counts. get_pending_comments_num() recomputed the admin pending-comments count with a hard-coded 'AND comment_type != note', the same gap fixed in wp_update_comment_count_now(): a type that opts out of default listings via default_excluded_comment_types still inflated the pending bubble shown next to each post in the admin list tables. Derive the excluded set from the same filter so the pending count stays consistent with the listings and the stored comment count. See #35214, #65537. --- src/wp-admin/includes/comment.php | 18 ++- .../comment/GetPendingCommentsNum_Test.php | 118 ++++++++++++++++++ 2 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 tests/phpunit/tests/admin/includes/comment/GetPendingCommentsNum_Test.php diff --git a/src/wp-admin/includes/comment.php b/src/wp-admin/includes/comment.php index ae5ba9d223350..07ba7d05d06df 100644 --- a/src/wp-admin/includes/comment.php +++ b/src/wp-admin/includes/comment.php @@ -139,6 +139,8 @@ function get_comment_to_edit( $id ) { * * @since 2.3.0 * @since 6.9.0 Exclude the 'note' comment type from the count. + * @since 7.1.0 The excluded comment types are derived from the + * {@see 'default_excluded_comment_types'} filter. * * @global wpdb $wpdb WordPress database abstraction object. * @@ -158,7 +160,21 @@ function get_pending_comments_num( $post_id ) { $post_id_array = array_map( 'intval', $post_id_array ); $post_id_in = "'" . implode( "', '", $post_id_array ) . "'"; - $pending = $wpdb->get_results( "SELECT comment_post_ID, COUNT(comment_ID) as num_comments FROM $wpdb->comments WHERE comment_post_ID IN ( $post_id_in ) AND comment_approved = '0' AND comment_type != 'note' GROUP BY comment_post_ID", ARRAY_A ); + /** This filter is documented in wp-includes/class-wp-comment-query.php */ + $excluded_types = apply_filters( 'default_excluded_comment_types', array( 'note' ), null ); + $excluded_types = array_unique( array_filter( array_map( 'strval', (array) $excluded_types ) ) ); + + $type_not_in = ''; + if ( $excluded_types ) { + $type_not_in = $wpdb->prepare( + sprintf( ' AND comment_type NOT IN ( %s )', implode( ', ', array_fill( 0, count( $excluded_types ), '%s' ) ) ), + $excluded_types + ); + } + + // $post_id_in is built from integers and $type_not_in is prepared above. + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $pending = $wpdb->get_results( "SELECT comment_post_ID, COUNT(comment_ID) as num_comments FROM $wpdb->comments WHERE comment_post_ID IN ( $post_id_in ) AND comment_approved = '0'$type_not_in GROUP BY comment_post_ID", ARRAY_A ); if ( $single ) { if ( empty( $pending ) ) { diff --git a/tests/phpunit/tests/admin/includes/comment/GetPendingCommentsNum_Test.php b/tests/phpunit/tests/admin/includes/comment/GetPendingCommentsNum_Test.php new file mode 100644 index 0000000000000..e609f7ca46f12 --- /dev/null +++ b/tests/phpunit/tests/admin/includes/comment/GetPendingCommentsNum_Test.php @@ -0,0 +1,118 @@ +comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => $comment_type, + 'comment_approved' => '0', + ) + ); + } + + /** + * @ticket 65537 + */ + public function test_counts_only_pending_comments() { + $post_id = self::factory()->post->create(); + $this->make_pending( $post_id ); + self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_approved' => '1', + ) + ); + + $this->assertSame( 1, get_pending_comments_num( $post_id ) ); + } + + /** + * @ticket 65537 + */ + public function test_excludes_note_type_by_default() { + $post_id = self::factory()->post->create(); + $this->make_pending( $post_id ); + $this->make_pending( $post_id, 'note' ); + + $this->assertSame( 1, get_pending_comments_num( $post_id ) ); + } + + /** + * A type added to the excluded set must drop out of the pending count. + * + * @ticket 65537 + */ + public function test_excludes_a_filtered_type() { + $post_id = self::factory()->post->create(); + $this->make_pending( $post_id ); + $this->make_pending( $post_id, 'review' ); + + // 'review' is counted by default. + $this->assertSame( 2, get_pending_comments_num( $post_id ) ); + + $filter = static function ( $types ) { + $types[] = 'review'; + return $types; + }; + add_filter( 'default_excluded_comment_types', $filter ); + $num = get_pending_comments_num( $post_id ); + remove_filter( 'default_excluded_comment_types', $filter ); + + $this->assertSame( 1, $num ); + } + + /** + * The exclusion is filter-driven, not a hard-coded 'note' literal. + * + * @ticket 65537 + */ + public function test_emptying_filter_counts_note_type() { + $post_id = self::factory()->post->create(); + $this->make_pending( $post_id, 'note' ); + + $this->assertSame( 0, get_pending_comments_num( $post_id ) ); + + add_filter( 'default_excluded_comment_types', '__return_empty_array' ); + $num = get_pending_comments_num( $post_id ); + remove_filter( 'default_excluded_comment_types', '__return_empty_array' ); + + $this->assertSame( 1, $num ); + } + + /** + * @ticket 65537 + */ + public function test_array_input_returns_counts_keyed_by_post() { + $post_a = self::factory()->post->create(); + $post_b = self::factory()->post->create(); + $this->make_pending( $post_a ); + $this->make_pending( $post_a, 'note' ); + $this->make_pending( $post_b ); + $this->make_pending( $post_b ); + + $counts = get_pending_comments_num( array( $post_a, $post_b ) ); + + $this->assertSame( + array( + $post_a => 1, + $post_b => 2, + ), + $counts + ); + } +} From e656cbdedc68f5c944188a440ae19fc8515a1060 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Sat, 11 Jul 2026 10:58:19 -0700 Subject: [PATCH 09/16] Comments: Extract wp_get_default_excluded_comment_types(). The default-excluded set plus filter application was triplicated across WP_Comment_Query, wp_update_comment_count_now(), and get_pending_comments_num(), each with subtly different normalization. Centralize it in one accessor that all three call, which is also the seam where a future comment type registry can derive the default from internal types. The accessor additionally strips the special type tokens understood by WP_Comment_Query ('all', 'comment', 'comments', 'pings'): previously a filter returning the 'pings' alias excluded nothing from the count functions but zeroed out an explicit type => 'pingback' query, because the explicit-request guard compares raw tokens while the query expansion resolves aliases. Also document the registration-timing contract (register the filter unconditionally rather than toggling per call - the query cache key does not include filter output), widen the null-context description, hoist the invariant 'all' check out of the per-type loop, add the missing @since entry to wp_update_comment_count_now(), and make the no-duplication test capture the WHERE clause via comments_clauses instead of asserting against $wpdb->last_query. --- src/wp-admin/includes/comment.php | 4 +- src/wp-includes/class-wp-comment-query.php | 50 ++++--------- src/wp-includes/comment.php | 71 ++++++++++++++++++- .../comment/GetPendingCommentsNum_Test.php | 16 +++++ tests/phpunit/tests/comment/query.php | 70 ++++++++++++++++-- .../tests/comment/wpUpdateCommentCountNow.php | 23 ++++++ 6 files changed, 186 insertions(+), 48 deletions(-) diff --git a/src/wp-admin/includes/comment.php b/src/wp-admin/includes/comment.php index 07ba7d05d06df..d750e5242269b 100644 --- a/src/wp-admin/includes/comment.php +++ b/src/wp-admin/includes/comment.php @@ -160,9 +160,7 @@ function get_pending_comments_num( $post_id ) { $post_id_array = array_map( 'intval', $post_id_array ); $post_id_in = "'" . implode( "', '", $post_id_array ) . "'"; - /** This filter is documented in wp-includes/class-wp-comment-query.php */ - $excluded_types = apply_filters( 'default_excluded_comment_types', array( 'note' ), null ); - $excluded_types = array_unique( array_filter( array_map( 'strval', (array) $excluded_types ) ) ); + $excluded_types = wp_get_default_excluded_comment_types(); $type_not_in = ''; if ( $excluded_types ) { diff --git a/src/wp-includes/class-wp-comment-query.php b/src/wp-includes/class-wp-comment-query.php index 7018c10cb5e0a..ea94dd7c42d39 100644 --- a/src/wp-includes/class-wp-comment-query.php +++ b/src/wp-includes/class-wp-comment-query.php @@ -772,44 +772,18 @@ protected function get_comment_ids() { 'NOT IN' => (array) $this->query_vars['type__not_in'], ); - /** - * Filters the comment types that are excluded from query results by default. - * - * Comment types in this list are omitted from `WP_Comment_Query` results - * unless the query explicitly requests the 'all' type, or requests the - * specific type via the 'type', 'type__in', or 'type__not_in' query - * variables. - * - * This allows plugins to keep comment types out of standard comment - * listings, counts, or feeds by default, without having to filter every - * query individually. The 'note' comment type, used by the editor, is - * excluded by default. The same set is applied when recalculating a - * post's stored comment count in wp_update_comment_count_now(), so an - * excluded type does not inflate get_comments_number(). - * - * This exclusion is a default-visibility convenience, not an access-control - * mechanism: callers can still retrieve excluded types explicitly (for - * example with 'type' => 'all'), so do not rely on this filter to keep - * comment data private. Enforce capability checks wherever the data is - * displayed or exposed (for example over REST). - * - * @since 7.1.0 - * - * @param string[] $excluded_types Comment types excluded from query results by default. - * Default array contains the 'note' type. - * @param WP_Comment_Query|null $query The WP_Comment_Query instance (passed by reference), - * or null when recalculating a post's comment count. - */ - $excluded_types = apply_filters_ref_array( 'default_excluded_comment_types', array( array( 'note' ), &$this ) ); - - // Exclude the default-excluded comment types, unless 'all' types or that type explicitly are requested. - foreach ( array_unique( (array) $excluded_types ) as $excluded_type ) { - if ( - ! in_array( 'all', $raw_types['IN'], true ) && - ! in_array( $excluded_type, $raw_types['IN'], true ) && - ! in_array( $excluded_type, $raw_types['NOT IN'], true ) - ) { - $raw_types['NOT IN'][] = $excluded_type; + $excluded_types = wp_get_default_excluded_comment_types( $this ); + + // Unless all types are requested, exclude each default-excluded type + // that the query does not explicitly request. + if ( ! in_array( 'all', $raw_types['IN'], true ) ) { + foreach ( $excluded_types as $excluded_type ) { + if ( + ! in_array( $excluded_type, $raw_types['IN'], true ) && + ! in_array( $excluded_type, $raw_types['NOT IN'], true ) + ) { + $raw_types['NOT IN'][] = $excluded_type; + } } } diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index acf634e2bab02..eccc81e09a73c 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -2834,10 +2834,77 @@ function wp_update_comment_count( $post_id, $do_deferred = false ) { return null; } +/** + * Retrieves the comment types that are excluded from queries and counts by default. + * + * Applies the {@see 'default_excluded_comment_types'} filter and normalizes the + * result: values are cast to strings, empties and duplicates are removed, and + * the special type tokens understood by WP_Comment_Query ('all', 'comment', + * 'comments', 'pings') are stripped - the filter deals in literal + * `comment_type` values only. + * + * @since 7.1.0 + * + * @param WP_Comment_Query|null $query Optional. The current query instance when called + * from WP_Comment_Query, or null in counting + * contexts. Default null. + * @return string[] Comment types excluded by default. + */ +function wp_get_default_excluded_comment_types( $query = null ) { + /** + * Filters the comment types that are excluded from query results by default. + * + * Comment types in this list are omitted from `WP_Comment_Query` results + * unless the query explicitly requests the 'all' type, or requests the + * specific type via the 'type', 'type__in', or 'type__not_in' query + * variables. + * + * This allows plugins to keep comment types out of standard comment + * listings, counts, or feeds by default, without having to filter every + * query individually. The 'note' comment type, used by the editor, is + * excluded by default. The same set is applied when recalculating a + * post's stored comment count in wp_update_comment_count_now() and when + * counting pending comments, so an excluded type does not inflate + * get_comments_number(). + * + * Values must be literal `comment_type` values as stored in the database; + * the special type tokens understood by WP_Comment_Query ('all', 'comment', + * 'comments', 'pings') are ignored. + * + * Register callbacks for this filter unconditionally (for example on + * 'plugins_loaded' or 'init') rather than toggling them per call: query + * results are cached against the comment `last_changed` key, which does not + * account for this filter's output, so per-call toggling can serve stale + * results from the cache. + * + * This exclusion is a default-visibility convenience, not an access-control + * mechanism: callers can still retrieve excluded types explicitly (for + * example with 'type' => 'all'), so do not rely on this filter to keep + * comment data private. Enforce capability checks wherever the data is + * displayed or exposed (for example over REST). + * + * @since 7.1.0 + * + * @param string[] $excluded_types Comment types excluded from query results by default. + * Default array contains the 'note' type. + * @param WP_Comment_Query|null $query The WP_Comment_Query instance, or null in counting + * contexts (recalculating a post's stored comment + * count, counting pending comments). + */ + $excluded_types = apply_filters( 'default_excluded_comment_types', array( 'note' ), $query ); + + $excluded_types = array_unique( array_filter( array_map( 'strval', (array) $excluded_types ) ) ); + + // Strip the special type tokens so an alias cannot poison explicit-type queries. + return array_values( array_diff( $excluded_types, array( 'all', 'comment', 'comments', 'pings' ) ) ); +} + /** * Updates the comment count for the post. * * @since 2.5.0 + * @since 7.1.0 The excluded comment types are derived from the + * {@see 'default_excluded_comment_types'} filter. * * @global wpdb $wpdb WordPress database abstraction object. * @@ -2876,9 +2943,7 @@ function wp_update_comment_count_now( $post_id ) { $new = apply_filters( 'pre_wp_update_comment_count_now', null, $old, $post_id ); if ( is_null( $new ) ) { - /** This filter is documented in wp-includes/class-wp-comment-query.php */ - $excluded_types = apply_filters( 'default_excluded_comment_types', array( 'note' ), null ); - $excluded_types = array_unique( array_filter( array_map( 'strval', (array) $excluded_types ) ) ); + $excluded_types = wp_get_default_excluded_comment_types(); if ( $excluded_types ) { $new = (int) $wpdb->get_var( diff --git a/tests/phpunit/tests/admin/includes/comment/GetPendingCommentsNum_Test.php b/tests/phpunit/tests/admin/includes/comment/GetPendingCommentsNum_Test.php index e609f7ca46f12..183c9d019bf37 100644 --- a/tests/phpunit/tests/admin/includes/comment/GetPendingCommentsNum_Test.php +++ b/tests/phpunit/tests/admin/includes/comment/GetPendingCommentsNum_Test.php @@ -94,6 +94,22 @@ public function test_emptying_filter_counts_note_type() { $this->assertSame( 1, $num ); } + /** + * A filter callback returning a non-array degrades gracefully to no exclusions. + * + * @ticket 65537 + */ + public function test_non_array_filter_return_counts_all_types() { + $post_id = self::factory()->post->create(); + $this->make_pending( $post_id, 'note' ); + + add_filter( 'default_excluded_comment_types', '__return_false' ); + $num = get_pending_comments_num( $post_id ); + remove_filter( 'default_excluded_comment_types', '__return_false' ); + + $this->assertSame( 1, $num ); + } + /** * @ticket 65537 */ diff --git a/tests/phpunit/tests/comment/query.php b/tests/phpunit/tests/comment/query.php index e65ccab53b400..33bfe1f508076 100644 --- a/tests/phpunit/tests/comment/query.php +++ b/tests/phpunit/tests/comment/query.php @@ -5724,8 +5724,6 @@ static function ( $types, $query ) use ( &$filter_args ) { * @covers WP_Comment_Query::get_comment_ids */ public function test_default_excluded_comment_types_filter_not_duplicated_in_query() { - global $wpdb; - $this->create_excluded_type_test_comments(); add_filter( @@ -5736,6 +5734,15 @@ static function ( array $types ): array { } ); + $captured_where = ''; + add_filter( + 'comments_clauses', + static function ( array $clauses ) use ( &$captured_where ): array { + $captured_where = $clauses['where']; + return $clauses; + } + ); + $query = new WP_Comment_Query(); $query->query( array( @@ -5744,7 +5751,62 @@ static function ( array $types ): array { ) ); - $private_count = substr_count( $wpdb->last_query, "'private'" ); - $this->assertSame( 1, $private_count, 'The private type should only appear once in the query.' ); + $private_count = substr_count( $captured_where, "'private'" ); + $this->assertSame( 1, $private_count, 'The private type should only appear once in the WHERE clause.' ); + } + + /** + * A filter callback returning a non-array degrades gracefully to no exclusions. + * + * @ticket 65537 + * @covers WP_Comment_Query::get_comment_ids + */ + public function test_default_excluded_comment_types_filter_non_array_return_is_tolerated() { + $comments = $this->create_note_type_test_comments(); + + add_filter( 'default_excluded_comment_types', '__return_false' ); + + $query = new WP_Comment_Query(); + $found = $query->query( array( 'fields' => 'ids' ) ); + + // With no exclusions, the note comment is included. + $this->assertContains( $comments['note'], $found ); + } + + /** + * The special type tokens understood by WP_Comment_Query are stripped from the + * filter output, so an alias cannot poison an explicit-type query. + * + * @ticket 65537 + * @covers ::wp_get_default_excluded_comment_types + */ + public function test_default_excluded_comment_types_filter_strips_special_tokens() { + $comments = $this->create_note_type_test_comments(); + + add_filter( + 'default_excluded_comment_types', + static function ( array $types ): array { + // 'pings' is a WP_Comment_Query alias, not a literal comment type. + $types[] = 'pings'; + return $types; + } + ); + + // An explicit request for pingbacks must still find them. + $query = new WP_Comment_Query(); + $found = $query->query( + array( + 'type' => 'pingback', + 'fields' => 'ids', + ) + ); + + $this->assertSame( array( $comments['pingback'] ), array_map( 'intval', $found ) ); + + // The alias excludes nothing from a default query either. + $query = new WP_Comment_Query(); + $found = $query->query( array( 'fields' => 'ids' ) ); + + $this->assertContains( $comments['pingback'], $found ); } } diff --git a/tests/phpunit/tests/comment/wpUpdateCommentCountNow.php b/tests/phpunit/tests/comment/wpUpdateCommentCountNow.php index 5f48b89f95524..cb0405a81e246 100644 --- a/tests/phpunit/tests/comment/wpUpdateCommentCountNow.php +++ b/tests/phpunit/tests/comment/wpUpdateCommentCountNow.php @@ -152,6 +152,29 @@ public function test_emptying_filter_counts_otherwise_excluded_types() { $this->assertSame( '1', get_comments_number( $post_id ) ); } + /** + * A filter callback returning a non-array degrades gracefully to no exclusions. + * + * @ticket 65537 + */ + public function test_non_array_filter_return_counts_all_types() { + $post_id = self::factory()->post->create(); + + self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'note', + 'comment_approved' => 1, + ) + ); + + add_filter( 'default_excluded_comment_types', '__return_false' ); + $this->assertTrue( wp_update_comment_count_now( $post_id ) ); + remove_filter( 'default_excluded_comment_types', '__return_false' ); + + $this->assertSame( '1', get_comments_number( $post_id ) ); + } + public function _return_100() { return 100; } From e0d3cf993cc995cde603761cc3c87372f0957f81 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Fri, 17 Jul 2026 11:48:31 -0700 Subject: [PATCH 10/16] Comments: Treat aliased type requests as explicit against excluded types. When default_excluded_comment_types excludes a literal ping type (for example 'pingback'), a query requesting the 'pings' alias built `comment_type IN ('pingback','trackback') AND comment_type NOT IN ('pingback')`, silently dropping the explicitly requested pingbacks. The guard compared excluded literals against the raw request tokens, so an alias never matched the literal it stands for. Expand the request's special tokens ('comment', 'comments', 'pings') to their literal comment_type values before deciding what to exclude, so a type requested via an alias is honored. This also drops the redundant NOT IN membership check, since the list is deduplicated with array_unique() before the clause is built. --- src/wp-includes/class-wp-comment-query.php | 31 +++++++++++++++--- tests/phpunit/tests/comment/query.php | 37 ++++++++++++++++++++++ 2 files changed, 63 insertions(+), 5 deletions(-) diff --git a/src/wp-includes/class-wp-comment-query.php b/src/wp-includes/class-wp-comment-query.php index ea94dd7c42d39..0a255a8be763f 100644 --- a/src/wp-includes/class-wp-comment-query.php +++ b/src/wp-includes/class-wp-comment-query.php @@ -775,13 +775,34 @@ protected function get_comment_ids() { $excluded_types = wp_get_default_excluded_comment_types( $this ); // Unless all types are requested, exclude each default-excluded type - // that the query does not explicitly request. + // that the query does not explicitly request. The special type tokens + // in the request ('comment', 'comments', 'pings') are first expanded to + // the literal comment_type values they represent, so a type requested + // via an alias (for example 'pings' for 'pingback' and 'trackback') is + // still treated as explicitly requested and is not excluded. if ( ! in_array( 'all', $raw_types['IN'], true ) ) { + $requested_types = array(); + foreach ( $raw_types['IN'] as $requested_type ) { + switch ( $requested_type ) { + case 'comment': + case 'comments': + $requested_types[] = ''; + $requested_types[] = 'comment'; + break; + + case 'pings': + $requested_types[] = 'pingback'; + $requested_types[] = 'trackback'; + break; + + default: + $requested_types[] = $requested_type; + break; + } + } + foreach ( $excluded_types as $excluded_type ) { - if ( - ! in_array( $excluded_type, $raw_types['IN'], true ) && - ! in_array( $excluded_type, $raw_types['NOT IN'], true ) - ) { + if ( ! in_array( $excluded_type, $requested_types, true ) ) { $raw_types['NOT IN'][] = $excluded_type; } } diff --git a/tests/phpunit/tests/comment/query.php b/tests/phpunit/tests/comment/query.php index 33bfe1f508076..d33bae13537b2 100644 --- a/tests/phpunit/tests/comment/query.php +++ b/tests/phpunit/tests/comment/query.php @@ -5809,4 +5809,41 @@ static function ( array $types ): array { $this->assertContains( $comments['pingback'], $found ); } + + /** + * A type requested via a query alias counts as an explicit request, so an + * excluded literal type is still returned when its alias is requested. + * + * @ticket 65537 + * @covers WP_Comment_Query::get_comment_ids + */ + public function test_default_excluded_comment_types_filter_respects_alias_request() { + $comments = $this->create_note_type_test_comments(); + + add_filter( + 'default_excluded_comment_types', + static function ( array $types ): array { + $types[] = 'pingback'; + return $types; + } + ); + + // 'pings' is a query alias for the 'pingback' and 'trackback' types, so + // an explicit request for it must still return pingbacks. + $query = new WP_Comment_Query(); + $found = $query->query( + array( + 'type' => 'pings', + 'fields' => 'ids', + ) + ); + + $this->assertContains( $comments['pingback'], $found ); + + // A default query, which does not request the type, still excludes it. + $query = new WP_Comment_Query(); + $found = $query->query( array( 'fields' => 'ids' ) ); + + $this->assertNotContains( $comments['pingback'], $found ); + } } From 4f69762850b22abd7783f2ac88c7a2f57461aa03 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Fri, 17 Jul 2026 11:48:44 -0700 Subject: [PATCH 11/16] Comments: Keep a comment type named '0' in the excluded types list. The normalization in wp_get_default_excluded_comment_types() ran the filtered list through array_filter() with no callback, which drops the string '0' along with empty strings. A comment type literally named '0' could therefore never be excluded through the filter. Filter on strlen() instead, so only empty strings are removed and '0' survives as a valid literal type. --- src/wp-includes/comment.php | 2 +- tests/phpunit/tests/comment/query.php | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index 8a7eda0e94d28..5fef6734ba549 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -2893,7 +2893,7 @@ function wp_get_default_excluded_comment_types( $query = null ) { */ $excluded_types = apply_filters( 'default_excluded_comment_types', array( 'note' ), $query ); - $excluded_types = array_unique( array_filter( array_map( 'strval', (array) $excluded_types ) ) ); + $excluded_types = array_unique( array_filter( array_map( 'strval', (array) $excluded_types ), 'strlen' ) ); // Strip the special type tokens so an alias cannot poison explicit-type queries. return array_values( array_diff( $excluded_types, array( 'all', 'comment', 'comments', 'pings' ) ) ); diff --git a/tests/phpunit/tests/comment/query.php b/tests/phpunit/tests/comment/query.php index d33bae13537b2..e03cfd06aef3e 100644 --- a/tests/phpunit/tests/comment/query.php +++ b/tests/phpunit/tests/comment/query.php @@ -5846,4 +5846,23 @@ static function ( array $types ): array { $this->assertNotContains( $comments['pingback'], $found ); } + + /** + * A comment type named '0' is preserved by the normalization rather than + * being dropped as an empty value. + * + * @ticket 65537 + * @covers ::wp_get_default_excluded_comment_types + */ + public function test_default_excluded_comment_types_filter_preserves_zero_string_type() { + add_filter( + 'default_excluded_comment_types', + static function ( array $types ): array { + $types[] = '0'; + return $types; + } + ); + + $this->assertContains( '0', wp_get_default_excluded_comment_types() ); + } } From 0ded145cc705a0200085af4f25e209befe1a8ba6 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Fri, 17 Jul 2026 11:48:51 -0700 Subject: [PATCH 12/16] Comments: Drop the unsupported "feeds" claim from the filter docblock. The default_excluded_comment_types docblock said the filter keeps types out of listings, counts, "or feeds". Comment feeds are served by WP_Query's raw SQL, which does not consult this filter, so the promise does not hold. Describe only the surfaces the filter actually covers. --- src/wp-includes/comment.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index 5fef6734ba549..51d4865446da5 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -2860,7 +2860,7 @@ function wp_get_default_excluded_comment_types( $query = null ) { * variables. * * This allows plugins to keep comment types out of standard comment - * listings, counts, or feeds by default, without having to filter every + * listings and counts by default, without having to filter every * query individually. The 'note' comment type, used by the editor, is * excluded by default. The same set is applied when recalculating a * post's stored comment count in wp_update_comment_count_now() and when From c082ed30c5a7f2b02e9da9dee2b10d622e17616e Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Fri, 17 Jul 2026 11:48:58 -0700 Subject: [PATCH 13/16] Comments: Apply the excluded types filter in the comments list table. The list table hardcoded `type__not_in => array( 'note' )` for its row query while the status count bubbles (via wp_count_comments()) now honor default_excluded_comment_types. A plugin that removed 'note' from the filter would therefore see the count include notes while the rows still hid them, so "All (N)" disagreed with the visible list. Source the row query's exclusions from wp_get_default_excluded_comment_types() so the list and its counts stay in agreement. --- src/wp-admin/includes/class-wp-comments-list-table.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-admin/includes/class-wp-comments-list-table.php b/src/wp-admin/includes/class-wp-comments-list-table.php index 2b927a7f81a6a..1a3070907aa2b 100644 --- a/src/wp-admin/includes/class-wp-comments-list-table.php +++ b/src/wp-admin/includes/class-wp-comments-list-table.php @@ -155,7 +155,7 @@ public function prepare_items() { 'number' => $number, 'post_id' => $post_id, 'type' => $comment_type, - 'type__not_in' => array( 'note' ), + 'type__not_in' => wp_get_default_excluded_comment_types(), 'orderby' => $orderby, 'order' => $order, 'post_type' => $post_type, From b3fc3efa65a564e9b4df806bc532ddffdc9e9bd6 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Thu, 23 Jul 2026 17:25:49 -0700 Subject: [PATCH 14/16] Comments: Drop non-scalar values from the excluded comment types filter. Casting the filter output with array_map( 'strval', ... ) trusts every callback to return strings. A callback that returns an object without __toString() raises an Error, and one that returns a nested array emits an "Array to string conversion" warning and silently adds the literal string "Array" to the exclusion list. Filter the raw output through is_scalar() before casting, so unusable values are discarded rather than fataling or poisoning the list. This matches how wp_speculation_rules_href_exclude_paths is normalized in speculative-loading.php and how wp_parse_list() guards its own input. Scalar values keep their existing treatment, including the '0' comment type, so no currently valid filter return changes behavior. --- src/wp-includes/comment.php | 15 +++++++++------ tests/phpunit/tests/comment/query.php | 26 ++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index a718dca247302..1d1d219607b0e 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -2876,10 +2876,10 @@ function wp_update_comment_count( $post_id, $do_deferred = false ) { * Retrieves the comment types that are excluded from queries and counts by default. * * Applies the {@see 'default_excluded_comment_types'} filter and normalizes the - * result: values are cast to strings, empties and duplicates are removed, and - * the special type tokens understood by WP_Comment_Query ('all', 'comment', - * 'comments', 'pings') are stripped - the filter deals in literal - * `comment_type` values only. + * result: non-scalar values are discarded, the rest are cast to strings, empties + * and duplicates are removed, and the special type tokens understood by + * WP_Comment_Query ('all', 'comment', 'comments', 'pings') are stripped - the + * filter deals in literal `comment_type` values only. * * @since 7.1.0 * @@ -2907,7 +2907,7 @@ function wp_get_default_excluded_comment_types( $query = null ) { * * Values must be literal `comment_type` values as stored in the database; * the special type tokens understood by WP_Comment_Query ('all', 'comment', - * 'comments', 'pings') are ignored. + * 'comments', 'pings') are ignored, as are values that are not scalar. * * Register callbacks for this filter unconditionally (for example on * 'plugins_loaded' or 'init') rather than toggling them per call: query @@ -2931,7 +2931,10 @@ function wp_get_default_excluded_comment_types( $query = null ) { */ $excluded_types = apply_filters( 'default_excluded_comment_types', array( 'note' ), $query ); - $excluded_types = array_unique( array_filter( array_map( 'strval', (array) $excluded_types ), 'strlen' ) ); + // Drop values that cannot be cast to a string, so a stray object or array cannot error out. + $excluded_types = array_filter( (array) $excluded_types, 'is_scalar' ); + + $excluded_types = array_unique( array_filter( array_map( 'strval', $excluded_types ), 'strlen' ) ); // Strip the special type tokens so an alias cannot poison explicit-type queries. return array_values( array_diff( $excluded_types, array( 'all', 'comment', 'comments', 'pings' ) ) ); diff --git a/tests/phpunit/tests/comment/query.php b/tests/phpunit/tests/comment/query.php index e03cfd06aef3e..fbd1206e71fe7 100644 --- a/tests/phpunit/tests/comment/query.php +++ b/tests/phpunit/tests/comment/query.php @@ -5865,4 +5865,30 @@ static function ( array $types ): array { $this->assertContains( '0', wp_get_default_excluded_comment_types() ); } + + /** + * Non-scalar values in the filter output are dropped rather than cast, so a + * stray object or array does not error out. + * + * @ticket 65537 + * @covers ::wp_get_default_excluded_comment_types + */ + public function test_default_excluded_comment_types_filter_drops_non_scalar_values() { + add_filter( + 'default_excluded_comment_types', + static function ( array $types ): array { + $types[] = new stdClass(); + $types[] = array( 'nested' ); + $types[] = null; + $types[] = 'private'; + return $types; + } + ); + + $this->assertSame( + array( 'note', 'private' ), + wp_get_default_excluded_comment_types(), + 'Only the scalar comment types should survive normalization.' + ); + } } From e658376ae06050770db5a43744ee45073d580be3 Mon Sep 17 00:00:00 2001 From: Adam Silverstein Date: Thu, 23 Jul 2026 19:57:40 -0700 Subject: [PATCH 15/16] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/wp-includes/comment.php | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index 1d1d219607b0e..d7b15126910bd 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -2893,9 +2893,8 @@ function wp_get_default_excluded_comment_types( $query = null ) { * Filters the comment types that are excluded from query results by default. * * Comment types in this list are omitted from `WP_Comment_Query` results - * unless the query explicitly requests the 'all' type, or requests the - * specific type via the 'type', 'type__in', or 'type__not_in' query - * variables. + * unless the query explicitly requests the 'all' type, or explicitly + * includes the specific type via the 'type' or 'type__in' query variables. * * This allows plugins to keep comment types out of standard comment * listings and counts by default, without having to filter every From e0e1c5e447152785de15cfd5720b35109b0716c2 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Sat, 25 Jul 2026 15:21:30 -0700 Subject: [PATCH 16/16] Comments: Let the list table show an explicitly requested excluded type. The comments list table passed the full default-excluded set as 'type__not_in', which contradicts an explicit 'type' request for one of those types and returns an empty list. A plugin that adds its own private type to the excluded set and surfaces it through 'admin_comment_types_dropdown' could therefore never list it. The requested type is now removed from 'type__not_in' before the query runs, with the 'comment'/'comments'/'pings' aliases expanded the same way WP_Comment_Query expands them. The exclusions are still forced for unrequested types so that a 'type=all' request, which makes WP_Comment_Query skip its own default exclusions, does not surface them in the list table. Also narrows the "non-array filter return" test docblocks: only values that normalize to an empty set disable the exclusions, since the accessor casts a scalar return to an array and treats it as a single excluded type. --- .../includes/class-wp-comments-list-table.php | 30 ++++- .../comment/GetPendingCommentsNum_Test.php | 7 +- .../tests/admin/wpCommentsListTable.php | 104 ++++++++++++++++++ tests/phpunit/tests/comment/query.php | 7 +- .../tests/comment/wpUpdateCommentCountNow.php | 7 +- 5 files changed, 148 insertions(+), 7 deletions(-) diff --git a/src/wp-admin/includes/class-wp-comments-list-table.php b/src/wp-admin/includes/class-wp-comments-list-table.php index 1a3070907aa2b..d622ccf2111a3 100644 --- a/src/wp-admin/includes/class-wp-comments-list-table.php +++ b/src/wp-admin/includes/class-wp-comments-list-table.php @@ -103,12 +103,40 @@ public function prepare_items() { $comment_status = 'all'; } + // The 'note' type is never listed here, so it is dropped from the request. $comment_type = ''; if ( ! empty( $_REQUEST['comment_type'] ) && 'note' !== $_REQUEST['comment_type'] ) { $comment_type = $_REQUEST['comment_type']; } + /* + * WP_Comment_Query drops the default exclusions when 'all' types are + * requested, so they are also passed as 'type__not_in' to keep excluded + * types out of the list table in that case. + * + * The requested type is removed from that list, so a plugin that adds + * its own default-excluded type to the type dropdown via + * 'admin_comment_types_dropdown' can still list it. Type aliases are + * expanded first, matching how WP_Comment_Query resolves them. + */ + switch ( $comment_type ) { + case 'comment': + case 'comments': + $requested_types = array( '', 'comment' ); + break; + + case 'pings': + $requested_types = array( 'pingback', 'trackback' ); + break; + + default: + $requested_types = array( $comment_type ); + break; + } + + $excluded_types = array_values( array_diff( wp_get_default_excluded_comment_types(), $requested_types ) ); + $search = $_REQUEST['s'] ?? ''; $post_type = ( isset( $_REQUEST['post_type'] ) ) ? sanitize_key( $_REQUEST['post_type'] ) : ''; @@ -155,7 +183,7 @@ public function prepare_items() { 'number' => $number, 'post_id' => $post_id, 'type' => $comment_type, - 'type__not_in' => wp_get_default_excluded_comment_types(), + 'type__not_in' => $excluded_types, 'orderby' => $orderby, 'order' => $order, 'post_type' => $post_type, diff --git a/tests/phpunit/tests/admin/includes/comment/GetPendingCommentsNum_Test.php b/tests/phpunit/tests/admin/includes/comment/GetPendingCommentsNum_Test.php index 183c9d019bf37..ef0059967a670 100644 --- a/tests/phpunit/tests/admin/includes/comment/GetPendingCommentsNum_Test.php +++ b/tests/phpunit/tests/admin/includes/comment/GetPendingCommentsNum_Test.php @@ -95,11 +95,14 @@ public function test_emptying_filter_counts_note_type() { } /** - * A filter callback returning a non-array degrades gracefully to no exclusions. + * A filter callback returning false degrades gracefully to no exclusions. + * + * Scalar returns are cast to an array and treated as a single excluded type; + * only values that normalize to an empty set disable the exclusions. * * @ticket 65537 */ - public function test_non_array_filter_return_counts_all_types() { + public function test_false_filter_return_counts_all_types() { $post_id = self::factory()->post->create(); $this->make_pending( $post_id, 'note' ); diff --git a/tests/phpunit/tests/admin/wpCommentsListTable.php b/tests/phpunit/tests/admin/wpCommentsListTable.php index 185bc5bfa48b0..3b9b6d35b482a 100644 --- a/tests/phpunit/tests/admin/wpCommentsListTable.php +++ b/tests/phpunit/tests/admin/wpCommentsListTable.php @@ -275,4 +275,108 @@ public function data_comment_type(): array { 'all type requested' => array( 'all' ), ); } + + /** + * A type added to the default-excluded set is not listed unless it is requested. + * + * The list table forces the default exclusions through 'type__not_in', so an + * excluded type stays hidden even for a 'type=all' request. + * + * @ticket 65537 + * + * @dataProvider data_unrequested_comment_type + * + * @param string $comment_type The comment_type request value to test. + */ + public function test_comments_list_table_hides_filtered_excluded_comment_type( string $comment_type ) { + $post_id = self::factory()->post->create(); + + self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'private', + 'comment_approved' => '1', + ) + ); + + $regular_comment_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => '', + 'comment_approved' => '1', + ) + ); + + add_filter( 'default_excluded_comment_types', array( $this, 'filter_add_private_comment_type' ) ); + + $_REQUEST['comment_type'] = $comment_type; + $this->table->prepare_items(); + + $this->assertSame( + array( $regular_comment_id ), + array_map( 'intval', wp_list_pluck( $this->table->items, 'comment_ID' ) ) + ); + } + + /** + * Data provider for test_comments_list_table_hides_filtered_excluded_comment_type(). + * + * @return array + */ + public function data_unrequested_comment_type(): array { + return array( + 'no type requested' => array( '' ), + 'all type requested' => array( 'all' ), + ); + } + + /** + * A type added to the default-excluded set is listed when explicitly requested. + * + * A plugin can surface its own excluded type through the + * 'admin_comment_types_dropdown' filter, so selecting it has to return results. + * + * @ticket 65537 + */ + public function test_comments_list_table_shows_explicitly_requested_excluded_comment_type() { + $post_id = self::factory()->post->create(); + + $private_comment_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'private', + 'comment_approved' => '1', + ) + ); + + self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => '', + 'comment_approved' => '1', + ) + ); + + add_filter( 'default_excluded_comment_types', array( $this, 'filter_add_private_comment_type' ) ); + + $_REQUEST['comment_type'] = 'private'; + $this->table->prepare_items(); + + $this->assertSame( + array( $private_comment_id ), + array_map( 'intval', wp_list_pluck( $this->table->items, 'comment_ID' ) ) + ); + } + + /** + * Adds the 'private' comment type to the default-excluded set. + * + * @param string[] $excluded_types Comment types excluded by default. + * @return string[] Filtered comment types. + */ + public function filter_add_private_comment_type( $excluded_types ): array { + $excluded_types[] = 'private'; + + return $excluded_types; + } } diff --git a/tests/phpunit/tests/comment/query.php b/tests/phpunit/tests/comment/query.php index fbd1206e71fe7..f591be9480219 100644 --- a/tests/phpunit/tests/comment/query.php +++ b/tests/phpunit/tests/comment/query.php @@ -5756,12 +5756,15 @@ static function ( array $clauses ) use ( &$captured_where ): array { } /** - * A filter callback returning a non-array degrades gracefully to no exclusions. + * A filter callback returning false degrades gracefully to no exclusions. + * + * Scalar returns are cast to an array and treated as a single excluded type; + * only values that normalize to an empty set disable the exclusions. * * @ticket 65537 * @covers WP_Comment_Query::get_comment_ids */ - public function test_default_excluded_comment_types_filter_non_array_return_is_tolerated() { + public function test_default_excluded_comment_types_filter_false_return_is_tolerated() { $comments = $this->create_note_type_test_comments(); add_filter( 'default_excluded_comment_types', '__return_false' ); diff --git a/tests/phpunit/tests/comment/wpUpdateCommentCountNow.php b/tests/phpunit/tests/comment/wpUpdateCommentCountNow.php index cb0405a81e246..2b0d8e5eab4d0 100644 --- a/tests/phpunit/tests/comment/wpUpdateCommentCountNow.php +++ b/tests/phpunit/tests/comment/wpUpdateCommentCountNow.php @@ -153,11 +153,14 @@ public function test_emptying_filter_counts_otherwise_excluded_types() { } /** - * A filter callback returning a non-array degrades gracefully to no exclusions. + * A filter callback returning false degrades gracefully to no exclusions. + * + * Scalar returns are cast to an array and treated as a single excluded type; + * only values that normalize to an empty set disable the exclusions. * * @ticket 65537 */ - public function test_non_array_filter_return_counts_all_types() { + public function test_false_filter_return_counts_all_types() { $post_id = self::factory()->post->create(); self::factory()->comment->create(