Skip to content

feat(i18n)!: share the translation runtime as stream-chat/i18n - #1830

Open
oliverlaz wants to merge 25 commits into
release-v10from
feat/i18n-core-module
Open

feat(i18n)!: share the translation runtime as stream-chat/i18n#1830
oliverlaz wants to merge 25 commits into
release-v10from
feat/i18n-core-module

Conversation

@oliverlaz

@oliverlaz oliverlaz commented Aug 18, 2026

Copy link
Copy Markdown
Member

Moves the translation runtime shared by stream-chat-react and stream-chat-react-native into this package as a new stream-chat/i18n subpath, plus stream-chat/i18n/codegen for the catalog generator. Both UI SDKs carried ~1,300 lines of near-duplicate runtime that had drifted apart, each reverse-mapping this package's English notification prose against its own hand-maintained table of sentences.

Breaking, and shipped together deliberately — the first commit is independently revertable if you'd rather split it:

  • Notifications are keyed on CORE_NOTIFICATION_TYPE / CoreNotificationType. Notification.message is now documented as a developer-facing fallback whose wording is not contractual. Two identifiers renamed (api:message{s}:query:failedmessageJumpFailed / messageJumpToLatestFailed).
  • Poll-composer field errors become { code, message, metadata? } keyed on POLL_VALIDATION_CODE, instead of plain English strings.
  • engines.node>=22.18.0 (the release that unflagged type stripping, which the .mts build scripts need). Node 18/20 are no longer tested — see the guide for what that means if you deploy the WS client there.
  • i18next and dayjs become direct dependencies. They stay out of the root bundle; the subpath is what isolates them, and assertBundleBoundaries fails the build if that ever regresses.

Also here: the generator moves out of src/ to codegen/i18n/ (it reads the filesystem — not library source), ships ESM-only, and scripts/bundle.mjs becomes .mts with a real typecheck gate.

Docs: v9-to-v10-migration-guide-i18n.md (new), plus a Node-floor section in -other.md. Initiative record in specs/i18n-to-core/.

Verified: lint, all three tsc projects, 2,804 tests, and a clean-install check of the packed tarball across all four export conditions. The root bundle reaches neither src/i18n/, i18next nor dayjs — machine-checked, not reviewed.

Consumer PRs, both blocked on this publishing as 10.0.0-rc.3: GetStream/stream-chat-react#3271 · GetStream/stream-chat-react-native#3777

…ntifiers

Both UI SDKs reverse-mapped stream-chat's English notification prose by exact
string match to resolve a translation, because nothing tied the identifiers they
dispatch on to anything checkable. Their maps had drifted apart in both
directions: entries for identifiers nothing emits, and core identifiers neither
maps, silently falling back to the English string.

The mechanism already existed -- `Notification.type` carried a
`domain:entity:operation:result` identifier on every emission site -- but it was
typed as a bare `string`, and its JSDoc documented a field named `code` that does
not exist, which is what made it look absent.

- add `CORE_NOTIFICATION_TYPE` + `CoreNotificationType`, typed as
  `CoreNotificationType | (string & {})` so SDK- and integrator-emitted
  identifiers still pass while core's autocomplete
- emit all 12 identifiers through the map, so the set is greppable from one place
  and a typo is a compile error
- document `Notification.message` as a developer-facing English fallback whose
  wording is not part of the public contract, not display copy
- give poll-composer field errors a stable `code` alongside their English
  `message`, with code -> copy held in one map so the two cannot drift
- guard both with tests: every declared identifier must actually be emitted, and
  no raw type literal may appear in src/

BREAKING CHANGE: `api:messages:query:failed` and `api:message:query:failed` were
a singular/plural split for two different operations. They are now
`api:message:jump:failed` and `api:message:jumpToLatest:failed`. Consumers keying
on the old values must update.

BREAKING CHANGE: `PollComposerFieldErrors` values are now
`{ code, message, metadata? }` rather than a bare English string. Read
`errors.<field>.message` for the previous value, or switch on
`errors.<field>.code` to localize.
Both UI SDKs had independently converged on the same i18n architecture and were
carrying ~1,300 lines of near-duplicate runtime to do it: a `Streami18n` class,
four formatters, `getDateString`, and the type machinery deriving a typed `t()`
from a generated key catalog. This moves that layer down to core so there is one
implementation, while each SDK keeps the one part that is genuinely its own --
its generated catalog.

Shipped as a separate entry point, not from the root barrel: the layer needs
i18next and dayjs, and core has three runtime dependencies. `scripts/bundle.mjs`
now asserts that boundary from esbuild's metafile in both directions, so a
stray `export * from './i18n'` fails the build instead of silently adding ~30kB
to every consumer. Verified: `dist/esm/index.mjs` is byte-identical.

- `StreamI18n` is reactive through a `StateStore`, replacing the single callback
  the web SDK had and the five listener members RN had. `subscribe` fires
  synchronously with the current value, which also dissolves the queued-override
  race the listener design needed `queuedTFunctionOverride` for.
- `setLanguage` returns `void`. It previously returned three different shapes and
  no call site in either SDK, either example app or the docs used the value --
  handing back a `t` that goes stale on the next language change only invites
  callers to cache it. The store is the single source of the current translator.
- `i18nextConfigOverrides` accepts any `InitOptions`, replacing a second
  positional constructor argument that could reach only a curated subset.
- `init()` is memoized and never cleared, so it is genuinely idempotent; RN's
  version cleared its guard on completion, leaving a re-entry window.
