Commit d91b1a92d12 for woocommerce

commit d91b1a92d12ccc9f2ff455f8b572e711c2501caa
Author: Miguel Gasca <miguel.gasca@automattic.com>
Date:   Tue Sep 15 17:42:17 2026 +0200

    Translate the default Cart page cross-sells heading at render time (#68500)

    Store the heading as a block pattern reference so it follows the site
    language instead of freezing the install-time locale in post_content.

diff --git a/plugins/woocommerce/changelog/fix-68096-cart-cross-sells-heading-translation b/plugins/woocommerce/changelog/fix-68096-cart-cross-sells-heading-translation
new file mode 100644
index 00000000000..6d4f85a1551
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-68096-cart-cross-sells-heading-translation
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Store the default Cart page cross-sells heading as a block pattern reference so it follows the site language instead of the install language.
diff --git a/plugins/woocommerce/includes/class-wc-install.php b/plugins/woocommerce/includes/class-wc-install.php
index 321be754747..cadf615510b 100644
--- a/plugins/woocommerce/includes/class-wc-install.php
+++ b/plugins/woocommerce/includes/class-wc-install.php
@@ -3167,10 +3167,7 @@ EOT;
 <!-- /wp:woocommerce/cart-line-items-block -->

 <!-- wp:woocommerce/product-collection {"queryId":0,"query":{"perPage":3,"pages":1,"offset":0,"postType":"product","order":"asc","orderBy":"title","search":"","exclude":[],"inherit":false,"taxQuery":{},"isProductCollectionBlock":true,"featured":false,"woocommerceOnSale":false,"woocommerceStockStatus":["instock","outofstock","onbackorder"],"woocommerceAttributes":[],"woocommerceHandPickedProducts":[],"filterable":false,"relatedBy":{"categories":true,"tags":true}},"tagName":"div","displayLayout":{"type":"flex","columns":3,"shrinkColumns":true},"dimensions":{"widthType":"fill"},"collection":"woocommerce/product-collection/cross-sells","hideControls":["filterable"],"queryContextIncludes":["collection"]} -->
-<div class="wp-block-woocommerce-product-collection"><!-- wp:heading {"textAlign":"left","style":{"spacing":{"margin":{"bottom":"1rem"}}}} -->
-<h2 class="wp-block-heading has-text-align-left" style="margin-bottom:1rem">' . __( 'You may be interested in&hellip;', 'woocommerce' ) . '</h2>
-
-<!-- /wp:heading -->
+<div class="wp-block-woocommerce-product-collection"><!-- wp:pattern {"slug":"woocommerce/cart-cross-sells-message"} /-->

 <!-- wp:woocommerce/product-template -->
 <!-- wp:woocommerce/product-image {"showSaleBadge":false,"imageSizing":"thumbnail","isDescendentOfQueryLoop":true} -->
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/Cart.php b/plugins/woocommerce/src/Blocks/BlockTypes/Cart.php
index 7c8465fc088..d060171d6b1 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/Cart.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/Cart.php
@@ -68,10 +68,10 @@ class Cart extends AbstractBlock {
 	}

 	/**
-	 * Register the Cart heading and cross-sells message block patterns.
+	 * Register the Cart heading block pattern.
 	 *
-	 * The empty cart and "New in store" patterns the default Cart page references are registered
-	 * in BlockTypesController::register_block_patterns() instead, so they resolve even when the
+	 * The patterns the default Cart page references are registered in
+	 * BlockTypesController::register_block_patterns() instead, so they resolve even when the
 	 * Cart block type is not registered.
 	 */
 	public function register_patterns() {
@@ -83,14 +83,6 @@ class Cart extends AbstractBlock {
 				'content'  => '<!-- wp:heading {"align":"wide", "level":1} --><h1 class="wp-block-heading alignwide">' . esc_html__( 'Cart', 'woocommerce' ) . '</h1><!-- /wp:heading -->',
 			)
 		);
-		register_block_pattern(
-			'woocommerce/cart-cross-sells-message',
-			array(
-				'title'    => '',
-				'inserter' => false,
-				'content'  => '<!-- wp:heading {"fontSize":"large"} --><h2 class="wp-block-heading has-large-font-size">' . esc_html__( 'You may be interested in…', 'woocommerce' ) . '</h2><!-- /wp:heading -->',
-			)
-		);
 	}

 	/**
diff --git a/plugins/woocommerce/src/Blocks/BlockTypesController.php b/plugins/woocommerce/src/Blocks/BlockTypesController.php
index 5dfe9fedeab..21c3ac94c89 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypesController.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypesController.php
@@ -268,6 +268,23 @@ final class BlockTypesController {
 				'content'  => '<!-- wp:heading {"textAlign":"center"} --><h2 class="wp-block-heading has-text-align-center">' . esc_html__( 'New in store', 'woocommerce' ) . '</h2><!-- /wp:heading -->',
 			)
 		);
+		// This heading is what a merchant sees above the cart cross-sells, so it has to match what the
+		// editor inserts: the same markup and the same string as the heading in
+		// client/blocks/assets/js/blocks/product-collection/collections/cross-sells.tsx. The installed
+		// Cart page only stores this pattern's slug, so this content is the single definition of it.
+		//
+		// The literal '…' is that shared msgid. The classic template templates/cart/cross-sells.php uses
+		// the '&hellip;' spelling, which gettext treats as a separate string; bg_BG, mk_MK and th have
+		// translated only that one, so they render this heading in English until they translate the
+		// literal form — as they already do for an editor-inserted cross-sells block.
+		register_block_pattern(
+			'woocommerce/cart-cross-sells-message',
+			array(
+				'title'    => '',
+				'inserter' => false,
+				'content'  => '<!-- wp:heading {"textAlign":"left","style":{"spacing":{"margin":{"bottom":"1rem"}}}} --><h2 class="wp-block-heading has-text-align-left" style="margin-bottom:1rem">' . esc_html__( 'You may be interested in…', 'woocommerce' ) . '</h2><!-- /wp:heading -->',
+			)
+		);
 	}

 	/**
diff --git a/plugins/woocommerce/tests/e2e/bin/blocks/pages/cart.html b/plugins/woocommerce/tests/e2e/bin/blocks/pages/cart.html
index 442fc5ef617..16a4c2bc30b 100644
--- a/plugins/woocommerce/tests/e2e/bin/blocks/pages/cart.html
+++ b/plugins/woocommerce/tests/e2e/bin/blocks/pages/cart.html
@@ -10,14 +10,7 @@

 			<!-- wp:woocommerce/product-collection {"queryId":0,"query":{"perPage":3,"pages":1,"offset":0,"postType":"product","order":"asc","orderBy":"title","search":"","exclude":[],"inherit":false,"taxQuery":{},"isProductCollectionBlock":true,"featured":false,"woocommerceOnSale":false,"woocommerceStockStatus":["instock","outofstock","onbackorder"],"woocommerceAttributes":[],"woocommerceHandPickedProducts":[],"filterable":false,"relatedBy":{"categories":true,"tags":true}},"tagName":"div","displayLayout":{"type":"flex","columns":3,"shrinkColumns":true},"dimensions":{"widthType":"fill"},"collection":"woocommerce/product-collection/cross-sells","hideControls":["filterable"],"queryContextIncludes":["collection"]} -->
 			<div class="wp-block-woocommerce-product-collection">
-				<!-- wp:heading {"textAlign":"left","style":{"spacing":{"margin":{"bottom":"1rem"}}}} -->
-				<h2
-					class="wp-block-heading has-text-align-left"
-					style="margin-bottom: 1rem"
-				>
-					You may be interested in…
-				</h2>
-				<!-- /wp:heading -->
+				<!-- wp:pattern {"slug":"woocommerce/cart-cross-sells-message"} /-->
 				<!-- wp:woocommerce/product-template -->
 				<!-- wp:woocommerce/product-image {"showSaleBadge":false,"imageSizing":"thumbnail","isDescendentOfQueryLoop":true} -->
 				<!-- wp:woocommerce/product-sale-badge {"align":"right"} /-->
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-install-test.php b/plugins/woocommerce/tests/php/includes/class-wc-install-test.php
index 39fb3d4c52d..aad6a1a9776 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-install-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-install-test.php
@@ -795,4 +795,112 @@ class WC_Install_Test extends \WC_Unit_Test_Case {
 			'The cart-empty-message pattern should render the Browse store link that the default Cart page lost when it moved to installer-generated content in 8.3.0.'
 		);
 	}
+
+	/**
+	 * @testdox Should reference a block pattern instead of baking the translated cross-sells heading into the Cart page content.
+	 */
+	public function test_cart_block_content_references_cross_sells_pattern(): void {
+		$method = new ReflectionMethod( WC_Install::class, 'get_cart_block_content' );
+		$method->setAccessible( true );
+		$content = $method->invoke( null );
+
+		$this->assertStringContainsString(
+			'<!-- wp:pattern {"slug":"woocommerce/cart-cross-sells-message"} /-->',
+			$content,
+			'The cross-sells heading should be stored as a pattern reference so it is translated at render time.'
+		);
+		$this->assertStringNotContainsString(
+			'You may be interested in',
+			$content,
+			'The cross-sells heading must not be frozen into the page content in the install-time locale.'
+		);
+	}
+
+	/**
+	 * @testdox Should render the cross-sells heading from the referenced pattern with the markup the installer used to inline.
+	 */
+	public function test_cart_cross_sells_message_pattern_renders_installer_markup(): void {
+		$registry = WP_Block_Patterns_Registry::get_instance();
+		$this->assertTrue(
+			$registry->is_registered( 'woocommerce/cart-cross-sells-message' ),
+			'The cart-cross-sells-message pattern must be registered during bootstrap; the installed Cart page renders nothing for it otherwise.'
+		);
+
+		$rendered = do_blocks( '<!-- wp:pattern {"slug":"woocommerce/cart-cross-sells-message"} /-->' );
+
+		// The literal ellipsis is the msgid the blocks JS defaults use, so every path that renders this
+		// heading shares one string. Asserting the exact spelling keeps that alignment deliberate: the
+		// classic template's '&hellip;' variant is a different msgid with different translation coverage.
+		$this->assertStringContainsString(
+			'You may be interested in…',
+			$rendered,
+			'The pattern should render the cross-sells heading with the literal-ellipsis msgid the blocks JS defaults use.'
+		);
+		// The pattern predates #60278, which restyled the heading in the installer without updating the
+		// pattern. These two assertions catch the styling drifting apart again and visibly changing the cart.
+		$this->assertStringContainsString(
+			'has-text-align-left',
+			$rendered,
+			'The rendered cross-sells heading should keep the alignment the installer previously inlined.'
+		);
+		$this->assertStringContainsString(
+			'margin-bottom:1rem',
+			$rendered,
+			'The rendered cross-sells heading should keep the bottom margin the installer previously inlined.'
+		);
+	}
+
+	/**
+	 * The point of the fix, asserted directly: the heading comes from the translation layer on each
+	 * request instead of the copy the installer used to freeze into post_content.
+	 *
+	 * The pattern resolves its own string when it registers, not when it renders, so a plain
+	 * switch_to_locale() here would prove nothing — the registry already holds the finished content.
+	 * Re-registering under the filter is what stands in for a request served in another language.
+	 *
+	 * @testdox Should take the cross-sells heading from the active translation rather than a frozen string.
+	 */
+	public function test_cart_cross_sells_heading_follows_the_active_translation(): void {
+		$slug      = 'woocommerce/cart-cross-sells-message';
+		$reference = '<!-- wp:pattern {"slug":"' . $slug . '"} /-->';
+		$sentinel  = 'PSEUDO_TRANSLATED_CROSS_SELLS_HEADING';
+
+		$translate = static function ( $translation, $text, $domain ) use ( $sentinel ) {
+			return ( 'woocommerce' === $domain && 'You may be interested in…' === $text ) ? $sentinel : $translation;
+		};
+
+		add_filter( 'gettext', $translate, 10, 3 );
+		$this->reregister_block_patterns();
+		$translated = do_blocks( $reference );
+		remove_filter( 'gettext', $translate, 10 );
+
+		// Leave the registry as this test found it, for anything that renders the pattern later.
+		$this->reregister_block_patterns();
+		$untranslated = do_blocks( $reference );
+
+		$this->assertStringContainsString(
+			$sentinel,
+			$translated,
+			'The heading should come from the translation layer, so a translated site renders translated copy.'
+		);
+		$this->assertStringNotContainsString(
+			'You may be interested in',
+			$translated,
+			'The English copy must not survive alongside the translation; that would mean it is frozen somewhere.'
+		);
+		$this->assertStringContainsString(
+			'You may be interested in…',
+			$untranslated,
+			'Dropping the translation should fall back to the English source string.'
+		);
+	}
+
+	/**
+	 * Rebuilds the patterns through the real registration, so the test exercises the content
+	 * BlockTypesController ships rather than a copy of it that could drift. Registering a pattern that
+	 * is already live replaces it, so the siblings this also rebuilds just get their own content back.
+	 */
+	private function reregister_block_patterns(): void {
+		wc_get_container()->get( \Automattic\WooCommerce\Blocks\BlockTypesController::class )->register_block_patterns();
+	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Blocks/BlockTypesController.php b/plugins/woocommerce/tests/php/src/Blocks/BlockTypesController.php
index 72d69d90337..b41e7ab4bcb 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/BlockTypesController.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/BlockTypesController.php
@@ -241,7 +241,7 @@ class BlockTypesController extends WC_Unit_Test_Case {
 	}

 	/**
-	 * @testdox register_block_patterns() registers the empty cart message patterns referenced by the installed Cart page.
+	 * @testdox register_block_patterns() registers the patterns referenced by the installed Cart page.
 	 */
 	public function test_register_block_patterns_registers_installed_cart_page_patterns(): void {
 		$registry = \WP_Block_Patterns_Registry::get_instance();
@@ -250,7 +250,7 @@ class BlockTypesController extends WC_Unit_Test_Case {
 		// (see WC_Install::get_cart_block_content()). Registering them here rather than
 		// in the Cart block type means the page can still resolve the references when
 		// the Cart block itself is not registered.
-		$slugs = array( 'woocommerce/cart-empty-message', 'woocommerce/cart-new-in-store-message' );
+		$slugs = array( 'woocommerce/cart-empty-message', 'woocommerce/cart-new-in-store-message', 'woocommerce/cart-cross-sells-message' );

 		foreach ( $slugs as $slug ) {
 			if ( $registry->is_registered( $slug ) ) {