Fix UI freeze on missing translation params (#10735)

* Fix UI freeze on missing translation params

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* Revert redundant changes

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* Clean up

Signed-off-by: Artem Savchenko <armisav@gmail.com>

---------

Signed-off-by: Artem Savchenko <armisav@gmail.com>
This commit is contained in:
Artyom Savchenko
2026-04-08 12:36:52 +07:00
committed by GitHub
parent 051d5b370c
commit 161a07321b
5 changed files with 180 additions and 69 deletions
@@ -15,12 +15,22 @@
// //
import type { Plugin, IntlString } from '../platform' import type { Plugin, IntlString } from '../platform'
import platform, { plugin } from '../platform' import platform, { getEmbeddedLabel, plugin } from '../platform'
import { Severity, Status } from '../status' import { Severity, Status } from '../status'
import { addStringsLoader, translate } from '../i18n' import { addStringsLoader, loadPluginStrings, translate, translateCB } from '../i18n'
import { addEventListener, PlatformEvent, removeEventListener } from '../event' import { addEventListener, PlatformEvent, removeEventListener } from '../event'
function translateCBAsync (
message: IntlString,
params: Record<string, any>,
language: string | undefined
): Promise<string> {
return new Promise((resolve) => {
translateCB(message, params, language, resolve)
})
}
const testId = 'test-strings' as Plugin const testId = 'test-strings' as Plugin
const test = plugin(testId, { const test = plugin(testId, {
@@ -112,4 +122,93 @@ describe('i18n', () => {
expect(translated).toBe(message) expect(translated).toBe(message)
removeEventListener(PlatformEvent, eventListener) removeEventListener(PlatformEvent, eventListener)
}) })
it('translateCB should match translate for loaded string', async () => {
const fromTranslate = await translate(test.string.loadingPlugin, { plugin: 'cb' })
const fromCB = await translateCBAsync(test.string.loadingPlugin, { plugin: 'cb' }, 'en')
expect(fromCB).toBe(fromTranslate)
})
it('translate and translateCB should return embedded label text', async () => {
const embedded = getEmbeddedLabel('Embedded copy')
expect(await translate(embedded, {})).toBe('Embedded copy')
expect(await translateCBAsync(embedded, {}, 'en')).toBe('Embedded copy')
})
it('loadPluginStrings(force) should clear format cache so strings still resolve', async () => {
await translate(test.string.loadingPlugin, { plugin: 'before' })
await loadPluginStrings('en', true)
const after = await translate(test.string.loadingPlugin, { plugin: 'after' })
expect(after).toContain('after')
})
it('translate with skipError should not broadcast platform status (no loader)', async () => {
const pluginId = 'plugin-skip-error-no-loader'
const message = `${pluginId}:string:any` as IntlString
let events = 0
const listener = async (): Promise<void> => {
events++
}
addEventListener(PlatformEvent, listener)
await translate(message, {}, 'en', true)
removeEventListener(PlatformEvent, listener)
expect(events).toBe(0)
})
it('translate should return message id and emit status when ICU format params are missing', async () => {
const fmtPlugin = 'i18n-icu-format-test' as Plugin
addStringsLoader(fmtPlugin, async () => ({
string: {
badPlural: '{dias, plural, =0 {} other {#d}} {horas, plural, =0 {} other {#h}}'
}
}))
const message = `${fmtPlugin}:string:badPlural` as IntlString
expect.assertions(2)
let gotStatus = false
const listener = async (_event: string, data: any): Promise<void> => {
if (data instanceof Status) {
gotStatus = true
}
}
addEventListener(PlatformEvent, listener)
const out = await translate(message, { days: 1, hours: 2 } as any, 'en')
removeEventListener(PlatformEvent, listener)
expect(out).toBe(message)
expect(gotStatus).toBe(true)
})
it('translateCB should resolve to message id when ICU format params are missing', async () => {
const fmtPlugin = 'i18n-icu-format-test-cb' as Plugin
addStringsLoader(fmtPlugin, async () => ({
string: {
badPlural: '{dias, plural, =0 {} other {#d}}'
}
}))
const message = `${fmtPlugin}:string:badPlural` as IntlString
expect.assertions(2)
let gotStatus = false
const listener = async (_event: string, data: any): Promise<void> => {
if (data instanceof Status) {
gotStatus = true
}
}
addEventListener(PlatformEvent, listener)
const out = await translateCBAsync(message, { days: 1 }, 'en')
removeEventListener(PlatformEvent, listener)
expect(out).toBe(message)
expect(gotStatus).toBe(true)
})
it('translateCB should defer to translate when translation is not yet cached', async () => {
const deferPlugin = 'i18n-defer-translate' as Plugin
const deferMsg = `${deferPlugin}:string:only` as IntlString
addStringsLoader(deferPlugin, async (locale: string) => {
if (locale === 'en') {
return { string: { only: 'Deferred {v}' } }
}
return { string: { only: 'Deferred {v}' } }
})
const out = await translateCBAsync(deferMsg, { v: 'ok' }, 'en')
expect(out).toBe('Deferred ok')
})
}) })
+76 -64
View File
@@ -70,6 +70,28 @@ async function setStatus (status: Status, skipError?: boolean): Promise<void> {
} }
} }
/** Notify platform and return a Status for load/resolve paths that do not use the per-message format cache. */
async function pipelineErrorToStatus (err: unknown, skipError?: boolean): Promise<Status> {
const status = unknownError(err)
await setStatus(status, skipError)
return status
}
/**
* On compile/resolve failure: cache failure for this intl id, notify platform, return `message` as UI fallback.
*/
async function handleIntlPipelineFailure (
err: unknown,
message: IntlString,
localeCache: Map<IntlString, IntlMessageFormat | Status>,
skipError?: boolean
): Promise<IntlString> {
const status = unknownError(err)
localeCache.set(message, status)
await setStatus(status, skipError)
return message
}
async function loadTranslationsForComponent ( async function loadTranslationsForComponent (
plugin: Plugin, plugin: Plugin,
locale: string, locale: string,
@@ -88,9 +110,7 @@ async function loadTranslationsForComponent (
try { try {
return (await loader('en')) as Record<string, IntlString> | Status return (await loader('en')) as Record<string, IntlString> | Status
} catch (err: any) { } catch (err: any) {
const status = unknownError(err) return await pipelineErrorToStatus(err, skipError)
await setStatus(status, skipError)
return status
} }
} }
} }
@@ -150,9 +170,7 @@ async function getTranslation (
return messages[id.name] as IntlString return messages[id.name] as IntlString
} }
} catch (err) { } catch (err) {
const status = unknownError(err) return await pipelineErrorToStatus(err, skipError)
await setStatus(status, skipError)
return status
} }
} }
@@ -173,33 +191,29 @@ export async function translate<P extends Record<string, any>> (
if (!cache.has(locale)) { if (!cache.has(locale)) {
cache.set(locale, localCache) cache.set(locale, localCache)
} }
const compiled = localCache.get(message) try {
const compiled = localCache.get(message)
if (compiled !== undefined) { if (compiled !== undefined) {
if (compiled instanceof Status) { if (compiled instanceof Status) {
return message
}
return compiled.format(params)
} else {
try {
const id = _parseId(message)
if (id.component === _EmbeddedId) {
return id.name
}
const translation = getCachedTranslation(id, locale) ?? (await getTranslation(id, locale, skipError)) ?? message
if (translation instanceof Status) {
localCache.set(message, translation)
return message return message
} }
const compiled = new IntlMessageFormat(translation, locale, undefined, { ignoreTag: true })
localCache.set(message, compiled)
return compiled.format(params) return compiled.format(params)
} catch (err) { }
const status = unknownError(err) const id = _parseId(message)
await setStatus(status, skipError) if (id.component === _EmbeddedId) {
localCache.set(message, status) return id.name
}
const translation = getCachedTranslation(id, locale) ?? (await getTranslation(id, locale, skipError)) ?? message
if (translation instanceof Status) {
localCache.set(message, translation)
return message return message
} }
const compiledNew = new IntlMessageFormat(translation, locale, undefined, { ignoreTag: true })
localCache.set(message, compiledNew)
return compiledNew.format(params)
} catch (err) {
return await handleIntlPipelineFailure(err, message, localCache, skipError)
} }
} }
/** /**
@@ -217,46 +231,44 @@ export function translateCB<P extends Record<string, any>> (
if (!cache.has(locale)) { if (!cache.has(locale)) {
cache.set(locale, localCache) cache.set(locale, localCache)
} }
const compiled = localCache.get(message) try {
const compiled = localCache.get(message)
if (compiled !== undefined) { if (compiled !== undefined) {
if (compiled instanceof Status) { if (compiled instanceof Status) {
resolve(message) resolve(message)
return return
} }
resolve(compiled.format(params)) resolve(compiled.format(params))
} else { } else {
let id: _IdInfo let id: _IdInfo
try { try {
id = _parseId(message) id = _parseId(message)
if (id.component === _EmbeddedId) { if (id.component === _EmbeddedId) {
resolve(id.name) resolve(id.name)
return
}
} catch (err) {
void handleIntlPipelineFailure(err, message, localCache, skipError)
return
}
const translation = getCachedTranslation(id, locale)
if (translation === undefined || translation instanceof Status) {
void translate(message, params, language, skipError)
.then((res) => {
resolve(res)
})
.catch((err) => {
void handleIntlPipelineFailure(err, message, localCache, skipError).then(resolve)
})
return return
} }
} catch (err) {
const status = unknownError(err)
void setStatus(status, skipError)
localCache.set(message, status)
resolve(message)
return
}
const translation = getCachedTranslation(id, locale)
if (translation === undefined || translation instanceof Status) {
void translate(message, params, language)
.then((res) => {
resolve(res)
})
.catch((err) => {
const status = unknownError(err)
void setStatus(status, skipError)
localCache.set(message, status)
resolve(message)
})
return
}
const compiled = new IntlMessageFormat(translation, locale, undefined, { ignoreTag: true }) const compiledNew = new IntlMessageFormat(translation, locale, undefined, { ignoreTag: true })
localCache.set(message, compiled) localCache.set(message, compiledNew)
resolve(compiled.format(params)) resolve(compiledNew.format(params))
}
} catch (err) {
void handleIntlPipelineFailure(err, message, localCache, skipError).then(resolve)
} }
} }
+1 -1
View File
@@ -15,7 +15,7 @@
"Scheduled": "Programado", "Scheduled": "Programado",
"Schedule": "Programa", "Schedule": "Programa",
"WithoutProject": "Sin proyecto", "WithoutProject": "Sin proyecto",
"TotalGroupTime": "{días, plural, =0 {} other {#d}} {horas, plural, =0 {} other {#h}} {minutos, plural, =0 {} other {#m}}", "TotalGroupTime": "{days, plural, =0 {} other {#d}} {hours, plural, =0 {} other {#h}} {minutes, plural, =0 {} other {#m}}",
"Tasks": "Tareas", "Tasks": "Tareas",
"WorkSlot": "Intervalo de trabajo", "WorkSlot": "Intervalo de trabajo",
"WorkItem": "Elemento de trabajo", "WorkItem": "Elemento de trabajo",
+1 -1
View File
@@ -15,7 +15,7 @@
"Scheduled": "Agendado", "Scheduled": "Agendado",
"Schedule": "Agenda", "Schedule": "Agenda",
"WithoutProject": "Sem projeto", "WithoutProject": "Sem projeto",
"TotalGroupTime": "{dias, plural, =0 {} other {#d}} {horas, plural, =0 {} other {#h}} {minutos, plural, =0 {} other {#m}}", "TotalGroupTime": "{days, plural, =0 {} other {#d}} {hours, plural, =0 {} other {#h}} {minutes, plural, =0 {} other {#m}}",
"Tasks": "Tarefas", "Tasks": "Tarefas",
"WorkSlot": "Intervalo de trabalho", "WorkSlot": "Intervalo de trabalho",
"WorkItem": "Item de trabalho", "WorkItem": "Item de trabalho",
+1 -1
View File
@@ -15,7 +15,7 @@
"Scheduled": "Agendado", "Scheduled": "Agendado",
"Schedule": "Agenda", "Schedule": "Agenda",
"WithoutProject": "Sem projeto", "WithoutProject": "Sem projeto",
"TotalGroupTime": "{dias, plural, =0 {} other {#d}} {horas, plural, =0 {} other {#h}} {minutos, plural, =0 {} other {#m}}", "TotalGroupTime": "{days, plural, =0 {} other {#d}} {hours, plural, =0 {} other {#h}} {minutes, plural, =0 {} other {#m}}",
"Tasks": "Tarefas", "Tasks": "Tarefas",
"WorkSlot": "Intervalo de trabalho", "WorkSlot": "Intervalo de trabalho",
"WorkItem": "Item de trabalho", "WorkItem": "Item de trabalho",