Commit 3ed08f0792c for woocommerce
commit 3ed08f0792c555bb52dd6f56b49ef0c0ae992363
Author: Cvetan Cvetanov <cvetan.cvetanov@automattic.com>
Date: Fri Jul 31 18:11:17 2026 +0300
Deprecate coupon updated props hook in favor of per-save successor (#67168)
* Add per-save props as third argument to coupon updated props hook
PR #66724 fixed the woocommerce_coupon_object_updated_props payload
accumulating across saves by changing the hook's second argument to
per-save semantics. That changed the observable value of a released
public hook argument, which the regression review flagged as a
backward compatibility break for out-of-tree listeners (issue #67147).
Take the additive path instead: the second argument returns to its
historical accumulate-across-saves behavior, byte-identical to every
released version, and the per-save property list is appended as a new
third argument. Existing listeners are unaffected; consumers that need
to know what the current save changed read the third argument. The
deprecated $updated_props property accumulates again as before, with
its deprecation notice now pointing at the third argument.
* Update changelog for coupon updated props hook change
The entry from PR #66724 promised per-save semantics on the hook's
second argument, which no longer matches the shipped behavior now that
the per-save list moved to a new third argument. Replace it with an
entry describing the additive change, since both land in the same
release.
* Add tests for two-arg delivery and shared-store accumulation
The suite captured the hook payloads only through three-arg callbacks,
so nothing proved a legacy listener registered with accepted_args = 2
still receives the accumulated array at position two, which is the
backward compatibility guarantee this branch exists to preserve. The
shared-store mechanics were likewise exercised only through the
deprecated property, not through the hook payload.
Pin both: a two-arg callback sees the accumulated list, and with a
shared data store instance (woocommerce_coupon_data_store filter
returning an object) one coupon's payload retains a prop written for
another, proving accumulation belongs to the store instance rather
than the coupon object.
* Tighten changelog wording on accumulation scope
The entry said the second argument accumulates across saves of the
same coupon object, which is only the default case. Accumulation
belongs to the data store instance, which spans different coupons
when a plugin shares one instance via the woocommerce_coupon_data_store
filter. Say so.
* Clarify coupon updated props docblocks and align param names
Code review flagged three documentation gaps around the restored hook
contract. The hook docblock described accumulation only for repeated
saves of the same coupon object, omitting that a store instance shared
via the woocommerce_coupon_data_store filter accumulates across
different coupons, so the second argument can list props written for a
coupon other than the first argument. The deprecated updated_props
property did not say it feeds that second argument, inviting a removal
that would silently change the hook payload. And the docblock named a
nonexistent variable for argument two while reusing the historical
argument-two name for argument three, which the generated hook
reference would propagate.
Document the shared-instance case and the load-bearing role of the
property, and introduce real locals whose names match the docblock:
accumulated_props for argument two, current_save_props for argument
three. No behavior change.
* Retire coupon updated props hook in favor of per-save successor
Review of the additive third-argument approach concluded that keeping
the accumulated payload as a live argument makes the migration hard to
discover: nothing at runtime tells a listener that the second argument
is not the one to use. Retiring the hook is explicit about the changed
behavior while do_action_deprecated() keeps backward compatibility for
existing callers and names the replacement.
Fire woocommerce_coupon_object_updated_props through
do_action_deprecated() with its exact historical payload (properties
accumulated across saves through one data store instance, with
duplicates), and add woocommerce_coupon_updated_props carrying only the
properties written by the current save. Existing listeners keep working
unchanged and see a deprecation notice under WP_DEBUG; the deprecated
action does not fire at all without listeners. The core Tracks listener
moves to the new action so core itself never triggers the notice.
* Update changelog for coupon hook retirement
diff --git a/plugins/woocommerce/changelog/39281-fix-coupon-updated-props-reset b/plugins/woocommerce/changelog/39281-fix-coupon-updated-props-reset
deleted file mode 100644
index ff487dfaab8..00000000000
--- a/plugins/woocommerce/changelog/39281-fix-coupon-updated-props-reset
+++ /dev/null
@@ -1,4 +0,0 @@
-Significance: patch
-Type: fix
-
-Fix the woocommerce_coupon_object_updated_props hook so it reports only the props changed by the current save, instead of an array that grows with duplicates across repeated saves of the same coupon. WC_Coupon_Data_Store_CPT::$updated_props is deprecated: it now holds the current save's props only while that hook runs, and is empty otherwise. Read the hook's second argument instead.
diff --git a/plugins/woocommerce/changelog/fix-67147-coupon-updated-props-deprecate-hook b/plugins/woocommerce/changelog/fix-67147-coupon-updated-props-deprecate-hook
new file mode 100644
index 00000000000..bd7784f798f
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-67147-coupon-updated-props-deprecate-hook
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Deprecate the woocommerce_coupon_object_updated_props action in favor of the new woocommerce_coupon_updated_props action, which reports only the properties changed by the current save. The deprecated action keeps firing with its historical payload (properties accumulated across saves through the same data store instance, with duplicates) whenever it has listeners. WC_Coupon_Data_Store_CPT::$updated_props is deprecated.
diff --git a/plugins/woocommerce/includes/data-stores/class-wc-coupon-data-store-cpt.php b/plugins/woocommerce/includes/data-stores/class-wc-coupon-data-store-cpt.php
index 3f512bbc011..9eced768f96 100644
--- a/plugins/woocommerce/includes/data-stores/class-wc-coupon-data-store-cpt.php
+++ b/plugins/woocommerce/includes/data-stores/class-wc-coupon-data-store-cpt.php
@@ -57,15 +57,14 @@ class WC_Coupon_Data_Store_CPT extends WC_Data_Store_WP implements WC_Coupon_Dat
/**
* The updated coupon properties.
*
- * Each save now records its updated properties in a local variable, so this
- * property no longer accumulates across saves. It is populated with the current
- * save's properties only for the duration of the
- * woocommerce_coupon_object_updated_props call, and emptied again afterwards, so
- * that code still reading it during that hook keeps working. Read the hook's
- * second argument instead.
+ * Accumulates the properties written by every save performed through this data
+ * store instance, with duplicates, and is never reset. It feeds the payload of
+ * the deprecated woocommerce_coupon_object_updated_props action and must not be
+ * removed while that action still fires. Use the woocommerce_coupon_updated_props
+ * action to learn what the current save changed.
*
* @since 4.1.0
- * @deprecated 11.1.0 Use the second argument of woocommerce_coupon_object_updated_props.
+ * @deprecated 11.1.0 Use the woocommerce_coupon_updated_props action.
* @var array
*/
protected $updated_props = array();
@@ -322,26 +321,38 @@ class WC_Coupon_Data_Store_CPT extends WC_Data_Store_WP implements WC_Coupon_Dat
$updated = $this->update_or_delete_post_meta( $coupon, $meta_key, $value );
if ( $updated ) {
- $updated_props[] = $prop;
+ $this->updated_props[] = $prop;
+ $updated_props[] = $prop;
}
}
- // Mirror the payload onto the deprecated property so that code still reading it
- // during the hook sees this save's properties, then empty it again so nothing
- // carries over to the next save.
- $this->updated_props = $updated_props;
-
/**
- * Fires after a coupon's properties have been updated.
+ * Fires after a coupon's properties have been updated, with its historical
+ * payload: the properties written by every save performed through this data
+ * store instance, accumulated with duplicates and never reset.
*
* @param WC_Coupon $coupon Coupon object.
- * @param string[] $updated_props Array of updated properties.
+ * @param string[] $updated_props Properties updated by all saves through this data store instance, with duplicates.
*
* @since 3.0.0
+ * @deprecated 11.1.0 Use woocommerce_coupon_updated_props, which reports only the current save's properties.
*/
- do_action( 'woocommerce_coupon_object_updated_props', $coupon, $updated_props );
+ do_action_deprecated(
+ 'woocommerce_coupon_object_updated_props',
+ array( $coupon, $this->updated_props ),
+ '11.1.0',
+ 'woocommerce_coupon_updated_props'
+ );
- $this->updated_props = array();
+ /**
+ * Fires after a coupon's properties have been updated.
+ *
+ * @param WC_Coupon $coupon Coupon object.
+ * @param string[] $updated_props Properties updated by the current save.
+ *
+ * @since 11.1.0
+ */
+ do_action( 'woocommerce_coupon_updated_props', $coupon, $updated_props );
}
/**
diff --git a/plugins/woocommerce/includes/tracks/events/class-wc-coupon-tracking.php b/plugins/woocommerce/includes/tracks/events/class-wc-coupon-tracking.php
index c5faffafcb7..84bd41ecbc7 100644
--- a/plugins/woocommerce/includes/tracks/events/class-wc-coupon-tracking.php
+++ b/plugins/woocommerce/includes/tracks/events/class-wc-coupon-tracking.php
@@ -14,7 +14,7 @@ class WC_Coupon_Tracking {
* Init tracking.
*/
public function init() {
- add_action( 'woocommerce_coupon_object_updated_props', array( $this, 'track_coupon_updated' ), 10, 2 );
+ add_action( 'woocommerce_coupon_updated_props', array( $this, 'track_coupon_updated' ), 10, 2 );
}
/**
diff --git a/plugins/woocommerce/tests/php/includes/data-stores/class-wc-coupon-data-store-cpt-test.php b/plugins/woocommerce/tests/php/includes/data-stores/class-wc-coupon-data-store-cpt-test.php
index 3b762162948..e8ab9fa67fb 100644
--- a/plugins/woocommerce/tests/php/includes/data-stores/class-wc-coupon-data-store-cpt-test.php
+++ b/plugins/woocommerce/tests/php/includes/data-stores/class-wc-coupon-data-store-cpt-test.php
@@ -9,11 +9,18 @@ declare( strict_types = 1 );
/**
* Class WC_Coupon_Data_Store_CPT_Test.
+ *
+ * Covers the woocommerce_coupon_updated_props action (per-save payload) and its
+ * deprecated predecessor woocommerce_coupon_object_updated_props (payload
+ * accumulated across saves through one data store instance). Tests without a
+ * listener on the deprecated action also implicitly verify that saving a coupon
+ * triggers no deprecation notice, because the WordPress test case fails on any
+ * unexpected deprecation.
*/
class WC_Coupon_Data_Store_CPT_Test extends WC_Unit_Test_Case {
/**
- * The payload of every woocommerce_coupon_object_updated_props fire, in order.
+ * The payload of every woocommerce_coupon_updated_props fire, in order.
*
* @var array[]
*/
@@ -28,14 +35,14 @@ class WC_Coupon_Data_Store_CPT_Test extends WC_Unit_Test_Case {
}
/**
- * Record the payload of every woocommerce_coupon_object_updated_props fire.
+ * Record the payload of every woocommerce_coupon_updated_props fire.
*
* Registered at priority 10 so that it observes the outer payload before any
* listener registered at a later priority can trigger a nested save.
*/
private function capture_updated_props(): void {
add_action(
- 'woocommerce_coupon_object_updated_props',
+ 'woocommerce_coupon_updated_props',
function ( $coupon, $updated_props ) {
$this->captured_payloads[] = $updated_props;
},
@@ -155,7 +162,7 @@ class WC_Coupon_Data_Store_CPT_Test extends WC_Unit_Test_Case {
$nested_save_done = false;
add_action(
- 'woocommerce_coupon_object_updated_props',
+ 'woocommerce_coupon_updated_props',
function ( $listener_coupon ) use ( &$nested_save_done ) {
if ( $nested_save_done ) {
return;
@@ -229,9 +236,92 @@ class WC_Coupon_Data_Store_CPT_Test extends WC_Unit_Test_Case {
}
/**
- * @testdox Should populate the deprecated updated-props property only for the duration of the hook.
+ * @testdox Should keep firing the deprecated action with its historical accumulated payload.
+ */
+ public function test_deprecated_action_receives_accumulated_props(): void {
+ $this->setExpectedDeprecated( 'woocommerce_coupon_object_updated_props' );
+
+ $coupon = $this->create_settled_coupon();
+
+ $received = array();
+ add_action(
+ 'woocommerce_coupon_object_updated_props',
+ function ( $coupon, $updated_props ) use ( &$received ) {
+ $received[] = $updated_props;
+ },
+ 10,
+ 2
+ );
+
+ $coupon->set_amount( 5 );
+ $coupon->save();
+
+ $coupon->set_amount( 10 );
+ $coupon->save();
+
+ $this->assertSame(
+ array( array( 'amount' ), array( 'amount', 'amount' ) ),
+ $received,
+ 'A listener on the deprecated action must keep receiving the historical accumulate-with-duplicates payload.'
+ );
+ }
+
+ /**
+ * @testdox Should accumulate deprecated-action props across coupons when a store instance is shared.
*/
- public function test_deprecated_updated_props_property_mirrors_the_current_save(): void {
+ public function test_deprecated_action_accumulates_across_coupons_when_store_is_shared(): void {
+ $this->setExpectedDeprecated( 'woocommerce_coupon_object_updated_props' );
+
+ $store = new WC_Coupon_Data_Store_CPT();
+
+ // Returning an object shares one store instance across every coupon in this test.
+ $store_filter = function () use ( $store ) {
+ return $store;
+ };
+ add_filter( 'woocommerce_coupon_data_store', $store_filter );
+
+ $fires = array();
+
+ try {
+ $coupon_a = WC_Helper_Coupon::create_coupon( 'shared-store-a' );
+ $coupon_a->save();
+ $coupon_b = WC_Helper_Coupon::create_coupon( 'shared-store-b' );
+ $coupon_b->save();
+
+ add_action(
+ 'woocommerce_coupon_object_updated_props',
+ function ( $coupon, $accumulated_props ) use ( &$fires ) {
+ $fires[] = array(
+ 'coupon_id' => $coupon->get_id(),
+ 'accumulated' => $accumulated_props,
+ );
+ },
+ 10,
+ 2
+ );
+
+ $coupon_a->set_amount( 5 );
+ $coupon_a->save();
+
+ $coupon_b->set_usage_limit( 3 );
+ $coupon_b->save();
+ } finally {
+ remove_filter( 'woocommerce_coupon_data_store', $store_filter );
+ }
+
+ $this->assertCount( 2, $fires, 'Each coupon save should fire the deprecated action exactly once.' );
+ $this->assertSame( $coupon_b->get_id(), $fires[1]['coupon_id'], 'The second fire belongs to coupon B.' );
+ $this->assertSame(
+ array( 'amount', 'usage_limit' ),
+ array_slice( $fires[1]['accumulated'], -2 ),
+ 'With a shared store instance, coupon B\'s payload retains the prop written for coupon A: accumulation belongs to the store instance, not the coupon.'
+ );
+ }
+
+ /**
+ * @testdox Should keep accumulating in the deprecated updated-props property.
+ */
+ public function test_deprecated_updated_props_property_keeps_accumulating(): void {
$store = new class() extends WC_Coupon_Data_Store_CPT {
/**
* Expose the deprecated updated-props state that a subclass may still read.
@@ -249,35 +339,22 @@ class WC_Coupon_Data_Store_CPT_Test extends WC_Unit_Test_Case {
};
add_filter( 'woocommerce_coupon_data_store', $store_filter );
- $observed_during_hook = null;
-
try {
$coupon = $this->create_settled_coupon();
- add_action(
- 'woocommerce_coupon_object_updated_props',
- function () use ( $store, &$observed_during_hook ) {
- $observed_during_hook = $store->get_updated_props();
- },
- 10,
- 2
- );
-
$coupon->set_amount( 5 );
$coupon->save();
+
+ $coupon->set_amount( 10 );
+ $coupon->save();
} finally {
remove_filter( 'woocommerce_coupon_data_store', $store_filter );
}
$this->assertSame(
- array( 'amount' ),
- $observed_during_hook,
- 'A subclass reading the deprecated property during the hook should see the current save\'s props.'
- );
- $this->assertSame(
- array(),
- $store->get_updated_props(),
- 'The deprecated property must be emptied after the hook so no props carry over to the next save.'
+ array( 'amount', 'amount' ),
+ array_slice( $store->get_updated_props(), -2 ),
+ 'A subclass reading the deprecated property must keep seeing the historical accumulated list after saves.'
);
}
}