From cf04f33c60a86255e909b249853482402fdb7de0 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Tue, 18 Aug 2026 11:14:38 +0200 Subject: [PATCH 1/8] build!: lift the baseline to React Native 0.79 / Expo 53 and declare stream-chat ^10 Two independent problems in the manifests, both of which CI could not see. `stream-chat` was declared `^9.51.0` across the workspaces while the source targets v10, papered over by a root `resolutions` entry pinning `10.0.0-rc.2`. **`resolutions` do not publish.** A consumer installing this SDK today gets `stream-chat@9.x` against v10 source, and every gate here passed because the override applied locally. The declared ranges are now `^10.0.0-rc.3` and the override is gone, so what ships is what is tested. The React Native floor moves 0.76 -> 0.79, and Expo's 52 -> 53 with it. Metro gates subpath `exports` behind `unstable_enablePackageExports`, and the default flips to `true` in exactly metro-config 0.82.0 -- which is what React Native 0.79 pins. Below that, `stream-chat/i18n` does not resolve. That would have broken 0.76-0.78 integrators on imports made *inside* this SDK, since it is their Metro that resolves them, and it would never have surfaced locally: the sample apps run 0.86. `@expo/metro-config` does not override the flag, so Expo inherits the same default. Residual, and documentable only: an integrator on 0.79+ who explicitly sets `unstable_enablePackageExports: false`. Also adds `V10` to the `check-pr` and `sdk-size-metrics` branch filters. Without it no PR stacked onto the v10 integration branch runs build, lint, typecheck or tests, and nothing measures the bundle -- every gate is manual for the whole release. Note that `yarn install --immutable` fails until `stream-chat@10.0.0-rc.3` is published; the lockfile is regenerated then. BREAKING CHANGE: React Native >=0.79 and Expo >=53 are now required, as is `stream-chat@^10`. React Native 0.76-0.78 cannot resolve the `stream-chat/i18n` subpath this SDK imports. --- .github/workflows/check-pr.yml | 3 +++ .github/workflows/sdk-size-metrics.yml | 4 ++++ examples/ExpoMessaging/package.json | 2 +- examples/SampleApp/package.json | 4 ++-- package.json | 1 - package/expo-package/package.json | 4 ++-- package/native-package/package.json | 2 +- package/package.json | 4 ++-- 8 files changed, 15 insertions(+), 9 deletions(-) diff --git a/.github/workflows/check-pr.yml b/.github/workflows/check-pr.yml index 0f561fc31f..04ed34cfed 100644 --- a/.github/workflows/check-pr.yml +++ b/.github/workflows/check-pr.yml @@ -5,6 +5,9 @@ on: branches: - develop - main + # The v10 integration branch. Without it, no PR stacked onto V10 runs build, lint, typecheck or + # tests — every gate is manual for the whole release. + - V10 - 'v[0-9]+.[0-9]+.[0-9]+*beta*' types: [opened, synchronize] diff --git a/.github/workflows/sdk-size-metrics.yml b/.github/workflows/sdk-size-metrics.yml index e201f6d94b..73982a2eda 100644 --- a/.github/workflows/sdk-size-metrics.yml +++ b/.github/workflows/sdk-size-metrics.yml @@ -9,6 +9,10 @@ on: branches: - develop - main + # Needed for the same reason as in check-pr.yml, and specifically for this initiative: moving the + # i18n runtime out of `lib/` changes both numbers in the migration guide's size table, and this + # workflow is what measures them. + - V10 env: HOMEBREW_NO_INSTALL_CLEANUP: 1 # Disable cleanup for homebrew, we don't need it on CI diff --git a/examples/ExpoMessaging/package.json b/examples/ExpoMessaging/package.json index db1c4b4629..a575ff6512 100644 --- a/examples/ExpoMessaging/package.json +++ b/examples/ExpoMessaging/package.json @@ -51,7 +51,7 @@ "react-native-teleport": "^1.1.12", "react-native-web": "^0.21.2", "react-native-worklets": "0.11.1", - "stream-chat": "^9.51.0", + "stream-chat": "^10.0.0-rc.3", "stream-chat-expo": "workspace:^", "stream-chat-react-native-core": "workspace:^" }, diff --git a/examples/SampleApp/package.json b/examples/SampleApp/package.json index 1fcd0fa999..2fe5d56e50 100644 --- a/examples/SampleApp/package.json +++ b/examples/SampleApp/package.json @@ -44,7 +44,7 @@ "@react-navigation/native": "^7.3.11", "@react-navigation/native-stack": "^7.18.3", "@shopify/flash-list": "^2.3.2", - "dayjs": "1.11.13", + "dayjs": "^1.11.13", "emoji-mart": "^5.6.0", "lodash.mergewith": "^4.6.2", "react": "19.2.3", @@ -64,7 +64,7 @@ "react-native-teleport": "^1.1.12", "react-native-video": "^6.19.2", "react-native-worklets": "^0.11.1", - "stream-chat": "^9.51.0", + "stream-chat": "^10.0.0-rc.3", "stream-chat-react-native": "workspace:^", "stream-chat-react-native-core": "workspace:^" }, diff --git a/package.json b/package.json index 61e998a893..101c1df6cd 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,6 @@ "examples/ExpoMessaging" ], "resolutions": { - "stream-chat": "10.0.0-rc.2", "@types/react": "^19.2.0" }, "engines": { diff --git a/package/expo-package/package.json b/package/expo-package/package.json index c12569fec7..aadd417d20 100644 --- a/package/expo-package/package.json +++ b/package/expo-package/package.json @@ -30,7 +30,7 @@ "stream-chat-react-native-core": "workspace:^" }, "peerDependencies": { - "expo": ">=52.0.0", + "expo": ">=53.0.0", "expo-audio": "*", "expo-av": "*", "expo-clipboard": "*", @@ -42,7 +42,7 @@ "expo-media-library": "*", "expo-sharing": "*", "expo-video": "*", - "react-native": ">=0.76.0" + "react-native": ">=0.79.0" }, "peerDependenciesMeta": { "expo-audio": { diff --git a/package/native-package/package.json b/package/native-package/package.json index 6d5e92b6f0..e46e38b73c 100644 --- a/package/native-package/package.json +++ b/package/native-package/package.json @@ -36,7 +36,7 @@ "@react-native-camera-roll/camera-roll": ">=7.9.0", "@react-native-clipboard/clipboard": ">=1.16.0", "@react-native-documents/picker": ">=10.1.1", - "react-native": ">=0.76.0", + "react-native": ">=0.79.0", "react-native-audio-recorder-player": ">=3.6.13", "react-native-blob-util": ">=0.22.0", "react-native-haptic-feedback": ">=2.3.0", diff --git a/package/package.json b/package/package.json index f59d1ce6d0..64ccac7755 100644 --- a/package/package.json +++ b/package/package.json @@ -79,7 +79,7 @@ "path": "0.12.7", "react-native-markdown-package": "1.8.2", "react-native-url-polyfill": "^2.0.0", - "stream-chat": "^9.51.0", + "stream-chat": "^10.0.0-rc.3", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { @@ -88,7 +88,7 @@ "@react-native-community/netinfo": ">=11.3.1", "@shopify/flash-list": ">=2.1.0", "emoji-mart": ">=5.4.0", - "react-native": ">=0.76.0", + "react-native": ">=0.79.0", "react-native-gesture-handler": ">=2.18.0", "react-native-reanimated": ">=3.16.0", "react-native-safe-area-context": ">=5.4.1", From dc2cf93602b73961630ca9d7d0780d57b5a651bf Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Tue, 18 Aug 2026 11:15:20 +0200 Subject: [PATCH 2/8] refactor(i18n)!: adopt the shared i18n layer from stream-chat/i18n `Streami18n`, the formatters and the whole date layer now come from `stream-chat/i18n`, shared with the React SDK. What stays here is this package's own catalog -- generated from its `t()` call sites, which is exactly what core cannot import. Both UI SDKs had converged on the same architecture independently and carried ~1,300 lines of near-duplicate runtime that had drifted apart. Each reverse-mapped `stream-chat`'s English notification prose against its own hand-maintained table of sentences, and those tables had rotted in both directions: entries for identifiers nothing emits, and core identifiers neither mapped, which fell through to rendering untranslated English. A fix landed in one SDK and not the other. Net -1,526 lines here. Deleted: `utils/i18n/{getDateString,predefinedFormatters,calendarFormats,types}.ts`, `i18n/externalStrings.ts`, `contexts/translationContext/isDayOrMoment.ts`, and `scripts/{i18n-call-sites.mts,tests/i18n-tooling-checks.mts}` (797 lines of codegen become a 43-line config shim over the generator core now owns, fixture tests included). `utils/i18n/Streami18n.ts` is a ~75-line subclass injecting `runtimeDefaults`. Reactivity is core's `StateStore`. `useStreami18n` is one `useStateStore` subscription in place of two listener registrations, and it identifies an instance by a branded static rather than `instanceof`: under `nmHoistingLimits: workspaces` this repo carries several physical `stream-chat` copies and an integrator's app can resolve another, in which case `instanceof` fails and the instance they configured is *silently discarded* for a fresh English default -- every registered dictionary, formatter and language with it, no error anywhere. It now warns. Notification copy is a `Record`, so a new core identifier is a compile error until mapped. That surfaced two live bugs. `translateCommandDisabled` branched on `reason === 'quoted_message'`, but core emits `'replying'`, so the reply case never matched and rendered core's English. And three core identifiers were unmapped entirely -- `validation:command:not-ready`, `api:message:jump:failed`, `api:message:jumpToLatest:failed` -- all rendering untranslated English. Three dead rows go (`api:reply:search:failed`, `api:location:share:failed`, `browser:location:get:failed`); none is emitted here. Poll field errors are keyed on `POLL_VALIDATION_CODE` the same way, instead of matching the English sentence the composer returned. `dayjs` moves from an exact `1.11.13` to `^1.11.13`, matching core's range, and `i18next` is dropped. The pin resolved to a *second* physical dayjs alongside core's, which broke `instanceof Dayjs` and would have broken an integrator's `import 'dayjs/locale/de'` -- the side effect landing on a copy the SDK never formats with, dates staying English with nothing thrown. `examples/SampleApp` had the same pin and is corrected too. Also removes the `moment-timezone` type leak: `TDateTimeParserOutput` referenced `Moment` from a devDependency, so the type reached the published `.d.ts` and failed to resolve for anyone who had not separately installed it. Core's structural `DateTimeLike` covers both. `TranslationContext`'s default parser is core's `defaultDateTimeParser`, which registers the dayjs plugins on first use. The bare `Dayjs(input)` it used before had no `.calendar`, so a component rendered outside the provider formatted relative dates malformed rather than throwing. A `publicExports` test now pins the i18n export surface. This change silently dropped `CalendarFormats` and `DayjsLocaleConfig` -- documented in the migration guide's type table, invisible to the build and the type gate because nothing internal imports them. BREAKING CHANGE: `getDateString` takes `messageCreatedAt` rather than `date`, and returns `null` rather than `undefined` when there is nothing renderable. `getDateStringForA11y` is renamed `getCalendarDateStringForA11y`; the old name now refers to the web SDK's `LLLL` variant. `Streami18n` is renamed `StreamI18n`, with the old spelling kept as a deprecated alias for one cycle. `setLanguage()` returns `void` -- read `i18n.t` or subscribe to `i18n.state`; `addOnLanguageChangeListener` is removed. The second positional i18next-config argument is replaced by `i18nextConfigOverrides` in the options object. `getTranslators()` is a deprecated alias for `init()`. `translateExternalString` and `EXTERNAL_STRING_KEYS` are removed. Three catalog keys are removed -- `notifications.locationRetrieveFailed.error`, `notifications.threadNotFound.error`, `poll.createPoll.maxVotes.range.error` -- none of which any component rendered. See `ai-docs/i18n-v10-migration.md`. --- AGENTS.md | 31 +- ai-docs/accessibility.md | 29 +- ai-docs/ai-migration-v9-to-v10.md | 46 ++ ai-docs/i18n-v10-migration.md | 152 +++- examples/SampleApp/src/i18n/README.md | 24 +- examples/SampleApp/src/i18n/de.ts | 7 +- examples/SampleApp/src/i18n/it.ts | 7 +- package/package.json | 3 +- package/scripts/generate-i18n-keys.mts | 329 +------- package/scripts/i18n-call-sites.mts | 106 --- package/scripts/tests/i18n-tooling-checks.mts | 381 ---------- .../hooks/useFileAttachmentListSections.ts | 4 +- .../hooks/useUserActivityStatus.ts | 4 +- .../ChannelPreview/ChannelPreviewStatus.tsx | 8 +- .../components/Chat/__tests__/Chat.test.tsx | 23 +- .../components/ImageGalleryHeader.tsx | 4 +- .../MessageItemView/MessageTimestamp.tsx | 4 +- .../MessageList/InlineDateSeparator.tsx | 6 +- .../components/MessageList/MessageSystem.tsx | 4 +- .../components/MessageList/StickyHeader.tsx | 6 +- .../__tests__/ScrollToBottomButton.test.tsx | 8 +- .../Notifications/notificationTranslations.ts | 107 ++- .../Poll/components/CreatePollOptions.tsx | 25 +- .../Poll/components/PollAnswersList.tsx | 4 +- .../Poll/components/PollResults/PollVote.tsx | 4 +- .../components/ThreadList/ThreadListItem.tsx | 11 +- .../translationContext/TranslationContext.tsx | 6 +- .../src/contexts/translationContext/index.ts | 1 - .../translationContext/isDayOrMoment.ts | 8 - .../src/contexts/translationContext/types.ts | 24 +- package/src/hooks/useStreami18n.ts | 97 +-- .../src/i18n/__tests__/catalog.fixture.json | 7 +- .../i18n/__tests__/externalStrings.test.ts | 59 -- .../src/i18n/__tests__/languageCodes.test.ts | 17 +- .../src/i18n/__tests__/publicExports.test.ts | 81 ++ package/src/i18n/externalStrings.ts | 42 -- package/src/i18n/keys.ts | 16 +- package/src/i18n/runtimeDefaults.ts | 17 +- package/src/i18n/types.ts | 218 ++---- package/src/i18n/utils.ts | 36 +- .../__tests__/Streami18nGuarantees.test.ts | 5 +- package/src/utils/i18n/Streami18n.ts | 705 ++---------------- package/src/utils/i18n/calendarFormats.ts | 20 - package/src/utils/i18n/getDateString.ts | 138 ---- .../src/utils/i18n/predefinedFormatters.ts | 98 --- package/src/utils/i18n/types.ts | 82 -- 46 files changed, 741 insertions(+), 2273 deletions(-) delete mode 100644 package/scripts/i18n-call-sites.mts delete mode 100644 package/scripts/tests/i18n-tooling-checks.mts delete mode 100644 package/src/contexts/translationContext/isDayOrMoment.ts delete mode 100644 package/src/i18n/__tests__/externalStrings.test.ts create mode 100644 package/src/i18n/__tests__/publicExports.test.ts delete mode 100644 package/src/i18n/externalStrings.ts delete mode 100644 package/src/utils/i18n/calendarFormats.ts delete mode 100644 package/src/utils/i18n/getDateString.ts delete mode 100644 package/src/utils/i18n/predefinedFormatters.ts delete mode 100644 package/src/utils/i18n/types.ts diff --git a/AGENTS.md b/AGENTS.md index e86f1bff35..e359c86ffa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,7 +56,7 @@ Respect repo-specific rules. Do not suppress lint rules broadly; justify and sco - `state-store/` — client-side stores on `useSyncExternalStore` with a selector pattern (audio player, video player, image gallery, message overlay, attachment picker, …) - `store/` — offline SQLite persistence: `OfflineDB.ts`, `SqliteClient.ts`, `schema.ts`, `mappers/`, `apis/` - `theme/` — theming system + `topologicalResolution.ts` + `generated/` tokens -- `i18n/` — the translation key layer: generated `keys.ts` catalog, `types.ts`, `runtimeDefaults.ts`, `externalStrings.ts`. No locale JSON — the SDK ships English only (the `Streami18n` wrapper class lives in `utils/i18n/`) +- `i18n/` — the translation key layer: generated `keys.ts` catalog, `types.ts`, `runtimeDefaults.ts`, `utils.ts`. No locale JSON — the SDK ships English only. The runtime lives in `stream-chat/i18n`, shared with the React SDK; `utils/i18n/Streami18n.ts` is a thin subclass injecting this package's bundled data - `a11y/` — accessibility primitives (`a11yUtils.ts`, `hooks/`) - `middlewares/` — command UI middlewares (`attachments.ts`, `emojiControl.ts`) - `icons/` — SVG icon components @@ -278,16 +278,35 @@ Integrators add languages additively — there is nothing in the SDK to fork or `package/src/i18n/runtimeDefaults.ts` — the only translation data that ships. `runtimeDefaults` holds just the keys with no inline copy to fall back on: `timestamp.*` / `duration.*` formatter expressions passed around as prop values, and keys built from a runtime value. -- `Streami18n` (`package/src/utils/i18n/Streami18n.ts`) wraps i18next; access `t` via +- **The runtime is `stream-chat/i18n`**, shared with the React SDK — one `StreamI18n`, one set of + formatters, one date layer. `package/src/utils/i18n/Streami18n.ts` is a ~30-line subclass that + injects this package's `runtimeDefaults` (core cannot import them: the catalog is generated from + *this* package's call sites). A behavioural fix belongs in `stream-chat`, not here. Access `t` via `useTranslationContext()`. `registerTranslation` **merges**, so a partial dictionary can never knock out the bundled formatter keys. +- **Reactivity is a `StateStore`,** not listeners. `i18n.state` publishes + `{ t, tDateTimeParser, language, initialized }`; `useStreami18n` subscribes with a module-scope + selector. `setLanguage()` returns `void` — the new `t` arrives through the store. There is no + `addOnLanguageChangeListener`. +- `language.*` (ISO language names) and `relativeTime.*` are typed into the catalog but come from + core, which owns the code that renders them. Neither is declared in `runtimeDefaults`. +- Notification copy is keyed on `stream-chat`'s `CORE_NOTIFICATION_TYPE`, in + `components/Notifications/notificationTranslations.ts`, as a `Record` — so + a new core identifier is a compile error until it is mapped. Never match on `notification.message`; + that is untranslated English whose wording is not part of core's contract. Poll field errors are + keyed the same way, on `POLL_VALIDATION_CODE`. +- **`dayjs` must resolve to exactly one copy.** Its range here has to stay compatible with core's + (`^1.11.13`) — an exact pin installs a second copy, `instanceof Dayjs` starts failing, and an + integrator's `import 'dayjs/locale/de'` lands on an instance the SDK never formats with. Do not + declare `i18next` at all; it arrives through `stream-chat`. - Only the `en` dayjs locale is bundled, and **no dayjs locale defines `calendar`** (that field belongs to the calendar plugin) — a new language needs both `import 'dayjs/locale/xx'` and a `calendar` config, or relative dates render English scaffolding around translated day names. -- Generation: `build-translations` runs `package/scripts/generate-i18n-keys.mts`, which enforces - five hard-fail guards (conflicting inline copy, unresolvable key, shadowed key, external-string - drift, strict prefix). Its fixture tests are **not** jest — run - `cd package && node --test scripts/tests/i18n-tooling-checks.mts`. +- Generation: `build-translations` runs `package/scripts/generate-i18n-keys.mts`, now a ~40-line + config shim over the generator in `stream-chat/i18n/codegen` (also shared with the React SDK). Four + hard-fail guards: conflicting inline copy, unresolvable key, shadowed key, strict prefix. The + external-string drift guard is gone with `externalStrings.ts`. The fixture tests live in + `stream-chat`'s own suite — there is no longer a `node --test` step here. - Validation: `validate-translations` runs inside `yarn lint` and in CI. It is a **drift gate** — it regenerates `keys.ts` and fails if the result differs from what is committed. - Adding a string: call `t('some.dotted.key', 'English copy')` → run `build-translations` → commit diff --git a/ai-docs/accessibility.md b/ai-docs/accessibility.md index 3a460f397a..d04a54b747 100644 --- a/ai-docs/accessibility.md +++ b/ai-docs/accessibility.md @@ -23,7 +23,7 @@ When `enabled` is false: - `useIncomingMessageAnnouncements` does not subscribe to `channel.on('message.new')`. - No `AccessibilityInfo` event listeners attach. - Components still render their `accessibilityRole` / `accessibilityState` / etc. attributes (these are passed to native views and only consulted by VO/TalkBack when active — sighted users incur ~zero cost). -- `useA11yLabel(key, params)` returns `undefined` so `t('a11y/...')` is **not** called on hot list paths. +- `useA11yLabel(key, params)` returns `undefined` so `t()` is **not** called on hot list paths. ## Configuration shape @@ -50,13 +50,14 @@ For RN-specific gesture-alternative toggles, the enum semantics are: ## Localization -All a11y strings flow through the existing `Streami18n` translation pipeline under the `a11y/*` namespace. Defaults ship in English in every locale; integrators can override per-key via the same mechanism they use for other strings: +All a11y strings flow through the `StreamI18n` translation pipeline, the same one every other string uses. +Integrators override per key: ```ts -const i18n = new Streami18n('nl'); +const i18n = new StreamI18n({ language: 'nl' }); i18n.registerTranslation('nl', { - 'a11y/Avatar of {{name}}': 'Avatar van {{name}}', - 'a11y/{{count}} new messages': '{{count}} nieuwe berichten', + 'avatar.accessibilityLabel': 'Avatar van {{name}}', + 'messageList.scrollToBottom.withCount.accessibilityLabel': '{{count}} nieuwe berichten', }); @@ -65,12 +66,24 @@ i18n.registerTranslation('nl', { ``` -`validate-translations` (run as part of `yarn lint`) enforces non-empty values for every `a11y/*` key in every locale. +Three things changed in v10, and the old form above fails quietly rather than erroring: + +- **The `a11y/*` namespace is gone.** Accessible names are the `.accessibilityLabel` leaf of the owning + component's key (`avatar.accessibilityLabel`), so a11y copy sits beside the visible copy it describes. See + `ai-docs/i18n-v10-migration.md` for the full old→new table. +- **The constructor takes an options object**, not a positional language string. +- **English is the only bundled language**, so "in every locale" no longer applies — a key you do not supply + renders its English copy from the inline `defaultValue` at the call site, never a raw dotted path. + +Most accessible names reach `t()` as a prop or a lookup value rather than a literal, so they have no inline +default and live in `package/src/i18n/runtimeDefaults.ts` instead. `validate-translations` (part of `yarn lint`) +is a drift gate on the generated catalog: it regenerates `src/i18n/keys.ts` and fails on any difference, which +is what catches a key that was renamed at the call site but not in `runtimeDefaults`. SDK-owned `Button` components can translate their own accessible names from an i18n key: ```tsx -