Problem/Motivation
On multilingual sites using the locale and language modules, programmatically saving active (English) configuration can incorrectly overwrite existing interface translations in locales_target, marking them as customized with the English source string. This happens when:
- Active configuration is saved with new or changed translatable values (e.g. during hook_update_N(), module install, or reinstalling shipped configuration such as a View).
- LanguageConfigFactoryOverride::onConfigSave() runs filterOverride() for existing language overrides (e.g. language.zh-hans).
- The filtered override is saved or deleted, which dispatches LanguageConfigOverrideEvents::SAVE_OVERRIDE / DELETE_OVERRIDE.
- LocaleConfigSubscriber::onOverrideChange() runs with LocaleConfigManager::isUpdatingTranslationsFromLocale() === FALSE.
- For translatable keys that exist in the active (English) configuration but are not present in the language override, LocaleConfigSubscriber::resetExistingTranslations() calls saveCustomizedTranslation() and writes the English source into locales_target with customized = 1.
Because interface translation strings are shared across configuration objects, this can affect multiple configs that use the same source string (e.g. a field label and a View field/filter label).
This is especially harmful because:
- The site did not intentionally change a configuration translation in the UI.
- The change is caused by automatic override structure synchronization after saving English configuration.
- Subsequent locale:update runs with overwrite_customized: false will not restore the original community/module translations.
Example scenario
A site has zh-hans configured with imported translations from .po files. Strings such as Nickname, Gender, Birthday, and Phone number are correctly translated with customized = 0.
An update hook reinstalls views.view.example from shipped configuration in config/install/, adding new translatable labels. After drush updb:
- locales_target for those strings becomes the English source.
- customized is set to 1.
- Both views.view.example and unrelated configs sharing the same source string (e.g. field.field.*) are affected.
Avatar and other newly added strings that were not previously in the override may behave differently, while existing shared strings are corrupted.
Root cause
LocaleConfigSubscriber::processTranslatableData() treats a missing key in a language override the same whether:
- the key was never translated yet because it was newly added to active configuration, or
- the key was explicitly removed by a user editing configuration translation.
In the first case, resetExistingTranslations() should not write back to locales_target. The override should be populated later by LocaleConfigManager::updateConfigTranslations() from existing interface translations.
There is a related historical note in #2925203 that it is difficult to distinguish all configuration save origins. This report narrows the scope to override changes triggered by LanguageConfigFactoryOverride::filterOverride() during active configuration saves.
Steps to reproduce
1.Install Drupal with locale, language, and config_translation enabled.
2.Add zh-hans and import translations for a custom module/profile (.po files).
3.Ensure shared strings such as Nickname, Gender, Birthday exist in locales_target with correct Chinese translations and customized = 0.
4.Ensure a translatable config object (e.g. a View) already has a language.zh-hans override.
5.Programmatically save the active (English) config object with new translatable labels that are not yet present in the zh-hans override. For example, reinstall a View from shipped configuration in config/install/.
6.Inspect locales_target for the affected source strings.
Actual result
•Affected strings in locales_target are overwritten with the English source.
•customized is set to 1.
•locales_location shows the strings linked to both the updated config and other configs sharing the same source.
Expected result
•Saving active English configuration should not mark existing interface translations as customized.
•New translatable keys that are not yet present in a language override should not trigger resetExistingTranslations().
•Existing translations should remain available for LocaleConfigManager::updateConfigTranslations() to apply to the override.
Proposed resolution
Implement one or both of the following:
Option A (preferred): Only reset when a translation key was removed from an override
In LocaleConfigSubscriber, only call resetExistingTranslations() when a translatable key was present in the previous override data and is now absent, not when the key was never present in the override.
This may require access to the original override data in LanguageConfigOverrideCrudEvent (or equivalent).
Option B: Skip locale feedback during override structure synchronization
When LanguageConfigFactoryOverride::onConfigSave() runs filterOverride(), set a flag (similar to LocaleConfigManager::$isUpdatingFromLocale) indicating that override changes are structural synchronization caused by an active configuration save. LocaleConfigSubscriber::onOverrideChange() should ignore those events. After structural changes, callers can rebuild overrides via:
\Drupal::service('locale.config_manager')->updateConfigTranslations([$config_name], $langcodes);
Optional hardening
In saveCustomizedTranslation(), avoid overwriting an existing non-customized translation with the source string when the change was not initiated by an explicit user translation edit.
Comments