Commit fea94ab42b8 for woocommerce

commit fea94ab42b89527c381652e38fc74850ab49eda2
Author: Saskia Teichmann <s-a-s-k-i-a@users.noreply.github.com>
Date:   Tue Oct 27 11:03:49 2026 +0100

    Match block-based default pages by their WooCommerce block in wc_create_page() (#68951)

    * Match block-based default pages by their WooCommerce block in wc_create_page()

    The existing-page lookup strips shortcode wrappers and searches for the
    short, stable shortcode. For block content the needle was the complete
    default block markup, so any customization of the existing page broke
    the match and a duplicate page was created when the page option was
    empty.

    The needle for block content is now the block opener including its
    trailing space (e.g. '<!-- wp:woocommerce/cart '), the same marker
    has_block() uses, so blocks with a longer name such as
    woocommerce/cart-link cannot match. The shortcode needle and the
    trashed-page lookup are unchanged.

    Fixes #68099

    * Replace regex-based WooCommerce block detection

    * Make block page selection deterministic

    * Distinguish classic Cart and Checkout blocks

    * Match nested WooCommerce blocks when creating pages

    ---------

    Co-authored-by: Thomas Roberts <5656702+opr@users.noreply.github.com>

diff --git a/plugins/woocommerce/changelog/68099-create-page-block-match b/plugins/woocommerce/changelog/68099-create-page-block-match
new file mode 100644
index 00000000000..4cca2ce3f0e
--- /dev/null
+++ b/plugins/woocommerce/changelog/68099-create-page-block-match
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Match existing block-based default pages by their WooCommerce block so a customized Cart or Checkout page is reused instead of duplicated.
diff --git a/plugins/woocommerce/includes/admin/wc-admin-functions.php b/plugins/woocommerce/includes/admin/wc-admin-functions.php
index 8953b44b651..d81f9e9f5fa 100644
--- a/plugins/woocommerce/includes/admin/wc-admin-functions.php
+++ b/plugins/woocommerce/includes/admin/wc-admin-functions.php
@@ -113,10 +113,34 @@ function wc_create_page( $slug, $option = '', $page_title = '', $page_content =
 		}
 	}

+	$content_needle = '';
 	if ( strlen( $page_content ) > 0 ) {
-		// Search for an existing page with the specified page content (typically a shortcode).
-		$shortcode        = str_replace( array( '<!-- wp:shortcode -->', '<!-- /wp:shortcode -->' ), '', $page_content );
-		$valid_page_found = $wpdb->get_var( $wpdb->prepare( "SELECT ID FROM $wpdb->posts WHERE post_type='page' AND post_status NOT IN ( 'pending', 'trash', 'future', 'auto-draft' ) AND post_content LIKE %s LIMIT 1;", "%{$shortcode}%" ) );
+		// Search for an existing page by its shortcode, or by a stable WooCommerce block identifier for block content.
+		$content_needle = str_replace( array( '<!-- wp:shortcode -->', '<!-- /wp:shortcode -->' ), '', $page_content );
+		$blocks         = parse_blocks( $page_content );
+		while ( ! empty( $blocks ) ) {
+			$block = array_shift( $blocks );
+			if ( isset( $block['blockName'] ) && 0 === strpos( $block['blockName'], 'woocommerce/' ) ) {
+				$block_needle = 'woocommerce/classic-shortcode' === $block['blockName'] ? serialize_block( $block ) : '<!-- wp:' . $block['blockName'] . ' ';
+				break;
+			}
+			if ( ! empty( $block['innerBlocks'] ) ) {
+				$blocks = array_merge( $block['innerBlocks'], $blocks );
+			}
+		}
+	}
+
+	if ( isset( $block_needle ) ) {
+		$valid_page_found = $wpdb->get_var(
+			$wpdb->prepare(
+				"SELECT ID FROM $wpdb->posts WHERE post_type='page' AND post_status NOT IN ( 'pending', 'trash', 'future', 'auto-draft' ) AND post_content LIKE %s ORDER BY post_name = %s DESC, post_status = %s DESC, ID ASC LIMIT 1;",
+				'%' . $wpdb->esc_like( $block_needle ) . '%',
+				$slug,
+				$post_status
+			)
+		);
+	} elseif ( strlen( $content_needle ) > 0 ) {
+		$valid_page_found = $wpdb->get_var( $wpdb->prepare( "SELECT ID FROM $wpdb->posts WHERE post_type='page' AND post_status NOT IN ( 'pending', 'trash', 'future', 'auto-draft' ) AND post_content LIKE %s LIMIT 1;", '%' . $wpdb->esc_like( $content_needle ) . '%' ) );
 	} else {
 		// Search for an existing page with the specified page slug.
 		$valid_page_found = $wpdb->get_var( $wpdb->prepare( "SELECT ID FROM $wpdb->posts WHERE post_type='page' AND post_status NOT IN ( 'pending', 'trash', 'future', 'auto-draft' )  AND post_name = %s LIMIT 1;", $slug ) );
diff --git a/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-functions-test.php b/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-functions-test.php
index 2aa16e9bd22..66143f12dab 100644
--- a/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-functions-test.php
+++ b/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-functions-test.php
@@ -686,4 +686,227 @@ class WC_Admin_Functions_Test extends \WC_Unit_Test_Case {
 			'Existing shipping lines named Shipping should not be replaced with the generic method title'
 		);
 	}
