diff --git a/src/i18n/Streami18n.ts b/src/i18n/Streami18n.ts index f7c7c6fc6e..27238c3ae8 100644 --- a/src/i18n/Streami18n.ts +++ b/src/i18n/Streami18n.ts @@ -105,9 +105,6 @@ export class Streami18n< C extends AnyTranslationCatalog = AnyTranslationCatalog, Bundled extends string = never, > { - /** Marks instances across bundle copies, where `instanceof` silently fails. */ - static readonly brand = Symbol.for('stream-chat.Streami18n'); - readonly i18nInstance: I18nInstance = i18next.createInstance(); readonly state: StateStore>; @@ -248,56 +245,51 @@ export class Streami18n< } /** - * Initializes i18next. Idempotent and safe to call concurrently. - * - * Memoized, so two independent consumers — a UI SDK's chat root and its overlay host — share one - * initialization. + * Initializes i18next. Memoized, so concurrent callers share one initialization. * - * An i18next failure does **not** reject: neither UI SDK awaits this, so a rejection would surface - * as an unhandled rejection and the memo would latch it for the process lifetime. It is logged - * instead, leaving the instance *degraded but safe*: `state.initialized` stays false, which is what - * keeps the methods below off a dead i18next instance, and `t` remains the default translator, so - * every call site still renders its inline English. There is no retry — construct a new instance. - * - * One path does escape: an integrator `logger` that throws is called from the `catch` itself, so it - * rejects out of here. Rare enough not to guard, but it is why this is not an absolute guarantee. + * **Rejects on failure**, so handle it. The instance stays usable either way: `initialized` stays + * false, keeping {@link Streami18n.registerTranslation} and {@link Streami18n.setLanguage} off a + * dead i18next instance, and `t` keeps returning each call site's inline English. */ init(): Promise> { - this.initPromise ??= this.runInit(); - return this.initPromise; + if (this.initPromise) return this.initPromise; + + const pending = this.runInit(); + this.initPromise = pending; + + // Clear the memo so a later call retries. Attached to a derived promise, so `pending` stays + // unhandled and still reaches the caller's `catch`. + pending.catch(() => { + if (this.initPromise === pending) this.initPromise = undefined; + }); + + return pending; } private async runInit(): Promise> { - // Everything is inside the `try` -- see `init()` for why an i18next failure must not reject. - try { - this.validateCurrentLanguage(); - this.assertPluralRulesCoverage(this.currentLanguage); + this.validateCurrentLanguage(); + this.assertPluralRulesCoverage(this.currentLanguage); - const dayjsLocale = this.dayjsLocales[this.currentLanguage]; - if (dayjsLocale) this.addOrUpdateLocale(this.currentLanguage, dayjsLocale); + const dayjsLocale = this.dayjsLocales[this.currentLanguage]; + if (dayjsLocale) this.addOrUpdateLocale(this.currentLanguage, dayjsLocale); - const t = await this.i18nInstance.init({ - ...this.i18nextConfig, - lng: this.currentLanguage, - resources: this.i18nextResources(), - }); + const t = await this.i18nInstance.init({ + ...this.i18nextConfig, + lng: this.currentLanguage, + resources: this.i18nextResources(), + }); - this.registerFormatters(); + this.registerFormatters(); - // After init, so post-processors attach to a live instance and buffered translators flush. - Object.entries(this.translationBuilderTopics).forEach(([topic, Topic]) => { - this.translationBuilder.registerTopic(topic, Topic); - }); - - this.state.partialNext({ - initialized: true, - ...(this.tOverridden - ? {} - : { t: t as unknown as StreamTFunctionFor }), - }); - } catch (error) { - this.logger(`Streami18n: initialization failed: ${describeError(error)}`); - } + // After init, so post-processors attach to a live instance and buffered translators flush. + Object.entries(this.translationBuilderTopics).forEach(([topic, Topic]) => { + this.translationBuilder.registerTopic(topic, Topic); + }); + + this.state.partialNext({ + initialized: true, + ...(this.tOverridden ? {} : { t: t as unknown as StreamTFunctionFor }), + }); return this.state.getLatestValue(); } diff --git a/test/unit/i18n/Streami18n.test.ts b/test/unit/i18n/Streami18n.test.ts index e7cc95009c..809f3394fe 100644 --- a/test/unit/i18n/Streami18n.test.ts +++ b/test/unit/i18n/Streami18n.test.ts @@ -678,11 +678,8 @@ describe('Streami18n — a failed setLanguage rolls back', () => { }); /** - * A failed `init()` leaves the instance degraded but *safe*. - * - * Neither UI SDK awaits `init()`, so it must never reject. And `initialized` must stay false, because - * it means "i18next is usable" -- `registerTranslation` and `setLanguage` both branch on it, and a - * `true` there sends them into an instance whose own init rejected. + * A failed `init()` rejects, *and* leaves the instance usable. Both hold together: `initialized` + * means "i18next is usable", and `registerTranslation` / `setLanguage` branch on it. */ describe('Streami18n — a failed init()', () => { beforeEach(() => { @@ -695,35 +692,42 @@ describe('Streami18n — a failed init()', () => { return i18n; }; - it('resolves rather than rejecting, and reports the failure', async () => { + it('rejects with the original error', async () => { + const i18n = failing(); + + await expect(i18n.init()).rejects.toThrow('i18next exploded'); + }); + + /** Reported once, by whoever handles the rejection. */ + it('does not log, leaving the report to the caller', async () => { const logger = vi.fn(); const i18n = failing(logger); - await expect(i18n.init()).resolves.toBeDefined(); - expect(logger).toHaveBeenCalledWith( - expect.stringContaining('initialization failed: i18next exploded'), + await expect(i18n.init()).rejects.toThrow(); + expect(logger).not.toHaveBeenCalledWith( + expect.stringContaining('initialization failed'), ); }); it('leaves `initialized` false', async () => { const i18n = failing(); - const state = await i18n.init(); - expect(state.initialized).toBe(false); + await expect(i18n.init()).rejects.toThrow(); expect(i18n.initialized).toBe(false); + expect(i18n.state.getLatestValue().initialized).toBe(false); }); it('keeps rendering the inline English copy', async () => { const i18n = failing(); - const { t } = await i18n.init(); - expect(t('fixture.prose', 'Cancel')).toBe('Cancel'); + await expect(i18n.init()).rejects.toThrow(); + expect(i18n.state.getLatestValue().t('fixture.prose', 'Cancel')).toBe('Cancel'); }); /** The bug this guards: `addResources` on a dead instance threw out of `registerTranslation`. */ it('does not throw from registerTranslation or setLanguage', async () => { const i18n = failing(); - await i18n.init(); + await expect(i18n.init()).rejects.toThrow(); expect(() => i18n.registerTranslation('de', { 'fixture.prose': 'Abbrechen' } as never), @@ -731,20 +735,27 @@ describe('Streami18n — a failed init()', () => { await expect(i18n.setLanguage('de')).resolves.toBeUndefined(); }); - /** - * The one path that escapes, recorded rather than guarded. - * - * The logger is called from the `catch`, so a logger that throws rejects out of `init()`. Both UI - * SDKs call `init()` without awaiting it, so that surfaces as an unhandled rejection — worth knowing - * before supplying a logger that can throw. - */ - it('rejects when the logger itself throws', async () => { - const i18n = failing(() => { - throw new Error('logger exploded'); - }); + /** Also the check that the internal bookkeeping `catch` does not swallow the rejection. */ + it('retries on a later call, and can succeed', async () => { + const i18n = setup(); + const spy = vi + .spyOn(i18n.i18nInstance, 'init') + .mockRejectedValueOnce(new Error('i18next exploded')); - await expect(i18n.init()).rejects.toThrow('logger exploded'); + await expect(i18n.init()).rejects.toThrow('i18next exploded'); expect(i18n.initialized).toBe(false); + + spy.mockRestore(); + await expect(i18n.init()).resolves.toBeDefined(); + expect(i18n.initialized).toBe(true); + }); + + it('shares the in-flight promise with concurrent callers before failing', async () => { + const i18n = failing(); + const first = i18n.init(); + + expect(i18n.init()).toBe(first); + await expect(first).rejects.toThrow('i18next exploded'); }); }); diff --git a/v9-to-v10-migration-guide-i18n.md b/v9-to-v10-migration-guide-i18n.md index fb1fead654..48733e0518 100644 --- a/v9-to-v10-migration-guide-i18n.md +++ b/v9-to-v10-migration-guide-i18n.md @@ -229,7 +229,10 @@ Notable if you are building custom UI directly on `stream-chat`: value, so there is no listener-registration ordering to get right. - `setLanguage()` returns `Promise`. The new `t` is published to `state`; a returned translator would go stale on the next language change. -- `init()` is idempotent and safe to call concurrently. +- `init()` is idempotent, safe to call concurrently, and **rejects** if i18next fails to initialize. + Handle it: an unhandled rejection is what you get otherwise. The instance stays usable in a degraded + form either way — `initialized` stays `false` and `t` keeps returning each call site's inline English, + so the UI renders rather than blanking. A later `init()` retries. - The keys with no inline default at their call site are injected via the `runtimeDefaults` option, because the catalog belongs to the UI layer rather than to core.