Skip to content

feat(i18n): english-only bundle with namespaced, type-checked translation keys - #3261

Open
oliverlaz wants to merge 19 commits into
release-v15from
sdk-i18n-refactor-8925a7
Open

feat(i18n): english-only bundle with namespaced, type-checked translation keys#3261
oliverlaz wants to merge 19 commits into
release-v15from
sdk-i18n-refactor-8925a7

Conversation

@oliverlaz

@oliverlaz oliverlaz commented Aug 10, 2026

Copy link
Copy Markdown
Member

🎯 Goal

Closes REACT-1028.

Two problems with i18n in v14, both fixed here.

The 11 non-English locales shipped whether you used them or not. They were statically imported and copied into Streami18n at construction, so they were un-treeshakeable even for an English-only app β€” ~112 KB gzip (27% of the bundle), plus 11 dayjs/locale/* side-effect imports and ~150 lines of calendar config.

Translation keys were the English copy. t('Send Message') meant 375 of 706 en.json entries were "X": "X" duplication, any copy edit silently orphaned all 12 translations, and the same word in two contexts couldn't be disambiguated β€” the codebase had already grown an ad-hoc aria/ prefix to work around exactly that.

v15 is the one window where renaming public translation keys is cheap, so both land together.

πŸ›  Implementation details

Keys are now stable dotted identifiers, with the English copy inline as i18next's defaultValue:

const { t } = useTranslationContext();

t('message.status.sent.text', 'Sent');

t('channel.memberCount.title', {
  count,
  defaultValue_one: '{{ count }} member',
  defaultValue_other: '{{ count }} members',
});

Namespaces follow the source tree (message.*, messageComposer.*, poll.*), with shared copy under common.* and the modality as the leaf (.label, .ariaLabel, .placeholder, .title, .text). 633 keys across 724 call sites.

Keeping the copy inline is what makes a partial dictionary safe β€” an unsupplied key still renders English rather than a raw key path β€” and it means only 71 keys ship as data (timestamp.*, duration.*, language.*, and one postProcessor directive: the ones that can't carry an inline default). The other 562 render from the defaultValue at their call site, removing a further 39,915 raw bytes of duplicated strings.

src/i18n/keys.ts is generated from the call sites and is type-only, so the typed key surface costs nothing at runtime. yarn build-translations regenerates it and hard-fails on a key used with two different copies, a key with no inline default and no bundled entry, or a key defined in both places. CI re-runs it and fails on any diff.


Modifying existing copy

Pass overrides for the built-in English. Everything you don't mention is untouched:

import { Streami18n } from 'stream-chat-react';
import type { TranslationDictionary } from 'stream-chat-react';

const i18n = new Streami18n({
  translationsForLanguage: {
    'textareaComposer.textareaPlaceholder.sendMessage.label': 'Write something…',
  } satisfies TranslationDictionary,
});

<Chat client={client} i18nInstance={i18n}>…</Chat>
placeholder      β†’ "Write something…"   (overridden)
common.cancel    β†’ "Cancel"             (untouched, from the inline default)
message timestamp→ "10:30"              (untouched, from runtimeDefaults)

registerTranslation('en', {…}) does the same thing after construction. Every language is layered over the bundled defaults β€” however you select it, and even with no dictionary at all β€” so overriding one string can't knock out timestamps.

Catching unknown keys at compile time

Key names are checked against the catalog, so a typo or a leftover v14 key is a compile error:

const good: TranslationDictionary = { 'common.cancel.label': 'Dismiss' };

const typo: TranslationDictionary = { 'common.cancel.lable': 'Dismiss' };
//                                          ^ TS2353 β€” not a key in the catalog

const stale: TranslationDictionary = { Cancel: 'Dismiss' };
//                                           ^ TS2353 β€” v14 key, no longer exists

(TypeScript reports the first unknown property per object literal, so fix them one at a time.)

No annotation needed β€” the parameters are typed, so a dictionary written straight into the call is checked too:

i18n.registerTranslation('de', { 'common.cancel.lable': 'Abbrechen' });
//                                ^ TS2353 β€” not a key in the catalog

This matters because the failure is otherwise silent: a key that doesn't match simply never applies, and you get English with no error.

Which type to use:

plural _one/_other extra categories (_few, _many, _zero) plural suffix on a non-plural key stale v14 key your own keys
TranslationDictionary βœ… βœ… ❌ rejected ❌ rejected ❌ rejected
LooseTranslationDictionary βœ… βœ… ⚠️ permitted ⚠️ permitted βœ…

registerTranslation() and translationsForLanguage take the strict TranslationDictionary, so a key written inline is checked β€” that is where a typo used to slip through. LooseTranslationDictionary is the escape hatch for keys the SDK does not define; annotate the variable you pass to opt into it, at the cost of not catching a stale key.

Reaching for it is rarely necessary: TranslationDictionary accepts any category Intl.PluralRules can select, so Russian or Arabic can supply _few / _many / _zero and stay fully checked.

TranslationKey is the union t() accepts; use it to type a t parameter. It is not the right key type for a dictionary, because plural entries live in the catalog as _one/_other while t() takes the bare handle.

Discovering keys: TranslationKey autocompletes in any editor, TranslationCatalog maps each key to its English copy (hover to see what a key renders, or index it β€” TranslationCatalog['common.cancel.label'] is 'Cancel'), and yarn i18n:export writes the 619 translatable keys as JSON for a translator or TMS. The 14 formatter expressions are left out β€” a TMS that localises {{value, notification}} breaks notifications β€” with --all for the full catalog.

Registering a new language

import { Streami18n } from 'stream-chat-react';
import type { TranslationDictionary } from 'stream-chat-react';
import 'dayjs/locale/de.js';

const de: TranslationDictionary = {
  'common.cancel.label': 'Abbrechen',
  'channelDetail.channelMembersView.members.title_one': '{{ count }} Mitglied',
  'channelDetail.channelMembersView.members.title_other': '{{ count }} Mitglieder',
};

const i18n = new Streami18n({
  language: 'de',
  dayjsLocaleConfigForLanguage: {
    calendar: { sameDay: '[heute um] LT', lastDay: '[gestern um] LT' /* … */ },
  },
});