- `runtimeDefaults` is injected rather than imported, since the catalog belongs
  to the UI SDK. It is layered under every language, which is what stops a
  partial dictionary from knocking out formatter keys.
- Type helpers are generic over the catalog, so the derivations live here while
  the catalog stays upstream. Two catalogs can coexist in one program, which
  module augmentation could not express.
- `relativeCompactDateFormatter` is now an alias of
  `timestampFormatter(relativeCompact: true)`, whose wording goes through `t()`.
  RN's standalone version hardcoded 'Today' / 'Yesterday' / '3d ago', which no
  dictionary could translate and which the codegen's English-prose guard could
  not see because it lived in a formatter body.
- No module-scope side effects: every `Dayjs.extend` moved into
  `ensureDayjsPlugins()`, so `sideEffects: false` is now accurate. RN's
  module-scope `Dayjs.updateLocale('en', ...)` is not ported -- it rewrote
  L/LL/LT for the entire host app.
- `Intl.PluralRules` coverage is checked during `init()`, turning Hermes' silent
  fallback to `{ other }` -- which makes a correct `_few`/`_many` dictionary
  render nothing, with no error -- into a warning.
- Dropped on the way: a dead `Dayjs = null` field, a no-op `dayjs/locale/en`
  import, moment-flavoured advice in a dayjs code path, duck-typed guards that
  threw on null, `JSON.stringify(error)` rendering an Error as `{}`, and a
  misspelled `geti18Instance`.
- `moment-timezone`'s types are replaced by a structural `DateTimeLike`, so a
  devDependency no longer leaks into the published `.d.ts`.

Tests: RN's three behavioural guarantees are ported as the acceptance contract
and run against a synthetic fixture catalog, since core has none. Vitest now
forces TZ=UTC -- the date assertions previously passed only on a machine that
happened to be in UTC, which is what CI is.
…tional peers

`stream-chat/i18n` imports both, so `stream-chat` should depend on them rather
than require consumers to install them for it.

They were initially declared as optional `peerDependencies` to hold core at three
runtime dependencies. That does not hold up: optional peers are not installed, so
`require('stream-chat/i18n')` failed with `MODULE_NOT_FOUND: Cannot find module
'dayjs'` in any project that had not separately added them. The requirement was
declared in core but satisfiable only somewhere else, which pushed the problem
onto every consumer and made the UI SDKs responsible for a dependency core is the
one importing.

The devDependency entries go with them: they existed only because optional peers
are not installed and core's own tsc and Vitest still had to resolve the imports.
As real dependencies they are installed, so the duplicate declaration is dead.

Accepted cost: ~2.3 MB unpacked in node_modules (i18next 416K, dayjs 1.9M) for a
consumer who never translates, and a dependency count of five rather than three.
Bundle size is unaffected -- the subpath entry point, not the dependency kind, is
what keeps them out of the root bundle, and `dist/esm/index.mjs` stays
byte-identical at 907,599. `scripts/bundle.mjs` externalizes `dependencies` and
`peerDependencies` alike, so no build change was needed and the boundary
assertion still fails the build on a leak.

Verified against a packed tarball: installing `stream-chat` alone now pulls both
in transitively, and `new StreamI18n().init()` resolves.
…ilder

Three pieces that both UI SDKs were carrying separately, or could not check.

`languageNames` — the 57 human-readable language names used to render "Translated
from German" for an auto-translated message. These are display copy for a
core-owned set: `message.i18n.language` is typed `TranslationLanguage`, so core
defines which languages exist and should own their names. React hand-maintained
its own copy with nothing tying it to the union, so its call site had to detect a
miss by comparing the rendered string against the key.
`satisfies Record<TranslationLanguage, string>` now closes that in both
directions: a language added to the API union fails to compile until named, and a
name for a language the union lacks is rejected. Both verified.

`CORE_NOTIFICATION_TRANSLATION_KEY` + `translateNotification` — one canonical
`type` -> key table, exhaustive over `CoreNotificationType`. Both SDKs
independently maintained the same 16-entry table and the copies had drifted in
both directions: entries for identifiers nothing emits, and core identifiers
neither mapped, which fell through to untranslated English. Keys are shared
rather than per-SDK so an integrator's notification dictionary is portable
between React and React Native. An unrecognized identifier renders `message`
verbatim, so a newer core cannot produce an empty toast.

`TranslationBuilder` — the i18next post-processor plumbing, for copy that cannot
be resolved from a key alone. React had it; RN dispatches at the render site
instead. Only the mechanism moves: topics and translators reference SDK key names
and stay upstream, so either approach works without a core change. Note that
post-processing is configured globally in i18next, so a topic is invoked for
every key and must pass through calls it does not recognize -- covered by a test,
since getting it wrong would silently rewrite unrelated copy.

Root bundle unchanged at 907,599 bytes; the i18n bundle grows to 38KB.
…ubpath

Both UI SDKs carried their own copy of this: a ~250-310 line generator plus a
~105 line call-site reader that differed by five lines. It reads every `t()` call
in the source, joins it with the SDK's bundled defaults, and regenerates the
type-only key catalog the i18n types derive from.

`typescript` is injected through the config rather than imported, so `stream-chat`
still does not depend on the compiler -- only the parser API is used, so there is
no Program and no type checker. It lives in `src/i18n-codegen/`, a sibling of
`src/i18n/` rather than a child, so the runtime layer physically cannot reach
`node:fs`; the build asserts that boundary. Shipped under a `node`-only export
condition.

