Skip to content

fix(i18n)!: reject from init() and drop the Streami18n brand static - #1839

Merged
isekovanic merged 3 commits into
release-v10from
fix/i18n-init-rejects
Aug 21, 2026
Merged

fix(i18n)!: reject from init() and drop the Streami18n brand static#1839
isekovanic merged 3 commits into
release-v10from
fix/i18n-init-rejects

Conversation

@oliverlaz

@oliverlaz oliverlaz commented Aug 21, 2026

Copy link
Copy Markdown
Member

Two related breaking changes to Streami18n, both of which move a decision out of core and into the caller. They ship together because the second is only safe once the first has landed in both UI SDKs.

1. init() rejects instead of swallowing

It caught every failure, logged a string, and resolved:

} catch (error) {
  this.logger(`Streami18n: initialization failed: ${describeError(error)}`);
}
return this.state.getLatestValue();

A consumer had no way to know initialization had failed short of watching the log, and an integrator who supplied a logger to route diagnostics somewhere quiet lost the signal entirely. The original error — stack, cause, i18next's own detail — was flattened to a string and dropped. Swallowing at a library boundary is the wrong default; the caller owns the policy.

The old justification was "neither UI SDK awaits this, so a rejection would be unhandled". That is a statement about the SDKs, not about the contract — so the SDKs are what got fixed, in the companion PRs below.

init() now rejects with the original error, and core no longer logs. A failure is reported once, by whoever handles it, in a form they can inspect.

What does not change is the degraded-but-safe guarantee, which is a separate concern worth keeping distinct:

after a failed init() before after
promise resolves rejects with the original error
core logs yes no — the handler reports
initialized false false
t default translator, inline English unchanged
registerTranslation / setLanguage do not throw unchanged

The promise reports the failure; the store keeps the UI alive. Rejecting does not mean the instance is unusable.

Two things fall out:

  • The memo is cleared on failure, so a later init() retries rather than replaying one rejection for the lifetime of the instance. Concurrent callers still share the in-flight promise, which is the only thing the memo was ever for.
  • The bookkeeping handler attaches to a derived promise, so it does not mark the returned one as handled and the caller's catch still sees the rejection. There is a test for exactly that, because getting it wrong silently swallows the rejection again.

2. Streami18n.brand is removed

static readonly brand = Symbol.for('stream-chat.Streami18n') existed so a UI SDK could recognize an instance across two physical copies of this package, where instanceof fails. Both SDKs have now stopped checking, so it has no consumer left in any of the three repos — verified by grep across all of them.

It was also blind to the case that actually bites. The brand sits on core's class, so a bare new Streami18n() from stream-chat/i18n — carrying none of a UI SDK's bundled runtimeDefaults, and therefore rendering timestamp.* and language.* as raw dotted keys — passed the check cleanly. All it ever rejected was a value that is not a Streami18n at all, which the prop types already cover.

Companion PRs

Both UI SDKs catch the rejection in their useStreami18n effect and report through console.warn, which keeps the Error object and its stack intact. Neither surfaces an error state, because the degraded instance still renders English and failing a whole chat subtree over a translation-layer fault would be the worse outcome. Both also dropped the brand check.

Their hook tests mock init() itself rather than the i18next instance underneath — what is under test there is the hook's handling of a rejection, not core's decision to reject, and it keeps those tests independent of which stream-chat version is installed while this rolls out.

Verification

yarn lint 0, yarn types 0, 2,881 tests. The rewritten Streami18n — a failed init() block pins the new contract: rejects with the original error, does not log, leaves initialized false, keeps rendering inline English, does not break registerTranslation / setLanguage, retries on a later call, and shares the in-flight promise with concurrent callers before failing.

Sequencing

This needs an rc after merge — the SDK-side catch is inert against rc.5, which still resolves — and then a version bump in both consumers.

