Commit 0b5899251c3 for woocommerce

commit 0b5899251c3307c93c13fbbc319c20b92b131212
Author: Taha Paksu <3295+tpaksu@users.noreply.github.com>
Date:   Tue May 12 23:13:09 2026 +0300

    Pre-fill the Review Order form with the customer's existing review (#64529)

    * Add accessible star-rating control for Review Order page

    Native `<input type="radio">` ✕ 5 wrapped in a `role="radiogroup"` container,
    visually replaced by SVG stars. Progressive enhancement adds:

    - Keyboard nav: ArrowLeft/Up, ArrowRight/Down, Home, End. Selecting also
      moves focus and dispatches `change`, so any future submission listener
      reacts naturally.
    - Dynamic caption underneath the stars, populated from the focused/checked
      radio's `data-label`. Defaults match the existing WC product-review
      labels (Very poor / Not that bad / Average / Good / Perfect) and are
      filterable via `woocommerce_review_order_rating_labels`.
    - High-contrast focus ring on the wrapping label whenever its input is
      `:focus-visible`.
    - Hover preview lights stars left of the cursor; selection persists when
      the cursor leaves.

    Without JavaScript the underlying radio group still works as a plain
    form field, so the page stays functional.

    Files:

    - `src/Internal/OrderReviews/StarRating.php` — server-side renderer.
      Builds the markup and exposes `get_labels()` (filter-aware, with safe
      fallbacks for buggy filters that drop slot keys).
    - `templates/order/star-rating.php` — theme-overridable partial.
    - `client/legacy/js/frontend/order-review.js` — vanilla JS, no jQuery.
    - `client/legacy/css/order-review.scss` — visual states.
    - `Internal/OrderReviews/Endpoint::enqueue_assets()` — script + style
      enqueued only when the review-order endpoint is actually rendering.
    - `templates/order/customer-review-order.php` — temporary preview render
      per item so reviewers can interact with the control. M4 (WOOPLUG-6595)
      swaps this preview for the real per-item form row.

    Tests:

    - `Internal\OrderReviews\StarRatingTest` — markup, defaults, filter
      override, filter fallback for missing keys.

    Closes #64308 (WOOPLUG-6594).

    * Add changefile(s) from automation for the following project(s): woocommerce

    * Remove duplicate changelog file (workflow generates the canonical one)

    * Address Copilot feedback on star-rating control PR

    - Route enqueue URLs through woocommerce_get_asset_url filter so
      CDN/asset-rewrite setups continue to work.
    - Add screen-reader-only 'Required' text alongside the visual asterisk
      on the rating label.
    - Correct the JS file header comment: only Arrow keys + Home/End are
      handled, not Space/Enter.

    * Fix StarRating docblock to use flat @param syntax

    The PHPDoc shape syntax (array{...}) trips the WC PHPCS sniff, which
    expects a single @param line per parameter. Reverted to plain
    @param array with the key descriptions in the body.

    * Drop the planning comment + minor tidy on star-rating styles

    * Load the RTL stylesheet variant on RTL sites

    wp_style_add_data( 'wc-order-review', 'rtl', 'replace' ) tells the
    classic-assets pipeline to swap order-review.css for order-review-rtl.css
    on right-to-left locales.

    * Trim defensive fallbacks and dedupe asset enqueue helpers

    - StarRating::get_labels(): replace the manual 1-5 fallback loop and
      reorder block with array_replace + array_intersect_key.
    - StarRating::render(): drop the unused $selected pre-cast; read it
      inline only when it's used.
    - Endpoint::enqueue_assets(): factor a local closure for the
      woocommerce_get_asset_url filter so we don't repeat the same call
      for the style and the script.
    - order/star-rating.php template: drop the data-caption-id attribute
      that duplicated aria-describedby.
    - order-review.js: read the caption id from aria-describedby and drop
      the empty-input early-out (init() already filters by class).

    No behaviour change. All six StarRating tests still pass.

    * Address review feedback on the star-rating control

    - Add @since 10.8.0 annotations on StarRating::render() and
      StarRating::get_labels() docblocks (per ayushpahwa).
    - Bound the `selected` argument: values outside 0-5 fall back to
      no-selection rather than passing an unrenderable value to the
      template (per ayushpahwa).
    - Cover the bound with a regression test.

    * Address remaining review feedback on the star-rating control

    - CSS: drop every `:has()` selector. The template now renders the
      inputs in reverse DOM order (5..1) and uses `flex-direction:
      row-reverse`; selected/hover/focus states are driven by the `~`
      sibling combinator and `:focus-visible + label`. Caption gets
      `order: -1` so it stays on the visual right.
    - JS: keyboard navigation flips so ArrowRight/Down still moves
      visually right (DOM-previous) and Home/End map to the visual
      leftmost/rightmost stars.
    - Template: pre-compute `$initial_caption` so the caption element no
      longer mixes selection logic, escaping, and output in a single
      expression.
    - Endpoint: enqueue the JS with `strategy => defer` (in addition to
      `in_footer => true`) since it only attaches DOM-ready listeners.

    * Add accessible star-rating control for Review Order page

    Native `<input type="radio">` ✕ 5 wrapped in a `role="radiogroup"` container,
    visually replaced by SVG stars. Progressive enhancement adds:

    - Keyboard nav: ArrowLeft/Up, ArrowRight/Down, Home, End. Selecting also
      moves focus and dispatches `change`, so any future submission listener
      reacts naturally.
    - Dynamic caption underneath the stars, populated from the focused/checked
      radio's `data-label`. Defaults match the existing WC product-review
      labels (Very poor / Not that bad / Average / Good / Perfect) and are
      filterable via `woocommerce_review_order_rating_labels`.
    - High-contrast focus ring on the wrapping label whenever its input is
      `:focus-visible`.
    - Hover preview lights stars left of the cursor; selection persists when
      the cursor leaves.

    Without JavaScript the underlying radio group still works as a plain
    form field, so the page stays functional.

    Files:

    - `src/Internal/OrderReviews/StarRating.php` — server-side renderer.
      Builds the markup and exposes `get_labels()` (filter-aware, with safe
      fallbacks for buggy filters that drop slot keys).
    - `templates/order/star-rating.php` — theme-overridable partial.
    - `client/legacy/js/frontend/order-review.js` — vanilla JS, no jQuery.
    - `client/legacy/css/order-review.scss` — visual states.
    - `Internal/OrderReviews/Endpoint::enqueue_assets()` — script + style
      enqueued only when the review-order endpoint is actually rendering.
    - `templates/order/customer-review-order.php` — temporary preview render
      per item so reviewers can interact with the control. M4 (WOOPLUG-6595)
      swaps this preview for the real per-item form row.

    Tests:

    - `Internal\OrderReviews\StarRatingTest` — markup, defaults, filter
      override, filter fallback for missing keys.

    Closes #64308 (WOOPLUG-6594).

    * Add per-item review form row and submit gate

    Replaces the temporary preview render from #64525 with the real per-item
    form that the Customer Review Request page submits to. Submission handler
    itself lands in M4 (WOOPLUG-6596).

    `templates/order/customer-review-order-row.php` (new, theme-overridable):

    - Linked product title that opens the product page in a new tab.
    - 120×120 thumbnail.
    - Hidden `product_id` and `order_item_id` so the row is identifiable
      on submit.
    - The accessible star-rating control from #64525, indexed by `row_index`
      for stable POST data.
    - Textarea labelled "Your review" with the i18n placeholder
      "Share your experience with this product...".
    - `do_action( 'woocommerce_review_order_form_fields', $item, $product,
      $order, $row_index )` after the textarea so extensions can inject
      fields without overriding the whole template.

    `templates/order/customer-review-order.php`:

    - Wraps the item list in `<form class="woocommerce-review-order__form"
      method="post" action="">` with a hidden `order_id`, hidden `key`
      (echoed back from the URL the Endpoint already validated), and the
      `woocommerce_submit_order_reviews` nonce.
    - "Submit reviews" button rendered disabled by default. The JS module
      enables it as soon as any row has a rating selected.

    `client/legacy/js/frontend/order-review.js` gains `initSubmitGate(form)`,
    which listens for `change` events on `.woocommerce-star-rating__input`
    inside the form and toggles the button's `disabled` state.

    - StarRating's docblock simplified to plain `@param array` so the WC
      PHPCS sniffs don't misparse the structured array shorthand.

    Closes #64309 (WOOPLUG-6595).

    * Add changefile(s) from automation for the following project(s): woocommerce

    * Drop M-numbered milestone references from per-item row templates

    * Strip non-layout styling from per-item row; let theme drive typography

    * Stack the per-item row vertically on viewports under 600px

    Folded in from the (now-closed) design-tokens-responsive PR; the only
    contribution that survived the simplification was the 12-line media
    query, which fits naturally with the per-item layout introduced here.

    * Add changefile(s) from automation for the following project(s): woocommerce

    * Wire customer-review-order.php to the per-item row partial

    The 6595 changes had been dropped during cascading rebases: the form
    wrapper, hidden inputs, foreach loop calling
    wc_get_template('order/customer-review-order-row.php', ...), and the
    submit button were all missing. Restored so each item actually renders
    the row partial and the submit gate's selectors exist in the DOM.

    * Strengthen the new-tab product link

    - rel='noopener noreferrer' (was just 'noopener') to also prevent
      referrer leakage in some browsers/configurations.
    - Add a .screen-reader-text 'opens in a new tab' suffix so assistive
      tech users are warned the link spawns a new window.

    * Address per-item-row review feedback

    - Collapse the .woocommerce-review-order__item top border to a single
      declaration; the previous form layered border-top-color over a
      currentColor border, which was redundant.
    - Log a console.warn from initSubmitGate when the submit button is
      missing so theme overrides that drop it surface the cause.

    * Wrap the long console.warn message to satisfy eslint max-len

    * Add AJAX submission handler for the Review Order form

    `Internal\OrderReviews\SubmissionHandler` registers
    `wp_ajax{,_nopriv}_woocommerce_submit_order_reviews`. For each rated row
    it inserts a `comment_type='review'` comment against the product, with
    `rating` (1-5) and `verified=1` commentmeta. Author fields are pulled
    from the order; logged-in customers must own the order, while guests
    authenticate via the order key.

    Per-row outcome is `ok`, `pending_moderation`, or `error`. One row's
    failure does not block the rest. The response shape:

    ```json
    {
      "success": true,
      "data": {
        "results": {
          "<row_index>": {
            "product_id": 123,
            "status":     "ok",
            "comment_id": 456
          }
        }
      }
    }
    ```

    `get_option('comment_moderation')` is honoured: when on, comments are
    inserted with `comment_approved=0` and the row reports
    `pending_moderation`.

    `woocommerce_review_order_submitted` action fires once per submission
    with the order and the per-row results array.

    `_wc_review_request_completed_at` order meta is set when every line
    item on the order has at least one comment by the customer's billing
    email — no matter whether those reviews were posted in this submission
    or earlier.

    JS:

    - `client/legacy/js/frontend/order-review.js` intercepts the form's
      `submit`, posts via `fetch` to admin-ajax, renders per-row status
      messages from the JSON response.
    - The form's `action` falls back to admin-ajax.php for the no-JS case
      (handler still works server-side).

    Wrapper:

    - `OrderReviews::init()` now lists `SubmissionHandler` as a third arg,
      so the container resolves and auto-init's it. No `class-woocommerce.php`
      changes needed.

    Tests:

    - `Internal\OrderReviews\SubmissionHandlerTest` covers bad nonce, bad
      key, valid insert + meta, empty-rating skip, `comment_moderation`,
      per-row isolation when one row references a product not on the
      order, and the completed-at meta on full vs partial submission.

    Closes #64310 (WOOPLUG-6596).

    * Add changefile(s) from automation for the following project(s): woocommerce

    * Address Copilot feedback on AJAX submission handler PR

    - Gate submissions on the same woocommerce_review_order_eligible_statuses
      filter the page-load endpoint uses, so a tampered POST with a valid key
      cannot bypass status restrictions.
    - Stop double-unslashing the row text. handle() already unslashes the
      whole reviews array; process_rows() trusted that.
    - Make maybe_mark_order_complete() idempotent: skip when the meta is
      already set, and replace the per-product N+1 lookup with a single
      grouped get_comments() call.
    - Localize the JS status messages via wp_localize_script() instead of
      hardcoding English strings client-side.
    - Test housekeeping: remove the AJAX/JSON filters in tearDown(), and add
      a test for the woocommerce_review_order_submitted action plus an
      ineligible-status rejection test.
    - Sanitize key and nonce inputs explicitly so the WordPress sniffs pass
      on the changed lines.

    * Drop empty meta_query arg that triggers WC slow-query lint

    The empty meta_query => array() option was redundant and the WC sniff
    flags the key's presence as a possible slow query.

    * Address Copilot feedback (round 2) on AJAX submission handler PR

    - Out-of-range ratings (e.g. 6) no longer disappear: only rating==0 is
      treated as a deliberate skip; values <1 or >5 now report an
      invalid_rating error per row.
    - maybe_mark_order_complete() fetches comment objects directly so the
      comment_post_ID lookup doesn't need a follow-up get_comment() per
      row (was a regression of the original N+1).
    - Submit gate is now reused after AJAX completion: expose syncSubmit
      on the form, and initAjaxSubmit calls it instead of unconditionally
      enabling the button. The button stays disabled when no rows have a
      rating selected (e.g. after a failed submit + cleared rating).

    * Add per-row status note style; source colors from WC --wc-green / --wc-red

    Avoids introducing new --wc-review-order-* tokens. The active theme
    controls everything else; we only need ok/error tinting on the inline
    status message and we use the WC variables that already exist at :root.

    * Point the Review Order form at admin-ajax for the new submission handler

    Adds action + data-ajax-url + the action hidden input so the JS submit
    gate's fetch() target and the no-JS POST both reach the handler.

    * Accept variation_id; exclude spam/trash from completion check

    - The Review Order row template posts the variation id for variable
      products, but $item->get_product_id() returns the parent. Accept
      either match and store the review against the parent so it shows on
      the product page regardless of which variation was bought.
    - Switch the completion lookup from status=>all to status=>[approve,hold]
      so spam/trash reviews can't trip _wc_review_request_completed_at.
    - Fix the maybe_mark_order_complete() docblock so its 'verified review'
      wording matches the actual behaviour (any non-spam/non-trash review
      by the customer counts).

    * Filter the network-failure error path to rows with a rating

    The catch handler renders an error on every item row, but the earlier
    'success: false' branch only flags rows that were actually rated. A
    network failure mid-submit now incorrectly tells the customer the rows
    they intentionally skipped also failed; mirror the existing 'has a
    checked rating' guard so only rated rows surface the error.

    * Add already-reviewed locked row state

    When the customer has already reviewed a product on the order (matched
    by billing email), the row renders a static "Reviewed" summary instead
    of the form. Reviews disabled on a product cause the row to be skipped
    entirely.

    Files:

    - `src/Internal/OrderReviews/ItemEligibility.php` — `describe()` returns
      one of `STATUS_FORM | STATUS_REVIEWED | STATUS_SKIP` plus the matched
      comment when applicable. Wraps the decision in
      `woocommerce_review_order_item_already_reviewed` so extensions can
      flip either direction.
    - `templates/order/customer-review-order-row-reviewed.php` — locked row
      partial: linked product title, thumbnail, "Reviewed" badge with
      posted-on date, locked star rating + label, truncated review text,
      optional "View on product page" link.
    - `templates/order/customer-review-order.php` — dispatch on the
      `ItemEligibility` decision; calls the existing form-row partial for
      pending items and the new reviewed-row partial for matches.
    - `client/legacy/css/order-review.scss` — reviewed-row styles
      (badge, locked rating layout, blockquote-style review text).
    - `tests/php/src/Internal/OrderReviews/ItemEligibilityTest.php` —
      defaults, comments-closed, existing-match, different-author, filter
      override, filter-args context.

    Drive-by: tightened lint warnings in `SubmissionHandler` so branch lint
    passes against trunk (alignment, yoda conditions, end-comments).

    Closes #64312 (WOOPLUG-6598).

    * Add changefile(s) from automation for the following project(s): woocommerce

    * Address Copilot feedback on already-reviewed locked row PR

    - Eliminate the N+1 query: ItemEligibility::prime() pre-fills a
      per-request cache with a single get_comments() call covering every
      product on the order, and find_existing_review() now reads that
      cache. The template calls prime() once before iterating items.
    - Tolerate the filter-only reviewed state: when the
      woocommerce_review_order_item_already_reviewed filter forces true
      without a backing comment, the locked row template renders the
      reviewed state without trying to access comment fields.
    - Add tests for both behaviors (prime-caches-results and
      filter_forced_reviewed_with_no_comment).

    * Remove only this test's comments_pre_query filter, not all of them

    remove_all_filters( 'comments_pre_query' ) wiped any other callbacks
    on a core hook, which could leak state to neighbouring tests. Hold a
    reference to the counter and remove only that one.

    * Add quote indent for the locked Reviewed row text

    * Close server-side gaps in the Review Order submission handler

    Previously the handler validated the row's order_item_id and that the
    submitted product_id matched the line item's product_id, but it did
    not consult any of the page-side eligibility logic. That left three
    attack vectors open to anyone with a valid order key:

    1. Submit a duplicate review for a product they already reviewed on
       this order (stacks verified-buyer comments).
    2. Submit a review for a fully-refunded line item.
    3. Submit a review for a product whose owner has disabled comments.

    process_rows() now mirrors the page's decision flow:

    - index_eligible_order_items() runs the items through the same
      woocommerce_review_order_eligible_items filter the page uses, so the
      default fully-refunded filter strips refunded items from the index
      (rows for them now report invalid_row).
    - For each row, ItemEligibility::describe() decides STATUS_FORM /
      STATUS_REVIEWED / STATUS_SKIP. REVIEWED returns the new
      already_reviewed error, SKIP returns reviews_not_open. Only
      STATUS_FORM proceeds to wp_insert_comment().

    Three new tests cover the three rejection paths and assert no
    duplicate comment is created in any of them. tearDown now also
    removes the eligible-items and already_reviewed filters so test
    fixtures cannot leak across cases.

    * Wire ItemEligibility::describe() dispatch into the Review Order page

    Calls prime() before the foreach, then per item:
    - STATUS_SKIP -> skip the row entirely (reviews disabled on product)
    - STATUS_REVIEWED -> render the locked variant (customer-review-order-row-reviewed.php)
    - STATUS_FORM -> render the form row

    * Document woocommerce_review_order_eligible_items at the handler call; soften reviewed quote border

    - Add a hook docblock at the handler's apply_filters call so the
      contract is discoverable from both the template and the API path.
    - Drop the hardcoded rgba(0,0,0,0.1) on the reviewed-row left border;
      use a plain 'border-left: 2px solid' so currentColor is honoured and
      dark themes get a visible accent.

    * Tighten already-reviewed lookups and the locked-row link

    - ItemEligibility: get_comments() now uses status=>approve plus
      include_unapproved=>[customer_email] so spam/trash reviews can't
      trip STATUS_REVIEWED and re-block a legitimate retry, while the
      customer's own pending review still counts. Both find_existing_review()
      and prime() are updated to match.
    - customer-review-order-row-reviewed.php: link to the parent product
      permalink (via $item->get_product_id()) instead of the variation
      permalink. Reviews are stored against the parent, so the link should
      match for consistency.
    - ItemEligibilityTest::test_prime_caches_results: wrap the describe()
      call in try/finally so the comments_pre_query filter is always
      removed even if an assertion throws.

    * Fix PHPStan parameter.phpDocType: fully-qualify WC_Order_Item

    The hook docblock declared @param WC_Order_Item[] without a leading
    backslash, so PHPStan resolved it to
    Automattic\WooCommerce\Internal\OrderReviews\WC_Order_Item which
    doesn't exist. Use \WC_Order_Item[] so PHPStan picks up the global
    WC class.

    * Address review feedback on the AJAX submission handler

    - maybe_mark_order_complete(): build the eligible-row set through the
      same `woocommerce_review_order_eligible_items` filter the page uses,
      count required reviews per parent product (so duplicate or
      multi-variation rows need their full count of reviews), and pass
      number=>0 to get_comments() so the default 20-row cap doesn't
      produce false negatives on large orders.
    - process_rows(): only attribute the inserted comment to a WP user
      when the current request is authenticated as that user. Guests
      reaching the page via the order key now get user_id=0 instead of
      the order's customer id.
    - customer-review-order.php: wrap the hidden `action` value in
      esc_attr() for consistency with adjacent hidden inputs.
    - SubmissionHandlerTest: add coverage for the `invalid_rating` and
      `product_mismatch` error codes (40 tests, was 38).

    * Resolve ItemEligibility from the container

    ItemEligibility was introduced as a static utility, but a follow-up PR
    (#64531) makes it register a default filter callback on
    woocommerce_review_order_eligible_items. The container resolution is
    where that registration is wired in, so introducing it alongside the
    class itself is the natural home.

    * Pre-fill the form row with the existing review instead of locking it

    Reshapes WOOPLUG-6598 to match the Figma intent and customer behaviour
    we want:

    - No separate "locked Reviewed" row template, no badge, no
      "Reviewed on X" sentence. Drops
      templates/order/customer-review-order-row-reviewed.php.
    - The regular form row is pre-filled with the customer's existing
      rating + text when they already have a review tied to *this order*.
      Customers can edit and resubmit.
    - Submissions update the matching comment in place (rating meta +
      content) when one exists for this order; otherwise insert as before.
      Server-side lookup uses (product_id, author_email, _review_order_id)
      so a tampered POST cannot edit someone else's comment.
    - A review tied to a *different* order doesn't surface here, so a
      repeat purchase of the same product gets a fresh empty form. The
      customer's experience may legitimately differ between purchases.
    - New commentmeta `_review_order_id` stamps each inserted review with
      its source order. Legacy reviews without the meta are treated as
      "from a different / unknown order" — they don't pre-fill or block.
    - ItemEligibility drops STATUS_REVIEWED (no consumer left) and gains
      prefill_for_item(). decide() returns STATUS_FORM | STATUS_SKIP plus
      the existing comment (or null) for the row.
    - The woocommerce_review_order_item_already_reviewed filter is gone:
      the locked-row decision it gated no longer exists.

    Tests: 24 across ItemEligibility + SubmissionHandler pass, including
    new coverage for same-order update, previous-order non-blocking, and
    legacy-meta-missing scenarios.

    * Add changefile(s) from automation for the following project(s): woocommerce

    * Fix branch-lint warnings: array alignment, docblock params, equals alignment

    ---------

    Co-authored-by: woocommercebot <woocommercebot@users.noreply.github.com>

diff --git a/plugins/woocommerce/changelog/64529-wooplug-6598-already-reviewed-row b/plugins/woocommerce/changelog/64529-wooplug-6598-already-reviewed-row
new file mode 100644
index 00000000000..e1b42000d22
--- /dev/null
+++ b/plugins/woocommerce/changelog/64529-wooplug-6598-already-reviewed-row
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Pre-fill the Review Order form with the customer's existing review for this order, scoped per-order so repeat purchases of the same product get a fresh empty row.
\ No newline at end of file
diff --git a/plugins/woocommerce/includes/class-woocommerce.php b/plugins/woocommerce/includes/class-woocommerce.php
index f2aed327111..626071d2a63 100644
--- a/plugins/woocommerce/includes/class-woocommerce.php
+++ b/plugins/woocommerce/includes/class-woocommerce.php
@@ -379,6 +379,7 @@ final class WooCommerce {
 		$container->get( Automattic\WooCommerce\Internal\OrderReviews\Scheduler::class );
 		$container->get( Automattic\WooCommerce\Internal\OrderReviews\Endpoint::class );
 		$container->get( Automattic\WooCommerce\Internal\OrderReviews\SubmissionHandler::class );
+		$container->get( Automattic\WooCommerce\Internal\OrderReviews\ItemEligibility::class );

 		// Feature flags.
 		if ( Constants::is_true( 'WOOCOMMERCE_BIS_ALPHA_ENABLED' ) ) {
diff --git a/plugins/woocommerce/src/Internal/OrderReviews/ItemEligibility.php b/plugins/woocommerce/src/Internal/OrderReviews/ItemEligibility.php
new file mode 100644
index 00000000000..bfa561adac3
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/OrderReviews/ItemEligibility.php
@@ -0,0 +1,325 @@
+<?php
+/**
+ * ItemEligibility class file.
+ */
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\OrderReviews;
+
+use WC_Order;
+use WC_Order_Item;
+use WC_Order_Item_Product;
+use WP_Comment;
+
+/**
+ * Decides how each Review Order line item should be rendered and supplies
+ * any pre-fill data for the row form.
+ *
+ * Two outcomes for a row:
+ *
+ * - `form` — render the editable form row (`customer-review-order-row.php`),
+ *   optionally pre-filled with the rating + text the customer has already
+ *   submitted for this product **on this order**.
+ * - `skip` — render nothing (e.g. the product has reviews disabled).
+ *
+ * Reviews left for a *different* order are not surfaced here: a customer who
+ * buys the same product again gets a fresh form row, because their experience
+ * the second time around may be different from the first.
+ *
+ * @internal Just for internal use.
+ *
+ * @since 10.8.0
+ */
+class ItemEligibility {
+
+	/**
+	 * Render the editable form row.
+	 *
+	 * @since 10.8.0
+	 */
+	public const STATUS_FORM = 'form';
+
+	/**
+	 * Render nothing (e.g. comments closed on the product).
+	 *
+	 * @since 10.8.0
+	 */
+	public const STATUS_SKIP = 'skip';
+
+	/**
+	 * Commentmeta key storing the order this review was submitted for.
+	 *
+	 * @since 10.8.0
+	 */
+	public const ORDER_META_KEY = '_review_order_id';
+
+	/**
+	 * Per-request cache for the "did this email review this product on this
+	 * order" lookup, keyed by `order_id|product_id|email`. Value is a
+	 * `WP_Comment` when one matches, or `null` when the slot has been checked
+	 * and nothing matches (so a second call doesn't re-query).
+	 *
+	 * @var array<string, ?WP_Comment>
+	 */
+	private static array $review_cache = array();
+
+	/**
+	 * Register the default filter callbacks the OrderReviews feature ships with.
+	 *
+	 * Auto-called by the WC dependency container after instantiation.
+	 *
+	 * @internal
+	 */
+	final public function init(): void {
+		add_filter(
+			'woocommerce_review_order_eligible_items',
+			array( self::class, 'exclude_fully_refunded_items' ),
+			10,
+			2
+		);
+	}
+
+	/**
+	 * Pre-fill the per-request review cache for a set of items in one query.
+	 *
+	 * Call this from the template before iterating items so each subsequent
+	 * `decide()` / `prefill_for_item()` call hits the cache instead of running
+	 * its own `get_comments()` query.
+	 *
+	 * @since 10.8.0
+	 *
+	 * @param iterable<WC_Order_Item_Product|mixed> $items Order line items.
+	 * @param WC_Order                              $order Order being reviewed.
+	 */
+	public static function preload_for_items( iterable $items, WC_Order $order ): void {
+		$email    = $order->get_billing_email();
+		$order_id = $order->get_id();
+		if ( '' === $email || $order_id <= 0 ) {
+			return;
+		}
+
+		$product_ids = array();
+		foreach ( $items as $item ) {
+			if ( $item instanceof WC_Order_Item_Product ) {
+				$pid = (int) $item->get_product_id();
+				if ( $pid > 0 ) {
+					$product_ids[ $pid ] = $pid;
+				}
+			}
+		}
+
+		if ( empty( $product_ids ) ) {
+			return;
+		}
+
+		// Scope to this order's reviews only: a customer who buys the same
+		// product on a later order shouldn't see their old review here.
+		$comments = get_comments(
+			array(
+				'post__in'           => array_values( $product_ids ),
+				'author_email'       => $email,
+				'type'               => 'review',
+				'status'             => 'approve',
+				'include_unapproved' => array( $email ),
+				'orderby'            => 'comment_date_gmt',
+				'order'              => 'DESC',
+				'meta_query'         => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- bounded by post__in + author_email.
+					array(
+						'key'   => self::ORDER_META_KEY,
+						'value' => (string) $order_id,
+					),
+				),
+			)
+		);
+
+		// Default every product id to null so subsequent reads don't re-query.
+		foreach ( $product_ids as $pid ) {
+			self::$review_cache[ self::cache_key( $order_id, $pid, $email ) ] = null;
+		}
+
+		if ( is_array( $comments ) ) {
+			foreach ( $comments as $comment ) {
+				if ( ! $comment instanceof WP_Comment ) {
+					continue;
+				}
+				$key = self::cache_key( $order_id, (int) $comment->comment_post_ID, $email );
+				if ( null === ( self::$review_cache[ $key ] ?? null ) ) {
+					self::$review_cache[ $key ] = $comment;
+				}
+			}
+		}
+	}
+
+	/**
+	 * Reset the per-request cache. Test helper.
+	 *
+	 * @since 10.8.0
+	 * @internal
+	 */
+	public static function reset_cache(): void {
+		self::$review_cache = array();
+	}
+
+	/**
+	 * Decide how an order line item should render on the Review Order page.
+	 *
+	 * Returns one of the STATUS_* constants plus the matched comment (when
+	 * one exists for this order) and the product id.
+	 *
+	 * @since 10.8.0
+	 *
+	 * @param WC_Order_Item_Product $item  Order line item.
+	 * @param WC_Order              $order Order being reviewed.
+	 * @return array{status:string, comment:?WP_Comment, product_id:int}
+	 */
+	public static function decide( WC_Order_Item_Product $item, WC_Order $order ): array {
+		$product_id = (int) $item->get_product_id();
+		$result     = array(
+			'status'     => self::STATUS_FORM,
+			'comment'    => null,
+			'product_id' => $product_id,
+		);
+
+		if ( $product_id <= 0 || ! comments_open( $product_id ) ) {
+			$result['status'] = self::STATUS_SKIP;
+			return $result;
+		}
+
+		$result['comment'] = self::find_existing_review( $product_id, $order );
+		return $result;
+	}
+
+	/**
+	 * Pre-fill payload for a line item: rating, text, and comment id.
+	 *
+	 * Returns zero/empty values when no review exists for this order's row,
+	 * so callers can use it unconditionally.
+	 *
+	 * @since 10.8.0
+	 *
+	 * @param WC_Order_Item_Product $item  Order line item.
+	 * @param WC_Order              $order Order being reviewed.
+	 * @return array{rating:int, text:string, comment_id:int}
+	 */
+	public static function prefill_for_item( WC_Order_Item_Product $item, WC_Order $order ): array {
+		$existing = self::find_existing_review( (int) $item->get_product_id(), $order );
+		if ( ! $existing instanceof WP_Comment ) {
+			return array(
+				'rating'     => 0,
+				'text'       => '',
+				'comment_id' => 0,
+			);
+		}
+
+		$rating = (int) get_comment_meta( (int) $existing->comment_ID, 'rating', true );
+		if ( $rating < 0 || $rating > 5 ) {
+			$rating = 0;
+		}
+
+		return array(
+			'rating'     => $rating,
+			'text'       => (string) $existing->comment_content,
+			'comment_id' => (int) $existing->comment_ID,
+		);
+	}
+
+	/**
+	 * Drop fully-refunded line items from the eligible-items list.
+	 *
+	 * Default callback wired onto `woocommerce_review_order_eligible_items`
+	 * so the page never shows a row for a product the customer no longer
+	 * owns. A line item is considered fully refunded when the absolute
+	 * refunded quantity is greater than or equal to the item's ordered
+	 * quantity. Fractional quantities are honoured.
+	 *
+	 * @since 10.8.0
+	 *
+	 * @param WC_Order_Item[] $items Order line items.
+	 * @param WC_Order        $order Order being reviewed.
+	 * @return WC_Order_Item[]
+	 */
+	public static function exclude_fully_refunded_items( array $items, WC_Order $order ): array {
+		$filtered = array();
+		foreach ( $items as $key => $item ) {
+			if ( ! $item instanceof WC_Order_Item_Product ) {
+				$filtered[ $key ] = $item;
+				continue;
+			}
+
+			$refunded_qty = (float) abs( (float) $order->get_qty_refunded_for_item( $item->get_id() ) );
+			$ordered_qty  = (float) $item->get_quantity();
+
+			if ( $ordered_qty > 0 && $refunded_qty >= $ordered_qty ) {
+				continue;
+			}
+
+			$filtered[ $key ] = $item;
+		}
+
+		return $filtered;
+	}
+
+	/**
+	 * Look up the customer's review for a product on this order.
+	 *
+	 * @since 10.8.0
+	 *
+	 * @param int      $product_id Product id.
+	 * @param WC_Order $order      Order being reviewed.
+	 * @return WP_Comment|null
+	 */
+	private static function find_existing_review( int $product_id, WC_Order $order ): ?WP_Comment {
+		$email    = $order->get_billing_email();
+		$order_id = (int) $order->get_id();
+		if ( '' === $email || $order_id <= 0 || $product_id <= 0 ) {
+			return null;
+		}
+
+		$key = self::cache_key( $order_id, $product_id, $email );
+		if ( array_key_exists( $key, self::$review_cache ) ) {
+			return self::$review_cache[ $key ];
+		}
+
+		$comments = get_comments(
+			array(
+				'post_id'            => $product_id,
+				'author_email'       => $email,
+				'type'               => 'review',
+				'status'             => 'approve',
+				'include_unapproved' => array( $email ),
+				'number'             => 1,
+				'orderby'            => 'comment_date_gmt',
+				'order'              => 'DESC',
+				'meta_query'         => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- bounded by post_id + author_email.
+					array(
+						'key'   => self::ORDER_META_KEY,
+						'value' => (string) $order_id,
+					),
+				),
+			)
+		);
+
+		if ( ! is_array( $comments ) || empty( $comments ) ) {
+			self::$review_cache[ $key ] = null;
+			return null;
+		}
+
+		$first = reset( $comments );
+		$found = $first instanceof WP_Comment ? $first : null;
+
+		self::$review_cache[ $key ] = $found;
+		return $found;
+	}
+
+	/**
+	 * Build the per-request cache key.
+	 *
+	 * @param int    $order_id   Order id.
+	 * @param int    $product_id Product id.
+	 * @param string $email      Customer email.
+	 */
+	private static function cache_key( int $order_id, int $product_id, string $email ): string {
+		return $order_id . '|' . $product_id . '|' . $email;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/OrderReviews/SubmissionHandler.php b/plugins/woocommerce/src/Internal/OrderReviews/SubmissionHandler.php
index cd86af8446c..cd91ca5f859 100644
--- a/plugins/woocommerce/src/Internal/OrderReviews/SubmissionHandler.php
+++ b/plugins/woocommerce/src/Internal/OrderReviews/SubmissionHandler.php
@@ -117,13 +117,17 @@ class SubmissionHandler {
 	 */
 	private function process_rows( WC_Order $order, array $rows_in ): array {
 		$results      = array();
-		$item_index   = $this->index_order_items( $order );
+		$item_index   = $this->index_eligible_order_items( $order );
 		$author_name  = trim( $order->get_billing_first_name() . ' ' . $order->get_billing_last_name() );
 		$author_email = $order->get_billing_email();
 		$author_ip    = $order->get_customer_ip_address();
 		$author_agent = $order->get_customer_user_agent();
 		$require_mod  = (bool) get_option( 'comment_moderation' );

+		// Preload the eligibility cache so the per-row decide() calls below
+		// don't issue one already-reviewed query each.
+		ItemEligibility::preload_for_items( $item_index, $order );
+
 		foreach ( $rows_in as $row_index => $row ) {
 			$row_index = (int) $row_index;
 			$row       = is_array( $row ) ? $row : array();
@@ -150,6 +154,8 @@ class SubmissionHandler {
 				continue;
 			}

+			// invalid_row also covers fully-refunded line items: index_eligible_order_items()
+			// runs them through woocommerce_review_order_eligible_items, which strips them.
 			if ( ! $product_id || ! $order_item_id || ! isset( $item_index[ $order_item_id ] ) ) {
 				$result['error']       = 'invalid_row';
 				$results[ $row_index ] = $result;
@@ -172,6 +178,15 @@ class SubmissionHandler {
 			// product page regardless of which variation was bought.
 			$review_post_id = $line_product_id;

+			// Reject submissions for products whose review form was never
+			// rendered (comments disabled on the product).
+			$decision = ItemEligibility::decide( $item, $order );
+			if ( ItemEligibility::STATUS_SKIP === $decision['status'] ) {
+				$result['error']       = 'reviews_not_open';
+				$results[ $row_index ] = $result;
+				continue;
+			}
+
 			// Only attribute the comment to a WP user when the current request is
 			// authenticated as that user. Guests reaching the page via the order
 			// key are not authenticated, so the comment stays unattributed (0).
@@ -179,6 +194,36 @@ class SubmissionHandler {
 			$current_user_id = get_current_user_id();
 			$comment_user_id = ( $current_user_id > 0 && $current_user_id === $customer_id ) ? $current_user_id : 0;

+			// If the customer already has a review tied to this order for this
+			// product, update it in place instead of stacking duplicates. The
+			// existing comment id comes from the server-side lookup, not the
+			// client, so a tampered POST can't target someone else's review.
+			$existing = $decision['comment'] instanceof \WP_Comment ? $decision['comment'] : null;
+
+			if ( $existing instanceof \WP_Comment ) {
+				$update_ok = wp_update_comment(
+					wp_slash(
+						array(
+							'comment_ID'       => (int) $existing->comment_ID,
+							'comment_content'  => $text,
+							'comment_approved' => $require_mod ? 0 : 1,
+						)
+					)
+				);
+				if ( false === $update_ok || is_wp_error( $update_ok ) ) {
+					$result['error']       = 'update_failed';
+					$results[ $row_index ] = $result;
+					continue;
+				}
+
+				update_comment_meta( (int) $existing->comment_ID, 'rating', $rating );
+
+				$result['comment_id']  = (int) $existing->comment_ID;
+				$result['status']      = $require_mod ? 'pending_moderation' : 'ok';
+				$results[ $row_index ] = $result;
+				continue;
+			}
+
 			$comment_data = array(
 				'comment_post_ID'      => $review_post_id,
 				'comment_author'       => '' !== $author_name ? $author_name : __( 'Anonymous', 'woocommerce' ),
@@ -200,6 +245,7 @@ class SubmissionHandler {

 			add_comment_meta( $comment_id, 'rating', $rating, true );
 			add_comment_meta( $comment_id, 'verified', 1, true );
+			add_comment_meta( $comment_id, ItemEligibility::ORDER_META_KEY, (int) $order->get_id(), true );

 			$result['comment_id']  = (int) $comment_id;
 			$result['status']      = $require_mod ? 'pending_moderation' : 'ok';
@@ -286,14 +332,31 @@ class SubmissionHandler {
 	}

 	/**
-	 * Map order_item_id => `WC_Order_Item_Product` for fast row lookup.
+	 * Map order_item_id => `WC_Order_Item_Product` for fast row lookup,
+	 * filtered through `woocommerce_review_order_eligible_items` so the
+	 * handler agrees with the page on which items are reviewable. The
+	 * default callback excludes fully-refunded items.
 	 *
 	 * @param WC_Order $order Order being reviewed.
 	 * @return array<int, \WC_Order_Item_Product>
 	 */
-	private function index_order_items( WC_Order $order ): array {
+	private function index_eligible_order_items( WC_Order $order ): array {
+		/**
+		 * Filter the eligible items considered by the Review Order
+		 * submission handler.
+		 *
+		 * Same hook the page uses; documented in
+		 * `templates/order/customer-review-order.php`.
+		 *
+		 * @since 10.8.0
+		 *
+		 * @param \WC_Order_Item[] $items Order line items.
+		 * @param WC_Order         $order The order being reviewed.
+		 */
+		$items = (array) apply_filters( 'woocommerce_review_order_eligible_items', $order->get_items(), $order );
+
 		$index = array();
-		foreach ( $order->get_items() as $item ) {
+		foreach ( $items as $item ) {
 			if ( $item instanceof \WC_Order_Item_Product ) {
 				$index[ $item->get_id() ] = $item;
 			}
diff --git a/plugins/woocommerce/templates/order/customer-review-order-row.php b/plugins/woocommerce/templates/order/customer-review-order-row.php
index 31128db5ccf..16125fd7d02 100644
--- a/plugins/woocommerce/templates/order/customer-review-order-row.php
+++ b/plugins/woocommerce/templates/order/customer-review-order-row.php
@@ -12,10 +12,12 @@
  * @package WooCommerce\Templates
  * @version 10.8.0
  *
- * @var WC_Order_Item_Product $item       Order line item being rendered.
- * @var WC_Product            $product    Product attached to the line item.
- * @var WC_Order              $order      Order being reviewed.
- * @var int                   $row_index  Zero-based row index, used in input names.
+ * @var WC_Order_Item_Product $item            Order line item being rendered.
+ * @var WC_Product            $product         Product attached to the line item.
+ * @var WC_Order              $order           Order being reviewed.
+ * @var int                   $row_index       Zero-based row index, used in input names.
+ * @var int                   $existing_rating Pre-fill rating (0 when no prior review for this order).
+ * @var string                $existing_text   Pre-fill review text (empty when no prior review for this order).
  */

 defined( 'ABSPATH' ) || exit;
@@ -24,6 +26,9 @@ if ( ! $item instanceof WC_Order_Item_Product || ! $product instanceof WC_Produc
 	return;
 }

+$existing_rating = isset( $existing_rating ) ? (int) $existing_rating : 0;
+$existing_text   = isset( $existing_text ) ? (string) $existing_text : '';
+
 $item_id         = $item->get_id();
 $product_id      = $product->get_id();
 $product_link    = $product->is_visible() ? get_permalink( $product_id ) : '';
@@ -38,6 +43,7 @@ $rating_control = \Automattic\WooCommerce\Internal\OrderReviews\StarRating::rend
 		'name'      => 'reviews[' . $row_index . '][rating]',
 		'id_prefix' => 'woocommerce-review-rating-' . $item_id,
 		'label_id'  => $rating_label_id,
+		'selected'  => $existing_rating,
 	)
 );
 ?>
@@ -85,7 +91,7 @@ $rating_control = \Automattic\WooCommerce\Internal\OrderReviews\StarRating::rend
 					name="reviews[<?php echo esc_attr( (string) $row_index ); ?>][text]"
 					rows="3"
 					placeholder="<?php esc_attr_e( 'Share your experience with this product...', 'woocommerce' ); ?>"
-				></textarea>
+				><?php echo esc_textarea( $existing_text ); ?></textarea>
 			</div>

 			<?php
diff --git a/plugins/woocommerce/templates/order/customer-review-order.php b/plugins/woocommerce/templates/order/customer-review-order.php
index 3ac0f4108a4..59f9c0a8e9b 100644
--- a/plugins/woocommerce/templates/order/customer-review-order.php
+++ b/plugins/woocommerce/templates/order/customer-review-order.php
@@ -86,6 +86,10 @@ foreach ( $items as $item ) {
 	);
 }

+// Single batched lookup of every existing review by this customer for the
+// items below. Without this each decide() call would issue its own query.
+\Automattic\WooCommerce\Internal\OrderReviews\ItemEligibility::preload_for_items( $items, $order );
+
 // The Endpoint has already validated the URL key against the order key, so the
 // canonical value on the order is the right thing to echo into the form post.
 $order_key = (string) $order->get_order_key();
@@ -122,16 +126,28 @@ $order_key = (string) $order->get_order_key();

 			<ul class="woocommerce-review-order__items">
 				<?php
-				foreach ( $renderable_rows as $row_index => $row ) {
+				$row_index = 0;
+				foreach ( $renderable_rows as $row ) {
+					$decision = \Automattic\WooCommerce\Internal\OrderReviews\ItemEligibility::decide( $row['item'], $order );
+
+					if ( \Automattic\WooCommerce\Internal\OrderReviews\ItemEligibility::STATUS_SKIP === $decision['status'] ) {
+						continue;
+					}
+
+					$prefill = \Automattic\WooCommerce\Internal\OrderReviews\ItemEligibility::prefill_for_item( $row['item'], $order );
+
 					wc_get_template(
 						'order/customer-review-order-row.php',
 						array(
-							'item'      => $row['item'],
-							'product'   => $row['product'],
-							'order'     => $order,
-							'row_index' => $row_index,
+							'item'            => $row['item'],
+							'product'         => $row['product'],
+							'order'           => $order,
+							'row_index'       => $row_index,
+							'existing_rating' => $prefill['rating'],
+							'existing_text'   => $prefill['text'],
 						)
 					);
+					++$row_index;
 				}
 				?>
 			</ul>
diff --git a/plugins/woocommerce/tests/php/src/Internal/OrderReviews/ItemEligibilityTest.php b/plugins/woocommerce/tests/php/src/Internal/OrderReviews/ItemEligibilityTest.php
new file mode 100644
index 00000000000..89b3d95ea8e
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/OrderReviews/ItemEligibilityTest.php
@@ -0,0 +1,207 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\OrderReviews;
+
+use Automattic\WooCommerce\Enums\OrderStatus;
+use Automattic\WooCommerce\Internal\OrderReviews\ItemEligibility;
+use Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper;
+use WC_Helper_Product;
+use WC_Order_Item_Product;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for ItemEligibility.
+ *
+ * @covers \Automattic\WooCommerce\Internal\OrderReviews\ItemEligibility
+ */
+class ItemEligibilityTest extends WC_Unit_Test_Case {
+
+	/**
+	 * Reset between tests.
+	 */
+	public function tearDown(): void {
+		ItemEligibility::reset_cache();
+		parent::tearDown();
+	}
+
+	/**
+	 * Build a 1-product completed order.
+	 *
+	 * @param string $email Billing email to set on the order.
+	 * @return array Map with `order`, `item`, and `product_id`.
+	 */
+	private function make_order( string $email = 'jane@example.test' ): array {
+		$order = OrderHelper::create_order();
+		foreach ( $order->get_items() as $line ) {
+			$order->remove_item( $line->get_id() );
+		}
+		$order->set_billing_email( $email );
+		$order->set_status( OrderStatus::COMPLETED );
+
+		$product = WC_Helper_Product::create_simple_product();
+		$order->add_product( $product, 1 );
+		$order->save();
+
+		$items = $order->get_items();
+		$item  = reset( $items );
+
+		return array(
+			'order'      => $order,
+			'item'       => $item,
+			'product_id' => $product->get_id(),
+		);
+	}
+
+	/**
+	 * Insert a customer review for a product, optionally tagged with the source order id.
+	 *
+	 * @param int      $product_id Product post id.
+	 * @param string   $email      Author email.
+	 * @param string   $body       Comment body.
+	 * @param int      $rating     Rating value 1-5.
+	 * @param int|null $order_id   Source order id stamped as `_review_order_id` commentmeta. Pass null to skip.
+	 * @param int      $approved   1 for approved, 0 for pending moderation.
+	 * @return int Inserted comment id.
+	 */
+	private function insert_review( int $product_id, string $email, string $body, int $rating, ?int $order_id = null, int $approved = 1 ): int {
+		$comment_id = (int) wp_insert_comment(
+			array(
+				'comment_post_ID'      => $product_id,
+				'comment_author'       => 'Reviewer',
+				'comment_author_email' => $email,
+				'comment_content'      => $body,
+				'comment_type'         => 'review',
+				'comment_approved'     => $approved,
+			)
+		);
+		add_comment_meta( $comment_id, 'rating', $rating, true );
+		if ( null !== $order_id ) {
+			add_comment_meta( $comment_id, ItemEligibility::ORDER_META_KEY, $order_id, true );
+		}
+		return $comment_id;
+	}
+
+	/**
+	 * @testdox decide() returns `form` and no comment when no review exists for this order.
+	 */
+	public function test_decide_default_returns_form(): void {
+		$built = $this->make_order();
+
+		$decision = ItemEligibility::decide( $built['item'], $built['order'] );
+
+		$this->assertSame( ItemEligibility::STATUS_FORM, $decision['status'] );
+		$this->assertNull( $decision['comment'] );
+	}
+
+	/**
+	 * @testdox decide() returns `skip` when comments are closed on the product.
+	 */
+	public function test_decide_skip_when_comments_closed(): void {
+		$built = $this->make_order();
+		wp_update_post(
+			array(
+				'ID'             => $built['product_id'],
+				'comment_status' => 'closed',
+			)
+		);
+
+		$decision = ItemEligibility::decide( $built['item'], $built['order'] );
+
+		$this->assertSame( ItemEligibility::STATUS_SKIP, $decision['status'] );
+	}
+
+	/**
+	 * @testdox decide() returns the matching review when one exists for *this* order.
+	 */
+	public function test_decide_surfaces_review_from_same_order(): void {
+		$built      = $this->make_order( 'match@example.test' );
+		$comment_id = $this->insert_review( $built['product_id'], 'match@example.test', 'Worked great.', 5, (int) $built['order']->get_id() );
+
+		$decision = ItemEligibility::decide( $built['item'], $built['order'] );
+
+		$this->assertSame( ItemEligibility::STATUS_FORM, $decision['status'] );
+		$this->assertNotNull( $decision['comment'] );
+		$this->assertSame( $comment_id, (int) $decision['comment']->comment_ID );
+	}
+
+	/**
+	 * @testdox decide() ignores reviews tagged to a different order (re-reviewing is allowed).
+	 */
+	public function test_decide_ignores_review_from_different_order(): void {
+		$built = $this->make_order( 'repeat@example.test' );
+		// Same customer + product, but review came from a different (older) order.
+		$this->insert_review( $built['product_id'], 'repeat@example.test', 'First time.', 4, (int) $built['order']->get_id() + 999 );
+
+		$decision = ItemEligibility::decide( $built['item'], $built['order'] );
+
+		$this->assertSame( ItemEligibility::STATUS_FORM, $decision['status'] );
+		$this->assertNull( $decision['comment'], 'Reviews from a different order must not pre-fill the current row.' );
+	}
+
+	/**
+	 * @testdox decide() ignores reviews without the order meta (default for legacy reviews).
+	 */
+	public function test_decide_ignores_review_without_order_meta(): void {
+		$built = $this->make_order( 'legacy@example.test' );
+		$this->insert_review( $built['product_id'], 'legacy@example.test', 'Pre-feature review.', 3, null );
+
+		$decision = ItemEligibility::decide( $built['item'], $built['order'] );
+
+		$this->assertSame( ItemEligibility::STATUS_FORM, $decision['status'] );
+		$this->assertNull( $decision['comment'] );
+	}
+
+	/**
+	 * @testdox prefill_for_item() returns rating + text + comment id when this order has a review.
+	 */
+	public function test_prefill_returns_existing_review_data(): void {
+		$built      = $this->make_order( 'prefill@example.test' );
+		$comment_id = $this->insert_review( $built['product_id'], 'prefill@example.test', 'Solid 4 stars.', 4, (int) $built['order']->get_id() );
+
+		$prefill = ItemEligibility::prefill_for_item( $built['item'], $built['order'] );
+
+		$this->assertSame( 4, $prefill['rating'] );
+		$this->assertSame( 'Solid 4 stars.', $prefill['text'] );
+		$this->assertSame( $comment_id, $prefill['comment_id'] );
+	}
+
+	/**
+	 * @testdox prefill_for_item() returns zeros / empty when no review for this order.
+	 */
+	public function test_prefill_returns_empty_when_no_review(): void {
+		$built = $this->make_order();
+
+		$prefill = ItemEligibility::prefill_for_item( $built['item'], $built['order'] );
+
+		$this->assertSame( 0, $prefill['rating'] );
+		$this->assertSame( '', $prefill['text'] );
+		$this->assertSame( 0, $prefill['comment_id'] );
+	}
+
+	/**
+	 * @testdox preload_for_items() caches per-order so decide() does not requery.
+	 */
+	public function test_preload_caches_results(): void {
+		$built = $this->make_order( 'cache@example.test' );
+		$this->insert_review( $built['product_id'], 'cache@example.test', 'Cached.', 5, (int) $built['order']->get_id() );
+
+		ItemEligibility::preload_for_items( $built['order']->get_items(), $built['order'] );
+
+		$call_count = 0;
+		$counter    = static function ( $value ) use ( &$call_count ) {
+			++$call_count;
+			return $value;
+		};
+		add_filter( 'comments_pre_query', $counter );
+
+		try {
+			$decision = ItemEligibility::decide( $built['item'], $built['order'] );
+		} finally {
+			remove_filter( 'comments_pre_query', $counter );
+		}
+
+		$this->assertNotNull( $decision['comment'] );
+		$this->assertSame( 0, $call_count, 'decide() should not query when preload_for_items() has cached the result.' );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/OrderReviews/SubmissionHandlerTest.php b/plugins/woocommerce/tests/php/src/Internal/OrderReviews/SubmissionHandlerTest.php
index 97f4c15ce73..669e2091f2b 100644
--- a/plugins/woocommerce/tests/php/src/Internal/OrderReviews/SubmissionHandlerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/OrderReviews/SubmissionHandlerTest.php
@@ -4,6 +4,7 @@ declare( strict_types = 1 );
 namespace Automattic\WooCommerce\Tests\Internal\OrderReviews;

 use Automattic\WooCommerce\Enums\OrderStatus;
+use Automattic\WooCommerce\Internal\OrderReviews\ItemEligibility;
 use Automattic\WooCommerce\Internal\OrderReviews\SubmissionHandler;
 use Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper;
 use WC_Helper_Product;
@@ -24,6 +25,7 @@ class SubmissionHandlerTest extends WC_Unit_Test_Case {
 		update_option( 'comment_moderation', '0' );
 		remove_all_filters( 'woocommerce_review_order_submitted' );
 		remove_all_filters( 'woocommerce_review_order_eligible_statuses' );
+		remove_all_filters( 'woocommerce_review_order_eligible_items' );
 		remove_all_filters( 'wp_die_ajax_handler' );
 		remove_all_filters( 'wp_send_json_handler' );
 		remove_all_filters( 'wp_doing_ajax' );
@@ -502,4 +504,231 @@ class SubmissionHandlerTest extends WC_Unit_Test_Case {

 		$this->assertFalse( $response['success'] );
 	}
+
+	/**
+	 * @testdox Resubmitting for the same order updates the existing review in place (no duplicate row).
+	 */
+	public function test_resubmit_for_same_order_updates_existing_review(): void {
+		$built      = $this->make_order( 1 );
+		$order      = $built['order'];
+		$product_id = $built['product_ids'][0];
+		$item_id    = $built['item_ids'][0];
+
+		// First submission inserts the comment with the order-id meta.
+		$_POST      = array(
+			'order_id' => $order->get_id(),
+			'key'      => $order->get_order_key(),
+			'_wcnonce' => wp_create_nonce( SubmissionHandler::ACTION ),
+			'reviews'  => array(
+				array(
+					'product_id'    => $product_id,
+					'order_item_id' => $item_id,
+					'rating'        => 3,
+					'text'          => 'First take.',
+				),
+			),
+		);
+		$first      = $this->dispatch();
+		$first_row  = reset( $first['data']['results'] );
+		$comment_id = (int) $first_row['comment_id'];
+		$this->assertGreaterThan( 0, $comment_id );
+
+		// Second submission edits the same row.
+		$_POST      = array(
+			'order_id' => $order->get_id(),
+			'key'      => $order->get_order_key(),
+			'_wcnonce' => wp_create_nonce( SubmissionHandler::ACTION ),
+			'reviews'  => array(
+				array(
+					'product_id'    => $product_id,
+					'order_item_id' => $item_id,
+					'rating'        => 5,
+					'text'          => 'On reflection — outstanding.',
+				),
+			),
+		);
+		$second     = $this->dispatch();
+		$second_row = reset( $second['data']['results'] );
+
+		$this->assertSame( 'ok', $second_row['status'] );
+		$this->assertSame( $comment_id, (int) $second_row['comment_id'], 'Re-submit must update the existing comment, not create a new one.' );
+
+		$updated = get_comment( $comment_id );
+		$this->assertSame( 'On reflection — outstanding.', $updated->comment_content );
+		$this->assertSame( '5', get_comment_meta( $comment_id, 'rating', true ) );
+
+		$total = (int) get_comments(
+			array(
+				'post_id'      => $product_id,
+				'author_email' => $order->get_billing_email(),
+				'type'         => 'review',
+				'count'        => true,
+				'status'       => 'all',
+			)
+		);
+		$this->assertSame( 1, $total, 'No duplicate comment may exist after an edit-resubmit.' );
+	}
+
+	/**
+	 * @testdox A review left for a previous order does not block re-reviewing the same product on a new order.
+	 */
+	public function test_review_from_previous_order_does_not_block_new_review(): void {
+		$built      = $this->make_order( 1 );
+		$order      = $built['order'];
+		$product_id = $built['product_ids'][0];
+		$item_id    = $built['item_ids'][0];
+
+		// Simulate a review from a different order: same email + product, different
+		// _review_order_id meta so the scoping doesn't surface it for this order.
+		$older_comment_id = (int) wp_insert_comment(
+			array(
+				'comment_post_ID'      => $product_id,
+				'comment_author'       => 'Jane Doe',
+				'comment_author_email' => $order->get_billing_email(),
+				'comment_content'      => 'First time round.',
+				'comment_type'         => 'review',
+				'comment_approved'     => 1,
+			)
+		);
+		add_comment_meta( $older_comment_id, ItemEligibility::ORDER_META_KEY, (int) $order->get_id() + 999, true );
+
+		$_POST    = array(
+			'order_id' => $order->get_id(),
+			'key'      => $order->get_order_key(),
+			'_wcnonce' => wp_create_nonce( SubmissionHandler::ACTION ),
+			'reviews'  => array(
+				array(
+					'product_id'    => $product_id,
+					'order_item_id' => $item_id,
+					'rating'        => 5,
+					'text'          => 'Second purchase, even better.',
+				),
+			),
+		);
+		$response = $this->dispatch();
+		$row      = reset( $response['data']['results'] );
+
+		$this->assertSame( 'ok', $row['status'] );
+		$this->assertNotSame( $older_comment_id, (int) $row['comment_id'], 'New order must produce a fresh comment, not edit the previous order\'s review.' );
+
+		// Two comments exist now: the legacy one and the new one for this order.
+		$total = (int) get_comments(
+			array(
+				'post_id'      => $product_id,
+				'author_email' => $order->get_billing_email(),
+				'type'         => 'review',
+				'count'        => true,
+				'status'       => 'all',
+			)
+		);
+		$this->assertSame( 2, $total );
+	}
+
+	/**
+	 * @testdox A row whose product has comments closed is rejected with reviews_not_open.
+	 */
+	public function test_rejects_row_when_reviews_disabled_on_product(): void {
+		$built      = $this->make_order( 1 );
+		$order      = $built['order'];
+		$product_id = $built['product_ids'][0];
+		$item_id    = $built['item_ids'][0];
+
+		wp_update_post(
+			array(
+				'ID'             => $product_id,
+				'comment_status' => 'closed',
+			)
+		);
+
+		$_POST = array(
+			'order_id' => $order->get_id(),
+			'key'      => $order->get_order_key(),
+			'_wcnonce' => wp_create_nonce( SubmissionHandler::ACTION ),
+			'reviews'  => array(
+				array(
+					'product_id'    => $product_id,
+					'order_item_id' => $item_id,
+					'rating'        => 5,
+				),
+			),
+		);
+
+		$response = $this->dispatch();
+		$results  = $response['data']['results'];
+		$row      = reset( $results );
+		$this->assertSame( 'error', $row['status'] );
+		$this->assertSame( 'reviews_not_open', $row['error'] );
+
+		$total = (int) get_comments(
+			array(
+				'post_id'      => $product_id,
+				'author_email' => $order->get_billing_email(),
+				'type'         => 'review',
+				'count'        => true,
+				'status'       => 'all',
+			)
+		);
+		$this->assertSame( 0, $total );
+	}
+
+	/**
+	 * @testdox A row for a fully-refunded line item is rejected via the eligible-items filter.
+	 */
+	public function test_rejects_row_for_fully_refunded_item(): void {
+		$built      = $this->make_order( 1 );
+		$order      = $built['order'];
+		$product_id = $built['product_ids'][0];
+		$item_id    = $built['item_ids'][0];
+
+		// Stand in for the round-1 default callback that would normally
+		// drop fully-refunded items. The handler uses the same filter, so
+		// dropping the item here mirrors what the WC default does.
+		add_filter(
+			'woocommerce_review_order_eligible_items',
+			static function ( $items, $order_arg ) use ( $item_id ) {
+				unset( $order_arg );
+				$filtered = array();
+				foreach ( $items as $key => $item ) {
+					if ( $item->get_id() !== $item_id ) {
+						$filtered[ $key ] = $item;
+					}
+				}
+				return $filtered;
+			},
+			10,
+			2
+		);
+
+		$_POST = array(
+			'order_id' => $order->get_id(),
+			'key'      => $order->get_order_key(),
+			'_wcnonce' => wp_create_nonce( SubmissionHandler::ACTION ),
+			'reviews'  => array(
+				array(
+					'product_id'    => $product_id,
+					'order_item_id' => $item_id,
+					'rating'        => 5,
+				),
+			),
+		);
+
+		$response = $this->dispatch();
+		$results  = $response['data']['results'];
+		$row      = reset( $results );
+		$this->assertSame( 'error', $row['status'] );
+		$this->assertSame( 'invalid_row', $row['error'] );
+
+		remove_all_filters( 'woocommerce_review_order_eligible_items' );
+
+		$total = (int) get_comments(
+			array(
+				'post_id'      => $product_id,
+				'author_email' => $order->get_billing_email(),
+				'type'         => 'review',
+				'count'        => true,
+				'status'       => 'all',
+			)
+		);
+		$this->assertSame( 0, $total );
+	}
 }