Commit 0cf4ec7b4f for woocommerce
commit 0cf4ec7b4f4916827e02e2534f69c0ded442f075
Author: Justin P <228780+layoutd@users.noreply.github.com>
Date: Mon Jan 12 14:05:54 2026 +0100
Ensure Order Attribution input element stamped (#62609)
* Remove static flag for order-attribution HTML element
* Add function to remove duplicate `<wc-order-attribution-inputs>` elements to prevent redundant data during submission
* Add changefile(s) from automation for the following project(s): woocommerce
* Reenable single_stamp flag and (deprecated) stamp once method, but allow multiple stamps using filter
* Enhance order attribution by ensuring duplicate input groups are removed before updating form values. This change prevents redundant data during submission and maintains data integrity.
* Update plugins/woocommerce/src/Internal/Orders/OrderAttributionController.php
Co-authored-by: Chi-Hsuan Huang <chihsuan.tw@gmail.com>
* Correct version
Co-authored-by: Chi-Hsuan Huang <chihsuan.tw@gmail.com>
* Refactor OrderAttributionController to replace deprecated `stamp_checkout_html_element_once` method with `stamp_html_element`
* Clarify static property documentation
* Reset flag at request start to ensure compatibility with persistent PHP environments
* Add tests for stamp_html_element in OrderAttributionControllerTest
* CodeRabbit suggested tweaks to tests
---------
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Chi-Hsuan Huang <chihsuan.tw@gmail.com>
diff --git a/plugins/woocommerce/changelog/62609-fix-order-attribution-ensure-element-stamped b/plugins/woocommerce/changelog/62609-fix-order-attribution-ensure-element-stamped
new file mode 100644
index 0000000000..458771fa5f
--- /dev/null
+++ b/plugins/woocommerce/changelog/62609-fix-order-attribution-ensure-element-stamped
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix missing order attribution elements when checkout page is pre-rendered.
\ No newline at end of file
diff --git a/plugins/woocommerce/client/legacy/js/frontend/order-attribution.js b/plugins/woocommerce/client/legacy/js/frontend/order-attribution.js
index 112f881d70..c409b0599e 100644
--- a/plugins/woocommerce/client/legacy/js/frontend/order-attribution.js
+++ b/plugins/woocommerce/client/legacy/js/frontend/order-attribution.js
@@ -27,12 +27,26 @@
return Object.fromEntries( entries );
}
+ /**
+ * Remove duplicate `<wc-order-attribution-inputs>` elements, leaving only the first one,
+ * to prevent sending the same data multiple times.
+ */
+ function removeDuplicateInputGroups() {
+ document.querySelectorAll( 'wc-order-attribution-inputs' ).forEach( ( group, index ) => {
+ if ( index > 0 ) {
+ group.remove();
+ }
+ } );
+ }
+
/**
* Update `wc_order_attribution` input elements' values.
*
* @param {Object} values Object containing field values.
*/
function updateFormValues( values ) {
+ // Remove duplicates before updating to ensure only one set of elements exists.
+ removeDuplicateInputGroups();
// Update `<wc-order-attribution-inputs>` elements if any exist.
for( const element of document.querySelectorAll( 'wc-order-attribution-inputs' ) ) {
element.values = values;
diff --git a/plugins/woocommerce/src/Internal/Orders/OrderAttributionController.php b/plugins/woocommerce/src/Internal/Orders/OrderAttributionController.php
index 12cb8319f3..1e7338a5d3 100644
--- a/plugins/woocommerce/src/Internal/Orders/OrderAttributionController.php
+++ b/plugins/woocommerce/src/Internal/Orders/OrderAttributionController.php
@@ -58,11 +58,17 @@ class OrderAttributionController implements RegisterHooksInterface {
private $proxy;
/**
- * Whether the `stamp_checkout_html_element` method has been called.
+ * Tracks whether stamp_html_element() has been called in single-output mode during the current request.
+ *
+ * When wc_order_attribution_allow_multiple_elements filter returns false,
+ * this flag prevents duplicate outputs across multiple action hooks within a single request.
+ *
+ * Note: This flag is reset at the start of each request in on_init() to ensure
+ * proper behavior in persistent PHP environments (PHP-FPM, OpCache).
*
* @var bool
*/
- private static $is_stamp_checkout_html_called = false;
+ private static $is_stamp_html_called = false;
/**
* Initialization method.
@@ -106,6 +112,9 @@ class OrderAttributionController implements RegisterHooksInterface {
return;
}
+ // Reset the static flag at the start of each request to prevent issues in persistent PHP environments.
+ self::$is_stamp_html_called = false;
+
// Register WPConsentAPI integration.
$this->consent->register();
@@ -124,11 +133,11 @@ class OrderAttributionController implements RegisterHooksInterface {
);
/**
- * Filter set of actions used to stamp the unique checkout order attribution HTML container element.
+ * Filter set of actions used to stamp the checkout order attribution HTML container element.
*
* @since 9.0.0
*
- * @param array $stamp_checkout_html_actions The set of actions used to stamp the unique checkout order attribution HTML container element.
+ * @param array $stamp_checkout_html_actions The set of actions used to stamp the checkout order attribution HTML container element.
*/
$stamp_checkout_html_actions = apply_filters(
'wc_order_attribution_stamp_checkout_html_actions',
@@ -141,7 +150,7 @@ class OrderAttributionController implements RegisterHooksInterface {
)
);
foreach ( $stamp_checkout_html_actions as $action ) {
- add_action( $action, array( $this, 'stamp_checkout_html_element_once' ) );
+ add_action( $action, array( $this, 'stamp_html_element' ) );
}
add_action( 'woocommerce_register_form', array( $this, 'stamp_html_element' ) );
@@ -387,28 +396,49 @@ class OrderAttributionController implements RegisterHooksInterface {
}
/**
- * Handles the `<wc-order-attribution-inputs>` element for checkout forms, ensuring that the field is only output once.
+ * Handles the `<wc-order-attribution-inputs>` element for checkout forms.
*
* @since 9.0.0
+ * @deprecated 10.5.0 Use stamp_html_element() instead.
*
* @return void
*/
public function stamp_checkout_html_element_once() {
- if ( self::$is_stamp_checkout_html_called ) {
- return;
- }
+ wc_deprecated_function( __METHOD__, '10.5.0', 'stamp_html_element' );
$this->stamp_html_element();
- self::$is_stamp_checkout_html_called = true;
}
/**
* Output `<wc-order-attribution-inputs>` element that contributes the order attribution values to the enclosing form.
- * Used customer register forms, and for checkout forms through `stamp_checkout_html_element()`.
+ *
+ * Used for customer register forms and checkout forms.
+ *
+ * Note: By default, this method may output multiple instances of the element when called
+ * multiple times (e.g., during checkout form pre-generation and actual rendering).
+ * The JavaScript layer will remove duplicate elements and ensure only one set of data is submitted.
*
* @return void
*/
public function stamp_html_element() {
+ /**
+ * Filter to allow sites to opt back into single-output behavior.
+ *
+ * @since 10.5.0
+ *
+ * @param bool $allow_multiple_elements True to allow multiple elements (new behavior), false for single element (old behavior).
+ */
+ $allow_multiple = apply_filters( 'wc_order_attribution_allow_multiple_elements', true );
+
+ // If single-output mode is enabled, use the static flag to prevent multiple outputs.
+ if ( ! $allow_multiple && self::$is_stamp_html_called ) {
+ return;
+ }
+
printf( '<wc-order-attribution-inputs></wc-order-attribution-inputs>' );
+
+ if ( ! $allow_multiple ) {
+ self::$is_stamp_html_called = true;
+ }
}
/**
diff --git a/plugins/woocommerce/tests/php/src/Internal/Orders/OrderAttributionControllerTest.php b/plugins/woocommerce/tests/php/src/Internal/Orders/OrderAttributionControllerTest.php
index e4f32588d7..53a6c1914a 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Orders/OrderAttributionControllerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Orders/OrderAttributionControllerTest.php
@@ -33,7 +33,7 @@ class OrderAttributionControllerTest extends WP_UnitTestCase {
*
* @return void
*/
- protected function setUp(): void {
+ public function setUp(): void {
parent::setUp();
$this->attribution_class = new OrderAttributionController();
@@ -58,6 +58,23 @@ class OrderAttributionControllerTest extends WP_UnitTestCase {
$this->attribution_class->init( $legacy_proxy, $feature_mock, $wp_consent_mock, $logger_mock );
}
+ /**
+ * Tears down the fixture, for example, close a network connection.
+ *
+ * This method is called after each test to reset static state.
+ *
+ * @return void
+ */
+ public function tearDown(): void {
+ // Reset the static flag between tests using reflection.
+ $reflection = new \ReflectionClass( OrderAttributionController::class );
+ $property = $reflection->getProperty( 'is_stamp_html_called' );
+ $property->setAccessible( true );
+ $property->setValue( null, false );
+
+ parent::tearDown();
+ }
+
/**
* Tests the output_origin_column method.
*
@@ -133,4 +150,129 @@ class OrderAttributionControllerTest extends WP_UnitTestCase {
$this->assertEquals( $test_case['expected_output'], $output );
}
}
+
+ /**
+ * Tests that stamp_html_element outputs the correct HTML element.
+ *
+ * @return void
+ */
+ public function test_stamp_html_element_outputs_correct_html() {
+ ob_start();
+ $this->attribution_class->stamp_html_element();
+ $output = ob_get_clean();
+
+ $this->assertStringContainsString( '<wc-order-attribution-inputs>', $output );
+ $this->assertStringContainsString( '</wc-order-attribution-inputs>', $output );
+ }
+
+ /**
+ * Tests that stamp_html_element respects the single-output filter.
+ *
+ * @return void
+ */
+ public function test_stamp_html_element_respects_single_output_filter() {
+ // Enable single-output mode via filter.
+ add_filter( 'wc_order_attribution_allow_multiple_elements', '__return_false' );
+
+ ob_start();
+ $this->attribution_class->stamp_html_element();
+ $this->attribution_class->stamp_html_element(); // Second call should be suppressed.
+ $output = ob_get_clean();
+
+ // Should only contain one instance.
+ $this->assertEquals( 1, substr_count( $output, '<wc-order-attribution-inputs>' ) );
+
+ remove_filter( 'wc_order_attribution_allow_multiple_elements', '__return_false' );
+ }
+
+ /**
+ * Tests that stamp_html_element allows multiple outputs by default.
+ *
+ * @return void
+ */
+ public function test_stamp_html_element_allows_multiple_outputs_by_default() {
+ ob_start();
+ $this->attribution_class->stamp_html_element();
+ $this->attribution_class->stamp_html_element(); // Second call should also output.
+ $output = ob_get_clean();
+
+ // Should contain two instances.
+ $this->assertEquals( 2, substr_count( $output, '<wc-order-attribution-inputs>' ) );
+ }
+
+ /**
+ * Tests that the deprecated method calls the main method correctly.
+ *
+ * @return void
+ */
+ public function test_deprecated_method_calls_main_method() {
+ $this->setExpectedDeprecated( 'Automattic\WooCommerce\Internal\Orders\OrderAttributionController::stamp_checkout_html_element_once' );
+
+ ob_start();
+ $this->attribution_class->stamp_checkout_html_element_once();
+ $output = ob_get_clean();
+
+ $this->assertStringContainsString( '<wc-order-attribution-inputs>', $output );
+ $this->assertStringContainsString( '</wc-order-attribution-inputs>', $output );
+ }
+
+ /**
+ * Tests that the static flag is reset between test cases.
+ *
+ * This test ensures our tearDown properly resets the static state.
+ *
+ * @return void
+ */
+ public function test_static_flag_isolation_between_tests() {
+ // Enable single-output mode.
+ add_filter( 'wc_order_attribution_allow_multiple_elements', '__return_false' );
+
+ // First call should output.
+ ob_start();
+ $this->attribution_class->stamp_html_element();
+ $output1 = ob_get_clean();
+ $this->assertStringContainsString( '<wc-order-attribution-inputs>', $output1 );
+
+ // Second call should be suppressed.
+ ob_start();
+ $this->attribution_class->stamp_html_element();
+ $output2 = ob_get_clean();
+ $this->assertEmpty( $output2 );
+
+ remove_filter( 'wc_order_attribution_allow_multiple_elements', '__return_false' );
+ }
+
+ /**
+ * Tests that the static flag is reset on each on_init call (simulating new requests).
+ *
+ * This ensures proper behavior in persistent PHP environments like PHP-FPM.
+ *
+ * @return void
+ */
+ public function test_static_flag_resets_on_each_request() {
+ // Enable single-output mode.
+ add_filter( 'wc_order_attribution_allow_multiple_elements', '__return_false' );
+
+ // Simulate first request - output should work.
+ $this->attribution_class->on_init();
+ ob_start();
+ $this->attribution_class->stamp_html_element();
+ $output1 = ob_get_clean();
+ $this->assertStringContainsString( '<wc-order-attribution-inputs>', $output1 );
+
+ // Simulate second request - on_init resets the flag.
+ $this->attribution_class->on_init();
+ ob_start();
+ $this->attribution_class->stamp_html_element();
+ $output2 = ob_get_clean();
+ $this->assertStringContainsString( '<wc-order-attribution-inputs>', $output2, 'Output should work on second request after on_init reset' );
+
+ // Within same request, second call should be suppressed.
+ ob_start();
+ $this->attribution_class->stamp_html_element();
+ $output3 = ob_get_clean();
+ $this->assertEmpty( $output3, 'Second call within same request should be suppressed' );
+
+ remove_filter( 'wc_order_attribution_allow_multiple_elements', '__return_false' );
+ }
}