From b81b378a9d5f8f74f454954a6197bdb0f5ad4279 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Wed, 6 Nov 2024 10:57:28 +0100 Subject: [PATCH 1/5] Use regex in `get_path_from_lang_dir` for more accurate results --- src/wp-includes/class-wp-textdomain-registry.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/class-wp-textdomain-registry.php b/src/wp-includes/class-wp-textdomain-registry.php index c62e751b6e47b..d6c81c3480a0c 100644 --- a/src/wp-includes/class-wp-textdomain-registry.php +++ b/src/wp-includes/class-wp-textdomain-registry.php @@ -321,9 +321,15 @@ private function get_path_from_lang_dir( $domain, $locale ) { $php_path = "$location/$domain-$locale.l10n.php"; foreach ( $files as $file_path ) { + $basename = str_replace( "$location/", '', $file_path ); + + /* + * Match "some-domain-de_DE.l10n.php or "some-domain-de_DE.mo", + * but not "some-de_DE.l10n.php or "some-de_DE.mo". + */ if ( ! in_array( $domain, $this->domains_with_translations, true ) && - str_starts_with( str_replace( "$location/", '', $file_path ), "$domain-" ) + 1 === preg_match( '/^' . preg_quote( $domain ) . '-[^-.]+\./', $basename ) ) { $this->domains_with_translations[] = $domain; } From 0ab7124160ee3cd7df5420d3bd310733757a83eb Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Wed, 6 Nov 2024 11:36:59 +0100 Subject: [PATCH 2/5] Try removing `$domains_with_translations` --- .../class-wp-textdomain-registry.php | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/src/wp-includes/class-wp-textdomain-registry.php b/src/wp-includes/class-wp-textdomain-registry.php index d6c81c3480a0c..4af2208c84bdc 100644 --- a/src/wp-includes/class-wp-textdomain-registry.php +++ b/src/wp-includes/class-wp-textdomain-registry.php @@ -63,8 +63,11 @@ class WP_Textdomain_Registry { * Holds a cached list of domains with translations to improve performance. * * @since 6.2.0 + * @since 6.8.0 This property is no longer used. * * @var string[] + * + * @deprecated */ protected $domains_with_translations = array(); @@ -119,8 +122,7 @@ public function get( $domain, $locale ) { public function has( $domain ) { return ( isset( $this->current[ $domain ] ) || - empty( $this->all[ $domain ] ) || - in_array( $domain, $this->domains_with_translations, true ) + empty( $this->all[ $domain ] ) ); } @@ -321,19 +323,6 @@ private function get_path_from_lang_dir( $domain, $locale ) { $php_path = "$location/$domain-$locale.l10n.php"; foreach ( $files as $file_path ) { - $basename = str_replace( "$location/", '', $file_path ); - - /* - * Match "some-domain-de_DE.l10n.php or "some-domain-de_DE.mo", - * but not "some-de_DE.l10n.php or "some-de_DE.mo". - */ - if ( - ! in_array( $domain, $this->domains_with_translations, true ) && - 1 === preg_match( '/^' . preg_quote( $domain ) . '-[^-.]+\./', $basename ) - ) { - $this->domains_with_translations[] = $domain; - } - if ( $file_path === $mo_path || $file_path === $php_path ) { $found_location = rtrim( $location, '/' ) . '/'; break 2; From bfeaf3a5cb7716d449fb4b2da50d399249f92954 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 10:54:20 +0000 Subject: [PATCH 3/5] I18N: Keep just-in-time loading working after a custom path is registered. Removing the `$domains_with_translations` property also removed the only condition in `WP_Textdomain_Registry::has()` that covers the state `set_custom_path()` leaves behind: `current` unset because the last lookup missed, while `all` still holds a resolved path for another locale. In that state `has()` started returning false, and because `load_plugin_textdomain()` and `load_theme_textdomain()` only register a path and hand the loading off to `_load_textdomain_just_in_time()`, the already known translations stopped being loaded entirely. That state is only reachable once a custom path has been registered, so check for the custom path directly instead. This keeps the scan (and the deprecated property) out of `get_path_from_lang_dir()` while restoring the previous behaviour. Adds tests covering `has()` for every state it can be asked about, including text domains that merely share a prefix with a translated one, and an end-to-end test for the locale-switch-then-`load_plugin_textdomain()` case. See #62348. --- .../class-wp-textdomain-registry.php | 13 +- .../tests/l10n/loadTextdomainJustInTime.php | 69 ++++++++ .../tests/l10n/wpTextdomainRegistry.php | 167 ++++++++++++++++++ 3 files changed, 245 insertions(+), 4 deletions(-) diff --git a/src/wp-includes/class-wp-textdomain-registry.php b/src/wp-includes/class-wp-textdomain-registry.php index 4af2208c84bdc..7d924d5abdf1d 100644 --- a/src/wp-includes/class-wp-textdomain-registry.php +++ b/src/wp-includes/class-wp-textdomain-registry.php @@ -63,7 +63,7 @@ class WP_Textdomain_Registry { * Holds a cached list of domains with translations to improve performance. * * @since 6.2.0 - * @since 6.8.0 This property is no longer used. + * @since 7.2.0 This property is no longer used. * * @var string[] * @@ -111,10 +111,14 @@ public function get( $domain, $locale ) { * Determines whether any MO file paths are available for the domain. * * This is the case if a path has been set for the current locale, - * or if there is no information stored yet, in which case - * {@see _load_textdomain_just_in_time()} will fetch the information first. + * if there is no information stored yet, in which case + * {@see _load_textdomain_just_in_time()} will fetch the information first, + * or if a custom path has been registered via {@see load_plugin_textdomain()} + * or {@see load_theme_textdomain()}, which is always worth looking at. * * @since 6.1.0 + * @since 7.2.0 Checks for a registered custom path instead of the + * `$domains_with_translations` property. * * @param string $domain Text domain. * @return bool Whether any MO file paths are available for the domain. @@ -122,7 +126,8 @@ public function get( $domain, $locale ) { public function has( $domain ) { return ( isset( $this->current[ $domain ] ) || - empty( $this->all[ $domain ] ) + empty( $this->all[ $domain ] ) || + isset( $this->custom_paths[ $domain ] ) ); } diff --git a/tests/phpunit/tests/l10n/loadTextdomainJustInTime.php b/tests/phpunit/tests/l10n/loadTextdomainJustInTime.php index af8bb8825ac9e..7d1345b181179 100644 --- a/tests/phpunit/tests/l10n/loadTextdomainJustInTime.php +++ b/tests/phpunit/tests/l10n/loadTextdomainJustInTime.php @@ -372,4 +372,73 @@ public function test_plugin_translation_should_be_translated_when_calling_load_p $this->assertSame( 'Das ist ein Dummy Plugin', $output_after ); $this->assertTrue( $is_textdomain_loaded_after ); } + + /** + * Text domains that merely share a prefix with a translated one must not be + * treated as translated themselves. + * + * "internationalized-plugin-de_DE.mo" exists, but there is no + * "internationalized-de_DE.mo", so the "internationalized" text domain has no + * translations at all. + * + * @ticket 62348 + * + * @covers ::_load_textdomain_just_in_time + * @covers ::is_textdomain_loaded + */ + public function test_text_domain_sharing_a_prefix_with_a_translated_one_is_not_translated() { + add_filter( 'locale', array( $this, 'filter_set_locale_to_german' ) ); + + $actual = __( 'This is a dummy plugin', 'internationalized' ); + + remove_filter( 'locale', array( $this, 'filter_set_locale_to_german' ) ); + + $this->assertFalse( + is_textdomain_loaded( 'internationalized' ), + 'The "internationalized" text domain should not be considered loaded' + ); + $this->assertSame( + 'This is a dummy plugin', + $actual, + 'A text domain sharing a prefix with a translated one should not be translated' + ); + } + + /** + * Calling load_plugin_textdomain() after translations have already been loaded + * for one locale and missed for another must not stop just-in-time loading. + * + * Since 6.7.0 load_plugin_textdomain() only registers a custom path and leaves + * the loading to _load_textdomain_just_in_time(), which bails out early when + * WP_Textdomain_Registry::has() returns false. + * + * @ticket 62348 + * + * @covers ::_load_textdomain_just_in_time + * @covers ::load_plugin_textdomain + */ + public function test_plugin_translations_survive_a_late_load_plugin_textdomain_call() { + require_once DIR_TESTDATA . '/plugins/internationalized-plugin.php'; + + // de_DE translations are found in the WordPress languages directory. + switch_to_locale( 'de_DE' ); + $before = i18n_plugin_test(); + + // A locale without any translations for this plugin is used in between. + switch_to_locale( 'ja_JP' ); + $untranslated = i18n_plugin_test(); + + // Only now does the plugin register its own languages directory. + load_plugin_textdomain( 'internationalized-plugin', false, 'internationalized-plugin/languages' ); + + restore_previous_locale(); + $after = i18n_plugin_test(); + + restore_current_locale(); + + $this->assertSame( 'Das ist ein Dummy Plugin', $before, 'de_DE translations should be loaded up front' ); + $this->assertSame( 'This is a dummy plugin', $untranslated, 'There should be no ja_JP translations' ); + $this->assertSame( 'Das ist ein Dummy Plugin', $after, 'de_DE translations should survive load_plugin_textdomain()' ); + } + } diff --git a/tests/phpunit/tests/l10n/wpTextdomainRegistry.php b/tests/phpunit/tests/l10n/wpTextdomainRegistry.php index f5a12ff779dde..5f1ed45c254c6 100644 --- a/tests/phpunit/tests/l10n/wpTextdomainRegistry.php +++ b/tests/phpunit/tests/l10n/wpTextdomainRegistry.php @@ -23,6 +23,7 @@ public function tear_down() { wp_cache_delete( md5( WP_LANG_DIR . '/plugins/' ), 'translation_files' ); wp_cache_delete( md5( WP_LANG_DIR . '/themes/' ), 'translation_files' ); wp_cache_delete( md5( WP_LANG_DIR . '/' ), 'translation_files' ); + wp_cache_delete( md5( WP_PLUGIN_DIR . '/custom-internationalized-plugin/languages/' ), 'translation_files' ); parent::tear_down(); } @@ -150,6 +151,172 @@ public function test_invalidate_mo_files_cache() { $this->assertFalse( wp_cache_get( md5( WP_LANG_DIR . '/' ), 'translation_files' ) ); } + /** + * The registry answers "yes" for a text domain it has never looked up, + * so that _load_textdomain_just_in_time() gets a chance to resolve it. + * + * @ticket 62348 + * + * @covers ::has + */ + public function test_has_returns_true_for_unknown_text_domain() { + $this->assertTrue( $this->instance->has( 'unknown-plugin' ) ); + } + + /** + * A lookup that found nothing still records the (negative) result for the + * current locale, which keeps has() truthy. + * + * @ticket 62348 + * + * @covers ::has + * @covers ::get + */ + public function test_has_returns_true_after_unsuccessful_lookup() { + $this->assertFalse( + $this->instance->get( 'unknown-plugin', 'de_DE' ), + 'A text domain without translations should not resolve to a path' + ); + $this->assertTrue( + $this->instance->has( 'unknown-plugin' ), + 'The negative result for the current locale should still be reported as available' + ); + } + + /** + * Registering a custom path late must not make previously found translations + * unreachable. + * + * load_plugin_textdomain() and load_theme_textdomain() only register a custom + * path and hand the actual loading off to _load_textdomain_just_in_time(), + * which bails early when has() returns false. + * + * @ticket 62348 + * + * @covers ::has + * @covers ::set_custom_path + */ + public function test_has_after_custom_path_is_registered_following_a_successful_lookup() { + // A first locale resolves to the WordPress languages directory. + $this->assertSame( + WP_LANG_DIR . '/plugins/', + $this->instance->get( 'internationalized-plugin', 'de_DE' ), + 'de_DE translations should be found in the WordPress languages directory' + ); + + // A second locale has no translations at all, so 'current' becomes false. + $this->assertFalse( + $this->instance->get( 'internationalized-plugin', 'fr_FR' ), + 'There should be no fr_FR translations' + ); + + // Only now does the plugin call load_plugin_textdomain(). + $this->instance->set_custom_path( + 'internationalized-plugin', + WP_PLUGIN_DIR . '/custom-internationalized-plugin/languages' + ); + + $this->assertTrue( + $this->instance->has( 'internationalized-plugin' ), + 'Registering a custom path should not hide the already known translations' + ); + $this->assertSame( + WP_LANG_DIR . '/plugins/', + $this->instance->get( 'internationalized-plugin', 'de_DE' ), + 'de_DE translations should still be found after registering a custom path' + ); + } + + /** + * Same as above, except that no locale ever resolved, so there is nothing + * left to remember once the negative results have been discarded. + * + * @ticket 62348 + * + * @covers ::has + * @covers ::set_custom_path + */ + public function test_has_after_custom_path_is_registered_following_an_unsuccessful_lookup() { + $this->assertFalse( + $this->instance->get( 'unknown-plugin', 'de_DE' ), + 'There should be no de_DE translations' + ); + + $this->instance->set_custom_path( + 'unknown-plugin', + WP_PLUGIN_DIR . '/custom-internationalized-plugin/languages' + ); + + $this->assertTrue( + $this->instance->has( 'unknown-plugin' ), + 'The newly registered custom path should be given a chance' + ); + } + + /** + * Text domains are matched in full, not by prefix. + * + * "internationalized-plugin-de_DE.mo" must not count as a translation for the + * "internationalized" text domain just because the file name starts with it. + * + * @ticket 62348 + * + * @covers ::get + * @covers ::has + * @dataProvider data_text_domains_sharing_a_prefix + * + * @param string $domain Text domain that has no translations of its own. + * @param string $locale Locale to look up. + */ + public function test_translations_are_not_shared_between_text_domains_with_a_common_prefix( $domain, $locale ) { + $this->assertFalse( + $this->instance->get( $domain, $locale ), + 'A text domain sharing a prefix with a translated one should not resolve to a path' + ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_text_domains_sharing_a_prefix() { + return array( + // "internationalized-plugin-{de_DE,es_ES}.mo" exist, "internationalized-*" do not. + 'prefix of a domain with .mo files' => array( 'internationalized', 'de_DE' ), + 'prefix of a domain with .mo files, es' => array( 'internationalized', 'es_ES' ), + // "internationalized-plugin-2-de_DE.l10n.php" exists, but is not a + // translation of "internationalized-plugin". + 'domain whose sibling adds a suffix' => array( 'internationalized-plugin', 'fr_FR' ), + ); + } + + /** + * Once a custom path is registered, the domain must stay resolvable even when + * an unrelated text domain shares its prefix. + * + * @ticket 62348 + * + * @covers ::has + * @covers ::set_custom_path + */ + public function test_has_with_custom_path_is_unaffected_by_text_domains_sharing_a_prefix() { + $this->assertFalse( + $this->instance->get( 'internationalized', 'de_DE' ), + 'The "internationalized" text domain has no translations of its own' + ); + + $this->instance->set_custom_path( + 'internationalized', + WP_PLUGIN_DIR . '/custom-internationalized-plugin/languages' + ); + + $this->assertTrue( + $this->instance->has( 'internationalized' ), + 'The newly registered custom path should be given a chance' + ); + } + public function data_domains_locales() { return array( 'Non-existent plugin' => array( From 1f73da8ea259fa820913fdd9b8eefbf32ec237e0 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Fri, 21 Aug 2026 13:35:28 +0200 Subject: [PATCH 4/5] Remove empty line --- tests/phpunit/tests/l10n/loadTextdomainJustInTime.php | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/phpunit/tests/l10n/loadTextdomainJustInTime.php b/tests/phpunit/tests/l10n/loadTextdomainJustInTime.php index 7d1345b181179..ebf7dfc6da47d 100644 --- a/tests/phpunit/tests/l10n/loadTextdomainJustInTime.php +++ b/tests/phpunit/tests/l10n/loadTextdomainJustInTime.php @@ -440,5 +440,4 @@ public function test_plugin_translations_survive_a_late_load_plugin_textdomain_c $this->assertSame( 'This is a dummy plugin', $untranslated, 'There should be no ja_JP translations' ); $this->assertSame( 'Das ist ein Dummy Plugin', $after, 'de_DE translations should survive load_plugin_textdomain()' ); } - } From 005789944882d5cb8fe586ff6532dce09566de50 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Fri, 21 Aug 2026 13:40:58 +0200 Subject: [PATCH 5/5] Lint fixes --- tests/phpunit/tests/l10n/wpTextdomainRegistry.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/phpunit/tests/l10n/wpTextdomainRegistry.php b/tests/phpunit/tests/l10n/wpTextdomainRegistry.php index 5f1ed45c254c6..1402a86ce0ad4 100644 --- a/tests/phpunit/tests/l10n/wpTextdomainRegistry.php +++ b/tests/phpunit/tests/l10n/wpTextdomainRegistry.php @@ -283,11 +283,11 @@ public function test_translations_are_not_shared_between_text_domains_with_a_com public function data_text_domains_sharing_a_prefix() { return array( // "internationalized-plugin-{de_DE,es_ES}.mo" exist, "internationalized-*" do not. - 'prefix of a domain with .mo files' => array( 'internationalized', 'de_DE' ), - 'prefix of a domain with .mo files, es' => array( 'internationalized', 'es_ES' ), + 'prefix of a domain with .mo files' => array( 'internationalized', 'de_DE' ), + 'prefix of a domain with .mo files, es' => array( 'internationalized', 'es_ES' ), // "internationalized-plugin-2-de_DE.l10n.php" exists, but is not a // translation of "internationalized-plugin". - 'domain whose sibling adds a suffix' => array( 'internationalized-plugin', 'fr_FR' ), + 'domain whose sibling adds a suffix' => array( 'internationalized-plugin', 'fr_FR' ), ); }