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