Commit 0fd1dc67d31 for woocommerce
commit 0fd1dc67d3107d3a6c10c319bdd4f420641cd865
Author: Peter Petrov <peter.petrov89@gmail.com>
Date: Tue Aug 11 15:24:21 2026 +0300
Regenerate missing combined translation file for WooCommerce Admin scripts on demand (#67548)
* Regenerate missing combined translation file for WooCommerce Admin scripts on demand
* Attempt combined translation file regeneration at most once per request
* Publish combined translation file atomically via temporary file
* Use non-overwriting move when publishing a missing combined translation file
* Replace combined translation file atomically with rename on direct filesystems
* Skip combined translation file rebuild when the languages directory is not writable
diff --git a/plugins/woocommerce/changelog/fix-51088-analytics-translations-combined-file b/plugins/woocommerce/changelog/fix-51088-analytics-translations-combined-file
new file mode 100644
index 00000000000..707183d1175
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-51088-analytics-translations-combined-file
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Regenerate the missing combined translation file for WooCommerce Admin scripts on demand, so Analytics and other admin screens no longer show untranslated strings when the file is absent.
diff --git a/plugins/woocommerce/src/Internal/Admin/Translations.php b/plugins/woocommerce/src/Internal/Admin/Translations.php
index 5058f5360f0..6ee3cbdc7c1 100644
--- a/plugins/woocommerce/src/Internal/Admin/Translations.php
+++ b/plugins/woocommerce/src/Internal/Admin/Translations.php
@@ -27,6 +27,14 @@ class Translations {
*/
private static $plugin_domain = 'woocommerce';
+ /**
+ * Whether an on-demand regeneration of the combined translation file has
+ * already been attempted during this request.
+ *
+ * @var bool
+ */
+ private $regeneration_attempted = false;
+
/**
* Get class instance.
*/
@@ -237,8 +245,29 @@ class Translations {
$cache_filename = $this->get_combined_translation_filename( $plugin_domain, $locale );
$chunk_translations_json = wp_json_encode( $translations_from_chunks );
- // Cache combined translations strings to a file.
- $wp_filesystem->put_contents( $language_dir . $cache_filename, $chunk_translations_json );
+ // Publish via a temp file so readers never observe a partial write.
+ $temp_path = $language_dir . $cache_filename . '.' . wp_generate_password( 12, false ) . '.tmp';
+ $cache_path = $language_dir . $cache_filename;
+
+ if ( ! $wp_filesystem->put_contents( $temp_path, $chunk_translations_json ) ) {
+ $wp_filesystem->delete( $temp_path );
+ return;
+ }
+
+ // Not WP_Filesystem_Direct::move(): it deletes the destination before renaming.
+ if ( 'direct' === get_filesystem_method() ) {
+ // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.rename_rename -- Atomic replace; failure handled below.
+ if ( ! @rename( $temp_path, $cache_path ) ) {
+ $wp_filesystem->delete( $temp_path );
+ }
+ return;
+ }
+
+ // Remote filesystems: overwrite only when refreshing an existing file.
+ $overwrite = $wp_filesystem->exists( $cache_path );
+ if ( ! $wp_filesystem->move( $temp_path, $cache_path, $overwrite ) ) {
+ $wp_filesystem->delete( $temp_path );
+ }
}
/**
@@ -264,6 +293,10 @@ class Translations {
$access_type = get_filesystem_method();
if ( 'direct' === $access_type ) {
+ // Skip the combine work when the file could never be saved anyway.
+ if ( ! wp_is_writable( $lang_dir ) ) {
+ return;
+ }
\WP_Filesystem();
$this->build_and_save_translations( $lang_dir, self::$plugin_domain, $locale );
} else {
@@ -307,8 +340,20 @@ class Translations {
$locale = determine_locale();
$cache_filename = $this->get_combined_translation_filename( $domain, $locale );
+ $cache_path = WP_LANG_DIR . '/plugins/' . $cache_filename;
+
+ // Rebuild the combined file on demand, since it is only generated on language pack updates and plugin activation.
+ if ( ! is_readable( $cache_path ) && ! $this->regeneration_attempted ) {
+ $this->regeneration_attempted = true;
+ $this->generate_translation_strings();
+ }
+
+ // Fall back so the app's own translation file still loads.
+ if ( ! is_readable( $cache_path ) ) {
+ return $file;
+ }
- return WP_LANG_DIR . '/plugins/' . $cache_filename;
+ return $cache_path;
}
/**
diff --git a/plugins/woocommerce/tests/php/src/Internal/Admin/TranslationsTest.php b/plugins/woocommerce/tests/php/src/Internal/Admin/TranslationsTest.php
new file mode 100644
index 00000000000..a07ad03476d
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Admin/TranslationsTest.php
@@ -0,0 +1,206 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\Admin;
+
+use Automattic\WooCommerce\Internal\Admin\Translations;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the Translations class.
+ */
+class TranslationsTest extends WC_Unit_Test_Case {
+
+ /**
+ * The System Under Test.
+ *
+ * @var Translations
+ */
+ private $sut;
+
+ /**
+ * Path to the language files directory.
+ *
+ * @var string
+ */
+ private $lang_dir;
+
+ /**
+ * Files created during the test, removed on teardown.
+ *
+ * @var string[]
+ */
+ private $created_files = array();
+
+ /**
+ * Set up test fixtures.
+ */
+ public function setUp(): void {
+ parent::setUp();
+
+ $this->sut = Translations::get_instance();
+ $this->lang_dir = WP_LANG_DIR . '/plugins/';
+
+ // The singleton keeps its once-per-request regeneration guard across tests.
+ $guard = new \ReflectionProperty( Translations::class, 'regeneration_attempted' );
+ $guard->setAccessible( true );
+ $guard->setValue( $this->sut, false );
+
+ wp_mkdir_p( $this->lang_dir );
+
+ add_filter( 'pre_determine_locale', array( $this, 'force_de_locale' ) );
+ }
+
+ /**
+ * Tear down test fixtures.
+ */
+ public function tearDown(): void {
+ remove_filter( 'pre_determine_locale', array( $this, 'force_de_locale' ) );
+
+ $combined = $this->lang_dir . 'woocommerce-de_DE-' . WC_ADMIN_APP . '.json';
+ if ( file_exists( $combined ) ) {
+ wp_delete_file( $combined );
+ }
+ foreach ( $this->created_files as $file ) {
+ if ( file_exists( $file ) ) {
+ wp_delete_file( $file );
+ }
+ }
+ $this->created_files = array();
+
+ parent::tearDown();
+ }
+
+ /**
+ * Forces determine_locale() to return de_DE.
+ *
+ * @return string Locale.
+ */
+ public function force_de_locale() {
+ return 'de_DE';
+ }
+
+ /**
+ * Writes a chunk translation JSON file in the official language pack format.
+ *
+ * @param string $md5_name Fake md5 part of the filename.
+ * @param string $reference Reference file path stored in the JSON.
+ * @param array $messages Translation messages.
+ */
+ private function create_chunk_json( $md5_name, $reference, $messages ) {
+ $data = array(
+ 'translation-revision-date' => '2026-01-01 00:00:00+0000',
+ 'generator' => 'GlotPress',
+ 'domain' => 'messages',
+ 'locale_data' => array(
+ 'messages' => array_merge(
+ array(
+ '' => array(
+ 'domain' => 'messages',
+ 'plural-forms' => 'nplurals=2; plural=n != 1;',
+ 'lang' => 'de',
+ ),
+ ),
+ $messages
+ ),
+ ),
+ 'comment' => array( 'reference' => $reference ),
+ );
+
+ $path = $this->lang_dir . 'woocommerce-de_DE-' . $md5_name . '.json';
+ file_put_contents( $path, wp_json_encode( $data ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
+ $this->created_files[] = $path;
+ }
+
+ /**
+ * @testdox Should leave the translation file untouched for other script handles.
+ */
+ public function test_ignores_other_script_handles(): void {
+ $result = $this->sut->load_script_translation_file( '/some/file.json', 'some-other-script', 'woocommerce' );
+
+ $this->assertSame( '/some/file.json', $result, 'Files for other handles should not be redirected' );
+ }
+
+ /**
+ * @testdox Should leave the translation file untouched for other text domains.
+ */
+ public function test_ignores_other_text_domains(): void {
+ $result = $this->sut->load_script_translation_file( '/some/file.json', WC_ADMIN_APP, 'some-other-domain' );
+
+ $this->assertSame( '/some/file.json', $result, 'Files for other domains should not be redirected' );
+ }
+
+ /**
+ * @testdox Should return the combined translation file when it exists.
+ */
+ public function test_returns_combined_file_when_present(): void {
+ $combined = $this->lang_dir . 'woocommerce-de_DE-' . WC_ADMIN_APP . '.json';
+ file_put_contents( $combined, '{}' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
+
+ $result = $this->sut->load_script_translation_file( '/some/file.json', WC_ADMIN_APP, 'woocommerce' );
+
+ $this->assertSame( $combined, $result, 'The combined translation file should be served when present' );
+ }
+
+ /**
+ * @testdox Should rebuild the combined translation file on demand from chunk files.
+ */
+ public function test_rebuilds_missing_combined_file_from_chunks(): void {
+ $this->create_chunk_json(
+ 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
+ WC_ADMIN_DIST_JS_FOLDER . 'chunks/analytics-report-products.js',
+ array( 'Compare Products' => array( 'Produkte vergleichen' ) )
+ );
+ $this->create_chunk_json(
+ 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
+ WC_ADMIN_DIST_JS_FOLDER . 'app/index.js',
+ array( 'Show' => array( 'Anzeigen' ) )
+ );
+
+ $result = $this->sut->load_script_translation_file( '/some/file.json', WC_ADMIN_APP, 'woocommerce' );
+
+ $combined = $this->lang_dir . 'woocommerce-de_DE-' . WC_ADMIN_APP . '.json';
+ $this->assertSame( $combined, $result, 'The combined file should be rebuilt and served' );
+ $this->assertFileExists( $combined, 'The combined file should be written to disk' );
+
+ $messages = json_decode( file_get_contents( $combined ), true )['locale_data']['messages']; // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
+ $this->assertSame( array( 'Produkte vergleichen' ), $messages['Compare Products'], 'Chunk translations should be merged into the combined file' );
+ $this->assertSame( array( 'Anzeigen' ), $messages['Show'], 'App translations should be merged into the combined file' );
+ $this->assertSame( array(), glob( $this->lang_dir . '*.tmp' ), 'No temporary files should be left behind after the combined file is published' );
+ }
+
+ /**
+ * @testdox Should replace an existing combined file when rebuilding.
+ */
+ public function test_replaces_existing_combined_file_on_rebuild(): void {
+ $combined = $this->lang_dir . 'woocommerce-de_DE-' . WC_ADMIN_APP . '.json';
+ file_put_contents( $combined, '{"stale":true}' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
+ $this->create_chunk_json(
+ 'cccccccccccccccccccccccccccccccc',
+ WC_ADMIN_DIST_JS_FOLDER . 'chunks/analytics-report-products.js',
+ array( 'Compare Products' => array( 'Produkte vergleichen' ) )
+ );
+
+ if ( ! function_exists( 'get_filesystem_method' ) ) {
+ require_once ABSPATH . 'wp-admin/includes/file.php';
+ }
+ \WP_Filesystem();
+ $build = new \ReflectionMethod( Translations::class, 'build_and_save_translations' );
+ $build->setAccessible( true );
+ $build->invoke( $this->sut, $this->lang_dir, 'woocommerce', 'de_DE' );
+
+ $data = json_decode( file_get_contents( $combined ), true ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
+ $this->assertArrayNotHasKey( 'stale', $data, 'The stale combined file should be replaced' );
+ $this->assertSame( array( 'Produkte vergleichen' ), $data['locale_data']['messages']['Compare Products'], 'The replacement should contain the rebuilt translations' );
+ $this->assertSame( array(), glob( $this->lang_dir . '*.tmp' ), 'No temporary files should be left behind after replacing the combined file' );
+ }
+
+ /**
+ * @testdox Should fall back to the original file when nothing can be rebuilt.
+ */
+ public function test_falls_back_to_original_file_when_rebuild_not_possible(): void {
+ $result = $this->sut->load_script_translation_file( '/some/file.json', WC_ADMIN_APP, 'woocommerce' );
+
+ $this->assertSame( '/some/file.json', $result, 'The original file should be used when no combined file can be generated' );
+ }
+}