i18n.registerTranslation('de', de);
common.cancel.label  β†’ "Abbrechen"
members.title, n=1   β†’ "1 Mitglied"      ← Intl.PluralRules picks the form
members.title, n=4   β†’ "4 Mitglieder"
common.back.label    β†’ "Back"            ← not supplied, falls back to English
message timestamp    β†’ "10:30"           ← inherited from runtimeDefaults

Plurals are stored as _one / _other; supply whichever categories your language needs and i18next selects between them, so Russian or Arabic can add _few, _many, _zero β€” all of them type-checked.

Date formats are the one thing you must supply yourself now, and it takes two steps.

1. Only the en dayjs locale is bundled, so import your locale and pass dayjsLocaleConfigForLanguage (including its calendar block), or pass your own preconfigured DateTimeParser. That covers month and weekday names, the plain formats, and the keys that format against the locale's own calendar.

2. Override the four timestamp.* keys that pass their own calendarFormats: DateSeparator, ReminderNotification, ChannelPreviewTimestamp, ChannelDetailPinnedMessageTimestamp. dayjs takes the calendar wording as part of the format string, so these carry English day words β€” and a per-key calendarFormats replaces the locale's calendar wholesale, so step 1 never reaches them. Skip this and a fully configured German app still renders "Today" in its date separators. Date and time has the copy-pasteable German version.


Migration

Every v14 key maps to exactly one v15 key. The full 603-row table is ai-docs/i18n-v15-key-map.json; the integrator-facing guide is ai-docs/i18n-v15-migration.md. Nothing to do if you use the SDK in English and never touched i18n.

A later release adding a key will not break a custom dictionary β€” TranslationDictionary is Partial, so the new string renders its inline English until it is translated. Keeping a language up to date shows how to turn that into a compile-time diff of what is still untranslated, and a CI gate for completeness.

Also removed: the deTranslations … trTranslations exports, and the misspelled geti18Instance() accessor (use the public i18nInstance field).

Bundled along the way: i18next 25 β†’ 26. None of its breaking changes apply to us β€” we never used interpolation.format (formatters already go through services.formatter.add), initImmediate, or i18next.format. One undocumented v26 change worth knowing: an undefined interpolation value now short-circuits before the formatter runs. No effect on the SDK, but a custom formatter that produced output from an undefined value will stop being called.

🎨 UI Changes

None. Every rendered string is byte-identical β€” the English copy moved from en.json into an inline defaultValue at the same call site. The existing suite is the evidence: ~808 assertions on rendered English text, all passing unchanged.

Verification
  • yarn test β€” 2789 passed, 227 files
  • tsc -p tsconfig.lib.json --noEmit β€” clean
  • yarn types:scripts β€” clean
  • yarn lint β€” clean
  • yarn validate-translations β€” clean (regenerates keys.ts byte-identically)
  • examples/vite and examples/tutorial β€” tsc clean

English is now the only bundled language. The 11 non-English JSON dictionaries were
statically imported and copied into `this.translations` in the Streami18n constructor,
making them un-treeshakeable even for integrators who never set `language`.

Removes ~122 KB gzip from the ESM bundle (419,172 -> 296,910 bytes, -29%).

- delete src/i18n/{de,es,fr,hi,it,ja,ko,nl,pt,ru,tr}.json and translations.ts
  (this drops the public deTranslations..trTranslations exports)
- drop the 11 dayjs locale imports and 11 Dayjs.updateLocale calendar blocks;
  integrators now import their own dayjs locale and pass dayjsLocaleConfigForLanguage
