Commit b24ba1d1836 for woocommerce

commit b24ba1d1836f49bede90b152acc71abf59bd9b5c
Author: Jason Kytros <jason.kytros@automattic.com>
Date:   Wed Jul 1 13:39:38 2026 +0300

    Fix tax recommendations card not staying dismissed in non-production environments (#66041)

    * Fix tax recommendations card not staying dismissed in non-production environments

    * Updated settings getter

    * Address co-pilot review comments

    * Added typecheck gate

    * Fixed class initialization

    * Updated comment

    * Refactor DismissableList into pure UI component with dedicated dismiss hooks

    * Fix useOptionDismiss never triggering the option resolver

    * Register REST routes via rest_api_init in TaxSettingsRecommendations test

    * handle a11y on dismiss

    ---------

    Co-authored-by: Chris Lilitsas <1105590+xristos3490@users.noreply.github.com>

diff --git a/plugins/woocommerce/client/admin/client/abandoned-cart-recovery/abandoned-cart-recovery-recommendations.tsx b/plugins/woocommerce/client/admin/client/abandoned-cart-recovery/abandoned-cart-recovery-recommendations.tsx
index bece6d8387f..53a0d45514e 100644
--- a/plugins/woocommerce/client/admin/client/abandoned-cart-recovery/abandoned-cart-recovery-recommendations.tsx
+++ b/plugins/woocommerce/client/admin/client/abandoned-cart-recovery/abandoned-cart-recovery-recommendations.tsx
@@ -17,6 +17,7 @@ import {
 	DismissableListHeading,
 } from '../settings-recommendations/dismissable-list';
 import { TrackedLink } from '~/components/tracked-link/tracked-link';
+import { useOptionDismiss } from '~/hooks/use-option-dismiss';
 import { createNoticesFromResponse } from '../lib/notices';
 import AutomateWooItem from './automatewoo-item';
 import MailPoetItem from './mailpoet-item';
@@ -63,49 +64,55 @@ export const AbandonedCartRecoveryRecommendationsList = ( {
 	children,
 }: {
 	children: React.ReactNode;
-} ) => (
-	<DismissableList
-		className="woocommerce-recommended-abandoned-cart-recovery-extensions"
-		dismissOptionName="woocommerce_abandoned_cart_recovery_recommendations_hidden"
-	>
-		<DismissableListHeading>
-			<Text variant="title.small" as="p" size="20" lineHeight="28px">
-				{ __( 'Recover more abandoned carts', 'woocommerce' ) }
-			</Text>
-			<Text
-				className="woocommerce-recommended-abandoned-cart-recovery__header-heading"
-				variant="caption"
-				as="p"
-				size="12"
-				lineHeight="16px"
-			>
-				{ __(
-					'Add multi-step recovery flows, customer segmentation, and ongoing email marketing to win back more shoppers.',
-					'woocommerce'
-				) }
-			</Text>
-		</DismissableListHeading>
-		<ul className="woocommerce-list">
-			{ Children.map( children, ( item ) => (
-				<li className="woocommerce-list__item">{ item }</li>
-			) ) }
-		</ul>
-		<CardFooter>
-			<TrackedLink
-				message={ __(
-					// translators: {{Link}} is a placeholder for a html element.
-					'Visit {{Link}}the WooCommerce Marketplace{{/Link}} to find more email marketing and customer engagement solutions.',
-					'woocommerce'
-				) }
-				targetUrl={ getAdminLink(
-					'admin.php?page=wc-admin&tab=extensions&path=/extensions&category=marketing'
-				) }
-				linkType="wc-admin"
-				eventName="abandoned_cart_recovery_visit_marketplace_click"
-			/>
-		</CardFooter>
-	</DismissableList>
-);
+} ) => {
+	const { isDismissed, onDismiss } = useOptionDismiss(
+		'woocommerce_abandoned_cart_recovery_recommendations_hidden'
+	);
+
+	return (
+		<DismissableList
+			className="woocommerce-recommended-abandoned-cart-recovery-extensions"
+			isDismissed={ isDismissed }
+		>
+			<DismissableListHeading onDismiss={ onDismiss }>
+				<Text variant="title.small" as="p" size="20" lineHeight="28px">
+					{ __( 'Recover more abandoned carts', 'woocommerce' ) }
+				</Text>
+				<Text
+					className="woocommerce-recommended-abandoned-cart-recovery__header-heading"
+					variant="caption"
+					as="p"
+					size="12"
+					lineHeight="16px"
+				>
+					{ __(
+						'Add multi-step recovery flows, customer segmentation, and ongoing email marketing to win back more shoppers.',
+						'woocommerce'
+					) }
+				</Text>
+			</DismissableListHeading>
+			<ul className="woocommerce-list">
+				{ Children.map( children, ( item ) => (
+					<li className="woocommerce-list__item">{ item }</li>
+				) ) }
+			</ul>
+			<CardFooter>
+				<TrackedLink
+					message={ __(
+						// translators: {{Link}} is a placeholder for a html element.
+						'Visit {{Link}}the WooCommerce Marketplace{{/Link}} to find more email marketing and customer engagement solutions.',
+						'woocommerce'
+					) }
+					targetUrl={ getAdminLink(
+						'admin.php?page=wc-admin&tab=extensions&path=/extensions&category=marketing'
+					) }
+					linkType="wc-admin"
+					eventName="abandoned_cart_recovery_visit_marketplace_click"
+				/>
+			</CardFooter>
+		</DismissableList>
+	);
+};

 const AbandonedCartRecoveryRecommendations = () => {
 	const activePlugins = useSelect(
diff --git a/plugins/woocommerce/client/admin/client/abandoned-cart-recovery/test/abandoned-cart-recovery-recommendations.test.tsx b/plugins/woocommerce/client/admin/client/abandoned-cart-recovery/test/abandoned-cart-recovery-recommendations.test.tsx
index 2eff5378c46..57d55aa3a51 100644
--- a/plugins/woocommerce/client/admin/client/abandoned-cart-recovery/test/abandoned-cart-recovery-recommendations.test.tsx
+++ b/plugins/woocommerce/client/admin/client/abandoned-cart-recovery/test/abandoned-cart-recovery-recommendations.test.tsx
@@ -22,6 +22,10 @@ jest.mock( '../../settings-recommendations/dismissable-list', () => ( {
 		children,
 } ) );

+jest.mock( '~/hooks/use-option-dismiss', () => ( {
+	useOptionDismiss: () => ( { isDismissed: false, onDismiss: jest.fn() } ),
+} ) );
+
 const mockActivePlugins = ( plugins: string[] ) => {
 	( useSelect as jest.Mock ).mockImplementation( ( fn ) =>
 		fn( () => ( {
diff --git a/plugins/woocommerce/client/admin/client/hooks/test/use-endpoint-dismiss.test.ts b/plugins/woocommerce/client/admin/client/hooks/test/use-endpoint-dismiss.test.ts
new file mode 100644
index 00000000000..a932f9c0199
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/hooks/test/use-endpoint-dismiss.test.ts
@@ -0,0 +1,67 @@
+/**
+ * External dependencies
+ */
+import { renderHook, act } from '@testing-library/react-hooks';
+import apiFetch from '@wordpress/api-fetch';
+
+/**
+ * Internal dependencies
+ */
+import { useEndpointDismiss } from '../use-endpoint-dismiss';
+import { createNoticesFromResponse } from '../../lib/notices';
+
+jest.mock( '@wordpress/api-fetch' );
+jest.mock( '../../lib/notices', () => ( {
+	createNoticesFromResponse: jest.fn(),
+} ) );
+
+const PATH = '/wc-admin/tax/recommendations/dismiss';
+
+const mockApiFetch = apiFetch as unknown as jest.Mock;
+
+describe( 'useEndpointDismiss', () => {
+	beforeEach( () => {
+		jest.clearAllMocks();
+	} );
+
+	it( 'honours the initial dismissal state', () => {
+		const { result } = renderHook( () => useEndpointDismiss( PATH, true ) );
+
+		expect( result.current.isDismissed ).toBe( true );
+	} );
+
+	it( 'optimistically dismisses and POSTs to the endpoint', async () => {
+		mockApiFetch.mockResolvedValueOnce( { dismissed: true } );
+
+		const { result } = renderHook( () =>
+			useEndpointDismiss( PATH, false )
+		);
+
+		await act( async () => {
+			result.current.onDismiss();
+		} );
+
+		expect( result.current.isDismissed ).toBe( true );
+		expect( mockApiFetch ).toHaveBeenCalledWith( {
+			path: PATH,
+			method: 'POST',
+		} );
+		expect( createNoticesFromResponse ).not.toHaveBeenCalled();
+	} );
+
+	it( 'rolls back and surfaces a notice when the request fails', async () => {
+		const error = { code: 'fail', message: 'Nope' };
+		mockApiFetch.mockRejectedValueOnce( error );
+
+		const { result } = renderHook( () =>
+			useEndpointDismiss( PATH, false )
+		);
+
+		await act( async () => {
+			result.current.onDismiss();
+		} );
+
+		expect( result.current.isDismissed ).toBe( false );
+		expect( createNoticesFromResponse ).toHaveBeenCalledWith( error );
+	} );
+} );
diff --git a/plugins/woocommerce/client/admin/client/hooks/test/use-option-dismiss.test.ts b/plugins/woocommerce/client/admin/client/hooks/test/use-option-dismiss.test.ts
new file mode 100644
index 00000000000..d1a1062c239
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/hooks/test/use-option-dismiss.test.ts
@@ -0,0 +1,80 @@
+/**
+ * External dependencies
+ */
+import { renderHook, act } from '@testing-library/react-hooks';
+import { useDispatch, useSelect } from '@wordpress/data';
+
+/**
+ * Internal dependencies
+ */
+import { useOptionDismiss } from '../use-option-dismiss';
+
+jest.mock( '@wordpress/data', () => ( {
+	...jest.requireActual( '@wordpress/data' ),
+	useSelect: jest.fn(),
+	useDispatch: jest.fn(),
+} ) );
+
+const OPTION_NAME = 'woocommerce_test_recommendations_hidden';
+
+const mockSelect = ( {
+	option,
+	hasResolved,
+}: {
+	option: string | boolean;
+	hasResolved: boolean;
+} ) => {
+	( useSelect as jest.Mock ).mockImplementation( ( fn ) =>
+		fn( () => ( {
+			getOption: () => option,
+			hasFinishedResolution: () => hasResolved,
+		} ) )
+	);
+};
+
+describe( 'useOptionDismiss', () => {
+	let updateOptions: jest.Mock;
+
+	beforeEach( () => {
+		updateOptions = jest.fn();
+		( useDispatch as jest.Mock ).mockReturnValue( { updateOptions } );
+	} );
+
+	it( 'treats an unresolved option as dismissed to avoid flashing the card', () => {
+		mockSelect( { option: false, hasResolved: false } );
+
+		const { result } = renderHook( () => useOptionDismiss( OPTION_NAME ) );
+
+		expect( result.current.isDismissed ).toBe( true );
+	} );
+
+	it( 'is dismissed when the resolved option is "yes"', () => {
+		mockSelect( { option: 'yes', hasResolved: true } );
+
+		const { result } = renderHook( () => useOptionDismiss( OPTION_NAME ) );
+
+		expect( result.current.isDismissed ).toBe( true );
+	} );
+
+	it( 'is not dismissed when the resolved option is not "yes"', () => {
+		mockSelect( { option: false, hasResolved: true } );
+
+		const { result } = renderHook( () => useOptionDismiss( OPTION_NAME ) );
+
+		expect( result.current.isDismissed ).toBe( false );
+	} );
+
+	it( 'persists the dismissal through updateOptions', () => {
+		mockSelect( { option: false, hasResolved: true } );
+
+		const { result } = renderHook( () => useOptionDismiss( OPTION_NAME ) );
+
+		act( () => {
+			result.current.onDismiss();
+		} );
+
+		expect( updateOptions ).toHaveBeenCalledWith( {
+			[ OPTION_NAME ]: 'yes',
+		} );
+	} );
+} );
diff --git a/plugins/woocommerce/client/admin/client/hooks/use-endpoint-dismiss.ts b/plugins/woocommerce/client/admin/client/hooks/use-endpoint-dismiss.ts
new file mode 100644
index 00000000000..54d6b5c6cd4
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/hooks/use-endpoint-dismiss.ts
@@ -0,0 +1,47 @@
+/**
+ * External dependencies
+ */
+import apiFetch from '@wordpress/api-fetch';
+import { useState } from '@wordpress/element';
+
+/**
+ * Internal dependencies
+ */
+import { createNoticesFromResponse } from '../lib/notices';
+import type { DismissState } from './use-option-dismiss';
+
+/**
+ * Dismissal state persisted through a dedicated REST endpoint.
+ *
+ * Used when the dismissal cannot ride the (frozen) options REST API and instead
+ * has a purpose-built endpoint. The initial state is supplied by the caller
+ * (typically preloaded into wc-admin settings) so the card renders the correct
+ * visibility without an extra request.
+ *
+ * The dismissal is optimistic: the card hides immediately, then the endpoint is
+ * called. If the request fails the card is restored and an error notice is
+ * surfaced so the failure is not silent.
+ *
+ * @param path    The REST path to POST to in order to persist the dismissal.
+ * @param initial The initial dismissal state (e.g. from preloaded settings).
+ * @return The current dismissal state and a callback to dismiss.
+ */
+export const useEndpointDismiss = (
+	path: string,
+	initial: boolean
+): DismissState => {
+	const [ isDismissed, setIsDismissed ] = useState< boolean >( initial );
+
+	const onDismiss = () => {
+		// Optimistically hide the card, then persist the dismissal site-wide.
+		setIsDismissed( true );
+		apiFetch( { path, method: 'POST' } ).catch( ( response ) => {
+			// Restore the card and surface the failure so the state stays
+			// accurate and the merchant is not left without feedback.
+			setIsDismissed( false );
+			createNoticesFromResponse( response );
+		} );
+	};
+
+	return { isDismissed, onDismiss };
+};
diff --git a/plugins/woocommerce/client/admin/client/hooks/use-option-dismiss.ts b/plugins/woocommerce/client/admin/client/hooks/use-option-dismiss.ts
new file mode 100644
index 00000000000..8880370d859
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/hooks/use-option-dismiss.ts
@@ -0,0 +1,52 @@
+/**
+ * External dependencies
+ */
+import { useDispatch, useSelect } from '@wordpress/data';
+import { optionsStore } from '@woocommerce/data';
+
+export type DismissState = {
+	isDismissed: boolean;
+	onDismiss: () => void;
+};
+
+/**
+ * Dismissal state backed by a WordPress option (via the wc-admin options store).
+ *
+ * The option is treated as a "yes"/"no" flag: a value of `'yes'` means the
+ * related card has been dismissed. While the option is still resolving the card
+ * is treated as dismissed so it does not flash before we know its real state.
+ *
+ * @param optionName The option name used to persist the dismissal.
+ * @return The current dismissal state and a callback to dismiss.
+ */
+export const useOptionDismiss = ( optionName: string ): DismissState => {
+	const isDismissed = useSelect(
+		( select ) => {
+			const { getOption, hasFinishedResolution } = select( optionsStore );
+
+			// Read the option first so the resolver is always triggered. Calling
+			// `getOption` is what kicks off the fetch; gating it behind the
+			// resolution check (e.g. via `||` short-circuit) would mean it is
+			// never called while unresolved, so resolution would never start and
+			// the card would stay hidden forever.
+			const value = getOption( optionName );
+
+			const hasResolved = hasFinishedResolution( 'getOption', [
+				optionName,
+			] );
+
+			// Treat "not yet resolved" as dismissed so the card does not flash
+			// before the option value is known.
+			return ! hasResolved || value === 'yes';
+		},
+		[ optionName ]
+	);
+
+	const { updateOptions } = useDispatch( optionsStore );
+
+	const onDismiss = () => {
+		updateOptions( { [ optionName ]: 'yes' } );
+	};
+
+	return { isDismissed, onDismiss };
+};
diff --git a/plugins/woocommerce/client/admin/client/settings-recommendations/dismissable-list.tsx b/plugins/woocommerce/client/admin/client/settings-recommendations/dismissable-list.tsx
index a0c98c2967c..b883d1e5b6f 100644
--- a/plugins/woocommerce/client/admin/client/settings-recommendations/dismissable-list.tsx
+++ b/plugins/woocommerce/client/admin/client/settings-recommendations/dismissable-list.tsx
@@ -1,12 +1,11 @@
 /**
  * External dependencies
  */
-import { useDispatch, useSelect } from '@wordpress/data';
 import { Button, Card, CardHeader } from '@wordpress/components';
-import { optionsStore } from '@woocommerce/data';
+import { useEffect, useRef } from '@wordpress/element';
+import { speak } from '@wordpress/a11y';
 import { EllipsisMenu } from '@woocommerce/components';
 import { __ } from '@wordpress/i18n';
-import { createContext, useContext } from '@wordpress/element';
 import clsx from 'clsx';

 /**
@@ -14,9 +13,14 @@ import clsx from 'clsx';
  */
 import './dismissable-list.scss';

-// using a context provider for the option name so that the option name prop doesn't need to be passed to the `DismissableListHeading` too
-const OptionNameContext = createContext( '' );
-
+/**
+ * Presentational heading for a {@link DismissableList}. Renders the card header
+ * and an ellipsis menu with a "Hide this" action that invokes `onDismiss`.
+ *
+ * Persistence is intentionally not handled here — the parent owns the dismissal
+ * state and supplies `onDismiss` (typically from a dismiss hook such as
+ * `useOptionDismiss` or `useEndpointDismiss`).
+ */
 export const DismissableListHeading = ( {
 	onDismiss = () => null,
 	children,
@@ -24,16 +28,6 @@ export const DismissableListHeading = ( {
 	children: React.ReactNode;
 	onDismiss?: () => void;
 } ) => {
-	const { updateOptions } = useDispatch( optionsStore );
-	const dismissOptionName = useContext( OptionNameContext );
-
-	const handleDismissClick = () => {
-		onDismiss();
-		updateOptions( {
-			[ dismissOptionName ]: 'yes',
-		} );
-	};
-
 	return (
 		<CardHeader>
 			<div className="woocommerce-dismissable-list__header">
@@ -44,7 +38,7 @@ export const DismissableListHeading = ( {
 					label={ __( 'Task List Options', 'woocommerce' ) }
 					renderContent={ () => (
 						<div className="woocommerce-dismissable-list__controls">
-							<Button onClick={ handleDismissClick }>
+							<Button onClick={ onDismiss }>
 								{ __( 'Hide this', 'woocommerce' ) }
 							</Button>
 						</div>
@@ -55,42 +49,65 @@ export const DismissableListHeading = ( {
 	);
 };

+/**
+ * Pure UI wrapper for a dismissable recommendation card. Hides the `Card` when
+ * `isDismissed` is true, otherwise wraps `children` in a `Card`.
+ *
+ * This component holds no persistence logic. Callers manage the dismissal state
+ * with a hook and pass the resulting `isDismissed` here and `onDismiss` to the
+ * nested {@link DismissableListHeading}.
+ *
+ * Dismissing the card unmounts whatever element held focus (the ellipsis menu
+ * button or its popover), which would otherwise drop keyboard and screen reader
+ * users onto `document.body` with no announcement. To keep them oriented, the
+ * surrounding wrapper stays mounted as a focus target: on dismissal we move
+ * focus to it and announce the change with `speak()`.
+ */
 export const DismissableList = ( {
 	children,
 	className,
-	dismissOptionName,
+	isDismissed,
 }: {
 	children: React.ReactNode;
 	className?: string;
-	dismissOptionName: string;
+	/**
+	 * Whether the card has been dismissed. When true the card is hidden.
+	 */
+	isDismissed?: boolean;
 } ) => {
-	const isVisible = useSelect(
-		( select ) => {
-			const { getOption, hasFinishedResolution } = select( optionsStore );
-
-			const hasFinishedResolving = hasFinishedResolution( 'getOption', [
-				dismissOptionName,
-			] );
-
-			const isDismissed = getOption( dismissOptionName ) === 'yes';
+	const wrapperRef = useRef< HTMLDivElement >( null );
+	// Seed with the initial value so an already-dismissed card on first render
+	// is treated as steady state, not a fresh dismissal.
+	const wasDismissed = useRef( isDismissed );

-			return hasFinishedResolving && ! isDismissed;
-		},
-		[ dismissOptionName ]
-	);
+	useEffect( () => {
+		if ( isDismissed && ! wasDismissed.current ) {
+			speak( __( 'Recommendation hidden.', 'woocommerce' ), 'assertive' );
+			wrapperRef.current?.focus();
+		}

-	if ( ! isVisible ) {
-		return null;
-	}
+		wasDismissed.current = isDismissed;
+	}, [ isDismissed ] );

 	return (
-		<Card
-			size="medium"
-			className={ clsx( 'woocommerce-dismissable-list', className ) }
+		<div
+			ref={ wrapperRef }
+			// Programmatically focusable (not in the tab order) so focus can
+			// land here once the card unmounts on dismissal.
+			tabIndex={ -1 }
+			className="woocommerce-dismissable-list__wrapper"
 		>
-			<OptionNameContext.Provider value={ dismissOptionName }>
-				{ children }
-			</OptionNameContext.Provider>
-		</Card>
+			{ ! isDismissed && (
+				<Card
+					size="medium"
+					className={ clsx(
+						'woocommerce-dismissable-list',
+						className
+					) }
+				>
+					{ children }
+				</Card>
+			) }
+		</div>
 	);
 };
diff --git a/plugins/woocommerce/client/admin/client/settings-recommendations/test/dismissable-list.test.js b/plugins/woocommerce/client/admin/client/settings-recommendations/test/dismissable-list.test.js
index c3611c278ca..36562d287a7 100644
--- a/plugins/woocommerce/client/admin/client/settings-recommendations/test/dismissable-list.test.js
+++ b/plugins/woocommerce/client/admin/client/settings-recommendations/test/dismissable-list.test.js
@@ -2,98 +2,133 @@
  * External dependencies
  */
 import { render, screen } from '@testing-library/react';
-import { useDispatch, useSelect } from '@wordpress/data';
 import userEvent from '@testing-library/user-event';
+import { speak } from '@wordpress/a11y';

 /**
  * Internal dependencies
  */
 import { DismissableList, DismissableListHeading } from '../dismissable-list';

-jest.mock( '@wordpress/data', () => ( {
-	...jest.requireActual( '@wordpress/data' ),
-	useSelect: jest.fn(),
-	useDispatch: jest.fn(),
+jest.mock( '@wordpress/a11y', () => ( {
+	speak: jest.fn(),
 } ) );

-const DismissableListMock = ( { children } ) => (
-	<DismissableList dismissOptionName="dismissable_option_mock">
-		{ children }
-		<span>dismissible children</span>
-	</DismissableList>
-);
-
 describe( 'DismissableList', () => {
-	beforeEach( () => {
-		useSelect.mockImplementation( ( fn ) =>
-			fn( () => ( {
-				getOption: () => false,
-				hasFinishedResolution: () => true,
-			} ) )
+	it( 'renders its children when isDismissed is false', () => {
+		render(
+			<DismissableList isDismissed={ false }>
+				<span>dismissible children</span>
+			</DismissableList>
 		);
-		useDispatch.mockReturnValue( { updateOptions: () => null } );
+
+		expect(
+			screen.queryByText( 'dismissible children' )
+		).toBeInTheDocument();
 	} );

-	it( 'should not render its children when the option is not resolved', () => {
-		useSelect.mockImplementation( ( fn ) =>
-			fn( () => ( {
-				getOption: () => false,
-				hasFinishedResolution: () => false,
-			} ) )
+	it( 'renders its children when isDismissed is omitted', () => {
+		render(
+			<DismissableList>
+				<span>dismissible children</span>
+			</DismissableList>
 		);
-		render( <DismissableListMock /> );

 		expect(
 			screen.queryByText( 'dismissible children' )
-		).not.toBeInTheDocument();
+		).toBeInTheDocument();
 	} );

-	it( 'should not render its children when the option is dismissed', () => {
-		useSelect.mockImplementation( ( fn ) =>
-			fn( () => ( {
-				getOption: () => 'yes',
-				hasFinishedResolution: () => true,
-			} ) )
+	it( 'renders nothing when isDismissed is true', () => {
+		render(
+			<DismissableList isDismissed={ true }>
+				<span>dismissible children</span>
+			</DismissableList>
 		);
-		render( <DismissableListMock /> );

 		expect(
 			screen.queryByText( 'dismissible children' )
 		).not.toBeInTheDocument();
 	} );

-	it( 'render its children', () => {
-		render( <DismissableListMock /> );
+	describe( 'dismissal accessibility', () => {
+		beforeEach( () => {
+			speak.mockClear();
+		} );

-		expect(
-			screen.queryByText( 'dismissible children' )
-		).toBeInTheDocument();
+		it( 'announces and moves focus to a stable element when dismissed', () => {
+			const { container, rerender } = render(
+				<DismissableList isDismissed={ false }>
+					<span>dismissible children</span>
+				</DismissableList>
+			);
+
+			const wrapper = container.querySelector(
+				'.woocommerce-dismissable-list__wrapper'
+			);
+
+			rerender(
+				<DismissableList isDismissed={ true }>
+					<span>dismissible children</span>
+				</DismissableList>
+			);
+
+			expect( speak ).toHaveBeenCalledWith(
+				'Recommendation hidden.',
+				'assertive'
+			);
+			// The wrapper stays mounted so focus has somewhere to land.
+			expect( wrapper ).toBeInTheDocument();
+			expect( wrapper ).toHaveFocus();
+		} );
+
+		it( 'does not announce or steal focus when dismissed on first render', () => {
+			render(
+				<DismissableList isDismissed={ true }>
+					<span>dismissible children</span>
+				</DismissableList>
+			);
+
+			expect( speak ).not.toHaveBeenCalled();
+			expect( document.body ).toHaveFocus();
+		} );
 	} );
+} );

-	it( 'should allow dismissing the option through the DismissableListHeading component', () => {
-		const handleDismissMock = jest.fn();
-		const updateOptionsMock = jest.fn();
-		useDispatch.mockReturnValue( { updateOptions: updateOptionsMock } );
+describe( 'DismissableListHeading', () => {
+	it( 'renders its children', () => {
 		render(
-			<DismissableListMock>
-				<DismissableListHeading onDismiss={ handleDismissMock }>
-					heading content mock
-				</DismissableListHeading>
-			</DismissableListMock>
+			<DismissableListHeading>
+				<span>heading content</span>
+			</DismissableListHeading>
 		);

-		expect(
-			screen.queryByText( 'heading content mock' )
-		).toBeInTheDocument();
+		expect( screen.queryByText( 'heading content' ) ).toBeInTheDocument();
+	} );
+
+	it( 'calls onDismiss when "Hide this" is clicked', () => {
+		const onDismiss = jest.fn();
+		render(
+			<DismissableListHeading onDismiss={ onDismiss }>
+				heading content
+			</DismissableListHeading>
+		);

 		userEvent.click( screen.getByTitle( 'Task List Options' ) );
 		userEvent.click( screen.getByText( 'Hide this' ) );

-		expect( handleDismissMock ).toHaveBeenCalled();
-		expect( updateOptionsMock ).toHaveBeenCalledWith(
-			expect.objectContaining( {
-				dismissable_option_mock: 'yes',
-			} )
+		expect( onDismiss ).toHaveBeenCalledTimes( 1 );
+	} );
+
+	it( 'does not throw when "Hide this" is clicked without an onDismiss prop', () => {
+		render(
+			<DismissableListHeading>heading content</DismissableListHeading>
 		);
+
+		userEvent.click( screen.getByTitle( 'Task List Options' ) );
+
+		expect( () =>
+			userEvent.click( screen.getByText( 'Hide this' ) )
+		).not.toThrow();
 	} );
 } );
diff --git a/plugins/woocommerce/client/admin/client/shipping/shipping-recommendations.tsx b/plugins/woocommerce/client/admin/client/shipping/shipping-recommendations.tsx
index ae4d002e05d..9900ada1d6b 100644
--- a/plugins/woocommerce/client/admin/client/shipping/shipping-recommendations.tsx
+++ b/plugins/woocommerce/client/admin/client/shipping/shipping-recommendations.tsx
@@ -20,6 +20,7 @@ import {
 import WoocommerceShippingItem from './woocommerce-shipping-item';
 import './shipping-recommendations.scss';
 import { TrackedLink } from '~/components/tracked-link/tracked-link';
+import { useOptionDismiss } from '~/hooks/use-option-dismiss';

 export const useInstallPlugin = () => {
 	const [ pluginsBeingSetup, setPluginsBeingSetup ] = useState<
@@ -98,49 +99,55 @@ export const ShippingRecommendationsList = ( {
 	children,
 }: {
 	children: React.ReactNode;
-} ) => (
-	<DismissableList
-		className="woocommerce-recommended-shipping-extensions"
-		dismissOptionName="woocommerce_settings_shipping_recommendations_hidden"
-	>
-		<DismissableListHeading>
-			<Text variant="title.small" as="p" size="20" lineHeight="28px">
-				{ __( 'Recommended shipping solutions', 'woocommerce' ) }
-			</Text>
-			<Text
-				className="woocommerce-recommended-shipping__header-heading"
-				variant="caption"
-				as="p"
-				size="12"
-				lineHeight="16px"
-			>
-				{ __(
-					'We recommend adding one of the following shipping extensions to your store.',
-					'woocommerce'
-				) }
-			</Text>
-		</DismissableListHeading>
-		<ul className="woocommerce-list">
-			{ Children.map( children, ( item ) => (
-				<li className="woocommerce-list__item">{ item }</li>
-			) ) }
-		</ul>
-		<CardFooter>
-			<TrackedLink
-				message={ __(
-					// translators: {{Link}} is a placeholder for a html element.
-					'Visit {{Link}}the WooCommerce Marketplace{{/Link}} to find more shipping, delivery, and fulfillment solutions.',
-					'woocommerce'
-				) }
-				targetUrl={ getAdminLink(
-					'admin.php?page=wc-admin&tab=extensions&path=/extensions&category=shipping-delivery-and-fulfillment'
-				) }
-				linkType="wc-admin"
-				eventName="settings_shipping_recommendation_visit_marketplace_click"
-			/>
-		</CardFooter>
-	</DismissableList>
-);
+} ) => {
+	const { isDismissed, onDismiss } = useOptionDismiss(
+		'woocommerce_settings_shipping_recommendations_hidden'
+	);
+
+	return (
+		<DismissableList
+			className="woocommerce-recommended-shipping-extensions"
+			isDismissed={ isDismissed }
+		>
+			<DismissableListHeading onDismiss={ onDismiss }>
+				<Text variant="title.small" as="p" size="20" lineHeight="28px">
+					{ __( 'Recommended shipping solutions', 'woocommerce' ) }
+				</Text>
+				<Text
+					className="woocommerce-recommended-shipping__header-heading"
+					variant="caption"
+					as="p"
+					size="12"
+					lineHeight="16px"
+				>
+					{ __(
+						'We recommend adding one of the following shipping extensions to your store.',
+						'woocommerce'
+					) }
+				</Text>
+			</DismissableListHeading>
+			<ul className="woocommerce-list">
+				{ Children.map( children, ( item ) => (
+					<li className="woocommerce-list__item">{ item }</li>
+				) ) }
+			</ul>
+			<CardFooter>
+				<TrackedLink
+					message={ __(
+						// translators: {{Link}} is a placeholder for a html element.
+						'Visit {{Link}}the WooCommerce Marketplace{{/Link}} to find more shipping, delivery, and fulfillment solutions.',
+						'woocommerce'
+					) }
+					targetUrl={ getAdminLink(
+						'admin.php?page=wc-admin&tab=extensions&path=/extensions&category=shipping-delivery-and-fulfillment'
+					) }
+					linkType="wc-admin"
+					eventName="settings_shipping_recommendation_visit_marketplace_click"
+				/>
+			</CardFooter>
+		</DismissableList>
+	);
+};

 const ShippingRecommendations = () => {
 	const [ pluginsBeingSetup, setupPlugin ] = useInstallPlugin();
diff --git a/plugins/woocommerce/client/admin/client/shipping/test/shipping-recommendations.test.js b/plugins/woocommerce/client/admin/client/shipping/test/shipping-recommendations.test.js
index 8b557c61da8..b7ec962e7f7 100644
--- a/plugins/woocommerce/client/admin/client/shipping/test/shipping-recommendations.test.js
+++ b/plugins/woocommerce/client/admin/client/shipping/test/shipping-recommendations.test.js
@@ -22,6 +22,9 @@ jest.mock( '../../settings-recommendations/dismissable-list', () => ( {
 jest.mock( '../../lib/notices', () => ( {
 	createNoticesFromResponse: () => null,
 } ) );
+jest.mock( '~/hooks/use-option-dismiss', () => ( {
+	useOptionDismiss: () => ( { isDismissed: false, onDismiss: jest.fn() } ),
+} ) );

 describe( 'ShippingRecommendations', () => {
 	beforeEach( () => {
diff --git a/plugins/woocommerce/client/admin/client/tax/tax-recommendations.tsx b/plugins/woocommerce/client/admin/client/tax/tax-recommendations.tsx
index ec01f0e180d..6acb5830f68 100644
--- a/plugins/woocommerce/client/admin/client/tax/tax-recommendations.tsx
+++ b/plugins/woocommerce/client/admin/client/tax/tax-recommendations.tsx
@@ -22,6 +22,8 @@ import {
 import { supportsWooCommerceTax } from '../task-lists/fills/tax/utils';
 import { TrackedLink } from '~/components/tracked-link/tracked-link';
 import { getCountryCode } from '~/dashboard/utils';
+import { getAdminSetting } from '~/utils/admin-settings';
+import { useEndpointDismiss } from '~/hooks/use-endpoint-dismiss';
 import './tax-recommendations.scss';

 const ANROK_LOGO_URL = 'https://ps.w.org/anrok-tax/assets/icon.svg';
@@ -207,49 +209,56 @@ const TaxRecommendationsList = ( {
 	children,
 }: {
 	children: React.ReactNode;
-} ) => (
-	<DismissableList
-		className="woocommerce-recommended-tax-extensions"
-		dismissOptionName="woocommerce_settings_tax_recommendations_hidden"
-	>
-		<DismissableListHeading>
-			<Text variant="title.small" as="p" size="20" lineHeight="28px">
-				{ __( 'Recommended tax solutions', 'woocommerce' ) }
-			</Text>
-			<Text
-				className="woocommerce-recommended-tax__header-heading"
-				variant="caption"
-				as="p"
-				size="12"
-				lineHeight="16px"
-			>
-				{ __(
-					'Explore tax extensions that can help automate calculations and compliance for your store.',
-					'woocommerce'
-				) }
-			</Text>
-		</DismissableListHeading>
-		<ul className="woocommerce-list">
-			{ Children.map( children, ( item ) => (
-				<li className="woocommerce-list__item">{ item }</li>
-			) ) }
-		</ul>
-		<CardFooter>
-			<TrackedLink
-				message={ __(
-					// translators: {{Link}} is a placeholder for a html element.
-					'Visit {{Link}}the WooCommerce Marketplace{{/Link}} to find more tax solutions.',
-					'woocommerce'
-				) }
-				targetUrl={ getAdminLink(
-					'admin.php?page=wc-admin&tab=extensions&path=/extensions&category=operations'
-				) }
-				linkType="wc-admin"
-				eventName="settings_tax_recommendation_visit_marketplace_click"
-			/>
-		</CardFooter>
-	</DismissableList>
-);
+} ) => {
+	const { isDismissed, onDismiss } = useEndpointDismiss(
+		'/wc-admin/tax/recommendations/dismiss',
+		getAdminSetting( 'taxRecommendationsHidden', false )
+	);
+
+	return (
+		<DismissableList
+			className="woocommerce-recommended-tax-extensions"
+			isDismissed={ isDismissed }
+		>
+			<DismissableListHeading onDismiss={ onDismiss }>
+				<Text variant="title.small" as="p" size="20" lineHeight="28px">
+					{ __( 'Recommended tax solutions', 'woocommerce' ) }
+				</Text>
+				<Text
+					className="woocommerce-recommended-tax__header-heading"
+					variant="caption"
+					as="p"
+					size="12"
+					lineHeight="16px"
+				>
+					{ __(
+						'Explore tax extensions that can help automate calculations and compliance for your store.',
+						'woocommerce'
+					) }
+				</Text>
+			</DismissableListHeading>
+			<ul className="woocommerce-list">
+				{ Children.map( children, ( item ) => (
+					<li className="woocommerce-list__item">{ item }</li>
+				) ) }
+			</ul>
+			<CardFooter>
+				<TrackedLink
+					message={ __(
+						// translators: {{Link}} is a placeholder for a html element.
+						'Visit {{Link}}the WooCommerce Marketplace{{/Link}} to find more tax solutions.',
+						'woocommerce'
+					) }
+					targetUrl={ getAdminLink(
+						'admin.php?page=wc-admin&tab=extensions&path=/extensions&category=operations'
+					) }
+					linkType="wc-admin"
+					eventName="settings_tax_recommendation_visit_marketplace_click"
+				/>
+			</CardFooter>
+		</DismissableList>
+	);
+};

 const getPluginSlugForAction = (
 	pluginSlugs: string[],
diff --git a/plugins/woocommerce/client/admin/client/tax/test/tax-recommendations.test.tsx b/plugins/woocommerce/client/admin/client/tax/test/tax-recommendations.test.tsx
index 503940ec45c..ec43838834e 100644
--- a/plugins/woocommerce/client/admin/client/tax/test/tax-recommendations.test.tsx
+++ b/plugins/woocommerce/client/admin/client/tax/test/tax-recommendations.test.tsx
@@ -34,6 +34,10 @@ jest.mock( '../../lib/notices', () => ( {
 	createNoticesFromResponse: () => null,
 } ) );

+jest.mock( '~/hooks/use-endpoint-dismiss', () => ( {
+	useEndpointDismiss: () => ( { isDismissed: false, onDismiss: jest.fn() } ),
+} ) );
+
 const clickAndFlush = async ( element: Element ) => {
 	await act( async () => {
 		await userEvent.click( element );
diff --git a/plugins/woocommerce/src/Internal/Admin/Loader.php b/plugins/woocommerce/src/Internal/Admin/Loader.php
index c17bf28dfe8..69eddadabd1 100644
--- a/plugins/woocommerce/src/Internal/Admin/Loader.php
+++ b/plugins/woocommerce/src/Internal/Admin/Loader.php
@@ -70,6 +70,7 @@ class Loader {
 		SiteHealth::get_instance();
 		SystemStatusReport::get_instance();

+		wc_get_container()->get( TaxSettingsRecommendations::class );
 		wc_get_container()->get( Reviews::class );
 		wc_get_container()->get( ReviewsCommentsOverrides::class );

diff --git a/plugins/woocommerce/src/Internal/Admin/TaxSettingsRecommendations.php b/plugins/woocommerce/src/Internal/Admin/TaxSettingsRecommendations.php
new file mode 100644
index 00000000000..182d168fd88
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Admin/TaxSettingsRecommendations.php
@@ -0,0 +1,115 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Internal\Admin;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * TaxSettingsRecommendations Class.
+ *
+ * Handles dismissal of the recommended tax solutions card on the Tax settings
+ * screen. The dismissal is stored site-wide in the
+ * `woocommerce_settings_tax_recommendations_hidden` option, but is read and
+ * written through a dedicated REST endpoint instead of the deprecated Options
+ * REST API (whose allowlist is frozen). This keeps the dismissal working in all
+ * environments, including non-production.
+ *
+ * @internal
+ */
+class TaxSettingsRecommendations {
+
+	/**
+	 * Option name used to persist the dismissal.
+	 *
+	 * @var string
+	 */
+	const DISMISSED_OPTION_NAME = 'woocommerce_settings_tax_recommendations_hidden';
+
+	/**
+	 * Class initialization, to be executed when the class is resolved by the container.
+	 *
+	 * @internal
+	 */
+	final public function init(): void {
+		add_action( 'rest_api_init', array( $this, 'register_routes' ) );
+		add_filter( 'woocommerce_admin_shared_settings', array( $this, 'preload_settings' ) );
+	}
+
+	/**
+	 * Register the REST route used to dismiss the recommended tax solutions card.
+	 */
+	public function register_routes(): void {
+		register_rest_route(
+			'wc-admin',
+			'/tax/recommendations/dismiss',
+			array(
+				array(
+					'methods'             => \WP_REST_Server::CREATABLE,
+					'callback'            => array( $this, 'dismiss' ),
+					'permission_callback' => array( $this, 'permissions_check' ),
+				),
+			)
+		);
+	}
+
+	/**
+	 * Check whether the current user can dismiss the recommendations.
+	 *
+	 * @param \WP_REST_Request<array<string, mixed>> $request Full details about the request.
+	 * @return bool|\WP_Error
+	 */
+	public function permissions_check( \WP_REST_Request $request ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found, Squiz.Commenting.FunctionComment.IncorrectTypeHint
+		if ( ! current_user_can( 'manage_woocommerce' ) ) {
+			return new \WP_Error(
+				'woocommerce_rest_cannot_edit',
+				__( 'Sorry, you are not allowed to dismiss these recommendations.', 'woocommerce' ),
+				array( 'status' => rest_authorization_required_code() )
+			);
+		}
+
+		return true;
+	}
+
+	/**
+	 * Persist the dismissal of the recommended tax solutions card.
+	 *
+	 * @param \WP_REST_Request<array<string, mixed>> $request Full details about the request.
+	 * @return \WP_REST_Response|\WP_Error
+	 */
+	public function dismiss( \WP_REST_Request $request ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found, Squiz.Commenting.FunctionComment.IncorrectTypeHint
+		// Already dismissed: update_option() returns false when the value is
+		// unchanged, which is not a failure, so report success directly.
+		if ( 'yes' === get_option( self::DISMISSED_OPTION_NAME ) ) {
+			return new \WP_REST_Response( array( 'dismissed' => true ), 200 );
+		}
+
+		if ( ! update_option( self::DISMISSED_OPTION_NAME, 'yes' ) ) {
+			return new \WP_Error(
+				'woocommerce_rest_tax_recommendations_dismiss_failed',
+				__( 'The dismissal could not be saved.', 'woocommerce' ),
+				array( 'status' => 500 )
+			);
+		}
+
+		return new \WP_REST_Response( array( 'dismissed' => true ), 200 );
+	}
+
+	/**
+	 * Preload the dismissal state into the wc-admin settings so the client can
+	 * render the correct initial visibility without an extra request.
+	 *
+	 * @param array $settings Shared settings.
+	 * @return array
+	 */
+	public function preload_settings( $settings ) {
+		if ( ! is_array( $settings ) ) {
+			return $settings;
+		}
+
+		$settings['taxRecommendationsHidden'] = 'yes' === get_option( self::DISMISSED_OPTION_NAME );
+
+		return $settings;
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Admin/TaxSettingsRecommendationsTest.php b/plugins/woocommerce/tests/php/src/Internal/Admin/TaxSettingsRecommendationsTest.php
new file mode 100644
index 00000000000..136be2d271c
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Admin/TaxSettingsRecommendationsTest.php
@@ -0,0 +1,123 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\Admin;
+
+use Automattic\WooCommerce\Internal\Admin\TaxSettingsRecommendations;
+use WC_Unit_Test_Case;
+use WP_REST_Request;
+
+/**
+ * Tests for the TaxSettingsRecommendations class.
+ */
+class TaxSettingsRecommendationsTest extends WC_Unit_Test_Case {
+
+	/**
+	 * The System Under Test.
+	 *
+	 * @var TaxSettingsRecommendations
+	 */
+	private $sut;
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		$this->sut = new TaxSettingsRecommendations();
+		$this->sut->init();
+
+		// Routes must register on rest_api_init; firing it runs init()'s callback.
+		// phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
+		do_action( 'rest_api_init' );
+	}
+
+	/**
+	 * Tear down test fixtures.
+	 */
+	public function tearDown(): void {
+		delete_option( TaxSettingsRecommendations::DISMISSED_OPTION_NAME );
+		wp_set_current_user( 0 );
+		parent::tearDown();
+	}
+
+	/**
+	 * @testdox Dismissing persists the option and returns a 200 response.
+	 */
+	public function test_dismiss_persists_option_and_returns_200(): void {
+		wp_set_current_user( $this->factory->user->create( array( 'role' => 'administrator' ) ) );
+
+		$response = rest_do_request( new WP_REST_Request( 'POST', '/wc-admin/tax/recommendations/dismiss' ) );
+
+		$this->assertSame( 200, $response->get_status(), 'A valid dismissal should return HTTP 200.' );
+		$this->assertSame(
+			'yes',
+			get_option( TaxSettingsRecommendations::DISMISSED_OPTION_NAME ),
+			'The dismissal option should be persisted as "yes".'
+		);
+	}
+
+	/**
+	 * @testdox Dismissing twice still returns a 200 response.
+	 */
+	public function test_dismiss_twice_returns_200(): void {
+		wp_set_current_user( $this->factory->user->create( array( 'role' => 'administrator' ) ) );
+		update_option( TaxSettingsRecommendations::DISMISSED_OPTION_NAME, 'yes' );
+
+		$response = rest_do_request( new WP_REST_Request( 'POST', '/wc-admin/tax/recommendations/dismiss' ) );
+
+		$this->assertSame(
+			200,
+			$response->get_status(),
+			'Re-dismissing an already-dismissed card should succeed, not error.'
+		);
+	}
+
+	/**
+	 * @testdox Users without manage_woocommerce cannot dismiss.
+	 */
+	public function test_dismiss_denied_without_capability(): void {
+		wp_set_current_user( $this->factory->user->create( array( 'role' => 'subscriber' ) ) );
+
+		$response = rest_do_request( new WP_REST_Request( 'POST', '/wc-admin/tax/recommendations/dismiss' ) );
+
+		$this->assertSame(
+			rest_authorization_required_code(),
+			$response->get_status(),
+			'A user lacking manage_woocommerce should be denied.'
+		);
+		$this->assertNotSame(
+			'yes',
+			get_option( TaxSettingsRecommendations::DISMISSED_OPTION_NAME ),
+			'A denied request must not persist the dismissal.'
+		);
+	}
+
+	/**
+	 * @testdox Preload maps the stored option to the taxRecommendationsHidden boolean.
+	 */
+	public function test_preload_settings_maps_option_to_boolean(): void {
+		$this->assertFalse(
+			$this->sut->preload_settings( array() )['taxRecommendationsHidden'],
+			'An unset option should preload as false.'
+		);
+
+		update_option( TaxSettingsRecommendations::DISMISSED_OPTION_NAME, 'yes' );
+
+		$this->assertTrue(
+			$this->sut->preload_settings( array() )['taxRecommendationsHidden'],
+			'A dismissed card should preload as true.'
+		);
+	}
+
+	/**
+	 * @testdox Preload leaves non-array input untouched.
+	 */
+	public function test_preload_settings_ignores_non_array(): void {
+		$this->assertSame(
+			'unchanged',
+			$this->sut->preload_settings( 'unchanged' ),
+			'Non-array settings should be returned as-is.'
+		);
+	}
+}