i18n: never get stuck on raw keys if a dynamic language import fails (#6503).

TAPi18n.init() awaited a dynamic import('./data/en.i18n.json') and only set TAPi18n.ready=true afterwards. If that dynamic chunk hung or rejected -- e.g. a stale snap client bundle after a refresh, the reported #6503 scenario -- ready stayed false forever and the '_' Blaze helper returned every key raw (the untranslated changeLanguagePopup-title), with no recovery. Two guards: (1) the default English is now STATICALLY bundled (import enData from './data/en.i18n.json') and registered first, so the UI is always readable even when dynamic import() is broken; every other language still loads lazily. (2) the normal loadLanguage('en') (which also layers custom DB translations) is bounded by a 10s timeout and its failure is swallowed, so ready.set(true) is reached in EVERY path. index.js also guards the startup so init can never leave an unhandled rejection. The ES-module-namespace unwrap and the timeout guard are extracted to a pure, unit-tested imports/i18n/loadHelpers.js and shared with loadLanguage().

Tests: tests/i18nLoadHelpers.test.cjs (10 checks) covers unwrapI18nModule (real namespace vs the #5756 literal-"default"-key trap, null/plain) and promiseWithTimeout (resolves/rejects in time, and REJECTS instead of hanging when the promise never settles), plus a source guard that init sets ready in every path. Note: this hardens the i18n (raw-key) symptom and makes it recoverable; the dead OIDC/Register buttons need in-browser confirmation of the stale dynamic-import cache (see issue notes).

Thanks to Alishara and xet7 !
This commit is contained in:
Lauri Ojansivu committed 2026-07-22 10:35:52 +03:00
1 parent ad9e7752b8
commit eb411d9a58
4 files changed
+199 -22

No files matched your search

+9 -2
View File
@@ -5,7 +5,14 @@ import './blaze';
export { TAPi18n };
// Initialize translations immediately and synchronously
// Initialize translations at startup. TAPi18n.init() is internally resilient
// (it registers the statically-bundled English and bounds the dynamic language
// load with a timeout, always setting `ready`), but guard the startup too so a
// stray rejection can never become an unhandled promise rejection (#6503).
Meteor.startup(async () => {
await TAPi18n.init();
try {
await TAPi18n.init();
} catch (e) {
console.error('TAPi18n.init failed at startup', e);
}
});
+50
View File
@@ -0,0 +1,50 @@
'use strict';
// Pure, dependency-free helpers for the i18n loader (imports/i18n/tap.js),
// extracted so the tricky module-unwrap and the timeout guard are unit-testable
// in plain Node without Meteor/i18next. Related to #6503: a hung or failed
// dynamic import() of a language file must never leave i18n permanently stuck.
// Unwrap a dynamic `import()` result that may be an ES-module namespace
// ({ default: {...}, __esModule: true } or a [Symbol.toStringTag]==='Module'
// object) into the bare translation map.
//
// NB: the translation data ITSELF contains a key literally named "default"
// (value "Default"/"Standard"), so we must NOT unwrap merely because a `default`
// property exists — only a genuine module namespace is unwrapped (#5756).
function unwrapI18nModule(data) {
const isModuleNamespace =
data &&
typeof data === 'object' &&
(data.__esModule === true || data[Symbol.toStringTag] === 'Module') &&
data.default &&
typeof data.default === 'object';
return isModuleNamespace ? data.default : data;
}
// Race a promise against a timeout. The returned promise settles with the input
// promise if it settles within `ms`, otherwise it REJECTS with a timeout Error.
// Used so a hung dynamic import() of the default language file can never leave
// TAPi18n.ready stuck false (the UI would show raw keys forever) — after the
// timeout the caller falls back to the statically-bundled English (#6503).
function promiseWithTimeout(promise, ms, timeoutMessage) {
return new Promise((resolve, reject) => {
let settled = false;
const finish = (fn, v) => {
if (settled) return;
settled = true;
clearTimeout(timer);
fn(v);
};
const timer = setTimeout(
() => finish(reject, new Error(timeoutMessage || `timed out after ${ms}ms`)),
ms,
);
Promise.resolve(promise).then(
v => finish(resolve, v),
e => finish(reject, e),
);
});
}
module.exports = { unwrapI18nModule, promiseWithTimeout };
+46 -20
View File
@@ -3,6 +3,18 @@ import { ReactiveVar } from 'meteor/reactive-var';
import i18next from 'i18next';
import sprintf from 'i18next-sprintf-postprocessor';
import languages from './languages';
import { unwrapI18nModule, promiseWithTimeout } from './loadHelpers';
// #6503: STATICALLY bundle the default English translations. Every other
// language still loads lazily via `import('./data/<tag>.i18n.json')`, but the
// default must never depend on a dynamic import: if dynamic import() is broken
// (e.g. a stale snap client bundle after a refresh), the UI would otherwise be
// stuck showing raw i18n keys forever. With English in the main bundle the UI is
// always readable and `TAPi18n.ready` always resolves.
import enData from './data/en.i18n.json';
// How long to wait for the default language's dynamic load before falling back
// to the bundled English so readiness is never blocked by a hung import (#6503).
const DEFAULT_LANGUAGE_LOAD_TIMEOUT_MS = 10000;
const DEFAULT_NAMESPACE = 'translation';
const DEFAULT_LANGUAGE = 'en';
@@ -73,8 +85,35 @@ export const TAPi18n = {
},
resources: {},
});
// Load the current language data
await TAPi18n.loadLanguage(DEFAULT_LANGUAGE);
// Register the STATICALLY bundled English first, so the default language is
// available even if dynamic import() is broken (#6503). This guarantees a
// readable UI and that readiness below is never at the mercy of a lazy chunk.
try {
this.i18n.addResourceBundle(
this.toI18nCode(DEFAULT_LANGUAGE),
DEFAULT_NAMESPACE,
unwrapI18nModule(enData),
);
} catch (e) {
if (Meteor.isClient) console.error('TAPi18n.init: failed to register bundled English', e);
}
// Then load the current language data the normal way (this also layers any
// custom DB translations on top of the built-in English). A failed or HUNG
// dynamic import must never leave i18n permanently unready — bound it with a
// timeout and swallow the error; the bundled English above still applies.
try {
await promiseWithTimeout(
TAPi18n.loadLanguage(DEFAULT_LANGUAGE),
DEFAULT_LANGUAGE_LOAD_TIMEOUT_MS,
'TAPi18n: default language load timed out',
);
} catch (e) {
if (Meteor.isClient) {
console.error('TAPi18n.init: default language dynamic load failed; using bundled English', e);
}
}
// ALWAYS reached now, in every path, so the UI never gets stuck on raw keys.
this.ready.set(true);
},
// Resolve an arbitrary language string to the canonical Wekan tag (a key in
@@ -137,26 +176,13 @@ export const TAPi18n = {
// 'zh') still finds the right file and registers under the code i18next looks up.
const key = this.resolveTag(language);
if (key && 'load' in languages[key]) {
let data = await languages[key].load();
// Dynamic `import()` of a JSON module can resolve to an ES-module
// namespace ({ default: {...} }) rather than the bare object, depending on
// the bundler/interop. Unwrap it so addResourceBundle receives the actual
// translation map (#5756).
//
// NB: the translation data ITSELF contains a key literally named
// "default" (value "Default"/"Standard"), so we must NOT unwrap on the
// mere presence of a `default` property. Only unwrap a genuine ES-module
// namespace, identified by its `__esModule` / Symbol.toStringTag marker
// and a `default` that is the real (object) translation map.
const isModuleNamespace =
data &&
typeof data === 'object' &&
(data.__esModule === true || data[Symbol.toStringTag] === 'Module') &&
data.default &&
typeof data.default === 'object';
if (isModuleNamespace) {
data = data.default;
}
// the bundler/interop. unwrapI18nModule unwraps a genuine module namespace
// (identified by __esModule / Symbol.toStringTag) but NOT a plain object
// that merely has a "default" key — the translation data itself contains a
// literal "default" key (#5756). Shared with init()'s bundled English.
let data = unwrapI18nModule(await languages[key].load());
let custom_translations = [];
await this.loadTranslation(key);
+94
View File
@@ -0,0 +1,94 @@
'use strict';
// Unit + negative tests for the i18n loader helpers (imports/i18n/loadHelpers.js),
// the robustness guards behind #6503 (a hung/failed dynamic language import must
// never leave i18n permanently stuck on raw keys).
// Run: node tests/i18nLoadHelpers.test.cjs
const assert = require('assert');
const {
unwrapI18nModule,
promiseWithTimeout,
} = require('../imports/i18n/loadHelpers');
let passed = 0;
const pending = [];
function check(name, fn) {
pending.push(
Promise.resolve().then(fn).then(() => { passed += 1; console.log(' ok -', name); }),
);
}
// ── unwrapI18nModule ────────────────────────────────────────────────────────
check('unwraps a real ES-module namespace (__esModule)', () => {
const ns = { __esModule: true, default: { hello: 'Hello' } };
assert.deepStrictEqual(unwrapI18nModule(ns), { hello: 'Hello' });
});
check('unwraps a Symbol.toStringTag "Module" namespace', () => {
const ns = { default: { a: '1' } };
Object.defineProperty(ns, Symbol.toStringTag, { value: 'Module' });
assert.deepStrictEqual(unwrapI18nModule(ns), { a: '1' });
});
check('#5756 trap: does NOT unwrap a plain object that merely has a "default" key', () => {
// Real translation data contains a literal key named "default".
const data = { default: 'Default', hello: 'Hello' };
assert.deepStrictEqual(unwrapI18nModule(data), data, 'must return the object unchanged');
});
check('returns non-namespace values unchanged (plain object / null / undefined)', () => {
const plain = { x: 'y' };
assert.strictEqual(unwrapI18nModule(plain), plain);
assert.strictEqual(unwrapI18nModule(null), null);
assert.strictEqual(unwrapI18nModule(undefined), undefined);
});
check('does not unwrap when default is not an object', () => {
const ns = { __esModule: true, default: 'nope' };
assert.deepStrictEqual(unwrapI18nModule(ns), ns);
});
// ── promiseWithTimeout ──────────────────────────────────────────────────────
check('resolves with the value when the promise settles in time', async () => {
const v = await promiseWithTimeout(Promise.resolve(42), 1000);
assert.strictEqual(v, 42);
});
check('rejects with the original error when the promise rejects in time', async () => {
await assert.rejects(
() => promiseWithTimeout(Promise.reject(new Error('boom')), 1000),
/boom/,
);
});
check('#6503: REJECTS (does not hang) when the promise never settles', async () => {
const neverSettles = new Promise(() => {}); // simulates a hung dynamic import()
await assert.rejects(
() => promiseWithTimeout(neverSettles, 20, 'timed out'),
/timed out/,
'a hung load must time out so the caller can fall back to bundled English',
);
});
check('a value that settles just before the timeout still wins over the timeout', async () => {
const slow = new Promise(resolve => setTimeout(() => resolve('ok'), 5));
const v = await promiseWithTimeout(slow, 200);
assert.strictEqual(v, 'ok');
});
// ── source guard: init() no longer blocks readiness on the dynamic load ─────
check('tap.js init registers bundled English and always sets ready (source guard)', () => {
const fs = require('fs');
const path = require('path');
const src = fs.readFileSync(path.join(__dirname, '..', 'imports', 'i18n', 'tap.js'), 'utf8');
assert.ok(/import enData from '\.\/data\/en\.i18n\.json'/.test(src),
'the default English must be statically imported');
assert.ok(/promiseWithTimeout\(\s*TAPi18n\.loadLanguage\(DEFAULT_LANGUAGE\)/.test(src),
'the default dynamic load must be bounded by a timeout');
// ready.set(true) must sit OUTSIDE the try, after the catch, so every path reaches it.
const idx = src.indexOf('this.ready.set(true);');
const catchIdx = src.lastIndexOf('} catch (e) {', idx);
assert.ok(idx > -1 && catchIdx > -1 && idx > catchIdx,
'ready.set(true) must run after the try/catch (reached in every path)');
});
Promise.all(pending).then(() => {
console.log(`\ni18nLoadHelpers: ${passed} checks passed`);
}).catch(err => {
console.error('FAILED:', err && err.message);
process.exit(1);
});