Commit 679f08c186a for woocommerce

commit 679f08c186a56c18d7f860d4b2d41238f0c9c55e
Author: Alefe Souza <contact@alefesouza.com>
Date:   Wed Sep 16 17:08:10 2026 -0300

    Add a setting to preserve the cart when a shopper logs out (#68398)

diff --git a/plugins/woocommerce/changelog/wooplug-6131-preserve-cart-on-logout b/plugins/woocommerce/changelog/wooplug-6131-preserve-cart-on-logout
new file mode 100644
index 00000000000..bdbeb122a06
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooplug-6131-preserve-cart-on-logout
@@ -0,0 +1,4 @@
+Significance: minor
+Type: enhancement
+
+Add a "Cart behavior on logout" setting under Accounts & Privacy, letting merchants keep a shopper's cart when they log out instead of emptying it. New stores keep the cart; existing stores keep emptying it until a merchant changes the setting.
diff --git a/plugins/woocommerce/includes/admin/settings/class-wc-settings-accounts.php b/plugins/woocommerce/includes/admin/settings/class-wc-settings-accounts.php
index 619c4d768dd..31429ff2d17 100644
--- a/plugins/woocommerce/includes/admin/settings/class-wc-settings-accounts.php
+++ b/plugins/woocommerce/includes/admin/settings/class-wc-settings-accounts.php
@@ -13,6 +13,7 @@ if ( class_exists( 'WC_Settings_Accounts', false ) ) {

 use Automattic\WooCommerce\Blocks\Utils\CartCheckoutUtils;
 use Automattic\WooCommerce\Admin\Features\Features;
+use Automattic\WooCommerce\Enums\CartBehaviorOnLogout;

 /**
  * WC_Settings_Accounts.
@@ -142,6 +143,23 @@ class WC_Settings_Accounts extends WC_Settings_Page {
 					'disabled-tooltip' => __( 'Enable an account creation method to use this feature.', 'woocommerce' ),
 				),
 			),
+			array(
+				'title'             => __( 'Cart behavior on logout', 'woocommerce' ),
+				'desc_tip'          => __( 'Whether shoppers keep the items in their cart after logging out. Clearing the cart suits stores where devices are shared or the items themselves are sensitive. Either way, the cart is restored when the shopper logs back in.', 'woocommerce' ),
+				'id'                => 'woocommerce_cart_behavior_on_logout',
+				'default'           => CartBehaviorOnLogout::CLEAR,
+				'type'              => 'select',
+				'class'             => 'wc-enhanced-select',
+				'options'           => array(
+					CartBehaviorOnLogout::PRESERVE => __( 'Preserve cart on logout', 'woocommerce' ),
+					CartBehaviorOnLogout::CLEAR    => __( 'Clear cart on logout', 'woocommerce' ),
+				),
+				'autoload'          => false,
+				// The starting value depends on whether the store is new, so WC_Install::create_options()
+				// writes it explicitly rather than seeding this default for every store. The default here
+				// only decides what an unseeded store shows and does, and matches the runtime fallback.
+				'skip_initial_save' => true,
+			),
 			array(
 				'title'         => __( 'Account erasure requests', 'woocommerce' ),
 				'desc'          => __( 'Remove personal data from orders on request', 'woocommerce' ),
diff --git a/plugins/woocommerce/includes/class-wc-install.php b/plugins/woocommerce/includes/class-wc-install.php
index b113382eddc..190f63d6d3a 100644
--- a/plugins/woocommerce/includes/class-wc-install.php
+++ b/plugins/woocommerce/includes/class-wc-install.php
@@ -8,6 +8,7 @@

 use Automattic\Jetpack\Constants;
 use Automattic\WooCommerce\Admin\API\Reports\Orders\Stats\DataStore;
+use Automattic\WooCommerce\Enums\CartBehaviorOnLogout;
 use Automattic\WooCommerce\Enums\ProductType;
 use Automattic\WooCommerce\Internal\Admin\EmailImprovements\EmailImprovements;
 use Automattic\WooCommerce\Internal\Caches\ProductCacheController;
@@ -1277,7 +1278,21 @@ class WC_Install {
 		add_option( 'woocommerce_checkout_highlight_required_fields', 'yes', '', 'yes' );
 		add_option( 'woocommerce_demo_store', 'no', '', 'no' );

-		if ( self::is_new_install() ) {
+		$is_new_install = self::is_new_install();
+
+		// New stores keep the cart through logout; existing stores stay on the behavior they have always
+		// had, and their merchants opt in. This runs here rather than as an update callback so the value
+		// is in place before anything can read it: update callbacks are queued through Action Scheduler,
+		// which would leave a window where the setting is visible but not yet corrected. add_option() is
+		// a no-op once a value exists, so a merchant's own choice is never overwritten.
+		add_option(
+			'woocommerce_cart_behavior_on_logout',
+			$is_new_install ? CartBehaviorOnLogout::PRESERVE : CartBehaviorOnLogout::CLEAR,
+			'',
+			false
+		);
+
+		if ( $is_new_install ) {
 			// Define initial tax classes.
 			WC_Tax::create_tax_class( __( 'Reduced rate', 'woocommerce' ) );
 			WC_Tax::create_tax_class( __( 'Zero rate', 'woocommerce' ) );
diff --git a/plugins/woocommerce/includes/class-wc-session-handler.php b/plugins/woocommerce/includes/class-wc-session-handler.php
index 5c995259363..d10c1d5d986 100644
--- a/plugins/woocommerce/includes/class-wc-session-handler.php
+++ b/plugins/woocommerce/includes/class-wc-session-handler.php
@@ -92,7 +92,9 @@ class WC_Session_Handler extends WC_Session {
 		add_action( 'wp', array( $this, 'maybe_set_customer_session_cookie' ), 99 );
 		add_action( 'template_redirect', array( $this, 'destroy_session_if_empty' ), 999 );
 		add_action( 'shutdown', array( $this, 'save_data' ), 20 );
-		add_action( 'wp_logout', array( $this, 'destroy_session' ) );
+		// CartLogoutBehavior brackets this priority (5 before, 15 after) to carry the cart across the
+		// teardown. Keep the two in step if this ever moves.
+		add_action( 'wp_logout', array( $this, 'destroy_session' ), 10 );

 		if ( ! is_user_logged_in() ) {
 			add_filter( 'nonce_user_logged_out', array( $this, 'maybe_update_nonce_user_logged_out' ), 10, 2 );
diff --git a/plugins/woocommerce/includes/class-woocommerce.php b/plugins/woocommerce/includes/class-woocommerce.php
index 1571f8fbbd7..6b854ded5d1 100644
--- a/plugins/woocommerce/includes/class-woocommerce.php
+++ b/plugins/woocommerce/includes/class-woocommerce.php
@@ -438,6 +438,7 @@ final class WooCommerce {
 		$container->get( Automattic\WooCommerce\Internal\ScheduledSalePriceReconciler::class )->register();
 		$container->get( Automattic\WooCommerce\Internal\OrderWithdrawal\OrderWithdrawalController::class )->register();
 		$container->get( Automattic\WooCommerce\Internal\Admin\OrderTaxLookupMigrator::class )->register();
+		$container->get( Automattic\WooCommerce\Internal\Cart\CartLogoutBehavior::class )->register();

 		// Classes inheriting from RestApiControllerBase.
 		$container->get( Automattic\WooCommerce\Internal\ReceiptRendering\ReceiptRenderingRestController::class )->register();
diff --git a/plugins/woocommerce/src/Enums/CartBehaviorOnLogout.php b/plugins/woocommerce/src/Enums/CartBehaviorOnLogout.php
new file mode 100644
index 00000000000..d33c46bc7e8
--- /dev/null
+++ b/plugins/woocommerce/src/Enums/CartBehaviorOnLogout.php
@@ -0,0 +1,26 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Enums;
+
+/**
+ * Enum class for the possible values of the 'woocommerce_cart_behavior_on_logout' option.
+ *
+ * @since 11.2.0
+ */
+final class CartBehaviorOnLogout {
+	/**
+	 * Carry the cart items over to the guest session created when the shopper logs out.
+	 *
+	 * @var string
+	 */
+	public const PRESERVE = 'preserve';
+
+	/**
+	 * Empty the cart when the shopper logs out.
+	 *
+	 * @var string
+	 */
+	public const CLEAR = 'clear';
+}
diff --git a/plugins/woocommerce/src/Enums/README.md b/plugins/woocommerce/src/Enums/README.md
index f331ba30a6d..7c22cf0f962 100644
--- a/plugins/woocommerce/src/Enums/README.md
+++ b/plugins/woocommerce/src/Enums/README.md
@@ -6,6 +6,7 @@ The enum classes make it easier to reference string values and avoid typos. They

 ## Available Enumerators

+- [CartBehaviorOnLogout](./CartBehaviorOnLogout.php) - Enumerates the possible values of the `woocommerce_cart_behavior_on_logout` option.
 - [CatalogSortOrder](./CatalogSortOrder.php) - Enumerates the possible values of the `woocommerce_default_catalog_orderby` option.
 - [CatalogVisibility](./CatalogVisibility.php) - Enumerates the possible catalog visibility options for a product.
 - [CurrencyPosition](./CurrencyPosition.php) - Enumerates the possible values of the `woocommerce_currency_pos` option.
diff --git a/plugins/woocommerce/src/Internal/Cart/CartLogoutBehavior.php b/plugins/woocommerce/src/Internal/Cart/CartLogoutBehavior.php
new file mode 100644
index 00000000000..3abe10b2dee
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Cart/CartLogoutBehavior.php
@@ -0,0 +1,185 @@
+<?php
+/**
+ * CartLogoutBehavior class file.
+ */
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\Cart;
+
+use Automattic\WooCommerce\Enums\CartBehaviorOnLogout;
+use Automattic\WooCommerce\Internal\RegisterHooksInterface;
+use WC_Cart;
+use WC_Session;
+
+/**
+ * Carries the cart over to the guest session created when a shopper logs out.
+ *
+ * `WC_Session_Handler::destroy_session()` runs on `wp_logout` and empties the cart along with the
+ * rest of the session, so a shopper who logs out mid-shop loses what they had on that device. When
+ * the `woocommerce_cart_behavior_on_logout` option is set to 'preserve', this class takes the cart
+ * contents before that teardown and writes them into the fresh guest session afterwards.
+ *
+ * The saved cart in `_woocommerce_persistent_cart_{blog_id}` user meta is untouched either way, so
+ * logging back in still restores the cart as it always has.
+ *
+ * @internal Just for internal use.
+ *
+ * @since 11.2.0
+ */
+class CartLogoutBehavior implements RegisterHooksInterface {
+
+	/**
+	 * Cart contents captured before the session was destroyed, in the shape stored under the
+	 * session's 'cart' key. Null when there is nothing to carry over.
+	 *
+	 * @var array|null
+	 */
+	private $captured_cart = null;
+
+	/**
+	 * Register hooks and filters.
+	 */
+	public function register(): void {
+		// WC_Session_Handler::init_hooks() attaches destroy_session() to wp_logout at priority 10, so the
+		// cart has to be read before that and written back after it. Keep the two in step if either moves.
+		add_action( 'wp_logout', array( $this, 'handle_wp_logout_capture' ), 5 );
+		add_action( 'wp_logout', array( $this, 'handle_wp_logout_restore' ), 15 );
+	}
+
+	/**
+	 * Handle the wp_logout hook, before the session is destroyed, by taking a copy of the cart.
+	 *
+	 * @internal
+	 */
+	public function handle_wp_logout_capture(): void {
+		$this->captured_cart = null;
+
+		if ( ! $this->should_cart_be_preserved() ) {
+			return;
+		}
+
+		$cart = WC()->cart;
+		if ( ! $cart instanceof WC_Cart ) {
+			return;
+		}
+
+		// Nothing has been read out of the session yet, so there is no cart to carry over. Reading one
+		// now would make WC_Cart::get_cart() load the session from under a request that never wanted a
+		// cart, on top of warning that it ran too early. Logouts from wp-login.php and the My account
+		// endpoint both happen well after this fires.
+		if ( ! did_action( 'woocommerce_load_cart_from_session' ) ) {
+			return;
+		}
+
+		try {
+			$cart_for_session = $cart->get_cart_for_session();
+		} catch ( \Throwable $error ) {
+			$this->log_failure( 'read the cart before the session was destroyed', $error );
+			return;
+		}
+
+		if ( empty( $cart_for_session ) ) {
+			return;
+		}
+
+		$this->captured_cart = $cart_for_session;
+	}
+
+	/**
+	 * Handle the wp_logout hook, after the session is destroyed, by seeding the new guest session
+	 * with the captured cart.
+	 *
+	 * @internal
+	 */
+	public function handle_wp_logout_restore(): void {
+		$captured_cart       = $this->captured_cart;
+		$this->captured_cart = null;
+
+		if ( empty( $captured_cart ) ) {
+			return;
+		}
+
+		$session = WC()->session;
+		if ( ! $session instanceof WC_Session ) {
+			return;
+		}
+
+		try {
+			$session->set( 'cart', $captured_cart );
+
+			// Without a cookie for the newly generated guest customer ID the session is written but never
+			// read back on the next request. The session handler can be swapped via the
+			// 'woocommerce_session_handler' filter, so the method is not guaranteed to exist.
+			if ( method_exists( $session, 'set_customer_session_cookie' ) ) {
+				$session->set_customer_session_cookie( true );
+			}
+
+			$cart = WC()->cart;
+			if ( $cart instanceof WC_Cart ) {
+				// Rebuild the in-memory cart, which destroy_session() emptied. Skipping this would leave the
+				// request finishing with an empty cart, and the shutdown handler would clear the cart cookies
+				// that front-end caches rely on.
+				$cart->get_cart_from_session();
+			}
+
+			// Write the session now instead of leaving it to the handler's own shutdown callback, which
+			// runs at priority 20, behind the cart cookies, the customer save, the deferred product sync
+			// and the webhook queue. wp_logout ends in a redirect and an exit, so anything that fatals or
+			// exits among those would drop the cart this class exists to keep. save_data() only writes
+			// when the session is dirty and clears the flag, so the shutdown call becomes a no-op rather
+			// than a second write.
+			if ( method_exists( $session, 'save_data' ) ) {
+				$session->save_data();
+			}
+		} catch ( \Throwable $error ) {
+			$this->log_failure( 'restore the cart into the new session', $error );
+		}
+	}
+
+	/**
+	 * Record a failure that must not be allowed to escape into the logout request.
+	 *
+	 * Rebuilding the cart runs third-party code, through the product data store and the actions that
+	 * `WC_Cart_Session::get_cart_from_session()` fires, and `wp_logout` has no error boundary of its
+	 * own: an escaping error would replace the redirect with a fatal error page and leave the shopper
+	 * unable to tell whether they had logged out. Preserving a cart is never worth that, so failures
+	 * are logged and the logout is allowed to finish.
+	 *
+	 * A failure part-way through the restore still leaves the captured cart in the session, so the
+	 * next request reads it back and re-sets the cart cookies.
+	 *
+	 * @param string     $attempted What the class was trying to do, for the log message.
+	 * @param \Throwable $error     The error that was raised.
+	 */
+	private function log_failure( string $attempted, \Throwable $error ): void {
+		wc_get_logger()->error(
+			sprintf(
+				'Could not %1$s on logout: %2$s in %3$s:%4$d',
+				$attempted,
+				$error->getMessage(),
+				$error->getFile(),
+				$error->getLine()
+			),
+			array(
+				'source'    => 'cart-logout-behavior',
+				'exception' => $error,
+			)
+		);
+	}
+
+	/**
+	 * Check whether the store is configured to carry the cart over on logout.
+	 *
+	 * A missing option means the installer has not seeded a value yet, so this falls back to
+	 * clearing, which is what every store did before the setting existed. Preserving always
+	 * takes an explicit stored 'preserve'.
+	 *
+	 * @return bool True if the cart should be preserved, false if it should be emptied.
+	 */
+	private function should_cart_be_preserved(): bool {
+		$behavior = get_option( 'woocommerce_cart_behavior_on_logout', CartBehaviorOnLogout::CLEAR );
+
+		return CartBehaviorOnLogout::PRESERVE === $behavior;
+	}
+}
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-install-test.php b/plugins/woocommerce/tests/php/includes/class-wc-install-test.php
index aad6a1a9776..e4259e7ae71 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-install-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-install-test.php
@@ -903,4 +903,100 @@ class WC_Install_Test extends \WC_Unit_Test_Case {
 	private function reregister_block_patterns(): void {
 		wc_get_container()->get( \Automattic\WooCommerce\Blocks\BlockTypesController::class )->register_block_patterns();
 	}
+
+	/**
+	 * The accounts settings field must opt out of the generic default seeding in
+	 * WC_Install::create_options(), which would otherwise write the field default for every store,
+	 * new ones included, and leave a window before the correct value was applied.
+	 */
+	public function test_cart_behavior_on_logout_setting_skips_initial_save(): void {
+		$settings = ( new WC_Settings_Accounts() )->get_settings_for_section( '' );
+
+		$field = null;
+		foreach ( $settings as $setting ) {
+			if ( isset( $setting['id'] ) && 'woocommerce_cart_behavior_on_logout' === $setting['id'] ) {
+				$field = $setting;
+				break;
+			}
+		}
+
+		$this->assertNotNull( $field, 'The cart behavior on logout setting should be registered.' );
+		$this->assertTrue(
+			$field['skip_initial_save'] ?? false,
+			"The setting must set 'skip_initial_save' so create_options() does not seed a default for every store."
+		);
+	}
+
+	/**
+	 * @testWith [true, "preserve"]
+	 *           [false, "clear"]
+	 *
+	 * @param bool   $is_new_install Whether the store should look like a new install.
+	 * @param string $expected       The cart behavior the installer should write.
+	 */
+	public function test_create_options_seeds_cart_behavior_on_logout( bool $is_new_install, string $expected ): void {
+		delete_option( 'woocommerce_cart_behavior_on_logout' );
+
+		$this->run_create_options_as_install( $is_new_install );
+
+		$this->assertSame(
+			$expected,
+			get_option( 'woocommerce_cart_behavior_on_logout' ),
+			$is_new_install
+				? 'New stores should keep the cart through logout.'
+				: 'Existing stores should stay on the behavior they have always had.'
+		);
+	}
+
+	/**
+	 * A merchant who has already chosen a behavior must keep it across later upgrades, when
+	 * create_options() runs again.
+	 */
+	public function test_create_options_does_not_overwrite_a_chosen_cart_behavior(): void {
+		update_option( 'woocommerce_cart_behavior_on_logout', 'preserve' );
+
+		$this->run_create_options_as_install( false );
+
+		$this->assertSame(
+			'preserve',
+			get_option( 'woocommerce_cart_behavior_on_logout' ),
+			"The merchant's own choice should survive an upgrade."
+		);
+	}
+
+	/**
+	 * Run WC_Install::create_options() with is_new_install() forced to a known value.
+	 *
+	 * @param bool $is_new_install Value that is_new_install() should report.
+	 */
+	private function run_create_options_as_install( bool $is_new_install ): void {
+		$supply_version    = function () use ( $is_new_install ) {
+			return $is_new_install ? false : '10.0.0';
+		};
+		$supply_live_store = function () {
+			return 'no';
+		};
+
+		add_filter( 'option_woocommerce_version', $supply_version );
+		if ( ! $is_new_install ) {
+			// Short-circuits is_new_install() on the "store is live" check, without touching products.
+			add_filter( 'option_woocommerce_coming_soon', $supply_live_store );
+		}
+
+		try {
+			$this->assertSame(
+				$is_new_install,
+				WC_Install::is_new_install(),
+				'The test should be exercising the intended install state.'
+			);
+
+			$create_options = function () {
+				static::create_options();
+			};
+			$create_options->call( new WC_Install() );
+		} finally {
+			remove_filter( 'option_woocommerce_version', $supply_version );
+			remove_filter( 'option_woocommerce_coming_soon', $supply_live_store );
+		}
+	}
 }
diff --git a/plugins/woocommerce/tests/php/includes/settings/class-wc-settings-accounts-test.php b/plugins/woocommerce/tests/php/includes/settings/class-wc-settings-accounts-test.php
index dabe71783d3..e61c927d3cd 100644
--- a/plugins/woocommerce/tests/php/includes/settings/class-wc-settings-accounts-test.php
+++ b/plugins/woocommerce/tests/php/includes/settings/class-wc-settings-accounts-test.php
@@ -55,6 +55,7 @@ class WC_Settings_Accounts_Test extends WC_Settings_Unit_Test_Case {
 			'woocommerce_enable_myaccount_registration'    => 'checkbox',
 			'woocommerce_registration_generate_username'   => 'checkbox',
 			'woocommerce_registration_generate_password'   => 'checkbox',
+			'woocommerce_cart_behavior_on_logout'          => 'select',
 			'woocommerce_erasure_request_removes_order_data' => 'checkbox',
 			'woocommerce_erasure_request_removes_download_data' => 'checkbox',
 			'woocommerce_allow_bulk_remove_personal_data'  => 'checkbox',
diff --git a/plugins/woocommerce/tests/php/src/Internal/Cart/CartLogoutBehaviorTest.php b/plugins/woocommerce/tests/php/src/Internal/Cart/CartLogoutBehaviorTest.php
new file mode 100644
index 00000000000..df56f1e8dda
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Cart/CartLogoutBehaviorTest.php
@@ -0,0 +1,377 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\Cart;
+
+use Automattic\WooCommerce\Enums\CartBehaviorOnLogout;
+use Automattic\WooCommerce\Internal\Cart\CartLogoutBehavior;
+use Automattic\WooCommerce\RestApi\UnitTests\LoggerSpyTrait;
+use WC_Helper_Product;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the CartLogoutBehavior class.
+ */
+class CartLogoutBehaviorTest extends WC_Unit_Test_Case {
+	use LoggerSpyTrait;
+
+	/**
+	 * The System Under Test.
+	 *
+	 * @var CartLogoutBehavior
+	 */
+	private $sut;
+
+	/**
+	 * The session handler the suite installed, put back after tests that swap in the real one.
+	 *
+	 * @var \WC_Session|null
+	 */
+	private $original_session;
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		$this->sut              = new CartLogoutBehavior();
+		$this->original_session = WC()->session;
+	}
+
+	/**
+	 * Tear down test fixtures.
+	 */
+	public function tearDown(): void {
+		// use_real_session_handler() replaces WC()->session with a live WC_Session_Handler, and the base
+		// teardown does not reset it, so later tests would inherit it instead of the mock.
+		WC()->session = $this->original_session;
+
+		parent::tearDown();
+	}
+
+	/**
+	 * @testdox Should carry the cart over to the new session when the store preserves carts on logout.
+	 */
+	public function test_cart_is_preserved_when_option_is_preserve(): void {
+		update_option( 'woocommerce_cart_behavior_on_logout', CartBehaviorOnLogout::PRESERVE );
+		$product_id = $this->add_product_to_cart();
+
+		$this->sut->handle_wp_logout_capture();
+		$this->destroy_session_like_logout_does();
+		$this->sut->handle_wp_logout_restore();
+
+		$this->assertSame(
+			array( $product_id ),
+			$this->get_product_ids_in_cart(),
+			'The cart should still hold the product after logging out'
+		);
+		$this->assertNotEmpty(
+			WC()->session->get( 'cart' ),
+			'The new session should be seeded with the cart so the next request can read it back'
+		);
+	}
+
+	/**
+	 * @testdox Should leave the cart empty when the store clears carts on logout.
+	 */
+	public function test_cart_is_not_preserved_when_option_is_clear(): void {
+		update_option( 'woocommerce_cart_behavior_on_logout', CartBehaviorOnLogout::CLEAR );
+		$this->add_product_to_cart();
+
+		$this->sut->handle_wp_logout_capture();
+		$this->destroy_session_like_logout_does();
+		$this->sut->handle_wp_logout_restore();
+
+		$this->assertTrue( WC()->cart->is_empty(), 'The cart should be empty after logging out' );
+		$this->assertEmpty( WC()->session->get( 'cart' ), 'The new session should not be seeded with the cart' );
+	}
+
+	/**
+	 * @testdox Should clear the cart when the option has not been saved, matching the behavior before the setting existed.
+	 */
+	public function test_cart_is_not_preserved_when_option_is_not_set(): void {
+		delete_option( 'woocommerce_cart_behavior_on_logout' );
+		$this->add_product_to_cart();
+
+		$this->sut->handle_wp_logout_capture();
+		$this->destroy_session_like_logout_does();
+		$this->sut->handle_wp_logout_restore();
+
+		$this->assertSame(
+			array(),
+			$this->get_product_ids_in_cart(),
+			'The cart should be cleared when the store has no stored behavior'
+		);
+	}
+
+	/**
+	 * @testdox Should not seed the new session when the cart was already empty.
+	 */
+	public function test_empty_cart_does_not_seed_the_new_session(): void {
+		update_option( 'woocommerce_cart_behavior_on_logout', CartBehaviorOnLogout::PRESERVE );
+
+		$this->sut->handle_wp_logout_capture();
+		$this->destroy_session_like_logout_does();
+		$this->sut->handle_wp_logout_restore();
+
+		$this->assertEmpty(
+			WC()->session->get( 'cart' ),
+			'Logging out with an empty cart should leave the new session without cart data'
+		);
+	}
+
+	/**
+	 * @testdox Should bracket the session handler's own wp_logout priority, whatever that priority is.
+	 */
+	public function test_hooks_bracket_the_real_session_teardown(): void {
+		$session = $this->use_real_session_handler();
+		$this->sut->register();
+
+		// Read the teardown's priority off the live handler rather than hardcoding 10, so this fails if
+		// core ever moves it instead of quietly passing against a stale number.
+		$teardown_priority = has_action( 'wp_logout', array( $session, 'destroy_session' ) );
+		$capture_priority  = has_action( 'wp_logout', array( $this->sut, 'handle_wp_logout_capture' ) );
+		$restore_priority  = has_action( 'wp_logout', array( $this->sut, 'handle_wp_logout_restore' ) );
+
+		$this->assertIsInt( $teardown_priority, 'The session handler should hook its teardown to wp_logout' );
+		$this->assertIsInt( $capture_priority, 'The class should hook its capture to wp_logout' );
+		$this->assertIsInt( $restore_priority, 'The class should hook its restore to wp_logout' );
+
+		$this->assertLessThan(
+			$teardown_priority,
+			$capture_priority,
+			'The cart must be captured before the session handler tears the session down'
+		);
+		$this->assertGreaterThan(
+			$teardown_priority,
+			$restore_priority,
+			'The cart must be restored after the session handler tears the session down'
+		);
+	}
+
+	/**
+	 * @testdox Should carry the cart through a real wp_logout, including the cookie for the new guest session.
+	 */
+	public function test_cart_survives_a_real_logout_through_the_session_handler(): void {
+		update_option( 'woocommerce_cart_behavior_on_logout', CartBehaviorOnLogout::PRESERVE );
+
+		$session = $this->use_real_session_handler();
+		$user_id = $this->factory->user->create( array( 'role' => 'customer' ) );
+		wp_set_current_user( $user_id );
+
+		$product_id = $this->add_product_to_cart();
+		$session->set_customer_session_cookie( true );
+		$session->save_data();
+
+		$customer_id_before = $session->get_customer_id();
+
+		$this->sut->register();
+
+		$cookies = array();
+		$this->capture_cookies( $cookies );
+
+		// The real thing: WC_Session_Handler::destroy_session() runs at priority 10 between the two
+		// callbacks, deleting the session row, emptying the cart and issuing a new customer ID.
+		do_action( 'wp_logout', $user_id );
+
+		$customer_id_after = WC()->session->get_customer_id();
+
+		$this->assertNotSame(
+			$customer_id_before,
+			$customer_id_after,
+			'The session handler should have issued a new guest customer ID'
+		);
+		$this->assertSame(
+			array( $product_id ),
+			$this->get_product_ids_in_cart(),
+			'The cart should still hold the product after a real logout'
+		);
+		$this->assertNotEmpty(
+			WC()->session->get( 'cart' ),
+			'The new guest session should be seeded with the cart'
+		);
+
+		// Without this cookie the seeded session is written but never read back on the next request,
+		// which is the whole point of the restore. The mock handler has no cookie handling, so this
+		// assertion is only reachable against the real one.
+		$cookie_name = ( new \ReflectionProperty( \WC_Session_Handler::class, '_cookie' ) );
+		$cookie_name->setAccessible( true );
+
+		$this->assertArrayHasKey(
+			$cookie_name->getValue( WC()->session ),
+			$cookies,
+			'A session cookie should have been set for the new guest session'
+		);
+		$this->assertStringStartsWith(
+			$customer_id_after . '|',
+			$cookies[ $cookie_name->getValue( WC()->session ) ],
+			'The session cookie should point at the new guest customer ID, not the logged-out one'
+		);
+	}
+
+	/**
+	 * @testdox Should write the new guest session to the database during logout, not leave it to shutdown.
+	 */
+	public function test_restored_session_is_persisted_before_the_request_ends(): void {
+		global $wpdb;
+
+		update_option( 'woocommerce_cart_behavior_on_logout', CartBehaviorOnLogout::PRESERVE );
+
+		$session = $this->use_real_session_handler();
+		$user_id = $this->factory->user->create( array( 'role' => 'customer' ) );
+		wp_set_current_user( $user_id );
+
+		$product_id = $this->add_product_to_cart();
+		$session->set_customer_session_cookie( true );
+		$session->save_data();
+
+		$this->sut->register();
+
+		$cookies = array();
+		$this->capture_cookies( $cookies );
+
+		do_action( 'wp_logout', $user_id );
+
+		// PHPUnit never fires the shutdown action, so the handler's own save_data() callback at priority
+		// 20 has not run. A row here can only come from the class writing the session itself.
+		$stored = $wpdb->get_var(
+			$wpdb->prepare(
+				"SELECT session_value FROM {$wpdb->prefix}woocommerce_sessions WHERE session_key = %s",
+				WC()->session->get_customer_id()
+			)
+		);
+
+		$this->assertNotNull(
+			$stored,
+			'The new guest session should already be in the database when the logout request ends'
+		);
+
+		$stored_cart = maybe_unserialize( maybe_unserialize( $stored )['cart'] ?? '' );
+
+		$this->assertSame(
+			array( $product_id ),
+			array_values( array_map( 'intval', array_column( (array) $stored_cart, 'product_id' ) ) ),
+			'The persisted session should hold the preserved cart'
+		);
+	}
+
+	/**
+	 * @testdox Should let the logout finish when restoring the cart throws, rather than replacing the redirect with a fatal.
+	 */
+	public function test_logout_survives_a_failure_while_restoring_the_cart(): void {
+		update_option( 'woocommerce_cart_behavior_on_logout', CartBehaviorOnLogout::PRESERVE );
+		$this->add_product_to_cart();
+
+		// Stand in for an extension that raises while the cart is rebuilt. wp_logout has no error
+		// boundary, so anything escaping here would surface as a fatal instead of the logout redirect.
+		$explode = function () {
+			throw new \RuntimeException( 'Extension blew up while the cart was rebuilt' );
+		};
+		add_action( 'woocommerce_cart_loaded_from_session', $explode );
+
+		try {
+			$this->sut->handle_wp_logout_capture();
+			$this->destroy_session_like_logout_does();
+			$this->sut->handle_wp_logout_restore();
+		} finally {
+			remove_action( 'woocommerce_cart_loaded_from_session', $explode );
+		}
+
+		$this->assertLogged(
+			'error',
+			'Could not restore the cart into the new session on logout',
+			array( 'source' => 'cart-logout-behavior' )
+		);
+		$this->assertNotEmpty(
+			WC()->session->get( 'cart' ),
+			'The captured cart should still reach the session, so the next request can recover it'
+		);
+	}
+
+	/**
+	 * @testdox Should not log an error when the cart is carried over successfully.
+	 */
+	public function test_successful_preserve_logs_no_error(): void {
+		update_option( 'woocommerce_cart_behavior_on_logout', CartBehaviorOnLogout::PRESERVE );
+		$this->add_product_to_cart();
+
+		$this->sut->handle_wp_logout_capture();
+		$this->destroy_session_like_logout_does();
+		$this->sut->handle_wp_logout_restore();
+
+		$this->assertNoErrorLogged();
+	}
+
+	/**
+	 * Add a simple product to the cart.
+	 *
+	 * @return int The ID of the product added to the cart.
+	 */
+	private function add_product_to_cart(): int {
+		$product = WC_Helper_Product::create_simple_product();
+		WC()->cart->add_to_cart( $product->get_id() );
+
+		return $product->get_id();
+	}
+
+	/**
+	 * Get the product IDs currently held in the in-memory cart.
+	 *
+	 * @return array<int>
+	 */
+	private function get_product_ids_in_cart(): array {
+		return array_values( array_map( 'intval', array_column( WC()->cart->get_cart_contents(), 'product_id' ) ) );
+	}
+
+	/**
+	 * Reproduce what WC_Session_Handler::destroy_session() does to the cart between the two
+	 * wp_logout callbacks. The test session handler is a bare WC_Session, so it has no
+	 * destroy_session() of its own to call here.
+	 */
+	private function destroy_session_like_logout_does(): void {
+		wc_empty_cart();
+		WC()->session->set( 'cart', null );
+
+		// forget_session() drops the whole session payload, not just the cart. Leaving cart_totals behind
+		// would keep get_cart_from_session() out of the calculate_totals() branch that writes the restored
+		// cart back to the session, so the fake has to clear it too. Customer ID rotation is left to
+		// test_cart_survives_a_real_logout_through_the_session_handler(), which uses the real handler.
+		WC()->session->set( 'cart_totals', null );
+	}
+
+	/**
+	 * Swap the bare WC_Mock_Session_Handler the suite installs for the real WC_Session_Handler, so the
+	 * wp_logout teardown, the customer ID rotation and the session cookie all behave as they do in
+	 * production.
+	 *
+	 * Built directly rather than through WC()->initialize_session(), which keeps whatever handler is
+	 * already in place. Earlier tests in the suite leave a real handler behind, and the WP test case
+	 * restores $wp_filter after each test, so that leftover instance has no hooks by the time this runs.
+	 *
+	 * @return \WC_Session_Handler The live session handler.
+	 */
+	private function use_real_session_handler(): \WC_Session_Handler {
+		$session = new \WC_Session_Handler();
+		$session->init();
+		WC()->session = $session;
+
+		return $session;
+	}
+
+	/**
+	 * Record the cookies WooCommerce tries to set, without emitting real headers from the CLI.
+	 *
+	 * @param array $cookies Filled with the last value set for each cookie name.
+	 */
+	private function capture_cookies( array &$cookies ): void {
+		add_filter(
+			'woocommerce_set_cookie_enabled',
+			function ( $enabled, $name, $value ) use ( &$cookies ) {
+				$cookies[ $name ] = $value;
+				return false;
+			},
+			10,
+			3
+		);
+	}
+}