Commit c34f26862bd for woocommerce

commit c34f26862bd081826edad8f284c681fbb531408e
Author: Chris Lilitsas <1105590+xristos3490@users.noreply.github.com>
Date:   Mon Nov 2 11:58:05 2026 +0200

    [BIS] Stop meta-only stock notification saves from re-entering save() (#70188)

    * fix: stop meta-only stock notification saves from re-entering save()

    * chore: drop PHPStan baseline entries for the removed after_meta_change()

    * test: match has_meta_changes() to save_meta_data() and cover its no-change cases

    Use the same branch order as WC_Data::save_meta_data() so the two stay easy
    to compare, and add tests for meta never read, unchanged loaded meta, and
    meta added then deleted before save, which all report no changes.

diff --git a/plugins/woocommerce/changelog/70168-fix-bis-meta-save-reentry b/plugins/woocommerce/changelog/70168-fix-bis-meta-save-reentry
new file mode 100644
index 00000000000..0b93684755b
--- /dev/null
+++ b/plugins/woocommerce/changelog/70168-fix-bis-meta-save-reentry
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Prevent back-in-stock notification saves that only change meta from re-saving the notification, which stored newly added meta (e.g. the unsubscribe key) twice.
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 6ddad26f090..187ea0065c7 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -59040,18 +59040,6 @@ parameters:
 			count: 3
 			path: src/Internal/DataStores/StockNotifications/StockNotificationsDataStore.php

-		-
-			message: '#^Parameter \#1 \$notification of method Automattic\\WooCommerce\\Internal\\DataStores\\StockNotifications\\StockNotificationsDataStore\:\:after_meta_change\(\) expects Automattic\\WooCommerce\\Internal\\StockNotifications\\Notification, Automattic\\WooCommerce\\Internal\\DataStores\\WC_Data given\.$#'
-			identifier: argument.type
-			count: 1
-			path: src/Internal/DataStores/StockNotifications/StockNotificationsDataStore.php
-
-		-
-			message: '#^Parameter \#1 \$notification of method Automattic\\WooCommerce\\Internal\\DataStores\\StockNotifications\\StockNotificationsDataStore\:\:after_meta_change\(\) expects Automattic\\WooCommerce\\Internal\\StockNotifications\\Notification, WC_Data given\.$#'
-			identifier: argument.type
-			count: 2
-			path: src/Internal/DataStores/StockNotifications/StockNotificationsDataStore.php
-
 		-
 			message: '#^Parameter \#1 \$notification of method Automattic\\WooCommerce\\Internal\\DataStores\\StockNotifications\\StockNotificationsDataStore\:\:filter_raw_meta_data\(\) expects Automattic\\WooCommerce\\Internal\\StockNotifications\\Notification, WC_Data given\.$#'
 			identifier: argument.type
diff --git a/plugins/woocommerce/src/Internal/DataStores/StockNotifications/StockNotificationsDataStore.php b/plugins/woocommerce/src/Internal/DataStores/StockNotifications/StockNotificationsDataStore.php
index 0ec8e589734..58751b41b44 100644
--- a/plugins/woocommerce/src/Internal/DataStores/StockNotifications/StockNotificationsDataStore.php
+++ b/plugins/woocommerce/src/Internal/DataStores/StockNotifications/StockNotificationsDataStore.php
@@ -282,7 +282,9 @@ CREATE TABLE $meta_table_name (
 		$result      = 0;
 		$status_from = $notification->get_data()['status'];

-		if ( array_intersect( array( 'product_id', 'user_id', 'user_email', 'status', 'date_modified', 'date_confirmed', 'date_last_attempt', 'date_notified', 'date_cancelled', 'cancellation_source' ), array_keys( $changes ) ) ) {
+		$has_prop_changes = (bool) array_intersect( array( 'product_id', 'user_id', 'user_email', 'status', 'date_modified', 'date_confirmed', 'date_last_attempt', 'date_notified', 'date_cancelled', 'cancellation_source' ), array_keys( $changes ) );
+
+		if ( $has_prop_changes || $notification->has_meta_changes() ) {

 			if ( ! array_key_exists( 'date_modified', $changes ) ) {
 				$notification->set_date_modified( time() );
@@ -314,10 +316,7 @@ CREATE TABLE $meta_table_name (
 		}

 		$notification->save_meta_data();
-
-		if ( $changes ) {
-			$notification->apply_changes();
-		}
+		$notification->apply_changes();

 		$status_to = $notification->get_status( 'edit' );

@@ -392,7 +391,6 @@ CREATE TABLE $meta_table_name (
 	 */
 	public function add_meta( &$notification, $meta ) {
 		$add_meta = $this->data_store_meta->add_meta( $notification, $meta );
-		$this->after_meta_change( $notification );
 		return $add_meta ? $add_meta : false;
 	}

@@ -415,9 +413,7 @@ CREATE TABLE $meta_table_name (
 	 * @return bool
 	 */
 	public function update_meta( &$notification, $meta ): bool {
-		$update_meta = $this->data_store_meta->update_meta( $notification, $meta );
-		$this->after_meta_change( $notification );
-		return $update_meta;
+		return $this->data_store_meta->update_meta( $notification, $meta );
 	}

 	/**
@@ -428,35 +424,7 @@ CREATE TABLE $meta_table_name (
 	 * @return bool
 	 */
 	public function delete_meta( &$notification, $meta ): bool {
-		$delete_meta = $this->data_store_meta->delete_meta( $notification, $meta );
-
-		$this->after_meta_change( $notification );
-		return $delete_meta;
-	}
-
-	/**
-	 * Perform after meta change operations.
-	 *
-	 * @param Notification $notification The notification object.
-	 * @return bool True if changes were applied, false otherwise.
-	 */
-	private function after_meta_change( &$notification ): bool {
-
-		$current_time      = time();
-		$current_date_time = new \WC_DateTime( "@$current_time", new \DateTimeZone( 'UTC' ) );
-
-		$should_save =
-			$notification->get_id() > 0
-			&& $notification->get_date_modified( 'edit' ) < $current_date_time
-			&& empty( $notification->get_changes() );
-
-		if ( $should_save ) {
-			$notification->set_date_modified( $current_time );
-			$saved = $notification->save();
-			return ! is_wp_error( $saved );
-		}
-
-		return false;
+		return $this->data_store_meta->delete_meta( $notification, $meta );
 	}

 	/**
diff --git a/plugins/woocommerce/src/Internal/StockNotifications/Notification.php b/plugins/woocommerce/src/Internal/StockNotifications/Notification.php
index 162d38cc492..7d69f2a7cad 100644
--- a/plugins/woocommerce/src/Internal/StockNotifications/Notification.php
+++ b/plugins/woocommerce/src/Internal/StockNotifications/Notification.php
@@ -574,4 +574,32 @@ class Notification extends \WC_Data {

 		return $key;
 	}
+
+	/**
+	 * Whether any meta changes are pending a save.
+	 *
+	 * Mirrors the checks in `save_meta_data()`, so meta marked for deletion counts,
+	 * unlike `get_meta_data()`.
+	 *
+	 * @since 11.4.0
+	 *
+	 * @return bool
+	 */
+	public function has_meta_changes(): bool {
+		if ( is_null( $this->meta_data ) ) {
+			return false;
+		}
+
+		foreach ( $this->meta_data as $meta ) {
+			if ( is_null( $meta->value ) ) {
+				if ( ! empty( $meta->id ) ) {
+					return true;
+				}
+			} elseif ( empty( $meta->id ) || $meta->get_changes() ) {
+				return true;
+			}
+		}
+
+		return false;
+	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Internal/DataStores/StockNotifications/StockNotificationsDataStoreTests.php b/plugins/woocommerce/tests/php/src/Internal/DataStores/StockNotifications/StockNotificationsDataStoreTests.php
index 70f82a28ae4..385249625c3 100644
--- a/plugins/woocommerce/tests/php/src/Internal/DataStores/StockNotifications/StockNotificationsDataStoreTests.php
+++ b/plugins/woocommerce/tests/php/src/Internal/DataStores/StockNotifications/StockNotificationsDataStoreTests.php
@@ -473,8 +473,11 @@ class StockNotificationsDataStoreTests extends \WC_Unit_Test_Case {
 		$this->assertEquals( '2024-01-01 00:00:00', $notification->get_date_modified()->format( 'Y-m-d H:i:s' ) );
 		$this->assertEquals( '', $notification->get_meta( 'test_meta' ) );
 		$notification->add_meta_data( 'test_meta', 'test_value' );
+		$fired = did_action( 'woocommerce_update_stock_notification' );
 		$notification->save();

+		$this->assertSame( 1, did_action( 'woocommerce_update_stock_notification' ) - $fired, 'A meta-only save fires the update action once.' );
+		$this->assertSame( 1, $this->count_meta_rows( $notification->get_id(), 'test_meta' ), 'The added meta is stored once.' );
 		$notification = new Notification( $notification->get_id() );
 		$this->assertNotEquals( '2024-01-01 00:00:00', $notification->get_date_modified()->format( 'Y-m-d H:i:s' ) );
 	}
@@ -496,8 +499,10 @@ class StockNotificationsDataStoreTests extends \WC_Unit_Test_Case {
 		$this->assertEquals( '2024-01-01 00:00:00', $notification->get_date_modified()->format( 'Y-m-d H:i:s' ) );
 		$this->assertEquals( 'test_value', $notification->get_meta( 'test_meta' ) );
 		$notification->update_meta_data( 'test_meta', 'updated_value' );
+		$fired = did_action( 'woocommerce_update_stock_notification' );
 		$notification->save();

+		$this->assertSame( 1, did_action( 'woocommerce_update_stock_notification' ) - $fired, 'A meta-only save fires the update action once.' );
 		$notification = new Notification( $notification->get_id() );
 		$this->assertEquals( 'updated_value', $notification->get_meta( 'test_meta' ) );
 		$this->assertNotEquals( '2024-01-01 00:00:00', $notification->get_date_modified()->format( 'Y-m-d H:i:s' ) );
@@ -518,12 +523,107 @@ class StockNotificationsDataStoreTests extends \WC_Unit_Test_Case {
 		$notification = new Notification( $notification->get_id() );
 		$this->assertEquals( '2024-01-01 00:00:00', $notification->get_date_modified()->format( 'Y-m-d H:i:s' ) );
 		$notification->delete_meta_data( 'test_meta' );
+		$fired = did_action( 'woocommerce_update_stock_notification' );
 		$notification->save();

+		$this->assertSame( 1, did_action( 'woocommerce_update_stock_notification' ) - $fired, 'A meta-only save fires the update action once.' );
+		$this->assertSame( 0, $this->count_meta_rows( $notification->get_id(), 'test_meta' ) );
 		$notification = new Notification( $notification->get_id() );
 		$this->assertNotEquals( '2024-01-01 00:00:00', $notification->get_date_modified()->format( 'Y-m-d H:i:s' ) );
 	}

+	/**
+	 * @testdox Should save prop and meta changes together with one update action.
+	 */
+	public function test_prop_and_meta_changes_save_with_one_update_action(): void {
+		$notification = $this->create_notification_modified_in_the_past();
+		$notification->set_status( NotificationStatus::ACTIVE );
+		$notification->add_meta_data( 'test_meta', 'test_value' );
+		$fired = did_action( 'woocommerce_update_stock_notification' );
+		$notification->save();
+
+		$this->assertSame( 1, did_action( 'woocommerce_update_stock_notification' ) - $fired );
+		$notification = new Notification( $notification->get_id() );
+		$this->assertSame( NotificationStatus::ACTIVE, $notification->get_status() );
+		$this->assertSame( 'test_value', $notification->get_meta( 'test_meta' ) );
+		$this->assertNotEquals( '2024-01-01 00:00:00', $notification->get_date_modified()->format( 'Y-m-d H:i:s' ) );
+	}
+
+	/**
+	 * @testdox Should keep an explicitly set modified date when meta also changes.
+	 */
+	public function test_explicit_modified_date_is_kept_when_meta_changes(): void {
+		$notification = $this->create_notification_modified_in_the_past();
+		$notification->set_date_modified( '2025-01-01 00:00:00' );
+		$notification->add_meta_data( 'test_meta', 'test_value' );
+		$notification->save();
+
+		$notification = new Notification( $notification->get_id() );
+		$this->assertSame( '2025-01-01 00:00:00', $notification->get_date_modified()->format( 'Y-m-d H:i:s' ) );
+	}
+
+	/**
+	 * @testdox Should fire the update action once and leave the modified date alone when a save has no changes.
+	 */
+	public function test_save_without_changes_keeps_modified_date(): void {
+		$notification = $this->create_notification_modified_in_the_past();
+		$fired        = did_action( 'woocommerce_update_stock_notification' );
+		$notification->save();
+
+		$this->assertSame( 1, did_action( 'woocommerce_update_stock_notification' ) - $fired );
+		$notification = new Notification( $notification->get_id() );
+		$this->assertSame( '2024-01-01 00:00:00', $notification->get_date_modified()->format( 'Y-m-d H:i:s' ) );
+	}
+
+	/**
+	 * @testdox Should report no meta changes when meta was never read.
+	 */
+	public function test_has_meta_changes_is_false_when_meta_was_never_read(): void {
+		$notification = $this->create_notification_modified_in_the_past();
+
+		$this->assertFalse( $notification->has_meta_changes() );
+	}
+
+	/**
+	 * @testdox Should report no meta changes for unchanged loaded meta.
+	 */
+	public function test_has_meta_changes_is_false_for_unchanged_loaded_meta(): void {
+		$notification = $this->create_notification_modified_in_the_past();
+		$notification->add_meta_data( 'test_meta', 'test_value' );
+		$notification->save();
+
+		$notification = new Notification( $notification->get_id() );
+		$notification->get_meta( 'test_meta' );
+
+		$this->assertFalse( $notification->has_meta_changes() );
+	}
+
+	/**
+	 * @testdox Should report no meta changes when meta is added and deleted before saving.
+	 */
+	public function test_has_meta_changes_is_false_when_meta_is_added_then_deleted_before_save(): void {
+		$notification = $this->create_notification_modified_in_the_past();
+		$notification->add_meta_data( 'test_meta', 'test_value' );
+		$this->assertTrue( $notification->has_meta_changes() );
+
+		$notification->delete_meta_data( 'test_meta' );
+		$this->assertFalse( $notification->has_meta_changes() );
+
+		$notification->save();
+		$notification = new Notification( $notification->get_id() );
+		$this->assertSame( '2024-01-01 00:00:00', $notification->get_date_modified()->format( 'Y-m-d H:i:s' ) );
+	}
+
+	/**
+	 * @testdox Should store a first unsubscribe key on an existing notification once.
+	 */
+	public function test_first_unsubscribe_key_is_stored_once(): void {
+		$notification = $this->create_notification_modified_in_the_past();
+		$notification->get_unsubscribe_key( true );
+
+		$this->assertSame( 1, $this->count_meta_rows( $notification->get_id(), 'unsubscribe_action_key' ) );
+	}
+
 	/**
 	 * Test querying notifications.
 	 */
@@ -901,4 +1001,38 @@ class StockNotificationsDataStoreTests extends \WC_Unit_Test_Case {

 		$this->assertSame( array( $notification->get_id() ), array_map( 'intval', $notifications ) );
 	}
+
+	/**
+	 * Create a saved notification last modified on 2024-01-01, read back from the database.
+	 *
+	 * @return Notification
+	 */
+	private function create_notification_modified_in_the_past(): Notification {
+		$notification = new Notification();
+		$notification->set_product_id( 1 );
+		$notification->set_user_id( 1 );
+		$notification->set_date_modified( '2024-01-01 00:00:00' );
+		$notification->save();
+
+		return new Notification( $notification->get_id() );
+	}
+
+	/**
+	 * Count the stored meta rows for a notification and key.
+	 *
+	 * @param int    $notification_id The notification ID.
+	 * @param string $meta_key        The meta key.
+	 * @return int
+	 */
+	private function count_meta_rows( int $notification_id, string $meta_key ): int {
+		global $wpdb;
+
+		return (int) $wpdb->get_var(
+			$wpdb->prepare(
+				"SELECT COUNT(*) FROM {$wpdb->prefix}wc_stock_notificationmeta WHERE notification_id = %d AND meta_key = %s",
+				$notification_id,
+				$meta_key
+			)
+		);
+	}
 }