Four guards, not five. The dropped one checked that an `EXTERNAL_STRING_KEYS`
entry's wording matched its key's catalog copy -- that map is gone, because
notifications now resolve through a stable identifier instead of by matching
English prose. Kept: conflicting inline copy, a key with no default and no
bundled entry (it would render as a raw dotted path), a key in both places (the
bundled value wins, so editing the call site would silently do nothing), and a
key that is a strict dotted prefix of another.

Guards return failures as data with a thin printer on top, so the tests assert on
the failure rather than scraping stderr, and run in-process instead of spawning
the script. That is most of why the SDK-side version of this suite was 381 lines.

Verified for fidelity against both SDKs' real, committed catalogs rather than
only against fixtures: React 634/634 entries and RN 408/408 plus its 97 bundled
keys reproduce identically, with no guard failures on either. Each SDK's script
becomes ~15 lines of configuration.

Also fixes a bug this found in the boundary assertion itself: it keyed forbidden
sources by elimination ("not the root entry"), so it flagged the codegen bundle
for reaching its own files. Entries now declare their boundary explicitly, and an
entry with no declared boundary is itself an error.
`v9-to-v10-migration-guide-i18n.md` follows the sibling convention (Scope
blockquote, TL;DR, exact before/after per entry, mechanical checklist at the end).

It is warranted even though the module move is additive, because Phase 0 is not:
the two renamed notification identifiers and the `PollComposerFieldErrors` shape
change are breaking, and the `Notification.message` contract change produces no
compile error at all — an integrator rendering it keeps working while relying on
something now documented as unstable. That is precisely the class of change a root
guide exists for, and it reaches integrators with no UI SDK.

Includes the full 12-identifier table with its translation keys, which did not
exist anywhere before and is the most useful artifact here for anyone writing a
notification dictionary. Both tables were verified against the compiled output in
both directions: every identifier the code exports appears in the guide, and the
guide claims none that do not exist.

Also:

- cross-link the new guide from `other.md`, whose Scope list was already stale --
  it named four siblings and omitted server-side and type-renames.
- `specs/i18n-to-core/{spec,plan,decisions}.md` + `state.json`. `decisions.md`
  records the reversals rather than rewriting them, since why a rejected option
  was rejected is the part that is not recoverable from the diff -- optional peers,
  the restored dayjs locale stubs, the barrel-import "fix" that measured worse.
- an i18n section in `CLAUDE.md`, which had none, leading with the invariant most
  likely to be broken by accident: `src/index.ts` must never re-export `./i18n`.
- correct the Build pipeline section, which still described three bundles from one
  entry point.
Adopting the shared layer in `stream-chat-react` ran its existing suite against
it for the first time, which found five real bugs in the port. All five render
wrong rather than throwing, so none would have been caught by types.

1. `getDateString` forwarded every option to `t()` including undefined ones, and
   those are merged *over* the arguments the key's own formatter expression
   declares. A component passing `format: undefined` -- the normal case, since
   these are optional props threaded straight through -- therefore overrode
   `timestampFormatter(format: HH:mm)` with nothing, and every message timestamp
   rendered as a raw ISO string. Only reproducible through `getDateString`;
   calling `t()` directly was fine, which is what hid it. The pre-move
   implementation filtered these too.
2. The timestamp handed to a formatter must be a `Date`. Integrators override a
   `timestamp.*` key with their own formatter and read `options.timestamp`,
   expecting to call `.toISOString()` on it.
3. `relativeCompact` was only honoured when it came from a key's expression, not
   when passed to `getDateString` directly, so callers asking for it got ordinary
   formatting instead.
4. A future timestamp rendered as "Today": the day difference was tested as
   `<= 0` rather than `=== 0`, with no branch for a negative difference.
5. The weeks branch matched when it should not have. With `maxWeeks: 0`, a
   three-day-old timestamp has `Math.floor(3 / 7) === 0`, so `0 <= 0` matched and
   rendered "0w ago" instead of falling through to a date.

Also:

- `RELATIVE_TIME_CATALOG` is exported, and the relative-compact wording restored
  to plural defaults. These keys are rendered here but declared in each SDK's
  generated catalog, so moving the call sites into core silently dropped them
  from `TranslationCatalog` -- English still rendered, so nothing looked broken,
  but an integrator could no longer type them in a dictionary and therefore could
  no longer translate relative dates at all.
- A malformed `calendarFormats` is now reported through the instance's logger
  rather than being passed to the translate function, which is not what a
  diagnostic is for. `FormatterContext` gains `logger`.
- `DateTimeLike` is declared with method shorthand. Under `strictFunctionTypes` a
  function *property* is checked contravariantly, which made a real Dayjs
  unassignable; method syntax is bivariant, which is what duck-typing across two
  date libraries needs.
- `LooseTranslateFunction` takes `any` parameters. An SDK's `t` is a four-overload
  callable keyed to its own catalog, and a narrower key parameter is not
  assignable to a `string` one -- which forced a cast at nine call sites in
  `stream-chat-react` alone.

Regression tests for all of it, including one pinning the `DateTimeLike`
declaration style and one asserting the relative-compact keys stay translatable.
…uction

Two more defects the React adoption surfaced.

`validateCurrentLanguage()` ran in the constructor as well as in `init()`.
`registerTranslation()` legitimately runs *after* construction -- it is the
documented way to add a language -- so the constructor call fired for every
integrator doing the normal thing, and warned twice when the language really was
unregistered. The check now runs only at init, which is the first moment the set
of registered languages is final. Pinned with three tests: no warning at
construction, exactly one at init when no dictionary ever arrives, and none when a
dictionary was registered in between.

