Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
5cceac7
refactor(i18n)!: adopt the shared i18n layer from stream-chat/i18n
oliverlaz Aug 18, 2026
dad272c
docs(i18n): document the shared runtime and its two rendering changes
oliverlaz Aug 18, 2026
78cea1d
refactor(i18n)!: keep the class named Streami18n and drop the depreca…
oliverlaz Aug 18, 2026
3a0f254
ci(size): run the size workflow on release-v15, and fix a stale codeg…
oliverlaz Aug 18, 2026
2131bf6
refactor(i18n): drop the i18next devDependency
oliverlaz Aug 18, 2026
5c7d800
test(i18n)!: stop testing the shared runtime from here
oliverlaz Aug 18, 2026
83bebb8
fix(i18n): restore the language-name miss-detection, dedupe BundledKey
oliverlaz Aug 18, 2026
141cdc9
docs(i18n): cover the two accessors removed from Streami18n
oliverlaz Aug 18, 2026
d040a38
refactor(poll): adopt the renamed poll validation code family
oliverlaz Aug 19, 2026
23743bf
refactor(i18n)!: move the translation wiring out of useChat
oliverlaz Aug 19, 2026
08ef1da
fix(i18n): defer the browser-language read, restore the week thresholds
oliverlaz Aug 20, 2026
da4a8d4
fix(i18n): restore the DayjsLocaleConfig export and expose Streami18n…
oliverlaz Aug 20, 2026
3cb51a5
chore(deps): move to stream-chat 10.0.0-rc.5
oliverlaz Aug 20, 2026
a1bab1f
Merge branch 'release-v15' into feat/i18n-adopt-shared-core
oliverlaz Aug 20, 2026
4ff50e7
fix(i18n): handle a rejected Streami18n.init()
oliverlaz Aug 21, 2026
4bf8999
docs(i18n): trim the init() rejection comments
oliverlaz Aug 21, 2026
f5d685c
feat(i18n)!: separate UI language from message-translation language
oliverlaz Aug 21, 2026
e93de26
fix(demo): update the vite example dictionaries for the v15 catalog
oliverlaz Aug 21, 2026
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
4 changes: 4 additions & 0 deletions .github/workflows/size.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ on:
pull_request:
branches:
- master
# The v15 release branch. Without it this workflow does not run on any PR stacked onto it, so the
# i18n consolidation's central size claim -- that moving the runtime into `stream-chat/i18n`
# shrinks the root bundle -- goes unmeasured for the whole release.
- release-v15
paths-ignore:
- '**.test.*'
- '**.md'
Expand Down
230 changes: 215 additions & 15 deletions ai-docs/i18n-v15-migration.md
Original file line number Diff line number Diff line change
@@ -1,26 +1,32 @@
# i18n changes in v15

Two breaking changes, both in v15:
Three breaking changes, all in v15:

