Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 35 additions & 43 deletions src/i18n/Streami18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Streami18nState<C, Bundled>>;
Expand Down Expand Up @@ -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<Streami18nState<C, Bundled>> {
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<Streami18nState<C, Bundled>> {
// 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<C, Bundled> }),
});
} 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<C, Bundled> }),
});

return this.state.getLatestValue();
}
Expand Down
63 changes: 37 additions & 26 deletions test/unit/i18n/Streami18n.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand All @@ -695,56 +692,70 @@ 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),
).not.toThrow();
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');
});
});

Expand Down
5 changes: 4 additions & 1 deletion v9-to-v10-migration-guide-i18n.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>`. 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.

Expand Down