From 32e09324c779981a199bda9883e2a03dae738c1b Mon Sep 17 00:00:00 2001
From: Riad Benguella
Date: Sun, 5 Jul 2026 17:33:39 +0200
Subject: [PATCH 1/8] Comments: Allow changing a comment's parent from the Edit
Comment screen.
Adds an editable 'In reply to' control to the Edit Comment screen, following
the pattern of the 'Submitted on' toggle: an Edit link reveals a select of
the other comments on the same post (author and trimmed excerpt), excluding
the comment itself and its descendants.
edit_comment() now reads comment_parent from the submitted form and
validates it before updating: the parent must be another comment on the
same post, and cannot be the comment itself or one of its descendants,
preventing threading loops.
Trac ticket: https://core.trac.wordpress.org/ticket/65570
Co-Authored-By: Claude Fable 5
---
src/js/_enqueues/admin/comment.js | 70 ++++++
src/wp-admin/css/edit.css | 10 +
src/wp-admin/edit-form-comment.php | 137 ++++++++++--
src/wp-admin/includes/comment.php | 38 ++++
.../includes/comment/EditComment_Test.php | 199 ++++++++++++++++++
5 files changed, 438 insertions(+), 16 deletions(-)
create mode 100644 tests/phpunit/tests/admin/includes/comment/EditComment_Test.php
diff --git a/src/js/_enqueues/admin/comment.js b/src/js/_enqueues/admin/comment.js
index 188caab2c30c5..738bc4dd1e63a 100644
--- a/src/js/_enqueues/admin/comment.js
+++ b/src/js/_enqueues/admin/comment.js
@@ -99,4 +99,74 @@ jQuery( function($) {
$edittimestamp.show().trigger( 'focus' );
$timestampdiv.slideUp( 'fast' );
});
+
+ var $commentparentdiv = $( '#comment-parent-div' ),
+ $commentparentdisplay = $( '#comment-parent-display' ),
+ commentparent = $commentparentdisplay.html(),
+ $editcommentparent = $commentparentdiv.siblings( 'a.edit-comment-parent' );
+
+ /**
+ * Adds event that opens the parent comment form if the form is hidden.
+ *
+ * @since 7.1.0
+ *
+ * @listens $editcommentparent:click
+ *
+ * @param {Event} event The event object.
+ * @return {void}
+ */
+ $editcommentparent.on( 'click', function( event ) {
+ if ( $commentparentdiv.is( ':hidden' ) ) {
+ // Slide down the form and set focus on the parent field.
+ $commentparentdiv.slideDown( 'fast', function() {
+ $( '#comment_parent' ).trigger( 'focus' );
+ } );
+ $(this).hide();
+ }
+ event.preventDefault();
+ });
+
+ /**
+ * Resets the parent comment value when the cancel button is clicked.
+ *
+ * @since 7.1.0
+ *
+ * @listens .cancel-comment-parent:click
+ *
+ * @param {Event} event The event object.
+ * @return {void}
+ */
+ $commentparentdiv.find( '.cancel-comment-parent' ).on( 'click', function( event ) {
+ // Move focus back to the Edit link.
+ $editcommentparent.show().trigger( 'focus' );
+ $commentparentdiv.slideUp( 'fast' );
+ $( '#comment_parent' ).val( $( '#hidden_comment_parent' ).val() );
+ $commentparentdisplay.html( commentparent );
+ event.preventDefault();
+ });
+
+ /**
+ * Updates the parent comment display when the ok button is clicked.
+ *
+ * @since 7.1.0
+ *
+ * @listens .save-comment-parent:click
+ *
+ * @param {Event} event The event object.
+ * @return {void}
+ */
+ $commentparentdiv.find( '.save-comment-parent' ).on( 'click', function( event ) {
+ var $selected = $( '#comment_parent option:selected' ),
+ parentLabel = $selected.data( 'author' ) || $selected.text();
+
+ event.preventDefault();
+
+ $commentparentdisplay
+ .text( wp.i18n.__( 'In reply to:' ) + ' ' )
+ .append( $( '' ).text( parentLabel ) );
+
+ // Move focus back to the Edit link.
+ $editcommentparent.show().trigger( 'focus' );
+ $commentparentdiv.slideUp( 'fast' );
+ });
});
diff --git a/src/wp-admin/css/edit.css b/src/wp-admin/css/edit.css
index 563151a3cdda6..e96cf4692fc03 100644
--- a/src/wp-admin/css/edit.css
+++ b/src/wp-admin/css/edit.css
@@ -554,6 +554,16 @@ form#tags-filter {
line-height: 1.76923076;
}
+#comment-parent-div {
+ padding-top: 5px;
+ /* Fieldsets cannot shrink below their content by default, allow it so the select cannot cause overflow. */
+ min-width: 0;
+}
+
+#comment-parent-div select {
+ width: 100%;
+}
+
#timestampdiv p {
margin: 8px 0 6px;
}
diff --git a/src/wp-admin/edit-form-comment.php b/src/wp-admin/edit-form-comment.php
index e0bbc9f657a73..c6bca346f744e 100644
--- a/src/wp-admin/edit-form-comment.php
+++ b/src/wp-admin/edit-form-comment.php
@@ -201,25 +201,130 @@
comment_parent ) :
+$parent_display = __( 'None' );
+
+if ( $comment->comment_parent ) {
$parent = get_comment( $comment->comment_parent );
- if ( $parent ) :
- $parent_link = esc_url( get_comment_link( $parent ) );
- $name = get_comment_author( $parent );
- ?>
-
- ' . $name . '';
+ }
+}
+
+$post_comments = get_comments(
+ array(
+ 'post_id' => $comment->comment_post_ID,
+ 'type' => 'comment',
+ 'status' => 'all',
+ 'orderby' => 'comment_date_gmt',
+ 'order' => 'ASC',
+ )
+);
+
+// Group comments by parent to find this comment's descendants, which cannot become its parent.
+$comments_by_parent = array();
+
+foreach ( $post_comments as $post_comment ) {
+ $comments_by_parent[ (int) $post_comment->comment_parent ][] = (int) $post_comment->comment_ID;
+}
+
+$comment_descendants = array();
+$comment_queue = array( $comment->comment_ID );
+
+while ( $comment_queue ) {
+ $descendant_parent = array_shift( $comment_queue );
+
+ if ( isset( $comments_by_parent[ $descendant_parent ] ) ) {
+ foreach ( $comments_by_parent[ $descendant_parent ] as $descendant_id ) {
+ $comment_descendants[ $descendant_id ] = true;
+ $comment_queue[] = $descendant_id;
+ }
+ }
+}
+?>
+
+
+
+
-
+ }
+
+ // The current parent may not be listed, e.g. a pingback or a comment no longer publicly visible.
+ if ( $comment->comment_parent && ! $current_parent_listed && $parent ) {
+ $option_label = sprintf(
+ /* translators: 1: Comment author, 2: Comment excerpt. */
+ __( '%1$s: %2$s' ),
+ get_comment_author( $parent ),
+ get_comment_excerpt( $parent )
+ );
+
+ printf(
+ "\t
\n",
+ (int) $comment->comment_parent,
+ esc_attr( get_comment_author( $parent ) ),
+ esc_html( $option_label )
+ );
+ }
+ ?>
+
+
+
+
+
+
+
+
comment_parent ) {
+ if ( $comment_parent === $comment_id ) {
+ return new WP_Error( 'comment_parent_invalid', __( 'A comment cannot be a reply to itself.' ) );
+ }
+
+ if ( $comment_parent ) {
+ $parent = get_comment( $comment_parent );
+
+ if ( ! $parent || (int) $parent->comment_post_ID !== (int) $comment->comment_post_ID ) {
+ return new WP_Error( 'comment_parent_invalid', __( 'The parent must be another comment on the same post.' ) );
+ }
+
+ // Walk up the new parent's ancestors to prevent creating a threading loop.
+ $ancestors = array();
+ $ancestor = $parent;
+
+ while ( $ancestor && $ancestor->comment_parent && ! isset( $ancestors[ $ancestor->comment_ID ] ) ) {
+ if ( (int) $ancestor->comment_parent === $comment_id ) {
+ return new WP_Error( 'comment_parent_invalid', __( 'A comment cannot be a reply to one of its own replies.' ) );
+ }
+
+ $ancestors[ $ancestor->comment_ID ] = true;
+
+ $ancestor = get_comment( $ancestor->comment_parent );
+ }
+ }
+ }
+ }
+
foreach ( array( 'aa', 'mm', 'jj', 'hh', 'mn' ) as $timeunit ) {
if ( ! empty( $_POST[ 'hidden_' . $timeunit ] ) && $_POST[ 'hidden_' . $timeunit ] !== $_POST[ $timeunit ] ) {
$_POST['edit_date'] = '1';
diff --git a/tests/phpunit/tests/admin/includes/comment/EditComment_Test.php b/tests/phpunit/tests/admin/includes/comment/EditComment_Test.php
new file mode 100644
index 0000000000000..36436101dcdff
--- /dev/null
+++ b/tests/phpunit/tests/admin/includes/comment/EditComment_Test.php
@@ -0,0 +1,199 @@
+user->create( array( 'role' => 'administrator' ) );
+ self::$post_id = $factory->post->create();
+ self::$other_post_id = $factory->post->create();
+ }
+
+ public function set_up() {
+ parent::set_up();
+
+ wp_set_current_user( self::$admin_id );
+ }
+
+ public function tear_down() {
+ $_POST = array();
+
+ parent::tear_down();
+ }
+
+ /**
+ * Calls edit_comment() with a comment ID and a new parent, as submitted from the Edit Comment screen.
+ *
+ * @param int $comment_id Comment ID.
+ * @param int $comment_parent New parent comment ID.
+ * @return int|WP_Error The edit_comment() return value.
+ */
+ private function update_comment_parent( $comment_id, $comment_parent ) {
+ $_POST = array(
+ 'comment_ID' => $comment_id,
+ 'comment_parent' => $comment_parent,
+ );
+
+ return edit_comment();
+ }
+
+ /**
+ * @ticket 65570
+ */
+ public function test_should_update_comment_parent() {
+ $parent_id = self::factory()->comment->create( array( 'comment_post_ID' => self::$post_id ) );
+ $comment_id = self::factory()->comment->create( array( 'comment_post_ID' => self::$post_id ) );
+
+ $result = $this->update_comment_parent( $comment_id, $parent_id );
+
+ $this->assertSame( 1, $result );
+ $this->assertSame( (string) $parent_id, get_comment( $comment_id )->comment_parent );
+ }
+
+ /**
+ * @ticket 65570
+ */
+ public function test_should_allow_clearing_comment_parent() {
+ $parent_id = self::factory()->comment->create( array( 'comment_post_ID' => self::$post_id ) );
+ $comment_id = self::factory()->comment->create(
+ array(
+ 'comment_post_ID' => self::$post_id,
+ 'comment_parent' => $parent_id,
+ )
+ );
+
+ $result = $this->update_comment_parent( $comment_id, 0 );
+
+ $this->assertSame( 1, $result );
+ $this->assertSame( '0', get_comment( $comment_id )->comment_parent );
+ }
+
+ /**
+ * @ticket 65570
+ */
+ public function test_should_reject_parent_on_a_different_post() {
+ $parent_id = self::factory()->comment->create( array( 'comment_post_ID' => self::$other_post_id ) );
+ $comment_id = self::factory()->comment->create( array( 'comment_post_ID' => self::$post_id ) );
+
+ $result = $this->update_comment_parent( $comment_id, $parent_id );
+
+ $this->assertWPError( $result );
+ $this->assertSame( 'comment_parent_invalid', $result->get_error_code() );
+ $this->assertSame( '0', get_comment( $comment_id )->comment_parent );
+ }
+
+ /**
+ * @ticket 65570
+ */
+ public function test_should_reject_nonexistent_parent() {
+ $comment_id = self::factory()->comment->create( array( 'comment_post_ID' => self::$post_id ) );
+
+ $result = $this->update_comment_parent( $comment_id, $comment_id + 1000 );
+
+ $this->assertWPError( $result );
+ $this->assertSame( 'comment_parent_invalid', $result->get_error_code() );
+ }
+
+ /**
+ * @ticket 65570
+ */
+ public function test_should_reject_comment_as_its_own_parent() {
+ $comment_id = self::factory()->comment->create( array( 'comment_post_ID' => self::$post_id ) );
+
+ $result = $this->update_comment_parent( $comment_id, $comment_id );
+
+ $this->assertWPError( $result );
+ $this->assertSame( 'comment_parent_invalid', $result->get_error_code() );
+ }
+
+ /**
+ * @ticket 65570
+ */
+ public function test_should_reject_child_as_parent() {
+ $comment_id = self::factory()->comment->create( array( 'comment_post_ID' => self::$post_id ) );
+ $child_id = self::factory()->comment->create(
+ array(
+ 'comment_post_ID' => self::$post_id,
+ 'comment_parent' => $comment_id,
+ )
+ );
+
+ $result = $this->update_comment_parent( $comment_id, $child_id );
+
+ $this->assertWPError( $result );
+ $this->assertSame( 'comment_parent_invalid', $result->get_error_code() );
+ }
+
+ /**
+ * @ticket 65570
+ */
+ public function test_should_reject_descendant_as_parent() {
+ $comment_id = self::factory()->comment->create( array( 'comment_post_ID' => self::$post_id ) );
+ $child_id = self::factory()->comment->create(
+ array(
+ 'comment_post_ID' => self::$post_id,
+ 'comment_parent' => $comment_id,
+ )
+ );
+ $grandchild_id = self::factory()->comment->create(
+ array(
+ 'comment_post_ID' => self::$post_id,
+ 'comment_parent' => $child_id,
+ )
+ );
+
+ $result = $this->update_comment_parent( $comment_id, $grandchild_id );
+
+ $this->assertWPError( $result );
+ $this->assertSame( 'comment_parent_invalid', $result->get_error_code() );
+ }
+
+ /**
+ * @ticket 65570
+ */
+ public function test_should_allow_resubmitting_unchanged_parent() {
+ $parent_id = self::factory()->comment->create( array( 'comment_post_ID' => self::$post_id ) );
+ $comment_id = self::factory()->comment->create(
+ array(
+ 'comment_post_ID' => self::$post_id,
+ 'comment_parent' => $parent_id,
+ )
+ );
+
+ $result = $this->update_comment_parent( $comment_id, $parent_id );
+
+ $this->assertNotWPError( $result );
+ $this->assertSame( (string) $parent_id, get_comment( $comment_id )->comment_parent );
+ }
+}
From 4a909819ea830ea8788fec016f28dcf4b47eb4a7 Mon Sep 17 00:00:00 2001
From: Riad Benguella
Date: Mon, 6 Jul 2026 10:56:06 +0200
Subject: [PATCH 2/8] Comments: Address review feedback on the parent comment
picker.
- Escape the comment author name in the 'In reply to' link.
- Cast comment_ID to int for the strict comparisons, since the raw
WP_Comment property is a numeric string.
- Use isset() for the unlisted-parent fallback check to aid static analysis.
- Use single-quoted attributes in the option printf format strings.
- Use native typed properties in the new test class.
Co-Authored-By: Claude Fable 5
---
src/wp-admin/edit-form-comment.php | 18 ++++++++++--------
.../includes/comment/EditComment_Test.php | 12 +++---------
2 files changed, 13 insertions(+), 17 deletions(-)
diff --git a/src/wp-admin/edit-form-comment.php b/src/wp-admin/edit-form-comment.php
index c6bca346f744e..3dd36994b9778 100644
--- a/src/wp-admin/edit-form-comment.php
+++ b/src/wp-admin/edit-form-comment.php
@@ -207,9 +207,11 @@
$parent = get_comment( $comment->comment_parent );
if ( $parent ) {
- $parent_link = esc_url( get_comment_link( $parent ) );
- $name = get_comment_author( $parent );
- $parent_display = '' . $name . '';
+ $parent_display = sprintf(
+ '%s',
+ esc_url( get_comment_link( $parent ) ),
+ esc_html( get_comment_author( $parent ) )
+ );
}
}
@@ -231,7 +233,7 @@
}
$comment_descendants = array();
-$comment_queue = array( $comment->comment_ID );
+$comment_queue = array( (int) $comment->comment_ID );
while ( $comment_queue ) {
$descendant_parent = array_shift( $comment_queue );
@@ -276,7 +278,7 @@
foreach ( $post_comments as $post_comment ) {
$post_comment_id = (int) $post_comment->comment_ID;
- if ( $post_comment_id === $comment->comment_ID || isset( $comment_descendants[ $post_comment_id ] ) ) {
+ if ( $post_comment_id === (int) $comment->comment_ID || isset( $comment_descendants[ $post_comment_id ] ) ) {
continue;
}
@@ -292,7 +294,7 @@
);
printf(
- "\t\n",
+ "\t\n",
$post_comment_id,
esc_attr( get_comment_author( $post_comment ) ),
selected( $post_comment_id, (int) $comment->comment_parent, false ),
@@ -301,7 +303,7 @@
}
// The current parent may not be listed, e.g. a pingback or a comment no longer publicly visible.
- if ( $comment->comment_parent && ! $current_parent_listed && $parent ) {
+ if ( $comment->comment_parent && ! $current_parent_listed && isset( $parent ) ) {
$option_label = sprintf(
/* translators: 1: Comment author, 2: Comment excerpt. */
__( '%1$s: %2$s' ),
@@ -310,7 +312,7 @@
);
printf(
- "\t\n",
+ "\t\n",
(int) $comment->comment_parent,
esc_attr( get_comment_author( $parent ) ),
esc_html( $option_label )
diff --git a/tests/phpunit/tests/admin/includes/comment/EditComment_Test.php b/tests/phpunit/tests/admin/includes/comment/EditComment_Test.php
index 36436101dcdff..75ba89bb3a8db 100644
--- a/tests/phpunit/tests/admin/includes/comment/EditComment_Test.php
+++ b/tests/phpunit/tests/admin/includes/comment/EditComment_Test.php
@@ -10,24 +10,18 @@ class Admin_Includes_Comment_EditComment_Test extends WP_UnitTestCase {
/**
* Admin user ID.
- *
- * @var int
*/
- public static $admin_id;
+ public static int $admin_id;
/**
* Post ID to add comments to.
- *
- * @var int
*/
- public static $post_id;
+ public static int $post_id;
/**
* Another post ID, to test cross-post re-parenting.
- *
- * @var int
*/
- public static $other_post_id;
+ public static int $other_post_id;
/**
* Create the user and posts for the tests.
From e93b9248607e7442aa3136781a19cfc161766740 Mon Sep 17 00:00:00 2001
From: Riad Benguella
Date: Tue, 7 Jul 2026 11:12:30 +0200
Subject: [PATCH 3/8] Address review feedback on the comment parent editing UI.
- Add a translators note for the 'None (top-level comment)' option.
- Rename the original parent display variable in comment.js.
- Un-nest the parent validation logic in edit_comment().
- Only allow changing the parent when threaded comments are enabled, and enforce the maximum threading depth.
- Require the parent to be a comment of the same type, not spam or trashed.
- Exclude spam and trashed comments from the parent dropdown and limit it to the 100 most recent comments.
- Use wp.i18n.sprintf() to build the parent display string.
- Add tests covering the new validations.
Co-Authored-By: Claude Fable 5
---
src/js/_enqueues/admin/comment.js | 14 ++-
src/wp-admin/edit-form-comment.php | 91 ++++++++++----
src/wp-admin/includes/comment.php | 48 +++++---
.../includes/comment/EditComment_Test.php | 112 ++++++++++++++++++
4 files changed, 222 insertions(+), 43 deletions(-)
diff --git a/src/js/_enqueues/admin/comment.js b/src/js/_enqueues/admin/comment.js
index 738bc4dd1e63a..f2c4aa3bd36a5 100644
--- a/src/js/_enqueues/admin/comment.js
+++ b/src/js/_enqueues/admin/comment.js
@@ -102,7 +102,7 @@ jQuery( function($) {
var $commentparentdiv = $( '#comment-parent-div' ),
$commentparentdisplay = $( '#comment-parent-display' ),
- commentparent = $commentparentdisplay.html(),
+ originalcommentparentdisplay = $commentparentdisplay.html(),
$editcommentparent = $commentparentdiv.siblings( 'a.edit-comment-parent' );
/**
@@ -141,7 +141,7 @@ jQuery( function($) {
$editcommentparent.show().trigger( 'focus' );
$commentparentdiv.slideUp( 'fast' );
$( '#comment_parent' ).val( $( '#hidden_comment_parent' ).val() );
- $commentparentdisplay.html( commentparent );
+ $commentparentdisplay.html( originalcommentparentdisplay );
event.preventDefault();
});
@@ -161,9 +161,13 @@ jQuery( function($) {
event.preventDefault();
- $commentparentdisplay
- .text( wp.i18n.__( 'In reply to:' ) + ' ' )
- .append( $( '' ).text( parentLabel ) );
+ $commentparentdisplay.html(
+ wp.i18n.sprintf(
+ /* translators: %s: Parent comment author. */
+ wp.i18n.__( 'In reply to: %s' ),
+ $( '' ).text( parentLabel ).prop( 'outerHTML' )
+ )
+ );
// Move focus back to the Edit link.
$editcommentparent.show().trigger( 'focus' );
diff --git a/src/wp-admin/edit-form-comment.php b/src/wp-admin/edit-form-comment.php
index 3dd36994b9778..8f028efd6b400 100644
--- a/src/wp-admin/edit-form-comment.php
+++ b/src/wp-admin/edit-form-comment.php
@@ -215,34 +215,67 @@
}
}
-$post_comments = get_comments(
- array(
- 'post_id' => $comment->comment_post_ID,
- 'type' => 'comment',
- 'status' => 'all',
- 'orderby' => 'comment_date_gmt',
- 'order' => 'ASC',
- )
-);
+// The parent can only be changed when threaded comments are enabled.
+$comment_threading_enabled = get_option( 'thread_comments' );
-// Group comments by parent to find this comment's descendants, which cannot become its parent.
-$comments_by_parent = array();
+if ( $comment_threading_enabled ) {
+ $max_thread_depth = (int) get_option( 'thread_comments_depth' );
-foreach ( $post_comments as $post_comment ) {
- $comments_by_parent[ (int) $post_comment->comment_parent ][] = (int) $post_comment->comment_ID;
-}
+ // Limit the number of comments to keep memory usage and the size of the dropdown reasonable on busy posts.
+ $post_comments = get_comments(
+ array(
+ 'post_id' => $comment->comment_post_ID,
+ 'type' => 'comment',
+ 'status' => array( 'approve', 'hold' ),
+ 'orderby' => 'comment_date_gmt',
+ 'order' => 'DESC',
+ 'number' => 100,
+ )
+ );
+
+ // Restore chronological order for display.
+ $post_comments = array_reverse( $post_comments );
+
+ $post_comments_by_id = array();
-$comment_descendants = array();
-$comment_queue = array( (int) $comment->comment_ID );
+ foreach ( $post_comments as $post_comment ) {
+ $post_comments_by_id[ (int) $post_comment->comment_ID ] = $post_comment;
+ }
+
+ // Group comments by parent to find this comment's descendants, which cannot become its parent.
+ $comments_by_parent = array();
+
+ foreach ( $post_comments as $post_comment ) {
+ $comments_by_parent[ (int) $post_comment->comment_parent ][] = (int) $post_comment->comment_ID;
+ }
-while ( $comment_queue ) {
- $descendant_parent = array_shift( $comment_queue );
+ $comment_descendants = array();
+ $comment_queue = array( (int) $comment->comment_ID );
- if ( isset( $comments_by_parent[ $descendant_parent ] ) ) {
- foreach ( $comments_by_parent[ $descendant_parent ] as $descendant_id ) {
- $comment_descendants[ $descendant_id ] = true;
- $comment_queue[] = $descendant_id;
+ while ( $comment_queue ) {
+ $descendant_parent = array_shift( $comment_queue );
+
+ if ( isset( $comments_by_parent[ $descendant_parent ] ) ) {
+ foreach ( $comments_by_parent[ $descendant_parent ] as $descendant_id ) {
+ $comment_descendants[ $descendant_id ] = true;
+ $comment_queue[] = $descendant_id;
+ }
+ }
+ }
+
+ // Compute each comment's depth, since comments at the maximum threading depth cannot become a parent.
+ $comment_depths = array();
+
+ foreach ( $post_comments as $post_comment ) {
+ $depth = 1;
+ $ancestor_id = (int) $post_comment->comment_parent;
+
+ while ( $ancestor_id && isset( $post_comments_by_id[ $ancestor_id ] ) ) {
+ ++$depth;
+ $ancestor_id = (int) $post_comments_by_id[ $ancestor_id ]->comment_parent;
}
+
+ $comment_depths[ (int) $post_comment->comment_ID ] = $depth;
}
}
?>
@@ -256,6 +289,7 @@
);
?>
+
+
comment_parent ) {
+ if ( $comment && $comment_parent && $comment_parent !== (int) $comment->comment_parent ) {
+ if ( ! get_option( 'thread_comments' ) ) {
+ return new WP_Error( 'comment_parent_invalid', __( 'The comment parent cannot be changed because threaded comments are disabled.' ) );
+ }
+
if ( $comment_parent === $comment_id ) {
return new WP_Error( 'comment_parent_invalid', __( 'A comment cannot be a reply to itself.' ) );
}
- if ( $comment_parent ) {
- $parent = get_comment( $comment_parent );
+ $parent = get_comment( $comment_parent );
- if ( ! $parent || (int) $parent->comment_post_ID !== (int) $comment->comment_post_ID ) {
- return new WP_Error( 'comment_parent_invalid', __( 'The parent must be another comment on the same post.' ) );
- }
+ if ( ! $parent || (int) $parent->comment_post_ID !== (int) $comment->comment_post_ID ) {
+ return new WP_Error( 'comment_parent_invalid', __( 'The parent must be another comment on the same post.' ) );
+ }
- // Walk up the new parent's ancestors to prevent creating a threading loop.
- $ancestors = array();
- $ancestor = $parent;
+ if ( $parent->comment_type !== $comment->comment_type ) {
+ return new WP_Error( 'comment_parent_invalid', __( 'The parent must be a comment of the same type.' ) );
+ }
- while ( $ancestor && $ancestor->comment_parent && ! isset( $ancestors[ $ancestor->comment_ID ] ) ) {
- if ( (int) $ancestor->comment_parent === $comment_id ) {
- return new WP_Error( 'comment_parent_invalid', __( 'A comment cannot be a reply to one of its own replies.' ) );
- }
+ if ( in_array( wp_get_comment_status( $parent ), array( 'spam', 'trash' ), true ) ) {
+ return new WP_Error( 'comment_parent_invalid', __( 'The parent cannot be a comment in the Trash or marked as spam.' ) );
+ }
- $ancestors[ $ancestor->comment_ID ] = true;
+ // Walk up the new parent's ancestors to prevent creating a threading loop.
+ $ancestors = array();
+ $ancestor = $parent;
+ $parent_depth = 1;
- $ancestor = get_comment( $ancestor->comment_parent );
+ while ( $ancestor && $ancestor->comment_parent && ! isset( $ancestors[ $ancestor->comment_ID ] ) ) {
+ if ( (int) $ancestor->comment_parent === $comment_id ) {
+ return new WP_Error( 'comment_parent_invalid', __( 'A comment cannot be a reply to one of its own replies.' ) );
}
+
+ $ancestors[ $ancestor->comment_ID ] = true;
+ ++$parent_depth;
+
+ $ancestor = get_comment( $ancestor->comment_parent );
+ }
+
+ $max_thread_depth = (int) get_option( 'thread_comments_depth' );
+
+ if ( $max_thread_depth && $parent_depth >= $max_thread_depth ) {
+ return new WP_Error( 'comment_parent_invalid', __( 'The parent comment is already at the maximum threading depth.' ) );
}
}
}
diff --git a/tests/phpunit/tests/admin/includes/comment/EditComment_Test.php b/tests/phpunit/tests/admin/includes/comment/EditComment_Test.php
index 75ba89bb3a8db..e78ab6e57f094 100644
--- a/tests/phpunit/tests/admin/includes/comment/EditComment_Test.php
+++ b/tests/phpunit/tests/admin/includes/comment/EditComment_Test.php
@@ -190,4 +190,116 @@ public function test_should_allow_resubmitting_unchanged_parent() {
$this->assertNotWPError( $result );
$this->assertSame( (string) $parent_id, get_comment( $comment_id )->comment_parent );
}
+
+ /**
+ * @ticket 65570
+ */
+ public function test_should_reject_new_parent_when_comment_threading_is_disabled() {
+ update_option( 'thread_comments', 0 );
+
+ $parent_id = self::factory()->comment->create( array( 'comment_post_ID' => self::$post_id ) );
+ $comment_id = self::factory()->comment->create( array( 'comment_post_ID' => self::$post_id ) );
+
+ $result = $this->update_comment_parent( $comment_id, $parent_id );
+
+ $this->assertWPError( $result );
+ $this->assertSame( 'comment_parent_invalid', $result->get_error_code() );
+ $this->assertSame( '0', get_comment( $comment_id )->comment_parent );
+ }
+
+ /**
+ * @ticket 65570
+ */
+ public function test_should_allow_clearing_parent_when_comment_threading_is_disabled() {
+ update_option( 'thread_comments', 0 );
+
+ $parent_id = self::factory()->comment->create( array( 'comment_post_ID' => self::$post_id ) );
+ $comment_id = self::factory()->comment->create(
+ array(
+ 'comment_post_ID' => self::$post_id,
+ 'comment_parent' => $parent_id,
+ )
+ );
+
+ $result = $this->update_comment_parent( $comment_id, 0 );
+
+ $this->assertSame( 1, $result );
+ $this->assertSame( '0', get_comment( $comment_id )->comment_parent );
+ }
+
+ /**
+ * @ticket 65570
+ */
+ public function test_should_reject_parent_at_maximum_threading_depth() {
+ update_option( 'thread_comments_depth', 2 );
+
+ $top_id = self::factory()->comment->create( array( 'comment_post_ID' => self::$post_id ) );
+ $child_id = self::factory()->comment->create(
+ array(
+ 'comment_post_ID' => self::$post_id,
+ 'comment_parent' => $top_id,
+ )
+ );
+ $comment_id = self::factory()->comment->create( array( 'comment_post_ID' => self::$post_id ) );
+
+ $result = $this->update_comment_parent( $comment_id, $child_id );
+
+ $this->assertWPError( $result );
+ $this->assertSame( 'comment_parent_invalid', $result->get_error_code() );
+ $this->assertSame( '0', get_comment( $comment_id )->comment_parent );
+ }
+
+ /**
+ * @ticket 65570
+ */
+ public function test_should_reject_parent_of_a_different_comment_type() {
+ $parent_id = self::factory()->comment->create(
+ array(
+ 'comment_post_ID' => self::$post_id,
+ 'comment_type' => 'pingback',
+ )
+ );
+ $comment_id = self::factory()->comment->create( array( 'comment_post_ID' => self::$post_id ) );
+
+ $result = $this->update_comment_parent( $comment_id, $parent_id );
+
+ $this->assertWPError( $result );
+ $this->assertSame( 'comment_parent_invalid', $result->get_error_code() );
+ $this->assertSame( '0', get_comment( $comment_id )->comment_parent );
+ }
+
+ /**
+ * @ticket 65570
+ *
+ * @dataProvider data_should_reject_spam_or_trashed_parent
+ *
+ * @param string $comment_approved The parent's comment_approved value.
+ */
+ public function test_should_reject_spam_or_trashed_parent( $comment_approved ) {
+ $parent_id = self::factory()->comment->create(
+ array(
+ 'comment_post_ID' => self::$post_id,
+ 'comment_approved' => $comment_approved,
+ )
+ );
+ $comment_id = self::factory()->comment->create( array( 'comment_post_ID' => self::$post_id ) );
+
+ $result = $this->update_comment_parent( $comment_id, $parent_id );
+
+ $this->assertWPError( $result );
+ $this->assertSame( 'comment_parent_invalid', $result->get_error_code() );
+ $this->assertSame( '0', get_comment( $comment_id )->comment_parent );
+ }
+
+ /**
+ * Data provider.
+ *
+ * @return array[]
+ */
+ public function data_should_reject_spam_or_trashed_parent() {
+ return array(
+ 'a spam parent' => array( 'spam' ),
+ 'a trashed parent' => array( 'trash' ),
+ );
+ }
}
From 48c38533138609d67e9eb4f52b796370dc14f384 Mon Sep 17 00:00:00 2001
From: Riad Benguella
Date: Tue, 7 Jul 2026 11:51:39 +0200
Subject: [PATCH 4/8] Trim the parent comment dropdown option labels to 50
characters.
Co-Authored-By: Claude Fable 5
---
src/wp-admin/edit-form-comment.php | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/wp-admin/edit-form-comment.php b/src/wp-admin/edit-form-comment.php
index 8f028efd6b400..49e12b294cb6d 100644
--- a/src/wp-admin/edit-form-comment.php
+++ b/src/wp-admin/edit-form-comment.php
@@ -334,7 +334,7 @@
/* translators: 1: Comment author, 2: Comment excerpt. */
__( '%1$s: %2$s' ),
get_comment_author( $post_comment ),
- get_comment_excerpt( $post_comment )
+ wp_html_excerpt( get_comment_excerpt( $post_comment ), 50, '…' )
);
printf(
@@ -352,7 +352,7 @@
/* translators: 1: Comment author, 2: Comment excerpt. */
__( '%1$s: %2$s' ),
get_comment_author( $parent ),
- get_comment_excerpt( $parent )
+ wp_html_excerpt( get_comment_excerpt( $parent ), 50, '…' )
);
printf(
From 1666fcc7414db378ea9550fde990ba8c501034cb Mon Sep 17 00:00:00 2001
From: Riad Benguella
Date: Wed, 8 Jul 2026 11:24:12 +0200
Subject: [PATCH 5/8] Account for a comment's replies when validating the
maximum threading depth.
When re-parenting a comment, its replies move with it. Validate that the
whole subtree stays within the maximum threading depth, and filter the
parent dropdown options accordingly.
Co-Authored-By: Claude Fable 5
---
src/wp-admin/edit-form-comment.php | 34 ++++++++-----
src/wp-admin/includes/comment.php | 37 +++++++++++++-
.../includes/comment/EditComment_Test.php | 51 +++++++++++++++++++
3 files changed, 108 insertions(+), 14 deletions(-)
diff --git a/src/wp-admin/edit-form-comment.php b/src/wp-admin/edit-form-comment.php
index 49e12b294cb6d..1401d1e6a7e8a 100644
--- a/src/wp-admin/edit-form-comment.php
+++ b/src/wp-admin/edit-form-comment.php
@@ -249,18 +249,28 @@
$comments_by_parent[ (int) $post_comment->comment_parent ][] = (int) $post_comment->comment_ID;
}
- $comment_descendants = array();
- $comment_queue = array( (int) $comment->comment_ID );
-
- while ( $comment_queue ) {
- $descendant_parent = array_shift( $comment_queue );
-
- if ( isset( $comments_by_parent[ $descendant_parent ] ) ) {
- foreach ( $comments_by_parent[ $descendant_parent ] as $descendant_id ) {
- $comment_descendants[ $descendant_id ] = true;
- $comment_queue[] = $descendant_id;
+ // Walk the descendants level by level, which also gives the height of the subtree that moves with the comment.
+ $comment_descendants = array();
+ $comment_subtree_height = 1;
+ $comment_level = array( (int) $comment->comment_ID );
+
+ while ( $comment_level ) {
+ $next_level = array();
+
+ foreach ( $comment_level as $level_comment_id ) {
+ if ( isset( $comments_by_parent[ $level_comment_id ] ) ) {
+ foreach ( $comments_by_parent[ $level_comment_id ] as $descendant_id ) {
+ $comment_descendants[ $descendant_id ] = true;
+ $next_level[] = $descendant_id;
+ }
}
}
+
+ if ( $next_level ) {
+ ++$comment_subtree_height;
+ }
+
+ $comment_level = $next_level;
}
// Compute each comment's depth, since comments at the maximum threading depth cannot become a parent.
@@ -321,8 +331,8 @@
continue;
}
- // Comments already at the maximum threading depth cannot become a parent.
- if ( $max_thread_depth && $comment_depths[ $post_comment_id ] >= $max_thread_depth ) {
+ // The comment and the replies that move with it must stay within the maximum threading depth.
+ if ( $max_thread_depth && $comment_depths[ $post_comment_id ] + $comment_subtree_height > $max_thread_depth ) {
continue;
}
diff --git a/src/wp-admin/includes/comment.php b/src/wp-admin/includes/comment.php
index f136b9260e4bc..aa8358e3a8898 100644
--- a/src/wp-admin/includes/comment.php
+++ b/src/wp-admin/includes/comment.php
@@ -124,8 +124,41 @@ function edit_comment() {
$max_thread_depth = (int) get_option( 'thread_comments_depth' );
- if ( $max_thread_depth && $parent_depth >= $max_thread_depth ) {
- return new WP_Error( 'comment_parent_invalid', __( 'The parent comment is already at the maximum threading depth.' ) );
+ if ( $max_thread_depth ) {
+ if ( $parent_depth >= $max_thread_depth ) {
+ return new WP_Error( 'comment_parent_invalid', __( 'The parent comment is already at the maximum threading depth.' ) );
+ }
+
+ // The comment's replies move with it, so the whole subtree must stay within the maximum depth.
+ $subtree_height = 1;
+ $subtree_ids = array( $comment_id => true );
+ $level_ids = array( $comment_id );
+
+ while ( $level_ids && $parent_depth + $subtree_height <= $max_thread_depth ) {
+ $level_ids = get_comments(
+ array(
+ 'parent__in' => $level_ids,
+ 'fields' => 'ids',
+ 'status' => 'any',
+ 'orderby' => 'none',
+ )
+ );
+
+ // Guard against a comment appearing twice, e.g. a loop in existing data.
+ $level_ids = array_diff( array_map( 'intval', $level_ids ), array_keys( $subtree_ids ) );
+
+ foreach ( $level_ids as $level_id ) {
+ $subtree_ids[ $level_id ] = true;
+ }
+
+ if ( $level_ids ) {
+ ++$subtree_height;
+ }
+ }
+
+ if ( $parent_depth + $subtree_height > $max_thread_depth ) {
+ return new WP_Error( 'comment_parent_invalid', __( 'The replies to this comment would exceed the maximum threading depth.' ) );
+ }
}
}
}
diff --git a/tests/phpunit/tests/admin/includes/comment/EditComment_Test.php b/tests/phpunit/tests/admin/includes/comment/EditComment_Test.php
index e78ab6e57f094..13ab871f85598 100644
--- a/tests/phpunit/tests/admin/includes/comment/EditComment_Test.php
+++ b/tests/phpunit/tests/admin/includes/comment/EditComment_Test.php
@@ -249,6 +249,57 @@ public function test_should_reject_parent_at_maximum_threading_depth() {
$this->assertSame( '0', get_comment( $comment_id )->comment_parent );
}
+ /**
+ * @ticket 65570
+ */
+ public function test_should_reject_parent_when_replies_would_exceed_maximum_threading_depth() {
+ update_option( 'thread_comments_depth', 3 );
+
+ $top_id = self::factory()->comment->create( array( 'comment_post_ID' => self::$post_id ) );
+ $mid_id = self::factory()->comment->create(
+ array(
+ 'comment_post_ID' => self::$post_id,
+ 'comment_parent' => $top_id,
+ )
+ );
+ $comment_id = self::factory()->comment->create( array( 'comment_post_ID' => self::$post_id ) );
+ self::factory()->comment->create(
+ array(
+ 'comment_post_ID' => self::$post_id,
+ 'comment_parent' => $comment_id,
+ )
+ );
+
+ // The comment itself would be at depth 3, but its reply would end up at depth 4.
+ $result = $this->update_comment_parent( $comment_id, $mid_id );
+
+ $this->assertWPError( $result );
+ $this->assertSame( 'comment_parent_invalid', $result->get_error_code() );
+ $this->assertSame( '0', get_comment( $comment_id )->comment_parent );
+ }
+
+ /**
+ * @ticket 65570
+ */
+ public function test_should_allow_moving_comment_with_replies_within_maximum_threading_depth() {
+ update_option( 'thread_comments_depth', 3 );
+
+ $top_id = self::factory()->comment->create( array( 'comment_post_ID' => self::$post_id ) );
+ $comment_id = self::factory()->comment->create( array( 'comment_post_ID' => self::$post_id ) );
+ self::factory()->comment->create(
+ array(
+ 'comment_post_ID' => self::$post_id,
+ 'comment_parent' => $comment_id,
+ )
+ );
+
+ // The comment ends up at depth 2 and its reply at depth 3, the maximum.
+ $result = $this->update_comment_parent( $comment_id, $top_id );
+
+ $this->assertSame( 1, $result );
+ $this->assertSame( (string) $top_id, get_comment( $comment_id )->comment_parent );
+ }
+
/**
* @ticket 65570
*/
From 44746796f751655a2dd4d1b6e26239a00b1135cd Mon Sep 17 00:00:00 2001
From: Riad Benguella
Date: Wed, 8 Jul 2026 11:31:07 +0200
Subject: [PATCH 6/8] Add tests for the parent comment selector on the Edit
Comment screen.
Renders the edit form and verifies which comments the parent dropdown
lists: excluding the comment itself, its replies, spam and trashed
comments, and comments too deep to become a parent, while keeping the
current parent listed and hiding the selector entirely when comment
threading is disabled.
Co-Authored-By: Claude Fable 5
---
.../tests/admin/EditFormComment_Test.php | 202 ++++++++++++++++++
1 file changed, 202 insertions(+)
create mode 100644 tests/phpunit/tests/admin/EditFormComment_Test.php
diff --git a/tests/phpunit/tests/admin/EditFormComment_Test.php b/tests/phpunit/tests/admin/EditFormComment_Test.php
new file mode 100644
index 0000000000000..2bc7853f3e212
--- /dev/null
+++ b/tests/phpunit/tests/admin/EditFormComment_Test.php
@@ -0,0 +1,202 @@
+user->create( array( 'role' => 'administrator' ) );
+ self::$post_id = $factory->post->create();
+ }
+
+ public function set_up() {
+ parent::set_up();
+
+ wp_set_current_user( self::$admin_id );
+ }
+
+ /**
+ * Renders the Edit Comment form for a comment and returns the output.
+ *
+ * @param int $comment_id Comment ID.
+ * @return string The rendered form.
+ */
+ private function render_edit_form( $comment_id ) {
+ global $comment;
+
+ $comment = get_comment_to_edit( $comment_id );
+ $action = 'editcomment';
+
+ set_current_screen( 'comment' );
+
+ ob_start();
+ require ABSPATH . 'wp-admin/edit-form-comment.php';
+ $output = ob_get_clean();
+
+ unset( $GLOBALS['comment'] );
+
+ return $output;
+ }
+
+ /**
+ * Returns the parent comment dropdown markup from the rendered form.
+ *
+ * @param int $comment_id Comment ID.
+ * @return string The dropdown markup, or an empty string if not rendered.
+ */
+ private function get_parent_dropdown( $comment_id ) {
+ $output = $this->render_edit_form( $comment_id );
+
+ if ( ! preg_match( '||s', $output, $matches ) ) {
+ return '';
+ }
+
+ return $matches[0];
+ }
+
+ /**
+ * @ticket 65570
+ */
+ public function test_should_list_valid_parents_and_exclude_invalid_ones() {
+ $top_id = self::factory()->comment->create( array( 'comment_post_ID' => self::$post_id ) );
+ $reply_id = self::factory()->comment->create(
+ array(
+ 'comment_post_ID' => self::$post_id,
+ 'comment_parent' => $top_id,
+ )
+ );
+ $spam_id = self::factory()->comment->create(
+ array(
+ 'comment_post_ID' => self::$post_id,
+ 'comment_approved' => 'spam',
+ )
+ );
+ $trash_id = self::factory()->comment->create(
+ array(
+ 'comment_post_ID' => self::$post_id,
+ 'comment_approved' => 'trash',
+ )
+ );
+ $comment_id = self::factory()->comment->create( array( 'comment_post_ID' => self::$post_id ) );
+ $child_id = self::factory()->comment->create(
+ array(
+ 'comment_post_ID' => self::$post_id,
+ 'comment_parent' => $comment_id,
+ )
+ );
+
+ $dropdown = $this->get_parent_dropdown( $comment_id );
+
+ $this->assertStringContainsString( 'value="0"', $dropdown, 'The "None" option should be listed.' );
+ $this->assertStringContainsString( "value='{$top_id}'", $dropdown, 'A top-level comment should be listed.' );
+ $this->assertStringContainsString( "value='{$reply_id}'", $dropdown, 'A nested comment should be listed.' );
+ $this->assertStringNotContainsString( "value='{$comment_id}'", $dropdown, 'The comment being edited should not be listed.' );
+ $this->assertStringNotContainsString( "value='{$child_id}'", $dropdown, 'A reply to the comment being edited should not be listed.' );
+ $this->assertStringNotContainsString( "value='{$spam_id}'", $dropdown, 'A spam comment should not be listed.' );
+ $this->assertStringNotContainsString( "value='{$trash_id}'", $dropdown, 'A trashed comment should not be listed.' );
+ }
+
+ /**
+ * @ticket 65570
+ */
+ public function test_should_not_render_parent_selector_when_comment_threading_is_disabled() {
+ update_option( 'thread_comments', 0 );
+
+ $comment_id = self::factory()->comment->create( array( 'comment_post_ID' => self::$post_id ) );
+
+ $output = $this->render_edit_form( $comment_id );
+
+ $this->assertStringContainsString( 'id="comment-parent-display"', $output, 'The parent should still be displayed.' );
+ $this->assertStringNotContainsString( 'name="comment_parent"', $output, 'The parent selector should not be rendered.' );
+ }
+
+ /**
+ * @ticket 65570
+ */
+ public function test_should_exclude_parents_at_maximum_threading_depth() {
+ update_option( 'thread_comments_depth', 2 );
+
+ $top_id = self::factory()->comment->create( array( 'comment_post_ID' => self::$post_id ) );
+ $deep_id = self::factory()->comment->create(
+ array(
+ 'comment_post_ID' => self::$post_id,
+ 'comment_parent' => $top_id,
+ )
+ );
+ $comment_id = self::factory()->comment->create( array( 'comment_post_ID' => self::$post_id ) );
+
+ $dropdown = $this->get_parent_dropdown( $comment_id );
+
+ $this->assertStringContainsString( "value='{$top_id}'", $dropdown, 'A top-level comment should be listed.' );
+ $this->assertStringNotContainsString( "value='{$deep_id}'", $dropdown, 'A comment at the maximum depth should not be listed.' );
+ }
+
+ /**
+ * @ticket 65570
+ */
+ public function test_should_account_for_replies_when_excluding_deep_parents() {
+ update_option( 'thread_comments_depth', 3 );
+
+ $top_id = self::factory()->comment->create( array( 'comment_post_ID' => self::$post_id ) );
+ $mid_id = self::factory()->comment->create(
+ array(
+ 'comment_post_ID' => self::$post_id,
+ 'comment_parent' => $top_id,
+ )
+ );
+ $comment_id = self::factory()->comment->create( array( 'comment_post_ID' => self::$post_id ) );
+ self::factory()->comment->create(
+ array(
+ 'comment_post_ID' => self::$post_id,
+ 'comment_parent' => $comment_id,
+ )
+ );
+
+ $dropdown = $this->get_parent_dropdown( $comment_id );
+
+ $this->assertStringContainsString( "value='{$top_id}'", $dropdown, 'A parent leaving room for the reply should be listed.' );
+ $this->assertStringNotContainsString( "value='{$mid_id}'", $dropdown, 'A parent whose depth plus the replies would exceed the maximum should not be listed.' );
+ }
+
+ /**
+ * @ticket 65570
+ */
+ public function test_should_list_current_parent_even_when_it_is_not_a_valid_option() {
+ $spam_parent_id = self::factory()->comment->create(
+ array(
+ 'comment_post_ID' => self::$post_id,
+ 'comment_approved' => 'spam',
+ )
+ );
+ $comment_id = self::factory()->comment->create(
+ array(
+ 'comment_post_ID' => self::$post_id,
+ 'comment_parent' => $spam_parent_id,
+ )
+ );
+
+ $dropdown = $this->get_parent_dropdown( $comment_id );
+
+ $this->assertStringContainsString( "value='{$spam_parent_id}'", $dropdown, 'The current parent should be listed.' );
+ $this->assertStringContainsString( ' selected>', $dropdown, 'The current parent should be selected.' );
+ }
+}
From 7d417dbcb96637de407279a30539f3821d707a76 Mon Sep 17 00:00:00 2001
From: Riad Benguella
Date: Wed, 8 Jul 2026 11:37:35 +0200
Subject: [PATCH 7/8] Simplify the comment parent validation guards.
Collapse the parent eligibility checks into a single conditional, fold
the two depth checks into one (a parent at the maximum depth is a
special case of the subtree check), and drop the redundant cycle
bookkeeping since the depth bound already terminates the loop. Remove a
form test subsumed by the subtree-aware depth test.
Co-Authored-By: Claude Fable 5
---
src/wp-admin/edit-form-comment.php | 10 ++---
src/wp-admin/includes/comment.php | 39 +++++++------------
.../tests/admin/EditFormComment_Test.php | 21 ----------
3 files changed, 18 insertions(+), 52 deletions(-)
diff --git a/src/wp-admin/edit-form-comment.php b/src/wp-admin/edit-form-comment.php
index 1401d1e6a7e8a..935a26aee479a 100644
--- a/src/wp-admin/edit-form-comment.php
+++ b/src/wp-admin/edit-form-comment.php
@@ -236,16 +236,12 @@
// Restore chronological order for display.
$post_comments = array_reverse( $post_comments );
+ // Index the comments by ID and by parent, to compute depths and find descendants.
$post_comments_by_id = array();
+ $comments_by_parent = array();
foreach ( $post_comments as $post_comment ) {
- $post_comments_by_id[ (int) $post_comment->comment_ID ] = $post_comment;
- }
-
- // Group comments by parent to find this comment's descendants, which cannot become its parent.
- $comments_by_parent = array();
-
- foreach ( $post_comments as $post_comment ) {
+ $post_comments_by_id[ (int) $post_comment->comment_ID ] = $post_comment;
$comments_by_parent[ (int) $post_comment->comment_parent ][] = (int) $post_comment->comment_ID;
}
diff --git a/src/wp-admin/includes/comment.php b/src/wp-admin/includes/comment.php
index aa8358e3a8898..68bd6c6042b6d 100644
--- a/src/wp-admin/includes/comment.php
+++ b/src/wp-admin/includes/comment.php
@@ -94,16 +94,14 @@ function edit_comment() {
$parent = get_comment( $comment_parent );
- if ( ! $parent || (int) $parent->comment_post_ID !== (int) $comment->comment_post_ID ) {
- return new WP_Error( 'comment_parent_invalid', __( 'The parent must be another comment on the same post.' ) );
- }
-
- if ( $parent->comment_type !== $comment->comment_type ) {
- return new WP_Error( 'comment_parent_invalid', __( 'The parent must be a comment of the same type.' ) );
- }
-
- if ( in_array( wp_get_comment_status( $parent ), array( 'spam', 'trash' ), true ) ) {
- return new WP_Error( 'comment_parent_invalid', __( 'The parent cannot be a comment in the Trash or marked as spam.' ) );
+ // The parent must be a comment of the same type, on the same post, and not in the Trash or marked as spam.
+ if (
+ ! $parent
+ || (int) $parent->comment_post_ID !== (int) $comment->comment_post_ID
+ || $parent->comment_type !== $comment->comment_type
+ || in_array( wp_get_comment_status( $parent ), array( 'spam', 'trash' ), true )
+ ) {
+ return new WP_Error( 'comment_parent_invalid', __( 'Invalid parent comment.' ) );
}
// Walk up the new parent's ancestors to prevent creating a threading loop.
@@ -125,13 +123,13 @@ function edit_comment() {
$max_thread_depth = (int) get_option( 'thread_comments_depth' );
if ( $max_thread_depth ) {
- if ( $parent_depth >= $max_thread_depth ) {
- return new WP_Error( 'comment_parent_invalid', __( 'The parent comment is already at the maximum threading depth.' ) );
- }
-
- // The comment's replies move with it, so the whole subtree must stay within the maximum depth.
+ /*
+ * The comment's replies move with it, so the whole subtree must stay within
+ * the maximum depth. Measure its height one level of replies at a time,
+ * stopping as soon as the subtree cannot fit, which also bounds the loop
+ * should the stored comment hierarchy contain a cycle.
+ */
$subtree_height = 1;
- $subtree_ids = array( $comment_id => true );
$level_ids = array( $comment_id );
while ( $level_ids && $parent_depth + $subtree_height <= $max_thread_depth ) {
@@ -144,20 +142,13 @@ function edit_comment() {
)
);
- // Guard against a comment appearing twice, e.g. a loop in existing data.
- $level_ids = array_diff( array_map( 'intval', $level_ids ), array_keys( $subtree_ids ) );
-
- foreach ( $level_ids as $level_id ) {
- $subtree_ids[ $level_id ] = true;
- }
-
if ( $level_ids ) {
++$subtree_height;
}
}
if ( $parent_depth + $subtree_height > $max_thread_depth ) {
- return new WP_Error( 'comment_parent_invalid', __( 'The replies to this comment would exceed the maximum threading depth.' ) );
+ return new WP_Error( 'comment_parent_invalid', __( 'The comment cannot be moved there because it or its replies would exceed the maximum threading depth.' ) );
}
}
}
diff --git a/tests/phpunit/tests/admin/EditFormComment_Test.php b/tests/phpunit/tests/admin/EditFormComment_Test.php
index 2bc7853f3e212..f75785b0495e9 100644
--- a/tests/phpunit/tests/admin/EditFormComment_Test.php
+++ b/tests/phpunit/tests/admin/EditFormComment_Test.php
@@ -129,27 +129,6 @@ public function test_should_not_render_parent_selector_when_comment_threading_is
$this->assertStringNotContainsString( 'name="comment_parent"', $output, 'The parent selector should not be rendered.' );
}
- /**
- * @ticket 65570
- */
- public function test_should_exclude_parents_at_maximum_threading_depth() {
- update_option( 'thread_comments_depth', 2 );
-
- $top_id = self::factory()->comment->create( array( 'comment_post_ID' => self::$post_id ) );
- $deep_id = self::factory()->comment->create(
- array(
- 'comment_post_ID' => self::$post_id,
- 'comment_parent' => $top_id,
- )
- );
- $comment_id = self::factory()->comment->create( array( 'comment_post_ID' => self::$post_id ) );
-
- $dropdown = $this->get_parent_dropdown( $comment_id );
-
- $this->assertStringContainsString( "value='{$top_id}'", $dropdown, 'A top-level comment should be listed.' );
- $this->assertStringNotContainsString( "value='{$deep_id}'", $dropdown, 'A comment at the maximum depth should not be listed.' );
- }
-
/**
* @ticket 65570
*/
From e3aca1c4f33373f1bec2023ed3afe4d524eca5e2 Mon Sep 17 00:00:00 2001
From: Riad Benguella
Date: Thu, 9 Jul 2026 11:02:00 +0200
Subject: [PATCH 8/8] Show 'None' in the parent display when a comment's parent
is removed.
Match the label the Edit Comment screen renders on load, and align the
translators comment for the 'In reply to' string with the PHP side.
Props westonruter.
Co-Authored-By: Claude Fable 5
---
src/js/_enqueues/admin/comment.js | 7 +++++--
1 file changed, 5 insertions(+), 2 deletions(-)
diff --git a/src/js/_enqueues/admin/comment.js b/src/js/_enqueues/admin/comment.js
index f2c4aa3bd36a5..b31f174bab5fe 100644
--- a/src/js/_enqueues/admin/comment.js
+++ b/src/js/_enqueues/admin/comment.js
@@ -157,13 +157,16 @@ jQuery( function($) {
*/
$commentparentdiv.find( '.save-comment-parent' ).on( 'click', function( event ) {
var $selected = $( '#comment_parent option:selected' ),
- parentLabel = $selected.data( 'author' ) || $selected.text();
+ parentLabel = '0' === $selected.val() ?
+ /* translators: Displayed when a comment has no parent. */
+ wp.i18n.__( 'None' ) :
+ $selected.data( 'author' ) || $selected.text();
event.preventDefault();
$commentparentdisplay.html(
wp.i18n.sprintf(
- /* translators: %s: Parent comment author. */
+ /* translators: %s: Parent comment link, or 'None'. */
wp.i18n.__( 'In reply to: %s' ),
$( '' ).text( parentLabel ).prop( 'outerHTML' )
)