Commit e3948a96cda for woocommerce

commit e3948a96cda11f89de2a7e9229d89b18fb125091
Author: Ann <annchichi@users.noreply.github.com>
Date:   Mon Jul 27 13:57:52 2026 +0800

    Fix shipping recommendation dismissal accessibility and fallbacks (#66689)

    * Fix shipping recommendation dismissal and fallbacks

    * Add changelog entry for shipping recommendation fixes

    * Fix shipping fallback when suggestions are disabled

diff --git a/plugins/woocommerce/changelog/wooplug-7093-shipping-recommendations-dismiss b/plugins/woocommerce/changelog/wooplug-7093-shipping-recommendations-dismiss
new file mode 100644
index 00000000000..5592ef31ec9
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooplug-7093-shipping-recommendations-dismiss
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Preserve focus and screen reader feedback when hiding shipping recommendations, and restore marketplace links where the recommendations component is unavailable.
diff --git a/plugins/woocommerce/client/admin/client/shipping/shipping-recommendations.tsx b/plugins/woocommerce/client/admin/client/shipping/shipping-recommendations.tsx
index 770fffdf5a9..dd34dc9ffcd 100644
--- a/plugins/woocommerce/client/admin/client/shipping/shipping-recommendations.tsx
+++ b/plugins/woocommerce/client/admin/client/shipping/shipping-recommendations.tsx
@@ -120,9 +120,7 @@ const ShippingRecommendations = () => {
 		! hasRecommendationEligibilityResolved ||
 		( ! hasRecommendationsDismissResolved && hasVisibleExtensions );
 	const shouldShowRecommendationsFallback =
-		( hasRecommendationsDismissResolved && isRecommendationsHidden ) ||
-		! hasVisibleExtensions ||
-		isSellingDigitalProductsOnly;
+		! hasVisibleExtensions || isSellingDigitalProductsOnly;
 	const shouldTrackRecommendationsImpression =
 		hasRecommendationEligibilityResolved &&
 		hasRecommendationsDismissResolved &&
@@ -182,7 +180,11 @@ const ShippingRecommendations = () => {
 			<ShippingTour
 				showShippingRecommendationsStep={ ! isRecommendationsHidden }
 			/>
-			<div style={ { paddingBottom: 60 } }>
+			<div
+				style={ {
+					paddingBottom: isRecommendationsHidden ? 0 : 60,
+				} }
+			>
 				<ShippingRecommendationsList
 					dismissState={ recommendationsDismissState }
 				>
@@ -241,6 +243,7 @@ const ShippingRecommendations = () => {
 					} ) }
 				</ShippingRecommendationsList>
 			</div>
+			{ isRecommendationsHidden && marketplaceFallbackLink }
 		</>
 	);
 };
diff --git a/plugins/woocommerce/client/admin/client/shipping/test/shipping-recommendations.test.tsx b/plugins/woocommerce/client/admin/client/shipping/test/shipping-recommendations.test.tsx
index 6c06500bf95..2b635fce98c 100644
--- a/plugins/woocommerce/client/admin/client/shipping/test/shipping-recommendations.test.tsx
+++ b/plugins/woocommerce/client/admin/client/shipping/test/shipping-recommendations.test.tsx
@@ -2,6 +2,7 @@
  * External dependencies
  */
 import { render, screen } from '@testing-library/react';
+import { speak } from '@wordpress/a11y';
 import { useSelect, useDispatch } from '@wordpress/data';
 import { recordEvent } from '@woocommerce/tracks';

@@ -23,24 +24,35 @@ jest.mock( '~/components/tracked-link/tracked-link', () => ( {
 		</span>
 	),
 } ) );