`getTranslations()` is restored. It was dropped as unused public API, but the web
SDK's tests use it, so it is not unused -- and removing a public accessor whose
backing field is public anyway is a breaking change with nothing to show for it.
Writing the migration guide for this behaviour is what caught it: the guide claimed
a null *or unparseable* timestamp renders as empty, and only the null half was
true. An unparseable string rendered "Invalid Date" -- junk a user can see, in the
same class as the literal "null" this already guarded against.

`getDateString` has always had this guard; `timestampFormatter` is a separate path
reached directly from a key's expression and did not. Both now agree.

`undefined` is deliberately not handled here and is unchanged: i18next skips
interpolation when the value is undefined, so it never reaches the formatter and
the raw expression comes through -- which is a useful signal that the option name
is misspelled at the call site.
…11y date helpers

Three defects the React Native SDK's suite surfaced while adopting `stream-chat/i18n`,
all of which the type system was supposed to have caught and did not.

`DateTimeLike` was circular: `startOf` returned `DateTimeLike`, so checking a Moment
against it required Moment's `startOf` return -- a Moment -- to satisfy `DateTimeLike`,
requiring `startOf` again. Method bivariance does not break that cycle, and narrow unit
unions compounded it. The docstring promised bring-your-own-Moment; it did not work.
`startOf` now returns only what is called on it, and the unit/operand parameters are
open, since no union can name both libraries' vocabularies.

`DateTimeParserModule`'s members were function *properties*, checked contravariantly, so
`locale?: (...args: unknown[]) => unknown` demanded an implementation accepting anything
at all and rejected moment's overloaded `locale`. Method shorthand, as `DateTimeLike`
already used.

`getDateStringForA11y` had flattened two genuinely different functions into the web SDK's.
The React Native one keeps the locale's relative wording and substitutes `LL` into the
calendar's `sameElse` slot, because iOS VoiceOver reads "04/08/2026" character by
character. It returns as `getCalendarDateStringForA11y`, with `A11Y_CALENDAR_FORMATS` for
the bundled locale. Collapsing them would have silently changed every announced date label
in one SDK or the other.

Type-level regression assertions cover the first two, so they fail at `yarn types` rather
than in a consumer. A hand-written Moment-shaped stand-in reproduces the narrow unions and
self-returning `startOf` without taking moment as a dependency.

Also removes `src/i18n/notifications.ts`. `CORE_NOTIFICATION_TRANSLATION_KEY` and
`translateNotification` were used by neither UI SDK, and could not be: the catalog codegen
reads the literal key at each `t()` call site, so a key resolved from a map never reaches
the catalog. The drift protection lives in `CORE_NOTIFICATION_TYPE` /
`CoreNotificationType`, which both SDKs key a `Record<CoreNotificationType, translator>`
on -- that is what makes a new identifier a compile error. Key *names* stay per-SDK; both
have shipped and integrators' dictionaries depend on them. The migration guide now shows
the record pattern directly instead of pointing at a helper that could not have worked.

BREAKING CHANGE: `getDateStringForA11y` from `stream-chat/i18n` is the `LLLL` variant.
Callers who want the calendar variant -- relative wording preserved, `LL` in the
`sameElse` slot -- should use `getCalendarDateStringForA11y`.
`CORE_NOTIFICATION_TRANSLATION_KEY` and `translateNotification` are removed; key a
`Record<CoreNotificationType, …>` on `CORE_NOTIFICATION_TYPE` instead.
…ted aliases

`StreamI18n` was a new spelling that bought nothing. Both UI SDKs have shipped and
documented `Streami18n` for years, so the capital `I` would have cost every integrator a
rename plus a `@deprecated` alias in each SDK, carried for a cycle, for a purely cosmetic
gain. Core is `Streami18n`; `Streami18nOptions` and `Streami18nState` follow, as does the
brand symbol and the log-message prefix.

The same argument applies to the two other `@deprecated` this module introduced, so both
are gone rather than carried:

- `getTranslators()` was an alias for `init()`. `init()` is the better name -- it
  initializes rather than gets -- so the old one is removed.
- `relativeCompactDateFormatter` was an alias for `timestampFormatter` with
  `relativeCompact: true`. The React Native SDK's standalone version hardcoded `'Today'`
  and `` `${n}d ago` ``, which no dictionary could reach; the aliased behaviour routes its
  wording through `t()`. Keeping the old name would have been a second name for the worse
  of the two. `predefinedFormatters` is three formatters, not four.

There is now no `@deprecated` anywhere in `src/i18n/`. This is a breaking release, so an old
name is removed rather than shipped with a countdown attached.

BREAKING CHANGE: the class exported from `stream-chat/i18n` is `Streami18n`, not
`StreamI18n`; `Streami18nOptions` and `Streami18nState` likewise. `getTranslators()` is
removed -- use `init()`, which returns the same state. The `relativeCompactDateFormatter`
i18next formatter is removed; use `timestampFormatter` with `relativeCompact: true`, so a
`timestamp.*` expression becomes `{{ timestamp | timestampFormatter(relativeCompact: true) }}`.
It said the prerelease channel comes from a branch named `rc`. The branch is
`release-v10` with `prerelease: "rc"`; `rc` survives only as a legacy allowance in
`release.yml`'s branch gate. Since `release.yml` is `workflow_dispatch` and releases from
whatever branch it is dispatched on, this is the difference between knowing where a v10 RC
comes from and guessing.

