From b7cb7fcfc9bc3d6dc089741b1f5bac829ac20dd6 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 24 Jun 2026 14:30:02 -0700 Subject: [PATCH 01/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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( From 50e826e7304354bf418f85f1baf2c84e396e0c59 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 12 Aug 2026 13:24:27 -0700 Subject: [PATCH 17/23] Comments: Include the excluded comment types in the comment query cache key. WP_Comment_Query hashes its query vars and salts the key with the comment `last_changed` value. The default-excluded types are neither: they are resolved from a filter on every cache miss, so the SQL they produce was never reflected in the key. With a persistent object cache, activating a plugin that excludes a type left previously cached ID lists in place, still containing that type, until some unrelated comment write bumped `last_changed` - potentially indefinitely on a quiet site. Deactivating had the mirror problem, hiding types that should have come back. Resolve the excluded set once in get_comments(), fold it into the hashed args, and reuse it in get_comment_ids() so the filter still runs only once per query. Each distinct set now gets its own cache entry, which also makes per-call toggling safe rather than merely discouraged. See #65537. --- src/wp-includes/class-wp-comment-query.php | 30 ++++++- tests/phpunit/tests/comment/query.php | 91 ++++++++++++++++++++++ 2 files changed, 120 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/class-wp-comment-query.php b/src/wp-includes/class-wp-comment-query.php index d99503e86ea78..100b9b393d522 100644 --- a/src/wp-includes/class-wp-comment-query.php +++ b/src/wp-includes/class-wp-comment-query.php @@ -66,6 +66,17 @@ class WP_Comment_Query { */ protected $filtered_where_clause; + /** + * Comment types excluded from the results by default. + * + * Resolved once per query in get_comments(), so that the set folded into the cache + * key is the same one get_comment_ids() builds the SQL from. Null until resolved. + * + * @since 7.1.0 + * @var string[]|null + */ + protected $default_excluded_comment_types = null; + /** * Date query container * @@ -455,6 +466,18 @@ public function get_comments() { $_args = wp_array_slice_assoc( $this->query_vars, array_keys( $this->query_var_defaults ) ); unset( $_args['fields'], $_args['update_comment_meta_cache'], $_args['update_comment_post_cache'] ); + /* + * The default-excluded types are not query vars, but they do change the results, + * so they belong in the key. Without them a persistent object cache would keep + * serving entries built before a plugin changed the excluded set, since changing + * it does not touch the comment 'last_changed' value the key is salted with. + * + * The resolved set is reused in get_comment_ids() so the filter runs once per query. + */ + $this->default_excluded_comment_types = wp_get_default_excluded_comment_types( $this ); + + $_args['default_excluded_comment_types'] = $this->default_excluded_comment_types; + $key = md5( serialize( $_args ) ); $last_changed = wp_cache_get_last_changed( 'comment' ); @@ -780,7 +803,12 @@ protected function get_comment_ids() { 'NOT IN' => (array) $this->query_vars['type__not_in'], ); - $excluded_types = wp_get_default_excluded_comment_types( $this ); + // Resolved in get_comments() when the cache key is built; resolve here for direct calls. + if ( null === $this->default_excluded_comment_types ) { + $this->default_excluded_comment_types = wp_get_default_excluded_comment_types( $this ); + } + + $excluded_types = $this->default_excluded_comment_types; // Unless all types are requested, exclude each default-excluded type // that the query does not explicitly request. The special type tokens diff --git a/tests/phpunit/tests/comment/query.php b/tests/phpunit/tests/comment/query.php index f591be9480219..a39dc61abe6e6 100644 --- a/tests/phpunit/tests/comment/query.php +++ b/tests/phpunit/tests/comment/query.php @@ -5894,4 +5894,95 @@ static function ( array $types ): array { 'Only the scalar comment types should survive normalization.' ); } + + /** + * The excluded set changes the results but is not a query var, so it has to be part + * of the cache key. Otherwise a persistent object cache serves entries built before + * a plugin added or removed a type. + * + * @ticket 65537 + * @covers WP_Comment_Query::get_comments + */ + public function test_default_excluded_comment_types_are_part_of_the_cache_key() { + $this->create_excluded_type_test_comments(); + + $callback = static function ( array $types ): array { + $types[] = 'private'; + return $types; + }; + + // Warm the cache with the type included. + $warm = new WP_Comment_Query(); + $this->assertContains( + 'private', + $this->get_comment_types_for_ids( $warm->query( array( 'fields' => 'ids' ) ) ), + 'The custom type should be returned before it is excluded.' + ); + + add_filter( 'default_excluded_comment_types', $callback ); + + $filtered = new WP_Comment_Query(); + $this->assertNotContains( + 'private', + $this->get_comment_types_for_ids( $filtered->query( array( 'fields' => 'ids' ) ) ), + 'A warm cache should not serve a type that is now excluded.' + ); + + remove_filter( 'default_excluded_comment_types', $callback ); + + $restored = new WP_Comment_Query(); + $this->assertContains( + 'private', + $this->get_comment_types_for_ids( $restored->query( array( 'fields' => 'ids' ) ) ), + 'A type should be returned again once it is no longer excluded.' + ); + } + + /** + * Resolving the excluded set once per query keeps the SQL and the cache key in + * agreement, and keeps the filter from running twice. + * + * @ticket 65537 + * @covers WP_Comment_Query::get_comments + */ + public function test_default_excluded_comment_types_filter_runs_once_per_query() { + $runs = 0; + + add_filter( + 'default_excluded_comment_types', + static function ( array $types ) use ( &$runs ): array { + ++$runs; + return $types; + } + ); + + $query = new WP_Comment_Query(); + $query->query( array( 'fields' => 'ids' ) ); + + $this->assertSame( 1, $runs, 'The filter should run once for a single query.' ); + } + + /** + * The admin count bubbles read wp_count_comments(), which routes through + * WP_Comment_Query and so inherits the exclusions. + * + * @ticket 65537 + * @covers ::wp_count_comments + */ + public function test_wp_count_comments_excludes_filtered_types() { + $this->create_excluded_type_test_comments(); + + add_filter( + 'default_excluded_comment_types', + static function ( array $types ): array { + $types[] = 'private'; + return $types; + } + ); + + $counts = wp_count_comments( self::$post_id ); + + $this->assertSame( 1, (int) $counts->approved, 'Only the regular comment should be counted.' ); + $this->assertSame( 1, (int) $counts->total_comments, 'The excluded types should not inflate the total.' ); + } } From ef244abe8ce6acb8ff0f325875dd95ff3d0bb7a4 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 12 Aug 2026 13:25:23 -0700 Subject: [PATCH 18/23] Comments: Exclude the default-excluded comment types from the comment feeds. The comment feed queries in WP_Query are assembled directly rather than through WP_Comment_Query, and still hard-coded `comment_type != 'note'` in all three of their WHERE clauses. A plugin that used `default_excluded_comment_types` to keep a private type out of comment listings therefore still had that type served in the site-wide, archive, and per-post comment feeds - unauthenticated public output, which defeats the point of excluding it. Route the three clauses through the same filtered set. The excluded types are compiled into a prepared `NOT IN` condition by a new private helper, which the comment counter and the pending-comment count now share too, so the four directly-assembled queries can no longer drift apart. The helper emits nothing when the set is empty. Both feed paths key their caches on the query string, so the new condition is part of the key already. See #65537. --- src/wp-admin/includes/comment.php | 10 +- src/wp-includes/class-wp-query.php | 10 +- src/wp-includes/comment.php | 96 ++++++++----- tests/phpunit/tests/query/commentFeed.php | 163 ++++++++++++++++++++++ 4 files changed, 235 insertions(+), 44 deletions(-) diff --git a/src/wp-admin/includes/comment.php b/src/wp-admin/includes/comment.php index 2dac177afa90b..283050d07305b 100644 --- a/src/wp-admin/includes/comment.php +++ b/src/wp-admin/includes/comment.php @@ -244,15 +244,7 @@ function get_pending_comments_num( $post_id ) { $post_id_array = array_map( 'intval', $post_id_array ); $post_id_in = "'" . implode( "', '", $post_id_array ) . "'"; - $excluded_types = wp_get_default_excluded_comment_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 - ); - } + $type_not_in = _wp_get_excluded_comment_types_clause(); // $post_id_in is built from integers and $type_not_in is prepared above. // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared diff --git a/src/wp-includes/class-wp-query.php b/src/wp-includes/class-wp-query.php index 228691d26d12b..1d9a8f5d13134 100644 --- a/src/wp-includes/class-wp-query.php +++ b/src/wp-includes/class-wp-query.php @@ -2825,13 +2825,15 @@ public function get_posts() { // Comments feeds. if ( $this->is_comment_feed && ! $this->is_singular ) { + $ctype_not_in = _wp_get_excluded_comment_types_clause( "{$wpdb->comments}.comment_type" ); + if ( $this->is_archive || $this->is_search ) { $cjoin = "JOIN {$wpdb->posts} ON ( {$wpdb->comments}.comment_post_ID = {$wpdb->posts}.ID ) $join "; - $cwhere = "WHERE comment_approved = '1' AND {$wpdb->comments}.comment_type != 'note' $where"; + $cwhere = "WHERE comment_approved = '1'$ctype_not_in $where"; $cgroupby = "{$wpdb->comments}.comment_id"; } else { // Other non-singular, e.g. front. $cjoin = "JOIN {$wpdb->posts} ON ( {$wpdb->comments}.comment_post_ID = {$wpdb->posts}.ID )"; - $cwhere = "WHERE ( post_status = 'publish' OR ( post_status = 'inherit' AND post_type = 'attachment' ) ) AND comment_approved = '1' AND {$wpdb->comments}.comment_type != 'note'"; + $cwhere = "WHERE ( post_status = 'publish' OR ( post_status = 'inherit' AND post_type = 'attachment' ) ) AND comment_approved = '1'$ctype_not_in"; $cgroupby = ''; } @@ -3485,11 +3487,13 @@ public function get_posts() { } if ( ! empty( $this->posts ) && $this->is_comment_feed && $this->is_singular ) { + $ctype_not_in = _wp_get_excluded_comment_types_clause( "{$wpdb->comments}.comment_type" ); + /** This filter is documented in wp-includes/class-wp-query.php */ $cjoin = apply_filters_ref_array( 'comment_feed_join', array( '', &$this ) ); /** This filter is documented in wp-includes/class-wp-query.php */ - $cwhere = apply_filters_ref_array( 'comment_feed_where', array( "WHERE comment_post_ID = '{$this->posts[0]->ID}' AND comment_approved = '1' AND {$wpdb->comments}.comment_type != 'note'", &$this ) ); + $cwhere = apply_filters_ref_array( 'comment_feed_where', array( "WHERE comment_post_ID = '{$this->posts[0]->ID}' AND comment_approved = '1'$ctype_not_in", &$this ) ); /** This filter is documented in wp-includes/class-wp-query.php */ $cgroupby = apply_filters_ref_array( 'comment_feed_groupby', array( '', &$this ) ); diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index 0db30e14ed804..259cf8b33d956 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -3105,8 +3105,8 @@ function wp_update_comment_count( $post_id, $do_deferred = false ) { * @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. + * from WP_Comment_Query, or null when the set is + * resolved outside of a comment query. Default null. * @return string[] Comment types excluded by default. */ function wp_get_default_excluded_comment_types( $query = null ) { @@ -3120,20 +3120,28 @@ function wp_get_default_excluded_comment_types( $query = null ) { * This allows plugins to keep comment types out of standard comment * 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 - * counting pending comments, so an excluded type does not inflate - * get_comments_number(). + * excluded by default. The same set is applied when recalculating a post's + * stored comment count in wp_update_comment_count_now(), when counting + * pending comments, and when building the comment feed queries, so an + * excluded type neither inflates get_comments_number() nor appears in + * /comments/feed/. * * 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, 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 - * 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. + * 'plugins_loaded' or 'init') rather than toggling them per call. Query + * results are cached against the filtered set, so toggling does not serve + * stale results, but each distinct set is cached separately. + * + * A plugin that starts excluding a type which already has comments in the + * database should recount the affected posts on activation, and again on + * deactivation: stored `wp_posts.comment_count` values and the 'counts' cache + * group are only refreshed when a comment is created, updated, or deleted, so + * until then get_comments_number() and the admin count bubbles keep reporting + * the pre-filter totals. Call wp_update_comment_count_now() for each post that + * has a comment of the type. * * This exclusion is a default-visibility convenience, not an access-control * mechanism: callers can still retrieve excluded types explicitly (for @@ -3145,9 +3153,10 @@ function wp_get_default_excluded_comment_types( $query = null ) { * * @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). + * @param WP_Comment_Query|null $query The WP_Comment_Query instance, or null when the set is + * resolved outside of a comment query (recalculating a + * post's stored comment count, counting pending comments, + * building a comment feed query). */ $excluded_types = apply_filters( 'default_excluded_comment_types', array( 'note' ), $query ); @@ -3160,6 +3169,41 @@ function wp_get_default_excluded_comment_types( $query = null ) { return array_values( array_diff( $excluded_types, array( 'all', 'comment', 'comments', 'pings' ) ) ); } +/** + * Builds the SQL condition that excludes the default-excluded comment types. + * + * For the comment queries that are assembled directly rather than through + * WP_Comment_Query - the comment feeds and the comment counters - so that they all + * honor the same {@see 'default_excluded_comment_types'} filter output. + * + * @since 7.1.0 + * @access private + * + * @global wpdb $wpdb WordPress database abstraction object. + * + * @param string $column Optional. The `comment_type` column to filter on, qualified with a + * table name where the query joins other tables. Must not contain + * user input. Default 'comment_type'. + * @return string Prepared ` AND NOT IN (...)` condition, or an empty string when + * no comment types are excluded. + */ +function _wp_get_excluded_comment_types_clause( $column = 'comment_type' ) { + global $wpdb; + + $excluded_types = wp_get_default_excluded_comment_types(); + + if ( ! $excluded_types ) { + return ''; + } + + $placeholders = implode( ', ', array_fill( 0, count( $excluded_types ), '%s' ) ); + $clause = sprintf( ' AND %s NOT IN ( %s )', $column, $placeholders ); + + // The column name comes from core call sites; the type values are prepared here. + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + return $wpdb->prepare( $clause, $excluded_types ); +} + /** * Updates the comment count for the post. * @@ -3204,26 +3248,14 @@ 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 ) ) { - $excluded_types = wp_get_default_excluded_comment_types(); + $type_not_in = _wp_get_excluded_comment_types_clause(); - 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 - ) - ); - } + $new = (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM $wpdb->comments WHERE comment_post_ID = %d AND comment_approved = '1'", + $post_id + ) . $type_not_in + ); } else { $new = (int) $new; } diff --git a/tests/phpunit/tests/query/commentFeed.php b/tests/phpunit/tests/query/commentFeed.php index d26bd3829c06a..7aa2a14af52e1 100644 --- a/tests/phpunit/tests/query/commentFeed.php +++ b/tests/phpunit/tests/query/commentFeed.php @@ -176,6 +176,169 @@ public function test_single_comment_feed_should_exclude_notes(): void { $this->assertSame( 5, $q->comment_count, 'Singular comments feed should include all regular comments.' ); } + /** + * Adds a custom comment type to the default-excluded set. + * + * @param string[] $excluded_types Comment types excluded by default. + * @return string[] Filtered comment types. + */ + public function filter_exclude_private_comment_type( $excluded_types ) { + $excluded_types[] = 'private'; + + return $excluded_types; + } + + /** + * @ticket 65537 + */ + public function test_main_comment_feed_should_exclude_filtered_types(): void { + $private_id = self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_ids[0], + 'comment_type' => 'private', + 'comment_approved' => '1', + ) + ); + + $args = array( + 'withcomments' => 1, + 'feed' => 'comments-rss', + ); + + $unfiltered = new WP_Query(); + $unfiltered->query( $args ); + + $this->assertContains( + $private_id, + array_map( 'intval', wp_list_pluck( $unfiltered->comments, 'comment_ID' ) ), + 'An unfiltered custom comment type should appear in the comments feed.' + ); + + add_filter( 'default_excluded_comment_types', array( $this, 'filter_exclude_private_comment_type' ) ); + + $q = new WP_Query(); + $q->query( $args ); + + $this->assertTrue( $q->is_comment_feed() ); + $this->assertFalse( $q->is_singular() ); + + $comment_ids = array_map( 'intval', wp_list_pluck( $q->comments, 'comment_ID' ) ); + $this->assertNotContains( $private_id, $comment_ids, 'Comments feed should not include excluded types.' ); + $this->assertSame( 15, $q->comment_count, 'Comments feed should include all regular comments.' ); + } + + /** + * @ticket 65537 + */ + public function test_archive_comment_feed_should_exclude_filtered_types(): void { + $private_id = self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_ids[0], + 'comment_type' => 'private', + 'comment_approved' => '1', + ) + ); + + add_filter( 'default_excluded_comment_types', array( $this, 'filter_exclude_private_comment_type' ) ); + + $q = new WP_Query(); + $q->query( + array( + 'withcomments' => 1, + 'feed' => 'comments-rss', + 'year' => (int) get_the_date( 'Y', self::$post_ids[0] ), + ) + ); + + $this->assertTrue( $q->is_comment_feed() ); + $this->assertTrue( $q->is_archive() ); + + $comment_ids = array_map( 'intval', wp_list_pluck( $q->comments, 'comment_ID' ) ); + $this->assertNotContains( $private_id, $comment_ids, 'Archive comments feed should not include excluded types.' ); + $this->assertSame( 15, $q->comment_count, 'Archive comments feed should include all regular comments.' ); + } + + /** + * @ticket 65537 + */ + public function test_single_comment_feed_should_exclude_filtered_types(): void { + $post = get_post( self::$post_ids[0] ); + $this->assertInstanceOf( WP_Post::class, $post ); + + $private_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $post->ID, + 'comment_type' => 'private', + 'comment_approved' => '1', + ) + ); + + add_filter( 'default_excluded_comment_types', array( $this, 'filter_exclude_private_comment_type' ) ); + + $q = new WP_Query(); + $q->query( + array( + 'withcomments' => 1, + 'feed' => 'comments-rss', + 'post_type' => $post->post_type, + 'name' => $post->post_name, + ) + ); + + $this->assertTrue( $q->is_comment_feed() ); + $this->assertTrue( $q->is_singular() ); + + $comment_ids = array_map( 'intval', wp_list_pluck( $q->comments, 'comment_ID' ) ); + $this->assertNotContains( $private_id, $comment_ids, 'Singular comments feed should not include excluded types.' ); + $this->assertSame( 5, $q->comment_count, 'Singular comments feed should include all regular comments.' ); + } + + /** + * The feed queries are cached against the SQL they build, so an excluded type + * must not survive in a cached result after the filter changes. + * + * @ticket 65537 + */ + public function test_comment_feed_cache_reflects_a_changed_excluded_set(): void { + $private_id = self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_ids[0], + 'comment_type' => 'private', + 'comment_approved' => '1', + ) + ); + + $args = array( + 'withcomments' => 1, + 'feed' => 'comments-rss', + ); + + $warm = new WP_Query(); + $warm->query( $args ); + + add_filter( 'default_excluded_comment_types', array( $this, 'filter_exclude_private_comment_type' ) ); + + $filtered = new WP_Query(); + $filtered->query( $args ); + + $this->assertNotContains( + $private_id, + array_map( 'intval', wp_list_pluck( $filtered->comments, 'comment_ID' ) ), + 'A warm feed cache should not serve a type that is now excluded.' + ); + + remove_filter( 'default_excluded_comment_types', array( $this, 'filter_exclude_private_comment_type' ) ); + + $restored = new WP_Query(); + $restored->query( $args ); + + $this->assertContains( + $private_id, + array_map( 'intval', wp_list_pluck( $restored->comments, 'comment_ID' ) ), + 'A type should reappear in the feed once it is no longer excluded.' + ); + } + /** * @ticket 36904 */ From f1bc386705a8c2297806a32044c0d8516fe7f2d9 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 12 Aug 2026 13:26:37 -0700 Subject: [PATCH 19/23] Comments: Default the excluded comment types to the registered internal types. Keeping a comment type out of the default listings took two separate steps once the registry landed: registering the type with `'internal' => true`, then adding the same name through `default_excluded_comment_types`. Two mechanisms for one intent invites plugins to do only one of them - skipping registration leaves the type without labels, REST discovery, or rendering, while skipping the filter leaves it visible despite being declared internal. Seed the default set from `get_comment_types( array( 'internal' => true ) )`, so declaring a type internal is enough and the filter is left for exceptions. The registry is not a hard dependency: the lookup is guarded by function_exists() so this can land before register_comment_type() does, and 'note' stays in the set unconditionally so the default also holds before types are registered on 'init'. The accompanying test skips until the registry is available. See #65537, #35214. --- src/wp-includes/comment.php | 37 +++++++++++++++++++------ tests/phpunit/tests/comment/query.php | 39 +++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 8 deletions(-) diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index 259cf8b33d956..f000a20a0805b 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -3096,11 +3096,12 @@ 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: 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. + * The default set is the 'note' type plus every comment type registered with + * `'internal' => true`. The {@see 'default_excluded_comment_types'} filter is then + * applied and the result normalized: 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 * @@ -3110,6 +3111,24 @@ function wp_update_comment_count( $post_id, $do_deferred = false ) { * @return string[] Comment types excluded by default. */ function wp_get_default_excluded_comment_types( $query = null ) { + $default_excluded_types = array( 'note' ); + + /* + * Comment types registered as internal are excluded by default. The 'note' type is + * listed above as well so that the default holds before comment types are registered + * on 'init', and on installs running without the registry. + */ + if ( function_exists( 'get_comment_types' ) ) { + $default_excluded_types = array_values( + array_unique( + array_merge( + $default_excluded_types, + get_comment_types( array( 'internal' => true ), 'names' ) + ) + ) + ); + } + /** * Filters the comment types that are excluded from query results by default. * @@ -3120,7 +3139,8 @@ function wp_get_default_excluded_comment_types( $query = null ) { * This allows plugins to keep comment types out of standard comment * 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 + * excluded by default, as is every comment type registered with + * `'internal' => true`. The same set is applied when recalculating a post's * stored comment count in wp_update_comment_count_now(), when counting * pending comments, and when building the comment feed queries, so an * excluded type neither inflates get_comments_number() nor appears in @@ -3152,13 +3172,14 @@ function wp_get_default_excluded_comment_types( $query = null ) { * @since 7.1.0 * * @param string[] $excluded_types Comment types excluded from query results by default. - * Default array contains the 'note' type. + * Defaults to the 'note' type and every comment type + * registered as internal. * @param WP_Comment_Query|null $query The WP_Comment_Query instance, or null when the set is * resolved outside of a comment query (recalculating a * post's stored comment count, counting pending comments, * building a comment feed query). */ - $excluded_types = apply_filters( 'default_excluded_comment_types', array( 'note' ), $query ); + $excluded_types = apply_filters( 'default_excluded_comment_types', $default_excluded_types, $query ); // 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' ); diff --git a/tests/phpunit/tests/comment/query.php b/tests/phpunit/tests/comment/query.php index a39dc61abe6e6..c54dc2c441acd 100644 --- a/tests/phpunit/tests/comment/query.php +++ b/tests/phpunit/tests/comment/query.php @@ -5895,6 +5895,45 @@ static function ( array $types ): array { ); } + /** + * A comment type registered as internal is excluded by default, so a plugin does + * not have to both register the type and add it through the filter. + * + * Skipped until the register_comment_type() API lands, at which point + * wp_get_default_excluded_comment_types() stops falling back to 'note' alone. + * + * @ticket 65537 + * @ticket 35214 + * @covers ::wp_get_default_excluded_comment_types + */ + public function test_internal_comment_types_are_excluded_by_default() { + if ( ! function_exists( 'register_comment_type' ) ) { + $this->markTestSkipped( 'Requires the comment type registry.' ); + } + + register_comment_type( + 'wp_tests_internal', + array( + 'label' => 'Internal', + 'public' => false, + 'internal' => true, + ) + ); + + $excluded_types = wp_get_default_excluded_comment_types(); + + $this->assertContains( + 'wp_tests_internal', + $excluded_types, + 'A comment type registered as internal should be excluded by default.' + ); + $this->assertContains( + 'note', + $excluded_types, + 'The note type should stay excluded alongside the registered internal types.' + ); + } + /** * The excluded set changes the results but is not a query var, so it has to be part * of the cache key. Otherwise a persistent object cache serves entries built before From 5aec0199de43f64674c6c1eb86b23af584a81df9 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 12 Aug 2026 13:29:33 -0700 Subject: [PATCH 20/23] Comments: Pin the visibility boundaries of the excluded comment types. Two places sit next to the new exclusion set without being driven by it, and neither said so. The comments list table drops a request for the note type independently of the filter, so emptying the excluded set surfaces notes in the untyped views while still refusing to filter the table to them; that asymmetry is deliberate, since notes carry their own visibility rules, but the old comment claimed notes are simply never listed. Over REST, excluding a type changes only the default collection: the type stays readable by ID and is still returned to a caller with edit_posts who names it, which is the documented "visibility, not access control" contract. Say both out loud in the code and pin them with tests, so a later change to either surface has to be a decision rather than an accident. Also note that the list table's comment/comments alias branch cannot subtract anything, since the accessor strips those tokens from the excluded set. See #65537. --- .../includes/class-wp-comments-list-table.php | 10 ++- .../tests/admin/wpCommentsListTable.php | 47 +++++++++++ .../rest-api/rest-comments-controller.php | 77 +++++++++++++++++++ 3 files changed, 133 insertions(+), 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 d622ccf2111a3..8dc3fd0eb0f98 100644 --- a/src/wp-admin/includes/class-wp-comments-list-table.php +++ b/src/wp-admin/includes/class-wp-comments-list-table.php @@ -103,7 +103,13 @@ public function prepare_items() { $comment_status = 'all'; } - // The 'note' type is never listed here, so it is dropped from the request. + /* + * Notes carry their own visibility rules, so they are never listed here and a + * request for them is dropped outright. This is deliberately narrower than the + * treatment of the other default-excluded types below, which can be listed when + * explicitly requested. Emptying the excluded set does surface notes in the + * untyped views, but not as a type the table can be filtered to. + */ $comment_type = ''; if ( ! empty( $_REQUEST['comment_type'] ) && 'note' !== $_REQUEST['comment_type'] ) { @@ -121,6 +127,8 @@ public function prepare_items() { * expanded first, matching how WP_Comment_Query resolves them. */ switch ( $comment_type ) { + // Kept for symmetry with WP_Comment_Query; the accessor strips these tokens + // from the excluded set, so this branch can never subtract anything. case 'comment': case 'comments': $requested_types = array( '', 'comment' ); diff --git a/tests/phpunit/tests/admin/wpCommentsListTable.php b/tests/phpunit/tests/admin/wpCommentsListTable.php index 3b9b6d35b482a..b77c59c974e53 100644 --- a/tests/phpunit/tests/admin/wpCommentsListTable.php +++ b/tests/phpunit/tests/admin/wpCommentsListTable.php @@ -368,6 +368,53 @@ public function test_comments_list_table_shows_explicitly_requested_excluded_com ); } + /** + * Emptying the excluded set surfaces notes in the untyped views, but the table + * still cannot be filtered to notes: the request for that type is dropped + * separately from the exclusions, because notes have their own visibility rules. + * + * @ticket 65537 + */ + public function test_comments_list_table_note_request_is_dropped_with_an_empty_excluded_set() { + $post_id = self::factory()->post->create(); + + $note_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'note', + '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', '__return_empty_array' ); + + $_REQUEST['comment_type'] = ''; + $this->table->prepare_items(); + + $this->assertSameSets( + array( $note_id, $regular_comment_id ), + array_map( 'intval', wp_list_pluck( $this->table->items, 'comment_ID' ) ), + 'An empty excluded set should surface notes in the untyped view.' + ); + + $_REQUEST['comment_type'] = 'note'; + $this->table->prepare_items(); + + $this->assertSameSets( + array( $note_id, $regular_comment_id ), + array_map( 'intval', wp_list_pluck( $this->table->items, 'comment_ID' ) ), + 'A request for the note type should be dropped rather than filter the table.' + ); + } + /** * Adds the 'private' comment type to the default-excluded set. * diff --git a/tests/phpunit/tests/rest-api/rest-comments-controller.php b/tests/phpunit/tests/rest-api/rest-comments-controller.php index 7162b278839d5..f9a4713aab8f2 100644 --- a/tests/phpunit/tests/rest-api/rest-comments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-comments-controller.php @@ -4234,6 +4234,83 @@ public function test_get_items_type_arg_unauthenticated( $comment_type, $count ) } } + /** + * The `default_excluded_comment_types` filter sets a visibility default, not an + * access-control boundary. An excluded type stays out of the default listing, but + * it is still readable by ID and still returned to a caller authorized to ask for + * it by name. + * + * @ticket 65537 + */ + public function test_excluded_comment_type_visibility_is_not_access_control() { + $comment_id = self::factory()->comment->create( + array( + 'comment_approved' => 1, + 'comment_post_ID' => self::$post_id, + 'comment_type' => 'private', + ) + ); + + add_filter( + 'default_excluded_comment_types', + static function ( array $types ): array { + $types[] = 'private'; + return $types; + } + ); + + wp_logout(); + + // The excluded type is kept out of the default collection. + $request = new WP_REST_Request( 'GET', '/wp/v2/comments' ); + $request->set_param( 'per_page', self::$per_page ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status(), 'The default collection should be readable.' ); + $this->assertNotContains( + $comment_id, + wp_list_pluck( $response->get_data(), 'id' ), + 'An excluded type should not appear in the default collection.' + ); + + // Unauthenticated callers cannot enumerate it by asking for the type. + $request = new WP_REST_Request( 'GET', '/wp/v2/comments' ); + $request->set_param( 'type', 'private' ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( + 'rest_forbidden_param', + $response, + 401, + 'Requesting a type without edit_posts should be forbidden.' + ); + + // An approved comment of the type is still readable by ID. + $request = new WP_REST_Request( 'GET', sprintf( '/wp/v2/comments/%d', $comment_id ) ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( + 200, + $response->get_status(), + 'Excluding a type does not restrict reading an approved comment of that type.' + ); + + // An authorized caller asking for the type explicitly still receives it. + wp_set_current_user( self::$admin_id ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/comments' ); + $request->set_param( 'type', 'private' ); + $request->set_param( 'per_page', self::$per_page ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status(), 'An authorized request for the type should succeed.' ); + $this->assertContains( + $comment_id, + wp_list_pluck( $response->get_data(), 'id' ), + 'An explicitly requested excluded type should still be returned.' + ); + } + /** * Data provider for comment type tests. * From 05d37393165fac42751ac4800d3bcc9fa7169600 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 12 Aug 2026 13:55:00 -0700 Subject: [PATCH 21/23] Comments: Filter empty excluded comment types with a boolean callback. array_filter() with 'strlen' was doing the right thing at runtime - dropping '' while keeping a type literally named '0' - but strlen() returns an int, not a bool, so PHPStan reports the callback as the wrong type and the static analysis job fails. Say what the filter means instead. See #65537. --- src/wp-includes/comment.php | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index f000a20a0805b..e5faf109e87c6 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -3184,7 +3184,18 @@ function wp_get_default_excluded_comment_types( $query = null ) { // 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' ) ); + /* + * Drop empty strings, but keep a type literally named '0', which array_filter() + * without a callback would treat as empty. + */ + $excluded_types = array_filter( + array_map( 'strval', $excluded_types ), + static function ( $excluded_type ) { + return '' !== $excluded_type; + } + ); + + $excluded_types = array_unique( $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' ) ) ); From eee634de6b59c262787a5c1b66bd83d5976f0908 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 12 Aug 2026 16:34:49 -0700 Subject: [PATCH 22/23] Comments: Apply the default comment type exclusions to comment page math. get_page_of_comment() counted older comments with 'type' => 'all', which is the one value that makes WP_Comment_Query skip its default exclusions, while comments_template() renders the same post with those exclusions applied. Every excluded-type comment older than the target therefore inflated the count, so a '#comment-' permalink could point at a page the visitor never sees. Two notes ahead of a comment on a two-per-page post were enough to send its permalink to page 2 while the comment renders on page 1. Default the argument to '' instead, which is WP_Comment_Query's own default and so counts exactly what the comment list shows. Callers wanting the old behavior can still pass 'all' explicitly. get_comment_link() forwards its arguments here and carried the same default, so it moves too, otherwise the fix would never reach the permalinks that need it. This predates the exclusion filter - it has been reachable since 6.9 gave core the note type - but the filter generalizes it to any private type a plugin registers. See #65537. --- src/wp-includes/comment-template.php | 3 +- src/wp-includes/comment.php | 18 ++- .../tests/comment/getPageOfComment.php | 122 ++++++++++++++++++ 3 files changed, 139 insertions(+), 4 deletions(-) diff --git a/src/wp-includes/comment-template.php b/src/wp-includes/comment-template.php index 43bd68ff972a4..5784c61b75fa3 100644 --- a/src/wp-includes/comment-template.php +++ b/src/wp-includes/comment-template.php @@ -755,6 +755,7 @@ function comment_ID() { // phpcs:ignore WordPress.NamingConventions.ValidFunctio * * @since 1.5.0 * @since 4.4.0 Added the ability for `$comment` to also accept a WP_Comment object. Added `$cpage` argument. + * @since 7.1.0 The default 'type' changed from 'all' to '', matching get_page_of_comment(). * * @see get_page_of_comment() * @@ -786,7 +787,7 @@ function get_comment_link( $comment = null, $args = array() ) { } $defaults = array( - 'type' => 'all', + 'type' => '', 'page' => '', 'per_page' => '', 'max_depth' => '', diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index e5faf109e87c6..cef3413cd8629 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -1086,6 +1086,8 @@ function get_comment_pages_count( $comments = null, $per_page = null, $threaded * Calculates what page number a comment will appear on for comment paging. * * @since 2.7.0 + * @since 7.1.0 The default 'type' changed from 'all' to '', so that the page math + * applies the same default exclusions as the rendered comment list. * * @global wpdb $wpdb WordPress database abstraction object. * @@ -1095,7 +1097,10 @@ function get_comment_pages_count( $comments = null, $per_page = null, $threaded * * @type string $type Limit paginated comments to those matching a given type. * Accepts 'comment', 'trackback', 'pingback', 'pings' - * (trackbacks and pingbacks), or 'all'. Default 'all'. + * (trackbacks and pingbacks), or 'all'. An empty string + * counts every type except those excluded by default, which + * is what comments_template() renders. Pass 'all' to count + * the excluded types as well. Default empty string. * @type int $per_page Per-page count to use when calculating pagination. * Defaults to the value of the 'comments_per_page' option. * @type int|string $max_depth If greater than 1, comment page will be determined @@ -1114,8 +1119,13 @@ function get_page_of_comment( $comment_id, $args = array() ) { return null; } + /* + * The default type is empty rather than 'all' so the comments counted here are the + * ones comments_template() actually renders. Counting the default-excluded types + * would put a comment's permalink on a page the visitor never sees. + */ $defaults = array( - 'type' => 'all', + 'type' => '', 'page' => '', 'per_page' => '', 'max_depth' => '', @@ -1195,7 +1205,9 @@ function get_page_of_comment( $comment_id, $args = array() ) { * * @type string $type Limit paginated comments to those matching a given type. * Accepts 'comment', 'trackback', 'pingback', 'pings' - * (trackbacks and pingbacks), or 'all'. Default 'all'. + * (trackbacks and pingbacks), or 'all'. An empty string + * counts every type except those excluded by default. + * Default empty string. * @type int $post_id ID of the post. * @type string $fields Comment fields to return. * @type bool $count Whether to return a comment count (true) or array diff --git a/tests/phpunit/tests/comment/getPageOfComment.php b/tests/phpunit/tests/comment/getPageOfComment.php index 44e6af5ac3f87..e7aaa82011ca8 100644 --- a/tests/phpunit/tests/comment/getPageOfComment.php +++ b/tests/phpunit/tests/comment/getPageOfComment.php @@ -543,4 +543,126 @@ public function test_page_number_when_unapproved_comments_are_included_for_curre wp_set_current_user( $current_user ); } + + /** + * The page math has to count the comments the rendered list actually shows. Counting + * the default-excluded types too would send a comment permalink to a page the visitor + * never sees. + * + * @ticket 65537 + */ + public function test_default_excluded_types_are_not_counted() { + $post_id = self::factory()->post->create(); + + // Two notes, then two regular comments, oldest first. + foreach ( array( 'note', 'note', '', '' ) as $index => $comment_type ) { + $comment_ids[] = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => $comment_type, + 'comment_approved' => '1', + 'comment_date' => sprintf( '2024-01-01 10:0%d:00', $index ), + 'comment_date_gmt' => sprintf( '2024-01-01 10:0%d:00', $index ), + ) + ); + } + + $last_comment = end( $comment_ids ); + + $this->assertSame( + 1, + get_page_of_comment( $last_comment, array( 'per_page' => 2 ) ), + 'Notes should not push a regular comment onto a later page.' + ); + $this->assertSame( + 2, + get_page_of_comment( + $last_comment, + array( + 'per_page' => 2, + 'type' => 'all', + ) + ), + 'An explicit type of all should still count every type.' + ); + } + + /** + * A type added through the filter is excluded from the page math the same way. + * + * @ticket 65537 + */ + public function test_filtered_excluded_types_are_not_counted() { + $post_id = self::factory()->post->create(); + + foreach ( array( 'private', 'private', '', '' ) as $index => $comment_type ) { + $comment_ids[] = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => $comment_type, + 'comment_approved' => '1', + 'comment_date' => sprintf( '2024-01-01 10:0%d:00', $index ), + 'comment_date_gmt' => sprintf( '2024-01-01 10:0%d:00', $index ), + ) + ); + } + + $last_comment = end( $comment_ids ); + + $this->assertSame( + 2, + get_page_of_comment( $last_comment, array( 'per_page' => 2 ) ), + 'An unfiltered custom type counts toward the page math.' + ); + + add_filter( + 'default_excluded_comment_types', + static function ( array $types ): array { + $types[] = 'private'; + return $types; + } + ); + + $this->assertSame( + 1, + get_page_of_comment( $last_comment, array( 'per_page' => 2 ) ), + 'Once excluded, the custom type should drop out of the page math.' + ); + } + + /** + * get_comment_link() forwards its own arguments to get_page_of_comment(), so its + * default has to match or the permalink lands on the wrong page. + * + * @ticket 65537 + * + * @covers ::get_comment_link + */ + public function test_get_comment_link_does_not_count_excluded_types() { + update_option( 'page_comments', 1 ); + update_option( 'comments_per_page', 2 ); + update_option( 'default_comments_page', 'oldest' ); + + $post_id = self::factory()->post->create(); + + foreach ( array( 'note', 'note', '', '' ) as $index => $comment_type ) { + $comment_ids[] = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => $comment_type, + 'comment_approved' => '1', + 'comment_date' => sprintf( '2024-01-01 10:0%d:00', $index ), + 'comment_date_gmt' => sprintf( '2024-01-01 10:0%d:00', $index ), + ) + ); + } + + $link = get_comment_link( end( $comment_ids ) ); + + $this->assertStringNotContainsString( + 'cpage=2', + $link, + 'The permalink should point at the page the comment is rendered on.' + ); + } } From 00576f09636ff6ce6e33bb6d3a4eef1e3f5e7217 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Sun, 23 Aug 2026 08:53:47 -0700 Subject: [PATCH 23/23] Comments: Stamp the excluded-types filter work for 7.2.0. The branch was written while trunk was 7.1-alpha; trunk is now 7.2-alpha, so the eleven @since tags this branch adds pointed at a release that will never contain them. Docblock-only change. See #65537. --- src/wp-admin/includes/comment.php | 2 +- src/wp-includes/class-wp-comment-query.php | 4 ++-- src/wp-includes/comment-template.php | 2 +- src/wp-includes/comment.php | 10 +++++----- tests/phpunit/tests/comment/query.php | 4 ++-- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/wp-admin/includes/comment.php b/src/wp-admin/includes/comment.php index 283050d07305b..a65b5011e6c8c 100644 --- a/src/wp-admin/includes/comment.php +++ b/src/wp-admin/includes/comment.php @@ -223,7 +223,7 @@ 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 + * @since 7.2.0 The excluded comment types are derived from the * {@see 'default_excluded_comment_types'} filter. * * @global wpdb $wpdb WordPress database abstraction object. diff --git a/src/wp-includes/class-wp-comment-query.php b/src/wp-includes/class-wp-comment-query.php index 100b9b393d522..8e12dea53e7e7 100644 --- a/src/wp-includes/class-wp-comment-query.php +++ b/src/wp-includes/class-wp-comment-query.php @@ -72,7 +72,7 @@ class WP_Comment_Query { * Resolved once per query in get_comments(), so that the set folded into the cache * key is the same one get_comment_ids() builds the SQL from. Null until resolved. * - * @since 7.1.0 + * @since 7.2.0 * @var string[]|null */ protected $default_excluded_comment_types = null; @@ -567,7 +567,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 7.1.0 The default-excluded comment types are filterable via {@see 'default_excluded_comment_types'}. + * @since 7.2.0 The default-excluded comment types are filterable via {@see 'default_excluded_comment_types'}. * * @global wpdb $wpdb WordPress database abstraction object. * diff --git a/src/wp-includes/comment-template.php b/src/wp-includes/comment-template.php index 5784c61b75fa3..6eed5822421c9 100644 --- a/src/wp-includes/comment-template.php +++ b/src/wp-includes/comment-template.php @@ -755,7 +755,7 @@ function comment_ID() { // phpcs:ignore WordPress.NamingConventions.ValidFunctio * * @since 1.5.0 * @since 4.4.0 Added the ability for `$comment` to also accept a WP_Comment object. Added `$cpage` argument. - * @since 7.1.0 The default 'type' changed from 'all' to '', matching get_page_of_comment(). + * @since 7.2.0 The default 'type' changed from 'all' to '', matching get_page_of_comment(). * * @see get_page_of_comment() * diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index cef3413cd8629..a12edd55549b5 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -1086,7 +1086,7 @@ function get_comment_pages_count( $comments = null, $per_page = null, $threaded * Calculates what page number a comment will appear on for comment paging. * * @since 2.7.0 - * @since 7.1.0 The default 'type' changed from 'all' to '', so that the page math + * @since 7.2.0 The default 'type' changed from 'all' to '', so that the page math * applies the same default exclusions as the rendered comment list. * * @global wpdb $wpdb WordPress database abstraction object. @@ -3115,7 +3115,7 @@ function wp_update_comment_count( $post_id, $do_deferred = false ) { * understood by WP_Comment_Query ('all', 'comment', 'comments', 'pings') are * stripped - the filter deals in literal `comment_type` values only. * - * @since 7.1.0 + * @since 7.2.0 * * @param WP_Comment_Query|null $query Optional. The current query instance when called * from WP_Comment_Query, or null when the set is @@ -3181,7 +3181,7 @@ function wp_get_default_excluded_comment_types( $query = null ) { * comment data private. Enforce capability checks wherever the data is * displayed or exposed (for example over REST). * - * @since 7.1.0 + * @since 7.2.0 * * @param string[] $excluded_types Comment types excluded from query results by default. * Defaults to the 'note' type and every comment type @@ -3220,7 +3220,7 @@ static function ( $excluded_type ) { * WP_Comment_Query - the comment feeds and the comment counters - so that they all * honor the same {@see 'default_excluded_comment_types'} filter output. * - * @since 7.1.0 + * @since 7.2.0 * @access private * * @global wpdb $wpdb WordPress database abstraction object. @@ -3252,7 +3252,7 @@ function _wp_get_excluded_comment_types_clause( $column = 'comment_type' ) { * Updates the comment count for the post. * * @since 2.5.0 - * @since 7.1.0 The excluded comment types are derived from the + * @since 7.2.0 The excluded comment types are derived from the * {@see 'default_excluded_comment_types'} filter. * * @global wpdb $wpdb WordPress database abstraction object. diff --git a/tests/phpunit/tests/comment/query.php b/tests/phpunit/tests/comment/query.php index c54dc2c441acd..6f178c968d06e 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 7.1.0 + * @since 7.2.0 * * @return array<'comment'|'note'|'private', int> Array of created comment IDs keyed by type. */ @@ -5665,7 +5665,7 @@ static function ( array $types ): array { /** * Data provider for explicit-request tests against a filtered excluded type. * - * @since 7.1.0 + * @since 7.2.0 * * @return array, expected_types: string[] }> */