-jest.mock( '../../settings-recommendations/dismissable-list', () => ( {
-	DismissableList: ( {
-		children,
-		isDismissed,
-	}: {
-		children: React.ReactNode;
-		isDismissed?: boolean;
-	} ) => (
-		<div
-			data-dismissed={ String( Boolean( isDismissed ) ) }
-			data-testid="dismissable-list"
-		>
-			{ ! isDismissed && children }
-		</div>
-	),
-	DismissableListHeading: ( { children }: { children: React.ReactNode } ) =>
-		children,
-} ) );
+jest.mock( '../../settings-recommendations/dismissable-list', () => {
+	const { DismissableList } = jest.requireActual(
+		'../../settings-recommendations/dismissable-list'
+	);
+
+	return {
+		DismissableList: ( {
+			children,
+			isDismissed,
+		}: {
+			children: React.ReactNode;
+			isDismissed?: boolean;
+		} ) => (
+			<div
+				data-dismissed={ String( Boolean( isDismissed ) ) }
+				data-testid="dismissable-list"
+			>
+				<DismissableList isDismissed={ isDismissed }>
+					{ children }
+				</DismissableList>
+			</div>
+		),
+		DismissableListHeading: ( {
+			children,
+		}: {
+			children: React.ReactNode;
+		} ) => children,
+	};
+} );
 jest.mock( '~/guided-tours/shipping-tour', () => ( {
 	ShippingTour: ( {
 		showShippingRecommendationsStep,
@@ -66,6 +78,9 @@ jest.mock( '../../lib/notices', () => ( {
 jest.mock( '@woocommerce/tracks', () => ( {
 	recordEvent: jest.fn(),
 } ) );
+jest.mock( '@wordpress/a11y', () => ( {
+	speak: jest.fn(),
+} ) );

 const defaultSelectReturn = {
 	getActivePlugins: () => [],
@@ -97,6 +112,7 @@ describe( 'ShippingRecommendations', () => {
 			activatePlugins: () => Promise.resolve(),
 		} );
 		( recordEvent as jest.Mock ).mockClear();
+		( speak as jest.Mock ).mockClear();
 	} );

 	it( 'renders recommendations and the shipping tour recommendations step', () => {
@@ -190,6 +206,46 @@ describe( 'ShippingRecommendations', () => {
 		);
 	} );

+	it( 'keeps the dismissal wrapper mounted to restore focus and announce the change', () => {
+		let dismissOption = 'no';
+		mockSelect( {
+			getOption: ( option: string ) =>
+				option === SHIPPING_RECOMMENDATIONS_DISMISS_OPTION
+					? dismissOption
+					: undefined,
+		} );
+
+		const { rerender } = render( <ShippingRecommendations /> );
+		const dismissalWrapper = document.querySelector(
+			'.woocommerce-dismissable-list__wrapper'
+		);
+
+		expect( dismissalWrapper ).toBeInTheDocument();
+		expect( screen.getByTestId( 'dismissable-list' ) ).toHaveAttribute(
+			'data-dismissed',
+			'false'
+		);
+
+		dismissOption = 'yes';
+		rerender( <ShippingRecommendations /> );
+
+		expect(
+			document.querySelector( '.woocommerce-dismissable-list__wrapper' )
+		).toBe( dismissalWrapper );
+		expect( screen.getByTestId( 'dismissable-list' ) ).toHaveAttribute(
+			'data-dismissed',
+			'true'
+		);
+		expect( speak ).toHaveBeenCalledWith(
+			'Recommendation hidden.',
+			'assertive'
+		);
+		expect( dismissalWrapper ).toHaveFocus();
+		expect(
+			screen.getByText( 'the WooCommerce Marketplace' )
+		).toBeInTheDocument();
+	} );
+
 	it( 'does not render recommendations before the product profile resolves', () => {
 		mockSelect( {
 			hasFinishedResolution: ( selector: string ) =>
@@ -231,9 +287,11 @@ describe( 'ShippingRecommendations', () => {
 		expect(
 			screen.queryByText( 'the WooCommerce Marketplace' )
 		).toBeInTheDocument();
-		expect(
-			screen.queryByTestId( 'dismissable-list' )
-		).not.toBeInTheDocument();
+		expect( screen.getByTestId( 'dismissable-list' ) ).toHaveAttribute(
+			'data-dismissed',
+			'true'
+		);
+		expect( speak ).not.toHaveBeenCalled();
 		expect( screen.getByTestId( 'shipping-tour' ) ).toHaveAttribute(
 			'data-show-recommendations-step',
 			'false'
diff --git a/plugins/woocommerce/includes/admin/views/html-admin-settings.php b/plugins/woocommerce/includes/admin/views/html-admin-settings.php
index 8352756fdb8..a11799577f3 100644
--- a/plugins/woocommerce/includes/admin/views/html-admin-settings.php
+++ b/plugins/woocommerce/includes/admin/views/html-admin-settings.php
@@ -97,6 +97,12 @@ $marketplace_links = array(
 		/* translators: %1$s: opening link tag, %2$s: closing link tag */
 		'message'     => __( '%1$sExplore solutions%2$s that help with tax calculations, compliance, and regional requirements.', 'woocommerce' ),
 	),
+	'shipping' => array(
+		'url'         => $marketplace_base_url . 'shipping-delivery-and-fulfillment/',
+		'is_external' => true,
+		/* translators: %1$s: opening link tag, %2$s: closing link tag */
+		'message'     => __( '%1$sExplore solutions%2$s that enhance shipping, delivery, and fulfillment workflows.', 'woocommerce' ),
+	),
 	'account'  => array(
 		'url'         => $marketplace_base_url . 'store-content-and-customizations/cart-and-checkout-features/',
 		'is_external' => true,
@@ -117,6 +123,27 @@ $marketplace_links = array(
 	),
 );

+// The React recommendations component owns the marketplace link on the main
+// Shipping screen, but it does not render on subsections, zone screens, or for
+// users who cannot install plugins.
+$shipping_zone_id = '';
+// phpcs:disable WordPress.Security.NonceVerification.Recommended -- Used only to select non-mutating view output.
+if ( isset( $_GET['zone_id'] ) && is_string( $_GET['zone_id'] ) ) {
+	$shipping_zone_id = sanitize_text_field( wp_unslash( $_GET['zone_id'] ) );
+}
+// phpcs:enable WordPress.Security.NonceVerification.Recommended
+
+$is_shipping_zone_screen = '' !== $shipping_zone_id;
+if (
+	'shipping' === $current_tab
+	&& (
+		'no' === get_option( 'woocommerce_show_marketplace_suggestions', 'yes' )
+		|| ( '' === $current_section && ! $is_shipping_zone_screen && current_user_can( 'install_plugins' ) )
+	)
+) {
+	unset( $marketplace_links['shipping'] );
+}
+
 ?>

 <div class="wrap woocommerce">
diff --git a/plugins/woocommerce/tests/php/includes/admin/views/class-wc-admin-settings-view-test.php b/plugins/woocommerce/tests/php/includes/admin/views/class-wc-admin-settings-view-test.php
index 30bf6b48394..4996d75c623 100644
--- a/plugins/woocommerce/tests/php/includes/admin/views/class-wc-admin-settings-view-test.php
+++ b/plugins/woocommerce/tests/php/includes/admin/views/class-wc-admin-settings-view-test.php
@@ -78,6 +78,82 @@ class WC_Admin_Settings_View_Test extends WC_Unit_Test_Case {
 		);
 	}

+	/**
+	 * @testdox Should render the shipping marketplace link on Shipping settings subsections.
+	 */
+	public function test_shipping_marketplace_link_is_rendered_on_shipping_settings_subsections(): void {
+		$output = $this->render_settings_view( 'shipping', 'options' );
+
+		$this->assertStringContainsString(
+			'data-settings-tab="shipping"',
+			$output
+		);
+		$this->assertStringContainsString(
+			'data-settings-section="options"',
+			$output
+		);
+		$this->assertStringContainsString(
+			'shipping-delivery-and-fulfillment',
+			$output
+		);
+	}
+
+	/**
+	 * @testdox Should not render the shipping marketplace link when marketplace suggestions are disabled.
+	 */
+	public function test_shipping_marketplace_link_is_not_rendered_when_marketplace_suggestions_are_disabled(): void {
+		update_option( 'woocommerce_show_marketplace_suggestions', 'no' );
+
+		$output = $this->render_settings_view( 'shipping', 'options' );
+
+		$this->assertStringNotContainsString(
+			'data-settings-tab="shipping"',
+			$output
+		);
+		$this->assertStringNotContainsString(
+			'shipping-delivery-and-fulfillment',
+			$output
+		);
+	}
+
+	/**
+	 * @testdox Should render the shipping marketplace link on Shipping zone screens.
+	 */
+	public function test_shipping_marketplace_link_is_rendered_on_shipping_zone_screens(): void {
+		$_GET['zone_id'] = '1';
+
+		$output = $this->render_settings_view( 'shipping' );
+
+		$this->assertStringContainsString(
+			'data-settings-tab="shipping"',
+			$output
+		);
+		$this->assertStringContainsString(
+			'shipping-delivery-and-fulfillment',
+			$output
+		);
+	}
+
+	/**
+	 * @testdox Should render the shipping marketplace link for users who cannot install plugins.
+	 */
+	public function test_shipping_marketplace_link_is_rendered_for_users_without_plugin_install_permissions(): void {
+		$shop_manager_user_id = self::factory()->user->create( array( 'role' => 'shop_manager' ) );
+		wp_set_current_user( $shop_manager_user_id );
+		$this->assertFalse( current_user_can( 'install_plugins' ) );
+
+		$output = $this->render_settings_view( 'shipping' );
+
+		$this->assertStringContainsString(
+			'data-settings-tab="shipping"',
+			$output
+		);
+		$this->assertStringContainsString(
+			'shipping-delivery-and-fulfillment',
+			$output
+		);
+	}
+
 	/**
 	 * @testdox Should keep non-shipping marketplace links when shipping smart defaults are enabled.
 	 */