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 platform, { plugin } from '../platform'
import platform, { getEmbeddedLabel, plugin } from '../platform'
import { Severity, Status } from '../status'
import { addStringsLoader, translate } from '../i18n'
import { addStringsLoader, loadPluginStrings, translate, translateCB } from '../i18n'
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 test = plugin(testId, {
@@ -112,4 +122,93 @@ describe('i18n', () => {
expect(translated).toBe(message)
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 (
plugin: Plugin,
locale: string,
@@ -88,9 +110,7 @@ async function loadTranslationsForComponent (
try {
return (await loader('en')) as Record<string, IntlString> | Status
} catch (err: any) {
const status = unknownError(err)
await setStatus(status, skipError)
return status
return await pipelineErrorToStatus(err, skipError)
}
}
}
@@ -150,9 +170,7 @@ async function getTranslation (
return messages[id.name] as IntlString
}
} catch (err) {
const status = unknownError(err)
await setStatus(status, skipError)
return status
return await pipelineErrorToStatus(err, skipError)
}
}
@@ -173,33 +191,29 @@ export async function translate<P extends Record<string, any>> (
if (!cache.has(locale)) {
cache.set(locale, localCache)
}
const compiled = localCache.get(message)
try {
const compiled = localCache.get(message)
if (compiled !== undefined) {
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)
if (compiled !== undefined) {
if (compiled instanceof Status) {
return message
}
const compiled = new IntlMessageFormat(translation, locale, undefined, { ignoreTag: true })
localCache.set(message, compiled)
return compiled.format(params)
} catch (err) {
const status = unknownError(err)
await setStatus(status, skipError)
localCache.set(message, status)
}
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
}
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)) {
cache.set(locale, localCache)
}
const compiled = localCache.get(message)
try {
const compiled = localCache.get(message)
if (compiled !== undefined) {
if (compiled instanceof Status) {
resolve(message)
return
}
resolve(compiled.format(params))
} else {
let id: _IdInfo
try {
id = _parseId(message)
if (id.component === _EmbeddedId) {
resolve(id.name)
if (compiled !== undefined) {
if (compiled instanceof Status) {
resolve(message)
return
}
resolve(compiled.format(params))
} else {
let id: _IdInfo
try {
id = _parseId(message)
if (id.component === _EmbeddedId) {
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
}
} 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 })
localCache.set(message, compiled)
resolve(compiled.format(params))
const compiledNew = new IntlMessageFormat(translation, locale, undefined, { ignoreTag: true })
localCache.set(message, compiledNew)
resolve(compiledNew.format(params))
}
} catch (err) {
void handleIntlPipelineFailure(err, message, localCache, skipError).then(resolve)
}
}