Commit 0b8f21c9ef for woocommerce

commit 0b8f21c9ef7d18e85f726f301e59aec18a3bcec1
Author: Albert Juhé Lluveras <contact@albertjuhe.com>
Date:   Mon Feb 16 11:45:55 2026 +0100

    Product Reviews block: add support for replies (#63161)

    * Product Reviews block: add support for replies

    * Add changelog file

    * Clean up code

    * Remove unnecessary optional chaining

    * Fix _embedded type

    * Remove unnecessary

    * chore: some TS minor cleanup

    ---------

    Co-authored-by: Tung Du <dinhtungdu@gmail.com>

diff --git a/plugins/woocommerce/changelog/fix-product-reviews-add-back-support-for-replies b/plugins/woocommerce/changelog/fix-product-reviews-add-back-support-for-replies
new file mode 100644
index 0000000000..ec43cfb5ff
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-product-reviews-add-back-support-for-replies
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Product Reviews block: add support for replies
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-reviews/inner-blocks/review-template/edit.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/product-reviews/inner-blocks/review-template/edit.tsx
index 9d115d9afc..070ae20787 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-reviews/inner-blocks/review-template/edit.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-reviews/inner-blocks/review-template/edit.tsx
@@ -7,6 +7,7 @@ import { __ } from '@wordpress/i18n';
 import { BlockInstance, BlockEditProps } from '@wordpress/blocks';
 import { Spinner } from '@wordpress/components';
 import { store as coreStore } from '@wordpress/core-data';
+import type { Comment as WPComment } from '@wordpress/core-data';
 import {
 	// eslint-disable-next-line @typescript-eslint/ban-ts-comment
 	// @ts-ignore No types for this exist yet.
@@ -23,10 +24,11 @@ import {
 /**
  * Internal dependencies
  */
-import { useCommentQueryArgs, useCommentList } from './hooks';
+import { useCommentQueryArgs, useCommentTree } from './hooks';

-interface Comment {
+export interface Comment {
 	commentId: number;
+	children?: Comment[];
 }

 interface ReviewTemplateInnerBlocksProps {
@@ -63,7 +65,7 @@ interface ReviewSettings {
 const getCommentsPlaceholder = ( {
 	perPage,
 	pageComments,
-}: ReviewSettings ) => {
+}: ReviewSettings ): Comment[] => {
 	const numberOfComments = pageComments ? Math.min( perPage, 3 ) : 3;

 	return Array.from( { length: numberOfComments }, ( _, i ) => ( {
@@ -129,6 +131,29 @@ const ReviewTemplateInnerBlocks = memo( function ReviewTemplateInnerBlocks( {
 					comment.commentId === ( activeCommentId || firstCommentId )
 				}
 			/>
+			{ comment.children && comment.children.length > 0 ? (
+				<ol>
+					{ comment.children.map( ( child, index ) => (
+						<BlockContextProvider
+							key={ child.commentId || index }
+							value={ {
+								commentId:
+									child.commentId < 0
+										? null
+										: child.commentId,
+							} }
+						>
+							<ReviewTemplateInnerBlocks
+								comment={ child }
+								activeCommentId={ activeCommentId }
+								setActiveCommentId={ setActiveCommentId }
+								blocks={ blocks }
+								firstCommentId={ firstCommentId }
+							/>
+						</BlockContextProvider>
+					) ) }
+				</ol>
+			) : null }
 		</li>
 	);
 } );
@@ -171,7 +196,14 @@ export default function ReviewTemplateEdit( {
 			};
 			return {
 				topLevelComments: commentQuery
-					? getEntityRecords( 'root', 'comment', commentQuery )
+					? ( getEntityRecords(
+							'root',
+							'comment',
+							commentQuery
+					  ) as ( WPComment & {
+							// eslint-disable-next-line @typescript-eslint/naming-convention
+							_embedded?: { children?: WPComment[][] };
+					  } )[] )
 					: null,
 				blocks: getBlocks( clientId ),
 			};
@@ -179,17 +211,30 @@ export default function ReviewTemplateEdit( {
 		[ clientId, commentQuery ]
 	);

-	let commentTree = useCommentList(
-		// Reverse the order of top comments if needed.
-		commentOrder === 'desc' && topLevelComments
-			? [
-					...( topLevelComments as Array< {
-						id: number;
-					} > ),
-			  ].reverse()
-			: ( topLevelComments as Array< {
-					id: number;
-			  } > )
+	let commentTree = useCommentTree(
+		Array.isArray( topLevelComments )
+			? topLevelComments.map( ( comment ) => {
+					const children = comment._embedded?.children;
+
+					if (
+						Array.isArray( children ) &&
+						children.length >= 1 &&
+						Array.isArray( children[ 0 ] )
+					) {
+						return {
+							id: comment.id,
+							children: children[ 0 ].map( ( child ) => ( {
+								id: child.id,
+							} ) ),
+						};
+					}
+
+					return {
+						id: comment.id,
+					};
+			  } )
+			: [],
+		commentOrder
 	);

 	if ( ! topLevelComments ) {
@@ -218,31 +263,25 @@ export default function ReviewTemplateEdit( {
 	return (
 		<ol { ...blockProps }>
 			{ commentTree &&
-				commentTree.map(
-					(
-						{
-							commentId,
-						}: {
-							commentId: number;
-						},
-						index: number
-					) => (
-						<BlockContextProvider
-							key={ commentId || index }
-							value={ {
-								commentId: commentId < 0 ? null : commentId,
-							} }
-						>
-							<ReviewTemplateInnerBlocks
-								comment={ { commentId } }
-								activeCommentId={ activeCommentId }
-								setActiveCommentId={ setActiveCommentId }
-								blocks={ blocks }
-								firstCommentId={ commentTree[ 0 ]?.commentId }
-							/>
-						</BlockContextProvider>
-					)
-				) }
+				commentTree.map( ( comment, index ) => (
+					<BlockContextProvider
+						key={ comment.commentId || index }
+						value={ {
+							commentId:
+								comment.commentId < 0
+									? null
+									: comment.commentId,
+						} }
+					>
+						<ReviewTemplateInnerBlocks
+							comment={ comment }
+							activeCommentId={ activeCommentId }
+							setActiveCommentId={ setActiveCommentId }
+							blocks={ blocks }
+							firstCommentId={ commentTree[ 0 ]?.commentId }
+						/>
+					</BlockContextProvider>
+				) ) }
 		</ol>
 	);
 }
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-reviews/inner-blocks/review-template/hooks.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/product-reviews/inner-blocks/review-template/hooks.tsx
index 431cea1415..04028ce75a 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-reviews/inner-blocks/review-template/hooks.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-reviews/inner-blocks/review-template/hooks.tsx
@@ -7,6 +7,11 @@ import { store as blockEditorStore } from '@wordpress/block-editor';
 import { addQueryArgs } from '@wordpress/url';
 import apiFetch from '@wordpress/api-fetch';

+/**
+ * Internal dependencies
+ */
+import type { Comment } from './edit';
+
 // This is limited by WP REST API
 const MAX_COMMENTS_PER_PAGE = 100;

@@ -23,6 +28,8 @@ export const useCommentQueryArgs = ( { postId }: { postId: number } ) => {
 			context: 'embed',
 			parent: 0,
 			type: 'review',
+			// Request embedded children so we can show direct replies.
+			_embed: 'children',
 		} ),
 		[]
 	);
@@ -152,23 +159,33 @@ export const useCommentQueryArgs = ( { postId }: { postId: number } ) => {
 };

 /**
- * Generate a list of IDs from a list of review entities.
+ * Generate a tree structure of comment IDs from a list of review entities.
  */
-export const useCommentList = (
-	// eslint-disable-next-line @typescript-eslint/naming-convention
+export const useCommentTree = (
 	topLevelComments: Array< {
 		id: number;
-	} >
+		children?: Array< { id: number } >;
+	} >,
+	commentOrder: string
 ) => {
-	const commentList = useMemo(
-		() =>
-			topLevelComments?.map( ( { id }: { id: number } ) => {
+	const commentTree = useMemo( () => {
+		const comments: Comment[] = topLevelComments.map(
+			( { id, children } ) => {
 				return {
 					commentId: id,
+					children: Array.isArray( children )
+						? children.map( ( child ) => ( {
+								commentId: child.id,
+						  } ) )
+						: [],
 				};
-			} ),
-		[ topLevelComments ]
-	);
+			}
+		);
+		if ( commentOrder === 'desc' ) {
+			return comments.reverse();
+		}
+		return comments;
+	}, [ topLevelComments, commentOrder ] );

-	return commentList;
+	return commentTree;
 };
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 871034c4db..e516dfaf8b 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -55077,18 +55077,6 @@ parameters:
 			count: 1
 			path: src/Blocks/BlockTypes/Reviews/ProductReviewRating.php

-		-
-			message: '#^Access to property \$comment_ID on an unknown class Automattic\\WooCommerce\\Blocks\\BlockTypes\\Reviews\\WP_Comment\.$#'
-			identifier: class.notFound
-			count: 3
-			path: src/Blocks/BlockTypes/Reviews/ProductReviewTemplate.php
-
-		-
-			message: '#^Access to property \$comment_post_ID on an unknown class Automattic\\WooCommerce\\Blocks\\BlockTypes\\Reviews\\WP_Comment\.$#'
-			identifier: class.notFound
-			count: 1
-			path: src/Blocks/BlockTypes/Reviews/ProductReviewTemplate.php
-
 		-
 			message: '#^Method Automattic\\WooCommerce\\Blocks\\BlockTypes\\Reviews\\ProductReviewTemplate\:\:render\(\) should return string but empty return statement found\.$#'
 			identifier: return.empty
@@ -55107,12 +55095,6 @@ parameters:
 			count: 1
 			path: src/Blocks/BlockTypes/Reviews/ProductReviewTemplate.php

-		-
-			message: '#^Parameter \$comments of method Automattic\\WooCommerce\\Blocks\\BlockTypes\\Reviews\\ProductReviewTemplate\:\:block_product_review_template_render_comments\(\) has invalid type Automattic\\WooCommerce\\Blocks\\BlockTypes\\Reviews\\WP_Comment\.$#'
-			identifier: class.notFound
-			count: 1
-			path: src/Blocks/BlockTypes/Reviews/ProductReviewTemplate.php
-
 		-
 			message: '#^Access to property \$parsed_block on an unknown class Automattic\\WooCommerce\\Blocks\\BlockTypes\\Reviews\\WP_Block\.$#'
 			identifier: class.notFound
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/Reviews/ProductReviewTemplate.php b/plugins/woocommerce/src/Blocks/BlockTypes/Reviews/ProductReviewTemplate.php
index 64e40ee65c..7f397bb48d 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/Reviews/ProductReviewTemplate.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/Reviews/ProductReviewTemplate.php
@@ -4,6 +4,8 @@ namespace Automattic\WooCommerce\Blocks\BlockTypes\Reviews;
 use Automattic\WooCommerce\Blocks\BlockTypes\AbstractBlock;
 use WP_Comment_Query;
 use WP_Block;
+use WP_Comment;
+
 /**
  * ProductReviewTemplate class.
  */
@@ -31,13 +33,19 @@ class ProductReviewTemplate extends AbstractBlock {
 	 *
 	 * @since 6.3.0 Changed render_block_context priority to `1`.
 	 *
-	 * @param WP_Comment[] $comments        The array of comments.
-	 * @param WP_Block     $block           Block instance.
+	 * @param WP_Comment[] $comments      The array of comments.
+	 * @param WP_Block     $block         Block instance.
+	 *
 	 * @return string
 	 */
-	protected function block_product_review_template_render_comments( $comments, $block ) {
+	protected function block_product_review_template_render_comments( array $comments, WP_Block $block ): string {
 		$content = '';
+
 		foreach ( $comments as $comment ) {
+			if ( ! $comment instanceof WP_Comment ) {
+				continue;
+			}
+
 			$comment_id           = $comment->comment_ID;
 			$filter_block_context = static function ( $context ) use ( $comment_id ) {
 				$context['commentId'] = $comment_id;
@@ -45,24 +53,49 @@ class ProductReviewTemplate extends AbstractBlock {
 			};

 			/*
-			* We set commentId context through the `render_block_context` filter so
-			* that dynamically inserted blocks (at `render_block` filter stage)
-			* will also receive that context.
-			*
-			* Use an early priority to so that other 'render_block_context' filters
-			* have access to the values.
-			*/
+			 * We set commentId context through the `render_block_context` filter so
+			 * that dynamically inserted blocks (at `render_block` filter stage)
+			 * will also receive that context.
+			 *
+			 * Use an early priority so that other 'render_block_context' filters
+			 * have access to the values.
+			 */
 			add_filter( 'render_block_context', $filter_block_context, 1 );

 			/*
-			* We construct a new WP_Block instance from the parsed block so that
-			* it'll receive any changes made by the `render_block_data` filter.
-			*/
+			 * We construct a new WP_Block instance from the parsed block so that
+			 * it'll receive any changes made by the `render_block_data` filter.
+			 */
 			$block_content = ( new WP_Block( $block->parsed_block ) )->render( array( 'dynamic' => false ) );

 			remove_filter( 'render_block_context', $filter_block_context, 1 );

-			$comment_classes = comment_class( '', $comment->comment_ID, $comment->comment_post_ID, false );
+			$children = $comment->get_children();
+
+			/*
+			* We need to create the CSS classes BEFORE recursing into the children.
+			* This is because comment_class() uses globals like `$comment_alt`
+			* and `$comment_thread_alt` which are order-sensitive.
+			*
+			* The `false` parameter at the end means that we do NOT want the function
+			* to `echo` the output but to return a string.
+			* See https://developer.wordpress.org/reference/functions/comment_class/#parameters.
+			*/
+			$comment_classes = comment_class(
+				'',
+				(int) $comment->comment_ID,
+				(int) $comment->comment_post_ID,
+				false
+			);
+
+			// If the comment has children, recurse to create the HTML for the nested comments.
+			if ( ! empty( $children ) ) {
+				$inner_content  = $this->block_product_review_template_render_comments(
+					$children,
+					$block,
+				);
+				$block_content .= sprintf( '<ol>%1$s</ol>', $inner_content );
+			}

 			$content .= sprintf( '<li id="comment-%1$s" %2$s>%3$s</li>', $comment->comment_ID, $comment_classes, $block_content );
 		}