- narrow SupportedTranslations to 'en'
- enable removeUnusedKeys, pruning 82 stale keys (706 -> 624). language/* and
  timestamp/* are preserved explicitly: they resolve from runtime values, so the
  extractor cannot see them and would otherwise delete them
- replace the zero-empty-string validate-translations script with a CI drift gate
  (git diff --exit-code -- src/i18n after build-translations)
- retarget Streami18n tests at registerTranslation, the only path to non-English now,
  including coverage for partial dictionaries falling back per key
Translation keys were the English copy itself, so 375 of 706 en.json entries were
"X": "X" duplication, any copy edit silently orphaned every translation, and the same
word in different contexts could not be disambiguated (worked around with an ad-hoc
`aria/` prefix).

Keys are now stable dotted identifiers with the English copy passed inline as i18next's
`defaultValue`:

  t('messageComposer.sendButton.label', 'Send Message')

This keeps call sites self-documenting, makes en.json generated output rather than a
maintained file, and preserves the graceful degradation the natural-language keys gave
for free β€” a key missing from a custom dictionary still renders English.

- 759 rewrites across 166 files, driven by a reviewed old->new mapping committed at
  scripts/i18n-migration/key-map.json (603 entries). That file doubles as the migration
  table integrators need: `translationsForLanguage` / `registerTranslation` dictionaries
  keyed on the old strings must be renamed.
- namespaces follow the source tree (message.*, messageComposer.*, poll.* …) so keys are
  predictable from the component; genuinely shared copy lives in common.*
- the `aria/` pseudo-namespace is retired in favour of an `.ariaLabel` leaf, which is what
  made "Send" ambiguous. This also fixes aria-labels that were rendering the raw
  "aria/Pause recording" key to screen readers, and interpolation that never ran
  ("Audio position {{ elapsed }} of {{ duration }}") β€” see the snapshot updates.
- formatter/plumbing keys (timestamp.*, duration.*, translationBuilderTopic.*) keep
  resolving from en.json and are preserved explicitly: their values are formatter
  expressions, not copy, so they carry no inline default.
- add src/i18n/externalStrings.ts to map English strings emitted by stream-chat
  notifications onto stable keys, with raw passthrough for unrecognised ones. The
  withReasonFallback helper is gone so every notification key is a literal the extractor
  can see.
- defaultTranslatorFunction (used before Streami18n initialises and outside <Chat>)
  returned the key verbatim, which with opaque keys would render
  "messageComposer.sendButton.label" in real UI. It now renders the inline default and
  interpolates.
- tests: ~40 files stubbed `t` as an identity function, which returned English only
  because keys *were* English. They now share mockT from mock-builders, which mirrors
  i18next's defaultValue, plural and interpolation behaviour. Stubs inside vi.mock /
  vi.hoisted factories get an inlined copy, since a top-level import is in its TDZ there.

Bundle: 419,172 -> 306,680 bytes gzip across the ESM tree, cumulative with the locale
removal in the previous commit (-27%).

Note: no TypeScript key union yet β€” `t` still accepts any string. Follows separately.
`t` accepted any string, so a typo compiled fine, and there was no way for an integrator to
discover which labels exist beyond reading src/i18n/en.json in the repo.

- generate src/i18n/keys.ts (`TranslationCatalog`, 633 entries) from en.json as part of
  build-translations. It is type-only β€” a mapped type, not a runtime object β€” so it costs
  nothing in the bundle while still emitting a self-contained .d.ts
- export the integrator-facing types: `TranslationKey` (union of every key),
  `TranslationDictionary` for registerTranslation()/translationsForLanguage (known keys
  autocomplete and are spell-checked, unknown keys allowed so integrators can register copy
  for their own components), and `StreamTFunction`
- type TranslationContextValue['t'] as StreamTFunction. The i18next -> Stream boundary is
  cast once, inside Streami18n.init()
- add `asDynamicKey()` for the 10 keys that are only known at run time (stream-chat
  notification messages, slash-command metadata from the API, language codes, an
  integrator-supplied prop). The brand is required, so a plain string is not assignable and
  every escape is deliberate and greppable
- do NOT install this via i18next's CustomTypeOptions: that augmentation is global and would
  force an integrator's own unrelated t() calls to satisfy the SDK's key union

Fixes a pre-existing packaging bug: the shipped Streami18n.d.ts imported './en.json' for
`Partial<typeof enTranslations>`, but tsc never copies JSON into dist/types, so that import
was unresolvable for consumers. It now references TranslationDictionary instead.

The typing immediately caught two real defects:
- threadList.unseenBanner.unreadThreads rendered the literal string
  "ThreadListUnseenThreadsBanner/unreadThreads" to users. Cause was in the key-map
  generator, which did not carry the `plural` flag onto mechanically-renamed keys, so the
  plural branch was skipped and the fallback used the key text. Generator fixed too
- getReadByTooltipText passed a string[] into a {{ lastUser }} interpolation (splice()
  returns an array)

Also moves timestamp.relative* to relativeTime.*: those four are user-facing copy, not
formatter config, and sharing the `timestamp.` prefix made that namespace mean two things.

tsc: 5.93s -> 4.79s.

Limitation: interpolation variables are typed for plural keys only. For prose keys both
defaultValue and options stay loose, because materialising CopyFor<ProseKey> (~540 string
literals) exceeds TypeScript's union size limit (TS2590). The checks that would buy are
covered elsewhere β€” default-vs-en.json by the drift gate, missing variables by a literal
{{ placeholder }} in output that the tests assert on.

Converts the build/migration scripts to TypeScript (.mts, since the package has no
"type": "module" and Node would read a plain .ts as CommonJS). Node 24 strips the types at
run time; `yarn types:scripts` checks them, wired into the CI lint job, with
erasableSyntaxOnly so anything type stripping cannot handle fails at check time.
The drift gate landed in the first commit did not actually work. Two problems, both found by
testing it rather than reading it:

1. It was red on every run. `generate-i18n-keys` emits JSON.stringify'd literals (double
   quotes) while prettier rewrites the committed file to single quotes, so regenerating always
   produced a full-file diff. build-translations now formats its own output.

2. It never caught a changed default. `i18next-cli extract` only *adds* keys β€” it deliberately
   never overwrites an existing value, which is correct for translated locales and wrong for
   the source language. So editing the copy at a call site left the bundled en.json value in
   place, silently changed nothing in the UI (en.json wins for `en`), and passed CI.

   scripts/sync-en-from-call-sites.mts now makes the inline defaults authoritative for en.json,
   and runs between extraction and key-type generation. It also rejects a key used with two
   different copies, which was previously undetectable.

That sync step immediately surfaced a half-fixed bug: threadList.unseenBanner.unreadThreads
still held "ThreadListUnseenThreadsBanner/unreadThreads" as its English value, so users saw
that raw string. The previous commit fixed the call site but not the bundled catalog that
actually renders.

- add docs/i18n-v15-migration.md: what changed, who is affected, how to rename keys, how to
  discover them (TranslationKey / TranslationDictionary / en.json), plurals, the non-prose
  formatter keys, recovering a deleted dictionary from a v14 tag, and dayjs locale setup.
  Linked from README and CLAUDE.md
- rewrite CLAUDE.md's i18n section, which still described 12 bundled languages and
  natural-language keys, and collapse the duplicate second copy of it into a cross-reference
… stale CLAUDE.md

- move the migration guide and the v14 -> v15 key mapping into ai-docs/, alongside the other
  migration material. The mapping is renamed to i18n-v15-key-map.json, since `key-map.json`
  is ambiguous next to breaking-changes.md and the other topics in that directory
- add an i18n section to ai-docs/ai-migration-v14-v15.md, which had no i18n coverage at all.
  Both i18n breaks fail silently β€” an unrenamed key just never matches and the English copy
  renders β€” so an agent following that guide had no way to detect them
- declare @types/node@^24 explicitly. `tsc -p tsconfig.scripts.json` depends on it, and it was
  only present transitively, so a clean install with different hoisting could have broken the
  scripts typecheck. Wire it explicitly into tsconfig.lib.json rather than relying on implicit
  inclusion of every installed @types package β€” `node` is genuinely needed there for the
  build-time `process.env.STREAM_CHAT_REACT_VERSION` define
- retype the `t` stubs in tests that were still declared as i18next's `TFunction`. The typed
  context surfaced 15 assignability errors under tsconfig.test.json; that config is not wired
  into CI, but the errors were IDE-visible. It now reports fewer errors than before this branch
  (1192 vs 1198 at the branch point)
- scope the translation drift gate to src/i18n/en.json and src/i18n/keys.ts. Watching all of
  src/i18n meant an unrelated edit under src/i18n/__tests__ tripped it
- CLAUDE.md: `yarn test` runs Vitest, not Jest; the documented `yarn e2e` / `yarn e2e-fixtures`
  scripts do not exist and are removed; and `yarn types` is called out as checking nothing. It
  runs tsc with no --project, so it picks up the root solution tsconfig with `"files": []` and
  exits 0 even with a deliberate type error in src/ (verified). The PR checklist now names the
  command that actually checks the library
`9877da511` ("migrate test suite from JavaScript to TypeScript") appended
`tsconfig.test.tsbuildinfo` onto the existing `shared` line instead of adding a new one,
producing `sharedtsconfig.test.tsbuildinfo`. That silently broke both entries: `shared` stopped
being ignored, and the tsbuildinfo was committed and has been dirtying `git status` on every
`tsc --project` run since.

- restore `shared` on its own line
- ignore `*.tsbuildinfo` rather than the one filename, so tsconfig.lib / tsconfig.scripts
  metadata is covered too
- `git rm --cached tsconfig.test.tsbuildinfo` (276 KB of build metadata)

Nothing is currently tracked under a `shared/` path, so restoring that entry has no effect on
the working tree today.
…ot inline

Every prose key already passes its English copy inline as i18next's `defaultValue`, so bundling
en.json shipped ~40 KB of strings that were already present in the component code.

`src/i18n/runtimeDefaults.ts` (generated) now holds the only translation data that ships: the 71
keys with no inline copy to fall back on β€” `language.*` (the key is built from a runtime language
code), `timestamp.*` / `duration.*` (formatter expressions passed around as prop values), and the
postProcessor directive. The other 562 render from their call site.

en.json is unchanged and still committed at full size β€” it is the translator/TMS reference and the
source for keys.ts β€” but it is no longer imported.

Removes 39,915 bytes raw from the bundle (44,325 -> 4,410 of translation data). Measured raw
rather than gzipped: gzip hides the duplication, since the second copy is a near-duplicate of text
already in its window, which understated the saving as ~7 KB.

Guards `parseMissingKeyHandler`. This was the blocker, and it is worse than a noisy log: i18next's
missing-key branch is `if ((usedKey || usedDefault) && parseMissingKeyHandler)` and it assigns
`res = parseMissingKeyHandler(...)`. With prose keys absent from the resource, `usedDefault` is
true for all of them, so an ordinary handler would have replaced most of the UI. i18next passes the
resolved default as the second argument, so `guardMissingKeyHandler` returns it untouched and only
defers to the integrator's handler for genuinely unknown keys. Six tests cover the real resolution
path (inline default, interpolation, plural selection, the bundled subset, and both handler
branches) rather than the mockT path most component tests take.

Also extracts the `t()` call-site parser into scripts/i18n-call-sites.mts. The sync step and the
generator both need to know which keys carry inline copy, and they must agree exactly or the
bundled resource and the generated types drift apart.

Known consequence, not mitigated: `debug: true` now logs a `missingKey` line for every prose key,
because i18next's logger call sits inside that same `usedDefault` branch and is not reachable from
Streami18n. `saveMissing` would behave the same way but is not exposed in Streami18nOptions.
…ext-cli

en.json stopped being imported when runtimeDefaults.ts took over the bundled
subset; it survived only as a build intermediate. Generate keys.ts from the two
places the copy actually lives instead: the inline defaultValue at each t() call
site (562 keys) and runtimeDefaults.ts (71), which flips from generated output to
hand-maintained source. The union is byte-identical to the en.json-derived file.

Removes i18next-cli (its only output was en.json) and the sync step (needed only
because extract never overwrites). Dead prose keys become structurally
impossible, retiring removeUnusedKeys/preservePatterns.

Adds two build-time guards: a key with no inline default and no runtimeDefaults
entry would render as the raw key; a key in both places is shadowed by the
bundled value, so the call site would silently have no effect.

yarn i18n:export writes the full catalog as JSON on demand for translators.

No bundle-size change β€” en.json was already unbundled.
The nine scripts in scripts/i18n-migration/ were one-shot and are all applied.
Two were already non-runnable: apply-key-map and generate-key-map read the
src/i18n/en.json deleted in 8b15f9a. Keeping generate-key-map was the real
hazard β€” it writes ai-docs/i18n-v15-key-map.json, so it looks like the way to
regenerate a table that is now hand-reviewed, and would clobber 603 rows.
extract-callsites is superseded by scripts/i18n-call-sites.mts. Git history keeps
them all.

The durable artifact, ai-docs/i18n-v15-key-map.json, stays; its $comment no
longer credits a script that does not exist.

Also fixes two pre-migration leftovers found while confirming the codemods had
nothing left to do:

- utils.a11y.ts documented `t('Pinned')`, which no longer compiles (TS2769) β€”
  `t` is StreamTFunction and rejects unbranded strings. Now shows the working
  pattern, t(asDynamicKey('myApp.channelList.pinned'), 'Pinned').
- PollAnswerList had a commented-out block still using natural-language keys.
registerTranslation replaced the stored dictionary, and translationsForLanguage
replaced it too for any language other than en. Either way the 71 runtimeDefaults
entries were discarded β€” the only keys with no inline defaultValue β€” so with
fallbackLng: false every timestamp.*, duration.* and language.* key rendered as
its raw dotted name. The class JSDoc's own registerTranslation example triggered
it, and non-English integrators now have no way around that path since the 11
bundled locales were removed.

Both entry points now layer {...runtimeDefaults, ...existing, ...dictionary}, so
partial dictionaries are safe as documented and repeated registrations
accumulate. addResources gets the merged object as well, or a language registered
after init() would write only the partial into i18next's store.

Also:
- export TranslationCatalog, so integrators can see the English copy a key
  renders instead of only its name (yarn i18n:export needs a repo clone)
- correct the JSDoc examples, which used keys that do not exist
  (messageList.empty, typing.multipleUsers) and so silently did nothing
- getTranslations() JSDoc no longer claims to return the full catalog
- migration guide: TranslationDictionary does NOT flag stale keys, it permits
  unknown ones; point at Partial<Record<TranslationKey, string>> instead
- type EXTERNAL_STRING_KEYS values as TranslationKey
- drop the misspelled geti18Instance accessor; the public i18nInstance field
  already exposes the i18next instance
None of the v26 breaking changes apply: we never used interpolation.format
(formatters already go through services.formatter.add, the API v26 mandates),
initImmediate, showSupportNotice, or i18next.format. The init config β€”
fallbackLng: false, keySeparator: false, nsSeparator: false, formatSeparator β€”
is untouched by the major.

Two things the migration guide omits but that matter here. simplifyPluralSuffix
was removed in 26.0.0, which is where the 65 plural keys live; the removal just
makes the _one/_other behaviour we already rely on the only behaviour, verified
by the suite. And nothing changed about parseMissingKeyHandler's
(key, usedDefault ? res : undefined) contract, which the prose-key design
depends on β€” the guard's two tests pass unchanged.

One real behavioural change, also undocumented: an undefined interpolation value
now short-circuits before the formatter runs, where v25 invoked the formatter
anyway. No effect on the SDK β€” our formatters return nothing for undefined
input, so a missing timestamp renders the raw placeholder on both versions, and
the normal paths are byte-identical. Integrators with a custom formatter that
produces output from an undefined value will see it stop being called; worth a
release note.

That change surfaced 4 test failures, all one artifact duplicated across the
Dayjs/moment loop: they called t('abc') with no options, so they asserted v25's
invoke-on-undefined behaviour rather than that the formatter was registered.
Passing a value fixes them and is version-agnostic β€” green on both 25 and 26.

Also drops a comment from tsconfig.lib.json (no behavioural change).

v26 has zero runtime dependencies (drops @babel/runtime) and its only peer,
typescript, is optional, so consumers inherit nothing new.

Not verified locally: yarn build and the CJS/ESM bundle validation β€” vite build
cannot run in this environment (nice(5) not permitted). CI covers both.
The v15 codemod only ran over src/, so the example still called t() with v14
natural-language keys. StreamTFunction rejects them, and `tsc` in examples/vite
failed with 34 errors across 6 files (30 distinct keys).

19 keys had a live SDK equivalent, resolved through ai-docs/i18n-v15-key-map.json
('Cancel' -> common.cancel.label, 'aria/Delete Message' ->
messageActions.deleteMessage.ariaLabel, 'timestamp/ChannelMembersLastActive' ->
timestamp.ChannelMembersLastActive, and so on).

The other 11 are app-owned: bulk member removal, the delete-option switches and
the sidebar toggle are demo-only features. Several were among the keys pruned as
dead in 207b4e9 β€” the deadness check scanned src/ and never saw the example
using them. They now take the documented integrator route,
t(asDynamicKey('viteExample.<area>.<thing>.<modality>'), 'English copy'), which
also makes the example a working reference for both halves: adopting SDK keys and
registering your own.

Panels.tsx hand-rolled its plural as
replyCount === 1 ? t('1 reply') : t('{{ count }} replies', { count }). That is now
one t('common.replyCount.label', { count, defaultValue_one, defaultValue_other }),
so Intl.PluralRules covers languages with more than two categories without
touching the component.

examples/vite tsc: 34 errors -> 0. examples/tutorial unaffected (0). Swept all 94
files across both example workspaces: no non-catalog literal t() key remains.
An override that never matches fails silently β€” no error, the English copy just
renders β€” so the type integrators reach for should be the one that catches a typo
or a leftover v14 key.

TranslationDictionary is that type: every SDK key including the _one / _other
plural entries, and nothing else. It is keyed on the generated catalog rather than
on TranslationKey, which matters β€” TranslationKey is the set t() accepts, where a
plural is the bare handle, so keying a dictionary on it rejects the very entries a
translator has to supply. The docs previously recommended exactly that, which broke
as soon as anyone added a plural.

LooseTranslationDictionary is the escape hatch: the same keys plus any of your own,
and the extra plural categories some languages need (_few / _many / _zero).
registerTranslation() and translationsForLanguage accept this wider shape β€” they
have to, or an app could no longer register its own copy through the same instance
β€” and a TranslationDictionary is assignable to it, so annotating strictly and
passing it in needs no cast.

Corrects the recommendation in both migration guides, the Streami18n JSDoc and
CLAUDE.md, and adds a test pinning the contract: plural entries accepted, a v14 key
a compile error, and both resolving at runtime.
@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

βš™οΈ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: d8dc29ca-0ffa-43dc-bd44-dc2347917d25

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • πŸ” Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❀️ Share

Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented Aug 10, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 75.72115% with 101 lines in your changes missing coverage. Please review.
⚠️ Please upload report for BASE (release-v15@59b7636). Learn more about missing BASE report.

Files with missing lines Patch % Lines
...8n/TranslationBuilder/notifications/translators.ts 14.81% 23 Missing ⚠️
src/components/Message/hooks/usePinHandler.ts 0.00% 8 Missing ⚠️
...omponents/MessageComposer/QuotedMessagePreview.tsx 38.46% 8 Missing ⚠️
src/components/ChannelListItem/utils.tsx 61.11% 7 Missing ⚠️
...ponents/MessageActions/MessageActions.defaults.tsx 73.07% 7 Missing ⚠️
...Accessibility/hooks/useInteractionAnnouncements.ts 81.48% 5 Missing ⚠️
...r/AttachmentPreviewList/AudioAttachmentPreview.tsx 20.00% 4 Missing ⚠️
...ssibility/hooks/useIncomingMessageAnnouncements.ts 50.00% 3 Missing ⚠️
...hannelHeader/hooks/useChannelHeaderOnlineStatus.ts 25.00% 3 Missing ⚠️
...components/Message/MessageTranslationIndicator.tsx 57.14% 3 Missing ⚠️
... and 21 more
Additional details and impacted files
@@              Coverage Diff               @@
##             release-v15    #3261   +/-   ##
==============================================
  Coverage               ?   84.22%           
==============================================
  Files                  ?      525           
  Lines                  ?    15989           
  Branches               ?     5114           
==============================================
  Hits                   ?    13467           
  Misses                 ?     2522           
  Partials               ?        0           

β˜” View full report in Codecov by Harness.
πŸ“’ Have feedback on the report? Share it here.

πŸš€ New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • πŸ“¦ JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

A later release adding a key does not break a custom dictionary β€”
TranslationDictionary is Partial, so the new string renders its inline English β€”
which is safe but silent. Nothing told an integrator that new copy had arrived
untranslated.

Documents the type-level diff that answers it: declare the dictionary
`as const satisfies TranslationDictionary`, then
`Exclude<keyof TranslationCatalog, keyof typeof de>` is the set of keys still
needing that language, browsable on hover and assertable as a CI gate.

Spells out why `satisfies` is load-bearing (a plain annotation widens
`keyof typeof de` to the whole catalog and the diff is always empty), and both
caveats: LooseTranslationDictionary gives the diff up in exchange for _few/_many,
and the check is compile-time only because TranslationCatalog is a type, so no
script can ask the installed package which keys exist.
@oliverlaz oliverlaz changed the title feat(i18n): English-only bundle with namespaced, type-checked translation keys feat(translations): English-only bundle with namespaced, type-checked translation keys Aug 10, 2026
@oliverlaz oliverlaz changed the title feat(translations): English-only bundle with namespaced, type-checked translation keys feat(i18n): english-only bundle with namespaced, type-checked translation keys Aug 10, 2026
Only `registerTranslation` and `translationsForLanguage` layered the bundled
`runtimeDefaults`. Selecting a language without supplying a dictionary β€” the
recipe the migration guide gives for an app that wants localized dates but is
happy with English copy β€” fell through to an empty dictionary, so every
`duration.*` rendered as its raw key and every timestamp came out as an
unformatted ISO string.

Every write into `translations` now goes through `mergeWithRuntimeDefaults`, and
`ensureLanguage` seeds any language that arrives without one β€” including into
i18next's store when that happens after `init()`, which is what fixes
`setLanguage()` to a never-registered language. That switch previously bypassed
every guard: no warning and no resource bundle, so dates broke. Before `init()`
the same call warned and fell back to English, so the outcome depended only on
whether `<Chat>` had mounted yet.

`validateCurrentLanguage()` was vacuous: the constructor seeded
`translations[currentLanguage]` before validating it, so the check could never
fail and its "language is not registered" warning was unreachable. It now tracks
the languages an integrator actually registered, and warns once instead of
resetting to `en` β€” the language is perfectly usable (English copy, localized
dates), and resetting silently discarded the integrator's `language` and dayjs
locale choice.
`timestamp.DateSeparator`, `ReminderNotification`, `ChannelPreviewTimestamp` and
`ChannelDetailPinnedMessageTimestamp` embed Today/Tomorrow/Yesterday/Last/at
inside their `calendarFormats` argument, and a per-key `calendarFormats` replaces
the locale's calendar β€” so `dayjsLocaleConfigForLanguage` cannot reach them. Both
migration guides said the opposite: that `timestamp.*` keys hold no text and are
overridden "to change how a date is formatted, not to translate anything". A
fully configured German app kept rendering "Today" in its date separators.

The guides now name the four keys and show the override, and a test asserts that
list matches `runtimeDefaults` so a fifth cannot appear without a docs update.
The German snippets are lifted from the passing tests.

Also trims the comments in `Streami18n.ts` and `runtimeDefaults.ts` to what the
code does rather than why it got there, and drops four `@ts-expect-error`
directives that no longer suppress anything.
@MartinCupela

Copy link
Copy Markdown
Contributor

@oliverlaz I think it would provide value to integrators if we included at least one other language mutation in the examples/vite app one. That way it will be easier to understand what steps are necessary to add own mutations. WDYT?

@oliverlaz

Copy link
Copy Markdown
Member Author

@oliverlaz I think it would provide value to integrators if we included at least one other language mutation in the examples/vite app one. That way it will be easier to understand what steps are necessary to add own mutations. WDYT?

Sure thing! I'll extend the demo and adjust the docs too. But first I wanted to get some feedback on the implementation before moving to the other parts.

Three related gaps in the typed i18n surface.

`registerTranslation()` and `translationsForLanguage` accepted
`LooseTranslationDictionary`, so the most obvious call shape β€” an inline object
literal β€” took any key. A typo compiled and then silently never applied, which is
the exact failure the typed catalog exists to prevent. Both now take
`TranslationDictionary`. The escape hatch survives untouched: excess-property
checking only applies to fresh literals, so a variable annotated
`LooseTranslationDictionary` (or `Record<string, string>`) is still assignable.
Making it strict immediately caught two test fixtures keyed on strings that are
not in the catalog.

`TranslationDictionary` also now accepts every `Intl.PluralRules` category on a
plural key, not just the `_one` / `_other` the SDK itself ships. Russian, Polish,
Arabic and friends needed `_few` / `_many` / `_zero`, and the only way to supply
them was to widen to the loose type and give up key checking on the whole
dictionary β€” including losing the completeness diff the guide recommends. A
plural suffix on a key that is not plural is still rejected, which the loose type
could not catch.

`yarn i18n:export` is documented as the file to hand a translator, but it emitted
the 14 `timestamp.*` / `duration.*` / `translationBuilderTopic.*` formatter
expressions alongside the copy, with nothing marking them. A TMS that localises
`{{value, notification}}` breaks notifications outright. The export now writes
the 619 translatable keys and names what it left out; `--all` restores the full
catalog.

Also guards the `externalStrings` seam: `translateExternalString` passes the raw
LLC sentence as the `defaultValue`, so that is what renders in English rather
than the key's catalog copy. When the two drift apart the catalog advertises a
string that never appears, so `build-translations` now fails on a mismatch. Two
entries word the same concept differently on purpose and are allowlisted.
`TranslationLanguage` from `stream-chat` enumerates the ~56 languages its
auto-translation feature supports, which is unrelated to what the SDK's UI can be
translated into β€” an integrator can register a dictionary for any language. Typing
`Streami18nOptions.language`, `registerTranslation`, `setLanguage`,
`addOrUpdateLocale`, `localeExists`, `currentLanguage` and
`TranslationContextValue.userLanguage` against it forced casts at every call site
for a language outside the union, including in the SDK's own tests. All of them
now take `string`.

That retires `isLanguageSupported` and `SupportedTranslations`, which only ever
answered "is this 'en'?" once the other locales stopped being bundled. Browser
language detection in `useChat` asks the i18n instance which languages actually
have a dictionary instead, so an integrator who registers German now gets German
picked up from the browser locale β€” it used to fall back to `defaultLanguage`
whatever they registered. `Chat`'s `defaultLanguage` prop widens to `string` for
the same reason.

Also moves `defaultStreami18nOptions` and `guardMissingKeyHandler` above the
`Streami18n` JSDoc. That block documents the class but sat directly on
`defaultStreami18nOptions`, so it never appeared when hovering `Streami18n`.

Drops the unused `notValidDateWarning` / `noParsingFunctionWarning` exports, the
`dayjsLocaleConfigForLanguage: null` default that only ever needed to be falsy,
and the try/catch around the Dayjs plugin registration, which rethrew the same
failure with a guess about its cause. Narrowing `DateTimeParser` through a local
lets the `isDayJs` type guard hold across the calls that follow it.
Comment thread src/i18n/Streami18n.ts
}

this.setLanguageCallback(t);
this.setLanguageCallback(t as unknown as StreamTFunction);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does it make sense to add castings as unknown as?

Shows what adding a language costs an integrator now that English is the only one
the SDK bundles: a dictionary of translated keys, the dayjs locale import, and a
`calendar` config. `src/i18n/de.ts` is the annotated one; `it.ts` is the same
shape without the commentary.

Both are complete β€” all 619 keys β€” and each ends with a compile-time completeness
gate built from `as const satisfies TranslationDictionary` plus
`Exclude<keyof TranslationCatalog, keyof typeof deTranslations>`. Add a key to the
SDK without translating it and the example fails to build naming the key. That is
deliberate, but it does mean an SDK change that adds copy has to touch these two
files. Partial dictionaries remain equally valid and the header says so β€” an
unsupplied key renders the English copy that ships inline at its call site.

Four `timestamp.*` keys are translated alongside the copy. dayjs takes calendar
wording as part of the format string, and a per-key `calendarFormats` argument
replaces the locale's own calendar, so `dayjsLocaleConfigForLanguage` cannot reach
them β€” without these a German app keeps rendering "Today" in its date separators.

Everything goes onto one `Streami18n` instance. `registerTranslation` takes the
dayjs config as its third argument, so each language carries its own and
`setLanguage()` swaps the active one with no remount. AppSettings > General now
has a Language section driving exactly that: the switcher writes to
`appSettingsStore`, a store subscription calls `setLanguage` and syncs the
`?language=` param, which keeps `aria-pressed` reactive.

Two fixes this surfaced:

`registerTranslation`'s third parameter was typed `Partial<ILocale>`, which has no
`calendar` property β€” that comes from dayjs's calendar plugin β€” so passing the
documented config was a TS2345 "no properties in common", while
`dayjsLocaleConfigForLanguage` was typed to accept it. The only path that works
for several languages on one instance was the broken one. Both now share an
exported `DayjsLocaleConfig`. Runtime behaviour was always correct.

The example imported `dayjs` without declaring it, resolving only via hoisting
from the SDK. Added to its package.json, which is the real lesson: adding a
language means having dayjs in your own dependencies.

The translations are machine-generated and not native-reviewed. They are
consistent and correct-looking, which is the bar for a demo, not for shipped copy.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants