From 391424bb2411919981bb3bcd68b34c5616a2f12a Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 24 Jun 2026 15:33:11 -0700 Subject: [PATCH 1/5] 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/5] 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/5] 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/5] 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. From 6f25cd68c931b23ba5aa59b0e2a71d8cfebdbcc3 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Sat, 11 Jul 2026 10:22:13 -0700 Subject: [PATCH 5/5] Comments: Document the render_callback element contract and pin it with tests. - State the full precedence chain (explicit wp_list_comments() 'callback', then 'render_callback', then default markup), the open-tag contract (output only the element opening; end_el() or 'end-callback' closes it after children), and the classic-theme-only scope (block themes never invoke Walker_Comment) in both the property and args docblocks. - Add the missing @since 7.1.0 changelog entry to Walker_Comment::start_el(). - Fix the test callbacks, which echoed closed
  • elements: end_el() appends its own close, so the previous assertions passed while the markup contained stray closing tags. Assert the full element shape, pin end_el()'s close, and cover a threaded child rendering inside its parent's callback-opened element plus legacy empty-string types. - Document the render_comments() helper's $args parameter and drop cleanup-only unregister calls now handled by the test framework. --- src/wp-includes/class-walker-comment.php | 2 + src/wp-includes/class-wp-comment-type.php | 19 ++++- src/wp-includes/comment.php | 9 ++- tests/phpunit/tests/comment/walker.php | 86 ++++++++++++++++++++--- 4 files changed, 99 insertions(+), 17 deletions(-) diff --git a/src/wp-includes/class-walker-comment.php b/src/wp-includes/class-walker-comment.php index 1c0c98795500e..15feb8a8a2329 100644 --- a/src/wp-includes/class-walker-comment.php +++ b/src/wp-includes/class-walker-comment.php @@ -158,6 +158,8 @@ public function display_element( $element, &$children_elements, $max_depth, $dep * @since 2.7.0 * @since 5.9.0 Renamed `$comment` to `$data_object` and `$id` to `$current_object_id` * to match parent class for PHP 8 named parameter support. + * @since 7.1.0 Comments of a registered comment type with a `render_callback` + * are rendered via that callback. * * @see Walker::start_el() * @see wp_list_comments() diff --git a/src/wp-includes/class-wp-comment-type.php b/src/wp-includes/class-wp-comment-type.php index a46a844507e27..3bf2a95a3c674 100644 --- a/src/wp-includes/class-wp-comment-type.php +++ b/src/wp-includes/class-wp-comment-type.php @@ -106,9 +106,22 @@ final class WP_Comment_Type { * 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. Output from the callback is printed unescaped; the - * callback is responsible for escaping all output. Default null. + * and the depth. The precedence chain is: an explicit `callback` passed to + * wp_list_comments(), then this callback, then the default markup. + * + * Like the `callback` argument of wp_list_comments(), the callback must only + * output the opening of the list element (an unclosed `
  • ` by default); + * {@see Walker_Comment::end_el()} (or the `end-callback` argument) closes + * the element after any child comments have been rendered. + * + * Output from the callback is printed unescaped; the callback is + * responsible for escaping all output. + * + * Only applies when comments are rendered via wp_list_comments() (classic + * themes). Block themes render comments through the `core/comment-template` + * block and do not invoke this callback. + * + * Default null. * * @since 7.1.0 * @var callable|null diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index 5127ce9b836fa..cbb994e03addd 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -377,9 +377,12 @@ function create_initial_comment_types() { * @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. Output is printed unescaped; the callback must - * escape all output. Default null. + * depth) and, like it, must only output the opening of the list + * element; Walker_Comment::end_el() closes the element. An + * explicit wp_list_comments() `callback` takes precedence. + * Output is printed unescaped; the callback must escape all + * output. Applies only when comments are rendered via + * wp_list_comments() (classic themes). Default null. * } * @return WP_Comment_Type|WP_Error The registered comment type object on success, * WP_Error object on failure. diff --git a/tests/phpunit/tests/comment/walker.php b/tests/phpunit/tests/comment/walker.php index 434b2c12b3d38..61c995038b956 100644 --- a/tests/phpunit/tests/comment/walker.php +++ b/tests/phpunit/tests/comment/walker.php @@ -59,6 +59,8 @@ public function test_has_children() { * Renders the comments on the test post and returns the markup. * * @param array $comments Comments to render. + * @param array $args Optional. Arguments merged into the wp_list_comments() defaults. + * Default empty array. * @return string Rendered markup. */ private function render_comments( $comments, $args = array() ) { @@ -83,8 +85,9 @@ public function test_render_callback_renders_registered_comment_type() { register_comment_type( 'review', array( + // Like a wp_list_comments() callback, only the element opening is output. 'render_callback' => static function ( $comment ) { - echo '
  • ' . esc_html( $comment->comment_content ) . '
  • '; + echo '
  • ' . esc_html( $comment->comment_content ); }, ) ); @@ -100,10 +103,8 @@ public function test_render_callback_renders_registered_comment_type() { $output = $this->render_comments( array( get_comment( $comment_id ) ) ); - $this->assertStringContainsString( 'class="review"', $output ); - $this->assertStringContainsString( 'Rendered by callback', $output ); - - unregister_comment_type( 'review' ); + // Walker_Comment::end_el() closes the element the callback opened. + $this->assertStringContainsString( '
  • Rendered by callback
  • ', $output ); } /** @@ -140,8 +141,6 @@ public function test_explicit_callback_takes_precedence_over_render_callback() { $this->assertStringContainsString( 'FROM_LIST_CALLBACK', $output ); $this->assertStringNotContainsString( 'FROM_TYPE_CALLBACK', $output ); - - unregister_comment_type( 'review' ); } /** @@ -182,7 +181,7 @@ public function test_render_callback_receives_comment_args_and_depth() { 'args' => $args, 'depth' => $depth, ); - echo '
  • '; + echo '
  • '; }, ) ); @@ -201,8 +200,75 @@ public function test_render_callback_receives_comment_args_and_depth() { $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.' ); + } + + /** + * A child comment renders inside the element opened by its parent's render_callback. + * + * This pins the open-tag contract end to end: the callback outputs only the + * element opening, children render within it, and end_el() closes each element. + * + * @ticket 35214 + */ + public function test_child_comment_renders_within_render_callback_parent_element() { + register_comment_type( + 'review', + array( + 'render_callback' => static function ( $comment ) { + echo '
  • ' . esc_html( $comment->comment_content ); + }, + ) + ); - unregister_comment_type( 'review' ); + $parent_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $this->post_id, + 'comment_type' => 'review', + 'comment_content' => 'Parent review', + 'comment_approved' => '1', + ) + ); + $child_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $this->post_id, + 'comment_type' => 'review', + 'comment_content' => 'Child review', + 'comment_approved' => '1', + 'comment_parent' => $parent_id, + ) + ); + + $output = $this->render_comments( array( get_comment( $parent_id ), get_comment( $child_id ) ) ); + + $parent_pos = strpos( $output, 'Parent review' ); + $children_pos = strpos( $output, '
      ' ); + $child_pos = strpos( $output, 'Child review' ); + + $this->assertNotFalse( $children_pos, 'A children list should be rendered.' ); + $this->assertGreaterThan( $parent_pos, $children_pos, 'The children list should open after the parent content.' ); + $this->assertGreaterThan( $children_pos, $child_pos, 'The child should render inside the children list.' ); + $this->assertSame( 2, substr_count( $output, '' ), 'Each element should be closed exactly once by end_el().' ); + } + + /** + * A comment stored with the legacy empty string type renders the default markup. + * + * @ticket 35214 + */ + public function test_legacy_empty_type_renders_default_markup() { + $comment_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $this->post_id, + 'comment_type' => '', + 'comment_content' => 'Legacy comment', + 'comment_approved' => '1', + ) + ); + + $output = $this->render_comments( array( get_comment( $comment_id ) ) ); + + $this->assertStringContainsString( 'Legacy comment', $output ); + $this->assertStringContainsString( '', $output ); } /** @@ -230,8 +296,6 @@ public function test_non_callable_render_callback_is_ignored() { $output = $this->render_comments( array( get_comment( $comment_id ) ) ); $this->assertStringContainsString( 'Falls back to normal rendering', $output ); - - unregister_comment_type( 'review' ); } }