Opened 5 months ago
Last modified 5 weeks ago
#64249 new defect (bug)
Automatic translation (JIT) loading doesn't work for network-activated plugins in multisite
| Reported by: |
|
Owned by: | |
|---|---|---|---|
| Milestone: | 7.1 | Priority: | normal |
| Severity: | normal | Version: | 6.7 |
| Component: | I18N | Keywords: | has-patch |
| Focuses: | multisite | Cc: |
Description
Description
The automatic just-in-time (JIT) translation loading introduced in [59461] for WordPress 6.7+ only registers text domains for site-level active plugins, but completely skips network-activated plugins in multisite environments.
This causes PHP translations (via __(), _e(), etc.) to fail for network-activated plugins, even when the plugin has correct Text Domain and Domain Path headers and translation files are present. Interestingly, JavaScript translations (.json files) continue to work correctly.
Possible Root Cause
In wp-settings.php, the automatic translation registration code processes plugins from wp_get_active_and_valid_plugins() but does not process plugins from wp_get_active_network_plugins().
The relevant code added in [59461] around line 380-390 of wp-settings.php:
<?php // Register plugin translations for active plugins foreach ( wp_get_active_and_valid_plugins() as $plugin ) { $plugin_data = get_plugin_data( $plugin, false, false ); if ( ! empty( $plugin_data['TextDomain'] ) && ! empty( $plugin_data['DomainPath'] ) ) { // Translation registration happens here } }
However, there is no equivalent loop for network-activated plugins loaded earlier via wp_get_active_network_plugins().
Steps to Reproduce
Set up a WordPress Multisite installation (any configuration: subdirectory or subdomain)
Create or install a plugin with:
Proper plugin headers including Text Domain: my-plugin and Domain Path: /languages
Translation files in the plugin's /languages directory (e.g., my-plugin-de_DE.mo)
PHP code using translation functions like __('Text', 'my-plugin')
Network Activate the plugin
Set the site language to match the translation file (e.g., German de_DE)
View the plugin's output on the front-end or admin
Expected Behavior
The plugin's translations should load automatically without requiring load_plugin_textdomain(), just as they do for site-level activated plugins. The plugin should display translated text in German.
Actual Behavior
The plugin displays English text (the original untranslated strings) because the text domain was never registered during the WordPress bootstrap process. Network-activated plugins must explicitly call load_plugin_textdomain() as a workaround.
Change History (6)
This ticket was mentioned in Slack in #core by juanmaguitar. View the logs.
7 weeks ago
#4
@
5 weeks ago
I've reviewed the issue and can confirm the root cause.
In wp-settings.php, the JIT translation registration introduced in [59461] only runs for site-level active plugins. The network-activated plugins loop (which runs earlier in the bootstrap) does not register text domains with $wp_textdomain_registry, so the JIT mechanism (_load_textdomain_just_in_time()) has no path to resolve translations for those plugins.
The fix requires two related changes in wp-settings.php:
1. Move the plugin.php include earlier
Currently, get_plugin_data() is made available after the network plugins loop via:
// To make get_plugin_data() available in a way that's compatible with plugins also loading this file, see #62244. require_once ABSPATH . 'wp-admin/includes/plugin.php';
This line needs to be moved to just before the if ( is_multisite() ) network plugins block, so that get_plugin_data() is available when processing network plugins.
2. Add text domain registration to the network plugins loop
Mirror the same registration logic already present in the site plugins loop:
// Load network activated plugins.
if ( is_multisite() ) {
foreach ( wp_get_active_network_plugins() as $network_plugin ) {
wp_register_plugin_realpath( $network_plugin );
$plugin_data = get_plugin_data( $network_plugin, false, false );
$textdomain = $plugin_data['TextDomain'];
if ( $textdomain ) {
if ( $plugin_data['DomainPath'] ) {
$GLOBALS['wp_textdomain_registry']->set_custom_path(
$textdomain,
dirname( $network_plugin ) . $plugin_data['DomainPath']
);
} else {
$GLOBALS['wp_textdomain_registry']->set_custom_path(
$textdomain,
dirname( $network_plugin )
);
}
}
$_wp_plugin_file = $network_plugin;
include_once $network_plugin;
$network_plugin = $_wp_plugin_file;
do_action( 'network_plugin_loaded', $network_plugin );
}
unset( $network_plugin, $_wp_plugin_file, $plugin_data, $textdomain );
}
Notes on safety
- Moving the
require_onceearlier is low risk —plugin.phpwas already made safe for early loading as part of #62244. get_plugin_data()only reads the plugin file's comment header; it makes no database calls and has no side effects.set_custom_path()is idempotent, so plugins that currently callload_plugin_textdomain()explicitly as a workaround will continue to work correctly — the registry entry will simply be set twice with the same value.- JavaScript translations are unaffected (they already work, as noted in the ticket).
I'm happy to prepare a patch if this approach is agreeable.
#5
@
5 weeks ago
This solution is already proposed in the original ticket description. Yes, a patch is still needed.
This ticket was mentioned in PR #11243 on WordPress/wordpress-develop by @sanket.parmar.
5 weeks ago
#6
- Keywords has-patch added; needs-patch removed
## Problem
Since WordPress 6.7 (59461), the just-in-time (JIT) translation loading mechanism registers text domains by reading each plugin's Text Domain and Domain Path headers during bootstrap. However, this registration only happened for site-level active plugins (wp_get_active_and_valid_plugins()), completely skipping network-activated plugins (wp_get_active_network_plugins()) in multisite installations.
As a result, PHP translation functions (__(), _e(), etc.) silently fell back to untranslated strings for all network-activated plugins — even when correct translation files were present — because $wp_textdomain_registry had no path registered for those domains and _load_textdomain_just_in_time() had nothing to work with.
Note: JavaScript translations were unaffected as they use a separate mechanism.
## Changes
wp-settings.php
- Moves
require_once ABSPATH . 'wp-admin/includes/plugin.php'earlier in the bootstrap — from just before the site plugins loop to just before the must-use plugins loop — makingget_plugin_data()available for all plugin loading stages. Comment updated to reference both #62244 and #64249. - Adds text domain registration inside the network-activated plugins loop, mirroring the logic already present in the site plugins loop: reads
TextDomainandDomainPathheaders viaget_plugin_data()and calls$wp_textdomain_registry->set_custom_path()accordingly. - Adds
$plugin_dataand$textdomainto theunset()at the end of the network plugins block, consistent with the site plugins loop.
## Backward Compatibility
get_plugin_data()only reads the plugin file's comment header — no database calls, no side effects.set_custom_path()is idempotent. Plugins already callingload_plugin_textdomain()explicitly as a workaround will continue to work correctly.- Moving the
require_onceearlier carries negligible risk —plugin.phpwas already hardened for early loading in #62244.
## References
- Trac ticket: https://core.trac.wordpress.org/ticket/64249
- Original JIT changeset: https://core.trac.wordpress.org/changeset/59461
- Related ticket: https://core.trac.wordpress.org/ticket/62244
## Use of AI Tools
From today's bug scrub