Commit 4cbd6e8c7f9 for woocommerce

commit 4cbd6e8c7f98c4199fb26a6cfac50f97ea394218
Author: Mike Jolley <mike.jolley@me.com>
Date:   Fri Oct 23 15:34:14 2026 +0100

    Save only changed session keys so overlapping requests don't lose cart items (#69559)

    * Save only changed session keys when another request saved in between

    * Add changelog entry for overlapping session saves fix

    * Save the whole session when the stored row cannot be read during a merge

    * Use an explicit flag for replacing the whole session on save

    The loaded snapshot is now always an array, and a separate flag marks
    saves that should overwrite the stored session. SessionDataWriter gets
    separate replace() and merge() methods instead of branching on null.

    Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

    * Compare stored session data as binary before writing

    The sessions table uses a case-insensitive collation, so the guarded
    update matched rows where another request only changed a value's case,
    then overwrote that change.

    Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

    * Cast the session key and expiry before saving the session

    WC_Session_Handler uses strict types, so a subclass that stores the
    customer ID as an int would fatal when passing it to SessionDataWriter.

    Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

    ---------

    Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

diff --git a/plugins/woocommerce/changelog/46483-merge-concurrent-session-saves b/plugins/woocommerce/changelog/46483-merge-concurrent-session-saves
new file mode 100644
index 00000000000..3f497df0b8e
--- /dev/null
+++ b/plugins/woocommerce/changelog/46483-merge-concurrent-session-saves
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Keep items added to the cart when another request from the same shopper finishes later, instead of losing them.
diff --git a/plugins/woocommerce/includes/abstracts/abstract-wc-session.php b/plugins/woocommerce/includes/abstracts/abstract-wc-session.php
index 4ab36834a77..531e8003eb1 100644
--- a/plugins/woocommerce/includes/abstracts/abstract-wc-session.php
+++ b/plugins/woocommerce/includes/abstracts/abstract-wc-session.php
@@ -39,6 +39,22 @@ abstract class WC_Session {
 	 */
 	protected $_dirty = false; // phpcs:ignore PSR2.Classes.PropertyDeclaration.Underscore

+	/**
+	 * Session data as loaded from storage, used to work out which keys this request changed.
+	 *
+	 * @var array
+	 */
+	private $loaded_session_snapshot = array();
+
+	/**
+	 * Whether saving should overwrite the stored session instead of merging this request's changes into it.
+	 *
+	 * True until a snapshot is recorded, so handlers that never record one keep overwriting the stored session.
+	 *
+	 * @var bool
+	 */
+	private $replace_session_on_save = true;
+
 	/**
 	 * Init hooks and session data. Extended by child classes.
 	 *
@@ -131,6 +147,49 @@ abstract class WC_Session {
 		$this->_data[ $key ] = $serialized_value;
 	}

+	/**
+	 * Record the session data as loaded from storage, so saving writes only the keys this request changed.
+	 *
+	 * @since 11.4.0
+	 *
+	 * @param array $data Session data as loaded from storage.
+	 */
+	protected function set_loaded_session_snapshot( array $data ): void {
+		$this->loaded_session_snapshot = $data;
+		$this->replace_session_on_save = false;
+	}
+
+	/**
+	 * Get the session data as loaded from storage.
+	 *
+	 * @since 11.4.0
+	 *
+	 * @return array
+	 */
+	protected function get_loaded_session_snapshot(): array {
+		return $this->loaded_session_snapshot;
+	}
+
+	/**
+	 * Make the next save overwrite the stored session, for requests that replace the whole session.
+	 *
+	 * @since 11.4.0
+	 */
+	protected function replace_session_on_save(): void {
+		$this->replace_session_on_save = true;
+	}
+
+	/**
+	 * Whether the next save should overwrite the stored session instead of merging into it.
+	 *
+	 * @since 11.4.0
+	 *
+	 * @return bool
+	 */
+	protected function should_replace_session_on_save(): bool {
+		return $this->replace_session_on_save;
+	}
+
 	/**
 	 * Get customer ID. If the session is not initialized, returns an empty string.
 	 *
diff --git a/plugins/woocommerce/includes/class-wc-session-handler.php b/plugins/woocommerce/includes/class-wc-session-handler.php
index 73588e3b671..437e7c16815 100644
--- a/plugins/woocommerce/includes/class-wc-session-handler.php
+++ b/plugins/woocommerce/includes/class-wc-session-handler.php
@@ -13,6 +13,7 @@ declare(strict_types=1);

 use Automattic\Jetpack\Constants;
 use Automattic\WooCommerce\Internal\Features\FeaturesController;
+use Automattic\WooCommerce\Internal\Session\SessionDataWriter;
 use Automattic\WooCommerce\Utilities\StringUtil;
 use Automattic\WooCommerce\StoreApi\Utilities\CartTokenUtils;

@@ -168,6 +169,7 @@ class WC_Session_Handler extends WC_Session {
 			// If there is no cookie, generate a new session/customer ID.
 			$this->_customer_id = $this->generate_customer_id();
 			$this->_data        = $this->get_session_data();
+			$this->set_loaded_session_snapshot( $this->_data );
 			return;
 		}

@@ -209,6 +211,7 @@ class WC_Session_Handler extends WC_Session {
 		$session_data                         = array_diff_key( $session_data, array( 'customer' => true ) );
 		$this->_data                          = $session_data;
 		$this->_dirty                         = true;
+		$this->replace_session_on_save();
 		$this->save_data();
 	}

@@ -222,6 +225,7 @@ class WC_Session_Handler extends WC_Session {
 		$this->_data        = $this->get_session( $guest_session_id, array() );
 		$this->_dirty       = true;
 		$this->_customer_id = $user_session_id;
+		$this->replace_session_on_save();
 		$this->save_data( $guest_session_id );

 		/**
@@ -269,6 +273,9 @@ class WC_Session_Handler extends WC_Session {
 		 * @return array Modified session data to be used for initialization.
 		 */
 		$this->_data = (array) apply_filters( 'woocommerce_restored_session_data', $session_data );
+
+		// Snapshot the unfiltered data, so changes made by the filter are saved like any other change.
+		$this->set_loaded_session_snapshot( $session_data );
 	}

 	/**
@@ -551,19 +558,19 @@ class WC_Session_Handler extends WC_Session {
 	public function save_data( $old_session_key = '' ) {
 		// Dirty if something changed - prevents saving nothing new.
 		if ( $this->_dirty && $this->has_session() ) {
-			global $wpdb;
+			$writer      = wc_get_container()->get( SessionDataWriter::class );
+			$customer_id = (string) $this->get_customer_id();
+			$expiry      = (int) $this->_session_expiration;

-			$wpdb->query(
-				$wpdb->prepare(
-					// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- trusted table name.
-					"INSERT INTO {$this->_table} (`session_key`, `session_value`, `session_expiry`) VALUES (%s, %s, %d)
- 					ON DUPLICATE KEY UPDATE `session_value` = VALUES(`session_value`), `session_expiry` = VALUES(`session_expiry`)",
-					$this->get_customer_id(),
-					maybe_serialize( $this->_data ),
-					$this->_session_expiration
-				)
-			);
-			wp_cache_set( $this->get_cache_prefix() . $this->get_customer_id(), $this->_data, WC_SESSION_CACHE_GROUP, $this->_session_expiration - time() );
+			// Merging writes only the keys this request changed, so changes other requests saved since it loaded are kept.
+			$saved_data = $this->should_replace_session_on_save()
+				? $writer->replace( $this->_table, $customer_id, $expiry, $this->_data )
+				: $writer->merge( $this->_table, $customer_id, $expiry, $this->_data, $this->get_loaded_session_snapshot() );
+
+			wp_cache_set( $this->get_cache_prefix() . $this->get_customer_id(), $saved_data, WC_SESSION_CACHE_GROUP, $this->_session_expiration - time() );
+
+			// Later saves in this request compare against this request's own data, so they merge again.
+			$this->set_loaded_session_snapshot( $this->_data );
 			$this->_dirty = false;

 			/**
@@ -601,6 +608,7 @@ class WC_Session_Handler extends WC_Session {
 		$this->_dirty       = false;
 		$this->_customer_id = $this->generate_customer_id();
 		$this->_has_cookie  = false;
+		$this->replace_session_on_save();
 	}

 	/**
diff --git a/plugins/woocommerce/src/Internal/Session/SessionDataWriter.php b/plugins/woocommerce/src/Internal/Session/SessionDataWriter.php
new file mode 100644
index 00000000000..09fcd6c58c5
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Session/SessionDataWriter.php
@@ -0,0 +1,149 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\Session;
+
+/**
+ * Saves a session row without reverting changes another request saved after this one loaded the session.
+ *
+ * Each request loads the whole session when it starts. Writing all of it back on save reverts anything other
+ * requests saved in the meantime, so only the keys this request changed are applied to the stored row. When two
+ * requests change the same key, the last one to save wins.
+ *
+ * @since 11.4.0
+ */
+class SessionDataWriter {
+
+	/**
+	 * Overwrite the stored session with this request's data.
+	 *
+	 * @param string $table       Sessions table name.
+	 * @param string $session_key Session key (customer ID).
+	 * @param int    $expiry      Session expiry timestamp.
+	 * @param array  $current     Session data in this request.
+	 * @return array The session data that was saved.
+	 */
+	public function replace( string $table, string $session_key, int $expiry, array $current ): array {
+		$this->upsert( $table, $session_key, $expiry, $current );
+		return $current;
+	}
+
+	/**
+	 * Apply only the keys that changed since this request loaded the session to the stored session.
+	 *
+	 * @param string $table       Sessions table name.
+	 * @param string $session_key Session key (customer ID).
+	 * @param int    $expiry      Session expiry timestamp.
+	 * @param array  $current     Session data in this request.
+	 * @param array  $loaded      Session data as this request loaded it.
+	 * @return array The session data that was saved.
+	 */
+	public function merge( string $table, string $session_key, int $expiry, array $current, array $loaded ): array {
+		// Usually no other request saved in the meantime, so the row still holds what this request loaded.
+		if ( $this->write_if_unchanged( $table, $session_key, $expiry, $current, $loaded ) ) {
+			return $current;
+		}
+
+		global $wpdb;
+		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- trusted table name.
+		$value = $wpdb->get_var( $wpdb->prepare( "SELECT session_value FROM {$table} WHERE session_key = %s", $session_key ) );
+
+		if ( '' !== $wpdb->last_error ) {
+			// Without the stored row there is nothing to apply the changes to, so save this request's whole session.
+			return $this->replace( $table, $session_key, $expiry, $current );
+		}
+
+		$stored = maybe_unserialize( $value );
+		$data   = $this->apply_changes( is_array( $stored ) ? $stored : array(), $current, $loaded );
+
+		$this->upsert( $table, $session_key, $expiry, $data );
+		return $data;
+	}
+
+	/**
+	 * Apply the keys this request added, changed, or removed to the stored session data.
+	 *
+	 * @param array $stored  Session data in storage now.
+	 * @param array $current Session data in this request.
+	 * @param array $loaded  Session data as this request loaded it.
+	 * @return array
+	 */
+	private function apply_changes( array $stored, array $current, array $loaded ): array {
+		foreach ( array_keys( $current + $loaded ) as $key ) {
+			$value = $current[ $key ] ?? null;
+
+			if ( ( $loaded[ $key ] ?? null ) === $value ) {
+				continue;
+			}
+
+			if ( null === $value ) {
+				unset( $stored[ $key ] );
+			} else {
+				$stored[ $key ] = $value;
+			}
+		}
+
+		return $stored;
+	}
+
+	/**
+	 * Write the row only if it still holds the data this request loaded.
+	 *
+	 * @param string $table       Sessions table name.
+	 * @param string $session_key Session key (customer ID).
+	 * @param int    $expiry      Session expiry timestamp.
+	 * @param array  $data        Session data to write.
+	 * @param array  $loaded      Session data as this request loaded it. Empty if the row should not exist yet.
+	 * @return bool Whether the row was written.
+	 */
+	private function write_if_unchanged( string $table, string $session_key, int $expiry, array $data, array $loaded ): bool {
+		global $wpdb;
+
+		if ( empty( $loaded ) ) {
+			return (bool) $wpdb->query(
+				$wpdb->prepare(
+					// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- trusted table name.
+					"INSERT IGNORE INTO {$table} (`session_key`, `session_value`, `session_expiry`) VALUES (%s, %s, %d)",
+					$session_key,
+					maybe_serialize( $data ),
+					$expiry
+				)
+			);
+		}
+
+		// Compare as binary, since the column's case-insensitive collation would match a row whose values only changed case.
+		return (bool) $wpdb->query(
+			$wpdb->prepare(
+				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- trusted table name.
+				"UPDATE {$table} SET `session_value` = %s, `session_expiry` = %d WHERE `session_key` = %s AND CAST(`session_value` AS BINARY) = %s",
+				maybe_serialize( $data ),
+				$expiry,
+				$session_key,
+				maybe_serialize( $loaded )
+			)
+		);
+	}
+
+	/**
+	 * Write the row, replacing whatever is stored.
+	 *
+	 * @param string $table       Sessions table name.
+	 * @param string $session_key Session key (customer ID).
+	 * @param int    $expiry      Session expiry timestamp.
+	 * @param array  $data        Session data to write.
+	 */
+	private function upsert( string $table, string $session_key, int $expiry, array $data ): void {
+		global $wpdb;
+
+		$wpdb->query(
+			$wpdb->prepare(
+				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- trusted table name.
+				"INSERT INTO {$table} (`session_key`, `session_value`, `session_expiry`) VALUES (%s, %s, %d)
+				ON DUPLICATE KEY UPDATE `session_value` = VALUES(`session_value`), `session_expiry` = VALUES(`session_expiry`)",
+				$session_key,
+				maybe_serialize( $data ),
+				$expiry
+			)
+		);
+	}
+}
diff --git a/plugins/woocommerce/src/StoreApi/SessionHandler.php b/plugins/woocommerce/src/StoreApi/SessionHandler.php
index 7c510a604ac..a376fbf67d6 100644
--- a/plugins/woocommerce/src/StoreApi/SessionHandler.php
+++ b/plugins/woocommerce/src/StoreApi/SessionHandler.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
 namespace Automattic\WooCommerce\StoreApi;

 use Automattic\Jetpack\Constants;
