Skip to content
Open
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
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
202 changes: 188 additions & 14 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 @@ -228,6 +234,122 @@ 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, defaultLanguage, i18nInstance });
```

`useChat` no longer takes `defaultLanguage` or `i18nInstance` either — both moved to `useStreami18n`.
Nothing changes for `<Chat>`: its props are the same and it wires both hooks internally.

One behavioural improvement comes with it. `userLanguage` now tracks `client.user.language` reactively,
so a user who connects _after_ `<Chat>` mounts gets their language applied; previously it was captured
once and a late connection kept the browser or default language. Passing a value that is not a
`Streami18n` now 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 +375,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 +428,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.
11 changes: 4 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,7 @@
}
},
"sideEffects": [
"*.css",
"./dist/i18n/Streami18n.js"
"*.css"
],
"keywords": [
"chat",
Expand All @@ -96,11 +95,9 @@
"@floating-ui/react": "^0.27.19",
"@react-aria/focus": "^3.22.0",
"clsx": "^2.1.1",
"dayjs": "^1.11.20",
"emoji-regex": "^9.2.2",
"fix-webm-duration": "^1.0.6",
"hast-util-find-and-replace": "^5.0.1",
"i18next": "^26.3.6",
"linkifyjs": "^4.3.3",
"lodash.debounce": "^4.0.8",
"lodash.mergewith": "^4.6.2",
Expand Down Expand Up @@ -132,7 +129,7 @@
"modern-normalize": "^3.0.1",
"react": "^19.0.0 || ^18.0.0 || ^17.0.0",
"react-dom": "^19.0.0 || ^18.0.0 || ^17.0.0",
"stream-chat": "10.0.0-rc.2"
"stream-chat": "10.0.0-rc.3"
},
"peerDependenciesMeta": {
"@breezystack/lamejs": {
Expand Down Expand Up @@ -186,6 +183,7 @@
"@vitest/eslint-plugin": "^1.6.20",
"concurrently": "^9.2.1",
"conventional-changelog-conventionalcommits": "^9.3.1",
"dayjs": "^1.11.13",
"emoji-mart": "^5.6.0",
"eslint": "^9.39.4",
"eslint-plugin-import": "^2.32.0",
Expand All @@ -196,13 +194,12 @@
"husky": "^9.1.7",
"jsdom": "^29.1.1",
"lint-staged": "^17.0.5",
"moment-timezone": "^0.5.48",
"prettier": "^3.8.3",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"sass": "^1.100.0",
"semantic-release": "^25.0.3",
"stream-chat": "10.0.0-rc.2",
"stream-chat": "10.0.0-rc.3",
"typescript": "^6.0.3",
"typescript-eslint": "^8.59.4",
"vite": "^8.1.3",
Expand Down
Loading
Loading