From b7cb7fcfc9bc3d6dc089741b1f5bac829ac20dd6 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 24 Jun 2026 14:30:02 -0700 Subject: [PATCH 01/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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[] }> */ From 280fcd6fd97c052ae676953fd6919e2b6b6aeee8 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 26 Aug 2026 17:44:46 -0700 Subject: [PATCH 24/33] Comments: Use the block format for multi-line inline comments. The WordPress inline documentation standards call for the /* */ block format once a comment runs past a single line. --- .../includes/class-wp-comments-list-table.php | 6 ++++-- src/wp-includes/class-wp-comment-query.php | 14 ++++++++------ tests/phpunit/tests/comment/query.php | 6 ++++-- 3 files changed, 16 insertions(+), 10 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 8dc3fd0eb0f98..6e1a2aeb8d1eb 100644 --- a/src/wp-admin/includes/class-wp-comments-list-table.php +++ b/src/wp-admin/includes/class-wp-comments-list-table.php @@ -127,8 +127,10 @@ 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. + /* + * 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/src/wp-includes/class-wp-comment-query.php b/src/wp-includes/class-wp-comment-query.php index 8e12dea53e7e7..f84acfe930d00 100644 --- a/src/wp-includes/class-wp-comment-query.php +++ b/src/wp-includes/class-wp-comment-query.php @@ -810,12 +810,14 @@ protected function get_comment_ids() { $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 - // 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. + /* + * Unless all types are requested, exclude each default-excluded type + * 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 ) { diff --git a/tests/phpunit/tests/comment/query.php b/tests/phpunit/tests/comment/query.php index 6f178c968d06e..28b16b555d537 100644 --- a/tests/phpunit/tests/comment/query.php +++ b/tests/phpunit/tests/comment/query.php @@ -5831,8 +5831,10 @@ static function ( array $types ): array { } ); - // 'pings' is a query alias for the 'pingback' and 'trackback' types, so - // an explicit request for it must still return pingbacks. + /* + * '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( From 8c5fea7da4f9406d54b61c514f83420de1ecadb3 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 26 Aug 2026 17:49:42 -0700 Subject: [PATCH 25/33] Comments: Ignore a non-string comment_type request in the list table. The requested type is subtracted from the default-excluded set with array_diff(), which casts its arguments to strings. A request such as comment_type[]=review raised an "Array to string conversion" warning and could skip the exclusion it named. --- .../includes/class-wp-comments-list-table.php | 7 +++- .../tests/admin/wpCommentsListTable.php | 41 +++++++++++++++++++ 2 files changed, 47 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 6e1a2aeb8d1eb..2b078147cf8a8 100644 --- a/src/wp-admin/includes/class-wp-comments-list-table.php +++ b/src/wp-admin/includes/class-wp-comments-list-table.php @@ -112,7 +112,12 @@ public function prepare_items() { */ $comment_type = ''; - if ( ! empty( $_REQUEST['comment_type'] ) && 'note' !== $_REQUEST['comment_type'] ) { + /* + * Only a string is honoured. A request that supplies an array, for example + * comment_type[]=review, is treated as no type filter at all so that the + * value can safely be compared and diffed as a string below. + */ + if ( ! empty( $_REQUEST['comment_type'] ) && is_string( $_REQUEST['comment_type'] ) && 'note' !== $_REQUEST['comment_type'] ) { $comment_type = $_REQUEST['comment_type']; } diff --git a/tests/phpunit/tests/admin/wpCommentsListTable.php b/tests/phpunit/tests/admin/wpCommentsListTable.php index b77c59c974e53..259f939dfb2c4 100644 --- a/tests/phpunit/tests/admin/wpCommentsListTable.php +++ b/tests/phpunit/tests/admin/wpCommentsListTable.php @@ -415,6 +415,47 @@ public function test_comments_list_table_note_request_is_dropped_with_an_empty_e ); } + /** + * A non-string 'comment_type' request is ignored rather than raising a warning. + * + * The requested type is subtracted from the default-excluded set with + * array_diff(), which casts its arguments to strings. An array request such as + * comment_type[]=private would otherwise raise an "Array to string conversion" + * warning and could skip the exclusion it names. + * + * @ticket 65537 + */ + public function test_comments_list_table_ignores_a_non_string_comment_type_request() { + $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'] = array( 'private' ); + $this->table->prepare_items(); + + $this->assertSame( + array( $regular_comment_id ), + array_map( 'intval', wp_list_pluck( $this->table->items, 'comment_ID' ) ), + 'An array request should be treated as no type filter and keep the excluded type hidden.' + ); + } + /** * Adds the 'private' comment type to the default-excluded set. * From 94dc58f11a4f392d0ff9b5637e4af34d33deaa47 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Sat, 7 Nov 2026 20:12:50 -0500 Subject: [PATCH 26/33] Comments: Quote the excluded types column with %i and drop the underscore. The column passed to the clause helper is an identifier, so prepare it with %i rather than interpolating it. A table-qualified name is split on the dot and each part quoted on its own, since `wp_comments.comment_type` as one quoted identifier would name a column that does not exist. Core is moving away from underscore-prefixed private functions, as they tend to become public later, so rename the helper to wp_get_excluded_comment_types_clause(). See #65537. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018vwYjUikEZMVw2seRAHMXe --- src/wp-admin/includes/comment.php | 2 +- src/wp-includes/class-wp-query.php | 4 ++-- src/wp-includes/comment.php | 18 +++++++++--------- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/wp-admin/includes/comment.php b/src/wp-admin/includes/comment.php index a65b5011e6c8c..98bd5cd7ef633 100644 --- a/src/wp-admin/includes/comment.php +++ b/src/wp-admin/includes/comment.php @@ -244,7 +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 ) . "'"; - $type_not_in = _wp_get_excluded_comment_types_clause(); + $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 5e513f46935c2..0696f2bd36907 100644 --- a/src/wp-includes/class-wp-query.php +++ b/src/wp-includes/class-wp-query.php @@ -2801,7 +2801,7 @@ 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" ); + $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 "; @@ -3522,7 +3522,7 @@ 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" ); + $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 ) ); diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index 8bbe3cd4b3af4..6b41fcaa660fd 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -3245,17 +3245,16 @@ static function ( $excluded_type ) { * honor the same {@see 'default_excluded_comment_types'} filter output. * * @since 7.2.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'. + * table name where the query joins other tables, for example + * 'wp_comments.comment_type'. 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' ) { +function wp_get_excluded_comment_types_clause( string $column = 'comment_type' ): string { global $wpdb; $excluded_types = wp_get_default_excluded_comment_types(); @@ -3264,12 +3263,13 @@ function _wp_get_excluded_comment_types_clause( $column = 'comment_type' ) { return ''; } + // Each part of a qualified name is its own identifier, so `table`.`column` quotes correctly. + $column_parts = explode( '.', $column ); + $column_sql = implode( '.', array_fill( 0, count( $column_parts ), '%i' ) ); $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 ); + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- Both hold placeholders only. + return (string) $wpdb->prepare( " AND $column_sql NOT IN ( $placeholders )", array_merge( $column_parts, $excluded_types ) ); } /** @@ -3316,7 +3316,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 ) ) { - $type_not_in = _wp_get_excluded_comment_types_clause(); + $type_not_in = wp_get_excluded_comment_types_clause(); $new = (int) $wpdb->get_var( $wpdb->prepare( From d4c6e2f0ff864cc0665a2e7d2eca8c85f610aa30 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Sat, 7 Nov 2026 20:12:50 -0500 Subject: [PATCH 27/33] Comments: Keep 'all' as the comment page math default. Changing the get_page_of_comment() and get_comment_link() defaults from 'all' to '' made the two values count differently, so a theme that copied the old default into its own call would keep counting excluded types. Walker_Comment does exactly that: it forwards the wp_list_comments() arguments, whose 'type' is 'all', so the comment permalinks rendered in the list still pointed at the wrong page. Restore the 'all' default and leave the default-excluded types out of the count when 'all' is requested instead. comments_template() never renders those types, so counting them only ever moved a permalink to a page the visitor does not see. A type requested by name is still counted. See #65537. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018vwYjUikEZMVw2seRAHMXe --- src/wp-includes/comment-template.php | 3 +- src/wp-includes/comment.php | 33 +++++----- .../tests/comment/getPageOfComment.php | 63 ++++++++++++++++--- 3 files changed, 75 insertions(+), 24 deletions(-) diff --git a/src/wp-includes/comment-template.php b/src/wp-includes/comment-template.php index 6480b5b79ab18..cc2608d4220e1 100644 --- a/src/wp-includes/comment-template.php +++ b/src/wp-includes/comment-template.php @@ -756,7 +756,6 @@ 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.2.0 The default 'type' changed from 'all' to '', matching get_page_of_comment(). * * @see get_page_of_comment() * @@ -788,7 +787,7 @@ function get_comment_link( $comment = null, $args = array() ) { } $defaults = array( - 'type' => '', + 'type' => 'all', 'page' => '', 'per_page' => '', 'max_depth' => '', diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index 6b41fcaa660fd..a2edd7bc35b63 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -1101,8 +1101,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.2.0 The default 'type' changed from 'all' to '', so that the page math - * applies the same default exclusions as the rendered comment list. + * @since 7.2.0 Comment types excluded by default are no longer counted when 'type' is 'all'. * * @global wpdb $wpdb WordPress database abstraction object. * @@ -1112,10 +1111,9 @@ 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'. 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. + * (trackbacks and pingbacks), or 'all'. 'all' leaves out the + * comment types excluded by default, as the comment list does + * not render them. Default 'all'. * @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 @@ -1134,13 +1132,8 @@ 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' => '', + 'type' => 'all', 'page' => '', 'per_page' => '', 'max_depth' => '', @@ -1198,6 +1191,15 @@ function get_page_of_comment( $comment_id, $args = array() ) { ), ); + /* + * WP_Comment_Query stops excluding the default-excluded types when 'all' is + * requested, but comments_template() never renders them. Counting them would put + * a comment's permalink on a page the visitor never sees. + */ + if ( in_array( 'all', (array) $args['type'], true ) ) { + $comment_args['type__not_in'] = wp_get_default_excluded_comment_types(); + } + if ( is_user_logged_in() ) { $comment_args['include_unapproved'] = array( get_current_user_id() ); } else { @@ -1212,6 +1214,7 @@ function get_page_of_comment( $comment_id, $args = array() ) { * Filters the arguments used to query comments in get_page_of_comment(). * * @since 5.5.0 + * @since 7.2.0 Added the `$type__not_in` argument. * * @see WP_Comment_Query::__construct() * @@ -1220,9 +1223,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'. An empty string - * counts every type except those excluded by default. - * Default empty string. + * (trackbacks and pingbacks), or 'all'. Default 'all'. + * @type string[] $type__not_in Comment types excluded by default. Only set when + * `$type` is 'all'. * @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 e7aaa82011ca8..faf7d76c7baae 100644 --- a/tests/phpunit/tests/comment/getPageOfComment.php +++ b/tests/phpunit/tests/comment/getPageOfComment.php @@ -575,7 +575,7 @@ public function test_default_excluded_types_are_not_counted() { 'Notes should not push a regular comment onto a later page.' ); $this->assertSame( - 2, + 1, get_page_of_comment( $last_comment, array( @@ -583,7 +583,50 @@ public function test_default_excluded_types_are_not_counted() { 'type' => 'all', ) ), - 'An explicit type of all should still count every type.' + 'An explicit type of all should match the default.' + ); + $this->assertSame( + 1, + get_page_of_comment( + $last_comment, + array( + 'per_page' => 2, + 'type' => '', + ) + ), + 'An empty type should match the default.' + ); + } + + /** + * A default-excluded type is still counted when it is requested by name. + * + * @ticket 65537 + */ + public function test_explicitly_requested_excluded_type_is_counted(): void { + $post_id = self::factory()->post->create(); + + foreach ( array( 'note', '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 ), + ) + ); + } + + $this->assertSame( + 2, + get_page_of_comment( + end( $comment_ids ), + array( + 'per_page' => 2, + 'type' => 'note', + ) + ) ); } @@ -631,8 +674,9 @@ static function ( array $types ): array { } /** - * 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. + * get_comment_link() forwards its arguments to get_page_of_comment(). Walker_Comment + * passes the wp_list_comments() arguments, which set 'type' to 'all', so both the + * default and an explicit 'all' have to land on the rendered page. * * @ticket 65537 * @@ -657,12 +701,17 @@ public function test_get_comment_link_does_not_count_excluded_types() { ); } - $link = get_comment_link( end( $comment_ids ) ); + $last_comment = end( $comment_ids ); $this->assertStringNotContainsString( 'cpage=2', - $link, - 'The permalink should point at the page the comment is rendered on.' + get_comment_link( $last_comment ), + 'The default permalink should point at the page the comment is rendered on.' + ); + $this->assertStringNotContainsString( + 'cpage=2', + get_comment_link( $last_comment, array( 'type' => 'all' ) ), + 'A permalink with an explicit type of all should point at the same page.' ); } } From 14dc7d65e4d71b35a0185a0286984d2d7e119950 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Sat, 7 Nov 2026 20:12:50 -0500 Subject: [PATCH 28/33] Tests: Tidy the excluded comment types tests per review. Use MockAction for the filter spies, assertSameSets() where order does not matter, and a message on every assertion in tests with more than one. Drop the remove_filter() calls the base test class already handles, and cover the quoting of the excluded types clause. See #65537. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018vwYjUikEZMVw2seRAHMXe --- .../comment/GetPendingCommentsNum_Test.php | 35 ++++---- .../tests/admin/wpCommentsListTable.php | 6 +- tests/phpunit/tests/comment/query.php | 80 ++++++++++++------- .../tests/comment/wpUpdateCommentCountNow.php | 41 +++++----- tests/phpunit/tests/query/commentFeed.php | 14 ++-- 5 files changed, 94 insertions(+), 82 deletions(-) diff --git a/tests/phpunit/tests/admin/includes/comment/GetPendingCommentsNum_Test.php b/tests/phpunit/tests/admin/includes/comment/GetPendingCommentsNum_Test.php index ef0059967a670..425687033ec7f 100644 --- a/tests/phpunit/tests/admin/includes/comment/GetPendingCommentsNum_Test.php +++ b/tests/phpunit/tests/admin/includes/comment/GetPendingCommentsNum_Test.php @@ -62,18 +62,17 @@ public function test_excludes_a_filtered_type() { $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 ); + $this->assertSame( 2, get_pending_comments_num( $post_id ), 'A custom type should be counted by default.' ); + + add_filter( + 'default_excluded_comment_types', + static function ( $types ) { + $types[] = 'review'; + return $types; + } + ); + + $this->assertSame( 1, get_pending_comments_num( $post_id ), 'A filtered type should not be counted.' ); } /** @@ -85,13 +84,11 @@ 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 ) ); + $this->assertSame( 0, get_pending_comments_num( $post_id ), 'A note should not be counted by default.' ); 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 ); + $this->assertSame( 1, get_pending_comments_num( $post_id ), 'A note should be counted once nothing is excluded.' ); } /** @@ -107,10 +104,8 @@ public function test_false_filter_return_counts_all_types() { $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 ); + $this->assertSame( 1, get_pending_comments_num( $post_id ) ); } /** @@ -126,7 +121,7 @@ public function test_array_input_returns_counts_keyed_by_post() { $counts = get_pending_comments_num( array( $post_a, $post_b ) ); - $this->assertSame( + $this->assertSameSetsWithIndex( array( $post_a => 1, $post_b => 2, diff --git a/tests/phpunit/tests/admin/wpCommentsListTable.php b/tests/phpunit/tests/admin/wpCommentsListTable.php index b9bae1f395c4e..de48d81a62332 100644 --- a/tests/phpunit/tests/admin/wpCommentsListTable.php +++ b/tests/phpunit/tests/admin/wpCommentsListTable.php @@ -312,7 +312,7 @@ public function test_comments_list_table_hides_filtered_excluded_comment_type( s $_REQUEST['comment_type'] = $comment_type; $this->table->prepare_items(); - $this->assertSame( + $this->assertSameSets( array( $regular_comment_id ), array_map( 'intval', wp_list_pluck( $this->table->items, 'comment_ID' ) ) ); @@ -362,7 +362,7 @@ public function test_comments_list_table_shows_explicitly_requested_excluded_com $_REQUEST['comment_type'] = 'private'; $this->table->prepare_items(); - $this->assertSame( + $this->assertSameSets( array( $private_comment_id ), array_map( 'intval', wp_list_pluck( $this->table->items, 'comment_ID' ) ) ); @@ -449,7 +449,7 @@ public function test_comments_list_table_ignores_a_non_string_comment_type_reque $_REQUEST['comment_type'] = array( 'private' ); $this->table->prepare_items(); - $this->assertSame( + $this->assertSameSets( array( $regular_comment_id ), array_map( 'intval', wp_list_pluck( $this->table->items, 'comment_ID' ) ), 'An array request should be treated as no type filter and keep the excluded type hidden.' diff --git a/tests/phpunit/tests/comment/query.php b/tests/phpunit/tests/comment/query.php index 5fa66356c5cc5..a5db233d1b6dc 100644 --- a/tests/phpunit/tests/comment/query.php +++ b/tests/phpunit/tests/comment/query.php @@ -5768,23 +5768,16 @@ public function data_default_excluded_comment_types_explicit_request(): array { * @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 - ); + $filter = new MockAction(); + add_filter( 'default_excluded_comment_types', array( $filter, 'filter' ), 10, 2 ); $query = new WP_Comment_Query(); $query->query( array( 'fields' => 'ids' ) ); + $filter_args = $filter->get_args()[0]; + $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.' ); + $this->assertSame( $query, $filter_args[1], 'The filter should receive the query instance.' ); } /** @@ -5875,13 +5868,16 @@ static function ( array $types ): array { ) ); - $this->assertSame( array( $comments['pingback'] ), array_map( 'intval', $found ) ); + $this->assertSameSets( + array( $comments['pingback'] ), + array_map( 'intval', $found ), + 'An explicit request for pingbacks should still find them.' + ); - // 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 ); + $this->assertContains( $comments['pingback'], $found, 'The alias should exclude nothing from a default query.' ); } /** @@ -5914,13 +5910,12 @@ static function ( array $types ): array { ) ); - $this->assertContains( $comments['pingback'], $found ); + $this->assertContains( $comments['pingback'], $found, 'Requesting the pings alias should return pingbacks.' ); - // 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 ); + $this->assertNotContains( $comments['pingback'], $found, 'A default query should still exclude pingbacks.' ); } /** @@ -5961,13 +5956,47 @@ static function ( array $types ): array { } ); - $this->assertSame( + $this->assertSameSets( array( 'note', 'private' ), wp_get_default_excluded_comment_types(), 'Only the scalar comment types should survive normalization.' ); } + /** + * A table-qualified column is quoted one identifier per part, so the clause stays + * valid when the query joins other tables. + * + * @ticket 65537 + * @covers ::wp_get_excluded_comment_types_clause + */ + public function test_excluded_comment_types_clause_quotes_a_qualified_column(): void { + global $wpdb; + + $this->assertSame( + " AND `comment_type` NOT IN ( 'note' )", + wp_get_excluded_comment_types_clause(), + 'The default column should be quoted as an identifier.' + ); + $this->assertSame( + " AND `{$wpdb->comments}`.`comment_type` NOT IN ( 'note' )", + wp_get_excluded_comment_types_clause( "{$wpdb->comments}.comment_type" ), + 'Each part of a qualified column should be quoted separately.' + ); + } + + /** + * Nothing is added to the query when no comment types are excluded. + * + * @ticket 65537 + * @covers ::wp_get_excluded_comment_types_clause + */ + public function test_excluded_comment_types_clause_is_empty_without_excluded_types(): void { + add_filter( 'default_excluded_comment_types', '__return_empty_array' ); + + $this->assertSame( '', wp_get_excluded_comment_types_clause() ); + } + /** * 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. @@ -6058,20 +6087,13 @@ public function test_default_excluded_comment_types_are_part_of_the_cache_key() * @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; - } - ); + $filter = new MockAction(); + add_filter( 'default_excluded_comment_types', array( $filter, 'filter' ) ); $query = new WP_Comment_Query(); $query->query( array( 'fields' => 'ids' ) ); - $this->assertSame( 1, $runs, 'The filter should run once for a single query.' ); + $this->assertSame( 1, $filter->get_call_count(), 'The filter should run once for a single query.' ); } /** diff --git a/tests/phpunit/tests/comment/wpUpdateCommentCountNow.php b/tests/phpunit/tests/comment/wpUpdateCommentCountNow.php index 2b0d8e5eab4d0..b5bab3cb275d2 100644 --- a/tests/phpunit/tests/comment/wpUpdateCommentCountNow.php +++ b/tests/phpunit/tests/comment/wpUpdateCommentCountNow.php @@ -105,20 +105,19 @@ public function test_filtered_excluded_type_does_not_inflate_count() { ) ); - // 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->assertTrue( wp_update_comment_count_now( $post_id ), 'The unfiltered count should be updated.' ); + $this->assertSame( '2', get_comments_number( $post_id ), 'Both approved comments should be counted without the filter.' ); + + add_filter( + 'default_excluded_comment_types', + static function ( $types ) { + $types[] = 'review'; + return $types; + } + ); - $this->assertSame( '1', get_comments_number( $post_id ) ); + $this->assertTrue( wp_update_comment_count_now( $post_id ), 'The filtered count should be updated.' ); + $this->assertSame( '1', get_comments_number( $post_id ), 'The filtered type should drop out of the count.' ); } /** @@ -140,16 +139,13 @@ public function test_emptying_filter_counts_otherwise_excluded_types() { ) ); - // By default the 'note' type is excluded. - $this->assertTrue( wp_update_comment_count_now( $post_id ) ); - $this->assertSame( '0', get_comments_number( $post_id ) ); + $this->assertTrue( wp_update_comment_count_now( $post_id ), 'The default count should be updated.' ); + $this->assertSame( '0', get_comments_number( $post_id ), 'A note should not be counted by default.' ); - // 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 ) ); + $this->assertTrue( wp_update_comment_count_now( $post_id ), 'The unfiltered count should be updated.' ); + $this->assertSame( '1', get_comments_number( $post_id ), 'A note should be counted once nothing is excluded.' ); } /** @@ -172,10 +168,9 @@ public function test_false_filter_return_counts_all_types() { ); 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 ) ); + $this->assertTrue( wp_update_comment_count_now( $post_id ), 'The count should be updated.' ); + $this->assertSame( '1', get_comments_number( $post_id ), 'A note should be counted once nothing is excluded.' ); } public function _return_100() { diff --git a/tests/phpunit/tests/query/commentFeed.php b/tests/phpunit/tests/query/commentFeed.php index 7aa2a14af52e1..48f380f02d74a 100644 --- a/tests/phpunit/tests/query/commentFeed.php +++ b/tests/phpunit/tests/query/commentFeed.php @@ -219,8 +219,8 @@ public function test_main_comment_feed_should_exclude_filtered_types(): void { $q = new WP_Query(); $q->query( $args ); - $this->assertTrue( $q->is_comment_feed() ); - $this->assertFalse( $q->is_singular() ); + $this->assertTrue( $q->is_comment_feed(), 'The query should be a comments feed.' ); + $this->assertFalse( $q->is_singular(), 'The query should not be 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.' ); @@ -250,8 +250,8 @@ public function test_archive_comment_feed_should_exclude_filtered_types(): void ) ); - $this->assertTrue( $q->is_comment_feed() ); - $this->assertTrue( $q->is_archive() ); + $this->assertTrue( $q->is_comment_feed(), 'The query should be a comments feed.' ); + $this->assertTrue( $q->is_archive(), 'The query should be an 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.' ); @@ -263,7 +263,7 @@ public function test_archive_comment_feed_should_exclude_filtered_types(): void */ public function test_single_comment_feed_should_exclude_filtered_types(): void { $post = get_post( self::$post_ids[0] ); - $this->assertInstanceOf( WP_Post::class, $post ); + $this->assertInstanceOf( WP_Post::class, $post, 'The fixture post should exist.' ); $private_id = self::factory()->comment->create( array( @@ -285,8 +285,8 @@ public function test_single_comment_feed_should_exclude_filtered_types(): void { ) ); - $this->assertTrue( $q->is_comment_feed() ); - $this->assertTrue( $q->is_singular() ); + $this->assertTrue( $q->is_comment_feed(), 'The query should be a comments feed.' ); + $this->assertTrue( $q->is_singular(), 'The query should be 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.' ); From f05722538517a95f96464540f722619a17ba9598 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Sat, 7 Nov 2026 20:18:31 -0500 Subject: [PATCH 29/33] Tests: Update the excluded comment type tests for trunk's reaction type. Trunk renamed the shared test comment helper and now excludes reactions by default alongside notes. --- tests/phpunit/tests/comment/query.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/phpunit/tests/comment/query.php b/tests/phpunit/tests/comment/query.php index 4388c62d231ef..88352ae4e0e80 100644 --- a/tests/phpunit/tests/comment/query.php +++ b/tests/phpunit/tests/comment/query.php @@ -5878,7 +5878,7 @@ static function ( array $clauses ) use ( &$captured_where ): array { * @covers WP_Comment_Query::get_comment_ids */ public function test_default_excluded_comment_types_filter_false_return_is_tolerated() { - $comments = $this->create_note_type_test_comments(); + $comments = $this->create_internal_comment_type_test_comments(); add_filter( 'default_excluded_comment_types', '__return_false' ); @@ -5897,7 +5897,7 @@ public function test_default_excluded_comment_types_filter_false_return_is_toler * @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(); + $comments = $this->create_internal_comment_type_test_comments(); add_filter( 'default_excluded_comment_types', @@ -5934,7 +5934,7 @@ static function ( array $types ): array { * @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(); + $comments = $this->create_internal_comment_type_test_comments(); add_filter( 'default_excluded_comment_types', @@ -6004,7 +6004,7 @@ static function ( array $types ): array { ); $this->assertSame( - array( 'note', 'private' ), + array( 'note', 'reaction', 'private' ), wp_get_default_excluded_comment_types(), 'Only the scalar comment types should survive normalization.' ); From d13a6136ddee0aee2a65507eb9c3e39b88fb560c Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Sun, 8 Nov 2026 09:28:36 -0500 Subject: [PATCH 30/33] Comments: Apply the excluded comment types to get_lastcommentmodified(). Trunk's last-modified query hard-codes the 'note' and 'reaction' exclusion, so a type added through the 'default_excluded_comment_types' filter still moved the comment feed's Last-Modified date, and emptying the filter did not bring notes back. Route it through wp_get_excluded_comment_types_clause() like the feeds and counters. The cached value keeps its fixed 'lastcommentmodified:' key, since the comment status handlers clear it by that name. A changed excluded set is therefore picked up the next time a comment changes status. See #65537, #35214. --- src/wp-includes/comment.php | 12 +++- .../tests/comment/getLastCommentModified.php | 68 +++++++++++++++++++ 2 files changed, 77 insertions(+), 3 deletions(-) diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index 8370bb5d4332c..8cde3aba9977f 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -402,6 +402,8 @@ function get_default_comment_status( $post_type = 'post', $comment_type = 'comme * @since 4.7.0 Replaced caching the modified date in a local static variable * with the Object Cache API. * @since 7.2.0 The 'note' and 'reaction' comment types are excluded from the query. + * @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. * @@ -419,19 +421,23 @@ function get_lastcommentmodified( $timezone = 'server' ) { return $comment_modified_date; } + $type_not_in = wp_get_excluded_comment_types_clause(); + + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $type_not_in is prepared. switch ( $timezone ) { case 'gmt': - $comment_modified_date = $wpdb->get_var( "SELECT comment_date_gmt FROM $wpdb->comments WHERE comment_approved = '1' AND comment_type NOT IN ( 'note', 'reaction' ) ORDER BY comment_date_gmt DESC LIMIT 1" ); + $comment_modified_date = $wpdb->get_var( "SELECT comment_date_gmt FROM $wpdb->comments WHERE comment_approved = '1'$type_not_in ORDER BY comment_date_gmt DESC LIMIT 1" ); break; case 'blog': - $comment_modified_date = $wpdb->get_var( "SELECT comment_date FROM $wpdb->comments WHERE comment_approved = '1' AND comment_type NOT IN ( 'note', 'reaction' ) ORDER BY comment_date_gmt DESC LIMIT 1" ); + $comment_modified_date = $wpdb->get_var( "SELECT comment_date FROM $wpdb->comments WHERE comment_approved = '1'$type_not_in ORDER BY comment_date_gmt DESC LIMIT 1" ); break; case 'server': $add_seconds_server = gmdate( 'Z' ); - $comment_modified_date = $wpdb->get_var( $wpdb->prepare( "SELECT DATE_ADD(comment_date_gmt, INTERVAL %s SECOND) FROM $wpdb->comments WHERE comment_approved = '1' AND comment_type NOT IN ( 'note', 'reaction' ) ORDER BY comment_date_gmt DESC LIMIT 1", $add_seconds_server ) ); + $comment_modified_date = $wpdb->get_var( $wpdb->prepare( "SELECT DATE_ADD(comment_date_gmt, INTERVAL %s SECOND) FROM $wpdb->comments WHERE comment_approved = '1'", $add_seconds_server ) . "$type_not_in ORDER BY comment_date_gmt DESC LIMIT 1" ); break; } + // phpcs:enable if ( $comment_modified_date ) { wp_cache_set( $key, $comment_modified_date, 'timeinfo' ); diff --git a/tests/phpunit/tests/comment/getLastCommentModified.php b/tests/phpunit/tests/comment/getLastCommentModified.php index 4f61f626ffb7a..512d1b058d95c 100644 --- a/tests/phpunit/tests/comment/getLastCommentModified.php +++ b/tests/phpunit/tests/comment/getLastCommentModified.php @@ -212,4 +212,72 @@ public function test_only_internal_comment_types_returns_false() { $this->assertFalse( get_lastcommentmodified() ); } + + /** + * Tests that a comment type added via the 'default_excluded_comment_types' filter + * does not move the last comment modified date. + * + * @ticket 65537 + * + * @dataProvider data_internal_comment_types_are_excluded + * + * @param string $timezone Timezone argument to pass to get_lastcommentmodified(). + * @param string $expected Expected date. + */ + public function test_filtered_excluded_comment_type_is_excluded( string $timezone, string $expected ): void { + add_filter( + 'default_excluded_comment_types', + static function ( array $types ): array { + $types[] = 'review'; + return $types; + } + ); + + self::factory()->comment->create( + array( + 'comment_date' => '2000-01-01 11:00:00', + 'comment_date_gmt' => '2000-01-01 10:00:00', + ) + ); + self::factory()->comment->create( + array( + 'comment_type' => 'review', + 'comment_date' => '2020-01-01 11:00:00', + 'comment_date_gmt' => '2020-01-01 10:00:00', + ) + ); + + $modified = get_lastcommentmodified( $timezone ); + + $this->assertIsString( $modified ); + $this->assertSame( strtotime( $expected ), strtotime( $modified ) ); + } + + /** + * Tests that emptying the excluded comment types lets notes move the date. + * + * @ticket 65537 + */ + public function test_empty_excluded_comment_types_includes_notes(): void { + add_filter( 'default_excluded_comment_types', '__return_empty_array' ); + + self::factory()->comment->create( + array( + 'comment_date' => '2000-01-01 11:00:00', + 'comment_date_gmt' => '2000-01-01 10:00:00', + ) + ); + self::factory()->comment->create( + array( + 'comment_type' => 'note', + 'comment_date' => '2020-01-01 11:00:00', + 'comment_date_gmt' => '2020-01-01 10:00:00', + ) + ); + + $modified = get_lastcommentmodified( 'gmt' ); + + $this->assertIsString( $modified ); + $this->assertSame( strtotime( '2020-01-01 10:00:00' ), strtotime( $modified ) ); + } } From c9ad985e02a8988360d68366a4ed25f9d5b03f79 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Sun, 8 Nov 2026 09:28:41 -0500 Subject: [PATCH 31/33] Comments: Tighten the excluded comment types documentation. The docblocks and inline comments around the excluded comment types had grown into design notes, some of them stale: the accessor and filter docs still described 'note' as the only built-in default and did not mention 'reaction' or get_lastcommentmodified(). Trim them to what a caller needs and bring them in line with trunk. Also drop the list table's 'comment'/'comments' alias branch, which could never subtract anything because the accessor strips those tokens, and filter the registry's internal type names to strings so the merged default set is typed for array_unique(). See #65537, #35214. --- .../includes/class-wp-comments-list-table.php | 32 +--- src/wp-admin/includes/comment.php | 3 +- src/wp-includes/class-wp-comment-query.php | 21 +-- src/wp-includes/comment.php | 137 ++++++------------ 4 files changed, 56 insertions(+), 137 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 928feee14d1cb..d948ef05c35b9 100644 --- a/src/wp-admin/includes/class-wp-comments-list-table.php +++ b/src/wp-admin/includes/class-wp-comments-list-table.php @@ -106,43 +106,21 @@ public function prepare_items() { } /* - * Notes and reactions 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. + * Notes and reactions cannot be requested as a type here, unlike other + * default-excluded types. A non-string request is ignored. */ $comment_type = ''; - /* - * Only a string is honoured. A request that supplies an array, for example - * comment_type[]=review, is treated as no type filter at all so that the - * value can safely be compared and diffed as a string below. - */ if ( ! empty( $_REQUEST['comment_type'] ) && is_string( $_REQUEST['comment_type'] ) && ! in_array( $_REQUEST['comment_type'], array( 'note', 'reaction' ), true ) ) { $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. + * The default exclusions are passed explicitly so they still apply to an 'all' + * request. The requested type is kept, so a plugin can list its own excluded + * type via the 'admin_comment_types_dropdown' filter. */ 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' ); - break; - case 'pings': $requested_types = array( 'pingback', 'trackback' ); break; diff --git a/src/wp-admin/includes/comment.php b/src/wp-admin/includes/comment.php index d52f073a87c1d..08017d067f703 100644 --- a/src/wp-admin/includes/comment.php +++ b/src/wp-admin/includes/comment.php @@ -247,8 +247,7 @@ function get_pending_comments_num( $post_id ) { $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 + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $post_id_in holds integers and $type_not_in is prepared. $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 ) { diff --git a/src/wp-includes/class-wp-comment-query.php b/src/wp-includes/class-wp-comment-query.php index 81418f7bf3b75..e73e3a9552423 100644 --- a/src/wp-includes/class-wp-comment-query.php +++ b/src/wp-includes/class-wp-comment-query.php @@ -67,10 +67,7 @@ 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. + * Comment types excluded from the results by default, or null until resolved. * * @since 7.2.0 * @var string[]|null @@ -472,12 +469,8 @@ public function get_comments() { 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. + * The excluded set changes the results without bumping 'last_changed', so it is + * part of the key. get_comment_ids() reuses it. */ $this->default_excluded_comment_types = wp_get_default_excluded_comment_types( $this ); @@ -813,12 +806,8 @@ protected function get_comment_ids() { $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 - * 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. + * Unless 'all' is requested, exclude each default-excluded type the query does not + * request. Aliases are expanded so a type requested via 'pings' stays included. */ if ( ! in_array( 'all', $raw_types['IN'], true ) ) { $requested_types = array(); diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index 8cde3aba9977f..4ad0eafe5882b 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -1119,8 +1119,7 @@ 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'. 'all' leaves out the - * comment types excluded by default, as the comment list does - * not render them. Default 'all'. + * comment types excluded by default. Default 'all'. * @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 @@ -1198,11 +1197,7 @@ function get_page_of_comment( $comment_id, $args = array() ) { ), ); - /* - * WP_Comment_Query stops excluding the default-excluded types when 'all' is - * requested, but comments_template() never renders them. Counting them would put - * a comment's permalink on a page the visitor never sees. - */ + // 'all' disables the default exclusions, but comments_template() never renders those types. if ( in_array( 'all', (array) $args['type'], true ) ) { $comment_args['type__not_in'] = wp_get_default_excluded_comment_types(); } @@ -1228,20 +1223,20 @@ function get_page_of_comment( $comment_id, $args = array() ) { * @param array $comment_args { * Array of WP_Comment_Query arguments. * - * @type string $type Limit paginated comments to those matching a given type. - * Accepts 'comment', 'trackback', 'pingback', 'pings' - * (trackbacks and pingbacks), or 'all'. Default 'all'. - * @type string[] $type__not_in Comment types excluded by default. Only set when - * `$type` is 'all'. - * @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 - * of comment objects (false). - * @type string $status Comment status. - * @type int $parent Parent ID of comment to retrieve children of. - * @type array $date_query Date query clauses to limit comments by. See WP_Date_Query. - * @type array $include_unapproved Array of IDs or email addresses whose unapproved comments - * will be included in paginated comments. + * @type string $type Limit paginated comments to those matching a given type. + * Accepts 'comment', 'trackback', 'pingback', 'pings' + * (trackbacks and pingbacks), or 'all'. Default 'all'. + * @type string[] $type__not_in Comment types excluded by default. Only set when + * `$type` is 'all'. + * @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 + * of comment objects (false). + * @type string $status Comment status. + * @type int $parent Parent ID of comment to retrieve children of. + * @type array $date_query Date query clauses to limit comments by. See WP_Date_Query. + * @type array $include_unapproved Array of IDs or email addresses whose unapproved comments + * will be included in paginated comments. * } */ $comment_args = apply_filters( 'get_page_of_comment_query_args', $comment_args ); @@ -3237,98 +3232,58 @@ function wp_update_comment_count( $post_id, $do_deferred = false ) { /** * Retrieves the comment types that are excluded from queries and counts by default. * - * 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. + * Starts from 'note', 'reaction' and every comment type registered as internal, then + * applies the {@see 'default_excluded_comment_types'} filter. The result holds unique, + * non-empty strings and never the WP_Comment_Query type aliases ('all', 'comment', + * 'comments', 'pings'). * * @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 - * resolved outside of a comment query. Default null. + * @param WP_Comment_Query|null $query Optional. The comment query resolving the set, if any. + * Default null. * @return string[] Comment types excluded by default. */ function wp_get_default_excluded_comment_types( $query = null ) { $default_excluded_types = array( 'note', 'reaction' ); - /* - * 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. - */ + // The comment type registry is optional; 'note' and 'reaction' stay excluded without it. 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' ) - ) - ) - ); + $internal_types = array_filter( (array) get_comment_types( array( 'internal' => true ), 'names' ), 'is_string' ); + + $default_excluded_types = array_values( array_unique( array_merge( $default_excluded_types, $internal_types ) ) ); } /** * 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 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 - * query individually. The 'note' comment type, used by the editor, is - * 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 - * /comments/feed/. + * Excluded types are left out of WP_Comment_Query results unless the query requests + * 'all' types or names the type in 'type' or 'type__in'. The same set is applied to + * the stored post comment count, the pending comment count, the comment feeds and + * get_lastcommentmodified(). * - * 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. + * Values are literal `comment_type` values. The WP_Comment_Query type aliases + * ('all', 'comment', 'comments', 'pings') and non-scalar values 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 filtered set, so toggling does not serve - * stale results, but each distinct set is cached separately. + * Stored comment counts are only refreshed when a comment changes, so a plugin that + * changes the set for types that already have comments should call + * wp_update_comment_count_now() for the affected posts on activation and deactivation. * - * 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 - * 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). + * This is a default-visibility setting, not access control: excluded types can still + * be queried explicitly. * * @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 + * @param string[] $excluded_types Comment types excluded by default. Defaults to + * 'note', 'reaction' 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). + * @param WP_Comment_Query|null $query The comment query resolving the set, or null + * outside of a comment 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' ); - /* - * Drop empty strings, but keep a type literally named '0', which array_filter() - * without a callback would treat as empty. - */ + // Keeps a type named '0', which array_filter() without a callback would drop. $excluded_types = array_filter( array_map( 'strval', $excluded_types ), static function ( $excluded_type ) { @@ -3338,16 +3293,14 @@ static function ( $excluded_type ) { $excluded_types = array_unique( $excluded_types ); - // Strip the special type tokens so an alias cannot poison explicit-type queries. + // An alias in the set would exclude types a query requests explicitly. 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. + * For comment queries built without WP_Comment_Query. * * @since 7.2.0 * @@ -3368,7 +3321,7 @@ function wp_get_excluded_comment_types_clause( string $column = 'comment_type' ) return ''; } - // Each part of a qualified name is its own identifier, so `table`.`column` quotes correctly. + // Quotes each part of a qualified name as its own identifier. $column_parts = explode( '.', $column ); $column_sql = implode( '.', array_fill( 0, count( $column_parts ), '%i' ) ); $placeholders = implode( ', ', array_fill( 0, count( $excluded_types ), '%s' ) ); From 5dba100e695b2c16830b601853e81a5deaec0dd3 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Sun, 8 Nov 2026 09:28:41 -0500 Subject: [PATCH 32/33] Tests: Add native return types to the excluded comment types tests. New test methods and data providers need native types to pass PHPStan at level 10. See #65537. --- .../comment/GetPendingCommentsNum_Test.php | 12 +++--- .../tests/admin/wpCommentsListTable.php | 8 ++-- .../tests/comment/getPageOfComment.php | 6 +-- tests/phpunit/tests/comment/query.php | 40 +++++++++---------- .../tests/comment/wpUpdateCommentCountNow.php | 6 +-- .../rest-api/rest-comments-controller.php | 2 +- 6 files changed, 37 insertions(+), 37 deletions(-) diff --git a/tests/phpunit/tests/admin/includes/comment/GetPendingCommentsNum_Test.php b/tests/phpunit/tests/admin/includes/comment/GetPendingCommentsNum_Test.php index 425687033ec7f..fe57d4ac236bf 100644 --- a/tests/phpunit/tests/admin/includes/comment/GetPendingCommentsNum_Test.php +++ b/tests/phpunit/tests/admin/includes/comment/GetPendingCommentsNum_Test.php @@ -28,7 +28,7 @@ private function make_pending( $post_id, $comment_type = 'comment' ) { /** * @ticket 65537 */ - public function test_counts_only_pending_comments() { + public function test_counts_only_pending_comments(): void { $post_id = self::factory()->post->create(); $this->make_pending( $post_id ); self::factory()->comment->create( @@ -44,7 +44,7 @@ public function test_counts_only_pending_comments() { /** * @ticket 65537 */ - public function test_excludes_note_type_by_default() { + public function test_excludes_note_type_by_default(): void { $post_id = self::factory()->post->create(); $this->make_pending( $post_id ); $this->make_pending( $post_id, 'note' ); @@ -57,7 +57,7 @@ public function test_excludes_note_type_by_default() { * * @ticket 65537 */ - public function test_excludes_a_filtered_type() { + public function test_excludes_a_filtered_type(): void { $post_id = self::factory()->post->create(); $this->make_pending( $post_id ); $this->make_pending( $post_id, 'review' ); @@ -80,7 +80,7 @@ static function ( $types ) { * * @ticket 65537 */ - public function test_emptying_filter_counts_note_type() { + public function test_emptying_filter_counts_note_type(): void { $post_id = self::factory()->post->create(); $this->make_pending( $post_id, 'note' ); @@ -99,7 +99,7 @@ public function test_emptying_filter_counts_note_type() { * * @ticket 65537 */ - public function test_false_filter_return_counts_all_types() { + public function test_false_filter_return_counts_all_types(): void { $post_id = self::factory()->post->create(); $this->make_pending( $post_id, 'note' ); @@ -111,7 +111,7 @@ public function test_false_filter_return_counts_all_types() { /** * @ticket 65537 */ - public function test_array_input_returns_counts_keyed_by_post() { + public function test_array_input_returns_counts_keyed_by_post(): void { $post_a = self::factory()->post->create(); $post_b = self::factory()->post->create(); $this->make_pending( $post_a ); diff --git a/tests/phpunit/tests/admin/wpCommentsListTable.php b/tests/phpunit/tests/admin/wpCommentsListTable.php index 9a82c7611ce3d..73892ea06f200 100644 --- a/tests/phpunit/tests/admin/wpCommentsListTable.php +++ b/tests/phpunit/tests/admin/wpCommentsListTable.php @@ -292,7 +292,7 @@ public static function data_comment_type(): array { * * @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 ) { + public function test_comments_list_table_hides_filtered_excluded_comment_type( string $comment_type ): void { $post_id = self::factory()->post->create(); self::factory()->comment->create( @@ -342,7 +342,7 @@ public function data_unrequested_comment_type(): array { * * @ticket 65537 */ - public function test_comments_list_table_shows_explicitly_requested_excluded_comment_type() { + public function test_comments_list_table_shows_explicitly_requested_excluded_comment_type(): void { $post_id = self::factory()->post->create(); $private_comment_id = self::factory()->comment->create( @@ -379,7 +379,7 @@ public function test_comments_list_table_shows_explicitly_requested_excluded_com * * @ticket 65537 */ - public function test_comments_list_table_note_request_is_dropped_with_an_empty_excluded_set() { + public function test_comments_list_table_note_request_is_dropped_with_an_empty_excluded_set(): void { $post_id = self::factory()->post->create(); $note_id = self::factory()->comment->create( @@ -429,7 +429,7 @@ public function test_comments_list_table_note_request_is_dropped_with_an_empty_e * * @ticket 65537 */ - public function test_comments_list_table_ignores_a_non_string_comment_type_request() { + public function test_comments_list_table_ignores_a_non_string_comment_type_request(): void { $post_id = self::factory()->post->create(); self::factory()->comment->create( diff --git a/tests/phpunit/tests/comment/getPageOfComment.php b/tests/phpunit/tests/comment/getPageOfComment.php index 6f5d5a6ad61c8..8d72c32b9b914 100644 --- a/tests/phpunit/tests/comment/getPageOfComment.php +++ b/tests/phpunit/tests/comment/getPageOfComment.php @@ -551,7 +551,7 @@ public function test_page_number_when_unapproved_comments_are_included_for_curre * * @ticket 65537 */ - public function test_default_excluded_types_are_not_counted() { + public function test_default_excluded_types_are_not_counted(): void { $post_id = self::factory()->post->create(); // Two notes, then two regular comments, oldest first. @@ -635,7 +635,7 @@ public function test_explicitly_requested_excluded_type_is_counted(): void { * * @ticket 65537 */ - public function test_filtered_excluded_types_are_not_counted() { + public function test_filtered_excluded_types_are_not_counted(): void { $post_id = self::factory()->post->create(); foreach ( array( 'private', 'private', '', '' ) as $index => $comment_type ) { @@ -682,7 +682,7 @@ static function ( array $types ): array { * * @covers ::get_comment_link */ - public function test_get_comment_link_does_not_count_excluded_types() { + public function test_get_comment_link_does_not_count_excluded_types(): void { update_option( 'page_comments', 1 ); update_option( 'comments_per_page', 2 ); update_option( 'default_comments_page', 'oldest' ); diff --git a/tests/phpunit/tests/comment/query.php b/tests/phpunit/tests/comment/query.php index 198886baf9476..2303a8daa5e2d 100644 --- a/tests/phpunit/tests/comment/query.php +++ b/tests/phpunit/tests/comment/query.php @@ -5491,8 +5491,8 @@ protected function create_internal_comment_type_test_comments(): array { * @covers WP_Comment_Query::get_comment_ids * @dataProvider data_internal_comment_type_exclusion * - * @param array $query_args Query arguments for WP_Comment_Query. - * @param string[] $expected_types Expected comment types. + * @param array $query_args Query arguments for WP_Comment_Query. + * @param string[] $expected_types Expected comment types. */ public function test_internal_comment_type_exclusion( array $query_args, array $expected_types ) { $this->create_internal_comment_type_test_comments(); @@ -5515,7 +5515,7 @@ static function ( int $comment_id ): string { * * @since 6.9.0 * - * @return array, expected_types: string[] }> + * @return array, expected_types: string[] }> */ public static function data_internal_comment_type_exclusion(): array { return array( @@ -5705,7 +5705,7 @@ static function ( int $comment_id ): string { * @ticket 65537 * @covers WP_Comment_Query::get_comment_ids */ - public function test_default_excluded_comment_types_filter_excludes_custom_type() { + public function test_default_excluded_comment_types_filter_excludes_custom_type(): void { $this->create_excluded_type_test_comments(); add_filter( @@ -5733,7 +5733,7 @@ static function ( array $types ): array { * @ticket 65537 * @covers WP_Comment_Query::get_comment_ids */ - public function test_default_excluded_comment_types_filter_can_remove_note() { + public function test_default_excluded_comment_types_filter_can_remove_note(): void { $this->create_excluded_type_test_comments(); add_filter( 'default_excluded_comment_types', '__return_empty_array' ); @@ -5755,10 +5755,10 @@ public function test_default_excluded_comment_types_filter_can_remove_note() { * @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. + * @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 ) { + public function test_default_excluded_comment_types_filter_respects_explicit_request( array $query_args, array $expected_types ): void { $this->create_excluded_type_test_comments(); add_filter( @@ -5780,7 +5780,7 @@ static function ( array $types ): array { * * @since 7.2.0 * - * @return array, expected_types: string[] }> + * @return array, expected_types: string[] }> */ public function data_default_excluded_comment_types_explicit_request(): array { return array( @@ -5809,7 +5809,7 @@ public function data_default_excluded_comment_types_explicit_request(): array { * @ticket 65537 * @covers WP_Comment_Query::get_comment_ids */ - public function test_default_excluded_comment_types_filter_receives_default_and_instance() { + public function test_default_excluded_comment_types_filter_receives_default_and_instance(): void { $filter = new MockAction(); add_filter( 'default_excluded_comment_types', array( $filter, 'filter' ), 10, 2 ); @@ -5829,7 +5829,7 @@ public function test_default_excluded_comment_types_filter_receives_default_and_ * @ticket 65537 * @covers WP_Comment_Query::get_comment_ids */ - public function test_default_excluded_comment_types_filter_not_duplicated_in_query() { + public function test_default_excluded_comment_types_filter_not_duplicated_in_query(): void { $this->create_excluded_type_test_comments(); add_filter( @@ -5870,7 +5870,7 @@ static function ( array $clauses ) use ( &$captured_where ): array { * @ticket 65537 * @covers WP_Comment_Query::get_comment_ids */ - public function test_default_excluded_comment_types_filter_false_return_is_tolerated() { + public function test_default_excluded_comment_types_filter_false_return_is_tolerated(): void { $comments = $this->create_internal_comment_type_test_comments(); add_filter( 'default_excluded_comment_types', '__return_false' ); @@ -5889,7 +5889,7 @@ public function test_default_excluded_comment_types_filter_false_return_is_toler * @ticket 65537 * @covers ::wp_get_default_excluded_comment_types */ - public function test_default_excluded_comment_types_filter_strips_special_tokens() { + public function test_default_excluded_comment_types_filter_strips_special_tokens(): void { $comments = $this->create_internal_comment_type_test_comments(); add_filter( @@ -5929,7 +5929,7 @@ static function ( array $types ): array { * @ticket 65537 * @covers WP_Comment_Query::get_comment_ids */ - public function test_default_excluded_comment_types_filter_respects_alias_request() { + public function test_default_excluded_comment_types_filter_respects_alias_request(): void { $comments = $this->create_internal_comment_type_test_comments(); add_filter( @@ -5967,7 +5967,7 @@ static function ( array $types ): array { * @ticket 65537 * @covers ::wp_get_default_excluded_comment_types */ - public function test_default_excluded_comment_types_filter_preserves_zero_string_type() { + public function test_default_excluded_comment_types_filter_preserves_zero_string_type(): void { add_filter( 'default_excluded_comment_types', static function ( array $types ): array { @@ -5986,7 +5986,7 @@ static function ( array $types ): array { * @ticket 65537 * @covers ::wp_get_default_excluded_comment_types */ - public function test_default_excluded_comment_types_filter_drops_non_scalar_values() { + public function test_default_excluded_comment_types_filter_drops_non_scalar_values(): void { add_filter( 'default_excluded_comment_types', static function ( array $types ): array { @@ -6050,7 +6050,7 @@ public function test_excluded_comment_types_clause_is_empty_without_excluded_typ * @ticket 35214 * @covers ::wp_get_default_excluded_comment_types */ - public function test_internal_comment_types_are_excluded_by_default() { + public function test_internal_comment_types_are_excluded_by_default(): void { if ( ! function_exists( 'register_comment_type' ) ) { $this->markTestSkipped( 'Requires the comment type registry.' ); } @@ -6086,7 +6086,7 @@ public function test_internal_comment_types_are_excluded_by_default() { * @ticket 65537 * @covers WP_Comment_Query::get_comments */ - public function test_default_excluded_comment_types_are_part_of_the_cache_key() { + public function test_default_excluded_comment_types_are_part_of_the_cache_key(): void { $this->create_excluded_type_test_comments(); $callback = static function ( array $types ): array { @@ -6128,7 +6128,7 @@ public function test_default_excluded_comment_types_are_part_of_the_cache_key() * @ticket 65537 * @covers WP_Comment_Query::get_comments */ - public function test_default_excluded_comment_types_filter_runs_once_per_query() { + public function test_default_excluded_comment_types_filter_runs_once_per_query(): void { $filter = new MockAction(); add_filter( 'default_excluded_comment_types', array( $filter, 'filter' ) ); @@ -6145,7 +6145,7 @@ public function test_default_excluded_comment_types_filter_runs_once_per_query() * @ticket 65537 * @covers ::wp_count_comments */ - public function test_wp_count_comments_excludes_filtered_types() { + public function test_wp_count_comments_excludes_filtered_types(): void { $this->create_excluded_type_test_comments(); add_filter( diff --git a/tests/phpunit/tests/comment/wpUpdateCommentCountNow.php b/tests/phpunit/tests/comment/wpUpdateCommentCountNow.php index 00fbe591cbb79..ed76805d68bd8 100644 --- a/tests/phpunit/tests/comment/wpUpdateCommentCountNow.php +++ b/tests/phpunit/tests/comment/wpUpdateCommentCountNow.php @@ -102,7 +102,7 @@ public function test_only_approved_regular_comments_are_counted() { * * @ticket 65537 */ - public function test_filtered_excluded_type_does_not_inflate_count() { + public function test_filtered_excluded_type_does_not_inflate_count(): void { $post_id = self::factory()->post->create(); self::factory()->comment->create( @@ -142,7 +142,7 @@ static function ( $types ) { * * @ticket 65537 */ - public function test_emptying_filter_counts_otherwise_excluded_types() { + public function test_emptying_filter_counts_otherwise_excluded_types(): void { $post_id = self::factory()->post->create(); self::factory()->comment->create( @@ -170,7 +170,7 @@ public function test_emptying_filter_counts_otherwise_excluded_types() { * * @ticket 65537 */ - public function test_false_filter_return_counts_all_types() { + public function test_false_filter_return_counts_all_types(): void { $post_id = self::factory()->post->create(); self::factory()->comment->create( diff --git a/tests/phpunit/tests/rest-api/rest-comments-controller.php b/tests/phpunit/tests/rest-api/rest-comments-controller.php index 59f8073ae6a22..cc2cf8efc563a 100644 --- a/tests/phpunit/tests/rest-api/rest-comments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-comments-controller.php @@ -4477,7 +4477,7 @@ public function test_get_items_type_arg_unauthenticated( $comment_type, $count ) * * @ticket 65537 */ - public function test_excluded_comment_type_visibility_is_not_access_control() { + public function test_excluded_comment_type_visibility_is_not_access_control(): void { $comment_id = self::factory()->comment->create( array( 'comment_approved' => 1, From c11b38b3c9c8f621fab6f4ec3a9f735117769fd7 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Sun, 8 Nov 2026 09:31:02 -0500 Subject: [PATCH 33/33] Docs: Mark the new comment type APIs as @since 7.3.0. Trunk is in the 7.2 beta, so these additions can no longer ship in 7.2.0. --- src/wp-admin/includes/comment.php | 2 +- src/wp-includes/class-wp-comment-query.php | 4 ++-- src/wp-includes/comment.php | 14 +++++++------- tests/phpunit/tests/comment/query.php | 4 ++-- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/wp-admin/includes/comment.php b/src/wp-admin/includes/comment.php index 08017d067f703..df066f1df5c32 100644 --- a/src/wp-admin/includes/comment.php +++ b/src/wp-admin/includes/comment.php @@ -224,7 +224,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.2.0 Exclude the 'reaction' comment type from the count. - * @since 7.2.0 The excluded comment types are derived from the + * @since 7.3.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 e73e3a9552423..090f758fa1be6 100644 --- a/src/wp-includes/class-wp-comment-query.php +++ b/src/wp-includes/class-wp-comment-query.php @@ -69,7 +69,7 @@ class WP_Comment_Query { /** * Comment types excluded from the results by default, or null until resolved. * - * @since 7.2.0 + * @since 7.3.0 * @var string[]|null */ protected $default_excluded_comment_types = null; @@ -566,7 +566,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.2.0 Excludes the 'reaction' comment type, unless 'all' or the 'reaction' types are requested. - * @since 7.2.0 The default-excluded comment types are filterable via {@see 'default_excluded_comment_types'}. + * @since 7.3.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.php b/src/wp-includes/comment.php index 4ad0eafe5882b..c4578c54fb9ed 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -402,7 +402,7 @@ function get_default_comment_status( $post_type = 'post', $comment_type = 'comme * @since 4.7.0 Replaced caching the modified date in a local static variable * with the Object Cache API. * @since 7.2.0 The 'note' and 'reaction' comment types are excluded from the query. - * @since 7.2.0 The excluded comment types are derived from the + * @since 7.3.0 The excluded comment types are derived from the * {@see 'default_excluded_comment_types'} filter. * * @global wpdb $wpdb WordPress database abstraction object. @@ -1108,7 +1108,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.2.0 Comment types excluded by default are no longer counted when 'type' is 'all'. + * @since 7.3.0 Comment types excluded by default are no longer counted when 'type' is 'all'. * * @global wpdb $wpdb WordPress database abstraction object. * @@ -1216,7 +1216,7 @@ function get_page_of_comment( $comment_id, $args = array() ) { * Filters the arguments used to query comments in get_page_of_comment(). * * @since 5.5.0 - * @since 7.2.0 Added the `$type__not_in` argument. + * @since 7.3.0 Added the `$type__not_in` argument. * * @see WP_Comment_Query::__construct() * @@ -3237,7 +3237,7 @@ function wp_update_comment_count( $post_id, $do_deferred = false ) { * non-empty strings and never the WP_Comment_Query type aliases ('all', 'comment', * 'comments', 'pings'). * - * @since 7.2.0 + * @since 7.3.0 * * @param WP_Comment_Query|null $query Optional. The comment query resolving the set, if any. * Default null. @@ -3271,7 +3271,7 @@ function wp_get_default_excluded_comment_types( $query = null ) { * This is a default-visibility setting, not access control: excluded types can still * be queried explicitly. * - * @since 7.2.0 + * @since 7.3.0 * * @param string[] $excluded_types Comment types excluded by default. Defaults to * 'note', 'reaction' and every comment type @@ -3302,7 +3302,7 @@ static function ( $excluded_type ) { * * For comment queries built without WP_Comment_Query. * - * @since 7.2.0 + * @since 7.3.0 * * @global wpdb $wpdb WordPress database abstraction object. * @@ -3334,7 +3334,7 @@ function wp_get_excluded_comment_types_clause( string $column = 'comment_type' ) * Updates the comment count for the post. * * @since 2.5.0 - * @since 7.2.0 The excluded comment types are derived from the + * @since 7.3.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 2303a8daa5e2d..e14853986d4eb 100644 --- a/tests/phpunit/tests/comment/query.php +++ b/tests/phpunit/tests/comment/query.php @@ -5654,7 +5654,7 @@ public function test_get_comment_count_excludes_internal_comment_types() { * * Creates one comment of each of the 'comment', 'note', and 'private' types. * - * @since 7.2.0 + * @since 7.3.0 * * @return array<'comment'|'note'|'private', int> Array of created comment IDs keyed by type. */ @@ -5778,7 +5778,7 @@ static function ( array $types ): array { /** * Data provider for explicit-request tests against a filtered excluded type. * - * @since 7.2.0 + * @since 7.3.0 * * @return array, expected_types: string[] }> */