Also notes that the PR workflows here carry no branch filter, unlike the two UI SDK repos.
The generator is Node-only build tooling that reads the filesystem -- the one thing this
SDK's source must never do, since a Node-only import breaks browser and React Native
bundles outright. It sat in `src/` as a sibling of `src/i18n/` with only a build-time regex
keeping the runtime layer away from it. It is now at `codegen/i18n/`, outside `src/`
entirely.

It stays published: two other repos import `stream-chat/i18n/codegen` from their build
scripts, so this is versioned, typed, semver-relevant API rather than internal tooling like
`scripts/bundle.mjs`. **The published surface is unchanged** -- `dist/types/` is
byte-identical, the root and i18n bundles are byte-identical (907,599), and `exports` /
`typesVersions` still point at `dist/types/i18n-codegen/`. The only diff in `dist/` is the
source paths esbuild embeds in the two codegen bundles, which got shorter.

The move is cheap because the generator was already fully decoupled: zero imports from
`src/`, only `node:fs`, `node:path`, a type-only `typescript` and its own siblings. Nothing
in `src/` imported it either.

Three things were scoped to `src/` and would each have failed **silently**:

- `tsconfig.json` has `rootDir: "./src"` and `include: ["./src/**/*"]`, which is what maps the
  generator's declarations to `dist/types/i18n-codegen/`. Widening `include` would not work --
  `rootDir` must contain every input, so it would become `.` and every path under
  `dist/types` would gain a `src/` prefix, breaking all the types entries. Hence
  `tsconfig.codegen.json`, with `rootDir: "./codegen/i18n"` so the output path is unchanged.
- `yarn types` was bare `tsc --noEmit` on the root project; the generator would have stopped
  being typechecked. Both `types` and `build` now run both projects.
- Every `eslint.config.mjs` rule block is `files: ['src/**/*.{js,ts}']`, and the top-level
  `ignores` has `'*.{js,ts}'`, so a new top-level directory inherits no rules whatsoever.
  Both blocks now list `codegen/**/*.{js,ts}`.

Verified rather than assumed: a `!` in `codegen/i18n/` is now reported by `yarn eslint`, a type
error there is now reported by `yarn types`, and the packed tarball's
`stream-chat/i18n/codegen` resolves with all ten exports and its `.d.ts` present.

One real gain beyond tidiness: the "i18n runtime must not reach the generator" boundary is now
enforced by the type system, since `codegen/` is outside the library project -- an import from
`src/i18n/` fails at `tsc` before the metafile assertion runs. The error is an oblique TS6059
"not under rootDir", so the assertion in `scripts/bundle.mjs` stays as the backstop, and its
comment now says why.
The generator shipped in both CJS and ESM because I copied the shape of the runtime
entries. Nothing needs the CJS one: both UI SDKs invoke it as
`node scripts/generate-i18n-keys.mts`, and `.mts` is unambiguously ESM, so they take the
`import` condition. Nothing anywhere references `dist/cjs/i18n-codegen.node.js` and no test
loads it -- the suite imports the source directly. It was an untested published artifact.

The CJS flavours of the other two entries are load-bearing for a reason that does not apply
here: React Native's Jest runs CJS with `customConditions: ["react-native"]` and does not
transform `node_modules`, so an `.mjs` there is a syntax error. That path loads the
*runtime*. The generator is invoked by a build script -- never bundled, never loaded by a
test runner.

`exports["./i18n/codegen"]` loses its `node`/`require` split entirely, since one artifact now
serves every caller. Verified from a clean install of the packed tarball: a direct ESM
`import` works, `await import()` from CommonJS works, and even a plain `require()` works on
Node 20.19+ via `require(esm)`. Only a `require()` on Node 18 or 20.18 now fails, and
`await import()` covers that.

The root bundle stays byte-identical at 907,599 and `dist/types/` is untouched.

BREAKING CHANGE: `stream-chat/i18n/codegen` ships ESM only. A CommonJS caller on Node below
20.19 must use `await import('stream-chat/i18n/codegen')` instead of `require()`.
…ld scripts

`.mts` on its own buys nothing -- Node strips types, it does not check them -- so this adds
`tsconfig.scripts.json` and a `types:scripts` gate folded into `yarn types`. Otherwise the
annotations could be wrong with no signal anywhere, which is worse than the JSDoc `@type`
comment they replaced, since that one was at least inert.

Turning the checks on found real things:

- **`packageJson.peerDependencies` does not exist.** `bundle.mjs` spread it into the
  externals list, so the code claimed to externalize peer dependencies and never could.
  Harmless today (this package has none) but dead and misleading. Rewritten to destructure
  with defaults so it stays correct whether or not the field is there.
- `browserIgnoreModules` was an implicit `any[]`.
- Two narrowings did not survive into callbacks: `output.entryPoint` and
  `forbidden.sources`, both guarded and both still `possibly undefined`/`null` inside the
  arrow that used them. Hoisted to locals.
- `commonBuildOptions` wants `satisfies esbuild.BuildOptions`, not a `:` annotation. The
  annotation widens every field to its optional declared type, so spreading
  `...commonBuildOptions.define` yielded a possibly-`undefined` value; `satisfies` checks the
  literal while keeping its exact shape.
- `['browser', 'node'].map(...)` widened `platform` and `format` to `string`, which
  `BuildOptions` rejects. `as const` plus an explicit return type on the callback.

Two coverage gaps had to be closed first, both silent:

