From 391424bb2411919981bb3bcd68b34c5616a2f12a Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 24 Jun 2026 15:33:11 -0700 Subject: [PATCH 01/11] 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 02/11] 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 03/11] 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 04/11] 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 05/11] 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' ); } } From 486ccb97e077db1a67ce5312a74aad8e5cd54575 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 12 Aug 2026 17:15:43 -0700 Subject: [PATCH 06/11] Docs: Spell out the `render_callback` element, echo, and override contract. Three parts of the contract were left for a developer to infer: The callback opens the element that `Walker_Comment::end_el()` closes, and `end_el()` emits `` or `` depending on the `style` argument. The docs said "an unclosed `
    • ` by default", which is true but reads as the whole story - a callback that always opens an `
    • ` produces mismatched markup under `style => 'div'`. Say that the callback has to branch on `$args['style']`, the way the built-in `comment()` does. The name deliberately echoes `register_block_type()`, whose `render_callback` returns a string. This one is captured with an output buffer, so a return value is silently discarded. Say "must echo" rather than "output". Built-in types cannot be re-registered, but `set_props()` applies the registration args filters to them too, so `register_comment_type_args` can set a callback on 'comment', 'pingback', or 'trackback'. That is worth keeping - it grants no more than the `callback` argument of wp_list_comments() always has - so document it as the intended override path, along with the one consequence that is easy to miss: a callback on the 'comment' type replaces the walker's link stripping for pending comments. --- src/wp-includes/class-wp-comment-type.php | 20 +++++++++++++++++--- src/wp-includes/comment.php | 12 +++++++----- 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/src/wp-includes/class-wp-comment-type.php b/src/wp-includes/class-wp-comment-type.php index 1faf7eed5dfb6..58846367b78d1 100644 --- a/src/wp-includes/class-wp-comment-type.php +++ b/src/wp-includes/class-wp-comment-type.php @@ -109,13 +109,27 @@ final class WP_Comment_Type { * 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 the opening of the list element; {@see Walker_Comment::end_el()} + * (or the `end-callback` argument) closes the element after any child + * comments have been rendered. Which element that is depends on the `style` + * argument, so the callback has to open a `
      ` when `$args['style']` is + * 'div' and an `
    • ` otherwise, the way {@see Walker_Comment::comment()} + * does. Opening the wrong one leaves the markup mismatched. + * + * The callback must echo its output. Unlike the `render_callback` argument + * of register_block_type(), a returned string is discarded. * * Output from the callback is printed unescaped; the callback is * responsible for escaping all output. * + * The built-in comment types register without a callback and cannot be + * re-registered, but setting one on them through the + * {@see 'register_comment_type_args'} filter is supported. It grants no more + * than the `callback` argument of wp_list_comments() already does. Note that + * a callback on the 'comment' type also takes over the walker's handling of + * unapproved comments, which strips links from a pending comment's text for + * everyone but its author. + * * 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. diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index 4854357c7428a..04cbe1dfd52f2 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -452,11 +452,13 @@ 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) 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 + * depth) and, like it, must echo only the opening of the list + * element - a `
      ` when the `style` argument is 'div' and an + * `
    • ` otherwise; Walker_Comment::end_el() closes the element. + * A returned string is discarded. 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. * @type bool $_builtin For internal core use only. Marks the type as native to * WordPress, which blocks it from being re-registered or From 0b19ed25065e4211ad9ded874d256d3b43875b80 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 12 Aug 2026 17:15:43 -0700 Subject: [PATCH 07/11] Comments: Pin the `render_callback` div style and built-in override paths. The suite covered the default `
    • ` style only, so nothing would catch a change that broke the pairing between a callback's opening tag and `end_el()`'s closing one under `style => 'div'`. Also pin the filter-based override of a built-in type. That path falls out of where `set_props()` applies the registration args filters, and is now documented as intended, so it needs a test to keep it from being tightened away as an oversight. --- tests/phpunit/tests/comment/walker.php | 74 ++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/tests/phpunit/tests/comment/walker.php b/tests/phpunit/tests/comment/walker.php index 61c995038b956..30828517003b3 100644 --- a/tests/phpunit/tests/comment/walker.php +++ b/tests/phpunit/tests/comment/walker.php @@ -107,6 +107,80 @@ public function test_render_callback_renders_registered_comment_type() { $this->assertStringContainsString( '
    • Rendered by callback
    • ', $output ); } + /** + * end_el() closes a `
      ` under the 'div' style, so a callback that always opened an + * `
    • ` would produce mismatched markup. The callback owns that choice. + * + * @ticket 35214 + */ + public function test_render_callback_pairs_with_the_div_style() { + register_comment_type( + 'review', + array( + 'render_callback' => static function ( $comment, $args ) { + $tag = ( 'div' === $args['style'] ) ? 'div' : 'li'; + + echo '<' . $tag . ' class="review">' . 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 ) ), array( 'style' => 'div' ) ); + + $this->assertStringContainsString( + '
      Rendered by callback
      ', + $output + ); + } + + /** + * Built-in types register without a callback and cannot be re-registered, but the + * registration args filter can still set one. That is the sanctioned override path, + * so pin it rather than leave it as an accident of the filter's placement. + * + * @ticket 35214 + */ + public function test_render_callback_can_be_added_to_a_built_in_type_by_filter() { + add_filter( + 'register_comment_type_args', + static function ( $args, $comment_type ) { + if ( 'comment' === $comment_type ) { + $args['render_callback'] = static function () { + echo '
    • '; + }; + } + + return $args; + }, + 10, + 2 + ); + + // Rebuild the built-ins so the filter applies to them. + create_initial_comment_types(); + + $comment_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $this->post_id, + 'comment_type' => 'comment', + 'comment_approved' => '1', + ) + ); + + $output = $this->render_comments( array( get_comment( $comment_id ) ) ); + + $this->assertStringContainsString( '
    • ', $output ); + } + /** * An explicit wp_list_comments() callback takes precedence over a type's render_callback. * From ebd2e8c824094df2a89474fc74c89cbe6821f893 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Sun, 23 Aug 2026 08:37:16 -0700 Subject: [PATCH 08/11] Comments: Stamp the render_callback for 7.2.0 and pin its echo contract. The branch was written while trunk was 7.1-alpha; trunk is now 7.2-alpha, so the render_callback property and the Walker_Comment behavior-change note carried @since tags for a release that never contained them - the only two stragglers after the base branch re-stamped itself. Also pin the one documented clause of the callback contract that had no test: a returned string is discarded, output must be echoed. A refactor of the ob_start()/call_user_func() dispatch could otherwise start honoring return values with the suite staying green. See #35214. --- src/wp-includes/class-walker-comment.php | 2 +- src/wp-includes/class-wp-comment-type.php | 2 +- tests/phpunit/tests/comment/walker.php | 31 +++++++++++++++++++++++ 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/class-walker-comment.php b/src/wp-includes/class-walker-comment.php index 15feb8a8a2329..23c5af1bc69c0 100644 --- a/src/wp-includes/class-walker-comment.php +++ b/src/wp-includes/class-walker-comment.php @@ -158,7 +158,7 @@ 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` + * @since 7.2.0 Comments of a registered comment type with a `render_callback` * are rendered via that callback. * * @see Walker::start_el() diff --git a/src/wp-includes/class-wp-comment-type.php b/src/wp-includes/class-wp-comment-type.php index ebb491908b9da..adf691bd6b475 100644 --- a/src/wp-includes/class-wp-comment-type.php +++ b/src/wp-includes/class-wp-comment-type.php @@ -136,7 +136,7 @@ final class WP_Comment_Type { * * Default null. * - * @since 7.1.0 + * @since 7.2.0 * @var callable|null */ public $render_callback = null; diff --git a/tests/phpunit/tests/comment/walker.php b/tests/phpunit/tests/comment/walker.php index 30828517003b3..dfe915a0879f9 100644 --- a/tests/phpunit/tests/comment/walker.php +++ b/tests/phpunit/tests/comment/walker.php @@ -371,6 +371,37 @@ public function test_non_callable_render_callback_is_ignored() { $this->assertStringContainsString( 'Falls back to normal rendering', $output ); } + + /** + * A render_callback must echo its output; a returned string is discarded, per the + * documented contract. Pinned so the callback path never quietly starts honoring + * return values. + * + * @ticket 35214 + */ + public function test_render_callback_return_value_is_discarded() { + register_comment_type( + 'review', + array( + 'render_callback' => static function () { + return '
    • RETURNED_MARKER'; + }, + ) + ); + + $comment_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $this->post_id, + 'comment_type' => 'review', + 'comment_content' => 'Return value test', + 'comment_approved' => '1', + ) + ); + + $output = $this->render_comments( array( get_comment( $comment_id ) ) ); + + $this->assertStringNotContainsString( 'RETURNED_MARKER', $output ); + } } class Comment_Callback_Test_Helper { From 57be7b9579ea261bd03c4c9e3cfe5ba2a666f722 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Sun, 8 Nov 2026 09:22:15 -0500 Subject: [PATCH 09/11] Comments: Tighten render_callback inline documentation. The property, argument, and walker comments repeated the same contract several times and read as design notes. Trimming them to what a caller needs (arguments, precedence, open-tag and echo rules, escaping, the 'comment' type caveat) keeps the docs scannable and accurate. See #35214. --- src/wp-includes/class-walker-comment.php | 5 +-- src/wp-includes/class-wp-comment-type.php | 48 ++++++----------------- src/wp-includes/comment.php | 20 ++++------ 3 files changed, 21 insertions(+), 52 deletions(-) diff --git a/src/wp-includes/class-walker-comment.php b/src/wp-includes/class-walker-comment.php index 23c5af1bc69c0..751cb27b424c3 100644 --- a/src/wp-includes/class-walker-comment.php +++ b/src/wp-includes/class-walker-comment.php @@ -187,10 +187,7 @@ 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. - */ + // A registered comment type's render callback, after an explicit `callback` argument. $comment_type_object = get_comment_type_object( $comment->comment_type ); if ( $comment_type_object && is_callable( $comment_type_object->render_callback ) ) { diff --git a/src/wp-includes/class-wp-comment-type.php b/src/wp-includes/class-wp-comment-type.php index 5a567b695564d..165c6194a0036 100644 --- a/src/wp-includes/class-wp-comment-type.php +++ b/src/wp-includes/class-wp-comment-type.php @@ -100,41 +100,19 @@ 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. 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; {@see Walker_Comment::end_el()} - * (or the `end-callback` argument) closes the element after any child - * comments have been rendered. Which element that is depends on the `style` - * argument, so the callback has to open a `
      ` when `$args['style']` is - * 'div' and an `
    • ` otherwise, the way {@see Walker_Comment::comment()} - * does. Opening the wrong one leaves the markup mismatched. - * - * The callback must echo its output. Unlike the `render_callback` argument - * of register_block_type(), a returned string is discarded. - * - * Output from the callback is printed unescaped; the callback is - * responsible for escaping all output. - * - * The built-in comment types register without a callback and cannot be - * re-registered, but setting one on them through the - * {@see 'register_comment_type_args'} filter is supported. It grants no more - * than the `callback` argument of wp_list_comments() already does. Note that - * a callback on the 'comment' type also takes over the walker's handling of - * unapproved comments, which strips links from a pending comment's text for - * everyone but its author. - * - * 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. + * Callback used to render a comment of this type in wp_list_comments(). + * + * Receives the same arguments as the wp_list_comments() `callback` argument, + * which takes precedence over it. Like that callback, it must echo only the + * opening of the element (a `
      ` when `$args['style']` is 'div', an `
    • ` + * otherwise), which Walker_Comment::end_el() closes. A returned string is + * discarded, and output is not escaped. + * + * Setting a callback on the 'comment' type through the + * {@see 'register_comment_type_args'} filter also replaces the walker's link + * stripping for pending comments. + * + * Not used by the `core/comment-template` block. Default null. * * @since 7.2.0 * @var callable|null diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index 8d51f6997eee4..deb436e206854 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -448,19 +448,13 @@ function create_initial_comment_types(): void { * @type bool $internal Whether the comment type is for internal use only. Internal * types are meant to be excluded from comment queries and * counts by default. Core does not currently act on this - * argument. - * Default false. - * @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) and, like it, must echo only the opening of the list - * element - a `
      ` when the `style` argument is 'div' and an - * `
    • ` otherwise; Walker_Comment::end_el() closes the element. - * A returned string is discarded. 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. + * argument. Default false. + * @type callable $render_callback Callback used to render a comment of this type in + * wp_list_comments(). Receives the same arguments as, and is + * overridden by, the wp_list_comments() `callback` argument. + * Must echo only the opening `
    • ` (or `
      ` for the 'div' + * style), unescaped; a returned string is discarded. + * Default null. * @type bool $_builtin For internal core use only. Marks the type as native to * WordPress, which blocks it from being re-registered or * unregistered. Default false. From fdf5edf10a3acc50177e0787a8eb4ce4d20123c2 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Sun, 8 Nov 2026 09:22:15 -0500 Subject: [PATCH 10/11] Tests: Add native types to the render_callback tests. New tests should declare return types, and the render_comments() helper casts wp_list_comments() output so its string return type holds. Test docblocks are trimmed to state what each test asserts. See #35214. --- tests/phpunit/tests/comment/walker.php | 44 ++++++++----------- tests/phpunit/tests/comment/wpCommentType.php | 2 +- 2 files changed, 19 insertions(+), 27 deletions(-) diff --git a/tests/phpunit/tests/comment/walker.php b/tests/phpunit/tests/comment/walker.php index dfe915a0879f9..e68472ca62663 100644 --- a/tests/phpunit/tests/comment/walker.php +++ b/tests/phpunit/tests/comment/walker.php @@ -58,13 +58,13 @@ 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. + * @param WP_Comment[] $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() ) { - return wp_list_comments( + private function render_comments( array $comments, array $args = array() ): string { + return (string) wp_list_comments( array_merge( array( 'echo' => false, @@ -81,7 +81,7 @@ private function render_comments( $comments, $args = array() ) { * * @ticket 35214 */ - public function test_render_callback_renders_registered_comment_type() { + public function test_render_callback_renders_registered_comment_type(): void { register_comment_type( 'review', array( @@ -108,12 +108,11 @@ public function test_render_callback_renders_registered_comment_type() { } /** - * end_el() closes a `
      ` under the 'div' style, so a callback that always opened an - * `
    • ` would produce mismatched markup. The callback owns that choice. + * A render_callback can open a `
      ` that end_el() closes under the 'div' style. * * @ticket 35214 */ - public function test_render_callback_pairs_with_the_div_style() { + public function test_render_callback_pairs_with_the_div_style(): void { register_comment_type( 'review', array( @@ -143,13 +142,11 @@ public function test_render_callback_pairs_with_the_div_style() { } /** - * Built-in types register without a callback and cannot be re-registered, but the - * registration args filter can still set one. That is the sanctioned override path, - * so pin it rather than leave it as an accident of the filter's placement. + * The register_comment_type_args filter can add a render_callback to a built-in type. * * @ticket 35214 */ - public function test_render_callback_can_be_added_to_a_built_in_type_by_filter() { + public function test_render_callback_can_be_added_to_a_built_in_type_by_filter(): void { add_filter( 'register_comment_type_args', static function ( $args, $comment_type ) { @@ -186,7 +183,7 @@ static function ( $args, $comment_type ) { * * @ticket 35214 */ - public function test_explicit_callback_takes_precedence_over_render_callback() { + public function test_explicit_callback_takes_precedence_over_render_callback(): void { register_comment_type( 'review', array( @@ -222,7 +219,7 @@ public function test_explicit_callback_takes_precedence_over_render_callback() { * * @ticket 35214 */ - public function test_comment_type_without_render_callback_renders_normally() { + public function test_comment_type_without_render_callback_renders_normally(): void { $comment_id = self::factory()->comment->create( array( 'comment_post_ID' => $this->post_id, @@ -243,7 +240,7 @@ public function test_comment_type_without_render_callback_renders_normally() { * * @ticket 35214 */ - public function test_render_callback_receives_comment_args_and_depth() { + public function test_render_callback_receives_comment_args_and_depth(): void { $received = array(); register_comment_type( @@ -279,12 +276,9 @@ public function test_render_callback_receives_comment_args_and_depth() { /** * 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() { + public function test_child_comment_renders_within_render_callback_parent_element(): void { register_comment_type( 'review', array( @@ -329,7 +323,7 @@ public function test_child_comment_renders_within_render_callback_parent_element * * @ticket 35214 */ - public function test_legacy_empty_type_renders_default_markup() { + public function test_legacy_empty_type_renders_default_markup(): void { $comment_id = self::factory()->comment->create( array( 'comment_post_ID' => $this->post_id, @@ -350,7 +344,7 @@ public function test_legacy_empty_type_renders_default_markup() { * * @ticket 35214 */ - public function test_non_callable_render_callback_is_ignored() { + public function test_non_callable_render_callback_is_ignored(): void { register_comment_type( 'review', array( @@ -373,13 +367,11 @@ public function test_non_callable_render_callback_is_ignored() { } /** - * A render_callback must echo its output; a returned string is discarded, per the - * documented contract. Pinned so the callback path never quietly starts honoring - * return values. + * A string returned by a render_callback is discarded. * * @ticket 35214 */ - public function test_render_callback_return_value_is_discarded() { + public function test_render_callback_return_value_is_discarded(): void { register_comment_type( 'review', array( diff --git a/tests/phpunit/tests/comment/wpCommentType.php b/tests/phpunit/tests/comment/wpCommentType.php index d225f77ee6a87..4fc4947c49bb8 100644 --- a/tests/phpunit/tests/comment/wpCommentType.php +++ b/tests/phpunit/tests/comment/wpCommentType.php @@ -31,7 +31,7 @@ public function test_instance_defaults(): void { * * @covers ::set_props */ - public function test_render_callback_is_stored() { + public function test_render_callback_is_stored(): void { $callback = static function () {}; $comment_type = new WP_Comment_Type( 'foo', array( 'render_callback' => $callback ) ); From c0f5198a9aa0d0abc78d0636038e1b783037b967 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Sun, 8 Nov 2026 09:31:43 -0500 Subject: [PATCH 11/11] 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-includes/class-walker-comment.php | 2 +- src/wp-includes/class-wp-comment-type.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/class-walker-comment.php b/src/wp-includes/class-walker-comment.php index 751cb27b424c3..36517490fe6cc 100644 --- a/src/wp-includes/class-walker-comment.php +++ b/src/wp-includes/class-walker-comment.php @@ -158,7 +158,7 @@ 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.2.0 Comments of a registered comment type with a `render_callback` + * @since 7.3.0 Comments of a registered comment type with a `render_callback` * are rendered via that callback. * * @see Walker::start_el() diff --git a/src/wp-includes/class-wp-comment-type.php b/src/wp-includes/class-wp-comment-type.php index c870b090d814c..ec682b72a67db 100644 --- a/src/wp-includes/class-wp-comment-type.php +++ b/src/wp-includes/class-wp-comment-type.php @@ -110,7 +110,7 @@ final class WP_Comment_Type { * * Not used by the `core/comment-template` block. Default null. * - * @since 7.2.0 + * @since 7.3.0 * @var callable|null */ public $render_callback = null;