1. **English is the only bundled language.** The `de`, `es`, `fr`, `hi`, `it`, `ja`, `ko`, `nl`,
`pt`, `ru` and `tr` dictionaries are gone, along with their `dayjs` locale data.
2. **Translation keys are namespaced identifiers**, not the English text. `t('Send Message')`
became `t('messageComposer.sendButton.send.ariaLabel', 'Send')`.
3. **The translation runtime moved into `stream-chat`**, shared with the React Native SDK. The class
keeps its name, two of its methods changed shape, and two timestamp edge cases render differently
— see [The shared runtime](#the-shared-runtime).

Together these cut ~112 KB gzip (27%) from the bundle: the 11 dictionaries were statically
imported and copied into `Streami18n` at construction, so they shipped even if you never set
`language`.

## Do I need to do anything?

| If you… | Action |
| --------------------------------------------- | ---------------------------------------------- |
| use the SDK in English and never touched i18n | **Nothing.** |
| passed `translationsForLanguage` | Rename your keys — see below |
| called `registerTranslation()` | Rename your keys — see below |
| used a built-in non-English language | Supply the dictionary yourself — see below |
| relied on non-English date formats | Import the `dayjs` locale yourself — see below |
| imported `deTranslations` … `trTranslations` | Those exports are removed |
| If you… | Action |
| ------------------------------------------------ | ---------------------------------------------- |
| use the SDK in English and never touched i18n | **Nothing.** |
| passed `translationsForLanguage` | Rename your keys — see below |
| called `registerTranslation()` | Rename your keys — see below |
| used a built-in non-English language | Supply the dictionary yourself — see below |
| relied on non-English date formats | Import the `dayjs` locale yourself — see below |
| imported `deTranslations` … `trTranslations` | Those exports are removed |
| construct `new Streami18n(...)` | **Nothing** — same name, same options object |
| assign `i18n.t` or read `setLanguage()`'s return | Both changed — see below |
| declared `i18next` or `dayjs` yourself | You can drop them; `stream-chat` supplies both |

## Renaming your keys

Expand Down Expand Up @@ -191,10 +197,19 @@ export const de = {
'common.back.label': 'Zurück',
} as const satisfies TranslationDictionary;

/** Formatter keys hold `dayjs` / `i18next` expressions, not copy, so they are not "translated". */
type TranslatableKey = Exclude<
keyof TranslationCatalog,
`duration.${string}` | `timestamp.${string}` | `translationBuilderTopic.${string}`
>;

/** Every key still needing German. Hover it to read the list. */
type Untranslated = Exclude<keyof TranslationCatalog, keyof typeof de>;
type Untranslated = Exclude<TranslatableKey, keyof typeof de>;
```

`language.*` (ISO language names) and `relativeTime.*` are ordinary copy and stay in the diff — they
render in the UI like anything else, so a complete language translates them too.

Hovering `Untranslated` in your editor lists the missing keys, and it shrinks as you add them. To
turn "am I complete?" into a build failure — useful in CI after a dependency bump — assert the diff
is empty:
Expand Down Expand Up @@ -228,6 +243,139 @@ git show v14.11.0:src/i18n/de.json > de.json
Then rename its keys with the mapping table above and register it. Note the old file's keys are the
_old_ natural-language keys, so it needs the same rename as your own overrides.

## The shared runtime

`Streami18n` used to live in this package. It now lives in `stream-chat` and is shared with
`stream-chat-react-native`, so both SDKs behave identically and a fix reaches both at once. You still
import it from here, and it still carries this SDK's own key catalog and copy.

### `getTranslators()` is now `init()`

Same return value; the old name was a getter that initialized, which is what made it worth renaming.

```ts
// v14
const { t, tDateTimeParser } = await i18n.getTranslators();

// v15
const { t, tDateTimeParser } = await i18n.init();
```

`init()` is idempotent and safe to call concurrently — the promise is memoized, which closes a
re-entry window the old implementation left open.

### `t` is read-only, and `setLanguage()` returns nothing

`t` is published through a reactive store rather than being a mutable field, which is what lets
`<Chat>` pick up a language change without remounting. Two consequences:

```ts
// v14 — assigning `t` directly
(i18n as any).t = myTranslator;

// v15 — publish it, and every subscriber updates
i18n.overrideTFunction(myTranslator);
```

```ts
// v14 — setLanguage returned a translator (sometimes; it had three return shapes)
const t = await i18n.setLanguage('de');

// v15 — it returns void. Read the current `t` from the instance, or let <Chat> re-render.
await i18n.setLanguage('de');
const { t } = i18n.state.getLatestValue();
```

The returned translator was removed deliberately: it went stale on the next language change, so
holding onto it was always a latent bug.

### `getTranslations()` and `getAvailableLanguages()` are gone

Both were public in v14, both leaked internal bookkeeping, and neither had a consumer in this SDK.

```ts
// v14 — reading the raw i18next resource map
i18n.getTranslations().en.translation['some.key'];

// v15 — render the key instead; that is the thing you actually wanted to know
i18n.t('some.key');
```

`getTranslations()` never held this SDK's English copy in the first place: prose renders from the
inline `defaultValue` at each call site, so the resource map only ever contained the bundled formatter
expressions plus whatever had been registered.

```ts
// v14 — "available" included languages created only to carry the bundled defaults,
// so a language nobody registered showed up here
i18n.getAvailableLanguages().includes('de');

// v15
i18n.registeredLanguages.has('de');
```

`registeredLanguages` is now a `ReadonlySet<string>`. Reading it is unchanged; `.add()` no longer
compiles — use `registerTranslation()`, since adding to the set would claim a language is registered
with no dictionary behind it.

Also now internal, none of them documented before: `translations`, `dayjsLocales`,
`isCustomDateTimeParser`, `localeExists()`, `addOrUpdateLocale()`, `validateCurrentLanguage()`. To
register a dayjs locale directly, `stream-chat/i18n` exports `addOrUpdateDayjsLocale()`.

### `useChat` no longer returns `translators`

The i18n wiring moved out of `useChat` into a dedicated `useStreami18n`, matching the hook
`stream-chat-react-native` already had. `useChat` was doing five unrelated jobs — user-agent stamping,
subsystem subscriptions, mutes, i18n and latest-message bookkeeping — and only held the translators to
hand them straight to a provider.

`useChat` is exported, so if you called it directly:

```ts
// v14
const { translators } = useChat({ client, defaultLanguage, i18nInstance });

// v15
const { getAppSettings, latestMessageDatesByChannels, mutes } = useChat({ client });
const translators = useStreami18n({ client, i18nInstance });
```

`useChat` no longer takes `i18nInstance`, which moved to `useStreami18n`. `defaultLanguage` is gone
from both, and from `<Chat>` — see below.

### `defaultLanguage` is removed, and so is browser detection

`<Chat defaultLanguage>` read as a fallback for UI translations, but it never drove them: the
`Streami18n` instance does. All it fed was `userLanguage`, which is the key the SDK reads
`message.i18n[<lang>_text]` with — and a fallback there cannot help, because with no
`client.user.language` the API is not translating at all, so `message.i18n` is absent and the text
falls through to `message.text` regardless.

For the same reason `userLanguage` no longer falls back to the two-letter browser language when that
language happens to have a registered dictionary. Having German UI copy says nothing about whether the
API produces `message.i18n.de_text`, so that branch only ever produced lookups that missed.
`stream-chat-react-native` never had it.

`userLanguage` is now `client.user.language` and nothing else, which is what every one of its consumers
already assumed. A non-English UI comes from registering a dictionary and setting `language` on the
instance; translated messages come from `language` in `connectUser`. The two are independent.

One behavioural improvement comes with it. `userLanguage` tracks `client.user.language` reactively, so
a language changed after connect now reaches the message components — it used to be read as a `useMemo`
dependency with no subscription, so it only refreshed if something else re-rendered. Passing a value
that is not a `Streami18n` warns and falls back to a default instance rather than throwing at render.

### You no longer need `i18next` or `dayjs` in your own dependencies

`stream-chat` depends on both, so they arrive transitively. If you declared them only for this SDK,
remove them — and if you keep them, **match `stream-chat`'s ranges**. Two copies of `dayjs` means
your `import 'dayjs/locale/de'` registers the locale on a different instance than the one formatting
dates, and dates silently stay English:

```bash
find . -maxdepth 4 -name dayjs -type d -path '*node_modules*' # expect exactly one
```

## Date and time

Only the `en` dayjs locale is bundled, and the per-language `calendar` formats the SDK used to ship
Expand All @@ -253,6 +401,46 @@ const i18n = new Streami18n({

Or pass your own preconfigured `DateTimeParser` (dayjs or moment).

### Two edge cases render differently

Both are confined to a `timestamp.*` key that specifies **no** format. Every key the SDK ships
specifies one (`format: HH:mm`, `calendar: true`, and so on), so you only see these if you overrode a
timestamp key with an expression that formats nothing.

**A `null` or unparseable timestamp renders as empty**, where v14 rendered the value stringified —
which for `null` was the literal text `null`:

```ts
// a key with no format
'timestamp.MessageTimestamp': '{{ timestamp | timestampFormatter(calendar: false) }}'

// t('timestamp.MessageTimestamp', { timestamp: null })
// v14 → "null"
// v15 → ""
```

The same applies when you call `predefinedFormatters.timestampFormatter` yourself: it returns `''`
rather than the stringified value. If you relied on that to spot a missing timestamp during
development, check for the empty string instead — rendering the word `null` into a message list was
never intentional.

Note this is specifically about a value that _reaches_ the formatter. Passing no `timestamp` at all
leaves i18next with nothing to interpolate, so the raw expression comes through unchanged — that was
true in v14 too, and is a sign the option name is misspelled at the call site.

**Unformatted output carries a numeric offset rather than `Z`:**

```ts
// v14 → 2019-04-03T14:42:47Z
// v15 → 2019-04-03T14:42:47+00:00
```

Same instant, different ISO spelling. v14 called dayjs's `.tz()` on every parse even when no
`timezone` was configured, which marks the instance as zoned and changes how `.format()` with no
template renders. v15 applies `.tz()` only when you actually set `timezone`, matching what the React
Native SDK already did. Configure a `format` on the key if you need a specific shape — relying on
dayjs's default is fragile either way.

## Why keys changed at all

The old keys _were_ the English copy, which meant:
Expand All @@ -266,8 +454,20 @@ Keys are now stable, and the English copy travels inline at the call site as i18
`defaultValue`. That keeps the copy readable where it is used, and means a key you do not supply
still renders English rather than a raw key path.

The exception is the ~71 keys that carry no inline copy — `timestamp.*` and `duration.*` (formatter
expressions), `language.*` (built from a runtime language code), and the postProcessor directive.
Those are bundled in `runtimeDefaults` instead, and both `registerTranslation()` and
`translationsForLanguage` merge your dictionary over them, so you inherit the working defaults
without listing them. You only need to supply one if you want a different date format.
The exception is the 15 keys that carry no inline copy — `timestamp.*` and `duration.*` (formatter
expressions) and the postProcessor directive. Those are bundled in `runtimeDefaults` instead, and both
`registerTranslation()` and `translationsForLanguage` merge your dictionary over them, so you inherit
the working defaults without listing them. You only need to supply one if you want a different date
format.

Two more sets are still overridable but now come from `stream-chat`, because it owns the code that
renders them:

- **`language.*`** — the 57 language names used to say "Translated from German" on an auto-translated
message. They are derived from the same language union the API uses, so the set can no longer drift
out of sync with it.
- **`relativeTime.*`** — `Today`, `Yesterday`, `{{ count }}d ago`, `{{ count }}w ago`, used by
`timestampFormatter(relativeCompact: true)`.

Both are part of your catalog's types, so you override them exactly as before — `t('language.de')` is
a checked key, and a typo in either is still a compile error.
2 changes: 1 addition & 1 deletion examples/tutorial/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"emoji-mart": "^5.6.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"stream-chat": "10.0.0-rc.4",
"stream-chat": "10.0.0-rc.5",
"stream-chat-react": "workspace:^"
},
"devDependencies": {
Expand Down
4 changes: 3 additions & 1 deletion examples/vite/docs-playwright/take-tier1-screenshots.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,9 @@ async function run() {

console.log('\n✅ Done!');
console.log('\n⚠ Not automated (need app-level config):');
console.log(' - Localization2.png — needs defaultLanguage="it" on Chat component');
console.log(
' - Localization2.png — needs an it dictionary registered and language: "it" on the Streami18n instance',
);
console.log(' - Diacritics.png — needs user with diacritical name in the channel');
console.log(
' - Transliteration.png — needs useMentionsTransliteration prop + Cyrillic user',
Expand Down
2 changes: 1 addition & 1 deletion examples/vite/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"modern-normalize": "^3.0.1",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"stream-chat": "10.0.0-rc.4",
"stream-chat": "10.0.0-rc.5",
"stream-chat-react": "workspace:^"
},
"devDependencies": {
Expand Down
7 changes: 4 additions & 3 deletions examples/vite/src/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -594,11 +594,13 @@ export const deTranslations = {
'notification.commandDisabled': 'Befehl nicht verfügbar',
'notification.commandDisabledWhileEditing': 'Befehl beim Bearbeiten nicht verfügbar',
'notification.commandDisabledWhileReplying': 'Befehl beim Antworten nicht verfügbar',
'notification.commandNotReady': 'Befehl kann noch nicht gesendet werden',
'notification.dismissNotification.ariaLabel': 'Benachrichtigung schließen',
'notification.jumpToFirstUnreadFailed':
'Sprung zur ersten ungelesenen Nachricht fehlgeschlagen',
'notification.list.notifications.ariaLabel': 'Benachrichtigungen',
'notification.locationGetFailed': 'Standort konnte nicht ermittelt werden',
'notification.messageJumpFailed': 'Sprung zur Nachricht fehlgeschlagen',
'notification.messageJumpToLatestFailed':
'Sprung zur neuesten Nachricht fehlgeschlagen',
'notification.locationShareFailed': 'Standort konnte nicht geteilt werden',
'notification.pollCreateFailed': 'Umfrage konnte nicht erstellt werden',
'notification.pollCreateFailedWithReason':
Expand Down Expand Up @@ -659,7 +661,6 @@ export const deTranslations = {
'Mehr als eine Option auswählen',
'poll.multipleAnswersField.typeNumber210.label': 'Gib eine Zahl von 2 bis 10 ein',
'poll.nameField.askQuestion.placeholder': 'Stelle eine Frage',
'poll.nameField.error.text': 'Fehler',
'poll.nameField.questionRequired.label': 'Eine Frage ist erforderlich',
'poll.optionFieldSet.addOption.placeholder': 'Option hinzufügen',
'poll.optionFieldSet.option.ariaLabel': 'Option {{ position }}',
Expand Down
7 changes: 4 additions & 3 deletions examples/vite/src/i18n/it.ts
Original file line number Diff line number Diff line change
Expand Up @@ -592,12 +592,14 @@ export const itTranslations = {
'Comando non disponibile durante la modifica',
'notification.commandDisabledWhileReplying':
'Comando non disponibile durante la risposta',
'notification.commandNotReady': "Comando non pronto per l'invio",
'notification.dismissNotification.ariaLabel': 'Chiudi la notifica',
'notification.jumpToFirstUnreadFailed':
'Impossibile passare al primo messaggio non letto',
'notification.list.notifications.ariaLabel': 'Notifiche',
'notification.locationGetFailed': 'Impossibile recuperare la posizione',
'notification.locationShareFailed': 'Impossibile condividere la posizione',
'notification.messageJumpFailed': 'Impossibile passare al messaggio',
'notification.messageJumpToLatestFailed':
'Impossibile passare al messaggio più recente',
'notification.pollCreateFailed': 'Impossibile creare il sondaggio',
'notification.pollCreateFailedWithReason':
'Impossibile creare il sondaggio a causa di {{reason}}',
Expand Down Expand Up @@ -655,7 +657,6 @@ export const itTranslations = {
'Seleziona più di una opzione',
'poll.multipleAnswersField.typeNumber210.label': 'Inserisci un numero da 2 a 10',
'poll.nameField.askQuestion.placeholder': 'Fai una domanda',
'poll.nameField.error.text': 'Errore',
'poll.nameField.questionRequired.label': 'La domanda è obbligatoria',
'poll.optionFieldSet.addOption.placeholder': 'Aggiungi un’opzione',
'poll.optionFieldSet.option.ariaLabel': 'Opzione {{ position }}',
Expand Down
Loading
Loading