Commit 76a969f8ed8 for woocommerce
commit 76a969f8ed8ce818caa5bdaf237d4a2fdc4e3fb7
Author: Jorge A. Torres <jorge.torres@automattic.com>
Date: Mon Jun 1 19:45:44 2026 +0100
Revert "Tighten shopper-collections docblocks and inline comments" (#65448)
This reverts commit a0536feb096c40c43621844297a4297823367177.
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/cart-line-items-table/cart-line-item-row.tsx b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/cart-line-items-table/cart-line-item-row.tsx
index c85220915f8..7e55fb004af 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/cart-line-items-table/cart-line-item-row.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/cart-line-items-table/cart-line-item-row.tsx
@@ -131,7 +131,14 @@ const CartLineItemRow: React.ForwardRefExoticComponent<
false,
isBoolean
);
- // User has to be logged in, feature enabled, and on the cart page with the block present.
+ // Three signals, each catching a distinct failure mode.
+ // Disabling the `cart_save_for_later` feature unregisters the
+ // saved-for-later block but leaves any prior insertion in the
+ // cart page's post content (the editor renders it as an
+ // "unsupported block" notice) — so presence alone could render
+ // this link with no working destination. Inversely, the feature
+ // can be enabled on cart pages that never inserted the block.
+ // And the REST endpoints behind the click are auth-only.
const showSaveForLater =
isUserLoggedIn &&
isSaveForLaterFeatureEnabled &&
@@ -369,14 +376,16 @@ const CartLineItemRow: React.ForwardRefExoticComponent<
if ( ! saved ) {
return;
}
- // `removeItem` surfaces its own errors via `processErrorResponse`. The
- // analytics event and a11y announcement still fire to mirror the
- // regular remove flow.
+ // removeItem surfaces its own errors
+ // via processErrorResponse; we still
+ // fire the analytics event and a11y
+ // announcement to mirror the regular
+ // remove flow.
await removeItem();
- // TODO: emit a dedicated
- // `cart-save-for-later` store event so
- // analytics can distinguish a save from
- // a plain remove.
+ // TODO: consider a dedicated
+ // 'cart-save-for-later' store event so
+ // analytics can distinguish a save
+ // from a plain remove.
dispatchStoreEvent(
'cart-remove-item',
{
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/context/hooks/cart/use-save-for-later.ts b/plugins/woocommerce/client/blocks/assets/js/base/context/hooks/cart/use-save-for-later.ts
index 0fa2f435bb7..54501e584bf 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/context/hooks/cart/use-save-for-later.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/base/context/hooks/cart/use-save-for-later.ts
@@ -8,9 +8,17 @@ import { store as noticesStore } from '@wordpress/notices';
import { cartStore } from '@woocommerce/block-data';
/**
- * Save a cart line item to the saved-for-later shopper list. Resolves to `true` on success. On failure,
- * surfaces an error notice in the cart context and resolves to `false`. Removing the line from the cart
- * is the caller's responsibility, so save and remove errors can be reported independently.
+ * Save a cart line item to the saved-for-later shopper list.
+ *
+ * Dispatches the wp.data cart store's `saveForLater` thunk, which POSTs the
+ * item and emits a `wc-blocks_store_sync_required` event. A Saved for Later
+ * block on the same page picks up that event in its iAPI store and applies
+ * the new row reactively.
+ *
+ * Resolves to `true` on success so the caller can chain the cart removal;
+ * on failure surfaces an error notice in the cart context and resolves to
+ * `false`. The cart removal is the caller's responsibility — keep the two
+ * awaits separate so save and remove errors stay distinct.
*/
export const useSaveForLater = (): {
isSaving: boolean;
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/shopper-lists.ts b/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/shopper-lists.ts
index a7060837601..523b1ca8944 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/shopper-lists.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/shopper-lists.ts
@@ -7,9 +7,11 @@ import type { CurrencyResponse } from '@woocommerce/types';
import type { Store as StoreNotices } from '@woocommerce/stores/store-notices';
/**
- * Mirror of {@see \Automattic\WooCommerce\StoreApi\Schemas\V1\ShopperListItemSchema::get_properties()}.
- * Keep in sync with the schema. UI-derived fields do not belong here. Display values are kept in
- * block-private stores or rendered server-side.
+ * Mirror of `Automattic\WooCommerce\StoreApi\Schemas\V1\ShopperListItemSchema::get_properties()`.
+ *
+ * Keep this in sync with the schema. State here must not include any UI-derived
+ * fields — display values belong in block-private stores or PHP SSR.
+ * TO DO: decide where UI-derived state lives
*/
export type ShopperListItemImage = {
id: number;
@@ -68,7 +70,14 @@ export type AddItemPayload = {
export type Store = {
state: {
restUrl: string;
- // Shared with the cart routes.
+ // TODO: revisit nonce handling when we look at authentication for
+ // the shopper-lists routes. Today PHP seeds this via
+ // `wp_create_nonce( 'wc_store_api' )` and we refresh it from
+ // response headers (see restRequest below). Likely changes once
+ // the routes start enforcing nonces server-side: align with the
+ // cart store's bootstrap-from-response-header pattern, share the
+ // cart's `state.nonce` instead of duplicating, or move to a
+ // caching-friendlier transport.
nonce: string;
lists: Record< string, ShopperListState >;
};
@@ -80,7 +89,7 @@ export type Store = {
};
};
-// Locked to prevent third-party use until the API stabilizes.
+// Stores are locked to prevent 3PD usage until the API is stable.
const universalLock =
'I acknowledge that using a private store means my plugin will inevitably break on the next store release.';
@@ -102,10 +111,15 @@ const ensureListState = (
};
/**
- * Send a Store API request using the cart store's auth shape: Nonce header
- * (`wc_store_api` action), cookie auth via `credentials: 'include'`, and
- * `cache: 'no-store'` for user-specific data. The nonce is seeded by PHP and
- * refreshed from the `Nonce` response header on each reply.
+ * Send a Store API request following the cart store's auth shape:
+ * Nonce header, `wc_store_api` action on the server side, cookie auth via
+ * `credentials: 'include'`, and `cache: 'no-store'` so user-specific data is
+ * never cached.
+ *
+ * The starter nonce is seeded by PHP via `wp_interactivity_state` and
+ * refreshed from the `Nonce` response header on every subsequent request,
+ * so the server-side enforcement (landing in a follow-up PR) can be
+ * flipped on without rewriting the client.
*/
async function restRequest< T >(
state: Store[ 'state' ],
@@ -152,6 +166,11 @@ async function restRequest< T >(
return json as T | null;
}
+// Do NOT supply `nonce` / `restUrl` defaults here. iAPI's deep-merge has the
+// JS-supplied state win over the existing (PHP-seeded) state for primitives,
+// so an empty-string default would clobber the values seeded server-side via
+// `wp_interactivity_state`. State for those fields comes purely from PHP. Same
+// reason the cart store doesn't ship state defaults — see cart.ts.
const { state, actions } = store< Store >(
'woocommerce/shopper-lists',
{
@@ -179,11 +198,12 @@ const { state, actions } = store< Store >(
const items = response.filter( isShopperListItem );
- // TODO: track in-flight mutation count and skip applying load results when mutations
- // are pending, so a slow loadList cannot overwrite a fresh add or remove.
+ // TODO: track in-flight mutation count and skip applying
+ // load results when mutations are pending, so a slow
+ // loadList cannot clobber a fresh add/remove.
list.items = items;
} catch ( error ) {
- // Logged for diagnostics.
+ // No user trigger to attach a banner to; log for ops.
// eslint-disable-next-line no-console
console.error( error );
} finally {
@@ -217,8 +237,10 @@ const { state, actions } = store< Store >(
);
}
- // Merge the returned item by key: replace if present, append otherwise. The server
- // merges quantity on duplicate saves, and this mirrors that behaviour client-side.
+ // Merge the returned item by key — replace if present,
+ // append otherwise. Re-saving the same product POSTs
+ // twice and the server merges quantity, so we mirror
+ // that behaviour locally.
const existingIndex = list.items.findIndex(
( i ) => i.key === item.key
);
@@ -242,8 +264,9 @@ const { state, actions } = store< Store >(
return;
}
- // Pessimistic remove: keep the row in place until the server confirms, to avoid a
- // momentary disappearance on failure. Buttons stay disabled meanwhile via `pendingKeys`.
+ // Pessimistic remove: leave the row in place until the
+ // server confirms, so failures don't flash. Buttons are
+ // disabled meanwhile via the block's `pendingKeys`.
try {
yield restRequest(
state,
@@ -257,7 +280,7 @@ const { state, actions } = store< Store >(
return;
}
- // Re-find. The list may have mutated during the await.
+ // Re-find — the list may have mutated during the await.
const removedIndex = list.items.findIndex(
( i ) => i.key === key
);
@@ -289,7 +312,16 @@ const { state, actions } = store< Store >(
{ lock: universalLock }
);
-// Syncs wp.data into this iAPI store after a wp.data action (e.g. the cart store's `saveForLater` thunk).
+// Listen for shopper-list item additions emitted from the wp.data side (e.g.
+// the cart store's saveForLater thunk). Mirrors the cart's iAPI → wp.data
+// sync direction, which also ships a payload (`from_iAPI` carries
+// `quantityChanges`). The event carries the saved item directly so we can
+// splice it in without an extra GET — keeps the merge ordering deterministic
+// and avoids the loadList-vs-mutation race the iAPI store's loadList still
+// has a TODO about.
+//
+// Keeps the discriminator + payload contract in sync with
+// `assets/js/data/cart/thunks.ts::saveForLater`.
window.addEventListener( 'wc-blocks_store_sync_required', ( event: Event ) => {
const detail = ( event as CustomEvent ).detail as
| { type?: string; slug?: string; item?: RawShopperListItem }
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-wishlist-button/frontend.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-wishlist-button/frontend.ts
index 00c5d480d63..1c639be4d9a 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-wishlist-button/frontend.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-wishlist-button/frontend.ts
@@ -35,12 +35,16 @@ type ButtonConfig = {
type BlockContext = {
productId: number;
isVariableType: boolean;
- // Mid-click flag so the button can be disabled while the request is in flight.
+ // Mid-click flag, gated per-block so the button can be disabled while
+ // the request is in flight. Single-instance block, no `pendingKeys`
+ // map needed (Wishlist/SFL use one because they're per-row).
isPending: boolean;
};
-// The slice of ATCWO's iAPI context this block reads. Reuses the cart store's `SelectedAttributes`
-// so shape changes flow through automatically.
+// The narrow slice of ATCWO's iAPI context this block consumes. Reuses
+// `SelectedAttributes` from the cart store — the same type ATCWO uses for
+// its own `selectedAttributes` context field — so any shape change there
+// (e.g. adding a `taxonomy` field) flows through here automatically.
type ATCWOContext = {
selectedAttributes: SelectedAttributes[];
};
@@ -75,8 +79,13 @@ const { state } = store< BlockStore >(
'woocommerce/add-to-wishlist-button',
{
state: {
- // Resolves to the selected variation's product ID via the products store. Returns 0 while a
- // variable product has no selection, which `isDisabled` treats as not yet selectable.
+ // For variable products, the effective product is the selected
+ // variation — resolved through the products store's
+ // `productInContext` derived getter, which already encapsulates
+ // "variation if one is selected, otherwise the parent." Returns
+ // 0 when the current resolution is still the variable parent
+ // (i.e. the shopper hasn't picked attributes yet), which
+ // `isDisabled` reads as "not yet selectable."
get effectiveProductId(): number {
const product = productsState.productInContext;
if ( ! product ) {
@@ -99,8 +108,12 @@ const { state } = store< BlockStore >(
return null;
}
const context = getContext< BlockContext >();
- // For variable products, several attribute combinations can map to the same variation,
- // so the picked attributes disambiguate the row. See `matchVariationItem`.
+ // For non-variable products, id alone uniquely identifies
+ // the wishlist row. For variable products with "any"
+ // attribute slots, several attribute combinations can map
+ // to the same variation product, so we additionally
+ // disambiguate by the shopper's picked attributes — see
+ // `matchVariationItem` for details.
if ( ! context.isVariableType ) {
return (
list.items.find( ( item ) => item.id === id ) ?? null
@@ -161,10 +174,21 @@ const { state } = store< BlockStore >(
existing.key
);
} else {
- // ATCWO stores selected attributes by display label ("Color"), but the
- // shopper-lists route expects taxonomy slugs ("pa_color"). Map via the parent
- // product's `attributes` table, falling back to the raw name for custom attributes.
- // TODO: drop this mapping once ATCWO exposes the taxonomy on `selectedAttributes`.
+ // We inherit ATCWO's iAPI context because this block
+ // is an inner block of ATCWO (enforced by
+ // `ancestor` in `block.json`). That lets us read
+ // the shopper-picked attributes — needed for
+ // variations with "any" slots, where the server
+ // can't resolve the line item without them.
+ //
+ // ATCWO stores them by display label ("Color"), but
+ // the shopper-lists route expects taxonomy slugs
+ // ("pa_color"). Map via the parent product's
+ // `attributes` table; fall back to the raw name for
+ // non-taxonomy custom attributes.
+ //
+ // TODO: drop this mapping once ATCWO exposes the
+ // taxonomy on `selectedAttributes` directly.
const addToCartContext = getContext< ATCWOContext >(
'woocommerce/add-to-cart-with-options'
);
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-wishlist-button/match-variation-item.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-wishlist-button/match-variation-item.ts
index f58c37e3e87..78369a4b093 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-wishlist-button/match-variation-item.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-wishlist-button/match-variation-item.ts
@@ -1,5 +1,7 @@
/**
- * Narrowed `RawShopperListItem` shape so this helper stays pure and unit-testable.
+ * Items in the wishlist whose variation values we can compare against the
+ * shopper's currently picked attributes. Narrowed from `RawShopperListItem`
+ * so this helper stays pure (no iAPI deps) and unit-testable in isolation.
*/
type MatchableItem = {
id: number;
@@ -10,7 +12,9 @@ type MatchableItem = {
};
/**
- * The shopper's currently picked attributes. Same shape as the cart store's `SelectedAttributes`.
+ * The shopper's currently picked attributes — same shape as ATCWO's
+ * `selectedAttributes` context entries (also re-exported from the cart store
+ * as `SelectedAttributes`).
*/
type SelectedPair = {
attribute: string;
@@ -18,13 +22,21 @@ type SelectedPair = {
};
/**
- * Whether a wishlist item matches the picked variation and attributes. An `id` match alone is not
- * enough for "any" attribute slots, where several combinations share one variation product, so the
- * attribute sets are compared too. The value comparison is case-insensitive because the Store API
- * returns the term display name ("Red") while ATCWO carries the slug ("red").
+ * Decide whether a wishlist item represents the exact variation + attribute
+ * combination the shopper has currently picked. For fully-constrained
+ * variations a single `id` match is enough, but for "any" attribute slots
+ * several attribute combinations can share the same variation product (and
+ * therefore the same `item.id`), so we additionally compare the attribute
+ * sets. The asymmetric `value` comparison is case-insensitive because the
+ * Store API returns each entry's `value` as the term display name ("Red")
+ * while ATCWO carries the term slug ("red") in `selectedAttributes`.
+ *
+ * Edge cases out of scope here (would need a slug→name lookup via the parent
+ * product's `terms`): slugs whose shape differs from the display name beyond
+ * capitalization, e.g. `"bright-red"` vs `"Bright Red"`.
*
* @param item Wishlist item from the shopper-lists store.
- * @param id Effective product/variation id to compare against.
+ * @param id Effective product/variation id we're comparing against.
* @param selected Shopper's picked attribute/value pairs.
*/
export function matchVariationItem(
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-wishlist-button/save.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-wishlist-button/save.tsx
index c299c223ee0..cdbc6befb56 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-wishlist-button/save.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-wishlist-button/save.tsx
@@ -1,3 +1,7 @@
+/**
+ * The block is rendered server-side and has no inner blocks, so there's
+ * nothing to serialise to `post_content`.
+ */
const Save = (): null => null;
export default Save;
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/saved-for-later/edit.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/saved-for-later/edit.tsx
index 77e79ea3ac8..2e2d4ea718f 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/saved-for-later/edit.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/saved-for-later/edit.tsx
@@ -23,6 +23,9 @@ interface EditProps {
const MIN_COLUMNS = 2;
const MAX_COLUMNS = 6;
+// Lives in JS because `__()` is needed for the heading copy. `block.json`
+// strings aren't run through translation, so keeping the template here
+// is the only way to ship a localized default.
const TEMPLATE: [ string, Record< string, unknown > ][] = [
[
'core/heading',
@@ -82,6 +85,11 @@ const Edit = ( { attributes, setAttributes }: EditProps ): JSX.Element => {
className: 'wc-block-saved-for-later',
} );
+ // `allowedBlocks` is read from block.json automatically — passing it
+ // here would just duplicate the declaration. `templateLock: false`
+ // is the default so we omit that too. The className matches the
+ // `<div>` PHP wraps `$content` in on the frontend, so any CSS keyed
+ // off `__header` applies in both contexts.
const innerBlocksProps = useInnerBlocksProps(
{ className: 'wc-block-saved-for-later__header' },
{ template: TEMPLATE }
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/saved-for-later/frontend.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/saved-for-later/frontend.ts
index 0713ad1462b..c78f8148c4e 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/saved-for-later/frontend.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/saved-for-later/frontend.ts
@@ -28,7 +28,10 @@ type SavedForLaterConfig = {
};
type BlockContext = {
- // Tracks whether the list has ever had items in this session. See `trackShownItems` below.
+ // Wrapper-scoped flag: starts as `items.length > 0` from SSR and the
+ // `trackShownItems` callback flips it to `true` the first time the
+ // list has any items at runtime. Lives in iAPI context so it resets
+ // on every full page load.
hasShownItems: boolean;
listItem?: RawShopperListItem;
htmlField?: 'price_html' | 'image_html';
@@ -58,8 +61,10 @@ type BlockStore = {
};
};
-// Allow-list for sanitizing the schema's preformatted strings on innerHTML swap. Covers the markup
-// emitted by `wc_price` and `wp_get_attachment_image` / `wc_placeholder_img`.
+// Allow-list for sanitizing the schema's preformatted strings on innerHTML
+// swap. Covers what `wc_price` (sale/discount markup, currency symbol) and
+// `wp_get_attachment_image` / `wc_placeholder_img` emit (responsive image
+// + dimensions + lazy loading).
const ALLOWED_TAGS = [
'a',
'b',
@@ -168,9 +173,12 @@ store< BlockStore >(
return ! listItem.is_purchasable;
},
- // `data-wp-text` writes its argument as text-content without entity decoding, so a name like
- // `Tom & Jerry` would render with the literal entity. Bind templates and SSR text spans
- // to this getter (not the raw context field) so rendered text matches PHP's first paint.
+ // `data-wp-text` writes its argument as text-content without
+ // running entity decoding, so a name returned by the schema as
+ // `Tom & Jerry` would render literally that way. Bind
+ // templates and SSR text spans to this getter instead of the
+ // raw context field so what the browser shows matches what
+ // PHP wrote on first paint.
get currentItemDisplayName(): string {
const { listItem } = getContext< BlockContext >();
return listItem ? decodeEntities( listItem.name ) : '';
@@ -237,10 +245,14 @@ store< BlockStore >(
return;
}
- // Map the schema's `variation` shape to the cart's `SelectedAttributes` shape. The schema
- // exposes the slug-form attribute under `raw_attribute` and a display label under
- // `attribute`. The cart matches by the slug form, so `attribute` is overridden with
- // `raw_attribute`. Same swap as mini-cart's `changeQuantity`. Empty for simple products.
+ // Map the schema's `variation` shape to the cart's
+ // SelectedAttributes shape. The schema returns the
+ // slug-form attribute under `raw_attribute` (e.g.
+ // `attribute_pa_color`) plus a display label under
+ // `attribute` (e.g. "Color"); the cart matches by the
+ // slug-form, so override `attribute` with `raw_attribute`.
+ // Same swap mini-cart's `changeQuantity` does. Empty for
+ // simple products.
const variation = listItem.variation.map(
( { raw_attribute: rawAttribute, value, attribute } ) => ( {
attribute: rawAttribute || attribute,
@@ -249,9 +261,11 @@ store< BlockStore >(
);
const isVariation = listItem.variation_id > 0;
- // `cartActions.addCartItem` catches its own errors and surfaces them as store notices,
- // so the yield resolves identically on success and failure. Snapshot the matching line's
- // quantity, run the add, and only remove from the saved list if the cart line grew.
+ // `cartActions.addCartItem` catches its own errors and
+ // surfaces them as store notices, so the yield resolves
+ // the same way on success and failure. Snapshot the
+ // matching line's quantity, run the add, then only remove
+ // from the saved list if it actually grew.
const lookup = {
id: listItem.id,
...( isVariation && { variation } ),
@@ -286,11 +300,15 @@ store< BlockStore >(
},
callbacks: {
- // Wrapper-level watcher: flips `hasShownItems` to `true` the first time the list has items.
- // Pairs with `state.isEmpty` to control the empty message. A shopper landing on a page with
- // nothing saved keeps the SSR-seeded `false` and sees no message. Once items have been seen,
- // an empty list surfaces the message. The flag never flips back to `false`, producing the
- // had-items to now-empty transition within a session.
+ // Wrapper-level watcher: flips `hasShownItems` to `true` the
+ // first time the list has any items. Pairs with `state.isEmpty`
+ // to gate the empty message — a new shopper landing on a page
+ // with nothing saved keeps the flag at its SSR-seeded `false`
+ // and never sees the message; once they save an item (or
+ // landed with items) the flag is `true`, so emptying the list
+ // from that point surfaces the message. The flag never flips
+ // back to `false`, which is what gives the "had-items → now-empty"
+ // transition we want during the session.
trackShownItems: () => {
const ctx = getContext< BlockContext >();
const list = getList( LIST_SLUG );
@@ -299,11 +317,16 @@ store< BlockStore >(
}
},
- // Shared innerHTML-swap callback for slots whose content is one of the schema's preformatted
- // HTML fields. The watched element carries `data-wp-context='{"htmlField":"price_html"}'` (or
- // `"image_html"`). This reads the named field off the row's `listItem` and writes its
- // sanitized HTML into `element.ref`. PHP renders the same HTML server-side, so hydration is
- // a no-op until the row's `listItem` changes.
+ // Single shared innerHTML-swap callback for any slot whose
+ // content is one of the schema's preformatted HTML fields.
+ // Mirrors the atomic product-elements `updateValue` callback:
+ // the watched element carries `data-wp-context='{"htmlField":"price_html"}'`
+ // (or `"image_html"`), and this callback reads that field
+ // off the row's `listItem` and pastes its sanitized HTML into
+ // `element.ref`. PHP renders the same HTML server-side, so
+ // hydration is a no-op when the row's listItem hasn't changed,
+ // and a clean swap when it has (e.g. after Remove shifts the
+ // next item into this slot).
updateInnerHtml: () => {
const { ref } = getElement();
const { listItem, htmlField } = getContext< BlockContext >();
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/saved-for-later/save.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/saved-for-later/save.tsx
index 7ed56b68a6e..6913e34a3d2 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/saved-for-later/save.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/saved-for-later/save.tsx
@@ -3,6 +3,12 @@
*/
import { InnerBlocks } from '@wordpress/block-editor';
+/**
+ * The block is rendered server-side, but the inner blocks (default
+ * `core/heading`) must be serialized to `post_content` so user edits
+ * persist — returning `null` would drop them. `<InnerBlocks.Content />`
+ * emits only the inner blocks' static markup.
+ */
const Save = (): JSX.Element => <InnerBlocks.Content />;
export default Save;
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/wishlist/edit.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/wishlist/edit.tsx
index caf1e3702ed..38bc1f98bf6 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/wishlist/edit.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/wishlist/edit.tsx
@@ -23,6 +23,9 @@ interface EditProps {
const MIN_COLUMNS = 2;
const MAX_COLUMNS = 6;
+// Lives in JS because `__()` is needed for the heading copy. `block.json`
+// strings aren't run through translation, so keeping the template here
+// is the only way to ship a localized default.
const TEMPLATE: [ string, Record< string, unknown > ][] = [
[ 'core/heading', { content: __( 'Wishlist', 'woocommerce' ), level: 2 } ],
];
@@ -73,6 +76,11 @@ const Edit = ( { attributes, setAttributes }: EditProps ): JSX.Element => {
className: 'wc-block-wishlist',
} );
+ // `allowedBlocks` is read from block.json automatically — passing it
+ // here would just duplicate the declaration. `templateLock: false`
+ // is the default so we omit that too. The className matches the
+ // `<div>` PHP wraps `$content` in on the frontend, so any CSS keyed
+ // off `__header` applies in both contexts.
const innerBlocksProps = useInnerBlocksProps(
{ className: 'wc-block-wishlist__header' },
{ template: TEMPLATE }
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/wishlist/frontend.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/wishlist/frontend.ts
index 9cffc36a3ba..a9d6451fcc3 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/wishlist/frontend.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/wishlist/frontend.ts
@@ -53,8 +53,10 @@ type BlockStore = {
};
};
-// Allow-list for sanitizing the schema's preformatted strings on innerHTML swap. Covers the markup
-// emitted by `wc_price` and `wp_get_attachment_image` / `wc_placeholder_img`.
+// Allow-list for sanitizing the schema's preformatted strings on innerHTML
+// swap. Covers what `wc_price` (sale/discount markup, currency symbol) and
+// `wp_get_attachment_image` / `wc_placeholder_img` emit (responsive image
+// + dimensions + lazy loading).
const ALLOWED_TAGS = [
'a',
'b',
@@ -137,9 +139,10 @@ store< BlockStore >(
return !! listItem && !! pendingKeys[ listItem.key ];
},
- // Unlike Saved for Later, Wishlist has no `hasShownItems` first-paint guard. The block is
- // reached deliberately (My Account endpoint or a merchant-placed instance), so the empty
- // state should be visible immediately on first paint when the list is empty.
+ // No `hasShownItems` gate: the visitor reached this block
+ // deliberately (My Account endpoint or merchant-placed), so
+ // showing the empty message immediately when the list is
+ // empty is the right signal.
get isEmpty(): boolean {
const list = getList( LIST_SLUG );
if ( ! list ) {
@@ -161,9 +164,12 @@ store< BlockStore >(
return ! listItem.is_purchasable;
},
- // `data-wp-text` writes its argument as text-content without entity decoding, so a name like
- // `Tom & Jerry` would render with the literal entity. Bind templates and SSR text spans
- // to this getter (not the raw context field) so rendered text matches PHP's first paint.
+ // `data-wp-text` writes its argument as text-content without
+ // running entity decoding, so a name returned by the schema as
+ // `Tom & Jerry` would render literally that way. Bind
+ // templates and SSR text spans to this getter instead of the
+ // raw context field so what the browser shows matches what
+ // PHP wrote on first paint.
get currentItemDisplayName(): string {
const { listItem } = getContext< BlockContext >();
return listItem ? decodeEntities( listItem.name ) : '';
@@ -216,10 +222,13 @@ store< BlockStore >(
return;
}
- // Map the schema's `variation` shape to the cart's `SelectedAttributes` shape. The schema
- // exposes the slug-form attribute under `raw_attribute` and a display label under
- // `attribute`. The cart matches by the slug form, so `attribute` is overridden with
- // `raw_attribute`. Empty for simple products.
+ // Map the schema's `variation` shape to the cart's
+ // SelectedAttributes shape. The schema returns the
+ // slug-form attribute under `raw_attribute` (e.g.
+ // `attribute_pa_color`) plus a display label under
+ // `attribute` (e.g. "Color"); the cart matches by the
+ // slug-form, so override `attribute` with `raw_attribute`.
+ // Empty for simple products.
const variation = listItem.variation.map(
( { raw_attribute: rawAttribute, value, attribute } ) => ( {
attribute: rawAttribute || attribute,
@@ -228,11 +237,14 @@ store< BlockStore >(
);
const isVariation = listItem.variation_id > 0;
- // Wishlist always adds quantity 1 (no quantity column). `cartActions.addCartItem` catches
- // its own errors and surfaces them as store notices, so the yield resolves identically
- // on success and failure. Snapshot the matching line's quantity, run the add, and only
- // remove from the wishlist if the cart line grew. Guards against partial-stock paths
- // where the wishlist entry must remain.
+ // Wishlist always adds quantity 1 (no quantity column).
+ // `cartActions.addCartItem` catches its own errors and
+ // surfaces them as store notices, so the yield resolves
+ // the same way on success and failure. Snapshot the
+ // matching line's quantity, run the add, then only remove
+ // from the wishlist if the cart line actually grew — that
+ // guards against partial-stock and silent-failure paths
+ // where we shouldn't drop the wishlist entry.
const lookup = {
id: listItem.id,
...( isVariation && { variation } ),
@@ -267,11 +279,16 @@ store< BlockStore >(
},
callbacks: {
- // Shared innerHTML-swap callback for slots whose content is one of the schema's preformatted
- // HTML fields. The watched element carries `data-wp-context='{"htmlField":"price_html"}'` (or
- // `"image_html"`). This reads the named field off the row's `listItem` and writes its
- // sanitized HTML into `element.ref`. PHP renders the same HTML server-side, so hydration is
- // a no-op until the row's `listItem` changes.
+ // Single shared innerHTML-swap callback for any slot whose
+ // content is one of the schema's preformatted HTML fields.
+ // Mirrors the atomic product-elements `updateValue` callback:
+ // the watched element carries `data-wp-context='{"htmlField":"price_html"}'`
+ // (or `"image_html"`), and this callback reads that field
+ // off the row's `listItem` and pastes its sanitized HTML into
+ // `element.ref`. PHP renders the same HTML server-side, so
+ // hydration is a no-op when the row's listItem hasn't changed,
+ // and a clean swap when it has (e.g. after Remove shifts the
+ // next item into this slot).
updateInnerHtml: () => {
const { ref } = getElement();
const { listItem, htmlField } = getContext< BlockContext >();
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/wishlist/save.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/wishlist/save.tsx
index 7ed56b68a6e..6913e34a3d2 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/wishlist/save.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/wishlist/save.tsx
@@ -3,6 +3,12 @@
*/
import { InnerBlocks } from '@wordpress/block-editor';
+/**
+ * The block is rendered server-side, but the inner blocks (default
+ * `core/heading`) must be serialized to `post_content` so user edits
+ * persist — returning `null` would drop them. `<InnerBlocks.Content />`
+ * emits only the inner blocks' static markup.
+ */
const Save = (): JSX.Element => <InnerBlocks.Content />;
export default Save;
diff --git a/plugins/woocommerce/client/blocks/assets/js/data/cart/thunks.ts b/plugins/woocommerce/client/blocks/assets/js/data/cart/thunks.ts
index 2ef9d387fe6..a1277513d2f 100644
--- a/plugins/woocommerce/client/blocks/assets/js/data/cart/thunks.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/data/cart/thunks.ts
@@ -486,10 +486,19 @@ export const removeItemFromCart =
};
/**
- * Save a cart line item to the saved-for-later shopper list. On success, dispatches a
- * `wc-blocks_store_sync_required` event so a `woocommerce/shopper-lists` iAPI store on the same page can
- * splice the returned item into local state without an extra GET. Removing the item from the cart is the
- * caller's responsibility, so save and remove errors can be reported independently.
+ * Saves a cart line item to the saved-for-later shopper list.
+ *
+ * On success, emits a `wc-blocks_store_sync_required` event with the saved
+ * item in `detail.item` so a `woocommerce/shopper-lists` iAPI store on the
+ * same page (rendered by a Saved for Later block) can splice the row
+ * into its local state — no extra GET, no race window between a slow
+ * refetch and concurrent mutations. Same envelope the cart's iAPI → wp.data
+ * sync uses to ship payloads (`detail.type === 'from_iAPI'` carries
+ * `quantityChanges`); this is the wp.data → iAPI direction of the same
+ * pattern.
+ *
+ * Removing the item from the cart is the caller's responsibility — keep the
+ * two awaits separate so save and remove errors can be reported distinctly.
*
* @param {string} cartItemKey Cart item to save.
*/
diff --git a/plugins/woocommerce/client/blocks/assets/js/data/shared-controls.ts b/plugins/woocommerce/client/blocks/assets/js/data/shared-controls.ts
index 4ea04bd85fa..e1cbd76034a 100644
--- a/plugins/woocommerce/client/blocks/assets/js/data/shared-controls.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/data/shared-controls.ts
@@ -130,9 +130,9 @@ const preventBatching = [
'/wc/store/v1/checkout',
'/wc/store/v1/checkout?__experimental_calc_totals=true',
'/wc/store/v1/cart/update-item',
- // Shopper-lists routes do not declare `allow_batch` server-side yet.
+ // Shopper-lists routes don't declare allow_batch yet. Drop these once
+ // the routes opt into batching server-side.
'/wc/store/v1/shopper-lists/saved-for-later/items',
- '/wc/store/v1/shopper-lists/wishlist/items',
];
/**
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/AddToWishlistButton.php b/plugins/woocommerce/src/Blocks/BlockTypes/AddToWishlistButton.php
index 3d9743f0d94..cc088fd2707 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/AddToWishlistButton.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/AddToWishlistButton.php
@@ -8,10 +8,21 @@ use Automattic\WooCommerce\Blocks\Utils\BlocksSharedState;
use Automattic\WooCommerce\Internal\ShopperLists\ShopperListRenderer;
/**
- * Add to Wishlist Button block. Single-product toggle for the wishlist, shipped as an inner block of
- * `woocommerce/add-to-cart-with-options` so it renders inside the form's iAPI scope and can read its
- * selected variation. On click, toggles the configured product in the shopper's wishlist via the shared
- * `woocommerce/shopper-lists` iAPI store. Logged-in only and behind the `product_wishlist` feature flag.
+ * Add to Wishlist Button block.
+ *
+ * Single-product trigger UI for the wishlist. Shipped as an inner block of
+ * `woocommerce/add-to-cart-with-options` (ATCWO) via the per-product-type
+ * template parts, so it always renders inside the form's iAPI scope and can
+ * read its `selectedAttributes` context directly. The `ancestor` restriction
+ * in `block.json` prevents merchants from inserting the block outside ATCWO
+ * (where it'd lose iAPI scope and the variation-attribute read would break).
+ *
+ * Hidden for guests and gated by the `product_wishlist` feature flag. On
+ * click, toggles the currently configured product (parent or selected
+ * variation) in the shopper's wishlist via the shared
+ * `woocommerce/shopper-lists` iAPI store. Errors are surfaced through the
+ * page's existing `woocommerce/store-notices` region — no inline notices
+ * area of its own.
*/
final class AddToWishlistButton extends AbstractBlock {
/**
@@ -35,7 +46,8 @@ final class AddToWishlistButton extends AbstractBlock {
* @return string Rendered block type output.
*/
protected function render( $attributes, $content, $block ) {
- // Guests have no personal list. Bail before enqueuing assets or seeding state.
+ // Guests can't have a wishlist — bail before enqueuing assets or
+ // seeding state.
if ( ! is_user_logged_in() ) {
return '';
}
@@ -57,7 +69,12 @@ final class AddToWishlistButton extends AbstractBlock {
$items = $this->prefetch_items();
- // Seed the shared shopper-lists store with the REST URL, prefetched items, and a bootstrap nonce.
+ // Seed the shared shopper-lists store the same way the Wishlist
+ // block does — restUrl + starter nonce + prefetched items. The
+ // two blocks may both render on the same page (e.g. the merchant
+ // drops the Wishlist block into a sidebar of single-product); iAPI's
+ // deep-merge keeps the first writer's payload, so seeding identical
+ // values here is a no-op when Wishlist already ran.
wp_interactivity_state(
'woocommerce/shopper-lists',
array(
@@ -72,7 +89,10 @@ final class AddToWishlistButton extends AbstractBlock {
)
);
- // Visible labels passed through `wp_interactivity_config` for the JS-side getter.
+ // Visible labels flow through `wp_interactivity_config` so the
+ // JS-side getter can pick the right one based on
+ // `state.isInWishlist`. PHP renders the matching one as the
+ // initial server-side label.
wp_interactivity_config(
'woocommerce/add-to-wishlist-button',
array(
@@ -143,8 +163,10 @@ final class AddToWishlistButton extends AbstractBlock {
}
/**
- * Prefetch the wishlist items via `rest_do_request()`. Returns an empty
- * list for logged-out users, since the route requires authentication.
+ * Prefetch the wishlist items via `rest_do_request()`. Logged-out
+ * users short-circuit to an empty list — the route requires
+ * authentication and we don't want to fire an API call that's only
+ * going to 401.
*
* @return array<int, array<string, mixed>> Items in the schema response shape.
*/
@@ -179,8 +201,10 @@ final class AddToWishlistButton extends AbstractBlock {
}
/**
- * Whether the current product is already in the prefetched wishlist. Always false for variable
- * products, since the selected variation is not known until JS hydrates.
+ * Whether the current product (or its parent, for a variable parent
+ * with no selection yet) is already in the prefetched wishlist. For
+ * variable products the SSR star is always empty — we can't know which
+ * variation the shopper will pick before JS hydrates.
*
* @param array<int, array<string, mixed>> $items Schema-shape items.
* @param \WC_Product $product The product being viewed.
@@ -213,7 +237,9 @@ final class AddToWishlistButton extends AbstractBlock {
}
/**
- * Visible label shown until the shopper picks variation attributes.
+ * Visible label when the shopper still needs to pick variation
+ * attributes before the wishlist toggle can resolve to a specific
+ * variation.
*/
private function get_select_options_label(): string {
return __( 'Select options first', 'woocommerce' );
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/SavedForLater.php b/plugins/woocommerce/src/Blocks/BlockTypes/SavedForLater.php
index f74b0d926aa..b0d5eb8750b 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/SavedForLater.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/SavedForLater.php
@@ -9,14 +9,25 @@ use Automattic\WooCommerce\Internal\ShopperLists\ShopperListRenderer;
use Automattic\WooCommerce\Proxies\LegacyProxy;
/**
- * Saved for Later block. Renders the shopper's Saved for Later list from the `shopper-lists` Store API,
- * sharing state with the cart via the `woocommerce/shopper-lists` iAPI store. Shared row markup lives in
- * {@see ShopperListRenderer}. Adds Block Hooks auto-injection, the empty-state guard, the quantity span,
- * and the Move-to-cart action.
+ * Saved for Later block.
+ *
+ * Renders the shopper's "Saved for Later" list, wired to the `shopper-lists`
+ * Store API endpoints via the shared `woocommerce/shopper-lists` iAPI store.
+ * PHP prefetches the list so the first paint is already populated; JS then
+ * takes over for adds, removes, and Move-to-cart.
+ *
+ * The row markup (image, name, price, remove badge, variation overlay) is
+ * shared with other shopper-list blocks via `ShopperListRenderer`. This
+ * class composes those fragments and adds the bits that are unique to
+ * Saved for Later: auto-injection via the Block Hooks API, the
+ * `hasShownItems` empty-state gating, the per-row quantity span, and the
+ * Move-to-cart action button.
*/
final class SavedForLater extends AbstractBlock {
/**
- * Slug of the shopper list this block renders.
+ * The list slug this block renders. Constant — when additional list
+ * types ship as their own blocks (e.g. Wishlist), each one will
+ * hardcode its own slug.
*/
private const LIST_SLUG = 'saved-for-later';
@@ -33,7 +44,7 @@ final class SavedForLater extends AbstractBlock {
protected function initialize(): void {
parent::initialize();
- // `BlockHooksTrait` is not used here because of PHPStan issues with the trait's annotations.
+ // We do not use `BlockHooksTrait` currently as it has issues with PHPStan.
add_filter( 'hooked_block_types', array( $this, 'register_hooked_block' ), 9, 4 );
add_filter( 'hooked_block_woocommerce/saved-for-later', array( $this, 'set_hooked_block_attributes' ), 10, 4 );
}
@@ -58,7 +69,8 @@ final class SavedForLater extends AbstractBlock {
return $hooked_block_types;
}
- // Skip injection if the block is already present in the cart page content.
+ // Don't double-inject if the block is already in the cart page
+ // content.
if ( has_block( $this->get_full_block_name(), $context ) ) {
return $hooked_block_types;
}
@@ -84,17 +96,23 @@ final class SavedForLater extends AbstractBlock {
return $parsed_hooked_block;
}
- // Seed a `core/heading` inner block so auto-injected instances carry the editor template's heading.
- // Append unconditionally: checking `empty( $parsed_hooked_block['innerBlocks'] )` would suppress this
- // heading whenever another extension already hooked into `hooked_block_woocommerce/saved-for-later`.
+ // Seed a `core/heading` inner block so freshly-injected instances
+ // ship with the same heading the editor template seeds. We append
+ // unconditionally — extensions are free to hook
+ // `hooked_block_woocommerce/saved-for-later` to add their own
+ // inner blocks, and gating on `empty( innerBlocks )` would silently
+ // suppress our heading whenever any other extension ran first.
//
- // `core/heading` is a static block, so the serialised markup must match the editor-saved form
- // (`<h2 class="wp-block-heading">…</h2>`) or block validation will fail when the cart page is opened
- // in the editor. `attrs.content` mirrors the editor template's seed (`{ content, level }`) so the
- // parsed shape round-trips identically. The value is the raw string because attrs are JSON-encoded
- // into the block comment delimiter, and `esc_html()` would corrupt translations containing `&`, `<`,
- // etc. The matching `null` push onto `innerContent` is required for `WP_Block::render()` to descend
- // into the heading when assembling `$content`.
+ // `core/heading` is a static block, so the serialised markup must
+ // match what the editor would have saved (`<h2 class="wp-block-heading">…</h2>`)
+ // or it'll fail block validation when the cart page is opened in the
+ // editor. `attrs.content` mirrors what the editor's template seeds
+ // (`{ content, level }`) so the parsed shape round-trips identically;
+ // the value is the raw string because attrs are JSON-encoded into the
+ // block comment and `esc_html()` would corrupt translations whose text
+ // contains `&`, `<`, etc. The matching `null` push onto `innerContent`
+ // is what makes `WP_Block::render()` walk into the heading when
+ // building `$content`.
$list_heading = __( 'Saved for later', 'woocommerce' );
$heading_html = '<h2 class="wp-block-heading">' . esc_html( $list_heading ) . '</h2>';
@@ -128,16 +146,22 @@ final class SavedForLater extends AbstractBlock {
* @return string Rendered block type output.
*/
protected function render( $attributes, $content, $block ) {
- // Guests have no personal list. Bail before enqueuing assets or seeding state.
+ // Guests have no personal list — bail before enqueuing assets or seeding state.
if ( ! is_user_logged_in() ) {
return '';
}
+ // Set from render() (not Cart::enqueue_data via has_block()) so it works when this
+ // block is auto-injected via the Block Hooks API and isn't in stored post_content.
if ( wc_get_container()->get( LegacyProxy::class )->call_function( 'is_cart' ) ) {
$this->asset_data_registry->add( 'cartPageHasSavedForLater', true );
}
- // Clamp to the 2-6 range supported by the SCSS.
+ // Clamp to the 2-6 range the SCSS `@for $i from 2 through 6` loop and
+ // the editor `RangeControl` both support. `absint()` first defends
+ // against a code-editor override (the attribute can be set to any
+ // JSON value there); the `min`/`max` then keep the value within the
+ // range where a `&.columns-#{$i}` rule actually exists.
$column_count = min( 6, max( 2, absint( $attributes['columnCount'] ?? 5 ) ) );
wp_enqueue_script_module( $this->get_full_block_name() );
@@ -145,12 +169,21 @@ final class SavedForLater extends AbstractBlock {
$consent = 'I acknowledge that using private APIs means my theme or plugin will inevitably break in the next version of WooCommerce';
BlocksSharedState::load_store_config( $consent );
BlocksSharedState::load_placeholder_image( $consent );
- // Required so the Move-to-cart action has a hydrated cart store to dispatch into.
+ // `Move to cart` calls into the shared cart store, which expects
+ // `state.cart.items` and friends. Without this load the cart store
+ // would have no hydrated cart and the action would throw on the
+ // first click.
BlocksSharedState::load_cart_state( $consent );
$items = $this->prefetch_items();
- // Seed the shared shopper-lists store with the REST URL, prefetched items, and a bootstrap nonce.
+ // Seed the shared shopper-lists store with the rest URL, the
+ // pre-fetched items, and a starter nonce. The starter nonce is
+ // what the cart store also seeds via `state.nonce` — the JS layer
+ // keeps it fresh by reading the `Nonce` response header on every
+ // subsequent request, so this is just the bootstrap value (and
+ // avoids deadlocking mutations that await `isNonceReady` before
+ // any GET has fired).
wp_interactivity_state(
'woocommerce/shopper-lists',
array(
@@ -165,7 +198,10 @@ final class SavedForLater extends AbstractBlock {
)
);
- // Sprintf templates passed through `wp_interactivity_config` for JS-side interpolation.
+ // Templates flow through `wp_interactivity_config` so the JS-side
+ // getters can interpolate them (`%d`, `%s`). Visible strings (empty
+ // state, error, action label) are rendered server-side and toggled
+ // with directives, so they don't need to ride here too.
wp_interactivity_config(
'woocommerce/saved-for-later',
array(
@@ -174,14 +210,25 @@ final class SavedForLater extends AbstractBlock {
)
);
- // `hasShownItems` seeds the per-block context that controls the empty message.
+ // `hasShownItems` seeds the per-block context so the empty message
+ // stays hidden for new shoppers who land on a page with nothing
+ // saved. The JS-side watcher flips it to `true` the first time the
+ // list has any items (whether that's the SSR seed or a runtime add
+ // via "Save for later"), and `state.isEmpty` only flips on when the
+ // flag is set *and* the list is currently empty. The flag lives in
+ // the per-block context, so it naturally resets on every full page
+ // load — no extra Store API field or persisted flag needed.
+ // `data-wp-context---notices` seeds the store-notices namespace
+ // alongside the block's own context on the same wrapper.
$wrapper_attributes = array(
'class' => 'wc-block-saved-for-later',
'data-wp-interactive' => 'woocommerce/saved-for-later',
'data-wp-context' => (string) wp_json_encode(
array(
'hasShownItems' => ! empty( $items ),
- // `stdClass` so JSON serializes as `{}` rather than `[]`.
+ // `stdClass` so it serialises as `{}`, not `[]` —
+ // iAPI's reactive proxy only fires updates on object
+ // writes, not array expandos.
'pendingKeys' => new \stdClass(),
)
),
@@ -198,8 +245,9 @@ final class SavedForLater extends AbstractBlock {
}
/**
- * Prefetch the saved-for-later items via `rest_do_request()`. Returns an empty
- * list for logged-out users, since the route requires authentication.
+ * Prefetch the saved-for-later items via `rest_do_request()`. Logged-out
+ * users short-circuit to an empty list — the route requires authentication
+ * and we don't want to fire an API call that's only going to 401.
*
* @return array<int, array<string, mixed>> Items in the schema response shape.
*/
@@ -214,7 +262,11 @@ final class SavedForLater extends AbstractBlock {
if ( $response->is_error() ) {
$error = $response->as_error();
$message = $error instanceof \WP_Error ? $error->get_error_message() : 'Unknown error';
- // Logged for diagnostics.
+ // Logged at debug level on purpose: prefetch failures are
+ // often transient (network blips, auth refresh races) and
+ // the user-visible behaviour is the empty state — nothing
+ // for ops to act on. Anyone investigating a regression can
+ // flip the WC logger to debug to surface them.
wc_get_logger()->debug(
sprintf( 'Saved for Later prefetch failed: %s', $message ),
array(
@@ -230,15 +282,19 @@ final class SavedForLater extends AbstractBlock {
return array();
}
- // The schema casts `prices` and image entries to stdClass so JSON renders them as objects.
- // Round-trip through JSON to normalise everything to nested arrays for the SSR markup helpers.
+ // The schema casts `prices` and image entries to stdClass so the
+ // JSON response renders objects, not arrays. Round-trip through
+ // JSON encode/decode to normalise everything to nested arrays so
+ // the SSR markup helpers below can treat fields uniformly.
$decoded = json_decode( (string) wp_json_encode( $data ), true );
return is_array( $decoded ) ? $decoded : array();
}
/**
- * Render the `<template data-wp-each>` used by iAPI to render rows on the client. Pre-rendered
- * `data-wp-each-child` elements sit alongside to populate first paint.
+ * The `<template data-wp-each>` describing how each item is rendered on
+ * the client. Pre-rendered children sit alongside as `data-wp-each-child`
+ * elements so first paint is populated. Composes the shared row markup
+ * with Saved for Later's quantity span and Move-to-cart action button.
*
* @return string
*/
@@ -250,7 +306,8 @@ final class SavedForLater extends AbstractBlock {
}
/**
- * Render the SSR markup for each item. Reconciled by iAPI via `data-wp-each-child` after hydration.
+ * Render the SSR markup for each item. JS will reconcile these via
+ * `data-wp-each-child` after hydration.
*
* @param array<int, array<string, mixed>> $items Schema-shape items.
* @return string
@@ -264,7 +321,8 @@ final class SavedForLater extends AbstractBlock {
}
/**
- * Render a single SSR item, combining the shared row markup with the quantity span and Move-to-cart button.
+ * Render a single SSR item. Composes the shared image / name / price
+ * markup with the SFL-specific quantity span and Move-to-cart button.
*
* @param array<string, mixed> $item Schema-shape item.
* @return string
@@ -277,7 +335,8 @@ final class SavedForLater extends AbstractBlock {
}
/**
- * Template-mode markup for the quantity span.
+ * Template-mode markup for the quantity span. SFL-specific — Wishlist
+ * has no quantity column.
*
* @return string
*/
@@ -289,7 +348,7 @@ final class SavedForLater extends AbstractBlock {
}
/**
- * Template-mode markup for the Move-to-cart action button.
+ * Template-mode markup for the Move-to-cart action button. SFL-specific.
*
* @return string
*/
@@ -311,7 +370,7 @@ final class SavedForLater extends AbstractBlock {
}
/**
- * SSR-mode markup for the quantity span.
+ * SSR-mode markup for the quantity span. SFL-specific.
*
* @param array<string, mixed> $item Schema-shape item.
* @return string
@@ -327,8 +386,10 @@ final class SavedForLater extends AbstractBlock {
}
/**
- * SSR-mode markup for the Move-to-cart action button. The wrapper is always emitted so iAPI can toggle
- * `hidden` after hydration. Starts hidden when the row is not purchasable.
+ * SSR-mode markup for the Move-to-cart action button. SFL-specific.
+ * Always emits the wrapper so iAPI can toggle `hidden` after hydration
+ * without swapping the row out. Starts hidden when the row isn't
+ * purchasable.
*
* @param array<string, mixed> $item Schema-shape item.
* @return string
@@ -360,10 +421,14 @@ final class SavedForLater extends AbstractBlock {
}
/**
- * Wrap the inner-block content in a wrapper that mirrors the empty-state visibility. Hidden until
- * `context.hasShownItems` flips to `true`. Returns an empty string when no content needs wrapping.
+ * Wrap the inner-block content (heading + any future siblings) in an
+ * element whose visibility mirrors the empty-state gating: hidden when
+ * the shopper has never seen items in this session, revealed once
+ * `context.hasShownItems` flips to `true`. Returns an empty string when
+ * there's no content to wrap (e.g. merchant deleted the heading and
+ * saved), so we don't emit an empty `<div>`.
*
- * @param string $content Rendered inner-block content (usually the heading HTML).
+ * @param string $content Rendered inner-block content (typically the heading HTML).
* @param bool $is_empty Whether the saved-for-later list is empty on initial paint.
* @return string
*/
@@ -380,8 +445,10 @@ final class SavedForLater extends AbstractBlock {
}
/**
- * Render the empty-state markup. Always present in the DOM so iAPI can reveal it once the last item is
- * removed. Initially hidden: `state.isEmpty` requires the `hasShownItems` context flag to flip first.
+ * Render the empty-state markup. Always present in the DOM so JS can
+ * toggle it on once the last item is removed. Initially hidden: SSR
+ * never shows the message, since `state.isEmpty` requires the JS-side
+ * `hasShownItems` context flag to flip first.
*
* @return string
*/
@@ -394,8 +461,10 @@ final class SavedForLater extends AbstractBlock {
}
/**
- * Sprintf template for the per-row quantity label. Shared between PHP SSR and the JS-side getter
- * (seeded via `wp_interactivity_config`) so both paths produce identical output.
+ * Sprintf template for the per-row quantity label. Used both by PHP SSR
+ * (`render_ssr_quantity()`) and by the JS-side getter (via
+ * `wp_interactivity_config`) so both paths produce the same string after
+ * `%d` interpolation.
*/
private function get_quantity_label_template(): string {
/* translators: %d: quantity of saved items. */
@@ -403,7 +472,8 @@ final class SavedForLater extends AbstractBlock {
}
/**
- * Sprintf template for the per-row remove button's aria-label. Shared between PHP SSR and JS.
+ * Sprintf template for the per-row remove button's aria-label. Same dual
+ * use as the quantity template.
*/
private function get_remove_label_template(): string {
/* translators: %s: product name. */
@@ -411,7 +481,8 @@ final class SavedForLater extends AbstractBlock {
}
/**
- * Visible label for the Move-to-cart action button. Used by the iAPI `<template>` and the SSR markup.
+ * Visible label for the move-to-cart action button, used by both the
+ * iAPI `<template>` and the SSR per-row markup.
*/
private function get_move_to_cart_label(): string {
return __( 'Move to cart', 'woocommerce' );
@@ -430,8 +501,16 @@ final class SavedForLater extends AbstractBlock {
}
/**
- * Frontend style handle. Returns null so WP loads the `style` array from block.json, which lists this
- * block's stylesheet and the atomic product-image/price/button stylesheets whose classes it reuses.
+ * Get the frontend style handle for this block type.
+ *
+ * Returning null lets WP use the `style` array from block.json, which
+ * lists this block's own stylesheet plus the atomic
+ * product-image / product-price / product-button stylesheets we
+ * borrow class names from. We can't render those atomic blocks as
+ * inner blocks (they rely on WP_Query / $post loop context, which
+ * this block doesn't have — it hydrates from a Store API call), so
+ * declaring them as style dependencies is the only way to get WP
+ * to enqueue their CSS whenever Saved for Later renders.
*
* @return null
*/
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/Wishlist.php b/plugins/woocommerce/src/Blocks/BlockTypes/Wishlist.php
index 436a34270f7..1a26fb39d39 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/Wishlist.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/Wishlist.php
@@ -8,14 +8,24 @@ use Automattic\WooCommerce\Blocks\Utils\BlocksSharedState;
use Automattic\WooCommerce\Internal\ShopperLists\ShopperListRenderer;
/**
- * Wishlist block. Renders the shopper's wishlist via the `shopper-lists` Store API and the shared
- * `woocommerce/shopper-lists` iAPI store. Merchant-placed (no Block Hooks integration), and also rendered
- * by the `/my-account/wishlist/` endpoint when the `product_wishlist` feature flag is enabled. The Add to
- * cart action adds the product to the cart and removes the row from the wishlist on confirmed success.
+ * Wishlist block.
+ *
+ * Renders the shopper's wishlist, wired to the `shopper-lists` Store API
+ * endpoints via the shared `woocommerce/shopper-lists` iAPI store. PHP
+ * prefetches the list so the first paint is already populated; JS then
+ * takes over for adds, removes, and the per-row "Add to cart" action.
+ *
+ * Unlike Saved for Later, this block is merchant-placed — no Block Hooks
+ * API integration. It's rendered by the `/my-account/wishlist/` endpoint
+ * (gated by the `product_wishlist` feature flag) and can also be placed
+ * on any other page or template. "Add to cart" mirrors Saved for Later's
+ * Move-to-cart flow: add the product to the cart, then remove it from the
+ * wishlist on confirmed success.
*/
final class Wishlist extends AbstractBlock {
/**
- * Slug of the shopper list this block renders.
+ * The list slug this block renders. Constant — when additional list
+ * types ship as their own blocks, each one hardcodes its own slug.
*/
private const LIST_SLUG = 'wishlist';
@@ -35,13 +45,20 @@ final class Wishlist extends AbstractBlock {
* @return string Rendered block type output.
*/
protected function render( $attributes, $content, $block ) {
- // Guests have no personal list. The My Account endpoint is unreachable for guests, and the same
- // guard is needed when a merchant places this block on any other page.
+ // Guests have no personal list — bail before enqueuing assets or
+ // seeding state. The My Account endpoint isn't reachable for
+ // guests, but the block can also be placed by a merchant on any
+ // page, where this guard is what stops it from rendering an
+ // empty shell for logged-out visitors.
if ( ! is_user_logged_in() ) {
return '';
}
- // Clamp to the 2-6 range supported by the SCSS.
+ // Clamp to the 2-6 range the SCSS `@for $i from 2 through 6` loop
+ // and the editor `RangeControl` both support. `absint()` first
+ // defends against a code-editor override (the attribute can be set
+ // to any JSON value there); the `min`/`max` then keep the value
+ // within the range where a `&.columns-#{$i}` rule actually exists.
$column_count = min( 6, max( 2, absint( $attributes['columnCount'] ?? 5 ) ) );
wp_enqueue_script_module( $this->get_full_block_name() );
@@ -49,12 +66,21 @@ final class Wishlist extends AbstractBlock {
$consent = 'I acknowledge that using private APIs means my theme or plugin will inevitably break in the next version of WooCommerce';
BlocksSharedState::load_store_config( $consent );
BlocksSharedState::load_placeholder_image( $consent );
- // Required so the Add to cart action has a hydrated cart store to dispatch into.
+ // `Add to cart` calls into the shared cart store, which expects
+ // `state.cart.items` and friends. Without this load the cart store
+ // would have no hydrated cart and the action would throw on the
+ // first click.
BlocksSharedState::load_cart_state( $consent );
$items = $this->prefetch_items();
- // Seed the shared shopper-lists store with the REST URL, prefetched items, and a bootstrap nonce.
+ // Seed the shared shopper-lists store with the rest URL, the
+ // pre-fetched items, and a starter nonce. The starter nonce is
+ // what the cart store also seeds via `state.nonce` — the JS layer
+ // keeps it fresh by reading the `Nonce` response header on every
+ // subsequent request, so this is just the bootstrap value (and
+ // avoids deadlocking mutations that await `isNonceReady` before
+ // any GET has fired).
wp_interactivity_state(
'woocommerce/shopper-lists',
array(
@@ -69,7 +95,9 @@ final class Wishlist extends AbstractBlock {
)
);
- // Only the remove-button aria-label template needs JS-side interpolation.
+ // Only the remove-button aria-label template needs JS-side
+ // interpolation; visible strings (empty state, action label) are
+ // rendered server-side and toggled with directives.
wp_interactivity_config(
'woocommerce/wishlist',
array(
@@ -77,13 +105,22 @@ final class Wishlist extends AbstractBlock {
)
);
- // No `hasShownItems` flag here, unlike Saved for Later: the empty message shows immediately.
+ // No `hasShownItems` flag: unlike Saved for Later (which auto-
+ // renders on every cart visit and must avoid flashing an empty
+ // message before a runtime save lands), Wishlist is reached
+ // deliberately — by the My Account endpoint or because a merchant
+ // placed it. Showing the empty message immediately is the right
+ // signal: the visitor came to look at their wishlist, and it's
+ // empty. `data-wp-context---notices` seeds the store-notices
+ // namespace alongside the block's own context on the same wrapper.
$wrapper_attributes = array(
'class' => 'wc-block-wishlist',
'data-wp-interactive' => 'woocommerce/wishlist',
'data-wp-context' => (string) wp_json_encode(
array(
- // `stdClass` so JSON serializes as `{}` rather than `[]`.
+ // `stdClass` so it serialises as `{}`, not `[]` —
+ // iAPI's reactive proxy only fires updates on object
+ // writes, not array expandos.
'pendingKeys' => new \stdClass(),
)
),
@@ -98,8 +135,10 @@ final class Wishlist extends AbstractBlock {
}
/**
- * Prefetch the wishlist items via `rest_do_request()`. Returns an empty
- * list for logged-out users, since the route requires authentication.
+ * Prefetch the wishlist items via `rest_do_request()`. Logged-out
+ * users short-circuit to an empty list — the route requires
+ * authentication and we don't want to fire an API call that's only
+ * going to 401.
*
* @return array<int, array<string, mixed>> Items in the schema response shape.
*/
@@ -114,7 +153,10 @@ final class Wishlist extends AbstractBlock {
if ( $response->is_error() ) {
$error = $response->as_error();
$message = $error instanceof \WP_Error ? $error->get_error_message() : 'Unknown error';
- // Logged for diagnostics.
+ // Logged at debug level on purpose: prefetch failures are
+ // often transient (network blips, auth refresh races) and
+ // the user-visible behaviour is the empty state — nothing
+ // for ops to act on.
wc_get_logger()->debug(
sprintf( 'Wishlist prefetch failed: %s', $message ),
array(
@@ -130,15 +172,20 @@ final class Wishlist extends AbstractBlock {
return array();
}
- // The schema casts `prices` and image entries to stdClass so JSON renders them as objects.
- // Round-trip through JSON to normalise everything to nested arrays for the SSR markup helpers.
+ // The schema casts `prices` and image entries to stdClass so the
+ // JSON response renders objects, not arrays. Round-trip through
+ // JSON encode/decode to normalise everything to nested arrays so
+ // the SSR markup helpers can treat fields uniformly.
$decoded = json_decode( (string) wp_json_encode( $data ), true );
return is_array( $decoded ) ? $decoded : array();
}
/**
- * Render the `<template data-wp-each>` used by iAPI to render rows on the client. Pre-rendered
- * `data-wp-each-child` elements sit alongside to populate first paint.
+ * The `<template data-wp-each>` describing how each item is rendered
+ * on the client. Pre-rendered children sit alongside as
+ * `data-wp-each-child` elements so first paint is populated. Composes
+ * the shared row markup with the Wishlist-specific "Add to cart"
+ * action button.
*
* @return string
*/
@@ -149,7 +196,8 @@ final class Wishlist extends AbstractBlock {
}
/**
- * Render the SSR markup for each item. Reconciled by iAPI via `data-wp-each-child` after hydration.
+ * Render the SSR markup for each item. JS will reconcile these via
+ * `data-wp-each-child` after hydration.
*
* @param array<int, array<string, mixed>> $items Schema-shape items.
* @return string
@@ -163,7 +211,8 @@ final class Wishlist extends AbstractBlock {
}
/**
- * Render a single SSR item, combining the shared row markup with the Add to cart button.
+ * Render a single SSR item. Composes the shared image / name / price
+ * markup with the Wishlist-specific "Add to cart" button.
*
* @param array<string, mixed> $item Schema-shape item.
* @return string
@@ -175,8 +224,9 @@ final class Wishlist extends AbstractBlock {
}
/**
- * Template-mode markup for the Add to cart action button. iAPI substitutes the per-row state through
- * `data-wp-bind--hidden` and `data-wp-bind--disabled`.
+ * Template-mode markup for the "Add to cart" action button. iAPI
+ * substitutes the per-row state through `data-wp-bind--hidden` and
+ * `data-wp-bind--disabled`.
*
* @return string
*/
@@ -198,8 +248,9 @@ final class Wishlist extends AbstractBlock {
}
/**
- * SSR-mode markup for the Add to cart action button. The wrapper is always emitted so iAPI can toggle
- * `hidden` after hydration. Starts hidden when the row is not purchasable.
+ * SSR-mode markup for the "Add to cart" action button. Always emits
+ * the wrapper so iAPI can toggle `hidden` after hydration without
+ * swapping the row out. Starts hidden when the row isn't purchasable.
*
* @param array<string, mixed> $item Schema-shape item.
* @return string
@@ -231,10 +282,13 @@ final class Wishlist extends AbstractBlock {
}
/**
- * Wrap the inner-block content in a div. The header is always shown when content is present, with no
- * `hasShownItems` guard. Returns an empty string when there is no content, to avoid an empty `<div>`.
+ * Wrap the inner-block content (heading + any future siblings) in a
+ * div. Unlike Saved for Later, no `hasShownItems` gating — the header
+ * is always shown when there's content for it. Returns an empty
+ * string when there's no content to wrap, so we don't emit an empty
+ * `<div>`.
*
- * @param string $content Rendered inner-block content (usually the heading HTML).
+ * @param string $content Rendered inner-block content (typically the heading HTML).
* @return string
*/
private function render_header_markup( string $content ): string {
@@ -245,8 +299,9 @@ final class Wishlist extends AbstractBlock {
}
/**
- * Render the empty-state markup. Visible on first paint when the list is empty. iAPI handles runtime
- * transitions via `state.isEmpty`.
+ * Render the empty-state markup. Visible on first paint when the
+ * list is empty (no `hasShownItems` gate), then iAPI takes over via
+ * `state.isEmpty` for runtime transitions.
*
* @param array<int, array<string, mixed>> $items Schema-shape items.
* @return string
@@ -260,8 +315,10 @@ final class Wishlist extends AbstractBlock {
}
/**
- * Sprintf template for the per-row remove button aria-label. Shared between PHP SSR and the JS-side
- * getter (seeded via `wp_interactivity_config`) so both paths produce identical output.
+ * Sprintf template for the per-row remove button's aria-label. Used
+ * both by PHP SSR and by the JS-side getter (via
+ * `wp_interactivity_config`) so both paths produce the same string
+ * after `%s` interpolation.
*/
private function get_remove_label_template(): string {
/* translators: %s: product name. */
@@ -269,7 +326,8 @@ final class Wishlist extends AbstractBlock {
}
/**
- * Visible label for the Add to cart action button. Used by the iAPI `<template>` and the SSR markup.
+ * Visible label for the add-to-cart action button, used by both the
+ * iAPI `<template>` and the SSR per-row markup.
*/
private function get_add_to_cart_label(): string {
return __( 'Add to cart', 'woocommerce' );
@@ -288,8 +346,12 @@ final class Wishlist extends AbstractBlock {
}
/**
- * Frontend style handle. Returns null so WP loads the `style` array from block.json, which lists this
- * block's stylesheet and the atomic product-image/price/button stylesheets whose classes it reuses.
+ * Get the frontend style handle for this block type.
+ *
+ * Returning null lets WP use the `style` array from block.json, which
+ * lists this block's own stylesheet plus the atomic
+ * product-image / product-price / product-button stylesheets we
+ * borrow class names from.
*
* @return null
*/
diff --git a/plugins/woocommerce/src/Internal/ShopperLists/ShopperList.php b/plugins/woocommerce/src/Internal/ShopperLists/ShopperList.php
index 13e9441535f..48fa653dfff 100644
--- a/plugins/woocommerce/src/Internal/ShopperLists/ShopperList.php
+++ b/plugins/woocommerce/src/Internal/ShopperLists/ShopperList.php
@@ -70,6 +70,8 @@ class ShopperList {
* @return self|false
*/
public static function get_by_slug( string $slug, ?int $user_id = null ) {
+ // Gate disabled or unknown slugs upfront so previously-persisted lists
+ // don't bypass the feature flag (the Store API surfaces this as 404).
if ( ! wc_get_container()->get( ShopperListsController::class )->is_enabled( $slug ) ) {
return false;
}
@@ -85,7 +87,7 @@ class ShopperList {
return self::from_array( $stored, $user_id );
}
- // In-memory list. Persisted on the first save().
+ // In-memory list; saved on the first save().
return new self(
$user_id,
$slug,
diff --git a/plugins/woocommerce/src/Internal/ShopperLists/ShopperListItem.php b/plugins/woocommerce/src/Internal/ShopperLists/ShopperListItem.php
index 85a784c374c..139a05d8d62 100644
--- a/plugins/woocommerce/src/Internal/ShopperLists/ShopperListItem.php
+++ b/plugins/woocommerce/src/Internal/ShopperLists/ShopperListItem.php
@@ -166,9 +166,9 @@ class ShopperListItem {
/**
* Resolve and validate the variation attribute array against the variation product.
*
- * Mirrors {@see CartController::parse_variation_data()}: specific values come from the variation
- * (server-authoritative). "Any" slots must be supplied by the caller with a value that exists on the
- * parent product.
+ * Mirrors {@see CartController::parse_variation_data()}: specific values come from
+ * the variation (server-authoritative); "any" slots must be supplied by the caller
+ * with a value that exists on the parent product.
*
* @throws \InvalidArgumentException When the supplied variation attributes are
* missing required values or don't match the
@@ -245,7 +245,7 @@ class ShopperListItem {
}
/**
- * Storage key. Also used as the response identifier.
+ * Storage key — also used as the response identifier.
*/
public function get_key(): string {
return $this->key;
@@ -307,8 +307,9 @@ class ShopperListItem {
}
/**
- * Whether the row serves live product data. True when the product (and its parent, for variations) is
- * `publish`. Password-protected products still qualify since their page renders behind a prompt.
+ * Whether the row serves live product data. True when the product (and its
+ * parent, for variations) is `publish`; password-gated products still
+ * qualify since their page renders behind a prompt.
*/
public function is_live(): bool {
$product = $this->get_product();
@@ -329,9 +330,10 @@ class ShopperListItem {
}
/**
- * Whether the product can be added to the cart. Requires `is_purchasable()`, `is_in_stock()`, and
- * a live row. Password-protected products (self or parent) are rejected because the cart-add flow
- * cannot prompt for a password.
+ * Whether the product can be added to the cart. Mirrors the catalog gate
+ * (`is_purchasable()` && `is_in_stock()`), but additionally requires the
+ * row to be live and rejects password-gated products (self or parent) —
+ * cart-add can't prompt for a password.
*/
public function is_purchasable(): bool {
$product = $this->get_product();
diff --git a/plugins/woocommerce/src/Internal/ShopperLists/ShopperListRenderer.php b/plugins/woocommerce/src/Internal/ShopperLists/ShopperListRenderer.php
index 383dde71db6..a41a6549afa 100644
--- a/plugins/woocommerce/src/Internal/ShopperLists/ShopperListRenderer.php
+++ b/plugins/woocommerce/src/Internal/ShopperLists/ShopperListRenderer.php
@@ -5,19 +5,32 @@ declare( strict_types = 1 );
namespace Automattic\WooCommerce\Internal\ShopperLists;
/**
- * Markup helpers for the shopper collection blocks.
+ * Shared markup helpers for blocks that render a shopper-list item card
+ * (Saved for Later, Wishlist, …). Static helpers, not an abstract base —
+ * the two blocks' lifecycles diverge enough (auto-injected vs merchant-
+ * placed, different actions, different empty-state gating) that inheritance
+ * is not a clean fit. Consumers stitch the fragments together with their
+ * own quantity / action button / heading bits.
+ *
+ * Any change here is co-reviewed with every consuming block — drift in the
+ * shared row shape will break first paint for whoever didn't get the memo.
*/
final class ShopperListRenderer {
/**
- * Shared CSS root class for the row.
+ * Shared CSS root class for the row. Each section helper outputs
+ * BEM-style modifiers off this base (`__image-slot`, `__remove`, …).
*/
public const ROW_CLASS = 'wc-block-shopper-list-item';
/**
- * Wrap `$inner` in the block's outer `<section><ul>…</ul></section>` grid scaffold. `$wrapper_attrs`
- * are merged with the block's wrapper attributes via `get_block_wrapper_attributes()`. Callers must
- * ensure `$inner` and `$before_list` contain only pre-escaped markup. Both are emitted verbatim.
+ * Wrap `$inner` in the block's outer `<section><ul>…</ul></section>`
+ * grid scaffold. `$wrapper_attrs` are merged with the block's wrapper
+ * attributes via `get_block_wrapper_attributes()`.
+ *
+ * Trust contract: callers are responsible for ensuring `$inner` and
+ * `$before_list` contain only safe, escaped HTML — typically composed
+ * from the section helpers below, never from raw schema/request input.
*
* @param array<string, mixed> $wrapper_attrs Attributes for the outer `<section>`.
* @param string $list_class Class attribute for the inner `<ul>`.
@@ -36,8 +49,12 @@ final class ShopperListRenderer {
}
/**
- * Wrap `$row_inner_markup` in a `<template data-wp-each>` element used by iAPI to render new rows.
- * Callers must ensure `$row_inner_markup` contains only pre-escaped markup. It is emitted verbatim.
+ * Wrap `$row_inner_markup` in a `<template data-wp-each>` element that
+ * iAPI uses to render new rows. `$row_inner_markup` is the inner HTML
+ * for the `<li>` — everything between `<li>` and `</li>`.
+ *
+ * Trust contract: caller is responsible for ensuring `$row_inner_markup`
+ * contains only safe, escaped HTML.
*
* @param string $row_inner_markup Inner markup for the `<li>`.
* @return string
@@ -51,9 +68,13 @@ final class ShopperListRenderer {
}
/**
- * Wrap `$row_inner_markup` in an SSR `<li data-wp-each-child>` element seeded with the per-row iAPI
- * context from `$item`. Hydration is a no-op diff against the `<template>` when the inner markup
- * matches. Callers must ensure `$row_inner_markup` contains only pre-escaped markup.
+ * Wrap `$row_inner_markup` in an SSR `<li data-wp-each-child>` element
+ * seeded with the per-row iAPI context derived from `$item`. iAPI's
+ * hydration treats this as a no-op diff against the `<template>` if
+ * the inner markup matches.
+ *
+ * Trust contract: caller is responsible for ensuring `$row_inner_markup`
+ * contains only safe, escaped HTML.
*
* @param array<string, mixed> $item Schema-shape item.
* @param string $row_inner_markup Inner markup for the `<li>`.
@@ -61,7 +82,7 @@ final class ShopperListRenderer {
*/
public static function render_each_child( array $item, string $row_inner_markup ): string {
$context = array( 'listItem' => $item );
- // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- wp_interactivity_data_wp_context() returns a safely-encoded attribute pair, and $row_inner_markup is composed of escaped fragments from the section helpers below.
+ // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- wp_interactivity_data_wp_context() returns a safely-encoded attribute pair; $row_inner_markup is composed of escaped fragments from the section helpers below.
return sprintf(
'<li class="%1$s" data-wp-each-child %2$s>%3$s</li>',
esc_attr( self::ROW_CLASS ),
@@ -71,7 +92,8 @@ final class ShopperListRenderer {
}
/**
- * Render the image + title + price triplet for the template-mode row (bindings only, no static attrs).
+ * Render the image + title + price triplet for the template-mode row
+ * (no static attrs; bindings only). Identical between consumer blocks.
*
* @return string
*/
@@ -102,8 +124,10 @@ final class ShopperListRenderer {
}
/**
- * Render the image + title + price triplet for the SSR-mode row, populated from `$item`. Binding
- * directives match the template-mode markup so iAPI hydration is a no-op diff after first paint.
+ * Render the image + title + price triplet for the SSR-mode row, with
+ * values populated from `$item` and `$remove_aria_label_template`. The
+ * binding directives match the template-mode markup so iAPI's hydration
+ * is a no-op diff after first paint.
*
* @param array<string, mixed> $item Schema-shape item.
* @param string $remove_aria_label_template Sprintf template for the remove button's aria-label. `%s` is replaced with the product name.
@@ -119,9 +143,10 @@ final class ShopperListRenderer {
$variation_label = self::get_variation_label( $item );
$remove_aria = sprintf( $remove_aria_label_template, $alt );
$is_price_hidden = '' === $price_html;
- // Tombstone rows (`is_live=false` or empty permalink) emit `<a>` without an href so the element
- // shape stays stable for iAPI reconciliation against the live-row template. The stylesheet drops
- // link affordances when the anchor has no href.
+ // Tombstone rows (`is_live=false` or empty permalink) render `<a>`
+ // without an href — keeps the element shape stable for iAPI
+ // reconciliation against the live-row template, and the CSS in the
+ // shared partial drops link affordances when the anchor has no href.
$href_attr = $is_live && '' !== $permalink ? 'href="' . esc_url( $permalink ) . '"' : '';
ob_start();
@@ -178,9 +203,11 @@ final class ShopperListRenderer {
}
/**
- * Empty-state `<li>` that the block reveals once `state.isEmpty` flips. With `$start_hidden = true`
- * the `<li>` carries `hidden` on first paint (Saved for Later, populated asynchronously). Pass false
- * when the empty state should be visible immediately on first paint (Wishlist).
+ * Empty-state `<li>` that the block toggles on once `state.isEmpty`
+ * flips. `$start_hidden = true` makes SSR ship with `hidden` so the
+ * message doesn't flash for shoppers whose list is being populated
+ * client-side. `$start_hidden = false` is for blocks (e.g. Wishlist)
+ * where the message should show on first paint when the list is empty.
*
* @param string $message Visible empty-state message.
* @param string $css_class Class attribute for the `<li>`.
@@ -197,8 +224,9 @@ final class ShopperListRenderer {
}
/**
- * Render the iAPI store-notices region used by row-level error banners. Mirrors
- * {@see AddToCartWithOptions::render_interactivity_notices_region()}. Keep in sync if the shape changes.
+ * Render the iAPI store-notices region used by the row-level error
+ * banners. Mirrors `AddToCartWithOptions::render_interactivity_notices_region()`
+ * — keep in sync if the shape changes.
*
* @param string $wrapper_class Class attribute for the outer `<div>`.
* @return string
@@ -239,9 +267,11 @@ final class ShopperListRenderer {
}
/**
- * Markup for the trash icon used in the remove-item button. Inlines the
- * `trash` icon from `@wordpress/icons` so SSR first paint matches the
- * post-hydration JS render. `currentColor` lets the wrapper drive the fill.
+ * Markup for the trash icon used in the remove-item button. Mirrors the
+ * `trash` icon from `@wordpress/icons` that the cart line item uses for
+ * `wc-block-cart-item__remove-link`, inlined here so SSR first paint
+ * matches what JS would render after hydration. `currentColor` lets the
+ * surrounding badge wrapper drive the fill.
*
* @return string
*/
@@ -250,8 +280,10 @@ final class ShopperListRenderer {
}
/**
- * Markup for the empty-star icon. Inlines the `starEmpty` icon from
- * `@wordpress/icons` so SSR first paint matches the post-hydration JS render.
+ * Markup for the empty-star icon. Mirrors `starEmpty` from
+ * `@wordpress/icons`, inlined here so SSR first paint matches what JS
+ * renders after hydration. `currentColor` lets the surrounding button
+ * drive the fill.
*
* @return string
*/
@@ -260,8 +292,10 @@ final class ShopperListRenderer {
}
/**
- * Markup for the filled-star icon. Inlines the `starFilled` icon from
- * `@wordpress/icons` so SSR first paint matches the post-hydration JS render.
+ * Markup for the filled-star icon. Mirrors `starFilled` from
+ * `@wordpress/icons`, inlined here so SSR first paint matches what JS
+ * renders after hydration. `currentColor` lets the surrounding button
+ * drive the fill.
*
* @return string
*/
diff --git a/plugins/woocommerce/src/Internal/ShopperLists/ShopperListsController.php b/plugins/woocommerce/src/Internal/ShopperLists/ShopperListsController.php
index 7dc226b8853..18e403a55ee 100644
--- a/plugins/woocommerce/src/Internal/ShopperLists/ShopperListsController.php
+++ b/plugins/woocommerce/src/Internal/ShopperLists/ShopperListsController.php
@@ -8,10 +8,10 @@ use Automattic\WooCommerce\Internal\RegisterHooksInterface;
use Automattic\WooCommerce\Utilities\FeaturesUtil;
/**
- * Tracks which shopper-list types are enabled and registers the user-facing
- * endpoints, menu items, and rewrite rules that depend on each.
+ * Tracks which shopper-list types are turned on and registers the
+ * user-facing pieces that depend on each.
*
- * @internal
+ * @internal Just for internal use.
*/
final class ShopperListsController implements RegisterHooksInterface {
@@ -24,17 +24,19 @@ final class ShopperListsController implements RegisterHooksInterface {
);
/**
- * Wishlist My Account endpoint slug.
+ * Wishlist My Account endpoint slug. Wrapped in a method (rather than
+ * a constant) so a future filter or settings hook can override it
+ * without touching every call site.
*/
public function get_wishlist_endpoint(): string {
return 'wishlist';
}
/**
- * Whether a given list type is enabled, or whether any list type is enabled
+ * Whether a given list type is on, or whether any list type is on
* when no slug is passed.
*
- * @param string|null $list_slug List slug, or null to check any list type.
+ * @param string|null $list_slug List slug, or null to ask about any type.
*/
public function is_enabled( ?string $list_slug = null ): bool {
if ( null === $list_slug ) {
@@ -147,10 +149,12 @@ final class ShopperListsController implements RegisterHooksInterface {
}
/**
- * Render the `woocommerce/wishlist` block.
+ * Render the wishlist endpoint by dispatching to the
+ * `woocommerce/wishlist` block. The block handles the empty state,
+ * logged-out guard, asset enqueues, and item rendering.
*/
public function render_wishlist_endpoint(): void {
- // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- the block string is a static literal, and `do_blocks()` invokes the registered block's render callback, which is responsible for its own escaping.
+ // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- the block string is a static literal; `do_blocks()` invokes the registered block's render callback, which is responsible for its own escaping.
echo do_blocks( '<!-- wp:woocommerce/wishlist /-->' );
}
}
diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/ShopperListItems.php b/plugins/woocommerce/src/StoreApi/Routes/V1/ShopperListItems.php
index 85c821f465a..ff4985e906e 100644
--- a/plugins/woocommerce/src/StoreApi/Routes/V1/ShopperListItems.php
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/ShopperListItems.php
@@ -14,7 +14,7 @@ use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
* POST saves an item to the list either from an existing cart line or from direct item payload fields.
*/
class ShopperListItems extends AbstractRoute {
- // Temporary CSRF guard. Will be removed once a Store API-wide trait lands on trunk.
+ // Stopgap CSRF guard, replaced once the upstream trait lands on trunk.
use ShopperListsNonceCheck;
/**
diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/ShopperListItemsByKey.php b/plugins/woocommerce/src/StoreApi/Routes/V1/ShopperListItemsByKey.php
index 050445b3557..ab44a9ff839 100644
--- a/plugins/woocommerce/src/StoreApi/Routes/V1/ShopperListItemsByKey.php
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/ShopperListItemsByKey.php
@@ -10,7 +10,7 @@ use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
* DELETE /shopper-lists/{slug}/items/{key}.
*/
class ShopperListItemsByKey extends AbstractRoute {
- // Temporary CSRF guard. Will be removed once a Store API-wide trait lands on trunk.
+ // Stopgap CSRF guard, replaced once the upstream trait lands on trunk.
use ShopperListsNonceCheck;
/**
diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/ShopperLists.php b/plugins/woocommerce/src/StoreApi/Routes/V1/ShopperLists.php
index d7aacdb84d9..18302622d76 100644
--- a/plugins/woocommerce/src/StoreApi/Routes/V1/ShopperLists.php
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/ShopperLists.php
@@ -6,7 +6,7 @@ namespace Automattic\WooCommerce\StoreApi\Routes\V1;
use Automattic\WooCommerce\Internal\ShopperLists\ShopperList;
/**
- * GET /shopper-lists: collection of the current user's shopper lists.
+ * GET /shopper-lists — collection of the current user's shopper lists.
*/
class ShopperLists extends AbstractRoute {
/**
diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/ShopperListsBySlug.php b/plugins/woocommerce/src/StoreApi/Routes/V1/ShopperListsBySlug.php
index e2500409548..6769834e54a 100644
--- a/plugins/woocommerce/src/StoreApi/Routes/V1/ShopperListsBySlug.php
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/ShopperListsBySlug.php
@@ -7,7 +7,7 @@ use Automattic\WooCommerce\Internal\ShopperLists\ShopperList;
use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
/**
- * GET /shopper-lists/{slug}: metadata for a single list.
+ * GET /shopper-lists/{slug} — metadata for a single list.
*/
class ShopperListsBySlug extends AbstractRoute {
/**
diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/ShopperListsNonceCheck.php b/plugins/woocommerce/src/StoreApi/Routes/V1/ShopperListsNonceCheck.php
index 772f1e7667e..d542f30dcf7 100644
--- a/plugins/woocommerce/src/StoreApi/Routes/V1/ShopperListsNonceCheck.php
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/ShopperListsNonceCheck.php
@@ -4,9 +4,14 @@ declare( strict_types = 1 );
namespace Automattic\WooCommerce\StoreApi\Routes\V1;
/**
- * CSRF guard for the write-capable shopper-lists routes. Enforces a `wc_store_api` Nonce header on writes
- * and refreshes the client nonce via response headers on every reply. Temporary, until a reusable
- * Store API-wide nonce trait lands on trunk.
+ * Stopgap CSRF guard for the write-capable shopper-lists routes.
+ *
+ * Enforces a `wc_store_api` Nonce header on writes and refreshes the
+ * client nonce via response headers on every reply. Same shape as the
+ * cart's existing flow, scoped to the nonce concern.
+ *
+ * To be replaced by a reusable Store API-wide nonce trait once that
+ * lands on trunk.
*
* @internal
*/
@@ -19,8 +24,8 @@ trait ShopperListsNonceCheck {
private static $store_api_nonce_action = 'wc_store_api';
/**
- * Enforce the `wc_store_api` Nonce header on writes and refresh it on every reply.
- * Overrides {@see AbstractRoute::get_response}.
+ * Override of {@see AbstractRoute::get_response} that enforces the
+ * `wc_store_api` Nonce header on writes and refreshes it on every reply.
*
* @param \WP_REST_Request $request Request object.
* @phpstan-param \WP_REST_Request<array<string, mixed>> $request
diff --git a/plugins/woocommerce/src/StoreApi/RoutesController.php b/plugins/woocommerce/src/StoreApi/RoutesController.php
index 7dd316b3e8c..6de8e81baed 100644
--- a/plugins/woocommerce/src/StoreApi/RoutesController.php
+++ b/plugins/woocommerce/src/StoreApi/RoutesController.php
@@ -77,7 +77,7 @@ class RoutesController {
Routes\V1\Patterns::IDENTIFIER => Routes\V1\Patterns::class,
],
'shopper_lists' => [
- // Registered only when at least one shopper-list feature flag is enabled (see ShopperListsController).
+ // Gated by ShopperListsController — registered only when at least one shopper-list feature is enabled.
Routes\V1\ShopperLists::IDENTIFIER => Routes\V1\ShopperLists::class,
Routes\V1\ShopperListsBySlug::IDENTIFIER => Routes\V1\ShopperListsBySlug::class,
Routes\V1\ShopperListItems::IDENTIFIER => Routes\V1\ShopperListItems::class,
diff --git a/plugins/woocommerce/src/StoreApi/Schemas/V1/ShopperListItemSchema.php b/plugins/woocommerce/src/StoreApi/Schemas/V1/ShopperListItemSchema.php
index 45aac76e4fa..9fdc2a61ba7 100644
--- a/plugins/woocommerce/src/StoreApi/Schemas/V1/ShopperListItemSchema.php
+++ b/plugins/woocommerce/src/StoreApi/Schemas/V1/ShopperListItemSchema.php
@@ -15,7 +15,7 @@ use Automattic\WooCommerce\StoreApi\Utilities\ProductItemTrait;
* item reports `is_live`, and falls back to at-save snapshot data otherwise.
*/
class ShopperListItemSchema extends AbstractSchema {
- // Only `format_variation_data()` is used from this trait. See phpstan.neon for related suppressions.
+ // We only call format_variation_data(); see phpstan.neon for the related suppressions.
use ProductItemTrait;
/**
@@ -264,8 +264,10 @@ class ShopperListItemSchema extends AbstractSchema {
}
/**
- * Get the main image for a shopper list item. Only the product's main image is returned. The
- * gallery is not exposed at the row level.
+ * Get the main image for a shopper list item.
+ *
+ * Returns the product's main image only — shopper list rows are compact and
+ * the gallery isn't needed at the row level.
*
* @param \WC_Product $product Live product instance.
* @return array
@@ -281,10 +283,13 @@ class ShopperListItemSchema extends AbstractSchema {
}
/**
- * Get the thumbnail image HTML for a shopper list item, with a WooCommerce
- * placeholder fallback when the product has no image or has been deleted.
- * Pre-formatted on the server so SSR and JS hydration consume one canonical
- * string. Follows the pattern used by `ProductSchema::price_html`.
+ * Get the thumbnail image HTML for a shopper list item, falling back to the
+ * WooCommerce placeholder when the product has no image or has been deleted.
+ *
+ * Pre-formatting on the server lets renderers (PHP SSR + JS hydration)
+ * consume one canonical string instead of each side composing the markup
+ * from the structured `images` array. Mirrors the pattern WC uses in
+ * `ProductSchema::price_html` / `ProductImage::render`.
*
* @param \WC_Product|null $product Live product instance, or null for tombstones.
* @return string
@@ -298,7 +303,10 @@ class ShopperListItemSchema extends AbstractSchema {
}
/**
- * Compute live prices for the saved item. Returns a thin subset of cart-item prices.
+ * Compute live prices for the saved item.
+ *
+ * We don't extend ProductSchema because saved items aren't products. The shape
+ * here is a thin subset of cart-item prices.
*
* @param \WC_Product $product Live product instance.
* @return array