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 ) );
+	}
 }