feat(i18n)!: share the translation runtime as stream-chat/i18n - #1830
Open
oliverlaz wants to merge 25 commits into
Open
feat(i18n)!: share the translation runtime as stream-chat/i18n#1830oliverlaz wants to merge 25 commits into
oliverlaz wants to merge 25 commits into
Conversation
…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.
oliverlaz
requested review from
MartinCupela,
isekovanic,
santhoshvai,
szuperaz and
vishalnarkhede
as code owners
August 18, 2026 12:02
`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 = { |
Contributor
There was a problem hiding this comment.
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.
…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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Moves the translation runtime shared by
stream-chat-reactandstream-chat-react-nativeinto this package as a newstream-chat/i18nsubpath, plusstream-chat/i18n/codegenfor 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:
CORE_NOTIFICATION_TYPE/CoreNotificationType.Notification.messageis now documented as a developer-facing fallback whose wording is not contractual. Two identifiers renamed (api:message{s}:query:failed→messageJumpFailed/messageJumpToLatestFailed).{ code, message, metadata? }keyed onPOLL_VALIDATION_CODE, instead of plain English strings.engines.node→>=22.18.0(the release that unflagged type stripping, which the.mtsbuild scripts need). Node 18/20 are no longer tested — see the guide for what that means if you deploy the WS client there.i18nextanddayjsbecome direct dependencies. They stay out of the root bundle; the subpath is what isolates them, andassertBundleBoundariesfails the build if that ever regresses.Also here: the generator moves out of
src/tocodegen/i18n/(it reads the filesystem — not library source), ships ESM-only, andscripts/bundle.mjsbecomes.mtswith a real typecheck gate.Docs:
v9-to-v10-migration-guide-i18n.md(new), plus a Node-floor section in-other.md. Initiative record inspecs/i18n-to-core/.Verified: lint, all three
tscprojects, 2,804 tests, and a clean-install check of the packed tarball across all four export conditions. The root bundle reaches neithersrc/i18n/,i18nextnordayjs— 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