From 161a07321b28b5d6bda87f7a3cb107f4ab3eefb9 Mon Sep 17 00:00:00 2001 From: Artyom Savchenko Date: Wed, 8 Apr 2026 12:36:52 +0700 Subject: [PATCH] Fix UI freeze on missing translation params (#10735) * Fix UI freeze on missing translation params Signed-off-by: Artem Savchenko * Revert redundant changes Signed-off-by: Artem Savchenko * Clean up Signed-off-by: Artem Savchenko --------- Signed-off-by: Artem Savchenko --- .../platform/src/__tests__/i18n.test.ts | 103 ++++++++++++- .../core/packages/platform/src/i18n.ts | 140 ++++++++++-------- plugins/time-assets/lang/es.json | 2 +- plugins/time-assets/lang/pt-br.json | 2 +- plugins/time-assets/lang/pt.json | 2 +- 5 files changed, 180 insertions(+), 69 deletions(-) diff --git a/foundations/core/packages/platform/src/__tests__/i18n.test.ts b/foundations/core/packages/platform/src/__tests__/i18n.test.ts index 53211fe106..683242feff 100644 --- a/foundations/core/packages/platform/src/__tests__/i18n.test.ts +++ b/foundations/core/packages/platform/src/__tests__/i18n.test.ts @@ -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, + language: string | undefined +): Promise { + 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 => { + 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 => { + 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 => { + 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') + }) }) diff --git a/foundations/core/packages/platform/src/i18n.ts b/foundations/core/packages/platform/src/i18n.ts index a5cab62511..cb265f6837 100644 --- a/foundations/core/packages/platform/src/i18n.ts +++ b/foundations/core/packages/platform/src/i18n.ts @@ -70,6 +70,28 @@ async function setStatus (status: Status, skipError?: boolean): Promise { } } +/** 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 { + 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, + skipError?: boolean +): Promise { + 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 | 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

> ( 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

> ( 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) } } diff --git a/plugins/time-assets/lang/es.json b/plugins/time-assets/lang/es.json index 31064aa46c..d497d462bb 100644 --- a/plugins/time-assets/lang/es.json +++ b/plugins/time-assets/lang/es.json @@ -15,7 +15,7 @@ "Scheduled": "Programado", "Schedule": "Programa", "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", "WorkSlot": "Intervalo de trabajo", "WorkItem": "Elemento de trabajo", diff --git a/plugins/time-assets/lang/pt-br.json b/plugins/time-assets/lang/pt-br.json index 0c742d00f6..7e6721e4b7 100644 --- a/plugins/time-assets/lang/pt-br.json +++ b/plugins/time-assets/lang/pt-br.json @@ -15,7 +15,7 @@ "Scheduled": "Agendado", "Schedule": "Agenda", "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", "WorkSlot": "Intervalo de trabalho", "WorkItem": "Item de trabalho", diff --git a/plugins/time-assets/lang/pt.json b/plugins/time-assets/lang/pt.json index 70df901b58..80801e3d3c 100644 --- a/plugins/time-assets/lang/pt.json +++ b/plugins/time-assets/lang/pt.json @@ -15,7 +15,7 @@ "Scheduled": "Agendado", "Schedule": "Agenda", "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", "WorkSlot": "Intervalo de trabalho", "WorkItem": "Item de trabalho",