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