mirror of
https://github.com/wekan/wekan.git
synced 2026-11-05 01:20:07 -05:00
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:
1 parent
ad9e7752b8
commit
eb411d9a58
4 files changed
+199
-22
No files matched your search
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user