+use Automattic\WooCommerce\Internal\Session\SessionDataWriter;
 use Automattic\WooCommerce\StoreApi\Utilities\CartTokenUtils;
 use WC_Session;
 defined( 'ABSPATH' ) || exit;
@@ -66,6 +67,7 @@ final class SessionHandler extends WC_Session {
 			$this->_customer_id       = $this->generate_customer_id();
 			$this->session_expiration = CartTokenUtils::get_cart_token_expiration();
 			$this->_data              = array();
+			$this->set_loaded_session_snapshot( $this->_data );
 			return;
 		}

@@ -74,6 +76,7 @@ final class SessionHandler extends WC_Session {
 		$this->_customer_id       = $payload['user_id'];
 		$this->session_expiration = $payload['exp'];
 		$this->_data              = (array) $this->get_session( $this->get_customer_id(), array() );
+		$this->set_loaded_session_snapshot( $this->_data );
 	}

 	/**
@@ -168,6 +171,7 @@ final class SessionHandler extends WC_Session {
 		$this->_data        = array();
 		$this->_dirty       = false;
 		$this->_customer_id = null;
+		$this->replace_session_on_save();
 	}

 	/**
@@ -191,18 +195,16 @@ final class SessionHandler extends WC_Session {
 	public function save_data() {
 		// Dirty if something changed - prevents saving nothing new.
 		if ( $this->_dirty ) {
-			global $wpdb;
-
-			$wpdb->query(
-				$wpdb->prepare(
-					// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- trusted table name.
-					"INSERT INTO {$this->table} (`session_key`, `session_value`, `session_expiry`) VALUES (%s, %s, %d) ON DUPLICATE KEY UPDATE `session_value` = VALUES(`session_value`), `session_expiry` = VALUES(`session_expiry`)",
-					$this->get_customer_id(),
-					maybe_serialize( $this->_data ),
-					$this->session_expiration
-				)
-			);
+			$writer = wc_get_container()->get( SessionDataWriter::class );

+			// Merging writes only the keys this request changed, so changes other requests saved since it loaded are kept.
+			if ( $this->should_replace_session_on_save() ) {
+				$writer->replace( $this->table, $this->get_customer_id(), (int) $this->session_expiration, $this->_data );
+			} else {
+				$writer->merge( $this->table, $this->get_customer_id(), (int) $this->session_expiration, $this->_data, $this->get_loaded_session_snapshot() );
+			}
+
+			$this->set_loaded_session_snapshot( $this->_data );
 			$this->_dirty = false;
 		}
 	}
diff --git a/plugins/woocommerce/tests/legacy/framework/class-wc-unit-test-case.php b/plugins/woocommerce/tests/legacy/framework/class-wc-unit-test-case.php
index e5bed3439b3..363045e2347 100644
--- a/plugins/woocommerce/tests/legacy/framework/class-wc-unit-test-case.php
+++ b/plugins/woocommerce/tests/legacy/framework/class-wc-unit-test-case.php
@@ -221,6 +221,14 @@ class WC_Unit_Test_Case extends WP_HTTP_TestCase {
 			wc_clear_notices();
 		}

+		if ( WC()->session instanceof WC_Session ) {
+			// The session row is rolled back but the session object keeps what it loaded, which no longer matches
+			// storage. Make its next save a full write instead of a merge against that stale snapshot.
+			( function () {
+				$this->replace_session_on_save();
+			} )->call( WC()->session );
+		}
+
 		if ( isset( WC()->countries ) ) {
 			// The locale is cached on first read. A test that reads it while a
 			// woocommerce_get_country_locale filter is attached leaves the filtered
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-session-concurrency-test.php b/plugins/woocommerce/tests/php/includes/class-wc-session-concurrency-test.php
new file mode 100644
index 00000000000..c33182b0dc7
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/class-wc-session-concurrency-test.php
@@ -0,0 +1,206 @@
+<?php
+declare( strict_types = 1 );
+
+use Automattic\WooCommerce\StoreApi\SessionHandler as StoreApiSessionHandler;
+use Automattic\WooCommerce\StoreApi\Utilities\CartTokenUtils;
+
+/**
+ * Tests for overlapping requests saving the same session.
+ *
+ * Each test loads the session in two "requests" before either saves, then saves request A and then request B, as
+ * happens when a slow request overlaps another one from the same shopper. Requests that change the same key
+ * still end with the value from the last save, so those cases are not covered here.
+ *
+ * @see https://github.com/woocommerce/woocommerce/issues/46483
+ */
+class WC_Session_Concurrency_Test extends WC_Unit_Test_Case {
+
+	private const GUEST_ID = 't_0123456789abcdef0123456789abcd';
+
+	/**
+	 * Tear down test fixtures.
+	 */
+	public function tearDown(): void {
+		unset( $_SERVER['HTTP_CART_TOKEN'] );
+		wp_set_current_user( 0 );
+		parent::tearDown();
+	}
+
+	/**
+	 * Cases: initial session data, the keys each request sets (null unsets), and the expected end state.
+	 *
+	 * @return array<string, array{array<string, mixed>, array<string, mixed>, array<string, mixed>, array<string, mixed>}>
+	 */
+	public function provide_overlapping_requests(): array {
+		$item = array(
+			'product_id' => 10,
+			'quantity'   => 1,
+		);
+
+		return array(
+			'add to cart vs notices write'      => array(
+				array(),
+				array( 'cart' => array( 'x' => $item ) ),
+				array( 'wc_notices' => array( 'notice' => array( 'Hello' ) ) ),
+				array(
+					'cart'       => array( 'x' => $item ),
+					'wc_notices' => array( 'notice' => array( 'Hello' ) ),
+				),
+			),
+			'value update vs unrelated write'   => array(
+				array( 'ext_consent' => 'granted' ),
+				array( 'ext_consent' => 'withdrawn' ),
+				array( 'chosen_shipping_methods' => array( 'free_shipping:1' ) ),
+				array(
+					'ext_consent'             => 'withdrawn',
+					'chosen_shipping_methods' => array( 'free_shipping:1' ),
+				),
+			),
+			'coupon removal vs unrelated write' => array(
+				array( 'applied_coupons' => array( 'dev10' ) ),
+				array( 'applied_coupons' => null ),
+				array( 'wc_notices' => array( 'notice' => array( 'Hello' ) ) ),
+				array( 'wc_notices' => array( 'notice' => array( 'Hello' ) ) ),
+			),
+		);
+	}
+
+	/**
+	 * @testdox WC_Session_Handler keeps both requests' changes when saves overlap: $_dataName.
+	 * @dataProvider provide_overlapping_requests
+	 *
+	 * @param array $initial   Session data before either request starts.
+	 * @param array $changes_a Keys set by request A, which saves first.
+	 * @param array $changes_b Keys set by request B, which saves last from older data.
+	 * @param array $expected  Expected stored session data.
+	 */
+	public function test_cookie_session_handler_overlapping_saves( array $initial, array $changes_a, array $changes_b, array $expected ): void {
+		wp_set_current_user( $this->factory->user->create( array( 'role' => 'customer' ) ) );
+
+		$this->run_overlapping_requests( WC_Session_Handler::class, $initial, $changes_a, $changes_b );
+
+		$this->assertEquals( $expected, $this->get_stored_session( (string) get_current_user_id() ), 'Request B should not overwrite request A\'s changes' );
+	}
+
+	/**
+	 * @testdox Store API SessionHandler keeps both requests' changes when saves overlap: $_dataName.
+	 * @dataProvider provide_overlapping_requests
+	 *
+	 * @param array $initial   Session data before either request starts.
+	 * @param array $changes_a Keys set by request A, which saves first.
+	 * @param array $changes_b Keys set by request B, which saves last from older data.
+	 * @param array $expected  Expected stored session data.
+	 */
+	public function test_store_api_session_handler_overlapping_saves( array $initial, array $changes_a, array $changes_b, array $expected ): void {
+		$_SERVER['HTTP_CART_TOKEN'] = CartTokenUtils::get_cart_token( self::GUEST_ID );
+
+		$this->run_overlapping_requests( StoreApiSessionHandler::class, $initial, $changes_a, $changes_b );
+
+		$this->assertEquals( $expected, $this->get_stored_session( self::GUEST_ID ), 'Request B should not overwrite request A\'s changes' );
+	}
+
+	/**
+	 * @testdox Keys removed by the woocommerce_restored_session_data filter are removed from storage on the next save.
+	 */
+	public function test_filtered_restored_data_is_saved(): void {
+		$user_id = (string) $this->factory->user->create( array( 'role' => 'customer' ) );
+		wp_set_current_user( (int) $user_id );
+		$this->apply_and_save( $this->start_request( WC_Session_Handler::class ), array( 'ext_tracking' => 'abc' ) );
+
+		add_filter(
+			'woocommerce_restored_session_data',
+			function ( $data ) {
+				unset( $data['ext_tracking'] );
+				return $data;
+			}
+		);
+
+		// The filter only runs when the session is restored from a cookie.
+		$request = $this->getMockBuilder( WC_Session_Handler::class )->onlyMethods( array( 'get_session_cookie' ) )->getMock();
+		$request->method( 'get_session_cookie' )->willReturn( array( $user_id, time() + DAY_IN_SECONDS, time() + HOUR_IN_SECONDS, 'hash' ) );
+		$request->init();
+		$this->apply_and_save( $request, array( 'wc_notices' => array( 'notice' => array( 'Hello' ) ) ) );
+
+		$this->assertEquals( array( 'wc_notices' => array( 'notice' => array( 'Hello' ) ) ), $this->get_stored_session( $user_id ) );
+	}
+
+	/**
+	 * @testdox A request that saves twice merges each save, so its second save does not undo another request's changes.
+	 */
+	public function test_second_save_in_same_request_still_merges(): void {
+		wp_set_current_user( $this->factory->user->create( array( 'role' => 'customer' ) ) );
+
+		$request_a = $this->start_request( WC_Session_Handler::class );
+		$request_b = $this->start_request( WC_Session_Handler::class );
+
+		$this->apply_and_save( $request_b, array( 'first' => 'b' ) );
+		$this->apply_and_save( $request_a, array( 'other' => 'a' ) );
+		$this->apply_and_save( $request_b, array( 'second' => 'b' ) );
+
+		$this->assertEquals(
+			array(
+				'first'  => 'b',
+				'other'  => 'a',
+				'second' => 'b',
+			),
+			$this->get_stored_session( (string) get_current_user_id() )
+		);
+	}
+
+	/**
+	 * Seeds the session, then interleaves two requests: load A, load B, save A, save B.
+	 *
+	 * @param string $handler_class Session handler class.
+	 * @param array  $initial       Session data before either request starts.
+	 * @param array  $changes_a     Keys set by request A.
+	 * @param array  $changes_b     Keys set by request B.
+	 */
+	private function run_overlapping_requests( string $handler_class, array $initial, array $changes_a, array $changes_b ): void {
+		$this->apply_and_save( $this->start_request( $handler_class ), $initial );
+
+		$request_a = $this->start_request( $handler_class );
+		$request_b = $this->start_request( $handler_class );
+
+		$this->apply_and_save( $request_a, $changes_a );
+		$this->apply_and_save( $request_b, $changes_b );
+	}
+
+	/**
+	 * Starts a request: a new session handler that has loaded the session.
+	 *
+	 * @param string $handler_class Session handler class.
+	 * @return WC_Session
+	 */
+	private function start_request( string $handler_class ): WC_Session {
+		$handler = new $handler_class();
+		$handler->init();
+		return $handler;
+	}
+
+	/**
+	 * Sets session keys (null unsets) and saves.
+	 *
+	 * @param WC_Session $handler Session handler.
+	 * @param array      $changes Keys to set.
+	 */
+	private function apply_and_save( WC_Session $handler, array $changes ): void {
+		foreach ( $changes as $key => $value ) {
+			$handler->set( $key, $value );
+		}
+		$handler->save_data();
+	}
+
+	/**
+	 * Reads the stored session straight from the database, bypassing the object cache.
+	 *
+	 * @param string $customer_id Session key.
+	 * @return array
+	 */
+	private function get_stored_session( string $customer_id ): array {
+		global $wpdb;
+
+		$value = $wpdb->get_var( $wpdb->prepare( "SELECT session_value FROM {$wpdb->prefix}woocommerce_sessions WHERE session_key = %s", $customer_id ) );
+
+		return array_map( 'maybe_unserialize', (array) maybe_unserialize( $value ) );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Session/SessionDataWriterTest.php b/plugins/woocommerce/tests/php/src/Internal/Session/SessionDataWriterTest.php
new file mode 100644
index 00000000000..144fca284e6
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Session/SessionDataWriterTest.php
@@ -0,0 +1,209 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\Session;
+
+use Automattic\WooCommerce\Internal\Session\SessionDataWriter;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the SessionDataWriter class.
+ */
+class SessionDataWriterTest extends WC_Unit_Test_Case {
+
+	private const SESSION_KEY = 't_session_writer_test';
+
+	/**
+	 * The System Under Test.
+	 *
+	 * @var SessionDataWriter
+	 */
+	private $sut;
+
+	/**
+	 * Sessions table name.
+	 *
+	 * @var string
+	 */
+	private $table;
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		$this->sut   = new SessionDataWriter();
+		$this->table = $GLOBALS['wpdb']->prefix . 'woocommerce_sessions';
+	}
+
+	/**
+	 * @testdox Inserts a new row when the session is not stored yet.
+	 */
+	public function test_inserts_new_row(): void {
+		$saved = $this->sut->merge( $this->table, self::SESSION_KEY, time() + 100, array( 'a' => 'mine' ), array() );
+
+		$this->assertSame( array( 'a' => 'mine' ), $saved );
+		$this->assertSame( array( 'a' => 'mine' ), $this->get_stored() );
+	}
+
+	/**
+	 * @testdox Replacing overwrites the stored row.
+	 */
+	public function test_replace_overwrites_stored_row(): void {
+		$this->store( array( 'a' => 'theirs' ) );
+
+		$this->sut->replace( $this->table, self::SESSION_KEY, time() + 100, array( 'b' => 'mine' ) );
+
+		$this->assertSame( array( 'b' => 'mine' ), $this->get_stored() );
+	}
+
+	/**
+	 * @testdox When another request saved since this one loaded, only this request's changes are applied to the stored row.
+	 */
+	public function test_applies_only_changed_keys_to_newer_data(): void {
+		$this->store(
+			array(
+				'kept'    => 'theirs',
+				'removed' => 'old',
+				'added'   => 'theirs',
+			)
+		);
+		$loaded  = array(
+			'kept'    => 'old',
+			'removed' => 'old',
+			'changed' => 'old',
+		);
+		$current = array(
+			'kept'    => 'old',
+			'changed' => 'mine',
+		);
+
+		$saved = $this->sut->merge( $this->table, self::SESSION_KEY, time() + 100, $current, $loaded );
+
+		$expected = array(
+			'kept'    => 'theirs',
+			'added'   => 'theirs',
+			'changed' => 'mine',
+		);
+		$this->assertSame( $expected, $saved );
+		$this->assertSame( $expected, $this->get_stored() );
+	}
+
+	/**
+	 * @testdox When both requests changed the same key, the last one to save wins.
+	 */
+	public function test_same_key_changed_by_both_requests_uses_the_last_save(): void {
+		$this->store( array( 'a' => 'theirs' ) );
+
+		$this->sut->merge( $this->table, self::SESSION_KEY, time() + 100, array( 'a' => 'mine' ), array( 'a' => 'old' ) );
+
+		$this->assertSame( array( 'a' => 'mine' ), $this->get_stored() );
+	}
+
+	/**
+	 * @testdox When another request only changed the case of a value, that change is kept.
+	 */
+	public function test_keeps_case_only_change_saved_by_another_request(): void {
+		$this->store(
+			array(
+				'coupon' => 'save10',
+				'other'  => 'old',
+			)
+		);
+		$loaded = array(
+			'coupon' => 'SAVE10',
+			'other'  => 'old',
+		);
+
+		$this->sut->merge(
+			$this->table,
+			self::SESSION_KEY,
+			time() + 100,
+			array(
+				'coupon' => 'SAVE10',
+				'other'  => 'mine',
+			),
+			$loaded
+		);
+
+		$this->assertSame(
+			array(
+				'coupon' => 'save10',
+				'other'  => 'mine',
+			),
+			$this->get_stored()
+		);
+	}
+
+	/**
+	 * @testdox When the stored row cannot be read, saves this request's whole session instead of only its changes.
+	 */
+	public function test_saves_whole_session_when_stored_row_cannot_be_read(): void {
+		global $wpdb;
+		$this->store(
+			array(
+				'a'    => 'theirs',
+				'keep' => 'kept',
+			)
+		);
+		$break_read = function ( $query ) {
+			return 0 === strpos( $query, 'SELECT session_value FROM' ) ? str_replace( 'FROM ', 'FROM missing_', $query ) : $query;
+		};
+		add_filter( 'query', $break_read );
+		$suppress = $wpdb->suppress_errors( true );
+
+		try {
+			$saved = $this->sut->merge(
+				$this->table,
+				self::SESSION_KEY,
+				time() + 100,
+				array(
+					'a'    => 'mine',
+					'keep' => 'kept',
+				),
+				array(
+					'a'    => 'old',
+					'keep' => 'kept',
+				)
+			);
+		} finally {
+			$wpdb->suppress_errors( $suppress );
+			remove_filter( 'query', $break_read );
+		}
+
+		$expected = array(
+			'a'    => 'mine',
+			'keep' => 'kept',
+		);
+		$this->assertSame( $expected, $saved );
+		$this->assertSame( $expected, $this->get_stored(), 'Keys this request did not change should not be dropped' );
+	}
+
+	/**
+	 * Store a session row directly.
+	 *
+	 * @param array $data Session data.
+	 */
+	private function store( array $data ): void {
+		global $wpdb;
+		$wpdb->replace(
+			$this->table,
+			array(
+				'session_key'    => self::SESSION_KEY,
+				'session_value'  => maybe_serialize( $data ),
+				'session_expiry' => time() + 100,
+			)
+		);
+	}
+
+	/**
+	 * Read the stored session row.
+	 *
+	 * @return array
+	 */
+	private function get_stored(): array {
+		global $wpdb;
+		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- trusted table name.
+		return (array) maybe_unserialize( $wpdb->get_var( $wpdb->prepare( "SELECT session_value FROM {$this->table} WHERE session_key = %s", self::SESSION_KEY ) ) );
+	}
+}