Commit f62ac51443c for woocommerce
commit f62ac51443ca58aefc47dc228571dda2e3d71b0b
Author: Lucio Giannotta <lucio.giannotta@a8c.com>
Date: Tue Oct 6 15:33:11 2026 +0200
Fix cross-sells and upsells in the Mini Cart (#62721)
diff --git a/plugins/woocommerce/changelog/61081-fix-mini-cart-product-collection-cross-sells b/plugins/woocommerce/changelog/61081-fix-mini-cart-product-collection-cross-sells
new file mode 100644
index 00000000000..48c3e86e8a2
--- /dev/null
+++ b/plugins/woocommerce/changelog/61081-fix-mini-cart-product-collection-cross-sells
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Restore cross-sells and upsells in the Mini Cart and keep them up to date as shoppers add or remove products.
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/mini-cart/frontend.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/mini-cart/frontend.ts
index 23b66ec1453..490e0aa2146 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/mini-cart/frontend.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/mini-cart/frontend.ts
@@ -100,7 +100,7 @@ function getClosestColor(
return getClosestColor( element.parentElement, colorType );
}
-type MiniCart = {
+export type MiniCart = {
state: {
isHydrated: boolean;
isOpen: boolean;
@@ -416,11 +416,7 @@ function resolveDataItemAttr(): ItemData | undefined {
dataProperty: DataProperty;
} >();
- return (
- itemData ||
- // eslint-disable-next-line @typescript-eslint/no-use-before-define
- cartItemState.cartItem[ dataProperty ]?.[ 0 ]
- );
+ return itemData || cartItemState.cartItem[ dataProperty ]?.[ 0 ];
}
/**
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/block.json b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/block.json
index 936003fd49a..248ed5cf92b 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/block.json
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/block.json
@@ -75,7 +75,11 @@
"forcePageReload": "forcePageReload",
"__privateProductCollectionPreviewState": "__privatePreviewState"
},
- "usesContext": [ "templateSlug", "postId" ],
+ "usesContext": [
+ "templateSlug",
+ "postId",
+ "woocommerce/productCollection/referenceType"
+ ],
"supports": {
"align": [ "wide", "full" ],
"anchor": true,
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/cart-reference-frontend.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/cart-reference-frontend.ts
new file mode 100644
index 00000000000..8202fb58741
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/cart-reference-frontend.ts
@@ -0,0 +1,208 @@
+/**
+ * Store part for cart-referencing Product Collections (e.g., cross-sells).
+ *
+ * This module extends the main product-collection store with the setup
+ * that keeps cart-referencing collections fresh: it watches the cart, and
+ * when the contents change it prefetches the current page with a
+ * cache-busting query value, then swaps the collection in (via the router)
+ * while the Mini-Cart drawer is open.
+ *
+ * The whole thing is here and scoped to only the consumer module, so we can
+ * enqueue this only when a cart-referencing Product Collection is rendered,
+ * and the Mini-Cart doesn't need to know any of this.
+ */
+
+/**
+ * External dependencies
+ */
+import { store, getConfig } from '@wordpress/interactivity';
+import '@woocommerce/stores/woocommerce/cart';
+import type { Store as WooCommerce } from '@woocommerce/stores/woocommerce/cart';
+
+/**
+ * Internal dependencies
+ */
+import type { MiniCart } from '../mini-cart/frontend';
+
+const universalLock =
+ 'I acknowledge that using a private store means my plugin will inevitably break on the next store release.';
+
+const { state: woocommerceState, actions: woocommerceActions } =
+ store< WooCommerce >( 'woocommerce', {}, { lock: universalLock } );
+
+const { state: miniCartState } = store< MiniCart >(
+ 'woocommerce/mini-cart',
+ {},
+ { lock: universalLock }
+);
+
+/**
+ * Product IDs from the last cart state a prefetch ran for. Recommendations
+ * depend on which products are present, not their quantities or line order.
+ * Sorting and deduplicating IDs avoids refreshes when only those change.
+ */
+let lastCartSignature: string | undefined;
+
+/**
+ * Cache-busted URL of the freshest prefetched page HTML, waiting to be
+ * navigated to while the drawer is open.
+ */
+let pendingRefreshUrl: string | undefined;
+let pendingPageUrl: string | undefined;
+
+/**
+ * URL of the last completed refresh navigation. Deduplicating on the URL
+ * avoids repeat navigations when the drawer is reopened with an unchanged
+ * cart, and covers either ordering of "drawer opened" vs. "prefetch settled".
+ */
+let lastNavigatedUrl: string | undefined;
+
+const getCartSignature = (
+ items: WooCommerce[ 'state' ][ 'cart' ][ 'items' ] | undefined
+): string =>
+ [ ...new Set( ( items ?? [] ).map( ( item ) => item.id ) ) ]
+ .sort()
+ .join( '|' );
+
+/**
+ * Store part with cart-referencing callbacks.
+ */
+const cartReferenceStorePart = {
+ callbacks: {
+ /**
+ * Keeps cart-referencing Product Collections (e.g., cross-sells)
+ * fresh. Collections are SSR'd at page load, so they don't update by
+ * themselves when cart items change.
+ *
+ * This watch reads both the cart items and the drawer state, so it
+ * re-runs on either kind of change: a cart change prefetches fresh
+ * HTML under a unique URL, and an open drawer (whether it opened
+ * before or after the prefetch settled) swaps it in.
+ */
+ *refreshCartReference() {
+ // When client-side navigation is disabled (an unsupported inner
+ // block was detected), navigate() falls back to a full-page load.
+ // Decline to refresh instead: stale cross-sells until the next
+ // real page load beat a surprise reload of the whole page.
+ if ( getConfig( 'core/router' )?.clientNavigationDisabled ) {
+ return;
+ }
+
+ const signature = getCartSignature( woocommerceState.cart?.items );
+
+ if ( lastCartSignature === undefined ) {
+ // First run only records the SSR baseline.
+ lastCartSignature = signature;
+ } else if ( signature !== lastCartSignature ) {
+ // Wait until in-flight cart mutations settle: prefetching
+ // while a Store API request is being processed would cache
+ // HTML rendered from a not-yet-updated cart.
+ yield woocommerceActions.waitForIdle();
+
+ // Re-check against the settled state and record it before
+ // the prefetch, so the extra watch runs a single mutation
+ // causes dedupe to one prefetch per settled cart state.
+ const settledSignature = getCartSignature(
+ woocommerceState.cart?.items
+ );
+ if ( settledSignature !== lastCartSignature ) {
+ const previousSignature = lastCartSignature;
+ lastCartSignature = settledSignature;
+
+ // A unique query value avoids reusing cached HTML for an
+ // earlier cart state. No `force`: a concurrent navigation
+ // to the same URL can share the prefetch request.
+ const pageUrl =
+ window.location.href === pendingRefreshUrl
+ ? pendingPageUrl ?? window.location.href
+ : window.location.href;
+ const url = new URL( pageUrl );
+ // Fetching markup must not repeat a classic add-to-cart action.
+ url.searchParams.delete( 'add-to-cart' );
+ url.searchParams.set(
+ 'wc-cache-bust',
+ Math.random().toString( 36 ).slice( 2 )
+ );
+ pendingRefreshUrl = url.href;
+ pendingPageUrl = pageUrl;
+
+ try {
+ const { actions: routerActions } = yield import(
+ '@wordpress/interactivity-router'
+ );
+ yield routerActions.prefetch( url.href );
+ } catch {
+ if ( pendingRefreshUrl === url.href ) {
+ lastCartSignature = previousSignature;
+ pendingRefreshUrl = undefined;
+ }
+ return;
+ }
+ }
+ }
+
+ const url = pendingRefreshUrl;
+
+ if ( ! miniCartState.isOpen || ! url || url === lastNavigatedUrl ) {
+ return;
+ }
+ // Claim the URL before the first await so a re-run of this
+ // callback during the navigation cannot start a second one.
+ lastNavigatedUrl = url;
+
+ // The URL carries a cache-busting query value; remember the
+ // canonical address to restore after the router swap.
+ const restoreUrl = pendingPageUrl;
+
+ try {
+ const {
+ actions: routerActions,
+ }: typeof import('@wordpress/interactivity-router') =
+ yield import( '@wordpress/interactivity-router' );
+ if (
+ window.location.href !== restoreUrl ||
+ pendingRefreshUrl !== url
+ ) {
+ if ( lastNavigatedUrl === url ) {
+ lastNavigatedUrl = undefined;
+ }
+ return;
+ }
+ // This is a background region refresh, not a user-initiated
+ // page navigation: suppress the loading animation and the
+ // screen-reader page-load announcement.
+ yield routerActions.navigate( url, {
+ replace: true,
+ loadingAnimation: false,
+ screenReaderAnnouncement: false,
+ } );
+ } catch {
+ // Release the claim so reopening the drawer can retry.
+ if ( lastNavigatedUrl === url ) {
+ lastNavigatedUrl = undefined;
+ }
+ return;
+ }
+
+ // A cancelled router navigation resolves normally. Only restore
+ // the address if this refresh actually reached its destination.
+ if ( window.location.href !== url ) {
+ if ( lastNavigatedUrl === url ) {
+ lastNavigatedUrl = undefined;
+ }
+ return;
+ }
+
+ // Restore the canonical URL, preserving whatever history state
+ // the router recorded for this entry.
+ window.history.replaceState( window.history.state, '', restoreUrl );
+ },
+ },
+};
+
+/**
+ * Extend the product-collection store with cart-referencing callbacks.
+ */
+store( 'woocommerce/product-collection', cartReferenceStorePart, {
+ lock: universalLock,
+} );
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/edit/inspector-controls/linked-product-control.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/edit/inspector-controls/linked-product-control.tsx
index bfb9800144f..87eda1f8f8a 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/edit/inspector-controls/linked-product-control.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/edit/inspector-controls/linked-product-control.tsx
@@ -1,11 +1,12 @@
/**
* External dependencies
*/
+import type { FC, Dispatch, SetStateAction } from 'react';
import { __ } from '@wordpress/i18n';
import ProductControl from '@woocommerce/editor-components/product-control';
import { SelectedOption } from '@woocommerce/block-hocs';
import { WC_BLOCKS_IMAGE_URL } from '@woocommerce/block-settings';
-import { useState, useRef } from '@wordpress/element';
+import { useState, useRef, useEffect } from '@wordpress/element';
import type { WooCommerceBlockLocation } from '@woocommerce/blocks/product-template/utils';
import { type ProductResponseItem, isEmpty } from '@woocommerce/types';
import { decodeEntities } from '@wordpress/html-entities';
@@ -35,7 +36,7 @@ const REFERENCE_TYPE_PRODUCT = 'product';
const REFERENCE_TYPE_CART = 'cart';
const REFERENCE_TYPE_ORDER = 'order';
-const ProductButton: React.FC< {
+const ProductButton: FC< {
isOpen: boolean;
onToggle: () => void;
product: ProductResponseItem | null;
@@ -98,10 +99,10 @@ const ProductButton: React.FC< {
);
};
-const LinkedProductPopoverContent: React.FC< {
+const LinkedProductPopoverContent: FC< {
query: ProductCollectionQuery;
setAttributes: ProductCollectionSetAttributes;
- setIsDropdownOpen: React.Dispatch< React.SetStateAction< boolean > >;
+ setIsDropdownOpen: Dispatch< SetStateAction< boolean > >;
} > = ( { query, setAttributes, setIsDropdownOpen } ) => (
<ProductControl
selected={ query?.productReference as SelectedOption }
@@ -128,6 +129,12 @@ const enum PRODUCT_REFERENCE_TYPE {
SPECIFIC_PRODUCT = 'SPECIFIC_PRODUCT',
}
+const isProductReferenceType = (
+ value: string
+): value is PRODUCT_REFERENCE_TYPE =>
+ value === PRODUCT_REFERENCE_TYPE.CURRENT_PRODUCT ||
+ value === PRODUCT_REFERENCE_TYPE.SPECIFIC_PRODUCT;
+
const getFromCurrentProductRadioLabel = (
currentLocation: string,
hasCartReference: boolean,
@@ -160,6 +167,8 @@ const LinkedProductControl = ( {
REFERENCE_TYPE_PRODUCT
);
const isCartLocation = location.type === REFERENCE_TYPE_CART;
+ const referenceType: ProductCollectionQuery[ 'productReferenceType' ] =
+ isCartLocation ? REFERENCE_TYPE_CART : null;
const hasCartReference = !! usesReference?.includes( REFERENCE_TYPE_CART );
const isOrderLocation = location.type === REFERENCE_TYPE_ORDER;
@@ -187,13 +196,39 @@ const LinkedProductControl = ( {
? radioControlState === PRODUCT_REFERENCE_TYPE.SPECIFIC_PRODUCT
: ! isEmpty( productReference );
+ // Sync initial radio state to attributes on mount.
+ // The UI shows "From current product/cart" selected based on location context.
+ // Intentionally runs only on mount: it is needed for compatibility
+ // with blocks saved before `productReferenceType` only.
+ useEffect( () => {
+ if ( ! showRadioControl ) {
+ return;
+ }
+ if ( query.productReferenceType !== undefined ) {
+ return;
+ }
+ if ( ! isEmpty( productReference ) ) {
+ return;
+ }
+
+ setAttributes( {
+ query: {
+ ...query,
+ productReferenceType: referenceType,
+ },
+ } );
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [] );
+
const showLinkedProductControl =
( showRadioControl || showSpecificProductSelector ) &&
/**
* Linked control is only useful for collection which uses product, cart or order reference.
*/
( hasProductReference || hasCartReference || hasOrderReference );
- if ( ! showLinkedProductControl ) return null;
+ if ( ! showLinkedProductControl ) {
+ return null;
+ }
const radioControlHelp =
radioControlState === PRODUCT_REFERENCE_TYPE.CURRENT_PRODUCT
@@ -206,19 +241,29 @@ const LinkedProductControl = ( {
'woocommerce'
);
- const handleRadioControlChange = ( newValue: PRODUCT_REFERENCE_TYPE ) => {
+ const handleRadioControlChange = ( newValue: string ) => {
+ if ( ! isProductReferenceType( newValue ) ) {
+ return;
+ }
if ( newValue === PRODUCT_REFERENCE_TYPE.CURRENT_PRODUCT ) {
const { productReference: toSave, ...rest } = query;
prevReference.current = toSave;
- setAttributes( { query: rest } );
+
+ setAttributes( {
+ query: {
+ ...rest,
+ productReferenceType: referenceType,
+ },
+ } );
} else {
+ const { productReferenceType, ...restQuery } = query;
setAttributes( {
query: prevReference.current
? {
- ...query,
+ ...restQuery,
productReference: prevReference.current,
}
- : query,
+ : restQuery,
} );
}
setRadioControlState( newValue );
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/frontend.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/frontend.ts
index 17980ab4e66..0f4e9404e43 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/frontend.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/frontend.ts
@@ -282,5 +282,5 @@ const productCollectionStore = {
};
store( 'woocommerce/product-collection', productCollectionStore, {
- lock: true,
+ lock: universalLock,
} );
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/test/cart-reference-frontend.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/test/cart-reference-frontend.ts
new file mode 100644
index 00000000000..9f815a0c8ea
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/test/cart-reference-frontend.ts
@@ -0,0 +1,244 @@
+const mockCart = { items: [] as { id: number; quantity: number }[] };
+const mockMiniCart = { isOpen: false };
+const mockWaitForIdle = jest.fn();
+const mockPrefetch = jest.fn();
+const mockNavigate = jest.fn();
+const mockGetConfig = jest.fn();
+let mockRefresh: () => Generator;
+
+jest.mock( '@woocommerce/stores/woocommerce/cart', () => ( {} ) );
+jest.mock( '@wordpress/interactivity', () => ( {
+ getConfig: mockGetConfig,
+ store: (
+ namespace: string,
+ descriptor: { callbacks: { refreshCartReference: () => Generator } }
+ ) => {
+ if ( namespace === 'woocommerce' ) {
+ return {
+ state: { cart: mockCart },
+ actions: { waitForIdle: mockWaitForIdle },
+ };
+ }
+ if ( namespace === 'woocommerce/mini-cart' ) {
+ return { state: mockMiniCart };
+ }
+ mockRefresh = descriptor.callbacks.refreshCartReference;
+ return descriptor;
+ },
+} ) );
+jest.mock( '@wordpress/interactivity-router', () => ( {
+ actions: { prefetch: mockPrefetch, navigate: mockNavigate },
+} ) );
+
+const refresh = async () => {
+ const generator = mockRefresh();
+ let step = generator.next();
+ while ( ! step.done ) {
+ try {
+ step = generator.next( await step.value );
+ } catch ( error ) {
+ step = generator.throw( error );
+ }
+ }
+};
+
+const deferred = () => {
+ let resolve!: () => void;
+ const promise = new Promise< void >( ( done ) => {
+ resolve = done;
+ } );
+ return { promise, resolve };
+};
+
+describe( 'cart reference refresh', () => {
+ beforeEach( () => {
+ jest.resetModules();
+ jest.resetAllMocks();
+ mockCart.items = [];
+ mockMiniCart.isOpen = false;
+ window.history.replaceState( {}, '', '/shop/?orderby=price#products' );
+ mockNavigate.mockImplementation( async ( url: string ) => {
+ window.history.replaceState( { router: true }, '', url );
+ } );
+ jest.requireActual( '../cart-reference-frontend' );
+ } );
+
+ it( 'records the initial cart without fetching and refreshes once after settled changes', async () => {
+ await refresh();
+ expect( mockPrefetch ).not.toHaveBeenCalled();
+ mockCart.items = [ { id: 1, quantity: 1 } ];
+ const idle = deferred();
+ mockWaitForIdle.mockReturnValue( idle.promise );
+ const first = refresh();
+ const second = refresh();
+ expect( mockPrefetch ).not.toHaveBeenCalled();
+ idle.resolve();
+ await Promise.all( [ first, second ] );
+ expect( mockPrefetch ).toHaveBeenCalledTimes( 1 );
+ expect( mockNavigate ).not.toHaveBeenCalled();
+ mockMiniCart.isOpen = true;
+ await refresh();
+ expect( mockNavigate ).toHaveBeenCalledTimes( 1 );
+ expect(
+ window.location.pathname +
+ window.location.search +
+ window.location.hash
+ ).toBe( '/shop/?orderby=price#products' );
+ expect( window.history.state ).toEqual( { router: true } );
+ await refresh();
+ expect( mockNavigate ).toHaveBeenCalledTimes( 1 );
+ } );
+
+ it( 'does not replay a classic add-to-cart action when fetching markup', async () => {
+ window.history.replaceState(
+ {},
+ '',
+ '/shop/?add-to-cart=1&orderby=price'
+ );
+ await refresh();
+ mockCart.items = [ { id: 1, quantity: 1 } ];
+ mockMiniCart.isOpen = true;
+ await refresh();
+ const requestUrl = new URL( mockPrefetch.mock.calls[ 0 ][ 0 ] );
+ expect( requestUrl.searchParams.has( 'add-to-cart' ) ).toBe( false );
+ expect( requestUrl.searchParams.get( 'orderby' ) ).toBe( 'price' );
+ expect( mockNavigate.mock.calls[ 0 ][ 0 ] ).toBe( requestUrl.href );
+ expect( window.location.search ).toBe( '?add-to-cart=1&orderby=price' );
+ } );
+
+ it( 'does not refresh for a reordered cart', async () => {
+ mockCart.items = [
+ { id: 1, quantity: 1 },
+ { id: 2, quantity: 1 },
+ ];
+ await refresh();
+ mockCart.items.reverse();
+ await refresh();
+ expect( mockPrefetch ).not.toHaveBeenCalled();
+ } );
+
+ it( 'does not refresh when quantities change or the same product gains another cart line', async () => {
+ mockCart.items = [ { id: 1, quantity: 1 } ];
+ mockMiniCart.isOpen = true;
+ await refresh();
+ mockCart.items = [ { id: 1, quantity: 2 } ];
+ await refresh();
+ mockCart.items = [
+ { id: 1, quantity: 1 },
+ { id: 1, quantity: 1 },
+ ];
+ await refresh();
+ expect( mockPrefetch ).not.toHaveBeenCalled();
+ expect( mockNavigate ).not.toHaveBeenCalled();
+ } );
+
+ it( 'refreshes when a product is removed, including the last product', async () => {
+ mockCart.items = [
+ { id: 1, quantity: 1 },
+ { id: 2, quantity: 1 },
+ ];
+ mockMiniCart.isOpen = true;
+ await refresh();
+ mockCart.items = [ { id: 2, quantity: 1 } ];
+ await refresh();
+ expect( mockPrefetch ).toHaveBeenCalledTimes( 1 );
+ expect( mockNavigate ).toHaveBeenCalledTimes( 1 );
+ mockCart.items = [];
+ await refresh();
+ expect( mockPrefetch ).toHaveBeenCalledTimes( 2 );
+ expect( mockNavigate ).toHaveBeenCalledTimes( 2 );
+ } );
+
+ it( 'does not overwrite a newer page navigation', async () => {
+ await refresh();
+ mockMiniCart.isOpen = true;
+ mockCart.items = [ { id: 1, quantity: 1 } ];
+ mockNavigate.mockImplementation( async () => {
+ window.history.replaceState(
+ { page: 'other' },
+ '',
+ '/other-page/'
+ );
+ } );
+ await refresh();
+ expect( window.location.pathname ).toBe( '/other-page/' );
+ expect( window.history.state ).toEqual( { page: 'other' } );
+ } );
+
+ it( 'does not navigate to a prefetched page after the shopper leaves it', async () => {
+ await refresh();
+ mockCart.items = [ { id: 1, quantity: 1 } ];
+ await refresh();
+ window.history.replaceState( {}, '', '/other-page/' );
+ mockMiniCart.isOpen = true;
+ await refresh();
+ expect( mockNavigate ).not.toHaveBeenCalled();
+ } );
+
+ it( 'retries a rejected refresh when the drawer reopens', async () => {
+ await refresh();
+ mockMiniCart.isOpen = true;
+ mockCart.items = [ { id: 1, quantity: 1 } ];
+ mockNavigate.mockRejectedValueOnce( new Error( 'Network error' ) );
+ await refresh();
+ await refresh();
+ expect( mockNavigate ).toHaveBeenCalledTimes( 2 );
+ } );
+
+ it( 'retries a rejected prefetch', async () => {
+ await refresh();
+ mockCart.items = [ { id: 1, quantity: 1 } ];
+ mockPrefetch.mockRejectedValueOnce( new Error( 'Network error' ) );
+ await refresh();
+ await refresh();
+ expect( mockPrefetch ).toHaveBeenCalledTimes( 2 );
+ } );
+
+ it( 'retries a navigation that the router cancelled without rejecting', async () => {
+ await refresh();
+ mockMiniCart.isOpen = true;
+ mockCart.items = [ { id: 1, quantity: 1 } ];
+ mockNavigate.mockResolvedValueOnce( undefined );
+ await refresh();
+ await refresh();
+ expect( mockNavigate ).toHaveBeenCalledTimes( 2 );
+ } );
+
+ it( 'keeps the newest refresh when an older navigation finishes later', async () => {
+ await refresh();
+ mockMiniCart.isOpen = true;
+ mockCart.items = [ { id: 1, quantity: 1 } ];
+ const started = deferred();
+ const cancelled = deferred();
+ mockNavigate.mockImplementationOnce( () => {
+ started.resolve();
+ return cancelled.promise;
+ } );
+ const older = refresh();
+ await started.promise;
+ mockCart.items = [
+ { id: 1, quantity: 1 },
+ { id: 2, quantity: 1 },
+ ];
+ await refresh();
+ cancelled.resolve();
+ await older;
+ await refresh();
+ expect( mockNavigate ).toHaveBeenCalledTimes( 2 );
+ expect(
+ window.location.pathname +
+ window.location.search +
+ window.location.hash
+ ).toBe( '/shop/?orderby=price#products' );
+ } );
+
+ it( 'does not trigger a full-page reload when client navigation is disabled', async () => {
+ mockGetConfig.mockReturnValue( { clientNavigationDisabled: true } );
+ await refresh();
+ mockCart.items = [ { id: 1, quantity: 1 } ];
+ mockMiniCart.isOpen = true;
+ await refresh();
+ expect( mockPrefetch ).not.toHaveBeenCalled();
+ expect( mockNavigate ).not.toHaveBeenCalled();
+ } );
+} );
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/types.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/types.ts
index c9efe8277a5..61ecd8b0df1 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/types.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/types.ts
@@ -120,6 +120,10 @@ export interface ProductCollectionQuery {
priceRange: undefined | PriceRange;
filterable: boolean;
productReference?: number;
+ /**
+ * If `null`/`undefined`: Infer from location context.
+ */
+ productReferenceType?: 'cart' | 'order' | null;
relatedBy?: RelatedBy | undefined;
}
diff --git a/plugins/woocommerce/client/blocks/bin/webpack-config-interactive-blocks.js b/plugins/woocommerce/client/blocks/bin/webpack-config-interactive-blocks.js
index c2df378ad88..e7abe4d8a20 100644
--- a/plugins/woocommerce/client/blocks/bin/webpack-config-interactive-blocks.js
+++ b/plugins/woocommerce/client/blocks/bin/webpack-config-interactive-blocks.js
@@ -1,3 +1,4 @@
+/* eslint-disable @typescript-eslint/no-require-imports -- Webpack loads this configuration as CommonJS. */
/**
* External dependencies
*/
@@ -46,6 +47,10 @@ const entries = {
// Product Quantity block and the Grouped Product Selector block.
'woocommerce/add-to-cart-with-options-quantity-selector':
'./assets/js/blocks/add-to-cart-with-options/quantity-selector/frontend.ts',
+ // Product Collection store part for cart-referencing collections
+ // (cross-sells, upsells). Conditionally enqueued.
+ 'woocommerce/product-collection-cart-reference':
+ './assets/js/blocks/product-collection/cart-reference-frontend.ts',
// Other
'@woocommerce/stores/woocommerce/cart':
'./assets/js/base/stores/woocommerce/cart.ts',
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 09642122ad3..98358c4b961 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -49899,12 +49899,6 @@ parameters:
count: 1
path: src/Blocks/BlockTypes/MiniCart.php
- -
- message: '#^Parameter \#1 \$html of function wp_interactivity_process_directives expects string, string\|false given\.$#'
- identifier: argument.type
- count: 1
- path: src/Blocks/BlockTypes/MiniCart.php
-
-
message: '#^Parameter \#1 \$query of method WP_HTML_Tag_Processor\:\:next_tag\(\) expects array\{tag_name\?\: string\|null, match_offset\?\: int\|null, class_name\?\: string\|null, tag_closers\?\: string\|null\}\|null, ''bdi'' given\.$#'
identifier: argument.type
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/MiniCart.php b/plugins/woocommerce/src/Blocks/BlockTypes/MiniCart.php
index 334b527f537..540194291d8 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/MiniCart.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/MiniCart.php
@@ -48,6 +48,23 @@ class MiniCart extends AbstractBlock {
*/
protected $display_cart_prices_including_tax = false;
+ /**
+ * Scopes the render_block_context filter to the overlay render.
+ *
+ * The overlay is a template part rendered by its own do_blocks() call,
+ * outside the mini-cart block's tree, we can't use context to pass it down.
+ *
+ * @var bool
+ */
+ private $is_providing_reference_context = false;
+
+ /**
+ * Overlay markup built during block render, echoed on wp_footer.
+ *
+ * @var string
+ */
+ private $overlay_markup = '';
+
/**
* Block Hook API placements.
*
@@ -104,6 +121,9 @@ class MiniCart extends AbstractBlock {
add_action( 'wp_loaded', array( $this, 'register_empty_cart_message_block_pattern' ) );
add_filter( 'hooked_block_woocommerce/mini-cart', array( $this, 'modify_hooked_block_attributes' ), 10, 5 );
add_filter( 'hooked_block_types', array( $this, 'register_hooked_block' ), 9, 4 );
+
+ // Provide a `cart` product reference to inner blocks rendered in the overlay.
+ add_filter( 'render_block_context', array( $this, 'handle_render_block_context' ), 10, 1 );
}
/**
@@ -296,17 +316,14 @@ class MiniCart extends AbstractBlock {
$cart = $this->get_cart_instance();
if ( $cart ) {
- $classes_styles = StyleAttributesUtils::get_classes_and_styles_by_attributes( $attributes );
- $icon_color = isset( $attributes['iconColor']['color'] ) ? esc_attr( $attributes['iconColor']['color'] ) : 'currentColor';
- $product_count_color = isset( $attributes['productCountColor']['color'] ) ? $attributes['productCountColor']['color'] : '';
- $styles = $product_count_color ? 'background:' . esc_attr( $product_count_color ) : '';
- $icon = MiniCartUtils::get_svg_icon( $attributes['miniCartIcon'] ?? '', $icon_color );
- $product_count_visibility = isset( $attributes['productCountVisibility'] ) ? $attributes['productCountVisibility'] : 'greater_than_zero';
- $wrapper_classes = sprintf( 'wc-block-mini-cart wp-block-woocommerce-mini-cart %s', $classes_styles['classes'] );
- $wrapper_styles = $classes_styles['styles'];
- // Pre-render the template part so nested blocks enqueue their assets before the overlay is printed in wp_footer.
- $template_part_contents = $this->get_template_part_contents( false );
- $template_part_contents = $this->render_template_part_contents( $template_part_contents );
+ $classes_styles = StyleAttributesUtils::get_classes_and_styles_by_attributes( $attributes );
+ $icon_color = isset( $attributes['iconColor']['color'] ) ? esc_attr( $attributes['iconColor']['color'] ) : 'currentColor';
+ $product_count_color = isset( $attributes['productCountColor']['color'] ) ? $attributes['productCountColor']['color'] : '';
+ $styles = $product_count_color ? 'background:' . esc_attr( $product_count_color ) : '';
+ $icon = MiniCartUtils::get_svg_icon( $attributes['miniCartIcon'] ?? '', $icon_color );
+ $product_count_visibility = isset( $attributes['productCountVisibility'] ) ? $attributes['productCountVisibility'] : 'greater_than_zero';
+ $wrapper_classes = sprintf( 'wc-block-mini-cart wp-block-woocommerce-mini-cart %s', $classes_styles['classes'] );
+ $wrapper_styles = $classes_styles['styles'];
$cart_item_count = $cart ? $cart->get_cart_contents_count() : 0;
$display_cart_price_including_tax = get_option( 'woocommerce_tax_display_cart' ) === TaxDisplayMode::INCLUSIVE;
$cart_item_count = $cart ? $cart->get_cart_contents_count() : 0;
@@ -373,6 +390,7 @@ class MiniCart extends AbstractBlock {
// Render the minicart overlay in the body, outside of the block itself.
if ( ! has_action( 'wp_footer', array( $this, 'render_mini_cart_overlay' ) ) ) {
+ $this->overlay_markup = $this->build_mini_cart_overlay();
add_action( 'wp_footer', array( $this, 'render_mini_cart_overlay' ) );
}
ob_start();
@@ -441,12 +459,32 @@ class MiniCart extends AbstractBlock {
/**
* Echoes the Interactivity API Mini Cart overlay markup.
*
- * @since 11.1.0
+ * @since 11.3.0
* @return void
*/
public function render_mini_cart_overlay() {
+ // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
+ echo $this->overlay_markup;
+ }
+
+ /**
+ * Build the Interactivity API Mini Cart overlay markup.
+ *
+ * Called while the block itself renders (before the `head` is printed on
+ * block themes), so script modules that inner blocks enqueue during this
+ * render still reach the import map.
+ *
+ * @return string The overlay markup.
+ */
+ private function build_mini_cart_overlay() {
$template_part_contents = $this->get_template_part_contents( false );
- $template_part_contents = $this->render_template_part_contents( $template_part_contents );
+
+ $this->is_providing_reference_context = true;
+ try {
+ $template_part_contents = $this->render_template_part_contents( $template_part_contents );
+ } finally {
+ $this->is_providing_reference_context = false;
+ }
ob_start();
?>
<div
@@ -478,8 +516,7 @@ class MiniCart extends AbstractBlock {
</div>
</div>
<?php
- // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
- echo wp_interactivity_process_directives( ob_get_clean() );
+ return wp_interactivity_process_directives( (string) ob_get_clean() );
}
/**
@@ -748,4 +785,26 @@ class MiniCart extends AbstractBlock {
public function should_not_render_mini_cart( array $attributes ) {
return isset( $attributes['cartAndCheckoutRenderStyle'] ) && 'hidden' !== $attributes['cartAndCheckoutRenderStyle'];
}
+
+ /**
+ * Provide a `cart` product reference to inner blocks rendered in the
+ * Mini Cart overlay.
+ *
+ * The overlay is rendered out of the regular block tree (via `do_blocks()`
+ * in the footer), so this is exposed through the `render_block_context`
+ * filter rather than a static `providesContext` declaration.
+ *
+ * @internal For exclusive use of the render_block_context filter.
+ *
+ * @param array $context The block context.
+ * @return array The block context, with the product reference added while the overlay renders.
+ */
+ public function handle_render_block_context( $context ) {
+ if ( ! $this->is_providing_reference_context ) {
+ return $context;
+ }
+
+ $context['woocommerce/productCollection/referenceType'] = 'cart';
+ return $context;
+ }
}
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection/HandlerRegistry.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection/HandlerRegistry.php
index 778299940ae..6ad385cb8b6 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection/HandlerRegistry.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection/HandlerRegistry.php
@@ -228,9 +228,18 @@ class HandlerRegistry {
},
function ( $collection_args, $query ) {
$product_references = isset( $query['productReference'] ) ? array( $query['productReference'] ) : null;
- // Infer the product reference from the location if an explicit product is not set.
- if ( empty( $product_references ) ) {
- $location = $collection_args['productCollectionLocation'];
+
+ // A cart reference applies on any page, regardless of location.
+ $reference_type = $query['productReferenceType'] ?? null;
+
+ if ( Renderer::REFERENCE_TYPE_CART === $reference_type && empty( $product_references ) ) {
+ // User explicitly selected "From products in the cart".
+ // An explicit product selection, if present, always wins:
+ // a leftover cart reference type must not override it.
+ $product_references = Utils::get_cart_product_ids();
+ } elseif ( empty( $product_references ) ) {
+ // Fall back to location-based inference (backward compatibility).
+ $location = $collection_args['productCollectionLocation'] ?? array();
if ( isset( $location['type'] ) && 'product' === $location['type'] ) {
$product_references = array( $location['sourceData']['productId'] );
}
@@ -249,9 +258,19 @@ class HandlerRegistry {
},
function ( $collection_args, $query, $request ) {
$product_reference = $request->get_param( 'productReference' );
+ $reference_type = $request->get_param( 'productReferenceType' );
+
+ // Handle explicit cart reference type in editor preview.
+ if ( Renderer::REFERENCE_TYPE_CART === $reference_type && empty( $product_reference ) ) {
+ // In editor, we can't access the actual cart, so return empty for preview.
+ // The block will show a placeholder or sample data.
+ $collection_args['upsellsProductReferences'] = array();
+ return $collection_args;
+ }
+
// In some cases the editor will send along block location context that we can infer the product reference from.
if ( empty( $product_reference ) ) {
- $location = $collection_args['productCollectionLocation'];
+ $location = $collection_args['productCollectionLocation'] ?? array();
if ( isset( $location['type'] ) && 'product' === $location['type'] ) {
$product_reference = $location['sourceData']['productId'];
}
@@ -303,9 +322,19 @@ class HandlerRegistry {
},
function ( $collection_args, $query ) {
$product_references = isset( $query['productReference'] ) ? array( $query['productReference'] ) : null;
- // Infer the product reference from the location if an explicit product is not set.
- if ( empty( $product_references ) ) {
- $location = $collection_args['productCollectionLocation'];
+
+ // A cart reference applies on any page, regardless of location.
+ $reference_type = $query['productReferenceType'] ?? null;
+
+ if ( Renderer::REFERENCE_TYPE_CART === $reference_type && empty( $product_references ) ) {
+ // User explicitly selected "From products in the cart".
+ // An explicit product selection, if present, always wins:
+ // a leftover cart reference type must not override it.
+ $product_references = Utils::get_cart_product_ids();
+ } elseif ( empty( $product_references ) ) {
+ // Fall back to location-based inference (backward compatibility).
+ $location = $collection_args['productCollectionLocation'] ?? array();
+
if ( isset( $location['type'] ) && 'product' === $location['type'] ) {
$product_references = array( $location['sourceData']['productId'] );
}
@@ -324,9 +353,18 @@ class HandlerRegistry {
},
function ( $collection_args, $query, $request ) {
$product_reference = $request->get_param( 'productReference' );
+ $reference_type = $request->get_param( 'productReferenceType' );
+
+ if ( Renderer::REFERENCE_TYPE_CART === $reference_type && empty( $product_reference ) ) {
+ // In editor, we can't access the actual cart, so return empty for preview.
+ $collection_args['crossSellsProductReferences'] = array();
+ return $collection_args;
+ }
+
// In some cases the editor will send along block location context that we can infer the product reference from.
if ( empty( $product_reference ) ) {
- $location = $collection_args['productCollectionLocation'];
+ $location = $collection_args['productCollectionLocation'] ?? array();
+
if ( isset( $location['type'] ) && 'product' === $location['type'] ) {
$product_reference = $location['sourceData']['productId'];
}
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection/Renderer.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection/Renderer.php
index 625a323a3dc..39ea66d826b 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection/Renderer.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection/Renderer.php
@@ -12,6 +12,23 @@ use WP_HTML_Tag_Processor;
*/
class Renderer {
+ /**
+ * Product reference type constant for cart-based products.
+ */
+ const REFERENCE_TYPE_CART = 'cart';
+ /**
+ * Product reference type constant for product-based products.
+ */
+ const REFERENCE_TYPE_PRODUCT = 'product';
+
+ /**
+ * Block context key through which an ancestor block can provide a product
+ * reference type to Product Collection blocks rendered inside it (e.g. the
+ * Mini-Cart overlay provides `cart` so cross-sells/upsells collections
+ * resolve against the cart contents wherever the drawer is rendered).
+ */
+ const PRODUCT_REFERENCE_CONTEXT = 'woocommerce/productCollection/referenceType';
+
/**
* The render state of the product collection block.
*
@@ -22,6 +39,14 @@ class Renderer {
'has_no_results_block' => false,
);
+ /**
+ * Whether an ancestor block provided a `cart` product reference context
+ * for the Product Collection currently being rendered.
+ *
+ * @var bool
+ */
+ private $has_cart_reference_context = false;
+
/**
* The Block with its attributes before it gets rendered
*
@@ -62,7 +87,7 @@ class Renderer {
1
);
add_filter( 'render_block_core/query-pagination', array( $this, 'add_navigation_link_directives' ), 10, 3 );
- add_filter( 'render_block_context', array( $this, 'extend_context_for_inner_blocks' ), 11, 1 );
+ add_filter( 'render_block_context', array( $this, 'extend_context_for_inner_blocks' ), 11, 2 );
}
/**
@@ -92,14 +117,60 @@ class Renderer {
* @return string
*/
public function handle_rendering( $block_content, $block ) {
+ $query = $block['attrs']['query'] ?? array();
+ $product_reference_type = $query['productReferenceType'] ?? null;
+ $is_cart_reference = empty( $query['productReference'] ) && ( self::REFERENCE_TYPE_CART === $product_reference_type || $this->has_cart_reference_context );
+
if ( $this->should_prevent_render() ) {
- return ''; // Prevent rendering.
+ // For cart-referencing collections (e.g., cross-sells in the
+ // Mini-Cart overlay), render an empty placeholder with a
+ // router-region so the Interactivity Router can refresh it when
+ // the cart changes.
+ if ( $is_cart_reference ) {
+ $this->reset_render_state();
+ return $this->render_empty_placeholder();
+ }
+
+ $this->reset_render_state();
+ return '';
}
+ $block_content = $this->enhance_product_collection_with_interactivity( $block_content, $block );
+
// Reset the render state for the next render.
$this->reset_render_state();
- return $this->enhance_product_collection_with_interactivity( $block_content, $block );
+ return $block_content;
+ }
+
+ /**
+ * Render an empty placeholder for cart-referencing collections.
+ * This allows the Interactivity Router to find and replace the content
+ * when the cart changes.
+ *
+ * @return string Empty placeholder HTML with router-region and watch attributes.
+ */
+ private function render_empty_placeholder() {
+ $query_id = $this->parsed_block['attrs']['queryId'] ?? null;
+
+ if ( null === $query_id ) {
+ return '';
+ }
+
+ wp_enqueue_script_module( 'woocommerce/product-collection' );
+ wp_enqueue_script_module( 'woocommerce/product-collection-cart-reference' );
+
+ ob_start();
+ ?>
+ <div
+ class="wp-block-woocommerce-product-collection"
+ data-wp-interactive="woocommerce/product-collection"
+ data-wp-router-region="<?php echo esc_attr( 'wc-product-collection-' . $query_id ); ?>"
+ data-wp-watch---cart-reference="callbacks.refreshCartReference"
+ data-product-reference-type="<?php echo esc_attr( self::REFERENCE_TYPE_CART ); ?>"
+ ></div>
+ <?php
+ return (string) ob_get_clean();
}
/**
@@ -115,10 +186,11 @@ class Renderer {
* Reset the render state.
*/
private function reset_render_state() {
- $this->render_state = array(
+ $this->render_state = array(
'has_results' => false,
'has_no_results_block' => false,
);
+ $this->has_cart_reference_context = false;
}
/**
@@ -166,6 +238,16 @@ class Renderer {
'wc-product-collection-' . ( $this->parsed_block['attrs']['queryId'] ?? '0' )
);
}
+
+ // For cart-referencing collections, add callback to refresh on drawer open.
+ $query = $block['attrs']['query'] ?? array();
+ $product_reference_type = $query['productReferenceType'] ?? null;
+ $is_cart_reference = empty( $query['productReference'] ) && ( self::REFERENCE_TYPE_CART === $product_reference_type || $this->has_cart_reference_context );
+ if ( $is_cart_reference ) {
+ wp_enqueue_script_module( 'woocommerce/product-collection-cart-reference' );
+ $p->set_attribute( 'data-product-reference-type', self::REFERENCE_TYPE_CART );
+ $p->set_attribute( 'data-wp-watch---cart-reference', 'callbacks.refreshCartReference' );
+ }
}
// Check if dimensions need to be set and handle accordingly.
@@ -336,7 +418,8 @@ class Renderer {
* 'sourceData' => array( 'productId' => 123 ),
* )
*
- * @param array $context The block context.
+ * @param array $context The block context.
+ * @param array $parsed_block The parsed block being rendered, used to detect the Product Collection block itself.
* @return array $context {
* The block context including the product collection location context.
*
@@ -346,26 +429,68 @@ class Renderer {
* }
* }
*/
- public function extend_context_for_inner_blocks( $context ) {
+ public function extend_context_for_inner_blocks( $context, $parsed_block = array() ) {
// Add iapi/provider to inner blocks so they can run this store's Interactivity API actions.
$context['iapi/provider'] = 'woocommerce/product-collection';
+ $block_name = $parsed_block['blockName'] ?? '';
+
+ if ( 'woocommerce/product-collection' === $block_name ) {
+ $this->has_cart_reference_context = $this->context_provides_cart_reference( $context );
+
+ // Set productCollectionLocation for the Product Collection block itself.
+ $context['productCollectionLocation'] = $this->get_block_location_context( $context );
+ return $context;
+ }
+
// Target only product collection's inner blocks that use the 'query' context.
if ( ! isset( $context['query'] ) || ! isset( $context['query']['isProductCollectionBlock'] ) || ! $context['query']['isProductCollectionBlock'] ) {
return $context;
}
- $is_in_single_product = isset( $context['singleProduct'] ) && ! empty( $context['postId'] );
- $context['productCollectionLocation'] = $is_in_single_product ? array(
- 'type' => 'product',
- 'sourceData' => array(
- 'productId' => absint( $context['postId'] ),
- ),
- ) : $this->get_location_context();
+ // Check parent block contexts (in order of specificity).
+ $is_in_single_product = isset( $context['singleProduct'] ) && ! empty( $context['postId'] );
+
+ if ( $is_in_single_product ) {
+ $context['productCollectionLocation'] = array(
+ 'type' => self::REFERENCE_TYPE_PRODUCT,
+ 'sourceData' => array(
+ 'productId' => absint( $context['postId'] ),
+ ),
+ );
+ } else {
+ $context['productCollectionLocation'] = $this->get_block_location_context( $context );
+ }
return $context;
}
+ /**
+ * Whether the given block context provides a `cart` product reference
+ * (see the PRODUCT_REFERENCE_CONTEXT constant).
+ *
+ * @param array $context The block context.
+ * @return bool
+ */
+ private function context_provides_cart_reference( $context ) {
+ return self::REFERENCE_TYPE_CART === ( $context[ self::PRODUCT_REFERENCE_CONTEXT ] ?? null );
+ }
+
+ /**
+ * Get the location context for a block, honoring a context-provided
+ * product reference before falling back to page-level detection.
+ *
+ * @param array $context The block context.
+ * @return array The location context.
+ */
+ private function get_block_location_context( $context ) {
+ if ( $this->context_provides_cart_reference( $context ) ) {
+ return ProductCollectionUtils::get_cart_location_context();
+ }
+
+ return $this->get_location_context();
+ }
+
/**
* Get the global location context.
* Serve as a runtime cache for the location context.
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection/Utils.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection/Utils.php
index 8c23a68e684..f49ec22683c 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection/Utils.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection/Utils.php
@@ -5,6 +5,7 @@ namespace Automattic\WooCommerce\Blocks\BlockTypes\ProductCollection;
use WP_Query;
use Automattic\WooCommerce\Blocks\Utils\CartCheckoutUtils;
+use Automattic\WooCommerce\Utilities\ArrayUtil;
/**
* Utility methods used for the Product Collection block.
@@ -105,6 +106,60 @@ class Utils {
return self::remove_empty_array_recursive( $queries );
}
+ /**
+ * Whether the cart is initialized and available for reading.
+ *
+ * @return bool
+ */
+ private static function is_cart_available() {
+ return isset( WC()->cart ) && is_a( WC()->cart, 'WC_Cart' );
+ }
+
+ /**
+ * Get the unique product IDs of the items currently in the cart.
+ *
+ * @since 11.3.0
+ *
+ * @return int[] Product IDs, or an empty array when the cart is unavailable.
+ */
+ public static function get_cart_product_ids() {
+ if ( ! self::is_cart_available() ) {
+ return array();
+ }
+
+ $items = array();
+ foreach ( WC()->cart->get_cart() as $cart_item ) {
+ if ( ! isset( $cart_item['product_id'] ) ) {
+ continue;
+ }
+
+ $items[] = absint( $cart_item['product_id'] );
+ }
+
+ return ArrayUtil::unique_truthy_values( $items );
+ }
+
+ /**
+ * Get a `cart` location context built from the current cart contents.
+ *
+ * Used when an ancestor block provides a `cart` product reference for a
+ * Product Collection (e.g. the Mini-Cart overlay), independently of the
+ * page the block is rendered on.
+ *
+ * When the cart is unavailable this still returns a cart location, with
+ * an empty product list.
+ *
+ * @since 11.3.0
+ *
+ * @return array The cart location context.
+ */
+ public static function get_cart_location_context() {
+ return array(
+ 'type' => 'cart',
+ 'sourceData' => array( 'productIds' => self::get_cart_product_ids() ),
+ );
+ }
+
/**
* Parse WP Query's front-end context for the Product Collection block.
*
@@ -149,20 +204,11 @@ class Utils {
$current_page = $wp_query->get_queried_object();
$has_cart_block = $current_page && \WC_Blocks_Utils::has_block_in_page( $current_page, 'woocommerce/cart' );
$has_checkout_block = $current_page && \WC_Blocks_Utils::has_block_in_page( $current_page, 'woocommerce/checkout' );
- $is_cart_available = isset( WC()->cart ) && is_a( WC()->cart, 'WC_Cart' );
+ $is_cart_available = self::is_cart_available();
if ( ( $has_cart_block || $has_checkout_block || is_cart() || is_checkout() ) && $is_cart_available ) {
- $type = 'cart';
- $items = array();
- foreach ( WC()->cart->get_cart() as $cart_item ) {
- if ( ! isset( $cart_item['product_id'] ) ) {
- continue;
- }
-
- $items[] = absint( $cart_item['product_id'] );
- }
- $items = array_unique( array_filter( $items ) );
- $source_data = array( 'productIds' => $items );
+ $type = 'cart';
+ $source_data = array( 'productIds' => self::get_cart_product_ids() );
} elseif ( is_product_taxonomy() ) {
diff --git a/plugins/woocommerce/src/Utilities/ArrayUtil.php b/plugins/woocommerce/src/Utilities/ArrayUtil.php
index fa21a1eaa83..a83dcf7e141 100644
--- a/plugins/woocommerce/src/Utilities/ArrayUtil.php
+++ b/plugins/woocommerce/src/Utilities/ArrayUtil.php
@@ -367,4 +367,16 @@ class ArrayUtil {
}
return true;
}
+
+ /**
+ * Get the unique truthy values of an array, reindexed.
+ *
+ * @since 11.3.0
+ *
+ * @param array $items The array to process.
+ * @return array The unique truthy values, with sequential integer keys.
+ */
+ public static function unique_truthy_values( array $items ): array {
+ return array_values( array_unique( array_filter( $items ) ) );
+ }
}
diff --git a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection/CartReferenceTest.php b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection/CartReferenceTest.php
new file mode 100644
index 00000000000..26f65cb52e4
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection/CartReferenceTest.php
@@ -0,0 +1,130 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Blocks\BlockTypes\ProductCollection;
+
+use Automattic\WooCommerce\Blocks\BlockTypes\ProductCollection\Renderer;
+use Automattic\WooCommerce\Tests\Blocks\Mocks\ProductCollectionMock;
+use WC_Helper_Product;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests cart references supplied by a Product Collection ancestor.
+ */
+class CartReferenceTest extends WC_Unit_Test_Case {
+ /**
+ * Script modules already enqueued before each test.
+ *
+ * @var string[]
+ */
+ private $enqueued_modules;
+
+ /**
+ * Record the script module queue, which WordPress does not reset between tests.
+ */
+ public function setUp(): void {
+ parent::setUp();
+ $this->enqueued_modules = wp_script_modules()->get_queue();
+ }
+
+ /**
+ * Restore modules added by the renderer.
+ */
+ public function tearDown(): void {
+ try {
+ foreach ( array_diff( wp_script_modules()->get_queue(), $this->enqueued_modules ) as $module_id ) {
+ wp_dequeue_script_module( $module_id );
+ }
+ } finally {
+ parent::tearDown();
+ }
+ }
+
+ /**
+ * @testdox Should use the cart context on any page and retain explicit references and hand-picked filters.
+ * @testWith ["cross-sells", "set_cross_sell_ids"]
+ * ["upsells", "set_upsell_ids"]
+ *
+ * @param string $collection Collection name.
+ * @param string $setter Product recommendation setter.
+ */
+ public function test_cart_context_and_explicit_references( string $collection, string $setter ): void {
+ $cart_product = WC_Helper_Product::create_simple_product();
+ $explicit_product = WC_Helper_Product::create_simple_product();
+ $cart_product->$setter( array( 101, 102 ) );
+ $cart_product->save();
+ $explicit_product->$setter( array( 103 ) );
+ $explicit_product->save();
+ WC()->cart->add_to_cart( $cart_product->get_id() );
+
+ $parsed_block = Utils::get_base_parsed_block();
+ $parsed_block['attrs']['collection'] = 'woocommerce/product-collection/' . $collection;
+ $renderer = new Renderer();
+ $context = $renderer->extend_context_for_inner_blocks(
+ array( Renderer::PRODUCT_REFERENCE_CONTEXT => Renderer::REFERENCE_TYPE_CART ),
+ $parsed_block
+ );
+ $this->assertSame(
+ array(
+ 'type' => 'cart',
+ 'sourceData' => array( 'productIds' => array( $cart_product->get_id() ) ),
+ ),
+ $context['productCollectionLocation']
+ );
+ $parsed_block['attrs']['productCollectionLocation'] = $context['productCollectionLocation'];
+ $sut = new ProductCollectionMock();
+ $query = Utils::initialize_merged_query( $sut, $parsed_block );
+ $this->assertSame( array( 101, 102 ), array_values( $query['post__in'] ) );
+
+ $parsed_block['attrs']['query']['woocommerceHandPickedProducts'] = array( 102, 103 );
+ $query = Utils::initialize_merged_query( $sut, $parsed_block );
+ $this->assertSame( array( 102 ), array_values( $query['post__in'] ), 'Hand-picked products must still intersect with recommendations.' );
+
+ unset( $parsed_block['attrs']['query']['woocommerceHandPickedProducts'] );
+ $parsed_block['attrs']['query']['productReferenceType'] = 'cart';
+ $parsed_block['attrs']['query']['productReference'] = $explicit_product->get_id();
+ $query = Utils::initialize_merged_query( $sut, $parsed_block );
+ $this->assertSame( array( 103 ), array_values( $query['post__in'] ), 'An explicit product takes precedence over both cart references.' );
+
+ $request = Utils::build_request(
+ array(
+ 'productCollectionQueryContext' => array( 'collection' => $parsed_block['attrs']['collection'] ),
+ 'productReferenceType' => 'cart',
+ 'productReference' => $explicit_product->get_id(),
+ )
+ );
+ $query = $sut->update_rest_query_in_editor( array(), $request );
+ $this->assertSame( array( 103 ), array_values( $query['post__in'] ), 'Editor previews must honor the same explicit product.' );
+ }
+
+ /**
+ * @testdox Should preserve an empty cart collection with query ID zero and reset context before the next collection.
+ */
+ public function test_empty_cart_placeholder_and_context_isolation(): void {
+ $sut = new Renderer();
+ $block = Utils::get_base_parsed_block();
+ $block['attrs']['queryId'] = 0;
+ $sut->set_parsed_block( $block );
+ $sut->extend_context_for_inner_blocks( array( Renderer::PRODUCT_REFERENCE_CONTEXT => 'cart' ), $block );
+ $html = $sut->handle_rendering( '<div></div>', $block );
+ $this->assertStringContainsString( 'data-wp-router-region="wc-product-collection-0"', $html );
+ $this->assertStringContainsString( 'data-wp-watch---cart-reference="callbacks.refreshCartReference"', $html );
+ $this->assertSame( '', $sut->handle_rendering( '<div></div>', $block ), 'Cart context must not leak to a later collection.' );
+ }
+
+ /**
+ * @testdox Should not attach cart refresh behavior to an explicitly selected product.
+ */
+ public function test_explicit_product_does_not_watch_cart(): void {
+ $sut = new Renderer();
+ $block = Utils::get_base_parsed_block();
+ $block['attrs']['queryId'] = 0;
+ $block['attrs']['query']['productReference'] = 123;
+ $block['attrs']['query']['productReferenceType'] = 'cart';
+ $sut->set_parsed_block( $block );
+ $sut->extend_context_for_inner_blocks( array( Renderer::PRODUCT_REFERENCE_CONTEXT => 'cart' ), $block );
+ $html = $sut->enhance_product_collection_with_interactivity( '<div class="wp-block-woocommerce-product-collection">Products</div>', $block );
+ $this->assertStringNotContainsString( 'callbacks.refreshCartReference', $html );
+ $this->assertSame( '', $sut->handle_rendering( '<div></div>', $block ), 'An empty explicit collection does not need a cart placeholder.' );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Utilities/ArrayUtilTest.php b/plugins/woocommerce/tests/php/src/Utilities/ArrayUtilTest.php
index 7867279d948..845b0c65cbb 100644
--- a/plugins/woocommerce/tests/php/src/Utilities/ArrayUtilTest.php
+++ b/plugins/woocommerce/tests/php/src/Utilities/ArrayUtilTest.php
@@ -1,4 +1,5 @@
<?php
+declare( strict_types = 1 );
namespace Automattic\WooCommerce\Tests\Utilities;
@@ -193,8 +194,8 @@ class ArrayUtilTest extends \WC_Unit_Test_Case {
),
);
- $actual = ArrayUtil::select( $items, 'foo' );
- $this->assertEquals( array( 1, 3 ), $actual, ArrayUtil::SELECT_BY_ARRAY_KEY );
+ $actual = ArrayUtil::select( $items, 'foo', ArrayUtil::SELECT_BY_ARRAY_KEY );
+ $this->assertSame( array( 1, 3 ), $actual );
}
/**
@@ -451,4 +452,36 @@ class ArrayUtilTest extends \WC_Unit_Test_Case {
$actual = ArrayUtil::group_by_column( $data, 'type', true );
$this->assertEquals( $expected, $actual );
}
+
+ /**
+ * @testdox `unique_truthy_values` removes falsy values and duplicates, and reindexes the result.
+ */
+ public function test_unique_truthy_values_filters_dedupes_and_reindexes() {
+ $input = array(
+ 5 => 403,
+ 7 => 0,
+ 9 => 404,
+ 11 => 403,
+ 13 => null,
+ 15 => '',
+ 17 => false,
+ 19 => 405,
+ );
+
+ $actual = ArrayUtil::unique_truthy_values( $input );
+
+ $this->assertEquals( array( 403, 404, 405 ), $actual );
+ }
+
+ /**
+ * @testdox `unique_truthy_values` returns an empty array when all values are falsy or the input is empty.
+ *
+ * @testWith [[]]
+ * [[0, null, "", false]]
+ *
+ * @param array $input The input array to test.
+ */
+ public function test_unique_truthy_values_returns_empty_array_when_nothing_survives( $input ) {
+ $this->assertEquals( array(), ArrayUtil::unique_truthy_values( $input ) );
+ }
}