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
+ )
+ );
+ }
}