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…', '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 '…' 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 '…' 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 ) ) {