+
+	/**
+	 * A block-based default page is matched by its WooCommerce block opener, so a customized
+	 * existing page is adopted instead of duplicated.
+	 *
+	 * @see https://github.com/woocommerce/woocommerce/issues/68099
+	 */
+	public function test_wc_create_page_adopts_customized_block_page(): void {
+		delete_option( 'woocommerce_cart_page_id' );
+
+		$existing_page_id = wp_insert_post(
+			array(
+				'post_type'    => 'page',
+				'post_status'  => 'publish',
+				'post_name'    => 'my-custom-cart',
+				'post_title'   => 'My custom cart',
+				'post_content' => '<!-- wp:paragraph --><p>Intro added by the merchant.</p><!-- /wp:paragraph --><!-- wp:woocommerce/cart --><div class="wp-block-woocommerce-cart">customized</div><!-- /wp:woocommerce/cart -->',
+			)
+		);
+
+		$page_id = wc_create_page( 'cart', 'woocommerce_cart_page_id', 'Cart', '<!-- wp:woocommerce/cart --><div class="wp-block-woocommerce-cart alignwide is-loading">default markup</div><!-- /wp:woocommerce/cart -->' );
+
+		$this->assertEquals( $existing_page_id, $page_id );
+		$this->assertEquals( $existing_page_id, get_option( 'woocommerce_cart_page_id' ) );
+	}
+
+	/**
+	 * @testdox A customized page is adopted when its WooCommerce block is nested inside another block.
+	 */
+	public function test_wc_create_page_adopts_nested_block_page(): void {
+		delete_option( 'woocommerce_cart_page_id' );
+
+		$existing_page_id = wp_insert_post(
+			array(
+				'post_type'    => 'page',
+				'post_status'  => 'publish',
+				'post_name'    => 'nested-cart',
+				'post_title'   => 'Nested cart',
+				'post_content' => '<!-- wp:group --><div class="wp-block-group"><!-- wp:paragraph --><p>Customized.</p><!-- /wp:paragraph --><!-- wp:woocommerce/cart --><div class="wp-block-woocommerce-cart">customized</div><!-- /wp:woocommerce/cart --></div><!-- /wp:group -->',
+			)
+		);
+
+		$page_id = wc_create_page( 'cart', 'woocommerce_cart_page_id', 'Cart', '<!-- wp:group --><div class="wp-block-group"><!-- wp:woocommerce/cart --><div class="wp-block-woocommerce-cart">default</div><!-- /wp:woocommerce/cart --></div><!-- /wp:group -->' );
+
+		$this->assertEquals( $existing_page_id, $page_id );
+		$this->assertEquals( $existing_page_id, get_option( 'woocommerce_cart_page_id' ) );
+	}
+
+	/**
+	 * @testdox The page with the requested slug and status is preferred when multiple pages contain the block.
+	 */
+	public function test_wc_create_page_prefers_matching_slug_and_status(): void {
+		delete_option( 'woocommerce_cart_page_id' );
+
+		wp_insert_post(
+			array(
+				'post_type'    => 'page',
+				'post_status'  => 'draft',
+				'post_name'    => 'untitled-cart-draft',
+				'post_title'   => '',
+				'post_content' => '<!-- wp:woocommerce/cart --><div class="wp-block-woocommerce-cart">draft</div><!-- /wp:woocommerce/cart -->',
+			)
+		);
+		$published_page_id = wp_insert_post(
+			array(
+				'post_type'    => 'page',
+				'post_status'  => 'publish',
+				'post_name'    => 'cart',
+				'post_title'   => 'Cart',
+				'post_content' => '<!-- wp:woocommerce/cart --><div class="wp-block-woocommerce-cart">customized</div><!-- /wp:woocommerce/cart -->',
+			)
+		);
+
+		$page_id = wc_create_page( 'cart', 'woocommerce_cart_page_id', 'Cart', '<!-- wp:woocommerce/cart --><div class="wp-block-woocommerce-cart">default</div><!-- /wp:woocommerce/cart -->' );
+
+		$this->assertEquals( $published_page_id, $page_id );
+	}
+
+	/**
+	 * @testdox A draft or private block page remains eligible when it is the only match.
+	 * @testWith ["draft"]
+	 *           ["private"]
+	 *
+	 * @param string $page_status Status of the existing page.
+	 */
+	public function test_wc_create_page_preserves_existing_status_policy( string $page_status ): void {
+		delete_option( 'woocommerce_cart_page_id' );
+
+		$existing_page_id = wp_insert_post(
+			array(
+				'post_type'    => 'page',
+				'post_status'  => $page_status,
+				'post_name'    => 'custom-cart',
+				'post_title'   => 'Custom cart',
+				'post_content' => '<!-- wp:woocommerce/cart --><div class="wp-block-woocommerce-cart">customized</div><!-- /wp:woocommerce/cart -->',
+			)
+		);
+
+		$page_id = wc_create_page( 'cart', 'woocommerce_cart_page_id', 'Cart', '<!-- wp:woocommerce/cart --><div class="wp-block-woocommerce-cart">default</div><!-- /wp:woocommerce/cart -->' );
+
+		$this->assertEquals( $existing_page_id, $page_id );
+		$this->assertSame( $page_status, get_post_status( $page_id ) );
+	}
+
+	/**
+	 * A page holding a different block with the same name prefix (e.g. cart-link) is not adopted.
+	 */
+	public function test_wc_create_page_does_not_adopt_page_with_prefixed_block_name(): void {
+		delete_option( 'woocommerce_cart_page_id' );
+
+		$other_page_id = wp_insert_post(
+			array(
+				'post_type'    => 'page',
+				'post_status'  => 'publish',
+				'post_name'    => 'header-links',
+				'post_title'   => 'Header links',
+				'post_content' => '<!-- wp:woocommerce/cart-link {"cartIcon":"cart"} --><a href="/cart/">Cart</a><!-- /wp:woocommerce/cart-link -->',
+			)
+		);
+
+		$page_id = wc_create_page( 'cart', 'woocommerce_cart_page_id', 'Cart', '<!-- wp:woocommerce/cart --><div class="wp-block-woocommerce-cart">default</div><!-- /wp:woocommerce/cart -->' );
+
+		$this->assertNotEquals( $other_page_id, $page_id );
+		$this->assertSame( 'cart', get_post( $page_id )->post_name );
+	}
+
+	/**
+	 * The classic shortcode matching keeps working unchanged.
+	 */
+	public function test_wc_create_page_still_matches_shortcode_content(): void {
+		delete_option( 'woocommerce_cart_page_id' );
+
+		$existing_page_id = wp_insert_post(
+			array(
+				'post_type'    => 'page',
+				'post_status'  => 'publish',
+				'post_name'    => 'old-cart',
+				'post_title'   => 'Old cart',
+				'post_content' => 'Before [woocommerce_cart] after',
+			)
+		);
+
+		$page_id = wc_create_page( 'cart', 'woocommerce_cart_page_id', 'Cart', '<!-- wp:shortcode -->[woocommerce_cart]<!-- /wp:shortcode -->' );
+
+		$this->assertEquals( $existing_page_id, $page_id );
+	}
+
+	/**
+	 * @testdox Classic Cart and Checkout blocks are distinguished by their shortcode attribute.
+	 */
+	public function test_wc_create_page_distinguishes_classic_shortcode_blocks(): void {
+		delete_option( 'woocommerce_cart_page_id' );
+		delete_option( 'woocommerce_checkout_page_id' );
+
+		$cart_page_id = wp_insert_post(
+			array(
+				'post_type'    => 'page',
+				'post_status'  => 'publish',
+				'post_name'    => 'cart',
+				'post_title'   => 'Cart',
+				'post_content' => '<!-- wp:paragraph --><p>Customized cart.</p><!-- /wp:paragraph --><!-- wp:woocommerce/classic-shortcode {"shortcode":"cart"} /-->',
+			)
+		);
+
+		$checkout_page_id = wc_create_page( 'checkout', 'woocommerce_checkout_page_id', 'Checkout', '<!-- wp:woocommerce/classic-shortcode {"shortcode":"checkout"} /-->' );
+		$matched_cart_id  = wc_create_page( 'cart', 'woocommerce_cart_page_id', 'Cart', '<!-- wp:woocommerce/classic-shortcode {"shortcode":"cart"} /-->' );
+
+		$this->assertNotEquals( $cart_page_id, $checkout_page_id );
+		$this->assertEquals( $checkout_page_id, get_option( 'woocommerce_checkout_page_id' ) );
+		$this->assertEquals( $cart_page_id, $matched_cart_id );
+	}
+
+	/**
+	 * @testdox An empty shortcode wrapper falls back to matching the requested slug.
+	 */
+	public function test_wc_create_page_does_not_match_empty_content_needle(): void {
+		delete_option( 'woocommerce_cart_page_id' );
+
+		wp_insert_post(
+			array(
+				'post_type'    => 'page',
+				'post_status'  => 'publish',
+				'post_name'    => 'unrelated',
+				'post_title'   => 'Unrelated page',
+				'post_content' => 'Unrelated content',
+			)
+		);
+		$cart_page_id = wp_insert_post(
+			array(
+				'post_type'    => 'page',
+				'post_status'  => 'publish',
+				'post_name'    => 'cart',
+				'post_title'   => 'Cart',
+				'post_content' => 'Existing cart content',
+			)
+		);
+
+		$page_id = wc_create_page( 'cart', 'woocommerce_cart_page_id', 'Cart', '<!-- wp:shortcode --><!-- /wp:shortcode -->' );
+
+		$this->assertEquals( $cart_page_id, $page_id );
+	}
+
+	/**
+	 * A trashed page that only contains the block is not restored; the trash lookup keeps its strict match.
+	 */
+	public function test_wc_create_page_does_not_restore_trashed_page_by_block_only(): void {
+		delete_option( 'woocommerce_cart_page_id' );
+
+		$trashed_page_id = wp_insert_post(
+			array(
+				'post_type'    => 'page',
+				'post_status'  => 'trash',
+				'post_name'    => 'cart__trashed',
+				'post_title'   => 'Cart',
+				'post_content' => '<!-- wp:paragraph --><p>Outdated intro.</p><!-- /wp:paragraph --><!-- wp:woocommerce/cart --><div class="wp-block-woocommerce-cart">old</div><!-- /wp:woocommerce/cart -->',
+			)
+		);
+
+		$page_id = wc_create_page( 'cart', 'woocommerce_cart_page_id', 'Cart', '<!-- wp:woocommerce/cart --><div class="wp-block-woocommerce-cart">default</div><!-- /wp:woocommerce/cart -->' );
+
+		$this->assertNotEquals( $trashed_page_id, $page_id );
+		$this->assertSame( 'trash', get_post( $trashed_page_id )->post_status );
+	}
 }