- The `yarn prettier` glob was `'**/*.{json,js,mjs,ts,yml,md}'` -- **no `mts`**. Every `.mts`
  in the repo has escaped the format gate since the first one landed, and renaming `bundle`
  would have quietly removed it too. Widened, which immediately flagged
  `scripts/apply-custom-data-types.mts`; the change there is formatting only.
- `scripts/` had no typecheck at all. The two `.mts` scripts already here turned out clean.

`scripts/` is **still not linted** -- `eslint.config.mjs` scopes every rule block to `src/**`
and `codegen/**`. Recorded in `CLAUDE.md` as a follow-up rather than widened here.

Verified: the build runs through the shebang with no loader, the root bundle stays
byte-identical at 907,599, and a deliberate `export * from './i18n'` in `src/index.ts` still
makes both `./scripts/bundle.mts` and `yarn build` exit 1 with the leaked sources and
dependencies listed.

One consequence worth knowing: `prepare` runs `yarn build`, so a **git-ref** install now needs
a Node new enough to strip types (22.6+ with the flag, 23.6+/24 without) even though
`engines.node` still says `>=18`. Registry installs are unaffected -- they get the prebuilt
`dist/`. Noted in `CLAUDE.md`.
Follow-up to the `.mts` conversion, which left two things inconsistent.

`.lintstagedrc.json` carries its **own** globs, separate from the `yarn prettier` script, and
they omitted `mts` the same way — so the pre-commit hook skipped every `.mts` file. Visible
in the hook's own output, which reported 3 of 5 staged files.

