Commit 31e4cbd4359 for woocommerce

commit 31e4cbd4359da81b0b32159d8ff29e98ebf6ce20
Author: Chris Lilitsas <1105590+xristos3490@users.noreply.github.com>
Date:   Thu Sep 10 12:54:00 2026 +0300

    Skip the Back in Stock form inside the Add to Cart + Options block (#68385)

    * fix: skip Back in Stock form inside Add to Cart + Options block

    * test: cover out of stock simple products in the Back in Stock block guard

    * docs: say block-theme Back in Stock support ships separately

    * test: render the Back in Stock form through the legacy Add to Cart Form block

    * test: simplify the legacy Add to Cart Form block Back in Stock case

    * test: enable the stock notifications feature in the product page tests

    Claude-Session: https://claude.ai/code/session_01YVrq8mzV7mXq3M4763PQw6

diff --git a/plugins/woocommerce/changelog/fix-bis-form-add-to-cart-with-options-legacy-mode b/plugins/woocommerce/changelog/fix-bis-form-add-to-cart-with-options-legacy-mode
new file mode 100644
index 00000000000..b087d89e10e
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-bis-form-add-to-cart-with-options-legacy-mode
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Stop the Back in Stock form from rendering inside the Add to Cart + Options block, which forced the block into a legacy HTML form.
diff --git a/plugins/woocommerce/src/Internal/StockNotifications/Frontend/ProductPageIntegration.php b/plugins/woocommerce/src/Internal/StockNotifications/Frontend/ProductPageIntegration.php
index 19ba734f30a..3ee1a9e26cd 100644
--- a/plugins/woocommerce/src/Internal/StockNotifications/Frontend/ProductPageIntegration.php
+++ b/plugins/woocommerce/src/Internal/StockNotifications/Frontend/ProductPageIntegration.php
@@ -16,6 +16,11 @@ use WC_Product;
  */
 class ProductPageIntegration {

+	/**
+	 * Name of the Add to Cart + Options block.
+	 */
+	private const ADD_TO_CART_WITH_OPTIONS_BLOCK = 'woocommerce/add-to-cart-with-options';
+
 	/**
 	 * Runtime cache for preventing double rendering.
 	 *
@@ -69,6 +74,13 @@ class ProductPageIntegration {
 			return;
 		}

+		// Add to Cart + Options buffers this hook inside its <form> and falls back to a
+		// legacy HTML form when the buffer contains form elements. Block-theme support
+		// will ship separately.
+		if ( $this->is_rendering_inside_add_to_cart_with_options() ) {
+			return;
+		}
+
 		global $product;
 		if ( ! is_product() || ! is_a( $product, 'WC_Product' ) ) {
 			return;
@@ -102,6 +114,19 @@ class ProductPageIntegration {
 		$this->render_form( $product );
 	}

+	/**
+	 * Whether the hook is firing from inside the Add to Cart + Options render callback.
+	 *
+	 * WP_Block::render() sets WP_Block_Supports::$block_to_render around the callback,
+	 * and the block fires its buffered template hooks before rendering inner blocks.
+	 *
+	 * @return bool
+	 */
+	private function is_rendering_inside_add_to_cart_with_options(): bool {
+		return isset( \WP_Block_Supports::$block_to_render['blockName'] )
+			&& self::ADD_TO_CART_WITH_OPTIONS_BLOCK === \WP_Block_Supports::$block_to_render['blockName'];
+	}
+
 	/**
 	 * Render the form.
 	 *
diff --git a/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Frontend/ProductPageIntegrationTest.php b/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Frontend/ProductPageIntegrationTest.php
new file mode 100644
index 00000000000..e4fa22ce660
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Frontend/ProductPageIntegrationTest.php
@@ -0,0 +1,194 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\StockNotifications\Frontend;
+
+use Automattic\WooCommerce\Enums\ProductStockStatus;
+use Automattic\WooCommerce\Internal\StockNotifications\Frontend\ProductPageIntegration;
+use Automattic\WooCommerce\Tests\Blocks\Helpers\FixtureData;
+use Automattic\WooCommerce\Tests\Blocks\Mocks\AddToCartWithOptionsMock;
+use Automattic\WooCommerce\Tests\Blocks\Mocks\AddToCartWithOptionsQuantitySelectorMock;
+use Automattic\WooCommerce\Tests\Blocks\Mocks\AddToCartWithOptionsVariationSelectorMock;
+use Automattic\WooCommerce\Tests\Blocks\Mocks\AddToCartWithOptionsVariationSelectorAttributeMock;
+use Automattic\WooCommerce\Tests\Blocks\Mocks\AddToCartWithOptionsVariationSelectorAttributeNameMock;
+use Automattic\WooCommerce\Tests\Internal\StockNotifications\StockNotificationsFeatureTrait;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the ProductPageIntegration class.
+ */
+class ProductPageIntegrationTest extends WC_Unit_Test_Case {
+
+	use StockNotificationsFeatureTrait;
+
+	/**
+	 * The System Under Test.
+	 *
+	 * @var ProductPageIntegration
+	 */
+	private $sut;
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		// The blocks are not registered on `init` because `init` runs with a classic theme.
+		// Other test classes may have registered them already.
+		if ( ! \WP_Block_Type_Registry::get_instance()->is_registered( 'woocommerce/add-to-cart-with-options' ) ) {
+			new AddToCartWithOptionsMock();
+			new AddToCartWithOptionsQuantitySelectorMock();
+			new AddToCartWithOptionsVariationSelectorMock();
+			new AddToCartWithOptionsVariationSelectorAttributeMock();
+			new AddToCartWithOptionsVariationSelectorAttributeNameMock();
+		}
+
+		update_option( 'woocommerce_customer_stock_notifications_allow_signups', 'yes' );
+
+		// The container caches the instance and the hooks it added in an earlier test are gone
+		// after that test tore down, so re-resolve the services to hook the product page again.
+		$this->enable_stock_notifications_feature();
+		$this->init_stock_notifications_services();
+
+		$this->sut = wc_get_container()->get( ProductPageIntegration::class );
+	}
+
+	/**
+	 * Tear down test fixtures.
+	 */
+	public function tearDown(): void {
+		try {
+			$this->restore_stock_notifications_feature_option();
+		} finally {
+			parent::tearDown();
+		}
+	}
+
+	/**
+	 * Create a variable product with an out of stock variation and load its single product page.
+	 *
+	 * @return int The product ID.
+	 */
+	private function create_and_visit_variable_product(): int {
+		$fixtures         = new FixtureData();
+		$variable_product = $fixtures->get_variable_product(
+			array(),
+			array( $fixtures->get_product_attribute( 'color', array( 'red', 'blue' ) ) )
+		);
+		$product_id       = $variable_product->get_id();
+
+		// Keep one variation purchasable so the block fires its template hooks.
+		$fixtures->get_variation_product(
+			$product_id,
+			array( 'pa_color' => 'red-slug' ),
+			array(
+				'regular_price' => 10,
+				'stock_status'  => ProductStockStatus::IN_STOCK,
+			)
+		);
+		$fixtures->get_variation_product(
+			$product_id,
+			array( 'pa_color' => 'blue-slug' ),
+			array(
+				'regular_price' => 10,
+				'stock_status'  => ProductStockStatus::OUT_OF_STOCK,
+			)
+		);
+
+		// Sync the parent stock status from its variations so the block treats it as purchasable.
+		\WC_Product_Variable::sync( $product_id );
+
+		$this->go_to( get_permalink( $product_id ) );
+
+		// `go_to()` overwrites the global with the query var, so set it again for the hook callbacks.
+		$GLOBALS['product'] = wc_get_product( $product_id ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+
+		return $product_id;
+	}
+
+	/**
+	 * Create an out of stock simple product and load its single product page.
+	 *
+	 * @return int The product ID.
+	 */
+	private function create_and_visit_out_of_stock_simple_product(): int {
+		$fixtures   = new FixtureData();
+		$product_id = $fixtures->get_simple_product(
+			array(
+				'regular_price' => 10,
+				'stock_status'  => ProductStockStatus::OUT_OF_STOCK,
+			)
+		)->get_id();
+
+		$this->go_to( get_permalink( $product_id ) );
+		$GLOBALS['product'] = wc_get_product( $product_id ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+
+		return $product_id;
+	}
+
+	/**
+	 * @testdox Should not render the form inside the Add to Cart + Options block so it keeps its Interactivity API form.
+	 */
+	public function test_form_is_not_rendered_inside_add_to_cart_with_options_block(): void {
+		$product_id = $this->create_and_visit_variable_product();
+
+		$markup = do_blocks( '<!-- wp:woocommerce/single-product {"productId":' . $product_id . '} --><!-- wp:woocommerce/add-to-cart-with-options /--><!-- /wp:woocommerce/single-product -->' );
+
+		$this->assertStringContainsString( 'data-wp-on--submit', $markup, 'The Add to Cart + Options block should keep its Interactivity API form when sign-ups are enabled.' );
+		$this->assertStringNotContainsString( 'wc_bis_form', $markup, 'The Back in Stock form should not render inside the Add to Cart + Options block.' );
+	}
+
+	/**
+	 * @testdox Should render the form when the classic template hook fires outside the Add to Cart + Options block.
+	 */
+	public function test_form_is_rendered_by_classic_template_hook(): void {
+		$this->create_and_visit_variable_product();
+
+		ob_start();
+		do_action( 'woocommerce_after_add_to_cart_form' );
+		$markup = ob_get_clean();
+
+		$this->assertStringContainsString( 'wc_bis_form', $markup, 'The Back in Stock form should render when the hook fires from a classic template.' );
+	}
+
+	/**
+	 * @testdox Should not render the form for an out of stock simple product inside the Add to Cart + Options block.
+	 */
+	public function test_form_is_not_rendered_for_simple_product_inside_add_to_cart_with_options_block(): void {
+		$product_id = $this->create_and_visit_out_of_stock_simple_product();
+
+		$markup = do_blocks( '<!-- wp:woocommerce/single-product {"productId":' . $product_id . '} --><!-- wp:woocommerce/add-to-cart-with-options /--><!-- /wp:woocommerce/single-product -->' );
+
+		$this->assertStringContainsString( 'data-wp-on--submit', $markup, 'The Add to Cart + Options block should keep its Interactivity API form for an out of stock simple product.' );
+		$this->assertStringNotContainsString( 'wc_bis_form', $markup, 'The Back in Stock form should not render inside the Add to Cart + Options block for a simple product.' );
+	}
+
+	/**
+	 * @testdox Should render the form when the simple product hook fires outside the Add to Cart + Options block.
+	 */
+	public function test_form_is_rendered_by_simple_product_hook(): void {
+		$this->create_and_visit_out_of_stock_simple_product();
+
+		ob_start();
+		do_action( 'woocommerce_simple_add_to_cart' );
+		$markup = ob_get_clean();
+
+		$this->assertStringContainsString( 'wc_bis_form', $markup, 'The Back in Stock form should render when the simple product hook fires from a classic template.' );
+	}
+
+	/**
+	 * @testdox Should render the form inside the legacy Add to Cart Form block.
+	 */
+	public function test_form_is_rendered_inside_legacy_add_to_cart_form_block(): void {
+		$variable_product_id = $this->create_and_visit_variable_product();
+		$markup              = do_blocks( '<!-- wp:woocommerce/single-product {"productId":' . $variable_product_id . '} --><!-- wp:woocommerce/add-to-cart-form /--><!-- /wp:woocommerce/single-product -->' );
+
+		$this->assertStringContainsString( 'wc_bis_form', $markup, 'The Back in Stock form should render inside the legacy Add to Cart Form block for a variable product.' );
+
+		$simple_product_id = $this->create_and_visit_out_of_stock_simple_product();
+		$markup            = do_blocks( '<!-- wp:woocommerce/single-product {"productId":' . $simple_product_id . '} --><!-- wp:woocommerce/add-to-cart-form /--><!-- /wp:woocommerce/single-product -->' );
+
+		$this->assertStringContainsString( 'wc_bis_form', $markup, 'The Back in Stock form should render inside the legacy Add to Cart Form block for an out of stock simple product.' );
+	}
+}