BREAKING CHANGE: Streami18n.init() now rejects when i18next fails to initialize, where it previously logged and resolved. Callers that ignore the returned promise will see an unhandled rejection. The instance remains usable in its degraded English form either way. Streami18n.brand is removed; nothing in either UI SDK read it, and a custom integration that recognized instances through it should compare against its own marker or accept the instance it was handed.

…rror

`init()` caught every failure, logged a string, and resolved. A consumer had no way to know
initialization had failed short of watching the log, and an integrator who supplied a `logger` to
route diagnostics somewhere quiet lost the signal entirely. Swallowing an error at a library
boundary is the wrong default: the caller owns the policy.

It now rejects with the original error, and core no longer logs -- so a failure is reported once,
by whoever handles it, rather than twice or in a form the handler cannot inspect.

The instance is still left *degraded but safe*, which is a separate guarantee and unchanged:
`initialized` stays false, keeping `registerTranslation` and `setLanguage` off a dead i18next
instance, and `t` remains the default translator, so every call site renders its inline English
rather than a blank or a dotted key. The promise reports the failure; the store keeps the UI alive.

Two changes fall out of it. The memo is cleared on failure, so a later `init()` retries rather than
replaying one rejection for the lifetime of the instance -- concurrent callers still share the
in-flight promise, which is the only thing the memo was ever for. And the internal bookkeeping
handler attaches to a derived promise, so it does not mark the returned one as handled: the caller's
`catch` still sees the rejection.

BREAKING CHANGE: `Streami18n.init()` now rejects when i18next fails to initialize, where it
previously logged and resolved. Callers that ignore the returned promise will see an unhandled
rejection. Both UI SDKs catch it and report through the instance logger; a custom integration should
do the same. The instance remains usable in its degraded English form either way.
`Streami18n.brand` existed so a UI SDK could recognize an instance across two physical copies of
this package, where `instanceof` fails. Both SDKs have stopped checking, so it has no consumer left
in any of the three repos.

It was also blind to the case that actually bites. The brand sits on *core's* class, so a bare
`new Streami18n()` from `stream-chat/i18n` -- carrying none of a UI SDK's bundled `runtimeDefaults`,
and so rendering `timestamp.*` and `language.*` as raw dotted keys -- passed it cleanly. All it ever
rejected was a value that is not a `Streami18n` at all, which the prop types already cover.

BREAKING CHANGE: `Streami18n.brand` is removed. Nothing in either UI SDK read it; a custom
integration that recognized instances through it should compare against its own marker, or accept
the instance it was handed.
@oliverlaz oliverlaz changed the title fix(i18n)!: reject from Streami18n.init() instead of swallowing the error fix(i18n)!: reject from init() and drop the Streami18n brand static Aug 21, 2026
@isekovanic
isekovanic merged commit 67bb1c7 into release-v10 Aug 21, 2026
4 checks passed
@isekovanic
isekovanic deleted the fix/i18n-init-rejects branch August 21, 2026 14:10
github-actions Bot pushed a commit that referenced this pull request Aug 21, 2026
## [10.0.0-rc.6](v10.0.0-rc.5...v10.0.0-rc.6) (2026-08-21)

### ⚠ BREAKING CHANGES

* remove hand-written types and API wrappers (#1836)
* **i18n:** `Streami18n.init()` now rejects when i18next fails to
initialize, where it previously logged and resolved. Callers that ignore
the returned promise will see an unhandled rejection. The instance
remains usable in its degraded English form either way.
`Streami18n.brand` is removed; nothing in either UI SDK read it, and a
custom integration that recognized instances through it should compare
against its own marker or accept the instance it was handed.

### Bug Fixes

* **i18n:** reject from init() and drop the Streami18n brand static ([#1839](#1839)) ([67bb1c7](67bb1c7))

### Features

* remove hand-written types and API wrappers ([#1836](#1836)) ([dc49a78](dc49a78))
@stream-ci-bot

Copy link
Copy Markdown

🎉 This PR is included in version 10.0.0-rc.6 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants