Commit a22ff54792a for woocommerce

commit a22ff54792ae396a38d5a20526a5815e8a067318
Author: Luigi Teschio <gigitux@gmail.com>
Date:   Mon Aug 10 14:31:14 2026 +0200

    Add offset controls to Reviews by Product and Category blocks (#67163)

    * Add offset controls to product review blocks

    * Add changelog entry for review block offset controls

    * fix types

    * improve ui

    * add custom error for offset

    * fix re-rendering

    * update UI

    * improve label

    * fix: reject invalid review offsets

    * handle -0 case

    * fix E2E test

    * fix e2e test

    ---------

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

diff --git a/plugins/woocommerce/client/blocks/assets/js/base/components/reviews/review-list/index.tsx b/plugins/woocommerce/client/blocks/assets/js/base/components/reviews/review-list/index.tsx
index 49c5fe5652d..6e044604d49 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/components/reviews/review-list/index.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/base/components/reviews/review-list/index.tsx
@@ -40,9 +40,9 @@ const ReviewList = ( {
 			{ reviews.length === 0 ? (
 				<ReviewListItem attributes={ attrs } />
 			) : (
-				reviews.map( ( review, i ) => (
+				reviews.map( ( review ) => (
 					<ReviewListItem
-						key={ review.id || i }
+						key={ review.id }
 						attributes={ attrs }
 						review={ review }
 					/>
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/hocs/test/with-reviews.jsx b/plugins/woocommerce/client/blocks/assets/js/base/hocs/test/with-reviews.jsx
index 736918f6dd4..e931f3aa09e 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/hocs/test/with-reviews.jsx
+++ b/plugins/woocommerce/client/blocks/assets/js/base/hocs/test/with-reviews.jsx
@@ -121,6 +121,34 @@ describe( 'withReviews Component', () => {
 		} );
 	} );

+	it( 'replaces reviews when the offset changes as the display count increases', async () => {
+		const { getReviews } = mockUtils;
+		getReviews.mockResolvedValue( {
+			reviews: mockReviews,
+			totalReviews: 20,
+		} );
+		await renderComponent( { offset: 5, reviewsToDisplay: 5 } );
+
+		await settle( () =>
+			renderResult.rerender(
+				<TestComponent
+					attributes={ {} }
+					offset={ 0 }
+					order="desc"
+					orderby="date_gmt"
+					productId={ 1 }
+					reviewsToDisplay={ 10 }
+				/>
+			)
+		);
+
+		expect( getReviews ).toHaveBeenNthCalledWith( 2, {
+			...defaultArgs,
+			offset: 0,
+			per_page: 10,
+		} );
+	} );
+
 	describe( 'when the API returns product data', () => {
 		beforeEach( async () => {
 			mockUtils.getReviews.mockImplementation( () =>
@@ -134,12 +162,29 @@ describe( 'withReviews Component', () => {

 		it( 'sets reviews based on API response', () => {
 			expect( lastProps.error ).toBeNull();
+			expect( lastProps.hasReviewsHiddenByOffset ).toBe( false );
 			expect( lastProps.isLoading ).toBe( false );
 			expect( lastProps.reviews ).toEqual( mockReviews.slice( 0, 2 ) );
 			expect( lastProps.totalReviews ).toEqual( mockReviews.length );
 		} );
 	} );

+	describe( 'when the offset hides all available reviews', () => {
+		beforeEach( async () => {
+			mockUtils.getReviews.mockResolvedValue( {
+				reviews: [],
+				totalReviews: mockReviews.length,
+			} );
+			await renderComponent( { offset: mockReviews.length } );
+		} );
+
+		it( 'indicates that reviews are hidden by the offset', () => {
+			expect( lastProps.hasReviewsHiddenByOffset ).toBe( true );
+			expect( lastProps.reviews ).toEqual( [] );
+			expect( lastProps.totalReviews ).toEqual( 0 );
+		} );
+	} );
+
 	describe( 'when the API returns an error', () => {
 		const error = { message: 'There was an error.' };
 		const formattedError = { message: 'There was an error.', type: 'api' };
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/hocs/with-reviews.tsx b/plugins/woocommerce/client/blocks/assets/js/base/hocs/with-reviews.tsx
index a01bf9f5e6c..39a09d952df 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/hocs/with-reviews.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/base/hocs/with-reviews.tsx
@@ -18,6 +18,7 @@ interface WithReviewsProps {
 	reviewsToDisplay: number;
 	categoryIds?: string | string[];
 	delayFunction?: ( f: () => void ) => DelayedFunction;
+	offset?: number;
 	onReviewsAppended?: () => void;
 	onReviewsLoadError?: ( error: ErrorObject ) => void;
 	onReviewsReplaced?: () => void;
@@ -29,6 +30,7 @@ interface WithReviewsProps {

 interface WithReviewsState {
 	error: string | ErrorObject | null;
+	hasReviewsHiddenByOffset: boolean;
 	loading: boolean;
 	reviews: Review[];
 	totalReviews: number;
@@ -56,6 +58,7 @@ const withReviews = (

 		state: WithReviewsState = {
 			error: null,
+			hasReviewsHiddenByOffset: false,
 			loading: true,
 			reviews:
 				this.isPreview && this.props.attributes?.previewReviews
@@ -73,13 +76,15 @@ const withReviews = (
 		}

 		componentDidUpdate( prevProps: WithReviewsProps ) {
-			if ( prevProps.reviewsToDisplay < this.props.reviewsToDisplay ) {
+			if ( this.shouldReplaceReviews( prevProps, this.props ) ) {
+				this.replaceReviews();
+			} else if (
+				prevProps.reviewsToDisplay < this.props.reviewsToDisplay
+			) {
 				// Since this attribute might be controlled via something with
 				// short intervals between value changes, this allows for optionally
 				// delaying review fetches via the provided delay function.
 				this.delayedAppendReviews();
-			} else if ( this.shouldReplaceReviews( prevProps, this.props ) ) {
-				this.replaceReviews();
 			}
 		}

@@ -90,6 +95,7 @@ const withReviews = (
 			return (
 				prevProps.orderby !== nextProps.orderby ||
 				prevProps.order !== nextProps.order ||
+				this.getOffset( prevProps ) !== this.getOffset( nextProps ) ||
 				prevProps.productId !== nextProps.productId ||
 				! isShallowEqual(
 					prevProps.categoryIds as string[],
@@ -108,6 +114,14 @@ const withReviews = (
 			}
 		}

+		getOffset( props = this.props ) {
+			const parsedOffset = Number( props.offset ?? 0 );
+
+			return Number.isInteger( parsedOffset ) && parsedOffset >= 0
+				? parsedOffset
+				: 0;
+		}
+
 		getArgs( reviewsToSkip: number ) {
 			const { categoryIds, order, orderby, productId, reviewsToDisplay } =
 				this.props;
@@ -115,7 +129,7 @@ const withReviews = (
 				order,
 				orderby,
 				per_page: reviewsToDisplay - reviewsToSkip,
-				offset: reviewsToSkip,
+				offset: this.getOffset() + reviewsToSkip,
 			};

 			if ( categoryIds ) {
@@ -167,6 +181,7 @@ const withReviews = (
 		updateListOfReviews( oldReviews: Review[] = [] ) {
 			const { reviewsToDisplay } = this.props;
 			const { totalReviews } = this.state;
+			const configuredOffset = this.getOffset();
 			const reviewsToLoad =
 				Math.min( totalReviews, reviewsToDisplay ) - oldReviews.length;

@@ -181,15 +196,24 @@ const withReviews = (
 						reviews: newReviews,
 						totalReviews: newTotalReviews,
 					} ) => {
+						const availableReviews = Math.max(
+							newTotalReviews - configuredOffset,
+							0
+						);
+
 						if ( this.isMounted ) {
 							this.setState( {
+								hasReviewsHiddenByOffset:
+									configuredOffset > 0 &&
+									newTotalReviews > 0 &&
+									availableReviews === 0,
 								reviews: oldReviews
 									.filter(
 										( review ) =>
 											Object.keys( review ).length
 									)
 									.concat( newReviews ),
-								totalReviews: newTotalReviews,
+								totalReviews: availableReviews,
 								loading: false,
 								error: null,
 							} );
@@ -216,12 +240,19 @@ const withReviews = (

 		render() {
 			const { reviewsToDisplay } = this.props;
-			const { error, loading, reviews, totalReviews } = this.state;
+			const {
+				error,
+				hasReviewsHiddenByOffset,
+				loading,
+				reviews,
+				totalReviews,
+			} = this.state;

 			return (
 				<OriginalComponent
 					{ ...this.props }
 					error={ error }
+					hasReviewsHiddenByOffset={ hasReviewsHiddenByOffset }
 					isLoading={ loading }
 					reviews={ reviews.slice( 0, reviewsToDisplay ) }
 					totalReviews={ totalReviews }
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/reviews/attributes.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/reviews/attributes.ts
index a4a79bc4006..75a10053f80 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/reviews/attributes.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/reviews/attributes.ts
@@ -110,6 +110,7 @@ export default {
 export type ReviewBlockAttributes = BlockAttributes & {
 	editMode: boolean;
 	imageType: string;
+	offset?: number;
 	orderby: 'most-recent' | 'highest-rating' | 'lowest-rating';
 	reviewsOnLoadMore: number;
 	reviewsOnPageLoad: number;
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/reviews/edit-utils.js b/plugins/woocommerce/client/blocks/assets/js/blocks/reviews/edit-utils.js
index 3b9f7c80133..b4ce4c5f80d 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/reviews/edit-utils.js
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/reviews/edit-utils.js
@@ -9,9 +9,12 @@ import {
 	Notice,
 	ToggleControl,
 	ToolbarGroup,
-	RangeControl,
 	SelectControl,
 	// eslint-disable-next-line @wordpress/no-unsafe-wp-apis
+	__experimentalInputControl as InputControl,
+	// eslint-disable-next-line @wordpress/no-unsafe-wp-apis
+	__experimentalNumberControl as NumberControl,
+	// eslint-disable-next-line @wordpress/no-unsafe-wp-apis
 	__experimentalToggleGroupControl as ToggleGroupControl,
 	// eslint-disable-next-line @wordpress/no-unsafe-wp-apis
 	__experimentalToggleGroupControlOption as ToggleGroupControlOption,
@@ -19,6 +22,20 @@ import {
 	__experimentalToolsPanelItem as ToolsPanelItem,
 } from '@wordpress/components';

+const MIN_REVIEW_COUNT = 1;
+const MAX_REVIEW_COUNT = 20;
+const DEFAULT_REVIEW_COUNT = 10;
+
+const normalizeReviewCount = ( value ) => {
+	const parsedValue = Number.parseInt( value, 10 );
+	const reviewCount = Number.isNaN( parsedValue )
+		? DEFAULT_REVIEW_COUNT
+		: parsedValue;
+	return Math.max(
+		MIN_REVIEW_COUNT,
+		Math.min( MAX_REVIEW_COUNT, reviewCount )
+	);
+};
 export const getBlockControls = ( editMode, setAttributes, buttonTitle ) => (
 	<BlockControls>
 		<ToolbarGroup
@@ -100,6 +117,42 @@ export const getSharedReviewContentControls = ( attributes, setAttributes ) => {
 					}
 				/>
 			</ToolsPanelItem>
+			<ToolsPanelItem
+				hasValue={ () => ! attributes.showReviewDate }
+				label={ __( 'Review date', 'woocommerce' ) }
+				onDeselect={ () => setAttributes( { showReviewDate: true } ) }
+				isShownByDefault
+			>
+				<ToggleControl
+					__nextHasNoMarginBottom
+					label={ __( 'Review date', 'woocommerce' ) }
+					checked={ attributes.showReviewDate }
+					onChange={ () =>
+						setAttributes( {
+							showReviewDate: ! attributes.showReviewDate,
+						} )
+					}
+				/>
+			</ToolsPanelItem>
+			<ToolsPanelItem
+				hasValue={ () => ! attributes.showReviewContent }
+				label={ __( 'Review content', 'woocommerce' ) }
+				onDeselect={ () =>
+					setAttributes( { showReviewContent: true } )
+				}
+				isShownByDefault
+			>
+				<ToggleControl
+					__nextHasNoMarginBottom
+					label={ __( 'Review content', 'woocommerce' ) }
+					checked={ attributes.showReviewContent }
+					onChange={ () =>
+						setAttributes( {
+							showReviewContent: ! attributes.showReviewContent,
+						} )
+					}
+				/>
+			</ToolsPanelItem>
 			<ToolsPanelItem
 				hasValue={ () =>
 					! attributes.showReviewImage ||
@@ -177,77 +230,29 @@ export const getSharedReviewContentControls = ( attributes, setAttributes ) => {
 					) }
 				</div>
 			</ToolsPanelItem>
-			<ToolsPanelItem
-				hasValue={ () => ! attributes.showReviewDate }
-				label={ __( 'Review date', 'woocommerce' ) }
-				onDeselect={ () => setAttributes( { showReviewDate: true } ) }
-				isShownByDefault
-			>
-				<ToggleControl
-					__nextHasNoMarginBottom
-					label={ __( 'Review date', 'woocommerce' ) }
-					checked={ attributes.showReviewDate }
-					onChange={ () =>
-						setAttributes( {
-							showReviewDate: ! attributes.showReviewDate,
-						} )
-					}
-				/>
-			</ToolsPanelItem>
-			<ToolsPanelItem
-				hasValue={ () => ! attributes.showReviewContent }
-				label={ __( 'Review content', 'woocommerce' ) }
-				onDeselect={ () =>
-					setAttributes( { showReviewContent: true } )
-				}
-				isShownByDefault
-			>
-				<ToggleControl
-					__nextHasNoMarginBottom
-					label={ __( 'Review content', 'woocommerce' ) }
-					checked={ attributes.showReviewContent }
-					onChange={ () =>
-						setAttributes( {
-							showReviewContent: ! attributes.showReviewContent,
-						} )
-					}
-				/>
-			</ToolsPanelItem>
 		</>
 	);
 };

-export const getSharedReviewListControls = ( attributes, setAttributes ) => {
-	const minPerPage = 1;
-	const maxPerPage = 20;
+export const getSharedReviewListControls = (
+	attributes,
+	setAttributes,
+	{ showOffset = false } = {}
+) => {
+	const defaultOffset = 0;

 	return (
 		<>
-			<ToolsPanelItem
-				hasValue={ () => ! attributes.showOrderby }
-				label={ __( 'Order by', 'woocommerce' ) }
-				onDeselect={ () => setAttributes( { showOrderby: true } ) }
-				isShownByDefault
-			>
-				<ToggleControl
-					__nextHasNoMarginBottom
-					label={ __( 'Order by', 'woocommerce' ) }
-					checked={ attributes.showOrderby }
-					onChange={ () =>
-						setAttributes( {
-							showOrderby: ! attributes.showOrderby,
-						} )
-					}
-				/>
-			</ToolsPanelItem>
 			<ToolsPanelItem
 				hasValue={ () => attributes.orderby !== 'most-recent' }
-				label={ __( 'Order Product Reviews by', 'woocommerce' ) }
+				label={ __( 'Default sort order', 'woocommerce' ) }
 				onDeselect={ () => setAttributes( { orderby: 'most-recent' } ) }
 				isShownByDefault
 			>
 				<SelectControl
-					label={ __( 'Order Product Reviews by', 'woocommerce' ) }
+					__next40pxDefaultSize
+					__nextHasNoMarginBottom
+					label={ __( 'Default sort order', 'woocommerce' ) }
 					value={ attributes.orderby }
 					options={ [
 						{
@@ -267,31 +272,106 @@ export const getSharedReviewListControls = ( attributes, setAttributes ) => {
 				/>
 			</ToolsPanelItem>
 			<ToolsPanelItem
-				hasValue={ () => attributes.reviewsOnPageLoad !== 10 }
-				label={ __( 'Starting Number of Reviews', 'woocommerce' ) }
-				onDeselect={ () => setAttributes( { reviewsOnPageLoad: 10 } ) }
+				hasValue={ () => ! attributes.showOrderby }
+				label={ __( 'Show "Order by" dropdown', 'woocommerce' ) }
+				onDeselect={ () => setAttributes( { showOrderby: true } ) }
 				isShownByDefault
 			>
-				<RangeControl
-					label={ __( 'Starting Number of Reviews', 'woocommerce' ) }
-					value={ attributes.reviewsOnPageLoad }
-					onChange={ ( reviewsOnPageLoad ) =>
-						setAttributes( { reviewsOnPageLoad } )
+				<ToggleControl
+					__nextHasNoMarginBottom
+					label={ __( 'Show "Order by" dropdown', 'woocommerce' ) }
+					checked={ attributes.showOrderby }
+					onChange={ () =>
+						setAttributes( {
+							showOrderby: ! attributes.showOrderby,
+						} )
 					}
-					max={ maxPerPage }
-					min={ minPerPage }
 				/>
 			</ToolsPanelItem>
+			<ToolsPanelItem
+				hasValue={ () =>
+					attributes.reviewsOnPageLoad !== DEFAULT_REVIEW_COUNT
+				}
+				label={ __( 'Number of reviews', 'woocommerce' ) }
+				onDeselect={ () =>
+					setAttributes( {
+						reviewsOnPageLoad: DEFAULT_REVIEW_COUNT,
+					} )
+				}
+				isShownByDefault
+			>
+				<InputControl
+					__next40pxDefaultSize
+					label={ __( 'Number of reviews', 'woocommerce' ) }
+					value={ String( attributes.reviewsOnPageLoad ) }
+					onChange={ ( value ) => {
+						setAttributes( {
+							reviewsOnPageLoad: normalizeReviewCount( value ),
+						} );
+					} }
+					type="number"
+					max={ MAX_REVIEW_COUNT }
+					min={ MIN_REVIEW_COUNT }
+					step={ 1 }
+				/>
+			</ToolsPanelItem>
+			{ showOffset && (
+				<ToolsPanelItem
+					hasValue={ () =>
+						( attributes.offset ?? defaultOffset ) !== defaultOffset
+					}
+					label={ __( 'Offset', 'woocommerce' ) }
+					onDeselect={ () =>
+						setAttributes( { offset: defaultOffset } )
+					}
+					isShownByDefault
+				>
+					<NumberControl
+						__next40pxDefaultSize
+						__unstableStateReducer={ ( state ) =>
+							Object.is( Number( state.value ), -0 )
+								? {
+										...state,
+										value: String( defaultOffset ),
+								  }
+								: state
+						}
+						label={ __( 'Offset', 'woocommerce' ) }
+						help={ __(
+							'Number of reviews to skip',
+							'woocommerce'
+						) }
+						value={ attributes.offset ?? defaultOffset }
+						onChange={ ( value ) => {
+							if ( value === '' ) {
+								return;
+							}
+
+							const offset = Number( value );
+							if (
+								! Number.isInteger( offset ) ||
+								offset < defaultOffset
+							) {
+								return;
+							}
+
+							setAttributes( { offset } );
+						} }
+						min={ defaultOffset }
+						step={ 1 }
+					/>
+				</ToolsPanelItem>
+			) }
 			<ToolsPanelItem
 				hasValue={ () =>
 					! attributes.showLoadMore ||
-					attributes.reviewsOnLoadMore !== 10
+					attributes.reviewsOnLoadMore !== DEFAULT_REVIEW_COUNT
 				}
-				label={ __( 'Load more', 'woocommerce' ) }
+				label={ __( 'Show "Load more" button', 'woocommerce' ) }
 				onDeselect={ () =>
 					setAttributes( {
 						showLoadMore: true,
-						reviewsOnLoadMore: 10,
+						reviewsOnLoadMore: DEFAULT_REVIEW_COUNT,
 					} )
 				}
 				isShownByDefault
@@ -299,7 +379,7 @@ export const getSharedReviewListControls = ( attributes, setAttributes ) => {
 				<div className="wc-block-reviews__tools-panel-item-container">
 					<ToggleControl
 						__nextHasNoMarginBottom
-						label={ __( 'Load more', 'woocommerce' ) }
+						label={ __( 'Show "Load more" button', 'woocommerce' ) }
 						checked={ attributes.showLoadMore }
 						onChange={ () =>
 							setAttributes( {
@@ -308,14 +388,20 @@ export const getSharedReviewListControls = ( attributes, setAttributes ) => {
 						}
 					/>
 					{ attributes.showLoadMore && (
-						<RangeControl
-							label={ __( 'Load More Reviews', 'woocommerce' ) }
-							value={ attributes.reviewsOnLoadMore }
-							onChange={ ( reviewsOnLoadMore ) =>
-								setAttributes( { reviewsOnLoadMore } )
+						<InputControl
+							__next40pxDefaultSize
+							label={ __( 'Load more reviews', 'woocommerce' ) }
+							value={ String( attributes.reviewsOnLoadMore ) }
+							onChange={ ( value ) =>
+								setAttributes( {
+									reviewsOnLoadMore:
+										normalizeReviewCount( value ),
+								} )
 							}
-							max={ maxPerPage }
-							min={ minPerPage }
+							type="number"
+							max={ MAX_REVIEW_COUNT }
+							min={ MIN_REVIEW_COUNT }
+							step={ 1 }
 						/>
 					) }
 				</div>
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/reviews/editor-block.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/reviews/editor-block.tsx
index 382b184c9bf..3b1db1e76f8 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/reviews/editor-block.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/reviews/editor-block.tsx
@@ -26,9 +26,11 @@ interface EditorBlockProps {
 	reviews: Review[];
 	totalReviews: number;
 	error?: ErrorObject;
+	hasReviewsHiddenByOffset: boolean;
 	isLoading: boolean;
 	noReviewsPlaceholder: React.ComponentType< {
 		attributes: EditorBlockProps[ 'attributes' ];
+		reason: 'no-reviews' | 'offset';
 	} >;
 }

@@ -40,6 +42,7 @@ class EditorBlock extends Component< EditorBlockProps > {
 		const {
 			attributes,
 			error,
+			hasReviewsHiddenByOffset,
 			isLoading,
 			noReviewsPlaceholder: NoReviewsPlaceholder,
 			reviews,
@@ -57,7 +60,14 @@ class EditorBlock extends Component< EditorBlockProps > {
 		}

 		if ( reviews.length === 0 && ! isLoading ) {
-			return <NoReviewsPlaceholder attributes={ attributes } />;
+			return (
+				<NoReviewsPlaceholder
+					attributes={ attributes }
+					reason={
+						hasReviewsHiddenByOffset ? 'offset' : 'no-reviews'
+					}
+				/>
+			);
 		}

 		const reviewRatingsEnabled = getSetting( 'reviewRatingsEnabled', true );
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/reviews/editor-container-block.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/reviews/editor-container-block.tsx
index 384a9f52520..d1dd263da63 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/reviews/editor-container-block.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/reviews/editor-container-block.tsx
@@ -58,6 +58,7 @@ const EditorContainerBlock = ( {
 					debounce( callback, 400 )
 				}
 				noReviewsPlaceholder={ noReviewsPlaceholder }
+				offset={ attributes.offset ?? 0 }
 				orderby={ orderby }
 				order={ order }
 				productId={ productId }
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/reviews/frontend-container-block.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/reviews/frontend-container-block.tsx
index 977e44f78f2..c36c74233fc 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/reviews/frontend-container-block.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/reviews/frontend-container-block.tsx
@@ -53,6 +53,13 @@ class FrontendContainerBlock extends Component<
 			: parseInt( attributes.reviewsOnLoadMore, 10 );
 	}

+	getOffset() {
+		const { attributes } = this.props;
+		const offset = Number( attributes.offset ?? 0 );
+
+		return Number.isInteger( offset ) && offset >= 0 ? offset : 0;
+	}
+
 	onAppendReviews() {
 		const { reviewsToDisplay } = this.state;

@@ -111,6 +118,7 @@ class FrontendContainerBlock extends Component<
 				onReviewsAppended={ this.onReviewsAppended }
 				onReviewsLoadError={ this.onReviewsLoadError }
 				onReviewsReplaced={ this.onReviewsReplaced }
+				offset={ this.getOffset() }
 				order={ order }
 				orderby={ orderby }
 				productId={ productId }
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/reviews/frontend.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/reviews/frontend.ts
index 6c2535c748b..8f76999365d 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/reviews/frontend.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/reviews/frontend.ts
@@ -17,12 +17,19 @@ const selector = `
 const getProps = ( el: HTMLElement ) => {
 	const showOrderby = el.dataset.showOrderby === 'true';
 	const showLoadMore = el.dataset.showLoadMore === 'true';
+	const parsedOffset = Number( el.dataset.offset ?? 0 );
+	const offset =
+		Number.isInteger( parsedOffset ) && parsedOffset >= 0
+			? parsedOffset
+			: 0;
+
 	const isFilteredReviewsBlock =
 		el.classList.contains( 'wp-block-woocommerce-reviews-by-product' ) ||
 		el.classList.contains( 'wp-block-woocommerce-reviews-by-category' );

 	return {
 		attributes: {
+			offset,
 			isFilteredReviewsBlock,
 			showOrderby,
 			showLoadMore,
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/reviews/reviews-by-category/block.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/reviews/reviews-by-category/block.tsx
index db5c0879a9b..3c7b947985c 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/reviews/reviews-by-category/block.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/reviews/reviews-by-category/block.tsx
@@ -92,12 +92,15 @@ const ReviewsByCategoryEditor = ( {
 							showOrderby: true,
 							orderby: 'most-recent',
 							reviewsOnPageLoad: 10,
+							offset: 0,
 							showLoadMore: true,
 							reviewsOnLoadMore: 10,
 						} )
 					}
 				>
-					{ getSharedReviewListControls( attributes, setAttributes ) }
+					{ getSharedReviewListControls( attributes, setAttributes, {
+						showOffset: true,
+					} ) }
 				</ToolsPanel>
 				<ToolsPanel
 					label={ __( 'Category', 'woocommerce' ) }
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/reviews/reviews-by-category/index.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/reviews/reviews-by-category/index.tsx
index 730eb27c50d..965660765d8 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/reviews/reviews-by-category/index.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/reviews/reviews-by-category/index.tsx
@@ -34,6 +34,13 @@ registerBlockType( metadata, {
 	},
 	attributes: {
 		...sharedAttributes,
+		/**
+		 * Number of reviews to skip.
+		 */
+		offset: {
+			type: 'number',
+			default: 0,
+		},
 		/**
 		 * The ids of the categories to load reviews for.
 		 */
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/reviews/reviews-by-category/no-reviews-placeholder.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/reviews/reviews-by-category/no-reviews-placeholder.tsx
index 963e51bac96..8e4777b5ba7 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/reviews/reviews-by-category/no-reviews-placeholder.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/reviews/reviews-by-category/no-reviews-placeholder.tsx
@@ -4,7 +4,14 @@
 import { __ } from '@wordpress/i18n';
 import { Placeholder } from '@wordpress/components';
 import { Icon, commentContent } from '@wordpress/icons';
-const NoReviewsPlaceholder = (): JSX.Element => {
+
+interface NoReviewsPlaceholderProps {
+	reason: 'no-reviews' | 'offset';
+}
+
+const NoReviewsPlaceholder = ( {
+	reason,
+}: NoReviewsPlaceholderProps ): JSX.Element => {
 	return (
 		<Placeholder
 			className="wc-block-reviews-by-category"
@@ -16,10 +23,15 @@ const NoReviewsPlaceholder = (): JSX.Element => {
 			}
 			label={ __( 'Reviews by Category', 'woocommerce' ) }
 		>
-			{ __(
-				'This block lists reviews for products from selected categories. The selected categories do not have any reviews yet, but they will show up here when they do.',
-				'woocommerce'
-			) }
+			{ reason === 'offset'
+				? __(
+						'No reviews are visible with the current offset. Reduce the offset to display reviews.',
+						'woocommerce'
+				  )
+				: __(
+						'This block lists reviews for products from selected categories. The selected categories do not have any reviews yet, but they will show up here when they do.',
+						'woocommerce'
+				  ) }
 		</Placeholder>
 	);
 };
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/reviews/reviews-by-category/types.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/reviews/reviews-by-category/types.ts
index aa48335d305..86719c6a015 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/reviews/reviews-by-category/types.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/reviews/reviews-by-category/types.ts
@@ -2,11 +2,13 @@ export interface ReviewsByCategoryEditorProps {
 	attributes: {
 		editMode: boolean;
 		categoryIds: number[];
+		offset: number;
 		showProductName: boolean;
 	};
 	setAttributes: ( attributes: {
 		editMode?: boolean;
 		categoryIds?: number[];
+		offset?: number;
 		showProductName?: boolean;
 	} ) => void;
 	debouncedSpeak: ( message: string ) => void;
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/reviews/reviews-by-product/edit.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/reviews/reviews-by-product/edit.tsx
index a1cbba691ec..43cff3894f4 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/reviews/reviews-by-product/edit.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/reviews/reviews-by-product/edit.tsx
@@ -101,12 +101,15 @@ const ReviewsByProductEditor = ( {
 							showOrderby: true,
 							orderby: 'most-recent',
 							reviewsOnPageLoad: 10,
+							offset: 0,
 							showLoadMore: true,
 							reviewsOnLoadMore: 10,
 						} )
 					}
 				>
-					{ getSharedReviewListControls( attributes, setAttributes ) }
+					{ getSharedReviewListControls( attributes, setAttributes, {
+						showOffset: true,
+					} ) }
 				</ToolsPanel>
 				<ToolsPanel
 					label={ __( 'Product', 'woocommerce' ) }
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/reviews/reviews-by-product/index.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/reviews/reviews-by-product/index.tsx
index f52feac8f54..8d06be15ae9 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/reviews/reviews-by-product/index.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/reviews/reviews-by-product/index.tsx
@@ -33,6 +33,13 @@ registerBlockType( metadata, {
 	},
 	attributes: {
 		...sharedAttributes,
+		/**
+		 * Number of reviews to skip.
+		 */
+		offset: {
+			type: 'number',
+			default: 0,
+		},
 		/**
 		 * The id of the product to load reviews for.
 		 */
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/reviews/reviews-by-product/no-reviews-placeholder.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/reviews/reviews-by-product/no-reviews-placeholder.tsx
index 103f5ee2406..7a3350dd790 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/reviews/reviews-by-product/no-reviews-placeholder.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/reviews/reviews-by-product/no-reviews-placeholder.tsx
@@ -20,6 +20,7 @@ interface NoReviewsPlaceholderProps {
 	getProduct: () => void;
 	isLoading: boolean;
 	product?: Product;
+	reason: 'no-reviews' | 'offset';
 }

 const NoReviewsPlaceholder = ( {
@@ -27,6 +28,7 @@ const NoReviewsPlaceholder = ( {
 	getProduct,
 	isLoading,
 	product,
+	reason,
 }: NoReviewsPlaceholderProps ) => {
 	const renderApiError = () => (
 		<ErrorPlaceholder
@@ -41,19 +43,29 @@ const NoReviewsPlaceholder = ( {
 		return renderApiError();
 	}

-	const content =
-		! product || isLoading ? (
-			<Spinner />
-		) : (
-			sprintf(
-				/* translators: %s is the product name. */
-				__(
-					"This block lists reviews for a selected product. %s doesn't have any reviews yet, but they will show up here when it does.",
+	let content =
+		reason === 'offset'
+			? __(
+					'No reviews are visible with the current offset. Reduce the offset to display reviews.',
 					'woocommerce'
-				),
-				decodeEntities( product.name )
-			)
-		);
+			  )
+			: undefined;
+
+	if ( ! content ) {
+		content =
+			! product || isLoading ? (
+				<Spinner />
+			) : (
+				sprintf(
+					/* translators: %s is the product name. */
+					__(
+						"This block lists reviews for a selected product. %s doesn't have any reviews yet, but they will show up here when it does.",
+						'woocommerce'
+					),
+					decodeEntities( product.name )
+				)
+			);
+	}

 	return (
 		<Placeholder
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/reviews/reviews-by-product/types.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/reviews/reviews-by-product/types.ts
index 297d7f62fbe..cf57d287774 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/reviews/reviews-by-product/types.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/reviews/reviews-by-product/types.ts
@@ -5,6 +5,7 @@ import { BlockEditProps } from '@wordpress/blocks';

 interface ReviewByProductAttributes {
 	editMode: boolean;
+	offset: number;
 	productId: number;
 }

diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/reviews/types.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/reviews/types.ts
index 2bef274784c..264dafa7967 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/reviews/types.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/reviews/types.ts
@@ -17,6 +17,7 @@ export interface Attributes {
 	categoryIds?: number[];
 	editMode?: boolean;
 	imageType?: string;
+	offset?: number;
 	orderby?: string;
 	productId?: number;
 	reviewsOnLoadMore?: number;
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/reviews/utils.js b/plugins/woocommerce/client/blocks/assets/js/blocks/reviews/utils.js
index f20df688d12..dad33ad3332 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/reviews/utils.js
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/reviews/utils.js
@@ -85,6 +85,7 @@ export const getDataAttrs = ( attributes ) => {
 	const {
 		categoryIds,
 		imageType,
+		offset,
 		orderby,
 		productId,
 		reviewsOnPageLoad,
@@ -102,6 +103,11 @@ export const getDataAttrs = ( attributes ) => {
 		'data-show-orderby': showOrderby,
 	};

+	const normalizedOffset = Number( offset );
+	if ( Number.isInteger( normalizedOffset ) && normalizedOffset > 0 ) {
+		data[ 'data-offset' ] = normalizedOffset;
+	}
+
 	if ( productId ) {
 		data[ 'data-product-id' ] = productId;
 	}
diff --git a/plugins/woocommerce/client/blocks/changelog/66286-add-review-block-offset b/plugins/woocommerce/client/blocks/changelog/66286-add-review-block-offset
new file mode 100644
index 00000000000..8e069f9a110
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/changelog/66286-add-review-block-offset
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add offset controls to the Reviews by Product and Reviews by Category blocks.
diff --git a/plugins/woocommerce/tests/e2e/tests/blocks/reviews-by-category/reviews-by-category.block_theme.spec.ts b/plugins/woocommerce/tests/e2e/tests/blocks/reviews-by-category/reviews-by-category.block_theme.spec.ts
index addfe575572..09e686b50bb 100644
--- a/plugins/woocommerce/tests/e2e/tests/blocks/reviews-by-category/reviews-by-category.block_theme.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/blocks/reviews-by-category/reviews-by-category.block_theme.spec.ts
@@ -6,7 +6,7 @@ import { expect, test } from '@woocommerce/e2e-utils';
 /**
  * Internal dependencies
  */
-import { hoodieReviews } from '../../../test-data/blocks/data/data';
+import { hoodieReviews, allReviews } from '../../../test-data/blocks/data/data';

 const latestReview = hoodieReviews[ hoodieReviews.length - 1 ];

@@ -105,8 +105,72 @@ test.describe( `${ BLOCK_NAME } Block`, () => {
 		await editor.publishAndVisitPost();

 		const block = await frontendUtils.getBlockByName( BLOCK_NAME );
+
 		await expect(
 			block.locator( '.wc-block-components-review-list-item__text' )
 		).toHaveCount( 0 );
 	} );
+
+	test( 'can skip reviews with an offset and load subsequent reviews', async ( {
+		page,
+		admin,
+		frontendUtils,
+		editor,
+	} ) => {
+		await admin.createNewPost();
+		await editor.insertBlock( { name: BLOCK_NAME } );
+		const blockLocator = await editor.getBlockByName( BLOCK_NAME );
+		await blockLocator
+			.getByRole( 'checkbox', {
+				name: 'Clothing',
+				exact: true,
+			} )
+			.check();
+		await blockLocator
+			.getByRole( 'checkbox', { name: /Accessories/ } )
+			.check();
+		await blockLocator
+			.getByRole( 'button', {
+				name: 'Done',
+			} )
+			.click();
+
+		await editor.openDocumentSettingsSidebar();
+		const sidebarSettings = page.getByRole( 'region', {
+			name: 'Editor settings',
+		} );
+		await sidebarSettings
+			.getByRole( 'spinbutton', { name: 'Number of reviews' } )
+			.fill( '10' );
+		await sidebarSettings
+			.getByRole( 'spinbutton', { name: 'Offset' } )
+			.fill( '1' );
+
+		await expect(
+			editor.canvas.getByText( allReviews[ 0 ].review )
+		).toBeVisible();
+		await expect(
+			editor.canvas.getByText( allReviews[ 1 ].review )
+		).toBeVisible();
+		await expect(
+			editor.canvas.getByText( allReviews[ 2 ].review )
+		).toBeVisible();
+		await expect(
+			editor.canvas.getByText( allReviews[ 3 ].review )
+		).toBeVisible();
+
+		await expect(
+			editor.canvas.getByText( allReviews[ 4 ].review )
+		).toBeHidden();
+
+		await editor.publishAndVisitPost();
+
+		const block = await frontendUtils.getBlockByName( BLOCK_NAME );
+
+		await expect( block.getByText( allReviews[ 0 ].review ) ).toBeVisible();
+		await expect( block.getByText( allReviews[ 1 ].review ) ).toBeVisible();
+		await expect( block.getByText( allReviews[ 2 ].review ) ).toBeVisible();
+		await expect( block.getByText( allReviews[ 3 ].review ) ).toBeVisible();
+		await expect( block.getByText( allReviews[ 4 ].review ) ).toBeHidden();
+	} );
 } );
diff --git a/plugins/woocommerce/tests/e2e/tests/blocks/reviews-by-product/reviews-by-product.block_theme.spec.ts b/plugins/woocommerce/tests/e2e/tests/blocks/reviews-by-product/reviews-by-product.block_theme.spec.ts
index bf155726a36..3973bcda605 100644
--- a/plugins/woocommerce/tests/e2e/tests/blocks/reviews-by-product/reviews-by-product.block_theme.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/blocks/reviews-by-product/reviews-by-product.block_theme.spec.ts
@@ -101,8 +101,61 @@ test.describe( `${ BLOCK_NAME } Block`, () => {
 		await editor.publishAndVisitPost();

 		const block = await frontendUtils.getBlockByName( BLOCK_NAME );
+
 		await expect(
 			block.locator( '.wc-block-components-review-list-item__text' )
 		).toHaveCount( 0 );
 	} );
+
+	test( 'can skip reviews with an offset in the editor and frontend', async ( {
+		page,
+		admin,
+		frontendUtils,
+		editor,
+	} ) => {
+		await admin.createNewPost();
+		await editor.insertBlock( { name: BLOCK_NAME } );
+		const productCheckbox = editor.canvas.getByLabel(
+			'Hoodie, has 3 reviews'
+		);
+		await productCheckbox.check();
+		await editor.canvas
+			.getByRole( 'button', {
+				name: 'Done',
+			} )
+			.click();
+
+		await editor.openDocumentSettingsSidebar();
+		const sidebarSettings = page.getByRole( 'region', {
+			name: 'Editor settings',
+		} );
+
+		await sidebarSettings
+			.getByRole( 'spinbutton', { name: 'Offset' } )
+			.fill( '1' );
+
+		await expect(
+			editor.canvas.getByText( hoodieReviews[ 0 ].review )
+		).toBeVisible();
+		await expect(
+			editor.canvas.getByText( hoodieReviews[ 1 ].review )
+		).toBeVisible();
+		await expect(
+			editor.canvas.getByText( hoodieReviews[ 2 ].review )
+		).toBeHidden();
+
+		await editor.publishAndVisitPost();
+
+		const block = await frontendUtils.getBlockByName( BLOCK_NAME );
+
+		await expect(
+			block.getByText( hoodieReviews[ 0 ].review )
+		).toBeVisible();
+		await expect(
+			block.getByText( hoodieReviews[ 1 ].review )
+		).toBeVisible();
+		await expect(
+			block.getByText( hoodieReviews[ 2 ].review )
+		).toBeHidden();
+	} );
 } );