Widening the lint-staged eslint glob to `.mts` then forced the question this deferred:
`eslint --max-warnings 0` treats a file matching no config block as a warning ("File ignored
because no matching configuration was supplied"), so a staged `.mts` would have failed the
hook. `eslint.config.mjs` now lists `scripts/**/*.mts` alongside `src/**` and `codegen/**`.

Total cost across all three scripts was two findings, and the first is a real latent break:

- `scripts/generate-filter-types.mts` imports `yaml`, which was declared in neither
  `dependencies` nor `devDependencies`. It resolves today only because `lint-staged` depends
  on it transitively, so a lint-staged bump could break the script with
  ERR_MODULE_NOT_FOUND. Declared as a devDependency at the version already resolving;
  `yarn.lock` gains exactly one line.
- A `prefer-const` in the same file.

`import/no-extraneous-dependencies` is the rule that caught the first one, and it had never
run on that directory.
The build scripts are `.mts`, executed by `node` with no loader, and `prepare` runs
`yarn build` -- so anyone installing from a git ref has to be able to run them. The declared
floor of `>=18` did not reflect that.

**22.18.0, not 22.12.** Unflagged type stripping landed in 22.18.0 (24.3.0 on the 24 line,
23.6.0 on the 23 line). 22.12 is a different milestone -- it unflagged `require(esm)` -- and
is not enough to execute a `.mts` file without `--experimental-strip-types`. The two get
conflated; on 22.12 the build would still need the flag.

A side effect: every supported Node now has `require(esm)`, which fully retires the caveat on
the ESM-only `stream-chat/i18n/codegen` subpath. `require()` of it works, not just
`await import()`. Both guide passages updated.

Documented in `v9-to-v10-migration-guide-other.md` as its own section, including the honest
qualifier: nothing in the *shipped runtime* is known to need 22.18. A registry install gets a
prebuilt `dist/` and never runs the build, `engines` is advisory, and most package managers
warn rather than fail. The accurate reading is "18 and 20 are no longer tested".

That collides with one existing piece of guidance, so both sides now say so rather than
leaving a reader to reconcile them: `v9-to-v10-migration-guide-server-side.md` documents
running the WebSocket client on Node 18/20 by injecting a `WebSocketImpl`, since Node only
gained a global `WebSocket` in 22. That still works mechanically and is still the right answer
for someone stuck on an older runtime, but it is now below the declared floor -- an
unsupported bridge rather than a supported configuration. Whether to keep supporting Node
18/20 in that scenario is a product decision, so the guidance is annotated, not removed.

BREAKING CHANGE: `engines.node` is now `>=22.18.0`, up from `>=18`. Node 18 and 20 are no
longer tested. The shipped runtime is not known to require 22.18 -- the floor is driven by the
build scripts, which only a git-ref install executes -- but if you deploy the WebSocket client
on Node 18/20 via `WebSocketImpl`, that path is now unsupported.
`TranslationTopic` takes an i18next instance and `Streami18n.i18nInstance` exposes one, so the
type is already part of this module's public surface — but naming it required reaching past
`stream-chat` into `i18next` and declaring that dependency yourself. Same shape as the
`moment-timezone` type leak this initiative removed.

Lets the React SDK drop its `i18next` devDependency, which existed solely to type a mock.
* **These values are public API.** UI SDKs key their translation tables on them, so renaming one is a
* breaking change.
*/
export const POLL_VALIDATION_CODE = {

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.

I would maybe change the name to be more specific what is the code related to. Probably it would make the purpose clearer if we included word 'notification' in the variable name.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in: 4ab62e0

…or us

The React SDK's i18n suites were testing this module through a thin re-export, so its
assertions are moving here. Adds what this suite did not already cover:

- The full relative-compact branch matrix — today/yesterday, the day and week counts, the
  fall-through to a date, and four boundaries that were regressions found during the port: a
  future timestamp rendering as "Today", `relativeCompactMaxWeeks: 0` rendering "0w ago",
  `relativeCompact` ignored on the direct `getDateString` path, and the weeks branch firing
  before a full week elapsed.
- Malformed `calendarFormats` reported through the instance logger rather than through
  `translate`. Worth recording what the probe showed: only a bare non-JSON word reaches the
  formatter. A brace-wrapped malformation is dropped by i18next's own argument parser first, so
  nothing is logged and the timestamp renders unformatted — this guard covers a subset.
- dayjs locale configs supplied both at construction and through `registerTranslation`.
- The timezone default (local) and the degradation when the parser has no timezone support.
- `registerTranslation` surviving `setLanguage` moving away and back, including via an
  unregistered language.

`createDefaultTranslatorFunction` stands in for `t` in the formatter tests: it honours inline
defaults and the `defaultValue_one`/`_other` pair exactly as i18next does, which is the shape
the formatter passes.
oliverlaz added a commit to GetStream/stream-chat-react that referenced this pull request Aug 18, 2026
`Streami18n`, `getDateString` and `predefinedFormatters` live in `stream-chat/i18n` now — this
package only re-exports them — so asserting their behaviour here duplicated core's suite in a
second repo. Every assertion removed is covered on that side, and the six it did not cover were
added there first (GetStream/stream-chat-js#1830).

−1,154 lines. `utils.test.ts` goes entirely: all 570 lines were `getDateString` and
`predefinedFormatters`. `Streami18n.test.ts` keeps the five describes that are genuinely about
*this* package and drops the ten that were not:

**Kept** — this SDK's catalog types and its `as const satisfies` completeness diff; the calendar
keys that carry English words, which is an assertion about this catalog and its migration guide;
the subclass merge behaviour (a caller's topic overriding the bundled `notification` one, and
`runtimeDefaults` not being mutated as a shared module object); and the vitest timezone config.

**Dropped** — default translator, prose resolution and `parseMissingKeyHandler`, registered and
custom dictionaries, `registerTranslation`, `setLanguage`, timezone, formatters, the
unregistered-language warning, and dates for an unregistered language. Core's G1/G2/G3
guarantees, `setLanguage`, `formatters` and `TranslationBuilder` suites assert all of it.

Removing them made `moment-timezone` dead here, so it is dropped from devDependencies — core
depends on no date library by name, its structural `DateTimeLike` covers both. `dayjs` stays:
three component tests still build dates with it.

Note the file opens with `/* eslint-disable */`, so the imports left dangling by the cut were
invisible to lint and had to be found by hand. Worth removing that blanket disable separately.

One correction folded in: renaming the i18next mock type last commit missed four
`i18n['t']` casts in NotificationTranslationBuilder.test.ts. `tsconfig.test.json` is unenforced
(~1200 pre-existing errors) so nothing flagged it; i18n test-type errors go 36 → 4, and the
remaining four are pre-existing.
Comment thread test/unit/i18n/Streami18nGuarantees.test.ts
oliverlaz added a commit to GetStream/stream-chat-react-native that referenced this pull request Aug 18, 2026
Same reasoning as the React SDK: `Streami18n` and the date layer live in `stream-chat/i18n`, so
asserting their behaviour here duplicated core's suite in a third repo. Everything removed is
covered on that side, and what core lacked was added there first
(GetStream/stream-chat-js#1830).

−430 lines.

`Streami18nGuarantees.test.ts` goes entirely. It was ported *to* core during this initiative and
core's copy is a superset — same G1/G2/G3 structure, plus the missing-key handler case and the
"when the warning fires" timing block.

`Streami18n.test.ts` goes 417 → 54 lines, keeping the two describes that are about *this*
package: the jest timezone config, and the two `runtimeDefaults` guards. Those guards are the
valuable part — they close the set of bundled keys that hide English inside a formatter
expression, where `dayjsLocaleConfigForLanguage` cannot reach it, so adding a third strands
English day words in a translated app and has to fail here first. Dropped: default instance,
registered and custom dictionaries, `registerTranslation`, `setLanguage`, timezone and
formatters.

`languageCodes.test.ts` goes entirely — its two region-code tests (`pt-BR` distinct from `pt`,
bundled defaults still layered under a region code) moved to core, which had no coverage of
hyphenated language names at all. The other three were already asserted there.

**Kept deliberately:** `pluralCategories.test.ts`. It looks like core's territory but is not —
it exercises the `intl-pluralrules` polyfill this SDK imports at `src/index.ts:1`, which core
must never depend on. Under Hermes' partial ICU those Arabic and Russian categories silently
collapse to `other`, so this is a React Native integration test. `catalogRenders.test.ts` and
`publicExports.test.ts` stay for the same reason: both are about this catalog and this export
surface.

`moment-timezone` drops out of devDependencies — the bring-your-own-Moment parser test was the
only thing using it, and that behaviour is core's, now asserted there against a hand-written
Moment-shaped stand-in so core needs no moment dependency either.

Verified against a locally packed core: typecheck clean, lint clean, the 9 remaining i18n suites
green at 441 tests, and the failing-suite set unchanged from V10's pre-existing 38.
Ported from the React Native SDK's `languageCodes.test.ts`, which owned these
before the runtime moved here and was deleted with the rest of that suite.

Nothing else asserts that a hyphenated language name survives i18next's lookup:
`keySeparator: false` and `nsSeparator: false` are what keep `pt-BR` a single
language rather than a namespace probe, a base-language dictionary must not
shadow the region-coded one, and `runtimeDefaults` still has to layer underneath.
Runtime:

- `ensureDayjsPlugins` now extends the module it is given rather than always our own
  `dayjs` import. An integrator supplying `DateTimeParser` may hand over a second
  physical copy, and extending ours left theirs plugin-less -- `.calendar()` absent,
  and `format('LT')` returning the literal "LT".
- Formatters are rebuilt on every language change, not only at `init()`. Factories take
  the language through their context and virtually all of them destructure it, so one
  built at initialization kept formatting in the initial language forever. The context
  also exposes accessors now, covering a formatter that holds it and reads per call.
- `Date.parse` returns 0 for the Unix epoch, so `!Date.parse(value)` classified a valid
  timestamp as junk: the formatter rendered '' and `getDateString` returned null.
- `setLanguage` restores the previous language when `changeLanguage` rejects. It
  published the new one up front, so a failed switch left the store advertising a
  language i18next never adopted while `tDateTimeParser` formatted dates in it.
- `runInit`'s prelude moved inside the `try`, and `init()` clears a rejected
  `initPromise`. Both UI SDKs call `init()` without awaiting it, so a throw there was an
  unhandled rejection latched for the process lifetime.

Codegen:

- A bundled plural is now expressible. `t(key, { count })` resolves as
  `<key>_<category>`, but the guard checked the bare key -- rejecting the correct catalog
  and accepting the form that renders one string for every count with no error. Plural
  categories are also kept out of the emitted `BundledTranslationKey` union, where they
  would offer a call that resolves nothing.

Tests: 19 added, covering each fix. The pre-registration buffer-removal case moves down
from the React SDK's suite, which owned it before this plumbing did.

Also corrects four references to `scripts/bundle.mjs`, renamed to `.mts` earlier on this
branch.
`Streami18n` was carrying a concern it did not need to: the translation dictionaries
and the rule that layers `runtimeDefaults` under every language. That rule is guarantee
G1 -- a partial dictionary must not knock out the bundled formatter keys -- and it was
only reachable through a fully initialized instance, so asserting on it required
i18next, dayjs and an async `init()`.

`TranslationStore` now owns it, depending on neither library. `Streami18n` adapts its
flat dictionaries to i18next's nested `resources` shape, so nothing in the store knows
about namespaces. Ten tests cover the rule directly, including one nothing asserted
before: the store does not mutate the `runtimeDefaults` object it was handed.

Removed from the public surface, both public in v9 in both UI SDKs and used by neither:

- `getTranslations()` returned the internal i18next resource map. It never held the
  SDK's English copy -- prose renders from the inline `defaultValue` at each call site
  -- so it only ever showed the bundled formatter expressions. Render the key instead:
  `i18n.t('some.key')`.
- `getAvailableLanguages()` counted every language with a dictionary, including ones
  created solely to carry the bundled defaults, so a language nobody registered looked
  available. Use `registeredLanguages`.

`registeredLanguages` is now a `ReadonlySet<string>`; reading is unchanged, `.add()` no
longer compiles, because adding to it would claim a language is registered with no
dictionary behind it. Six members become private, none previously documented:
`translations`, `dayjsLocales`, `isCustomDateTimeParser`, `localeExists()`,
`addOrUpdateLocale()`, `validateCurrentLanguage()`.

Public surface 26 -> 18 members, measured from the emitted declarations.

Also thins the class comments from 205 lines to 111. What went: three section banners,
restatements of the code beneath them, narration of past edits, multi-paragraph JSDoc
on privates. What stayed, compressed: the facts whose absence would let a silent bug
back in -- `keySeparator` must stay false, formatters must be rebuilt per language
change because factories destructure, the post-init write must pass the merged
dictionary, Hermes' partial ICU.

BREAKING CHANGE: `Streami18n.getTranslations()` and
`Streami18n.getAvailableLanguages()` are removed, and `Streami18n.registeredLanguages`
is a `ReadonlySet`. See "Removed from the `Streami18n` surface" in
`v9-to-v10-migration-guide-i18n.md`.
Review feedback: `POLL_VALIDATION_CODE` did not say what it validated. It is now
`POLL_COMPOSER_VALIDATION_CODE`, which matches the module it lives in and the
`PollComposer*` prefix its siblings already use (`PollComposerFieldErrors`,
`PollComposerState`, `PollComposerOption`).

The whole family moves, not just the constant -- renaming `PollValidationCode` while
`PollValidationError` sat beside it would read worse than the original:

  POLL_VALIDATION_CODE      -> POLL_COMPOSER_VALIDATION_CODE
  PollValidationCode        -> PollComposerValidationCode
  PollValidationError       -> PollComposerValidationError
  pollValidationError()     -> pollComposerValidationError()
  isPollValidationError()   -> isPollComposerValidationError()

The review also read these as notification codes, which is the more useful signal: the
distinction was too quiet. The doc comment now leads with it -- these are field errors
rendered beside their input and never reach `NotificationManager`, because routing them
there would raise a toast per keystroke, and `CORE_NOTIFICATION_TYPE` is the disjoint
notification counterpart. "notification" is deliberately not in the name for that reason.

Also fixes a copy-paste bug in the migration guide's sample, which used the type without
importing it.

Landing before `10.0.0-rc.3` publishes, while these identifiers are still unreleased.

BREAKING CHANGE: `POLL_VALIDATION_CODE`, `PollValidationCode`, `PollValidationError`,
`pollValidationError` and `isPollValidationError` are renamed to their
`PollComposer*` equivalents. The identifier *values* (`validation:poll:name:required` and
the rest) are unchanged, so a translation table keyed on them needs no edit.
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