11# i18n changes in v15
22
3- Two breaking changes, both in v15:
3+ Three breaking changes, all in v15:
44
551 . ** English is the only bundled language.** The ` de ` , ` es ` , ` fr ` , ` hi ` , ` it ` , ` ja ` , ` ko ` , ` nl ` ,
66 ` pt ` , ` ru ` and ` tr ` dictionaries are gone, along with their ` dayjs ` locale data.
772 . ** Translation keys are namespaced identifiers** , not the English text. ` t('Send Message') `
88 became ` t('messageComposer.sendButton.send.ariaLabel', 'Send') ` .
9+ 3 . ** The translation runtime moved into ` stream-chat ` ** , shared with the React Native SDK. The class
10+ keeps its name, two of its methods changed shape, and two timestamp edge cases render differently
11+ — see [ The shared runtime] ( #the-shared-runtime ) .
912
1013Together these cut ~ 112 KB gzip (27%) from the bundle: the 11 dictionaries were statically
1114imported and copied into ` Streami18n ` at construction, so they shipped even if you never set
1215` language ` .
1316
1417## Do I need to do anything?
1518
16- | If you… | Action |
17- | --------------------------------------------- | ---------------------------------------------- |
18- | use the SDK in English and never touched i18n | ** Nothing.** |
19- | passed ` translationsForLanguage ` | Rename your keys — see below |
20- | called ` registerTranslation() ` | Rename your keys — see below |
21- | used a built-in non-English language | Supply the dictionary yourself — see below |
22- | relied on non-English date formats | Import the ` dayjs ` locale yourself — see below |
23- | imported ` deTranslations ` … ` trTranslations ` | Those exports are removed |
19+ | If you… | Action |
20+ | ------------------------------------------------ | ---------------------------------------------- |
21+ | use the SDK in English and never touched i18n | ** Nothing.** |
22+ | passed ` translationsForLanguage ` | Rename your keys — see below |
23+ | called ` registerTranslation() ` | Rename your keys — see below |
24+ | used a built-in non-English language | Supply the dictionary yourself — see below |
25+ | relied on non-English date formats | Import the ` dayjs ` locale yourself — see below |
26+ | imported ` deTranslations ` … ` trTranslations ` | Those exports are removed |
27+ | construct ` new Streami18n(...) ` | ** Nothing** — same name, same options object |
28+ | assign ` i18n.t ` or read ` setLanguage() ` 's return | Both changed — see below |
29+ | declared ` i18next ` or ` dayjs ` yourself | You can drop them; ` stream-chat ` supplies both |
2430
2531## Renaming your keys
2632
@@ -191,10 +197,19 @@ export const de = {
191197 ' common.back.label' : ' Zurück' ,
192198} as const satisfies TranslationDictionary ;
193199
200+ /** Formatter keys hold `dayjs` / `i18next` expressions, not copy, so they are not "translated". */
201+ type TranslatableKey = Exclude <
202+ keyof TranslationCatalog ,
203+ ` duration.${string } ` | ` timestamp.${string } ` | ` translationBuilderTopic.${string } `
204+ >;
205+
194206/** Every key still needing German. Hover it to read the list. */
195- type Untranslated = Exclude <keyof TranslationCatalog , keyof typeof de >;
207+ type Untranslated = Exclude <TranslatableKey , keyof typeof de >;
196208```
197209
210+ ` language.* ` (ISO language names) and ` relativeTime.* ` are ordinary copy and stay in the diff — they
211+ render in the UI like anything else, so a complete language translates them too.
212+
198213Hovering ` Untranslated ` in your editor lists the missing keys, and it shrinks as you add them. To
199214turn "am I complete?" into a build failure — useful in CI after a dependency bump — assert the diff
200215is empty:
@@ -228,6 +243,139 @@ git show v14.11.0:src/i18n/de.json > de.json
228243Then rename its keys with the mapping table above and register it. Note the old file's keys are the
229244_ old_ natural-language keys, so it needs the same rename as your own overrides.
230245
246+ ## The shared runtime
247+
248+ ` Streami18n ` used to live in this package. It now lives in ` stream-chat ` and is shared with
249+ ` stream-chat-react-native ` , so both SDKs behave identically and a fix reaches both at once. You still
250+ import it from here, and it still carries this SDK's own key catalog and copy.
251+
252+ ### ` getTranslators() ` is now ` init() `
253+
254+ Same return value; the old name was a getter that initialized, which is what made it worth renaming.
255+
256+ ``` ts
257+ // v14
258+ const { t, tDateTimeParser } = await i18n .getTranslators ();
259+
260+ // v15
261+ const { t, tDateTimeParser } = await i18n .init ();
262+ ```
263+
264+ ` init() ` is idempotent and safe to call concurrently — the promise is memoized, which closes a
265+ re-entry window the old implementation left open.
266+
267+ ### ` t ` is read-only, and ` setLanguage() ` returns nothing
268+
269+ ` t ` is published through a reactive store rather than being a mutable field, which is what lets
270+ ` <Chat> ` pick up a language change without remounting. Two consequences:
271+
272+ ``` ts
273+ // v14 — assigning `t` directly
274+ (i18n as any ).t = myTranslator ;
275+
276+ // v15 — publish it, and every subscriber updates
277+ i18n .overrideTFunction (myTranslator );
278+ ```
279+
280+ ``` ts
281+ // v14 — setLanguage returned a translator (sometimes; it had three return shapes)
282+ const t = await i18n .setLanguage (' de' );
283+
284+ // v15 — it returns void. Read the current `t` from the instance, or let <Chat> re-render.
285+ await i18n .setLanguage (' de' );
286+ const { t } = i18n .state .getLatestValue ();
287+ ```
288+
289+ The returned translator was removed deliberately: it went stale on the next language change, so
290+ holding onto it was always a latent bug.
291+
292+ ### ` getTranslations() ` and ` getAvailableLanguages() ` are gone
293+
294+ Both were public in v14, both leaked internal bookkeeping, and neither had a consumer in this SDK.
295+
296+ ``` ts
297+ // v14 — reading the raw i18next resource map
298+ i18n .getTranslations ().en .translation [' some.key' ];
299+
300+ // v15 — render the key instead; that is the thing you actually wanted to know
301+ i18n .t (' some.key' );
302+ ```
303+
304+ ` getTranslations() ` never held this SDK's English copy in the first place: prose renders from the
305+ inline ` defaultValue ` at each call site, so the resource map only ever contained the bundled formatter
306+ expressions plus whatever had been registered.
307+
308+ ``` ts
309+ // v14 — "available" included languages created only to carry the bundled defaults,
310+ // so a language nobody registered showed up here
311+ i18n .getAvailableLanguages ().includes (' de' );
312+
313+ // v15
314+ i18n .registeredLanguages .has (' de' );
315+ ```
316+
317+ ` registeredLanguages ` is now a ` ReadonlySet<string> ` . Reading it is unchanged; ` .add() ` no longer
318+ compiles — use ` registerTranslation() ` , since adding to the set would claim a language is registered
319+ with no dictionary behind it.
320+
321+ Also now internal, none of them documented before: ` translations ` , ` dayjsLocales ` ,
322+ ` isCustomDateTimeParser ` , ` localeExists() ` , ` addOrUpdateLocale() ` , ` validateCurrentLanguage() ` . To
323+ register a dayjs locale directly, ` stream-chat/i18n ` exports ` addOrUpdateDayjsLocale() ` .
324+
325+ ### ` useChat ` no longer returns ` translators `
326+
327+ The i18n wiring moved out of ` useChat ` into a dedicated ` useStreami18n ` , matching the hook
328+ ` stream-chat-react-native ` already had. ` useChat ` was doing five unrelated jobs — user-agent stamping,
329+ subsystem subscriptions, mutes, i18n and latest-message bookkeeping — and only held the translators to
330+ hand them straight to a provider.
331+
332+ ` useChat ` is exported, so if you called it directly:
333+
334+ ``` ts
335+ // v14
336+ const { translators } = useChat ({ client , defaultLanguage , i18nInstance });
337+
338+ // v15
339+ const { getAppSettings, latestMessageDatesByChannels, mutes } = useChat ({ client });
340+ const translators = useStreami18n ({ client , i18nInstance });
341+ ```
342+
343+ ` useChat ` no longer takes ` i18nInstance ` , which moved to ` useStreami18n ` . ` defaultLanguage ` is gone
344+ from both, and from ` <Chat> ` — see below.
345+
346+ ### ` defaultLanguage ` is removed, and so is browser detection
347+
348+ ` <Chat defaultLanguage> ` read as a fallback for UI translations, but it never drove them: the
349+ ` Streami18n ` instance does. All it fed was ` userLanguage ` , which is the key the SDK reads
350+ ` message.i18n[<lang>_text] ` with — and a fallback there cannot help, because with no
351+ ` client.user.language ` the API is not translating at all, so ` message.i18n ` is absent and the text
352+ falls through to ` message.text ` regardless.
353+
354+ For the same reason ` userLanguage ` no longer falls back to the two-letter browser language when that
355+ language happens to have a registered dictionary. Having German UI copy says nothing about whether the
356+ API produces ` message.i18n.de_text ` , so that branch only ever produced lookups that missed.
357+ ` stream-chat-react-native ` never had it.
358+
359+ ` userLanguage ` is now ` client.user.language ` and nothing else, which is what every one of its consumers
360+ already assumed. A non-English UI comes from registering a dictionary and setting ` language ` on the
361+ instance; translated messages come from ` language ` in ` connectUser ` . The two are independent.
362+
363+ One behavioural improvement comes with it. ` userLanguage ` tracks ` client.user.language ` reactively, so
364+ a language changed after connect now reaches the message components — it used to be read as a ` useMemo `
365+ dependency with no subscription, so it only refreshed if something else re-rendered. Passing a value
366+ that is not a ` Streami18n ` warns and falls back to a default instance rather than throwing at render.
367+
368+ ### You no longer need ` i18next ` or ` dayjs ` in your own dependencies
369+
370+ ` stream-chat ` depends on both, so they arrive transitively. If you declared them only for this SDK,
371+ remove them — and if you keep them, ** match ` stream-chat ` 's ranges** . Two copies of ` dayjs ` means
372+ your ` import 'dayjs/locale/de' ` registers the locale on a different instance than the one formatting
373+ dates, and dates silently stay English:
374+
375+ ``` bash
376+ find . -maxdepth 4 -name dayjs -type d -path ' *node_modules*' # expect exactly one
377+ ```
378+
231379## Date and time
232380
233381Only the ` en ` dayjs locale is bundled, and the per-language ` calendar ` formats the SDK used to ship
@@ -253,6 +401,46 @@ const i18n = new Streami18n({
253401
254402Or pass your own preconfigured ` DateTimeParser ` (dayjs or moment).
255403
404+ ### Two edge cases render differently
405+
406+ Both are confined to a ` timestamp.* ` key that specifies ** no** format. Every key the SDK ships
407+ specifies one (` format: HH:mm ` , ` calendar: true ` , and so on), so you only see these if you overrode a
408+ timestamp key with an expression that formats nothing.
409+
410+ ** A ` null ` or unparseable timestamp renders as empty** , where v14 rendered the value stringified —
411+ which for ` null ` was the literal text ` null ` :
412+
413+ ``` ts
414+ // a key with no format
415+ ' timestamp.MessageTimestamp' : ' {{ timestamp | timestampFormatter(calendar: false) }}'
416+
417+ // t('timestamp.MessageTimestamp', { timestamp: null })
418+ // v14 → "null"
419+ // v15 → ""
420+ ```
421+
422+ The same applies when you call ` predefinedFormatters.timestampFormatter ` yourself: it returns ` '' `
423+ rather than the stringified value. If you relied on that to spot a missing timestamp during
424+ development, check for the empty string instead — rendering the word ` null ` into a message list was
425+ never intentional.
426+
427+ Note this is specifically about a value that _ reaches_ the formatter. Passing no ` timestamp ` at all
428+ leaves i18next with nothing to interpolate, so the raw expression comes through unchanged — that was
429+ true in v14 too, and is a sign the option name is misspelled at the call site.
430+
431+ ** Unformatted output carries a numeric offset rather than ` Z ` :**
432+
433+ ``` ts
434+ // v14 → 2019-04-03T14:42:47Z
435+ // v15 → 2019-04-03T14:42:47+00:00
436+ ```
437+
438+ Same instant, different ISO spelling. v14 called dayjs's ` .tz() ` on every parse even when no
439+ ` timezone ` was configured, which marks the instance as zoned and changes how ` .format() ` with no
440+ template renders. v15 applies ` .tz() ` only when you actually set ` timezone ` , matching what the React
441+ Native SDK already did. Configure a ` format ` on the key if you need a specific shape — relying on
442+ dayjs's default is fragile either way.
443+
256444## Why keys changed at all
257445
258446The old keys _ were_ the English copy, which meant:
@@ -266,8 +454,20 @@ Keys are now stable, and the English copy travels inline at the call site as i18
266454` defaultValue ` . That keeps the copy readable where it is used, and means a key you do not supply
267455still renders English rather than a raw key path.
268456
269- The exception is the ~ 71 keys that carry no inline copy — ` timestamp.* ` and ` duration.* ` (formatter
270- expressions), ` language.* ` (built from a runtime language code), and the postProcessor directive.
271- Those are bundled in ` runtimeDefaults ` instead, and both ` registerTranslation() ` and
272- ` translationsForLanguage ` merge your dictionary over them, so you inherit the working defaults
273- without listing them. You only need to supply one if you want a different date format.
457+ The exception is the 15 keys that carry no inline copy — ` timestamp.* ` and ` duration.* ` (formatter
458+ expressions) and the postProcessor directive. Those are bundled in ` runtimeDefaults ` instead, and both
459+ ` registerTranslation() ` and ` translationsForLanguage ` merge your dictionary over them, so you inherit
460+ the working defaults without listing them. You only need to supply one if you want a different date
461+ format.
462+
463+ Two more sets are still overridable but now come from ` stream-chat ` , because it owns the code that
464+ renders them:
465+
466+ - ** ` language.* ` ** — the 57 language names used to say "Translated from German" on an auto-translated
467+ message. They are derived from the same language union the API uses, so the set can no longer drift
468+ out of sync with it.
469+ - ** ` relativeTime.* ` ** — ` Today ` , ` Yesterday ` , ` {{ count }}d ago ` , ` {{ count }}w ago ` , used by
470+ ` timestampFormatter(relativeCompact: true) ` .
471+
472+ Both are part of your catalog's types, so you override them exactly as before — ` t('language.de') ` is
473+ a checked key, and a typo in either is still a compile error.
0 commit comments