From 391424bb2411919981bb3bcd68b34c5616a2f12a Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 24 Jun 2026 15:33:11 -0700 Subject: [PATCH 1/4] Comments: Allow comment types to register a display callback. Add a `render_callback` argument to `register_comment_type()`, stored on `WP_Comment_Type`. When a comment's registered type defines one, `Walker_Comment` uses it to render that comment, receiving the same arguments as the `callback` argument of wp_list_comments() (the comment, the arguments, and the depth). This gives custom comment types control over their own display, as raised in the tracking ticket, without each type having to filter the walker. An explicit `callback` passed to wp_list_comments() still takes precedence, and built-in types set no callback, so there is no change to existing output. See #35214. --- src/wp-includes/class-walker-comment.php | 13 +++++++++++ src/wp-includes/class-wp-comment-type.php | 27 ++++++++++++++++++----- src/wp-includes/comment.php | 9 ++++++-- 3 files changed, 41 insertions(+), 8 deletions(-) diff --git a/src/wp-includes/class-walker-comment.php b/src/wp-includes/class-walker-comment.php index 3d2eb012e4903..1c0c98795500e 100644 --- a/src/wp-includes/class-walker-comment.php +++ b/src/wp-includes/class-walker-comment.php @@ -185,6 +185,19 @@ public function start_el( &$output, $data_object, $depth = 0, $args = array(), $ return; } + /* + * Allow a registered comment type to render itself. An explicit `callback` + * argument passed to wp_list_comments() takes precedence and is handled above. + */ + $comment_type_object = get_comment_type_object( $comment->comment_type ); + + if ( $comment_type_object && is_callable( $comment_type_object->render_callback ) ) { + ob_start(); + call_user_func( $comment_type_object->render_callback, $comment, $args, $depth ); + $output .= ob_get_clean(); + return; + } + if ( 'comment' === $comment->comment_type ) { add_filter( 'comment_text', array( $this, 'filter_comment_text' ), 40, 2 ); } diff --git a/src/wp-includes/class-wp-comment-type.php b/src/wp-includes/class-wp-comment-type.php index 6f88c5e7b499d..fbd37f1feec9b 100644 --- a/src/wp-includes/class-wp-comment-type.php +++ b/src/wp-includes/class-wp-comment-type.php @@ -104,6 +104,20 @@ final class WP_Comment_Type { */ public $_builtin = false; + /** + * Callback used to render a comment of this type in comment lists. + * + * When set to a callable, {@see Walker_Comment} invokes it to render a + * comment of this type, receiving the same arguments as the `callback` + * argument of wp_list_comments(): the comment object, the arguments array, + * and the depth. An explicit `callback` passed to wp_list_comments() still + * takes precedence. Default null. + * + * @since 7.1.0 + * @var callable|null + */ + public $render_callback = null; + /** * Whether the comment type is hierarchical. * @@ -187,12 +201,13 @@ public function set_props( $args ) { * treated as a provided value and overwrite the default name with false. */ $defaults = array( - 'labels' => array(), - 'description' => '', - 'public' => true, - 'internal' => false, - 'show_ui' => null, - '_builtin' => false, + 'labels' => array(), + 'description' => '', + 'public' => true, + 'internal' => false, + 'show_ui' => null, + 'render_callback' => null, + '_builtin' => false, ); $args = array_merge( $defaults, $args ); diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index 2ebc55a1804b0..f3f68650532d5 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -365,8 +365,13 @@ function create_initial_comment_types() { * the admin interface or by front-end users. Default true. * @type bool $internal Whether the comment type is for internal use only and should be * excluded from default public-facing contexts. Default false. - * @type bool $show_ui Whether to generate and allow a UI for managing this comment type - * in the admin. Default is value of $public. + * @type bool $show_ui Whether to generate and allow a UI for managing this comment + * type in the admin. Default is value of $public. + * @type callable $render_callback Callback used to render a comment of this type in comment + * lists. Receives the same arguments as the `callback` argument + * of wp_list_comments() (the comment, the arguments, and the + * depth). An explicit wp_list_comments() `callback` takes + * precedence. Default null. * } * @return WP_Comment_Type|WP_Error The registered comment type object on success, * WP_Error object on failure. From 7e8fbf8896b40066fd8cb086a0417294e8697992 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 24 Jun 2026 15:33:11 -0700 Subject: [PATCH 2/4] Comments: Add tests for comment type display callbacks. Cover the `render_callback` argument: that a registered type's callback renders its comments through `Walker_Comment`, that an explicit wp_list_comments() `callback` takes precedence, that a type without a callback renders normally, and that the property defaults to null and stores a provided callable. See #35214. --- tests/phpunit/tests/comment/walker.php | 109 ++++++++++++++++++ tests/phpunit/tests/comment/wpCommentType.php | 13 +++ 2 files changed, 122 insertions(+) diff --git a/tests/phpunit/tests/comment/walker.php b/tests/phpunit/tests/comment/walker.php index 504c5e3b0f2a9..f9cb4ca1087f7 100644 --- a/tests/phpunit/tests/comment/walker.php +++ b/tests/phpunit/tests/comment/walker.php @@ -54,6 +54,115 @@ public function test_has_children() { array( $comment_child, $comment_parent ) ); } + + /** + * Renders the comments on the test post and returns the markup. + * + * @param array $comments Comments to render. + * @return string Rendered markup. + */ + private function render_comments( $comments, $args = array() ) { + return wp_list_comments( + array_merge( + array( + 'echo' => false, + 'walker' => new Walker_Comment(), + ), + $args + ), + $comments + ); + } + + /** + * A registered comment type's render_callback is used to render its comments. + * + * @ticket 35214 + */ + public function test_render_callback_renders_registered_comment_type() { + register_comment_type( + 'review', + array( + 'render_callback' => static function ( $comment ) { + echo '
  • ' . esc_html( $comment->comment_content ) . '
  • '; + }, + ) + ); + + $comment_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $this->post_id, + 'comment_type' => 'review', + 'comment_content' => 'Rendered by callback', + 'comment_approved' => '1', + ) + ); + + $output = $this->render_comments( array( get_comment( $comment_id ) ) ); + + $this->assertStringContainsString( 'class="review"', $output ); + $this->assertStringContainsString( 'Rendered by callback', $output ); + + unregister_comment_type( 'review' ); + } + + /** + * An explicit wp_list_comments() callback takes precedence over a type's render_callback. + * + * @ticket 35214 + */ + public function test_explicit_callback_takes_precedence_over_render_callback() { + register_comment_type( + 'review', + array( + 'render_callback' => static function () { + echo 'FROM_TYPE_CALLBACK'; + }, + ) + ); + + $comment_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $this->post_id, + 'comment_type' => 'review', + 'comment_approved' => '1', + ) + ); + + $output = $this->render_comments( + array( get_comment( $comment_id ) ), + array( + 'callback' => static function () { + echo 'FROM_LIST_CALLBACK'; + }, + ) + ); + + $this->assertStringContainsString( 'FROM_LIST_CALLBACK', $output ); + $this->assertStringNotContainsString( 'FROM_TYPE_CALLBACK', $output ); + + unregister_comment_type( 'review' ); + } + + /** + * Built-in comment types without a render_callback render normally. + * + * @ticket 35214 + */ + public function test_comment_type_without_render_callback_renders_normally() { + $comment_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $this->post_id, + 'comment_type' => 'comment', + 'comment_content' => 'A normal comment', + 'comment_approved' => '1', + ) + ); + + $output = $this->render_comments( array( get_comment( $comment_id ) ) ); + + $this->assertStringContainsString( 'A normal comment', $output ); + } } class Comment_Callback_Test_Helper { diff --git a/tests/phpunit/tests/comment/wpCommentType.php b/tests/phpunit/tests/comment/wpCommentType.php index bf6d3c7df28dd..9dd8c3a37187b 100644 --- a/tests/phpunit/tests/comment/wpCommentType.php +++ b/tests/phpunit/tests/comment/wpCommentType.php @@ -24,6 +24,19 @@ public function test_instance_defaults() { $this->assertFalse( $comment_type->_builtin ); $this->assertTrue( $comment_type->show_ui ); $this->assertFalse( $comment_type->hierarchical ); + $this->assertNull( $comment_type->render_callback ); + } + + /** + * @ticket 35214 + * + * @covers ::set_props + */ + public function test_render_callback_is_stored() { + $callback = static function () {}; + $comment_type = new WP_Comment_Type( 'foo', array( 'render_callback' => $callback ) ); + + $this->assertSame( $callback, $comment_type->render_callback ); } /** From eb227550a4cf5fb6329786b999cdb37707e1b2fa Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 24 Jun 2026 20:28:36 -0700 Subject: [PATCH 3/4] Comments: Cover render_callback argument contract and non-callable guard. Add a test asserting the render_callback receives the comment, the arguments array, and the depth (the documented wp_list_comments() callback contract, previously only the comment was exercised), and a test that a non-callable render_callback is ignored via is_callable() and the comment renders normally. See #35214. --- tests/phpunit/tests/comment/walker.php | 70 ++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/tests/phpunit/tests/comment/walker.php b/tests/phpunit/tests/comment/walker.php index f9cb4ca1087f7..434b2c12b3d38 100644 --- a/tests/phpunit/tests/comment/walker.php +++ b/tests/phpunit/tests/comment/walker.php @@ -163,6 +163,76 @@ public function test_comment_type_without_render_callback_renders_normally() { $this->assertStringContainsString( 'A normal comment', $output ); } + + /** + * The render_callback receives the comment, the arguments array, and the depth, + * matching the wp_list_comments() `callback` contract. + * + * @ticket 35214 + */ + public function test_render_callback_receives_comment_args_and_depth() { + $received = array(); + + register_comment_type( + 'review', + array( + 'render_callback' => static function ( $comment, $args, $depth ) use ( &$received ) { + $received = array( + 'comment' => $comment, + 'args' => $args, + 'depth' => $depth, + ); + echo '
  • '; + }, + ) + ); + + $comment_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $this->post_id, + 'comment_type' => 'review', + 'comment_approved' => '1', + ) + ); + + $this->render_comments( array( get_comment( $comment_id ) ) ); + + $this->assertInstanceOf( 'WP_Comment', $received['comment'], 'The callback should receive the comment object.' ); + $this->assertSame( (string) $comment_id, (string) $received['comment']->comment_ID, 'The callback should receive the rendered comment.' ); + $this->assertIsArray( $received['args'], 'The callback should receive the arguments array.' ); + $this->assertSame( 1, $received['depth'], 'A top-level comment should be rendered at depth 1.' ); + + unregister_comment_type( 'review' ); + } + + /** + * A render_callback that is not callable is ignored and the comment renders normally. + * + * @ticket 35214 + */ + public function test_non_callable_render_callback_is_ignored() { + register_comment_type( + 'review', + array( + 'render_callback' => 'this_is_not_a_callable_function', + ) + ); + + $comment_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $this->post_id, + 'comment_type' => 'review', + 'comment_content' => 'Falls back to normal rendering', + 'comment_approved' => '1', + ) + ); + + $output = $this->render_comments( array( get_comment( $comment_id ) ) ); + + $this->assertStringContainsString( 'Falls back to normal rendering', $output ); + + unregister_comment_type( 'review' ); + } } class Comment_Callback_Test_Helper { From 338339b8944e71550e78543f4bc21c102b2f6ff2 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 24 Jun 2026 22:19:35 -0700 Subject: [PATCH 4/4] Comments: Document that render_callback output is not escaped. Walker_Comment prints the render_callback output verbatim, matching the existing wp_list_comments() `callback` contract. Note in both the WP_Comment_Type::$render_callback property and the register_comment_type() parameter docs that the callback is responsible for escaping its output. --- src/wp-includes/class-wp-comment-type.php | 3 ++- src/wp-includes/comment.php | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/class-wp-comment-type.php b/src/wp-includes/class-wp-comment-type.php index fbd37f1feec9b..a8c0463eb16d7 100644 --- a/src/wp-includes/class-wp-comment-type.php +++ b/src/wp-includes/class-wp-comment-type.php @@ -111,7 +111,8 @@ final class WP_Comment_Type { * comment of this type, receiving the same arguments as the `callback` * argument of wp_list_comments(): the comment object, the arguments array, * and the depth. An explicit `callback` passed to wp_list_comments() still - * takes precedence. Default null. + * takes precedence. Output from the callback is printed unescaped; the + * callback is responsible for escaping all output. Default null. * * @since 7.1.0 * @var callable|null diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index f3f68650532d5..4e3f6f58f0996 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -371,7 +371,8 @@ function create_initial_comment_types() { * lists. Receives the same arguments as the `callback` argument * of wp_list_comments() (the comment, the arguments, and the * depth). An explicit wp_list_comments() `callback` takes - * precedence. Default null. + * precedence. Output is printed unescaped; the callback must + * escape all output. Default null. * } * @return WP_Comment_Type|WP_Error The registered comment type object on success, * WP_Error object on failure.