feedback7 G-TOOLS: out-of-order language-pack responses no longer overwrite the newer choice (PR #304 by @siruimei07)
Applied as-is. initLang and switchLang record a request counter before the first await and apply a pack only when that request is still the latest. Choosing the language already on screen cancels an in-flight load. A failed latest choice still saves the preference and leaves the previous UI. Tests: node --test test/ui/lang.test.js test/ui/i18n.e2e.test.js test/i18n-packs.test.js test/i18n-server.test.js — 29 pass, 0 fail. The Chrome suite in test/ui/i18n.e2e.test.js skipped (SP_E2E unset).
This commit is contained in:
1 parent
7c307d8613
commit
5662b7ef65
3 files changed
+362
-5
No files matched your search
+19
-5
@@ -39,6 +39,8 @@ const uiLoads = new Map();
|
||||
let indexLoad = null;
|
||||
/** Whether the pack index arrived (without it the chain of a pack is taken from its own `_meta` as it loads). */
|
||||
let indexOk = false;
|
||||
/** Only the latest boot or explicit selection may apply its loaded language. */
|
||||
let langRequest = 0;
|
||||
const INDEX_URL = `${PACKS_URL}${PACK_INDEX_FILE}`;
|
||||
|
||||
const defaultFetch = (...a) => globalThis.fetch(...a);
|
||||
@@ -194,20 +196,23 @@ function applyLang(lang) {
|
||||
* @returns {Promise<string>} the language in effect
|
||||
*/
|
||||
export async function initLang() {
|
||||
const request = ++langRequest;
|
||||
wire();
|
||||
applyDocument(getLang());
|
||||
const TIMEOUT = Symbol('timeout');
|
||||
const boot = (async () => {
|
||||
const listed = await loadLangIndex();
|
||||
const { lang, fromUrl } = initialLang(undefined, undefined, { tentative: !listed });
|
||||
if (fromUrl) { savePref(PREF_KEY, lang); stripLangParam(); }
|
||||
if (fromUrl) stripLangParam();
|
||||
if (request !== langRequest) return null;
|
||||
if (fromUrl) savePref(PREF_KEY, lang);
|
||||
if (lang === DEFAULT_LANG) return null;
|
||||
return (await loadLangChain(lang)) ? lang : null;
|
||||
})();
|
||||
const got = await Promise.race([boot, new Promise((r) => setTimeout(() => r(TIMEOUT), BOOT_WAIT_MS))]);
|
||||
if (got === TIMEOUT) {
|
||||
boot.then((lang) => { if (lang && normalizeLang(loadPref(PREF_KEY, null)) === lang) applyLang(lang); });
|
||||
} else if (got) applyLang(got);
|
||||
boot.then((lang) => { if (request === langRequest && lang && normalizeLang(loadPref(PREF_KEY, null)) === lang) applyLang(lang); });
|
||||
} else if (request === langRequest && got) applyLang(got);
|
||||
return getLang();
|
||||
}
|
||||
|
||||
@@ -218,10 +223,12 @@ export async function initLang() {
|
||||
* @returns {Promise<string>} the language in effect
|
||||
*/
|
||||
export async function switchLang(lang) {
|
||||
const request = ++langRequest;
|
||||
wire();
|
||||
const want = normalizeLang(lang) || DEFAULT_LANG;
|
||||
savePref(PREF_KEY, want);
|
||||
if (want !== DEFAULT_LANG && !(await loadLangChain(want))) return getLang();
|
||||
if (request !== langRequest) return getLang();
|
||||
applyLang(want);
|
||||
return getLang();
|
||||
}
|
||||
@@ -290,10 +297,17 @@ export function machineTranslationNote() {
|
||||
export function LangToggle({ class: cls }) {
|
||||
const lang = useLang();
|
||||
const menu = langMenuModel(useLangs(), lang);
|
||||
const pick = (code) => { if (code && code !== getLang()) switchLang(code); };
|
||||
const pick = async (code, select = null) => {
|
||||
if (!code) return;
|
||||
const switched = switchLang(code);
|
||||
const request = langRequest;
|
||||
await switched;
|
||||
// A failed current request keeps the displayed language; stale requests must not reset a newer pending pick.
|
||||
if (select && request === langRequest) select.value = getLang();
|
||||
};
|
||||
if (menu.kind === 'select') {
|
||||
return html`<label class=${`set-seg lang-toggle lang-select${cls ? ` ${cls}` : ''}`} data-testid="lang-toggle">
|
||||
<select aria-label=${SWITCH_LABEL} value=${lang} onChange=${(e) => pick(e.currentTarget.value)}>
|
||||
<select aria-label=${SWITCH_LABEL} value=${lang} onChange=${(e) => pick(e.currentTarget.value, e.currentTarget)}>
|
||||
${menu.items.map((it) => html`<option key=${it.code} value=${it.code} lang=${it.htmlLang} title=${it.title || undefined} selected=${it.on}>${it.label}</option>`)}
|
||||
</select>
|
||||
</label>`;
|
||||
|
||||
@@ -179,4 +179,125 @@ describe('language switch on the title screen', { skip: !ENABLED && 'set SP_E2E=
|
||||
assert.deepEqual(problems, []);
|
||||
await page.close();
|
||||
});
|
||||
|
||||
// Hold only UI pack fetches; the real loader, cache, Preact menu and document updates still run.
|
||||
const holdPacks = (page, codes) => page.evaluate((held) => {
|
||||
const fetch = globalThis.fetch.bind(globalThis);
|
||||
globalThis.languageRequests = {};
|
||||
const gates = Object.fromEntries(held.map((code) => {
|
||||
let release;
|
||||
const ready = new Promise((resolve) => { release = resolve; });
|
||||
return [code, { ready, release }];
|
||||
}));
|
||||
globalThis.releaseLanguage = async (code, ok) => {
|
||||
const { loadLangChain } = await import('/js/ui/lang.js');
|
||||
const settled = loadLangChain(code);
|
||||
gates[code].release(ok);
|
||||
await settled;
|
||||
await new Promise(requestAnimationFrame);
|
||||
};
|
||||
globalThis.fetch = (url, ...args) => {
|
||||
const code = held.find((c) => url === `/i18n/${c}.json`);
|
||||
if (!code) return fetch(url, ...args);
|
||||
globalThis.languageRequests[code] = (globalThis.languageRequests[code] || 0) + 1;
|
||||
return gates[code].ready.then((ok) => ok ? fetch(url, ...args) : new Response('', { status: 503 }));
|
||||
};
|
||||
}, codes);
|
||||
const waitForPack = (page, code) => page.waitForFunction((c) => globalThis.languageRequests[c] > 0, {}, code);
|
||||
const releasePack = (page, code, ok = true) => page.evaluate((c, success) => globalThis.releaseLanguage(c, success), code, ok);
|
||||
const languageState = (page) => page.evaluate(async () => ({
|
||||
current: (await import('/shared/i18n.js')).getLang(),
|
||||
document: document.documentElement.lang,
|
||||
dataLang: document.documentElement.dataset.lang,
|
||||
script: document.documentElement.dataset.script,
|
||||
title: document.title,
|
||||
start: document.querySelector('.title-login .btn--primary').textContent.trim(),
|
||||
selected: document.querySelector('[data-testid="lang-toggle"] select').value,
|
||||
saved: JSON.parse(localStorage.getItem('sp.pref.lang')),
|
||||
}));
|
||||
const expectedLanguage = {
|
||||
zh: { current: 'zh', document: 'zh-CN', dataLang: 'zh', script: 'cjk', title: '卫戍协议:盟约 · STRONGHOLD PROTOCOL', start: '开始', selected: 'zh', saved: 'zh' },
|
||||
en: { current: 'en', document: 'en', dataLang: 'en', script: 'alphabetic', title: 'Stronghold Protocol: Alliance · Web Simulation', start: 'Start', selected: 'en', saved: 'en' },
|
||||
'zh-TW': { current: 'zh-TW', document: 'zh-TW', dataLang: 'zh-TW', script: 'cjk', title: '衛戍協議:盟約 · STRONGHOLD PROTOCOL', start: '開始', selected: 'zh-TW', saved: 'zh-TW' },
|
||||
};
|
||||
|
||||
test('a late English pack cannot overwrite a newer Traditional Chinese selection; its cache remains reusable', async (t) => {
|
||||
const page = await browser.newPage();
|
||||
t.after(() => page.close());
|
||||
await page.goto(`${base}/?lang=zh`, { waitUntil: 'networkidle0' });
|
||||
await holdPacks(page, ['en']);
|
||||
await pick(page, 'en');
|
||||
await waitForPack(page, 'en');
|
||||
await pick(page, 'zh-TW');
|
||||
await page.waitForFunction(() => document.documentElement.lang === 'zh-TW');
|
||||
await releasePack(page, 'en');
|
||||
assert.deepEqual(await languageState(page), expectedLanguage['zh-TW']);
|
||||
await pick(page, 'en');
|
||||
await page.waitForFunction(() => document.documentElement.lang === 'en');
|
||||
assert.deepEqual(await languageState(page), expectedLanguage.en);
|
||||
assert.equal(await page.evaluate(() => globalThis.languageRequests.en), 1, 'the stale pack was cached');
|
||||
});
|
||||
|
||||
test('selecting the displayed default language cancels a pending pack', async (t) => {
|
||||
const page = await browser.newPage();
|
||||
t.after(() => page.close());
|
||||
await page.goto(`${base}/?lang=zh`, { waitUntil: 'networkidle0' });
|
||||
await holdPacks(page, ['en']);
|
||||
await pick(page, 'en');
|
||||
await waitForPack(page, 'en');
|
||||
assert.equal(await page.$eval('[data-testid="lang-toggle"] select', (el) => el.value), 'en', 'the native menu can still change back to Chinese');
|
||||
assert.equal(await page.$eval('[data-testid="lang-toggle"] select', (el) => el.disabled), false);
|
||||
await pick(page, 'zh');
|
||||
await releasePack(page, 'en');
|
||||
assert.deepEqual(await languageState(page), expectedLanguage.zh);
|
||||
});
|
||||
|
||||
test('repeated selections reuse a pending pack and still supersede an intervening selection', async (t) => {
|
||||
const page = await browser.newPage();
|
||||
t.after(() => page.close());
|
||||
await page.goto(`${base}/?lang=zh`, { waitUntil: 'networkidle0' });
|
||||
await holdPacks(page, ['en', 'zh-TW']);
|
||||
await pick(page, 'en');
|
||||
await waitForPack(page, 'en');
|
||||
await pick(page, 'en');
|
||||
await pick(page, 'zh-TW');
|
||||
await waitForPack(page, 'zh-TW');
|
||||
await pick(page, 'en');
|
||||
await releasePack(page, 'en');
|
||||
assert.deepEqual(await languageState(page), expectedLanguage.en);
|
||||
await releasePack(page, 'zh-TW');
|
||||
assert.deepEqual(await languageState(page), expectedLanguage.en);
|
||||
assert.equal(await page.evaluate(() => globalThis.languageRequests.en), 1);
|
||||
});
|
||||
|
||||
test('a failed latest selection keeps the last successful UI and menu; an older pack stays stale', async (t) => {
|
||||
const page = await browser.newPage();
|
||||
t.after(() => page.close());
|
||||
await page.goto(`${base}/?lang=zh`, { waitUntil: 'networkidle0' });
|
||||
await holdPacks(page, ['en', 'zh-TW']);
|
||||
await pick(page, 'en');
|
||||
await waitForPack(page, 'en');
|
||||
await pick(page, 'zh-TW');
|
||||
await waitForPack(page, 'zh-TW');
|
||||
await releasePack(page, 'zh-TW', false);
|
||||
const unchanged = { ...expectedLanguage.zh, saved: 'zh-TW' };
|
||||
assert.deepEqual(await languageState(page), unchanged, 'failed preference stays saved, as before');
|
||||
await releasePack(page, 'en');
|
||||
assert.deepEqual(await languageState(page), unchanged);
|
||||
});
|
||||
|
||||
test('a stale failure leaves a newer pending dropdown selection intact', async (t) => {
|
||||
const page = await browser.newPage();
|
||||
t.after(() => page.close());
|
||||
await page.goto(`${base}/?lang=zh`, { waitUntil: 'networkidle0' });
|
||||
await holdPacks(page, ['en', 'zh-TW']);
|
||||
await pick(page, 'en');
|
||||
await waitForPack(page, 'en');
|
||||
await pick(page, 'zh-TW');
|
||||
await waitForPack(page, 'zh-TW');
|
||||
await releasePack(page, 'en', false);
|
||||
assert.deepEqual(await languageState(page), { ...expectedLanguage.zh, saved: 'zh-TW', selected: 'zh-TW' });
|
||||
await releasePack(page, 'zh-TW');
|
||||
assert.deepEqual(await languageState(page), expectedLanguage['zh-TW']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
// Controlled UI-pack completions exercise real language switching without network timing or a browser.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { getLang, onLangChange, registerLangs, setLang, setMessages, setI18nWarn, t } from '../../shared/i18n.js';
|
||||
|
||||
const TITLE = '卫戍协议:盟约 · STRONGHOLD PROTOCOL';
|
||||
const PACKS = {
|
||||
en: { 开始: 'Start', 卫戍协议: 'Stronghold Protocol', [TITLE]: 'Stronghold Protocol: Alliance' },
|
||||
'zh-TW': { 开始: '開始', 卫戍协议: '衛戍協議', [TITLE]: '衛戍協議:盟約' },
|
||||
};
|
||||
const STATES = {
|
||||
zh: { lang: 'zh', text: '开始', htmlLang: 'zh-CN', dataLang: 'zh', script: 'cjk', title: TITLE },
|
||||
en: { lang: 'en', text: 'Start', htmlLang: 'en', dataLang: 'en', script: 'alphabetic', title: 'Stronghold Protocol: Alliance' },
|
||||
'zh-TW': { lang: 'zh-TW', text: '開始', htmlLang: 'zh-TW', dataLang: 'zh-TW', script: 'cjk', title: '衛戍協議:盟約' },
|
||||
};
|
||||
const INDEX = { version: 1, packs: Object.keys(PACKS).map((code) => ({
|
||||
id: code, type: 'lang', lang: code, name: code, files: { ui: `/i18n/${code}.json` },
|
||||
})) };
|
||||
const turn = () => new Promise((resolve) => setImmediate(resolve));
|
||||
let imports = 0;
|
||||
|
||||
async function setup(ctx, preference = null, address = 'https://example.test/') {
|
||||
const previous = new Map(['document', 'localStorage', 'fetch', 'location', 'history'].map((key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)]));
|
||||
const stored = new Map(preference ? [['sp.pref.lang', JSON.stringify(preference)]] : []);
|
||||
const doc = { documentElement: { lang: 'zh-CN', dataset: { lang: 'zh', script: 'cjk' } }, title: TITLE };
|
||||
const pending = new Map();
|
||||
const calls = [];
|
||||
globalThis.document = doc;
|
||||
globalThis.localStorage = { getItem: (key) => stored.get(key) ?? null, setItem: (key, value) => stored.set(key, value) };
|
||||
globalThis.location = new URL(address);
|
||||
globalThis.history = {
|
||||
state: null,
|
||||
replaceState(state, _title, relative) {
|
||||
this.state = state;
|
||||
globalThis.location.href = new URL(relative, globalThis.location.href).href;
|
||||
},
|
||||
};
|
||||
globalThis.fetch = (url) => {
|
||||
calls.push(url);
|
||||
const request = Promise.withResolvers();
|
||||
if (!pending.has(url)) pending.set(url, []);
|
||||
pending.get(url).push(request);
|
||||
return request.promise;
|
||||
};
|
||||
const warnings = [];
|
||||
ctx.mock.method(console, 'warn', (message) => warnings.push(message));
|
||||
registerLangs(Object.keys(PACKS).map((code) => ({ code, fallback: [], base: null, data: false, ui: `/i18n/${code}.json` })));
|
||||
setLang('zh');
|
||||
for (const code of Object.keys(PACKS)) setMessages(code, {});
|
||||
const url = new URL('../../public/js/ui/lang.js', import.meta.url);
|
||||
url.searchParams.set('test', String(++imports));
|
||||
const lang = await import(url.href);
|
||||
ctx.after(() => {
|
||||
setLang('zh');
|
||||
setI18nWarn(null);
|
||||
for (const code of Object.keys(PACKS)) setMessages(code, {});
|
||||
for (const [key, descriptor] of previous) {
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else delete globalThis[key];
|
||||
}
|
||||
});
|
||||
const finish = (path, body, status = 200) => {
|
||||
const request = pending.get(path)?.shift();
|
||||
assert.ok(request, `a request is pending for ${path}`);
|
||||
request.resolve({ ok: status === 200, status, json: async () => body });
|
||||
};
|
||||
const state = (code, saved = code) => assert.deepEqual({
|
||||
lang: getLang(), text: t('开始'), htmlLang: doc.documentElement.lang, dataLang: doc.documentElement.dataset.lang,
|
||||
script: doc.documentElement.dataset.script, title: doc.title, saved: stored.get('sp.pref.lang') ?? null,
|
||||
}, { ...STATES[code], saved: saved == null ? null : JSON.stringify(saved) });
|
||||
return {
|
||||
...lang, calls, warnings, state,
|
||||
finish: (code, status = 200) => finish(`/i18n/${code}.json`, PACKS[code], status),
|
||||
finishIndex: () => finish('/packs/index.json', INDEX),
|
||||
};
|
||||
}
|
||||
|
||||
test('a delayed language cannot overwrite the later successful selection', async (ctx) => {
|
||||
const h = await setup(ctx);
|
||||
const first = h.switchLang('en');
|
||||
const latest = h.switchLang('zh-TW');
|
||||
h.finish('zh-TW');
|
||||
assert.equal(await latest, 'zh-TW');
|
||||
h.state('zh-TW');
|
||||
h.finish('en');
|
||||
assert.equal(await first, 'zh-TW');
|
||||
h.state('zh-TW');
|
||||
});
|
||||
|
||||
test('selecting the default language invalidates a pending pack', async (ctx) => {
|
||||
const h = await setup(ctx);
|
||||
const first = h.switchLang('en');
|
||||
assert.equal(await h.switchLang('zh'), 'zh');
|
||||
h.state('zh');
|
||||
h.finish('en');
|
||||
assert.equal(await first, 'zh');
|
||||
h.state('zh');
|
||||
});
|
||||
|
||||
test('reselecting the current language cancels a different pending selection and uses its cached pack', async (ctx) => {
|
||||
const h = await setup(ctx);
|
||||
const initial = h.switchLang('zh-TW');
|
||||
h.finish('zh-TW');
|
||||
await initial;
|
||||
const pending = h.switchLang('en');
|
||||
await h.switchLang('zh-TW');
|
||||
h.finish('en');
|
||||
await pending;
|
||||
h.state('zh-TW');
|
||||
assert.deepEqual(h.calls, ['/i18n/zh-TW.json', '/i18n/en.json']);
|
||||
});
|
||||
|
||||
test('A, B, A shares the in-flight A pack and applies only the latest selection', async (ctx) => {
|
||||
const h = await setup(ctx);
|
||||
const changes = [];
|
||||
ctx.after(onLangChange((code) => changes.push(code)));
|
||||
const first = h.switchLang('en');
|
||||
const middle = h.switchLang('zh-TW');
|
||||
const latest = h.switchLang('en');
|
||||
h.finish('zh-TW');
|
||||
await middle;
|
||||
h.state('zh', 'en');
|
||||
h.finish('en');
|
||||
await Promise.all([first, latest]);
|
||||
h.state('en');
|
||||
assert.deepEqual(changes, ['en']);
|
||||
await h.switchLang('en');
|
||||
assert.deepEqual(h.calls, ['/i18n/en.json', '/i18n/zh-TW.json']);
|
||||
assert.deepEqual(changes, ['en'], 'repeated successful selections do not notify a language change');
|
||||
});
|
||||
|
||||
test('a failed latest request retains the successful interface, rejects stale success, and can retry', async (ctx) => {
|
||||
const h = await setup(ctx);
|
||||
const first = h.switchLang('en');
|
||||
const latest = h.switchLang('zh-TW');
|
||||
h.finish('zh-TW', 503);
|
||||
assert.equal(await latest, 'zh');
|
||||
h.state('zh', 'zh-TW');
|
||||
assert.equal(h.warnings.length, 1);
|
||||
assert.match(h.warnings[0], /HTTP 503.*interface stays as it is/);
|
||||
h.finish('en');
|
||||
await first;
|
||||
h.state('zh', 'zh-TW');
|
||||
const retry = h.switchLang('zh-TW');
|
||||
h.finish('zh-TW');
|
||||
await retry;
|
||||
h.state('zh-TW');
|
||||
await h.switchLang('en');
|
||||
h.state('en');
|
||||
assert.deepEqual(h.calls, ['/i18n/en.json', '/i18n/zh-TW.json', '/i18n/zh-TW.json'], 'failed loads retry; stale successful packs remain cached');
|
||||
});
|
||||
|
||||
test('startup applies the stored language after its pack is ready', async (ctx) => {
|
||||
const h = await setup(ctx, 'en');
|
||||
ctx.mock.method(globalThis, 'setTimeout', () => 0);
|
||||
const boot = h.initLang();
|
||||
h.finishIndex();
|
||||
await turn();
|
||||
h.finish('en');
|
||||
assert.equal(await boot, 'en');
|
||||
h.state('en');
|
||||
});
|
||||
|
||||
test('startup cannot overwrite a newer switch before the boot timeout', async (ctx) => {
|
||||
const h = await setup(ctx, 'en');
|
||||
ctx.mock.method(globalThis, 'setTimeout', () => 0);
|
||||
const boot = h.initLang();
|
||||
h.finishIndex();
|
||||
await turn();
|
||||
const latest = h.switchLang('zh-TW');
|
||||
h.finish('zh-TW');
|
||||
await latest;
|
||||
h.finish('en');
|
||||
await boot;
|
||||
h.state('zh-TW');
|
||||
});
|
||||
|
||||
test('a delayed startup index cannot restore the URL choice after a newer selection', async (ctx) => {
|
||||
const h = await setup(ctx, null, 'https://example.test/play?room=alliance&lang=en#settings');
|
||||
ctx.mock.method(globalThis, 'setTimeout', () => 0);
|
||||
const boot = h.initLang();
|
||||
const latest = h.switchLang('zh-TW');
|
||||
h.finish('zh-TW');
|
||||
await latest;
|
||||
h.finishIndex();
|
||||
await turn();
|
||||
h.state('zh-TW');
|
||||
assert.equal(await boot, 'zh-TW');
|
||||
assert.deepEqual(h.calls, ['/packs/index.json', '/i18n/zh-TW.json']);
|
||||
assert.equal(globalThis.location.href, 'https://example.test/play?room=alliance#settings');
|
||||
assert.deepEqual(h.initialLang(), { lang: 'zh-TW', fromUrl: false }, 'reload uses the newer saved choice');
|
||||
});
|
||||
|
||||
test('timed-out startup applies its pack later when the selection has not changed', async (ctx) => {
|
||||
const h = await setup(ctx, 'en');
|
||||
let timeout;
|
||||
ctx.mock.method(globalThis, 'setTimeout', (fn) => { timeout = fn; return 0; });
|
||||
const boot = h.initLang();
|
||||
h.finishIndex();
|
||||
await turn();
|
||||
timeout();
|
||||
assert.equal(await boot, 'zh');
|
||||
h.state('zh', 'en');
|
||||
h.finish('en');
|
||||
await turn();
|
||||
h.state('en');
|
||||
});
|
||||
|
||||
test('timed-out startup cannot overwrite a newer default selection', async (ctx) => {
|
||||
const h = await setup(ctx, 'en');
|
||||
let timeout;
|
||||
ctx.mock.method(globalThis, 'setTimeout', (fn) => { timeout = fn; return 0; });
|
||||
const boot = h.initLang();
|
||||
h.finishIndex();
|
||||
await turn();
|
||||
timeout();
|
||||
await boot;
|
||||
await h.switchLang('zh');
|
||||
h.finish('en');
|
||||
await turn();
|
||||
h.state('zh');
|
||||
});
|
||||
Reference in new issue
Block a user