diff --git a/front/js/cache.js b/front/js/cache.js index 4412a622..dcae0d18 100644 --- a/front/js/cache.js +++ b/front/js/cache.js @@ -50,6 +50,7 @@ const CACHE_KEYS = { // --- Internal init tracking --- GRAPHQL_STARTED: 'graphQLServerStarted', // set when GraphQL server responds STRINGS_COUNT: 'cacheStringsCountCompleted', // count of language packs loaded + STRINGS_LANG: 'cacheStrings_v2_language', // active non-English locale at last load COMPLETED_CALLS: 'completedCalls', // comma-joined list of completed init calls INIT_TIMESTAMP: 'nax_init_timestamp', // ms timestamp of last successful cache init CACHE_VERSION: 'nax_cache_version', // version stamp for auto-bust on deploy @@ -178,8 +179,10 @@ function cacheApiConfig() { // Module-level declaration so cachePluginStrings() and other callers can // safely reference pluginsData even before cacheSettings() has fetched it. -// Populated (or repopulated) by cacheSettings() on every full fetch. -var pluginsData = []; +// null = fetch not yet attempted or fetch failed (unknown state). +// [] = fetch succeeded and confirmed no plugins are configured. +// [...] = fetch succeeded and plugins are present. +var pluginsData = null; function cacheSettings() { @@ -192,12 +195,18 @@ function cacheSettings() // so the extra fetch would be pointless on every subsequent warm load. if (getCache(CACHE_KEYS.initFlag('cachePluginStrings_v1')) !== 'true') { fetchJson('plugins.json') - .catch(() => []) .then((pluginsArr) => { - if (Array.isArray(pluginsArr) && pluginsArr.length > 0) { + // Only update pluginsData on a successful fetch so that a network + // failure is not misread as "no plugins configured" (null sentinel). + if (Array.isArray(pluginsArr)) { pluginsData = pluginsArr; } resolve(); + }) + .catch(() => { + // Fetch failed — leave pluginsData as null (unknown state) so + // cachePluginStrings() retries rather than treating it as no-op. + resolve(); }); return; } @@ -312,10 +321,23 @@ function cacheStrings() { return new Promise((resolve, reject) => { if(getCache(CACHE_KEYS.initFlag('cacheStrings_v2')) === "true") { - // Core strings are already cached. Plugin strings are now handled by - // the dedicated cachePluginStrings() step — nothing to do here. - resolve(); - return; + // Verify STRINGS_COUNT and the stored language both match the current + // configuration. A count-only check allows a locale switch between two + // non-English languages (both count=2) to pass undetected, leaving the + // UI with stale strings for the old language. + const activeLang = getLangCode(); + const expectedCount = activeLang === 'en_us' ? 1 : 2; + const currentCount = parseInt(getCache(CACHE_KEYS.STRINGS_COUNT)) || 0; + const storedLang = getCache(CACHE_KEYS.STRINGS_LANG) || 'en_us'; + if (currentCount === expectedCount && storedLang === activeLang) { + resolve(); + return; + } + // Mismatch — reset count and fall through to reload strings for the + // current language set. The flag stays set; handleSuccess re-increments. + console.log(`[cacheStrings] stale cache (count: ${currentCount}/${expectedCount}, lang: '${storedLang}' → '${activeLang}') — reloading strings.`); + setCache(CACHE_KEYS.STRINGS_COUNT, 0); + // Do NOT return — fall through to full load path below. } // Create a promise for each language (include en_us by default as fallback) @@ -351,6 +373,9 @@ function cacheStrings() { // Wait for all language promises to complete Promise.all(languagePromises) .then(() => { + // Record the active language so the early-return check can detect + // a locale switch between two non-English languages (both count=2). + setCache(CACHE_KEYS.STRINGS_LANG, getLangCode()); resolve(); }) .catch((error) => { @@ -386,7 +411,10 @@ function cachePluginStrings() { // No plugin strings returned. If plugins are loaded the file may not // be ready yet — reject so retryStep() retries. If no plugins are // loaded at all, empty strings are expected: succeed. - const hasPlugins = Array.isArray(pluginsData) && pluginsData.length > 0; + // null = plugins.json fetch failed (unknown) — retry to be safe. + // [] = confirmed no plugins — accept empty strings as valid. + // [...] = plugins present — retry, file may not be ready yet. + const hasPlugins = pluginsData === null || (Array.isArray(pluginsData) && pluginsData.length > 0); if (hasPlugins) { console.warn('[cachePluginStrings] 0 entries returned but plugins are loaded — file may not be ready yet. Will retry.'); reject(new Error('Plugin language strings empty')); diff --git a/front/pluginsCore.php b/front/pluginsCore.php index 70ca8322..a8f36686 100755 --- a/front/pluginsCore.php +++ b/front/pluginsCore.php @@ -312,7 +312,7 @@ async function getData() { // module-level fetchJson defined in cache.js, which returns res['data'] and // is used by cachePluginStrings() during app initialization. async function fetchPluginJson(filename) { - const response = await fetch(`php/server/query_json.php?file=${filename}`); + const response = await fetch(`php/server/query_json.php?file=${encodeURIComponent(filename)}&nocache=${Date.now()}`); if (!response.ok) throw new Error(`Failed to load ${filename}`); return await response.json(); } diff --git a/test/ui/test_ui_translations.py b/test/ui/test_ui_translations.py index 7bbca4cf..aa775e9d 100644 --- a/test/ui/test_ui_translations.py +++ b/test/ui/test_ui_translations.py @@ -9,7 +9,6 @@ PRD: Reliable Loading of Plugin Translation Strings import sys import os -import time from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait @@ -161,16 +160,19 @@ def test_plugins_page_no_undefined_strings_after_cache_clear(driver): _wait_for_app_init(driver) - # Give the tabs a moment to render after init completes - time.sleep(2) + # Wait for getData() to complete: either tab links or the empty-state + # paragraph will appear once generateTabs() has run. + WebDriverWait(driver, 10).until( + lambda d: len(d.find_elements( + By.CSS_SELECTOR, + "#tabs-location a, #tabs-content-location p.text-muted" + )) > 0 + ) - # Allow the word 'undefined' inside code/technical blocks but not as a - # standalone visible label replacing a translation string. - # We check that it does not appear as a trimmed standalone word in headings/tabs. tab_labels = driver.find_elements(By.CSS_SELECTOR, "#tabs-location a, .tab-label, h5") for el in tab_labels: label = el.text.strip() assert label != "undefined", ( - f"Tab/heading label is literally 'undefined' — plugin strings were not " - f"loaded before getData() rendered the tabs on plugins.php." + "Tab/heading label is literally 'undefined' — plugin strings were not " + "loaded before getData() rendered the tabs on plugins.php." )