From b5833a11ce80f3344a76c6deec41033b0c63c45b Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Mon, 17 Aug 2026 22:54:44 +0200 Subject: [PATCH 01/27] feat(notifications)!: key notifications and poll errors on scoped identifiers 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..message` for the previous value, or switch on `errors..code` to localize. --- src/messageComposer/attachmentManager.ts | 9 +- src/messageComposer/messageComposer.ts | 5 +- .../postUpload/uploadErrorHandler.ts | 3 +- .../preUpload/blockedUploadNotification.ts | 3 +- .../middleware/messageComposer/attachments.ts | 3 +- .../middleware/pollComposer/index.ts | 1 + .../middleware/pollComposer/state.ts | 39 +++-- .../middleware/pollComposer/types.ts | 11 +- .../middleware/pollComposer/validation.ts | 72 ++++++++++ .../middleware/textComposer/commandUtils.ts | 5 +- src/notifications/types.ts | 96 +++++++------ .../paginators/MessageIntervalPaginator.ts | 5 +- src/poll.ts | 3 +- .../middleware/pollComposer/state.test.ts | 22 +-- .../notifications/notificationTypes.test.ts | 136 ++++++++++++++++++ 15 files changed, 335 insertions(+), 78 deletions(-) create mode 100644 src/messageComposer/middleware/pollComposer/validation.ts create mode 100644 test/unit/notifications/notificationTypes.test.ts diff --git a/src/messageComposer/attachmentManager.ts b/src/messageComposer/attachmentManager.ts index 42c07dc835..0469837a2f 100644 --- a/src/messageComposer/attachmentManager.ts +++ b/src/messageComposer/attachmentManager.ts @@ -20,6 +20,7 @@ import { AttachmentPreUploadMiddlewareExecutor, } from './middleware/attachmentManager'; import { StateStore } from '../store'; +import { CORE_NOTIFICATION_TYPE } from '../notifications'; import { generateUUIDv4 } from '../utils'; import { DEFAULT_UPLOAD_SIZE_LIMIT_BYTES } from '../constants'; import type { @@ -492,7 +493,7 @@ export class AttachmentManager { this.client.notifications.addError({ message: 'File is required for upload attachment', origin: { emitter: 'AttachmentManager', context: { attachment } }, - options: { type: 'validation:attachment:file:missing' }, + options: { type: CORE_NOTIFICATION_TYPE.attachmentFileMissing }, }); return; } @@ -501,7 +502,7 @@ export class AttachmentManager { this.client.notifications.addError({ message: 'Local upload attachment missing local id', origin: { emitter: 'AttachmentManager', context: { attachment } }, - options: { type: 'validation:attachment:id:missing' }, + options: { type: CORE_NOTIFICATION_TYPE.attachmentIdMissing }, }); return; } @@ -604,7 +605,7 @@ export class AttachmentManager { context: { attachment, blockedAttachment: localAttachment }, }, options: { - type: 'validation:attachment:upload:blocked', + type: CORE_NOTIFICATION_TYPE.attachmentUploadBlocked, metadata: { reason: localAttachment.localMetadata.uploadPermissionCheck?.reason, }, @@ -634,7 +635,7 @@ export class AttachmentManager { context: { attachment, failedAttachment }, }, options: { - type: 'api:attachment:upload:failed', + type: CORE_NOTIFICATION_TYPE.attachmentUploadFailed, metadata: { reason }, originalError: error instanceof Error ? error : undefined, }, diff --git a/src/messageComposer/messageComposer.ts b/src/messageComposer/messageComposer.ts index dc16bd16a1..e040ff2274 100644 --- a/src/messageComposer/messageComposer.ts +++ b/src/messageComposer/messageComposer.ts @@ -1,3 +1,4 @@ +import { CORE_NOTIFICATION_TYPE } from '../notifications'; import { AttachmentManager } from './attachmentManager'; import { CustomDataManager } from './CustomDataManager'; import { LinkPreviewsManager } from './linkPreviewsManager'; @@ -1008,7 +1009,7 @@ export class MessageComposer extends WithSubscriptions { context: { composer: this }, }, options: { - type: 'api:poll:create:failed', + type: CORE_NOTIFICATION_TYPE.pollCreateFailed, metadata: { reason: (error as Error).message, }, @@ -1034,7 +1035,7 @@ export class MessageComposer extends WithSubscriptions { context: { composer: this }, }, options: { - type: 'api:location:create:failed', + type: CORE_NOTIFICATION_TYPE.locationCreateFailed, metadata: { reason: (error as Error).message, }, diff --git a/src/messageComposer/middleware/attachmentManager/postUpload/uploadErrorHandler.ts b/src/messageComposer/middleware/attachmentManager/postUpload/uploadErrorHandler.ts index d33d7354a0..6cd1274a91 100644 --- a/src/messageComposer/middleware/attachmentManager/postUpload/uploadErrorHandler.ts +++ b/src/messageComposer/middleware/attachmentManager/postUpload/uploadErrorHandler.ts @@ -1,4 +1,5 @@ import type { MiddlewareHandlerParams } from '../../../../middleware'; +import { CORE_NOTIFICATION_TYPE } from '../../../../notifications'; import type { MessageComposer } from '../../../messageComposer'; import type { AttachmentPostUploadMiddleware, @@ -27,7 +28,7 @@ export const createUploadErrorHandlerMiddleware = ( context: { attachment }, }, options: { - type: 'api:attachment:upload:failed', + type: CORE_NOTIFICATION_TYPE.attachmentUploadFailed, metadata: { reason }, originalError: error, }, diff --git a/src/messageComposer/middleware/attachmentManager/preUpload/blockedUploadNotification.ts b/src/messageComposer/middleware/attachmentManager/preUpload/blockedUploadNotification.ts index ff676a0c09..844fb6660a 100644 --- a/src/messageComposer/middleware/attachmentManager/preUpload/blockedUploadNotification.ts +++ b/src/messageComposer/middleware/attachmentManager/preUpload/blockedUploadNotification.ts @@ -1,4 +1,5 @@ import type { MiddlewareHandlerParams } from '../../../../middleware'; +import { CORE_NOTIFICATION_TYPE } from '../../../../notifications'; import type { MessageComposer } from '../../../messageComposer'; import type { AttachmentPreUploadMiddleware, @@ -24,7 +25,7 @@ export const createBlockedAttachmentUploadNotificationMiddleware = ( context: { blockedAttachment: attachment }, }, options: { - type: 'validation:attachment:upload:blocked', + type: CORE_NOTIFICATION_TYPE.attachmentUploadBlocked, metadata: { reason: attachment.localMetadata.uploadPermissionCheck?.reason, }, diff --git a/src/messageComposer/middleware/messageComposer/attachments.ts b/src/messageComposer/middleware/messageComposer/attachments.ts index 3793b66625..46b5eabc1d 100644 --- a/src/messageComposer/middleware/messageComposer/attachments.ts +++ b/src/messageComposer/middleware/messageComposer/attachments.ts @@ -1,4 +1,5 @@ import type { MiddlewareHandlerParams } from '../../../middleware'; +import { CORE_NOTIFICATION_TYPE } from '../../../notifications'; import type { Attachment } from '../../../types'; import type { MessageComposer } from '../../messageComposer'; import type { LocalAttachment } from '../../types'; @@ -36,7 +37,7 @@ export const createAttachmentsCompositionMiddleware = ( context: { composer }, }, options: { - type: 'validation:attachment:upload:in-progress', + type: CORE_NOTIFICATION_TYPE.attachmentUploadInProgress, }, }); return discard(); diff --git a/src/messageComposer/middleware/pollComposer/index.ts b/src/messageComposer/middleware/pollComposer/index.ts index 6e49a2d115..c7707ec78c 100644 --- a/src/messageComposer/middleware/pollComposer/index.ts +++ b/src/messageComposer/middleware/pollComposer/index.ts @@ -1,3 +1,4 @@ export * from './PollComposerMiddlewareExecutor'; export * from './state'; export * from './types'; +export * from './validation'; diff --git a/src/messageComposer/middleware/pollComposer/state.ts b/src/messageComposer/middleware/pollComposer/state.ts index 2cce5ce8c2..efe863eba8 100644 --- a/src/messageComposer/middleware/pollComposer/state.ts +++ b/src/messageComposer/middleware/pollComposer/state.ts @@ -6,6 +6,12 @@ import type { PollComposerStateChangeMiddlewareValue, TargetedPollOptionTextUpdate, } from './types'; +import type { PollValidationError } from './validation'; +import { + isPollValidationError, + POLL_VALIDATION_CODE, + pollValidationError, +} from './validation'; export const VALID_MAX_VOTES_VALUE_REGEX = /^([2-9]|10)$/; @@ -14,8 +20,8 @@ export const MAX_POLL_OPTIONS = 100 as const; const textFieldIsEmpty = (text: string) => !text.trim(); export type PollStateValidationOutput = Partial< - Omit, 'options'> & { - options?: Record; + Omit, 'options'> & { + options?: Record; } >; @@ -31,22 +37,30 @@ export const pollStateChangeValidators: Partial< enforce_unique_vote: () => ({ max_votes_allowed: undefined }), max_votes_allowed: ({ data, value }) => { if (data.enforce_unique_vote && value) - return { max_votes_allowed: 'Enforce unique vote is enabled' }; + return { + max_votes_allowed: pollValidationError( + POLL_VALIDATION_CODE.maxVotesUniqueVoteEnforced, + ), + }; const numericMatch = value.match(/^[0-9]+$/); if (!numericMatch && value) { - return { max_votes_allowed: 'Only numbers are allowed' }; + return { + max_votes_allowed: pollValidationError(POLL_VALIDATION_CODE.maxVotesNotNumeric), + }; } if (value?.length > 1 && !value.match(VALID_MAX_VOTES_VALUE_REGEX)) - return { max_votes_allowed: 'Type a number from 2 to 10' }; + return { + max_votes_allowed: pollValidationError(POLL_VALIDATION_CODE.maxVotesOutOfRange), + }; return { max_votes_allowed: undefined }; }, options: ({ value: options }) => { - const errors: Record = {}; + const errors: Record = {}; const seenOptions = new Set(); options.forEach((option: { id: string; text: string }) => { if (seenOptions.has(option.text) && option.text.length) { - errors[option.id] = 'Option already exists'; + errors[option.id] = pollValidationError(POLL_VALIDATION_CODE.optionDuplicate); } else { seenOptions.add(option.text); } @@ -62,7 +76,7 @@ export const defaultPollFieldChangeEventValidators: Partial< name: ({ currentError, value }) => value && currentError ? { name: undefined } - : { name: typeof currentError === 'string' ? currentError : undefined }, + : { name: isPollValidationError(currentError) ? currentError : undefined }, }; export const defaultPollFieldBlurEventValidators: Partial< @@ -70,11 +84,14 @@ export const defaultPollFieldBlurEventValidators: Partial< > = { max_votes_allowed: ({ value }) => { if (value && !value.match(VALID_MAX_VOTES_VALUE_REGEX)) - return { max_votes_allowed: 'Type a number from 2 to 10' }; + return { + max_votes_allowed: pollValidationError(POLL_VALIDATION_CODE.maxVotesOutOfRange), + }; return { max_votes_allowed: undefined }; }, name: ({ value }) => { - if (textFieldIsEmpty(value)) return { name: 'Question is required' }; + if (textFieldIsEmpty(value)) + return { name: pollValidationError(POLL_VALIDATION_CODE.nameRequired) }; return { name: undefined }; }, options: (params) => { @@ -83,7 +100,7 @@ export const defaultPollFieldBlurEventValidators: Partial< params.value.forEach((option: { id: string; text: string }, index: number) => { const isTheLastOption = index === params.value.length - 1; if (textFieldIsEmpty(option.text) && !isTheLastOption) { - errors[option.id] = 'Option is empty'; + errors[option.id] = pollValidationError(POLL_VALIDATION_CODE.optionEmpty); } }); return Object.keys(errors).length > 0 ? { options: errors } : { options: undefined }; diff --git a/src/messageComposer/middleware/pollComposer/types.ts b/src/messageComposer/middleware/pollComposer/types.ts index 9dcca6b030..3e9274ca7e 100644 --- a/src/messageComposer/middleware/pollComposer/types.ts +++ b/src/messageComposer/middleware/pollComposer/types.ts @@ -1,5 +1,6 @@ import type { MiddlewareExecutionResult } from '../../../middleware'; import type { CreatePollRequest, VotingVisibility } from '../../../types'; +import type { PollValidationError } from './validation'; export type PollComposerOption = { id: string; @@ -19,9 +20,15 @@ export type UpdateFieldsData = Partial, 'options'> & { - options?: Record; + Omit, 'options'> & { + options?: Record; } >; diff --git a/src/messageComposer/middleware/pollComposer/validation.ts b/src/messageComposer/middleware/pollComposer/validation.ts new file mode 100644 index 0000000000..61d50cc432 --- /dev/null +++ b/src/messageComposer/middleware/pollComposer/validation.ts @@ -0,0 +1,72 @@ +/** + * Stable identifiers for poll-composer field validation failures. + * + * Same `domain:entity:operation:result` convention as `CORE_NOTIFICATION_TYPE`, but these are *field* + * errors rendered inline next to an input, so they deliberately do not go through + * `NotificationManager` — that would surface a toast per keystroke. + * + * **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 = { + maxVotesNotNumeric: 'validation:poll:maxVotes:notNumeric', + maxVotesOutOfRange: 'validation:poll:maxVotes:outOfRange', + maxVotesUniqueVoteEnforced: 'validation:poll:maxVotes:uniqueVoteEnforced', + nameRequired: 'validation:poll:name:required', + optionDuplicate: 'validation:poll:option:duplicate', + optionEmpty: 'validation:poll:option:empty', +} as const; + +export type PollValidationCode = + (typeof POLL_VALIDATION_CODE)[keyof typeof POLL_VALIDATION_CODE]; + +/** + * Untranslated English for each code. + * + * Kept here rather than at the call sites so one code cannot end up with two different wordings, and + * so the whole set is reviewable in one place. This is a developer-facing fallback — the wording is + * not part of the public contract and may change in a minor release. + */ +const POLL_VALIDATION_MESSAGE: Record = { + [POLL_VALIDATION_CODE.maxVotesNotNumeric]: 'Only numbers are allowed', + [POLL_VALIDATION_CODE.maxVotesOutOfRange]: 'Type a number from 2 to 10', + [POLL_VALIDATION_CODE.maxVotesUniqueVoteEnforced]: 'Enforce unique vote is enabled', + [POLL_VALIDATION_CODE.nameRequired]: 'Question is required', + [POLL_VALIDATION_CODE.optionDuplicate]: 'Option already exists', + [POLL_VALIDATION_CODE.optionEmpty]: 'Option is empty', +}; + +/** + * A poll-composer field validation failure. + * + * `code` is the stable identifier to resolve localized copy from. `message` carries untranslated + * English alongside it so a consumer with no i18n layer still renders something, and so an + * identifier a consumer does not recognize degrades to readable text instead of a blank field. + */ +export type PollValidationError = { + /** Stable identifier. See {@link POLL_VALIDATION_CODE}. */ + code: PollValidationCode; + /** Untranslated English fallback. Not part of the public contract. */ + message: string; + /** Extra context for interpolation, e.g. the offending value. */ + metadata?: Record; +}; + +/** Builds a {@link PollValidationError}, filling in the English fallback for `code`. */ +export const pollValidationError = ( + code: PollValidationCode, + metadata?: Record, +): PollValidationError => ({ + code, + message: POLL_VALIDATION_MESSAGE[code], + ...(metadata ? { metadata } : {}), +}); + +/** + * Narrows a field's error to a single failure. + * + * `options` errors are keyed by option id, so a field error is either one `PollValidationError` or a + * record of them; this distinguishes the two. + */ +export const isPollValidationError = (value: unknown): value is PollValidationError => + typeof value === 'object' && value !== null && 'code' in value && 'message' in value; diff --git a/src/messageComposer/middleware/textComposer/commandUtils.ts b/src/messageComposer/middleware/textComposer/commandUtils.ts index a46e7cf2b7..fe40bdfa42 100644 --- a/src/messageComposer/middleware/textComposer/commandUtils.ts +++ b/src/messageComposer/middleware/textComposer/commandUtils.ts @@ -1,4 +1,5 @@ import type { MessageComposer } from '../../messageComposer'; +import { CORE_NOTIFICATION_TYPE } from '../../../notifications'; import type { Command, UserResponse } from '../../../types'; import type { CommandSendability } from '../../configuration'; import type { CommandSearchSource } from './commands'; @@ -72,7 +73,7 @@ export const notifyCommandDisabled = (composer: MessageComposer, command: Comman context: { command, composer }, }, options: { - type: 'validation:command:disabled', + type: CORE_NOTIFICATION_TYPE.commandDisabled, metadata: { command: command.name, reason: disabledReason, @@ -99,7 +100,7 @@ export const notifyCommandNotReady = ({ context: { command: sendability.command, composer }, }, options: { - type: 'validation:command:not-ready', + type: CORE_NOTIFICATION_TYPE.commandNotReady, metadata: { command: sendability.command.name, ...(sendability.reason ? { reason: sendability.reason } : {}), diff --git a/src/notifications/types.ts b/src/notifications/types.ts index f764f69ead..87adf348a6 100644 --- a/src/notifications/types.ts +++ b/src/notifications/types.ts @@ -18,11 +18,58 @@ export type NotificationAction = { export type NotificationOrigin = { emitter: string; context?: Record }; +/** + * Every notification type emitted by `stream-chat` itself. + * + * Format is `domain:entity:operation:result`: + * - `domain` — where it happened: `api`, `validation`, `permission`, `network`, `auth`, `system` + * - `entity` — what was operated on: `attachment`, `poll`, `message`, `command`, `location` + * - `operation` — what was attempted, lowerCamelCase: `upload`, `create`, `castVote`, `jumpToLatest` + * - `result` — what happened: `failed`, `blocked`, `invalid`, `missing`, `limit`, `success`, + * or a short hyphenated state such as `in-progress` / `not-ready` + * + * **These values are public API.** UI SDKs key their translation tables on them, so renaming one is + * a breaking change. Emit them through this map rather than writing the literal inline, so the whole + * set stays greppable from one place and a typo is a compile error. + * + * Consumers translating notifications should switch on {@link Notification.type} rather than matching + * on {@link Notification.message}, which is untranslated English intended as a developer-facing + * fallback. + */ +export const CORE_NOTIFICATION_TYPE = { + attachmentFileMissing: 'validation:attachment:file:missing', + attachmentIdMissing: 'validation:attachment:id:missing', + attachmentUploadBlocked: 'validation:attachment:upload:blocked', + attachmentUploadFailed: 'api:attachment:upload:failed', + attachmentUploadInProgress: 'validation:attachment:upload:in-progress', + /** Carries `metadata.reason` (`'editing' | 'replying'`), which the message depends on. */ + commandDisabled: 'validation:command:disabled', + commandNotReady: 'validation:command:not-ready', + locationCreateFailed: 'api:location:create:failed', + /** Jumping to a specific message failed. */ + messageJumpFailed: 'api:message:jump:failed', + /** Jumping to the latest message failed. */ + messageJumpToLatestFailed: 'api:message:jumpToLatest:failed', + pollCastVoteLimit: 'validation:poll:castVote:limit', + pollCreateFailed: 'api:poll:create:failed', +} as const; + +/** A notification type emitted by `stream-chat` itself. See {@link CORE_NOTIFICATION_TYPE}. */ +export type CoreNotificationType = + (typeof CORE_NOTIFICATION_TYPE)[keyof typeof CORE_NOTIFICATION_TYPE]; + /** Represents a single notification message */ export type Notification = { /** Unique identifier for the notification */ id: string; - /** The notification message text */ + /** + * Untranslated English text describing what happened. + * + * This is a **developer-facing fallback, not display copy.** It is not localized and its exact + * wording is not part of the public contract — it can be reworded in a minor release. Anything + * user-facing should resolve {@link Notification.type} to its own copy and fall back to this string + * only for an identifier it does not recognize. + */ message: string; /** Timestamp when notification was created */ createdAt: number; @@ -36,49 +83,14 @@ export type Notification = { /** The severity level of the notification (defaults to `undefined` unless explicitly provided). */ severity?: NotificationSeverity; /** - * Optional code that can be used to group the notifications of the same type, e.g. attachment-upload-blocked. - * Format: domain:entity:operation:result - * domain: where the error occurred (api, validation, permission, etc) - * entity: what was being operated on (poll, attachment, message, etc) - * operation: what was being attempted (create, upload, validate, etc) - * result: what happened (failed, blocked, invalid, etc) - * - * Poll related errors - * 'api:poll:create:failed' // API call to create poll failed - * 'validation:poll:create:invalid' // Poll creation validation failed - * - * Attachment related errors - * 'validation:attachment:file:missing' // Required file is missing - * 'permission:attachment:upload:blocked' // Upload blocked due to permissions - * 'api:attachment:upload:failed' // API upload call failed - * 'validation:attachment:type:unsupported' // Unsupported file type - * 'validation:attachment:size:exceeded' // File size too large - * 'validation:attachment:count:exceeded' // Too many attachments - * - * MessageRequest related errors - * 'api:message:send:failed' // MessageRequest send failed - * 'validation:message:content:empty' // MessageRequest content validation failed - * - * Channel related errors - * 'api:channel:join:failed' // Channel join failed - * 'permission:channel:access:denied' // Channel access denied - * - * Authentication related errors - * 'auth:token:expired' // Auth token expired - * 'auth:token:invalid' // Invalid auth token - * - * Network related errors - * 'network:request:timeout' // Request timed out - * 'network:request:failed' // Network request failed - * - * Rate limiting - * 'rate:limit:exceeded' // Rate limit exceeded + * Stable identifier for what this notification is about, used to group notifications of the same + * kind and — for UI SDKs — to resolve a translation without matching on the English `message`. * - * System errors - * 'system:internal:error' // Internal system error - * 'system:resource:unavailable'; // System resource unavailable + * Values emitted by `stream-chat` are enumerated in {@link CORE_NOTIFICATION_TYPE}; those are the + * ones that autocomplete. The type stays open so SDKs and integrators can emit their own + * identifiers following the same `domain:entity:operation:result` convention. */ - type?: string; + type?: CoreNotificationType | (string & {}); /** Optional auto-dismiss duration in milliseconds. The timeout starts when NotificationManager.startTimeout() is called. */ duration?: number; /** Optional metadata to attach to the notification */ diff --git a/src/pagination/paginators/MessageIntervalPaginator.ts b/src/pagination/paginators/MessageIntervalPaginator.ts index 1121d868d0..8863f8a96d 100644 --- a/src/pagination/paginators/MessageIntervalPaginator.ts +++ b/src/pagination/paginators/MessageIntervalPaginator.ts @@ -27,6 +27,7 @@ import type { UserResponse, } from '../../types'; import type { Channel } from '../../channel'; +import { CORE_NOTIFICATION_TYPE } from '../../notifications'; import { StateStore } from '../../store'; import { computeOwnReactions, @@ -546,7 +547,7 @@ export class MessageIntervalPaginator extends BasePaginator< this.channel.getClient().notifications.addError({ message: 'Jump to message unsuccessful', origin: { emitter: 'MessagePaginator.jumpToMessage', context: { messageId } }, - options: { type: 'api:messages:query:failed' }, + options: { type: CORE_NOTIFICATION_TYPE.messageJumpFailed }, }); return false; } @@ -598,7 +599,7 @@ export class MessageIntervalPaginator extends BasePaginator< this.channel.getClient().notifications.addError({ message: 'Jump to latest message unsuccessful', origin: { emitter: 'MessagePaginator.jumpToTheLatestMessage' }, - options: { type: 'api:message:query:failed' }, + options: { type: CORE_NOTIFICATION_TYPE.messageJumpToLatestFailed }, }); return false; } diff --git a/src/poll.ts b/src/poll.ts index 34cf113b03..f3a868e074 100644 --- a/src/poll.ts +++ b/src/poll.ts @@ -1,4 +1,5 @@ import { StateStore } from './store'; +import { CORE_NOTIFICATION_TYPE } from './notifications'; import type { StreamChat } from './client'; import type { EventPayload, @@ -307,7 +308,7 @@ export class Poll { context: { messageId, optionId }, }, options: { - type: 'validation:poll:castVote:limit', + type: CORE_NOTIFICATION_TYPE.pollCastVoteLimit, }, }); return; diff --git a/test/unit/MessageComposer/middleware/pollComposer/state.test.ts b/test/unit/MessageComposer/middleware/pollComposer/state.test.ts index 781071fd7a..3ad2bb0400 100644 --- a/test/unit/MessageComposer/middleware/pollComposer/state.test.ts +++ b/test/unit/MessageComposer/middleware/pollComposer/state.test.ts @@ -9,6 +9,10 @@ import { createPollComposerStateMiddleware, PollComposerStateMiddlewareFactoryOptions, } from '../../../../../src/messageComposer/middleware/pollComposer/state'; +import { + POLL_VALIDATION_CODE, + pollValidationError, +} from '../../../../../src/messageComposer/middleware/pollComposer/validation'; import { VotingVisibility } from '../../../../../src/types'; const setupHandlerParams = (initialState: PollComposerStateChangeMiddlewareValue) => { @@ -213,8 +217,8 @@ describe('PollComposerStateMiddleware', () => { }), ); - expect(result.state.nextState.errors.max_votes_allowed).toBe( - 'Enforce unique vote is enabled', + expect(result.state.nextState.errors.max_votes_allowed?.code).toBe( + POLL_VALIDATION_CODE.maxVotesUniqueVoteEnforced, ); expect(result.state.nextState.data.max_votes_allowed).toBe('5'); expect(result.status).toBeUndefined; @@ -518,8 +522,8 @@ describe('PollComposerStateMiddleware', () => { expect(result.state.nextState.errors.options).toBeDefined(); expect(Object.keys(result.state.nextState.errors.options!)).toHaveLength(1); - expect(result.state.nextState.errors.options!['option-id1']).toBe( - 'Option is empty', + expect(result.state.nextState.errors.options!['option-id1'].code).toBe( + POLL_VALIDATION_CODE.optionEmpty, ); }); it('should not validate options with only white spaces on blur', async () => { @@ -539,11 +543,11 @@ describe('PollComposerStateMiddleware', () => { expect(result.state.nextState.errors.options).toBeDefined(); expect(Object.keys(result.state.nextState.errors.options!)).toHaveLength(2); - expect(result.state.nextState.errors.options!['option-id1']).toBe( - 'Option is empty', + expect(result.state.nextState.errors.options!['option-id1'].code).toBe( + POLL_VALIDATION_CODE.optionEmpty, ); - expect(result.state.nextState.errors.options!['option-id2']).toBe( - 'Option already exists', + expect(result.state.nextState.errors.options!['option-id2'].code).toBe( + POLL_VALIDATION_CODE.optionDuplicate, ); }); @@ -581,7 +585,7 @@ describe('PollComposerStateMiddleware', () => { ); expect(result.state.nextState.errors.options).toEqual({ - 'option-2': 'Option already exists', + 'option-2': pollValidationError(POLL_VALIDATION_CODE.optionDuplicate), }); }); diff --git a/test/unit/notifications/notificationTypes.test.ts b/test/unit/notifications/notificationTypes.test.ts new file mode 100644 index 0000000000..68843c4efa --- /dev/null +++ b/test/unit/notifications/notificationTypes.test.ts @@ -0,0 +1,136 @@ +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { join, relative } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +import { + CORE_NOTIFICATION_TYPE, + isPollValidationError, + POLL_VALIDATION_CODE, + pollValidationError, +} from '../../../src'; +import type { + CoreNotificationType, + PollValidationCode, + PollValidationError, +} from '../../../src'; + +const SRC = join(__dirname, '../../../src'); + +/** Generated OpenAPI models and the offline-support error taxonomy are out of scope. */ +const EXCLUDED_DIRS = ['gen', 'offline-support']; + +const sourceFiles = (dir: string): string[] => + readdirSync(dir).flatMap((entry) => { + const full = join(dir, entry); + if (statSync(full).isDirectory()) { + return EXCLUDED_DIRS.includes(entry) ? [] : sourceFiles(full); + } + return entry.endsWith('.ts') ? [full] : []; + }); + +const files = sourceFiles(SRC).map((path) => ({ + path: relative(SRC, path), + contents: readFileSync(path, 'utf8'), +})); + +const allSource = files.map((f) => f.contents).join('\n'); + +describe('CORE_NOTIFICATION_TYPE', () => { + it('is exported from the public barrel with its type', () => { + const value: CoreNotificationType = CORE_NOTIFICATION_TYPE.pollCreateFailed; + expect(value).toBe('api:poll:create:failed'); + }); + + it('follows the domain:entity:operation:result convention', () => { + for (const [key, type] of Object.entries(CORE_NOTIFICATION_TYPE)) { + expect(type, `${key} must have 3 or 4 colon-separated segments`).toMatch( + /^[a-z]+(:[a-zA-Z][\w-]*){2,3}$/, + ); + } + }); + + it('has no duplicate identifiers', () => { + const values = Object.values(CORE_NOTIFICATION_TYPE); + expect(new Set(values).size).toBe(values.length); + }); + + /** + * Guards against a dead identifier: one that UI SDKs still carry a translation for while nothing + * emits it any more. That is how both UI SDKs ended up with entries for types no SDK emits. + */ + it('emits every identifier it declares', () => { + const unused = Object.keys(CORE_NOTIFICATION_TYPE).filter( + (key) => !allSource.includes(`CORE_NOTIFICATION_TYPE.${key}`), + ); + expect(unused, 'declared but never emitted — remove it or emit it').toEqual([]); + }); + + /** + * Guards against the bypass: a raw string literal at a call site is invisible to the union, so it + * cannot be renamed safely and a typo never fails the build. + */ + it('is the only source of notification type literals in src/', () => { + const offenders = files.flatMap(({ path, contents }) => + contents + .split('\n') + .map((line, i) => ({ line, lineNumber: i + 1 })) + .filter(({ line }) => /\btype:\s*'[a-z]+:[a-zA-Z][\w-]*:/.test(line)) + .map(({ line, lineNumber }) => `${path}:${lineNumber} ${line.trim()}`), + ); + expect(offenders, 'use CORE_NOTIFICATION_TYPE. instead of a literal').toEqual( + [], + ); + }); +}); + +describe('POLL_VALIDATION_CODE', () => { + it('is exported from the public barrel with its type and helpers', () => { + const code: PollValidationCode = POLL_VALIDATION_CODE.nameRequired; + const error: PollValidationError = pollValidationError(code); + expect(error).toEqual({ code, message: 'Question is required' }); + expect(isPollValidationError(error)).toBe(true); + }); + + it('follows the same convention and has no duplicates', () => { + const values = Object.values(POLL_VALIDATION_CODE); + expect(new Set(values).size).toBe(values.length); + for (const [key, code] of Object.entries(POLL_VALIDATION_CODE)) { + expect(code, `${key} must be validation:poll::`).toMatch( + /^validation:poll:[a-zA-Z][\w-]*:[a-zA-Z][\w-]*$/, + ); + } + }); + + it('pairs every code with a non-empty English fallback', () => { + for (const code of Object.values(POLL_VALIDATION_CODE)) { + expect(pollValidationError(code).message, `${code} has no fallback`).toBeTruthy(); + } + }); + + it('emits every code it declares', () => { + const unused = Object.keys(POLL_VALIDATION_CODE).filter( + (key) => !allSource.includes(`POLL_VALIDATION_CODE.${key}`), + ); + expect(unused, 'declared but never emitted').toEqual([]); + }); + + it('attaches metadata only when supplied', () => { + expect(pollValidationError(POLL_VALIDATION_CODE.optionEmpty)).not.toHaveProperty( + 'metadata', + ); + expect( + pollValidationError(POLL_VALIDATION_CODE.optionEmpty, { optionId: 'a' }).metadata, + ).toEqual({ optionId: 'a' }); + }); + + it('rejects non-errors in the narrowing guard', () => { + expect(isPollValidationError(undefined)).toBe(false); + expect(isPollValidationError('Option is empty')).toBe(false); + // an `options` error record, which is the other shape a field error can take + expect( + isPollValidationError({ + 'option-1': pollValidationError(POLL_VALIDATION_CODE.optionEmpty), + }), + ).toBe(false); + }); +}); From 183bbb220cef788d49f8683713a0ac9dd20f4a29 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Mon, 17 Aug 2026 23:08:57 +0200 Subject: [PATCH 02/27] feat(i18n): add the shared i18n layer as a stream-chat/i18n subpath 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. --- package.json | 36 ++ scripts/bundle.mjs | 59 ++- src/i18n/StreamI18n.ts | 537 ++++++++++++++++++++ src/i18n/dayjs.ts | 175 +++++++ src/i18n/formatters.ts | 279 ++++++++++ src/i18n/index.ts | 12 + src/i18n/translator.ts | 76 +++ src/i18n/types.ts | 315 ++++++++++++ test/unit/i18n/StreamI18n.test.ts | 245 +++++++++ test/unit/i18n/StreamI18nGuarantees.test.ts | 170 +++++++ test/unit/i18n/fixtures.ts | 47 ++ vite.config.ts | 4 + yarn.lock | 21 + 13 files changed, 1974 insertions(+), 2 deletions(-) create mode 100644 src/i18n/StreamI18n.ts create mode 100644 src/i18n/dayjs.ts create mode 100644 src/i18n/formatters.ts create mode 100644 src/i18n/index.ts create mode 100644 src/i18n/translator.ts create mode 100644 src/i18n/types.ts create mode 100644 test/unit/i18n/StreamI18n.test.ts create mode 100644 test/unit/i18n/StreamI18nGuarantees.test.ts create mode 100644 test/unit/i18n/fixtures.ts diff --git a/package.json b/package.json index 126b56bdc6..a6ba920baa 100644 --- a/package.json +++ b/package.json @@ -26,8 +26,30 @@ }, "node": "./dist/cjs/index.node.js", "default": "./dist/esm/index.mjs" + }, + "./i18n": { + "types": "./dist/types/i18n/index.d.ts", + "browser": { + "import": "./dist/esm/i18n.mjs", + "require": "./dist/cjs/i18n.browser.js" + }, + "react-native": { + "import": "./dist/esm/i18n.mjs", + "require": "./dist/cjs/i18n.browser.js" + }, + "node": "./dist/cjs/i18n.node.js", + "default": "./dist/esm/i18n.mjs" + }, + "./package.json": "./package.json" + }, + "typesVersions": { + "*": { + "i18n": [ + "./dist/types/i18n/index.d.ts" + ] } }, + "sideEffects": false, "license": "SEE LICENSE IN LICENSE", "keywords": [ "chat", @@ -47,6 +69,18 @@ "axios": "^1.19.0", "linkifyjs": "^4.3.3" }, + "peerDependencies": { + "dayjs": "^1.11.13", + "i18next": "^26.3.6" + }, + "peerDependenciesMeta": { + "dayjs": { + "optional": true + }, + "i18next": { + "optional": true + } + }, "devDependencies": { "@commitlint/cli": "^21.0.1", "@commitlint/config-conventional": "^21.0.1", @@ -58,6 +92,7 @@ "@vitest/coverage-v8": "^4.1.10", "concurrently": "^9.2.1", "conventional-changelog-conventionalcommits": "^9.3.1", + "dayjs": "^1.11.13", "dotenv": "^17.4.2", "esbuild": "^0.28.2", "eslint": "^9.39.4", @@ -66,6 +101,7 @@ "eslint-plugin-unused-imports": "^4.4.1", "globals": "^17.6.0", "husky": "^9.1.7", + "i18next": "^26.3.6", "lint-staged": "^17.0.5", "prettier": "^3.8.3", "semantic-release": "^25.0.9", diff --git a/scripts/bundle.mjs b/scripts/bundle.mjs index c22b71dfd0..ff9b97e931 100755 --- a/scripts/bundle.mjs +++ b/scripts/bundle.mjs @@ -29,8 +29,15 @@ const nodeExternal = [...modules, ...builtinModules]; /** @type esbuild.BuildOptions */ const commonBuildOptions = { - entryPoints: [resolve(__dirname, '../src/index.ts')], + // Name-keyed so `[name]` stays stable per entry. `i18n` is a separate entry point on purpose: it + // pulls in i18next and dayjs, and keeping those out of the root bundle is the whole reason + // `stream-chat/i18n` exists as a subpath. `assertBundleBoundaries` enforces that below. + entryPoints: { + index: resolve(__dirname, '../src/index.ts'), + i18n: resolve(__dirname, '../src/i18n/index.ts'), + }, bundle: true, + metafile: true, target: 'ES2020', sourcemap: watchModeEnabled ? 'inline' : 'linked', define: { @@ -38,6 +45,53 @@ const commonBuildOptions = { }, }; +/** Dependencies that must never be reachable from the root bundle. */ +const I18N_ONLY_DEPENDENCIES = ['i18next', 'dayjs']; + +/** + * Fails the build if the entry-point boundaries have been crossed. + * + * Two directions, both of which are a single careless `export * from './i18n'` away: + * - the root bundle must not reach `src/i18n/` or its dependencies, or every consumer of + * `stream-chat` pays for i18next and dayjs whether they translate anything or not; + * - the i18n bundle must not reach `src/i18n-codegen/`, which is Node-only build tooling. + * + * Checked here rather than left to review, because the failure is invisible: everything still works, + * the bundle is just quietly bigger. + */ +const assertBundleBoundaries = (metafile) => { + const failures = []; + + for (const [outputFile, output] of Object.entries(metafile.outputs)) { + if (!output.entryPoint) continue; + + const forbidden = output.entryPoint.endsWith('src/index.ts') + ? { deps: I18N_ONLY_DEPENDENCIES, sources: /(^|\/)src\/i18n\// } + : { deps: [], sources: /(^|\/)src\/i18n-codegen\// }; + + const leakedSources = Object.keys(output.inputs).filter((input) => + forbidden.sources.test(input), + ); + const leakedDeps = (output.imports ?? []) + .map(({ path }) => path) + .filter((path) => + forbidden.deps.some((dep) => path === dep || path.startsWith(`${dep}/`)), + ); + + if (leakedSources.length || leakedDeps.length) { + failures.push( + `${outputFile} (entry ${output.entryPoint}) must not reach: ` + + [...new Set([...leakedSources, ...leakedDeps])].join(', '), + ); + } + } + + if (failures.length) { + console.error(`\nBundle boundary violated:\n ${failures.join('\n ')}\n`); + process.exit(1); + } +}; + /** * process.env.CLIENT_BUNDLE values: * @@ -86,5 +140,6 @@ if (watchModeEnabled) { console.log('ESBuild is watching for changes...'); } else { - await Promise.all(bundles.map((config) => esbuild.build(config))); + const results = await Promise.all(bundles.map((config) => esbuild.build(config))); + results.forEach(({ metafile }) => metafile && assertBundleBoundaries(metafile)); } diff --git a/src/i18n/StreamI18n.ts b/src/i18n/StreamI18n.ts new file mode 100644 index 0000000000..67862eeeb3 --- /dev/null +++ b/src/i18n/StreamI18n.ts @@ -0,0 +1,537 @@ +import i18next from 'i18next'; +import type { i18n as I18nInstance, InitOptions } from 'i18next'; + +import { StateStore } from '../store'; +import { + addOrUpdateDayjsLocale, + dayjsLocaleExists, + ensureDayjsPlugins, + getDefaultDateTimeParserModule, + isDayjsLike, + supportsTimezone, +} from './dayjs'; +import type { DayjsLocaleConfig } from './dayjs'; +import { predefinedFormatters } from './formatters'; +import { + asDynamicKey, + createDefaultTranslatorFunction, + guardMissingKeyHandler, +} from './translator'; +import type { + AnyTranslationCatalog, + CustomFormatters, + DateTimeParserModule, + LooseTranslateFunction, + PredefinedFormatters, + StreamTFunctionFor, + TDateTimeParser, + TranslationDictionaryOf, +} from './types'; + +const DEFAULT_NAMESPACE = 'translation'; +const DEFAULT_LANGUAGE = 'en'; + +export type StreamI18nOptions = { + /** A dayjs or moment module. Defaults to dayjs with the required plugins registered. */ + DateTimeParser?: DateTimeParserModule; + dayjsLocaleConfigForLanguage?: DayjsLocaleConfig; + debug?: boolean; + /** Keep dates in English regardless of the active language. */ + disableDateTimeTranslations?: boolean; + formatters?: Partial & CustomFormatters; + /** + * Any i18next `InitOptions`. Applied over the SDK's defaults, so it can reach settings the SDK does + * not surface. `parseMissingKeyHandler` supplied here is guarded the same way as the top-level + * option. + */ + i18nextConfigOverrides?: Partial; + language?: string; + logger?: (message?: string) => void; + /** + * Called only for keys that are genuinely missing — one with no inline default and no bundled + * value. See {@link guardMissingKeyHandler} for why it cannot be passed straight to i18next. + */ + parseMissingKeyHandler?: (key: string, defaultValue?: string) => string; + /** + * The SDK's bundled translation data: the keys that cannot carry an inline `defaultValue` at their + * call site. + * + * Injected rather than imported because the catalog belongs to the UI SDK, not to core. Layered + * under **every** language, which is what stops a partial dictionary from knocking out formatter + * keys. + */ + runtimeDefaults?: Record; + /** A valid TZ identifier, e.g. `Europe/Prague`. */ + timezone?: string; + translationsForLanguage?: TranslationDictionaryOf; +}; + +export type StreamI18nState< + C extends AnyTranslationCatalog = AnyTranslationCatalog, + Bundled extends string = never, +> = { + initialized: boolean; + language: string; + t: StreamTFunctionFor; + tDateTimeParser: TDateTimeParser; +}; + +/** + * Wrapper around [i18next](https://www.i18next.com/) for Stream's translations. A UI SDK passes an + * instance to its `` component to control language and copy. + * + * Only English ships, and only as much of it as has to: prose renders from the inline `defaultValue` + * at each call site, so the bundled data is just formatter expressions and the handful of keys + * resolved by name at runtime. Every other language comes from the integrator. + * + * Reactivity goes through {@link StreamI18n.state}, a {@link StateStore}. `subscribe` fires + * synchronously with the current value, so a consumer that attaches after `init()` still sees the live + * `t` immediately and there is no callback-registration ordering to get wrong. + * + * ## Overriding some of the English copy + * + * ```ts + * const i18n = new StreamI18n({ + * translationsForLanguage: { 'autoCompleteInput.placeholder': 'Write something…' }, + * }); + * ``` + * + * ## Registering a language + * + * ```ts + * import 'dayjs/locale/de'; + * + * const i18n = new StreamI18n({ language: 'de' }); + * i18n.registerTranslation('de', de, { + * calendar: { sameDay: '[heute um] LT', lastDay: '[gestern um] LT', ... }, + * }); + * ``` + * + * A partial dictionary is safe: keys you do not supply render their English copy, never a raw dotted + * path. Plurals are stored as `_one` / `_other`; supply whichever categories your language + * needs and `Intl.PluralRules` selects between them. + * + * Note that no dayjs locale file defines `calendar` — that field belongs to the calendar plugin — so a + * new language needs both `import 'dayjs/locale/xx'` and a `calendar` config, or relative dates render + * English scaffolding around translated day names. + */ +export class StreamI18n< + C extends AnyTranslationCatalog = AnyTranslationCatalog, + Bundled extends string = never, +> { + /** Marks instances across bundle copies, where `instanceof` silently fails. */ + static readonly brand = Symbol.for('stream-chat.StreamI18n'); + + readonly i18nInstance: I18nInstance = i18next.createInstance(); + + readonly state: StateStore>; + + /** The resource dictionaries handed to i18next, keyed by language. */ + translations: Record>> = {}; + + /** + * Languages an integrator actually supplied a dictionary for. + * + * Deliberately narrower than `Object.keys(this.translations)`, which also contains every language + * created just to carry the bundled defaults. Without the distinction the unregistered-language + * warning could never fire. + */ + readonly registeredLanguages = new Set([DEFAULT_LANGUAGE]); + + /** + * Locale configs supplied through `registerTranslation`, applied when the language becomes active. + * + * `Dayjs.locale()` also changes the *global* locale, which registering a translation must not do. + */ + readonly dayjsLocales: Record = {}; + + readonly logger: (message?: string) => void; + readonly DateTimeParser: DateTimeParserModule; + readonly isCustomDateTimeParser: boolean; + readonly formatters: PredefinedFormatters & CustomFormatters; + readonly timezone?: string; + + private readonly runtimeDefaults: Record; + private readonly disableDateTimeTranslations: boolean; + private readonly i18nextConfig: InitOptions; + private initPromise?: Promise>; + /** Set by {@link overrideTFunction}, so `init()` does not clobber a swapped-in implementation. */ + private tOverridden = false; + + constructor(options: StreamI18nOptions = {}) { + this.logger = options.logger ?? ((message?: string) => console.warn(message)); + this.runtimeDefaults = options.runtimeDefaults ?? {}; + this.disableDateTimeTranslations = options.disableDateTimeTranslations ?? false; + this.timezone = options.timezone; + this.formatters = { ...predefinedFormatters, ...options.formatters }; + this.isCustomDateTimeParser = Boolean(options.DateTimeParser); + + const language = options.language ?? DEFAULT_LANGUAGE; + + if (options.DateTimeParser) { + this.DateTimeParser = options.DateTimeParser; + // A dayjs module the integrator supplied needs the same plugins as ours. + if (isDayjsLike(this.DateTimeParser)) ensureDayjsPlugins(); + } else { + this.DateTimeParser = getDefaultDateTimeParserModule(); + } + + const tDateTimeParser: TDateTimeParser = (timestamp) => { + const locale = + this.disableDateTimeTranslations || !this.localeExists(this.currentLanguageValue) + ? DEFAULT_LANGUAGE + : this.currentLanguageValue; + + const parsed = this.DateTimeParser(timestamp); + const withZone = + this.timezone && supportsTimezone(this.DateTimeParser) + ? (parsed as unknown as { tz: (tz: string) => typeof parsed }).tz(this.timezone) + : parsed; + + return (withZone as unknown as { locale: (l: string) => typeof parsed }).locale( + locale, + ); + }; + + this.state = new StateStore>({ + initialized: false, + language, + t: createDefaultTranslatorFunction(), + tDateTimeParser, + }); + + // `en` always exists so the bundled keys resolve, and so does the active language — including one + // nobody registered, which then renders the SDK's English copy from the inline defaults rather + // than dotted key paths. + this.ensureLanguage(DEFAULT_LANGUAGE); + this.ensureLanguage(language); + + if (options.translationsForLanguage) { + this.translations[language] = { + [DEFAULT_NAMESPACE]: this.mergeWithRuntimeDefaults( + language, + options.translationsForLanguage as Record, + ), + }; + this.registeredLanguages.add(language); + } + + const missingKeyHandler = + options.parseMissingKeyHandler ?? + options.i18nextConfigOverrides?.parseMissingKeyHandler; + + this.i18nextConfig = { + debug: options.debug ?? false, + fallbackLng: false, + interpolation: { escapeValue: false, formatSeparator: '|' }, + // Keys are flat strings that happen to contain dots, and several contain `...` in their copy, + // which `keySeparator: '.'` would mis-resolve. This must stay false. + keySeparator: false, + lng: language, + nsSeparator: false, + ...options.i18nextConfigOverrides, + // An integrator handler replaces ours wholesale, so it has to be guarded too — otherwise + // supplying one silently blanks every prose key. + parseMissingKeyHandler: missingKeyHandler + ? guardMissingKeyHandler(missingKeyHandler) + : (key: string, defaultValue?: string) => { + if (typeof defaultValue === 'string') return defaultValue; + this.logger(`StreamI18n: missing translation for key: ${key}`); + return key; + }, + }; + + this.validateCurrentLanguage(); + + if (options.dayjsLocaleConfigForLanguage) { + this.addOrUpdateLocale(language, options.dayjsLocaleConfigForLanguage); + } else if (!this.localeExists(language)) { + this.logger( + `StreamI18n: no dayjs locale is registered for '${language}', so dates render with the ` + + `English locale. Import it with "import 'dayjs/locale/${language}';" in your app, or pass ` + + `a config via registerTranslation('${language}', translation, dayjsLocaleConfig).`, + ); + } + } + + /* --------------------------------------------------------------------------------------------- + * State-backed accessors + * ------------------------------------------------------------------------------------------- */ + + get t(): StreamTFunctionFor { + return this.state.getLatestValue().t; + } + + get tDateTimeParser(): TDateTimeParser { + return this.state.getLatestValue().tDateTimeParser; + } + + get currentLanguage(): string { + return this.state.getLatestValue().language; + } + + get initialized(): boolean { + return this.state.getLatestValue().initialized; + } + + /** Read inside the constructor, before `state` getters are safe to rely on externally. */ + private get currentLanguageValue(): string { + return this.state?.getLatestValue().language ?? DEFAULT_LANGUAGE; + } + + /* --------------------------------------------------------------------------------------------- + * Lifecycle + * ------------------------------------------------------------------------------------------- */ + + /** + * Initializes i18next. Idempotent and safe to call concurrently. + * + * The promise is memoized and never cleared: two independent consumers (a UI SDK's chat root and its + * overlay host, say) both call this, and clearing it on completion would leave a window where a + * third caller re-entered initialization. + */ + init(): Promise> { + this.initPromise ??= this.runInit(); + return this.initPromise; + } + + /** @deprecated Use {@link init}, which returns the same state. */ + getTranslators(): Promise> { + return this.init(); + } + + private async runInit(): Promise> { + this.validateCurrentLanguage(); + this.assertPluralRulesCoverage(this.currentLanguage); + + const dayjsLocale = this.dayjsLocales[this.currentLanguage]; + if (dayjsLocale) this.addOrUpdateLocale(this.currentLanguage, dayjsLocale); + + try { + const t = await this.i18nInstance.init({ + ...this.i18nextConfig, + lng: this.currentLanguage, + resources: this.translations, + }); + + Object.entries(this.formatters).forEach(([name, factory]) => { + if (!factory) return; + const formatter = factory({ + currentLanguage: this.currentLanguage, + dateTimeParser: this.DateTimeParser, + tDateTimeParser: this.tDateTimeParser, + timezone: this.timezone, + translate: this.translate, + }); + // A custom formatter's value type is declared `never` so that any implementation is + // assignable to it (parameters are contravariant). i18next's own signature takes `any`, so + // the widening happens here rather than weakening the public type. + this.i18nInstance.services.formatter?.add( + name, + formatter as (value: any, lng: string | undefined, options: any) => string, + ); + }); + + this.state.partialNext({ + initialized: true, + // An `overrideTFunction` call before init must not be undone by init. + ...(this.tOverridden + ? {} + : { t: t as unknown as StreamTFunctionFor }), + }); + } catch (error) { + this.logger(`StreamI18n: initialization failed: ${describeError(error)}`); + this.state.partialNext({ initialized: true }); + } + + return this.state.getLatestValue(); + } + + /* --------------------------------------------------------------------------------------------- + * Languages and dictionaries + * ------------------------------------------------------------------------------------------- */ + + /** + * A dictionary layered over the bundled defaults. + * + * Every write into `this.translations` goes through here: bundled keys have no inline `defaultValue` + * at their call site and `fallbackLng` is false, so a language missing them renders raw dotted keys + * and unformatted ISO timestamps. + */ + private mergeWithRuntimeDefaults = ( + language: string, + translation?: Record, + ): Record => ({ + ...this.runtimeDefaults, + ...this.translations[language]?.[DEFAULT_NAMESPACE], + ...translation, + }); + + /** + * Guarantees `language` has a dictionary, so a language nobody registered still formats dates and + * renders the SDK's copy in English. Writes into i18next's store too when already initialized — the + * only route for a language added after `init()`. + */ + private ensureLanguage = (language: string) => { + const translation = this.mergeWithRuntimeDefaults(language); + this.translations[language] = { [DEFAULT_NAMESPACE]: translation }; + + if (this.initialized) { + this.i18nInstance.addResources(language, DEFAULT_NAMESPACE, translation); + } + }; + + registerTranslation( + language: string, + translation: TranslationDictionaryOf, + dayjsLocaleConfig?: DayjsLocaleConfig, + ) { + if (!translation) { + this.logger( + 'StreamI18n: registerTranslation called without a translation dictionary', + ); + return; + } + + // Merged, not replaced, so repeated calls for one language accumulate and the bundled keys + // survive a partial dictionary. + const merged = this.mergeWithRuntimeDefaults( + language, + translation as Record, + ); + this.translations[language] = { [DEFAULT_NAMESPACE]: merged }; + this.registeredLanguages.add(language); + + if (dayjsLocaleConfig) { + this.dayjsLocales[language] = { ...dayjsLocaleConfig }; + } else if (!this.localeExists(language)) { + this.logger( + `StreamI18n: no dayjs locale is registered for '${language}'. Import it with ` + + `"import 'dayjs/locale/${language}';" in your app, or pass a config as the third ` + + `argument to registerTranslation.`, + ); + } + + if (this.initialized) { + // `merged`, not `translation`: for a language registered after init this is the only write into + // i18next's store, so passing the partial would leave the bundled defaults absent there. + this.i18nInstance.addResources(language, DEFAULT_NAMESPACE, merged); + } + } + + /** + * Changes the active language. + * + * Returns nothing: the new `t` is published to {@link StreamI18n.state}, which is the single source + * of the current translator. Handing one back would offer a value that goes stale on the next + * language change and invite callers to cache it. + */ + async setLanguage(language: string): Promise { + this.state.partialNext({ language }); + this.ensureLanguage(language); + + if (!this.initialized) return; + + this.validateCurrentLanguage(); + this.assertPluralRulesCoverage(language); + + try { + const t = await this.i18nInstance.changeLanguage(language); + const dayjsLocale = this.dayjsLocales[language]; + if (dayjsLocale) this.addOrUpdateLocale(language, dayjsLocale); + if (!this.tOverridden) { + this.state.partialNext({ t: t as unknown as StreamTFunctionFor }); + } + } catch (error) { + this.logger(`StreamI18n: failed to set language: ${describeError(error)}`); + } + } + + /** + * Swaps in a different translation implementation, for an app that already has an i18n layer. + * + * Safe before `init()`: the store holds it and initialization will not overwrite it. + */ + overrideTFunction(t: StreamTFunctionFor) { + this.tOverridden = true; + this.state.partialNext({ t }); + } + + /** + * Warns when the active language has no registered dictionary. + * + * Not an error, and not a reason to fall back to `en`: the language renders the SDK's English copy + * from the inline defaults while keeping its own date formats. Silently resetting the language + * instead discards the integrator's choice and makes the cause very hard to see. + */ + validateCurrentLanguage = () => { + const language = this.currentLanguageValue; + if (this.registeredLanguages.has(language)) return; + + this.logger( + `StreamI18n: no translation dictionary is registered for '${language}', so the SDK's copy ` + + `renders in English. Call registerTranslation('${language}', {...}) to translate it. ` + + `Registered: ${[...this.registeredLanguages].join(', ')}`, + ); + }; + + /** Whether the date library has locale data for `language`. */ + localeExists = (language: string) => { + if (this.isCustomDateTimeParser) return true; + return dayjsLocaleExists(language); + }; + + addOrUpdateLocale(language: string, config: DayjsLocaleConfig) { + addOrUpdateDayjsLocale(language, config); + } + + /** Languages with a dictionary, including those carrying only the bundled defaults. */ + getAvailableLanguages = () => Object.keys(this.translations); + + /** + * A loose translate used by formatters, which resolve keys handed to them at runtime. + * + * Bound to i18next rather than to the typed `t` so a formatter can reach its own `relativeTime.*` + * copy without the catalog having to declare it. + */ + private translate: LooseTranslateFunction = (key, defaultValueOrOptions, options) => + (this.t as LooseTranslateFunction)( + asDynamicKey(key), + defaultValueOrOptions, + options, + ) as string; + + /** + * Warns when `Intl.PluralRules` has no data for a language. + * + * Hermes ships a partial ICU: the constructor exists but silently falls back to the root locale's + * rules — `{ other }` only — for locales it lacks data for. A dictionary correctly supplying + * `_few` / `_many` then renders none of them, with no error anywhere. React Native apps load + * `intl-pluralrules` to fix this; this check is what turns the silent version into a visible one for + * anyone who has not. + * + * Checked here rather than at module scope because i18next builds its plural resolver during + * `init()`, caching an `Intl.PluralRules` per language — so this is the last moment a polyfill could + * still have been loaded in time. + */ + private assertPluralRulesCoverage = (language: string) => { + try { + const resolved = new Intl.PluralRules(language).resolvedOptions().locale; + if (resolved.split('-')[0] === language.split('-')[0]) return; + this.logger( + `StreamI18n: Intl.PluralRules has no data for '${language}' (it resolved to ` + + `'${resolved}'), so every count selects the '_other' form. On React Native, import ` + + `'intl-pluralrules' before anything else in your entry file.`, + ); + } catch { + this.logger( + `StreamI18n: Intl.PluralRules is unavailable, so plural selection will not work. On React ` + + `Native, import 'intl-pluralrules' before anything else in your entry file.`, + ); + } + }; +} + +/** `JSON.stringify(error)` renders an `Error` as `{}`, which is how these used to get logged. */ +const describeError = (error: unknown) => + error instanceof Error ? error.message : String(error); diff --git a/src/i18n/dayjs.ts b/src/i18n/dayjs.ts new file mode 100644 index 0000000000..ccd23f473d --- /dev/null +++ b/src/i18n/dayjs.ts @@ -0,0 +1,175 @@ +import Dayjs from 'dayjs'; +import calendar from 'dayjs/plugin/calendar.js'; +import duration from 'dayjs/plugin/duration.js'; +import localeData from 'dayjs/plugin/localeData.js'; +import localizedFormat from 'dayjs/plugin/localizedFormat.js'; +import relativeTime from 'dayjs/plugin/relativeTime.js'; +import timezone from 'dayjs/plugin/timezone.js'; +import updateLocale from 'dayjs/plugin/updateLocale.js'; +import utc from 'dayjs/plugin/utc.js'; + +import type { + DateTimeLike, + DateTimeParserModule, + TDateTimeParserInput, + TDateTimeParserOutput, +} from './types'; + +/** + * The calendar-plugin config shape. Not part of dayjs's own `ILocale`, so it has to be declared here. + * + * Supplying it is how relative wording ("heute um", "ieri alle") gets localized — no dayjs locale file + * defines `calendar`, which is the single most common surprise when adding a language. + */ +export type CalendarFormats = { + lastDay: string; + lastWeek: string; + nextDay: string; + nextWeek: string; + sameDay: string; + sameElse: string; +}; + +/** + * A dayjs locale config, as accepted by `dayjsLocaleConfigForLanguage` and by + * `registerTranslation`'s third argument. + * + * Typing this as a bare `Partial` makes passing a calendar config a TS2345 "no properties in + * common" error, which is exactly the wording an integrator hits first — hence the explicit + * `calendar`. + */ +export type DayjsLocaleConfig = Partial & { calendar?: CalendarFormats }; + +/** + * The English locale skeleton a custom locale is merged over, so a partial config still has month and + * weekday names to fall back on. + */ +const EN_LOCALE_FALLBACK = { + /** + * `formats` and `relativeTime` are empty on purpose, and are not removable: `Dayjs.locale()` takes an + * `ILocale`, which declares both as required, so omitting them is a type error. Empty means "inherit + * dayjs's own defaults", which is the intent. + */ + formats: {}, + relativeTime: {}, + months: [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', + ], + weekdays: [ + 'Sunday', + 'Monday', + 'Tuesday', + 'Wednesday', + 'Thursday', + 'Friday', + 'Saturday', + ], +}; + +let pluginsRegistered = false; + +/** + * Registers the dayjs plugins the formatters need, once. + * + * Deliberately **not** done at module scope. Module-scope `Dayjs.extend(...)` is a side effect, which + * would force `stream-chat` to declare `sideEffects` and would make importing this module do work + * whether or not anything uses it. Calling it from both the constructor and `defaultDateTimeParser` + * covers the two ways the formatters can be reached, including a standalone `getDateString()` call + * with no `StreamI18n` instance in play. + * + * Idempotent twice over: guarded here, and dayjs itself no-ops a repeated `extend` via the plugin's + * `$i` marker. + * + * `timezone` is included because it depends on `utc` and callers can set `timezone` at any point; + * registering it lazily on first use would leave the plugin missing for an instance that only sets + * `timezone` later. + */ +export const ensureDayjsPlugins = () => { + if (pluginsRegistered) return; + pluginsRegistered = true; + + // `updateLocale` and `utc` first: `timezone` builds on `utc`. + Dayjs.extend(updateLocale); + Dayjs.extend(utc); + Dayjs.extend(timezone); + Dayjs.extend(localizedFormat); + Dayjs.extend(calendar); + Dayjs.extend(localeData); + Dayjs.extend(relativeTime); + Dayjs.extend(duration); +}; + +/** + * The parser used when none is supplied, and by `getDateString()` called outside an instance. + * + * Note there is no `import 'dayjs/locale/en'` anywhere: dayjs bundles `en` and has it registered + * before any import runs (`Object.keys(Dayjs.Ls)` is already `['en']`), so that import — which both UI + * SDKs carried — was a no-op. + */ +export const defaultDateTimeParser = (input?: TDateTimeParserInput) => { + ensureDayjsPlugins(); + return Dayjs(input); +}; + +/** + * The dayjs module itself, with plugins registered. + * + * `StreamI18n.DateTimeParser` has to be the *module*, not a parse function, because + * `durationFormatter` calls `.duration()` — which lives on the module, not on a parsed instance. + */ +export const getDefaultDateTimeParserModule = (): DateTimeParserModule => { + ensureDayjsPlugins(); + return Dayjs as unknown as DateTimeParserModule; +}; + +/** Registers or updates a dayjs locale without changing the global locale. */ +export const addOrUpdateDayjsLocale = (language: string, config: DayjsLocaleConfig) => { + ensureDayjsPlugins(); + if (dayjsLocaleExists(language)) { + Dayjs.updateLocale(language, { ...config }); + return; + } + // Merged over the English skeleton so missing keys still resolve. + Dayjs.locale({ name: language, ...EN_LOCALE_FALLBACK, ...config }, undefined, true); +}; + +export const dayjsLocaleExists = (language: string) => + Object.keys(Dayjs.Ls).includes(language); + +/** + * Whether a parser is dayjs, as opposed to a Moment the integrator brought. + * + * A property check rather than the `.extend !== undefined` both UI SDKs used, which throws on `null`. + */ +export const isDayjsLike = (parser: unknown): parser is DateTimeParserModule => + typeof parser === 'function' && + typeof (parser as DateTimeParserModule).extend === 'function'; + +/** Whether a parser supports `.tz()`, i.e. dayjs with the timezone plugin, or moment-timezone. */ +export const supportsTimezone = (parser: unknown): boolean => + typeof parser === 'function' && typeof (parser as { tz?: unknown }).tz === 'function'; + +export const isDate = (value: TDateTimeParserOutput): value is Date => + value instanceof Date; + +export const isNumberOrString = ( + value: TDateTimeParserOutput, +): value is number | string => typeof value === 'number' || typeof value === 'string'; + +/** Whether a parser output is a dayjs or Moment object rather than a raw Date/string/number. */ +export const isDayOrMoment = (value: TDateTimeParserOutput): value is DateTimeLike => + typeof value === 'object' && + value !== null && + !(value instanceof Date) && + typeof (value as DateTimeLike).format === 'function'; diff --git a/src/i18n/formatters.ts b/src/i18n/formatters.ts new file mode 100644 index 0000000000..23ae651035 --- /dev/null +++ b/src/i18n/formatters.ts @@ -0,0 +1,279 @@ +import { isDate, isDayOrMoment, isNumberOrString } from './dayjs'; +import { asDynamicKey } from './translator'; +import type { + DurationFormatterOptions, + FormatterContext, + FormatterFactory, + LooseTranslateFunction, + PredefinedFormatters, + TDateTimeParser, + TimestampFormatterOptions, +} from './types'; + +/** Defaults for the relative-compact window, matching what both UI SDKs shipped. */ +const DEFAULT_RELATIVE_COMPACT_MAX_DAYS = 6; +const DEFAULT_RELATIVE_COMPACT_MAX_WEEKS = 3; + +/** + * Coerces a numeric formatter option. + * + * These arrive as strings, not numbers: they are written inside an i18next format expression + * (`{{ timestamp | timestampFormatter(relativeCompactMaxDays: 10) }}`), and i18next hands every + * argument over as text. The declared type says `number` because that is what a programmatic caller + * passes, so both have to be accepted. + */ +const asNumber = (value: unknown, fallback: number) => { + const parsed = + typeof value === 'number' ? value : Number.parseInt(String(value ?? ''), 10); + return Number.isFinite(parsed) ? parsed : fallback; +}; + +/** + * Per-key calendar config may arrive as an object or as a JSON string. + * + * The string case is not a quirk to clean up: bundled defaults embed the config inside the i18next + * expression itself, so by the time it reaches a formatter it is text. + */ +const parseCalendarFormats = ( + value: TimestampFormatterOptions['calendarFormats'], + translate: LooseTranslateFunction, +): Record | undefined => { + if (!value) return undefined; + if (typeof value !== 'string') return value; + try { + return JSON.parse(value) as Record; + } catch { + translate( + asDynamicKey('__invalidCalendarFormats'), + `StreamI18n: calendarFormats is not valid JSON, ignoring it: ${value}`, + ); + return undefined; + } +}; + +/** + * "Today" / "Yesterday" / "3d ago" / "2w ago", falling back to a short date. + * + * Every word goes through `t()`. The React Native SDK shipped this as a standalone formatter with the + * English baked in, which no dictionary could translate — and because the wording lived in a formatter + * body rather than a catalog value, the codegen's English-prose guard never saw it either. + */ +const relativeCompactDateString = ({ + maxDays, + maxWeeks, + tDateTimeParser, + timestamp, + translate, +}: { + maxDays: number; + maxWeeks: number; + tDateTimeParser: TDateTimeParser; + timestamp: string | Date; + translate: LooseTranslateFunction; +}): string | null => { + const parsed = tDateTimeParser(timestamp as string); + if (!isDayOrMoment(parsed)) return null; + + const now = tDateTimeParser(new Date()); + if (!isDayOrMoment(now)) return null; + + const daysAgo = now.startOf('day').diff(parsed.startOf('day'), 'day'); + + if (daysAgo <= 0) return translate('relativeTime.today', 'Today'); + if (daysAgo === 1) return translate('relativeTime.yesterday', 'Yesterday'); + if (daysAgo <= maxDays) { + return translate('relativeTime.daysAgo', '{{ count }}d ago', { count: daysAgo }); + } + + const weeksAgo = Math.floor(daysAgo / 7); + if (weeksAgo <= maxWeeks) { + return translate('relativeTime.weeksAgo', '{{ count }}w ago', { count: weeksAgo }); + } + + return parsed.format('DD/MM/YY'); +}; + +const timestampFormatter: FormatterFactory = + ({ tDateTimeParser, translate }: FormatterContext) => + (value, _lng, options) => { + const { + calendar, + calendarFormats, + format, + relativeCompact, + relativeCompactMaxDays, + relativeCompactMaxWeeks, + } = options as TimestampFormatterOptions; + + if (value === null || value === undefined) return ''; + + if (relativeCompact) { + const relative = relativeCompactDateString({ + maxDays: asNumber(relativeCompactMaxDays, DEFAULT_RELATIVE_COMPACT_MAX_DAYS), + maxWeeks: asNumber(relativeCompactMaxWeeks, DEFAULT_RELATIVE_COMPACT_MAX_WEEKS), + tDateTimeParser, + timestamp: value, + translate, + }); + if (relative !== null) return relative; + } + + const parsed = tDateTimeParser(value as string); + + if (isDayOrMoment(parsed)) { + if (calendar && typeof parsed.calendar === 'function') { + return parsed.calendar( + undefined, + parseCalendarFormats(calendarFormats, translate), + ); + } + return parsed.format(format); + } + if (isDate(parsed)) return parsed.toDateString(); + if (isNumberOrString(parsed)) return String(parsed); + return ''; + }; + +/** + * Renders a length of time, e.g. `600000` -> "10 minutes". + * + * Goes through the date library's `.duration()` rather than parsing the number as a timestamp — which + * would read 600000 as "10 minutes past the epoch" and render "57 years ago". + */ +const durationFormatter: FormatterFactory = + ({ dateTimeParser }: FormatterContext) => + (value, _lng, options) => { + const { format, withSuffix } = options as DurationFormatterOptions; + if (typeof dateTimeParser.duration !== 'function') return String(value); + + const duration = dateTimeParser.duration(value as number); + // Only dayjs's duration plugin has `.format`; moment durations humanize only. + if (format && typeof duration.format === 'function') return duration.format(format); + return duration.humanize(Boolean(withSuffix)); + }; + +const fromNowFormatter: FormatterFactory = + ({ tDateTimeParser }: FormatterContext) => + (value, _lng, options) => { + if (value === null || value === undefined) return ''; + const parsed = tDateTimeParser(value as string); + if (!isDayOrMoment(parsed) || typeof parsed.fromNow !== 'function') return ''; + return parsed.fromNow( + Boolean((options as { withoutSuffix?: boolean }).withoutSuffix), + ); + }; + +/** + * The formatters registered with i18next by default. + * + * `relativeCompactDateFormatter` is an alias rather than its own implementation — see the deprecation + * note on {@link PredefinedFormatters}. + */ +export const predefinedFormatters: PredefinedFormatters = { + durationFormatter, + fromNowFormatter, + relativeCompactDateFormatter: (context) => (value, lng, options) => + timestampFormatter(context)(value, lng, { ...options, relativeCompact: true }), + timestampFormatter, +}; + +/* ------------------------------------------------------------------------------------------------ + * getDateString + * ---------------------------------------------------------------------------------------------- */ + +export type GetDateStringParams = TimestampFormatterOptions & { + /** The timestamp to render. */ + messageCreatedAt?: string | Date; + /** An integrator-supplied override, which wins over everything else. */ + formatDate?: (date: Date) => string; + /** The key carrying a formatter expression for this timestamp, if there is one. */ + timestampTranslationKey?: string; + t?: LooseTranslateFunction; + tDateTimeParser?: TDateTimeParser; +}; + +/** + * Resolves a timestamp to a display string. + * + * Resolution order, and why: an integrator's `formatDate` wins outright; then the translation key, so + * a language can restyle the timestamp without touching component props; then the parser. Returns + * `null` rather than a placeholder when there is nothing sensible to render, so callers can omit the + * element entirely. + */ +export const getDateString = ({ + calendar, + calendarFormats, + format, + formatDate, + messageCreatedAt, + relativeCompact, + relativeCompactMaxDays, + relativeCompactMaxWeeks, + t, + tDateTimeParser, + timestampTranslationKey, +}: GetDateStringParams): string | number | null => { + if ( + !messageCreatedAt || + (typeof messageCreatedAt === 'string' && !Date.parse(messageCreatedAt)) + ) { + return null; + } + + if (formatDate) return formatDate(new Date(messageCreatedAt)); + + if (t && timestampTranslationKey) { + const translated = t(asDynamicKey(timestampTranslationKey), { + calendar, + calendarFormats, + format, + relativeCompact, + relativeCompactMaxDays, + relativeCompactMaxWeeks, + timestamp: messageCreatedAt, + }); + // i18next echoes the key back when nothing resolved it, which is how a miss is detected. + if (translated !== timestampTranslationKey) return translated; + } + + if (!tDateTimeParser) return null; + + const parsed = tDateTimeParser(messageCreatedAt); + + if (isDayOrMoment(parsed)) { + if (calendar && typeof parsed.calendar === 'function') { + return parsed.calendar( + undefined, + typeof calendarFormats === 'string' ? undefined : calendarFormats, + ); + } + return parsed.format(format); + } + if (isDate(parsed)) return parsed.toDateString(); + if (isNumberOrString(parsed)) return parsed; + return null; +}; + +/** + * The same resolution as {@link getDateString}, but always spelling the date out in full. + * + * A screen reader announcing "14:32" with no date is ambiguous, so the a11y string ignores the compact + * and calendar options a visual timestamp uses. + */ +export const getDateStringForA11y = ({ + messageCreatedAt, + t, + tDateTimeParser, + timestampTranslationKey, +}: Pick< + GetDateStringParams, + 'messageCreatedAt' | 't' | 'tDateTimeParser' | 'timestampTranslationKey' +>): string | number | null => + getDateString({ + calendar: false, + format: 'LLLL', + messageCreatedAt, + t, + tDateTimeParser, + timestampTranslationKey, + }); diff --git a/src/i18n/index.ts b/src/i18n/index.ts new file mode 100644 index 0000000000..8b7b80079a --- /dev/null +++ b/src/i18n/index.ts @@ -0,0 +1,12 @@ +/** + * The shared i18n layer, published as `stream-chat/i18n`. + * + * Deliberately **not** re-exported from `stream-chat`'s root barrel: this module pulls in `i18next` and + * `dayjs`, and keeping them out of the root bundle is the entire reason it is a separate entry point. + * `scripts/bundle.mjs` asserts that boundary at build time. + */ +export * from './dayjs'; +export * from './formatters'; +export * from './StreamI18n'; +export * from './translator'; +export * from './types'; diff --git a/src/i18n/translator.ts b/src/i18n/translator.ts new file mode 100644 index 0000000000..1cbad8d9f8 --- /dev/null +++ b/src/i18n/translator.ts @@ -0,0 +1,76 @@ +import type { + AnyTranslationCatalog, + DynamicTranslationKey, + StreamTFunctionFor, +} from './types'; + +/** + * Brands a runtime-resolved string as a translation key. + * + * The brand on {@link DynamicTranslationKey} is required, so this is the only way to pass a key the + * compiler cannot see — which keeps every such escape deliberate and greppable. + */ +export const asDynamicKey = (key: string): DynamicTranslationKey => + key as DynamicTranslationKey; + +/** Matches `{{ name }}` / `{{name}}`, allowing dots so `{{ user.name }}` interpolates too. */ +const INTERPOLATION_PATTERN = /\{\{\s*([\w.]+)\s*\}\}/g; + +const interpolate = (copy: string, values: Record) => + copy.replace(INTERPOLATION_PATTERN, (whole, name: string) => + values[name] === undefined ? whole : String(values[name]), + ); + +/** + * The `t` in force before i18next has initialized, and the default for a UI SDK's translation context. + * + * It has to honour the inline `defaultValue`: every prose call site passes its English copy as the + * second argument, so echoing the key back would flash raw dotted paths on the first frame — and would + * render them permanently anywhere a context default is in play (a component used outside the SDK's + * provider). + * + * A ~30-line stand-in for i18next, deliberately: pulling i18next in just to render the first frame + * would defeat keeping it out of the default path. + */ +export const createDefaultTranslatorFunction = < + C extends AnyTranslationCatalog = AnyTranslationCatalog, + Bundled extends string = never, +>(): StreamTFunctionFor => + (( + key: string, + defaultValueOrOptions?: string | Record, + maybeOptions?: Record, + ) => { + // Prose: the copy arrives positionally. + if (typeof defaultValueOrOptions === 'string') { + return maybeOptions + ? interpolate(defaultValueOrOptions, maybeOptions) + : defaultValueOrOptions; + } + + const options = defaultValueOrOptions ?? maybeOptions; + if (!options) return key; + + // Plural call sites pass their copy as `defaultValue_one` / `defaultValue_other` inside the + // options object, so a bare `defaultValue` check would still leak the raw key for them. English + // only distinguishes one from other; a registered language's own categories are irrelevant here, + // since this function is only ever in play before i18next has initialized. + const resolved = + (options.count === 1 ? options.defaultValue_one : options.defaultValue_other) ?? + options.defaultValue; + + return typeof resolved === 'string' ? interpolate(resolved, options) : key; + }) as StreamTFunctionFor; + +/** + * Wraps an integrator's `parseMissingKeyHandler` so it only sees genuinely missing translations. + * + * i18next counts every prose key as missing — they render from the inline `defaultValue`, not from a + * resource bundle — and lets the handler's return value replace the rendered string. An unguarded + * handler therefore blanks out most of the UI. A resolved default arrives as the second argument, + * which is how the two cases are told apart. + */ +export const guardMissingKeyHandler = + (handler: (key: string, defaultValue?: string) => string) => + (key: string, defaultValue?: string) => + typeof defaultValue === 'string' ? defaultValue : handler(key, defaultValue); diff --git a/src/i18n/types.ts b/src/i18n/types.ts new file mode 100644 index 0000000000..a8a2cdfde1 --- /dev/null +++ b/src/i18n/types.ts @@ -0,0 +1,315 @@ +import type { TOptions } from 'i18next'; + +/* ------------------------------------------------------------------------------------------------ + * Catalog-generic key machinery + * + * Core ships no translation catalog. Each UI SDK generates its own `keys.ts` from its `t()` call + * sites, then instantiates these helpers against it once. Everything here is type-only and erased at + * runtime. + * + * These are generic rather than driven by module augmentation on purpose: two catalogs must be able + * to coexist in one TypeScript program (a monorepo typechecking both UI SDKs in one pass), and a + * single augmented interface can only hold one. Augmentation is also ambient, which would leak an + * SDK's key union into an integrator's unrelated `t()` calls — the same objection that kept this out + * of i18next's `CustomTypeOptions`. + * ---------------------------------------------------------------------------------------------- */ + +/** The shape a generated `keys.ts` catalog satisfies: key -> its English copy. */ +export type AnyTranslationCatalog = Record; + +type Whitespace = ' ' | '\n' | '\t'; + +type Trim = S extends `${Whitespace}${infer R}` + ? Trim + : S extends `${infer R}${Whitespace}` + ? Trim + : S; + +/** `{{ value, formatter }}` and `{{ value | formatter(...) }}` — the name is the leading part. */ +type VarName = Trim< + S extends `${infer Name},${string}` + ? Name + : S extends `${infer Name}|${string}` + ? Name + : S +>; + +/** + * The interpolation variables a copy string requires. + * + * i18next ships `InterpolationMap`, but it does not trim the placeholder, so `{{ setting }}` yields a + * property literally named `" setting "`. SDK copy uses spaced placeholders throughout, so the + * placeholders are parsed here instead. + */ +type InterpolationVars = + S extends `${string}{{${infer V}}}${infer Rest}` + ? (VarName extends '' ? never : VarName) | InterpolationVars + : never; + +type InterpolationArgs = [InterpolationVars] extends [never] + ? Record + : { [K in InterpolationVars]: number | string }; + +/** Every plural category `Intl.PluralRules` can select. */ +export type PluralSuffix = 'zero' | 'one' | 'two' | 'few' | 'many' | 'other'; + +export type CatalogKeyOf = keyof C & string; + +/** + * Keys whose catalog entries are plural forms (`_one` / `_other`). + * + * The `infer K` indirection is what makes the inner conditional distribute over the key union; a bare + * `Extract<..., \`${string}_other\`>` would not. + */ +export type PluralTranslationKeyOf = + CatalogKeyOf extends infer K + ? K extends `${infer Base}_other` + ? Base + : never + : never; + +/** + * Every key the SDK's `t` accepts: the singular entries plus the bare handle for each plural. + * + * This is the *call-site* key set. It is deliberately **not** the right type for a dictionary: a + * plural lives in the catalog as `_one` / `_other` while `t()` takes the bare ``, so + * keying a dictionary on this rejects the very entries a translator has to supply. Use + * {@link TranslationDictionaryOf} for that. + */ +export type TranslationKeyOf = + | Exclude, `${string}_${PluralSuffix}`> + | PluralTranslationKeyOf; + +/** + * A translation dictionary for `registerTranslation()` / `translationsForLanguage`. + * + * Restricted to the SDK's own keys, so a typo or a leftover key from a previous major is a compile + * error rather than an override that silently never applies. Keyed on the catalog rather than on + * {@link TranslationKeyOf} so the `_one` / `_other` plural entries are accepted. + * + * SDK copy only needs `_one` / `_other`, but a plural key accepts every category `Intl.PluralRules` + * can select, so Arabic, Hebrew or Russian can supply `_few`, `_many` and `_zero` and stay checked. A + * plural suffix on a key that is not plural is rejected. + */ +export type TranslationDictionaryOf = Partial< + Record, string> +> & + Partial}_${PluralSuffix}`, string>>; + +/** + * A dictionary that also admits keys the SDK does not define, so one instance can carry an + * application's own copy alongside the SDK's. + * + * Nothing catches a mistyped or stale SDK key here — it compiles, then never matches at runtime. + * {@link TranslationDictionaryOf} already covers the extra plural categories, so a language needing + * `_few` / `_many` / `_zero` does not have to give up key checking. + */ +export type LooseTranslationDictionaryOf = Partial< + Record, string> +> & + Record; + +/** The English copy for a key, used to infer that key's interpolation variables. */ +export type CopyFor = + K extends CatalogKeyOf + ? C[K] + : `${K}_other` extends CatalogKeyOf + ? C[`${K}_other` & CatalogKeyOf] + : string; + +/** + * Formatter expression keys, matched by prefix so overload resolution stays cheap. + * + * An SDK adds its own bundled prose keys through the `Bundled` parameter rather than widening this. + */ +export type FormatterExpressionKey = `timestamp.${string}` | `duration.${string}`; + +/** + * A translation key resolved from a runtime value rather than written literally. + * + * The brand is *required*, so a plain `string` is not assignable and the escape hatch has to be taken + * deliberately via `asDynamicKey()` — which also makes every such site greppable. + * + * @example t(asDynamicKey(command.description)) + */ +export type DynamicTranslationKey = string & { + readonly __dynamicTranslationKey: true; +}; + +/** Keys whose value is English copy, passed inline as the `defaultValue`. */ +export type ProseKeyOf< + C extends AnyTranslationCatalog, + Bundled extends string = never, +> = Exclude< + TranslationKeyOf, + FormatterExpressionKey | Bundled | PluralTranslationKeyOf +>; + +/** + * The SDK's translation function, instantiated once per catalog. + * + * Every prose call site passes its English copy inline as i18next's `defaultValue`, so the key stays + * stable across copy edits and a key missing from a custom dictionary still renders English. + * Interpolation variables are inferred from that copy, and plural keys require `count`. + * + * `Bundled` is the SDK's own set of keys resolved from bundled defaults rather than from an inline + * default — screen-reader labels and lookup-table entries that are ordinary prose but reach `t()` as + * runtime values, leaving nowhere to write a default. **It must default to `never`:** defaulting to + * `string` would collapse the prose overload and silently disable all key checking. + */ +export type StreamTFunctionFor< + C extends AnyTranslationCatalog, + Bundled extends string = never, +> = { + /** Plural key: `count` selects between the `_one` / `_other` copy. */ + >( + key: K, + options: TOptions & { count: number } & InterpolationArgs>, + ): string; + /** Bundled or formatter key: resolves from bundled defaults, so no inline default. */ + ( + key: FormatterExpressionKey | Bundled, + options?: TOptions & Record, + ): string; + /** + * Prose key with its English copy inline. + * + * Neither `defaultValue` nor `options` is tied to the key's exact copy. That would mean + * materialising the union of every copy string in the catalog, and the two checks it would buy are + * covered elsewhere: the default matching the generated catalog is enforced by the codegen drift + * gate, and a missing interpolation variable surfaces as a literal `{{ placeholder }}` in the + * rendered output, which the render tests assert on. + * + * Plural keys keep precise typing (see the first overload) because that union is small. + */ + >( + key: K, + defaultValue: string, + options?: TOptions & Record, + ): string; + /** Escape hatch for keys only known at runtime. */ + ( + key: DynamicTranslationKey, + defaultValueOrOptions?: string | (TOptions & Record), + options?: TOptions & Record, + ): string; +}; + +/* ------------------------------------------------------------------------------------------------ + * Date/time + * ---------------------------------------------------------------------------------------------- */ + +/** + * The dayjs/moment surface the formatters actually call. + * + * Structural on purpose: naming `moment-timezone` here would leak a type-only dependency into the + * published `.d.ts`, so consumers without it installed got unresolved types. Bring-your-own-Moment + * still works — it satisfies this shape. + */ +export type DateTimeLike = { + format: (template?: string) => string; + calendar?: (referenceTime?: unknown, formats?: Record) => string; + fromNow?: (withoutSuffix?: boolean) => string; + diff: (other: unknown, unit?: string) => number; + startOf: (unit: string) => DateTimeLike; + valueOf: () => number; +}; + +export type TDateTimeParserInput = string | number | Date; + +export type TDateTimeParserOutput = string | number | Date | DateTimeLike; + +export type TDateTimeParser = (input?: TDateTimeParserInput) => TDateTimeParserOutput; + +/** + * A duration, as returned by dayjs's or moment's `.duration()`. + * + * `format` is optional because only dayjs's duration plugin provides it; moment durations humanize + * only. + */ +export type DurationLike = { + humanize: (withSuffix?: boolean) => string; + format?: (template?: string) => string; +}; + +/** + * A date/time library *module*, as accepted by `StreamI18nOptions.DateTimeParser`. + * + * Structural for the same reason as {@link DateTimeLike}: this admits `dayjs` and `moment` without + * naming either. It is the module rather than a parse function because `durationFormatter` needs + * `.duration()`, which lives on the module. + */ +export type DateTimeParserModule = ((input?: TDateTimeParserInput) => DateTimeLike) & { + duration?: (input: number | string) => DurationLike; + extend?: (plugin: unknown, option?: unknown) => unknown; + tz?: unknown; + locale?: (...args: unknown[]) => unknown; +}; + +/* ------------------------------------------------------------------------------------------------ + * Formatters + * ---------------------------------------------------------------------------------------------- */ + +/** + * A translate function loose enough for formatter internals. + * + * Formatters resolve keys they are handed at runtime (and their own `relativeTime.*` copy), so they + * cannot be typed against a specific catalog. + */ +export type LooseTranslateFunction = ( + key: string, + defaultValueOrOptions?: string | Record, + options?: Record, +) => string; + +/** + * What a formatter is given about the instance it belongs to. + * + * Structural rather than the concrete class, so `types.ts` does not have to import `StreamI18n.ts` + * and formatter factories stay testable in isolation. + */ +export type FormatterContext = { + currentLanguage: string; + /** The date library module. `durationFormatter` needs `.duration()`, which lives here. */ + dateTimeParser: DateTimeParserModule; + /** Parses a single timestamp, with the active locale and timezone already applied. */ + tDateTimeParser: TDateTimeParser; + translate: LooseTranslateFunction; + timezone?: string; +}; + +export type FormatterFactory = ( + context: FormatterContext, +) => (value: V, lng: string | undefined, options: Record) => string; + +export type TimestampFormatterOptions = { + /** Render relative to today ("Today at 14:32") via the dayjs calendar plugin. */ + calendar?: boolean | null; + /** Per-key calendar config. Replaces the locale's calendar wholesale for this key. */ + calendarFormats?: Record | string; + /** A dayjs/moment format template, e.g. `LT` or `dddd L`. */ + format?: string; + /** Render as "Today" / "Yesterday" / "3d ago" / "2w ago", then fall back to a date. */ + relativeCompact?: boolean; + relativeCompactMaxDays?: number; + relativeCompactMaxWeeks?: number; +}; + +export type DurationFormatterOptions = { + format?: string; + withSuffix?: boolean; +}; + +export type PredefinedFormatters = { + durationFormatter: FormatterFactory; + fromNowFormatter: FormatterFactory; + /** + * @deprecated Use `timestampFormatter` with `relativeCompact: true`, which routes the wording + * through `t()` and is therefore translatable. Kept as an alias so existing `timestamp.*` bundled + * defaults keep working. + */ + relativeCompactDateFormatter: FormatterFactory; + timestampFormatter: FormatterFactory; +}; + +export type CustomFormatters = Record>; diff --git a/test/unit/i18n/StreamI18n.test.ts b/test/unit/i18n/StreamI18n.test.ts new file mode 100644 index 0000000000..a81f40a70b --- /dev/null +++ b/test/unit/i18n/StreamI18n.test.ts @@ -0,0 +1,245 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { StreamI18n, type StreamI18nState } from '../../../src/i18n'; +import { + fixtureRuntimeDefaults, + type FixtureBundledKey, + type FixtureCatalog, +} from './fixtures'; + +const setup = (options: Record = {}) => + new StreamI18n({ + logger: () => {}, + runtimeDefaults: fixtureRuntimeDefaults, + ...options, + }); + +describe('StreamI18n', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-03-13T14:32:00.000Z')); + }); + + describe('formatters', () => { + it('renders a timestamp through its format template', async () => { + const { t } = await setup().init(); + expect( + t('timestamp.MessageTimestamp', { timestamp: '2026-03-13T14:32:00.000Z' }), + ).toBe('2:32 PM'); + }); + + it('renders a calendar timestamp', async () => { + const { t } = await setup().init(); + expect( + t('timestamp.DateSeparator', { timestamp: '2026-03-13T09:00:00.000Z' }), + ).toBe('Today at 9:00 AM'); + }); + + /** + * Regression: a duration has to go through the date library's `.duration()`. Parsing the number as + * a timestamp instead reads 600000 as "ten minutes past the epoch" and renders "57 years ago". + */ + it('renders a duration as a length of time, not a date', async () => { + const { t } = await setup().init(); + expect(t('duration.messageReminder', { milliseconds: 1000 * 60 * 10 })).toBe( + 'in 10 minutes', + ); + }); + + it('leaves no uninterpolated placeholders in any bundled default', async () => { + const { t } = await setup().init(); + for (const key of Object.keys(fixtureRuntimeDefaults)) { + const rendered = t(key as never, { + milliseconds: 1000, + timestamp: '2026-03-13T14:32:00.000Z', + }); + expect(rendered, `${key} left a placeholder`).not.toContain('{{'); + } + }); + + it('accepts a custom formatter and an override of a predefined one', async () => { + const i18n = setup({ + formatters: { + shout: () => (value: unknown) => String(value).toUpperCase(), + }, + }); + i18n.registerTranslation('en', { 'common.cancel.label': '{{ word | shout }}' }); + const { t } = await i18n.init(); + expect(t('common.cancel.label', 'Cancel', { word: 'cancel' })).toBe('CANCEL'); + }); + }); + + describe('plurals and interpolation', () => { + it('selects a plural category and interpolates', async () => { + const i18n = setup(); + i18n.registerTranslation('en', { + 'channel.memberCount.title_one': '{{ count }} member', + 'channel.memberCount.title_other': '{{ count }} members', + }); + const { t } = await i18n.init(); + + expect(t('channel.memberCount.title', { count: 1 })).toBe('1 member'); + expect(t('channel.memberCount.title', { count: 5 })).toBe('5 members'); + }); + + it('interpolates a prose default', async () => { + const { t } = await setup().init(); + expect(t('common.greeting.text', 'Hello {{ name }}', { name: 'Ada' })).toBe( + 'Hello Ada', + ); + }); + }); + + describe('state store', () => { + it('publishes the live translator, so a late subscriber sees it immediately', async () => { + const i18n = setup(); + await i18n.init(); + + const seen: StreamI18nState[] = []; + // `subscribe` fires synchronously with the current value — that is what removes the + // callback-registration ordering problem the listener-based API had. + const unsubscribe = i18n.state.subscribe((state) => seen.push(state)); + + expect(seen).toHaveLength(1); + expect(seen[0].initialized).toBe(true); + expect(seen[0].language).toBe('en'); + unsubscribe(); + }); + + it('publishes a language change', async () => { + const i18n = setup(); + i18n.registerTranslation('de', { 'common.cancel.label': 'Abbrechen' }); + await i18n.init(); + + const languages: string[] = []; + const unsubscribe = i18n.state.subscribeWithSelector( + (state) => ({ language: state.language }), + ({ language }) => languages.push(language), + ); + + await i18n.setLanguage('de'); + + expect(languages).toEqual(['en', 'de']); + expect(i18n.t('common.cancel.label', 'Cancel')).toBe('Abbrechen'); + unsubscribe(); + }); + + it('exposes t, language and initialized as state-backed getters', async () => { + const i18n = setup(); + expect(i18n.initialized).toBe(false); + await i18n.init(); + expect(i18n.initialized).toBe(true); + expect(i18n.currentLanguage).toBe('en'); + expect(typeof i18n.t).toBe('function'); + }); + }); + + describe('init', () => { + it('is idempotent and shares one promise across concurrent callers', async () => { + const i18n = setup(); + const spy = vi.spyOn(i18n.i18nInstance, 'init'); + + const [a, b] = await Promise.all([i18n.init(), i18n.init()]); + await i18n.init(); + + // Two independent consumers (a chat root and an overlay host) both call this. + expect(spy).toHaveBeenCalledTimes(1); + expect(a).toBe(b); + }); + + it('resolves to the same object the state store holds', async () => { + const i18n = setup(); + const state = await i18n.init(); + expect(state).toEqual(i18n.state.getLatestValue()); + }); + }); + + describe('overrideTFunction', () => { + it('applies after init', async () => { + const i18n = setup(); + await i18n.init(); + i18n.overrideTFunction((() => 'OVERRIDDEN') as never); + expect(i18n.t('common.cancel.label', 'Cancel')).toBe('OVERRIDDEN'); + }); + + /** The queued-override race in the listener-based design: init must not undo a pre-init override. */ + it('survives a later init', async () => { + const i18n = setup(); + i18n.overrideTFunction((() => 'OVERRIDDEN') as never); + await i18n.init(); + expect(i18n.t('common.cancel.label', 'Cancel')).toBe('OVERRIDDEN'); + }); + + it('survives a later setLanguage', async () => { + const i18n = setup(); + i18n.registerTranslation('de', { 'common.cancel.label': 'Abbrechen' }); + await i18n.init(); + i18n.overrideTFunction((() => 'OVERRIDDEN') as never); + await i18n.setLanguage('de'); + expect(i18n.t('common.cancel.label', 'Cancel')).toBe('OVERRIDDEN'); + }); + }); + + describe('setLanguage', () => { + it('returns nothing, so no caller can cache a translator that goes stale', async () => { + const i18n = setup(); + await i18n.init(); + await expect(i18n.setLanguage('de')).resolves.toBeUndefined(); + }); + + it('takes effect before init and is applied at init', async () => { + const i18n = setup(); + i18n.registerTranslation('de', { 'common.cancel.label': 'Abbrechen' }); + await i18n.setLanguage('de'); + const { t } = await i18n.init(); + expect(i18n.currentLanguage).toBe('de'); + expect(t('common.cancel.label', 'Cancel')).toBe('Abbrechen'); + }); + }); + + describe('timezone', () => { + it('renders in the configured zone', async () => { + const { t } = await setup({ timezone: 'Asia/Tokyo' }).init(); + // 14:32 UTC is 23:32 in Tokyo. + expect( + t('timestamp.MessageTimestamp', { timestamp: '2026-03-13T14:32:00.000Z' }), + ).toBe('11:32 PM'); + }); + }); + + describe('disableDateTimeTranslations', () => { + it('keeps dates in English for a registered language', async () => { + const i18n = setup({ disableDateTimeTranslations: true, language: 'de' }); + i18n.registerTranslation('de', { 'common.cancel.label': 'Abbrechen' }); + const { t } = await i18n.init(); + expect( + t('timestamp.DateSeparator', { timestamp: '2026-03-13T09:00:00.000Z' }), + ).toBe('Today at 9:00 AM'); + }); + }); + + describe('logging', () => { + /** `JSON.stringify(error)` renders an Error as `{}`, which is how these used to be logged. */ + it('logs an Error by message rather than as an empty object', async () => { + const logger = vi.fn(); + const i18n = setup({ logger }); + await i18n.init(); + vi.spyOn(i18n.i18nInstance, 'changeLanguage').mockRejectedValue(new Error('boom')); + + await i18n.setLanguage('de'); + + expect(logger).toHaveBeenCalledWith(expect.stringContaining('boom')); + expect(logger).not.toHaveBeenCalledWith(expect.stringContaining('{}')); + }); + }); + + describe('getAvailableLanguages', () => { + it('includes languages carrying only the bundled defaults', async () => { + const i18n = setup({ language: 'de' }); + await i18n.init(); + expect(i18n.getAvailableLanguages()).toContain('de'); + // ...while registeredLanguages stays narrower, which is what makes the G3 warning possible. + expect(i18n.registeredLanguages.has('de')).toBe(false); + }); + }); +}); diff --git a/test/unit/i18n/StreamI18nGuarantees.test.ts b/test/unit/i18n/StreamI18nGuarantees.test.ts new file mode 100644 index 0000000000..3465915701 --- /dev/null +++ b/test/unit/i18n/StreamI18nGuarantees.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { asDynamicKey, StreamI18n } from '../../../src/i18n'; +import { + FORMATTER_KEY, + fixtureRuntimeDefaults, + type FixtureBundledKey, + type FixtureCatalog, +} from './fixtures'; + +/** + * The three behavioural guarantees the shared i18n architecture has to hold. + * + * Ported from `stream-chat-react-native`'s `Streami18nGuarantees.test.ts`, where each one was written + * against a real bug found reviewing the web implementation. They live here now because they describe + * `StreamI18n` behaviour rather than anything React- or RN-specific, which means a third SDK cannot + * regress them and neither UI SDK has to keep its own copy. + * + * G1 — every language is layered over the SDK's bundled defaults, however it was selected. + * G2 — a partial dictionary is safe: unsupplied keys render English, never a raw dotted path. + * G3 — selecting an unregistered language warns and continues; it must not silently reset to `en`. + */ + +type Dictionary = Partial>; + +/** Core has no catalog, so every instance is handed the fixture's bundled defaults. */ +const setup = (options: Record = {}) => + new StreamI18n({ + logger: () => {}, + runtimeDefaults: fixtureRuntimeDefaults, + ...options, + }); + +describe('G1 — bundled defaults are layered under every language', () => { + it('applies to a language selected via the `language` option', async () => { + const i18n = setup({ language: 'de' }); + const { t } = await i18n.getTranslators(); + + expect(t(FORMATTER_KEY)).not.toBe(FORMATTER_KEY); + }); + + it('applies to a language added with registerTranslation', async () => { + const i18n = setup(); + i18n.registerTranslation('de', { + 'common.cancel.label': 'Abbrechen', + } satisfies Dictionary); + await i18n.setLanguage('de'); + const { t } = await i18n.getTranslators(); + + expect(t(FORMATTER_KEY)).not.toBe(FORMATTER_KEY); + }); + + it('applies to `en` when no dictionary is supplied at all', async () => { + const i18n = setup(); + const { t } = await i18n.getTranslators(); + + expect(t(FORMATTER_KEY)).not.toBe(FORMATTER_KEY); + }); + + it('survives registerTranslation for a language that already had one', async () => { + const i18n = setup({ language: 'de' }); + i18n.registerTranslation('de', { + 'common.cancel.label': 'Abbrechen', + } satisfies Dictionary); + i18n.registerTranslation('de', { 'common.loading.text': 'Lädt...' }); + const { t } = await i18n.getTranslators(); + + // Registering twice must accumulate, and must not knock out the bundled formatter keys. + expect(t(FORMATTER_KEY)).not.toBe(FORMATTER_KEY); + expect(t('common.cancel.label', 'Cancel')).toBe('Abbrechen'); + expect(t('common.loading.text', 'Loading...')).toBe('Lädt...'); + }); + + it('never lets an integrator dictionary shadow a bundled key by omission', async () => { + const i18n = setup({ + language: 'de', + translationsForLanguage: { 'common.cancel.label': 'Abbrechen' }, + }); + const { t } = await i18n.getTranslators(); + + expect(t(FORMATTER_KEY)).toBe(fixtureRuntimeDefaults[FORMATTER_KEY]); + }); +}); + +describe('G2 — a partial dictionary renders English, not a dotted path', () => { + it('renders the inline default for a key the dictionary does not supply', async () => { + const i18n = setup({ language: 'de' }); + i18n.registerTranslation('de', { + 'common.cancel.label': 'Abbrechen', + } satisfies Dictionary); + const { t } = await i18n.getTranslators(); + + expect(t('common.loading.text', 'Loading...')).toBe('Loading...'); + }); + + it('renders the supplied translation when the dictionary does supply it', async () => { + const i18n = setup({ language: 'de' }); + i18n.registerTranslation('de', { + 'common.cancel.label': 'Abbrechen', + } satisfies Dictionary); + const { t } = await i18n.getTranslators(); + + expect(t('common.cancel.label', 'Cancel')).toBe('Abbrechen'); + }); + + it('never renders a raw dotted key for a prose key', async () => { + const i18n = setup({ language: 'de' }); + const { t } = await i18n.getTranslators(); + + const rendered = t('common.loading.text', 'Loading...'); + expect(rendered).not.toMatch(/^[a-z][a-zA-Z]*(\.[a-zA-Z]+)+$/); + }); + + it('does not let an integrator parseMissingKeyHandler blank out prose keys', async () => { + // Every prose key looks "missing" to i18next — it resolves from the inline default, not from a + // resource bundle — and the handler's return value replaces the rendered string. An unguarded + // handler therefore blanks out most of the UI. + const i18n = setup({ i18nextConfigOverrides: { parseMissingKeyHandler: () => '' } }); + const { t } = await i18n.getTranslators(); + + expect(t('common.loading.text', 'Loading...')).toBe('Loading...'); + }); + + it('still reports a genuinely missing key to an integrator handler', async () => { + const parseMissingKeyHandler = vi.fn(() => 'MISSING'); + const i18n = setup({ i18nextConfigOverrides: { parseMissingKeyHandler } }); + const { t } = await i18n.getTranslators(); + + // No inline default and not in runtimeDefaults — this one really is missing. + expect(t(asDynamicKey('nothing.declares.this'))).toBe('MISSING'); + expect(parseMissingKeyHandler).toHaveBeenCalled(); + }); +}); + +describe('G3 — an unregistered language warns and continues', () => { + it('does not silently reset the language to en', async () => { + const i18n = setup({ language: 'de' }); + await i18n.getTranslators(); + + expect(i18n.currentLanguage).toBe('de'); + }); + + it('warns that the language has no dictionary', async () => { + const logger = vi.fn(); + const i18n = setup({ language: 'de', logger }); + await i18n.getTranslators(); + + // Specifically the *translation* warning — not an unrelated dayjs "locale config for de does not + // exist" message, which would let this pass for the wrong reason. + expect(logger).toHaveBeenCalledWith(expect.stringContaining('registerTranslation')); + expect(logger).toHaveBeenCalledWith( + expect.stringMatching(/no translation dictionary is registered/i), + ); + }); + + it('keeps the language after setLanguage to an unregistered one', async () => { + const i18n = setup(); + await i18n.getTranslators(); + await i18n.setLanguage('de'); + + expect(i18n.currentLanguage).toBe('de'); + }); + + it('still renders English copy in the unregistered language', async () => { + const i18n = setup({ language: 'de' }); + const { t } = await i18n.getTranslators(); + + expect(t('common.loading.text', 'Loading...')).toBe('Loading...'); + }); +}); diff --git a/test/unit/i18n/fixtures.ts b/test/unit/i18n/fixtures.ts new file mode 100644 index 0000000000..4a99318828 --- /dev/null +++ b/test/unit/i18n/fixtures.ts @@ -0,0 +1,47 @@ +/** + * A synthetic translation catalog standing in for a UI SDK's generated `keys.ts`. + * + * Core ships no catalog of its own — each UI SDK generates one from its own `t()` call sites — so the + * behavioural suites here run against this fixture instead. That is deliberately better than testing + * through a real 400+ key catalog: every key *shape* the type layer and the runtime have to handle is + * present and named, so a shape that stops working fails a test rather than hiding among hundreds of + * structurally identical prose keys. + */ +export type FixtureCatalog = { + // plain prose + 'common.cancel.label': 'Cancel'; + 'common.loading.text': 'Loading...'; + // prose carrying interpolation + 'common.greeting.text': 'Hello {{ name }}'; + // a plural pair, single variable + 'channel.memberCount.title_one': '{{ count }} member'; + 'channel.memberCount.title_other': '{{ count }} members'; + // a plural pair with a second variable, to exercise multi-var inference + 'poll.voteCount.title_one': '{{ count }} vote in {{ pollName }}'; + 'poll.voteCount.title_other': '{{ count }} votes in {{ pollName }}'; + // formatter expressions — bundled, no inline default anywhere + 'timestamp.MessageTimestamp': '{{ timestamp | timestampFormatter(format: LT) }}'; + 'timestamp.DateSeparator': '{{ timestamp | timestampFormatter(calendar: true) }}'; + 'duration.messageReminder': '{{ milliseconds | durationFormatter(withSuffix: true) }}'; + // ordinary prose that nonetheless reaches t() as a runtime value, so it is bundled + 'a11y.close.label': 'Close'; +}; + +/** The SDK-bundled keys with no inline default at any call site. */ +export type FixtureBundledKey = 'a11y.close.label'; + +/** + * The only translation data a UI SDK ships: keys that cannot carry an inline `defaultValue`. + * + * If these are not layered under every language, a formatter key renders as its own dotted path and + * a timestamp renders as an unformatted ISO string — which is guarantee G1 below. + */ +export const fixtureRuntimeDefaults: Record = { + 'a11y.close.label': 'Close', + 'duration.messageReminder': '{{ milliseconds | durationFormatter(withSuffix: true) }}', + 'timestamp.DateSeparator': '{{ timestamp | timestampFormatter(calendar: true) }}', + 'timestamp.MessageTimestamp': '{{ timestamp | timestampFormatter(format: LT) }}', +}; + +/** A formatter key: bundled data, no inline default. Renders as the literal key if G1 is broken. */ +export const FORMATTER_KEY = 'timestamp.MessageTimestamp'; diff --git a/vite.config.ts b/vite.config.ts index 6c85b43f84..88352c2381 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,6 +4,10 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { + // Date/time formatting assertions (src/i18n) are timezone-sensitive. Without this they pass only + // on a machine that happens to be in UTC, which is what CI is -- so a local run would disagree + // with CI by exactly the host's offset. + env: { TZ: 'UTC' }, testTimeout: 20000, // not all errors have been handled so this is necessary (at least for the time being) dangerouslyIgnoreUnhandledErrors: true, diff --git a/yarn.lock b/yarn.lock index 8dd5698e91..0f0e257960 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2745,6 +2745,13 @@ __metadata: languageName: node linkType: hard +"dayjs@npm:^1.11.13": + version: 1.11.21 + resolution: "dayjs@npm:1.11.21" + checksum: 10c0/bd97dfdc4bfea3c66268635690313828b386faa040fbc1f829ff42a2bd748b72c9d9b3c8f9616ce9e61fcb78923f1461a462c969c54b1084458ae1b715898fb0 + languageName: node + linkType: hard + "debug@npm:4, debug@npm:^4.0.0, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.4, debug@npm:^4.4.1, debug@npm:^4.4.3": version: 4.4.3 resolution: "debug@npm:4.4.3" @@ -4180,6 +4187,18 @@ __metadata: languageName: node linkType: hard +"i18next@npm:^26.3.6": + version: 26.3.6 + resolution: "i18next@npm:26.3.6" + peerDependencies: + typescript: ^5 || ^6 || ^7 + peerDependenciesMeta: + typescript: + optional: true + checksum: 10c0/5920ac8fb6b647a2bdd439d121de04e5297246e52c4d95d13f5faae0824fd45b2faeb66038bcd61fc62362daa815f9c32fb992d6faa3787607d1dfe768e9a8b1 + languageName: node + linkType: hard + "iconv-lite@npm:^0.6.2": version: 0.6.3 resolution: "iconv-lite@npm:0.6.3" @@ -7286,6 +7305,7 @@ __metadata: axios: "npm:^1.19.0" concurrently: "npm:^9.2.1" conventional-changelog-conventionalcommits: "npm:^9.3.1" + dayjs: "npm:^1.11.13" dotenv: "npm:^17.4.2" esbuild: "npm:^0.28.2" eslint: "npm:^9.39.4" @@ -7294,6 +7314,7 @@ __metadata: eslint-plugin-unused-imports: "npm:^4.4.1" globals: "npm:^17.6.0" husky: "npm:^9.1.7" + i18next: "npm:^26.3.6" linkifyjs: "npm:^4.3.3" lint-staged: "npm:^17.0.5" prettier: "npm:^3.8.3" From a3ef68c52232e738431125dcf1a3cd30d9018e0b Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Mon, 17 Aug 2026 23:43:28 +0200 Subject: [PATCH 03/27] refactor(i18n): depend on i18next and dayjs directly instead of as optional 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. --- package.json | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/package.json b/package.json index a6ba920baa..0425c86ff8 100644 --- a/package.json +++ b/package.json @@ -67,19 +67,9 @@ "dependencies": { "@stream-io/logger": "^2.0.0", "axios": "^1.19.0", - "linkifyjs": "^4.3.3" - }, - "peerDependencies": { "dayjs": "^1.11.13", - "i18next": "^26.3.6" - }, - "peerDependenciesMeta": { - "dayjs": { - "optional": true - }, - "i18next": { - "optional": true - } + "i18next": "^26.3.6", + "linkifyjs": "^4.3.3" }, "devDependencies": { "@commitlint/cli": "^21.0.1", @@ -92,7 +82,6 @@ "@vitest/coverage-v8": "^4.1.10", "concurrently": "^9.2.1", "conventional-changelog-conventionalcommits": "^9.3.1", - "dayjs": "^1.11.13", "dotenv": "^17.4.2", "esbuild": "^0.28.2", "eslint": "^9.39.4", @@ -101,7 +90,6 @@ "eslint-plugin-unused-imports": "^4.4.1", "globals": "^17.6.0", "husky": "^9.1.7", - "i18next": "^26.3.6", "lint-staged": "^17.0.5", "prettier": "^3.8.3", "semantic-release": "^25.0.9", From ded549cabb82e198f974b78bbd42820e4334d9f4 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Mon, 17 Aug 2026 23:57:09 +0200 Subject: [PATCH 04/27] feat(i18n): share language names, notification keys and TranslationBuilder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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` 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. --- src/i18n/StreamI18n.ts | 23 +++ src/i18n/TranslationBuilder.ts | 165 ++++++++++++++++++++++ src/i18n/index.ts | 3 + src/i18n/languageNames.ts | 103 ++++++++++++++ src/i18n/notifications.ts | 72 ++++++++++ test/unit/i18n/TranslationBuilder.test.ts | 148 +++++++++++++++++++ test/unit/i18n/notifications.test.ts | 165 ++++++++++++++++++++++ 7 files changed, 679 insertions(+) create mode 100644 src/i18n/TranslationBuilder.ts create mode 100644 src/i18n/languageNames.ts create mode 100644 src/i18n/notifications.ts create mode 100644 test/unit/i18n/TranslationBuilder.test.ts create mode 100644 test/unit/i18n/notifications.test.ts diff --git a/src/i18n/StreamI18n.ts b/src/i18n/StreamI18n.ts index 67862eeeb3..1fcd5b23ea 100644 --- a/src/i18n/StreamI18n.ts +++ b/src/i18n/StreamI18n.ts @@ -12,6 +12,8 @@ import { } from './dayjs'; import type { DayjsLocaleConfig } from './dayjs'; import { predefinedFormatters } from './formatters'; +import { TranslationBuilder } from './TranslationBuilder'; +import type { TranslationTopicConstructor } from './TranslationBuilder'; import { asDynamicKey, createDefaultTranslatorFunction, @@ -63,6 +65,12 @@ export type StreamI18nOptions; /** A valid TZ identifier, e.g. `Europe/Prague`. */ timezone?: string; + /** + * Post-processor topics for copy that cannot be resolved from a key alone — see + * {@link TranslationBuilder}. The key here must match the post-processor name in the translation + * value, i.e. `{{ value, topicName }}`. + */ + translationBuilderTopics?: Record; translationsForLanguage?: TranslationDictionaryOf; }; @@ -126,6 +134,8 @@ export class StreamI18n< readonly state: StateStore>; + readonly translationBuilder: TranslationBuilder; + /** The resource dictionaries handed to i18next, keyed by language. */ translations: Record>> = {}; @@ -151,6 +161,7 @@ export class StreamI18n< readonly formatters: PredefinedFormatters & CustomFormatters; readonly timezone?: string; + private readonly translationBuilderTopics: Record; private readonly runtimeDefaults: Record; private readonly disableDateTimeTranslations: boolean; private readonly i18nextConfig: InitOptions; @@ -165,6 +176,8 @@ export class StreamI18n< this.timezone = options.timezone; this.formatters = { ...predefinedFormatters, ...options.formatters }; this.isCustomDateTimeParser = Boolean(options.DateTimeParser); + this.translationBuilder = new TranslationBuilder(this.i18nInstance); + this.translationBuilderTopics = options.translationBuilderTopics ?? {}; const language = options.language ?? DEFAULT_LANGUAGE; @@ -229,6 +242,10 @@ export class StreamI18n< keySeparator: false, lng: language, nsSeparator: false, + // i18next only runs post-processors it was told about at init time. + ...(Object.keys(this.translationBuilderTopics).length > 0 + ? { postProcess: Object.keys(this.translationBuilderTopics) } + : {}), ...options.i18nextConfigOverrides, // An integrator handler replaces ours wholesale, so it has to be guarded too — otherwise // supplying one silently blanks every prose key. @@ -332,6 +349,12 @@ export class StreamI18n< ); }); + // After init, so the topics' post-processors are attached to a live instance and any buffered + // translator registrations flush. + Object.entries(this.translationBuilderTopics).forEach(([topic, Topic]) => { + this.translationBuilder.registerTopic(topic, Topic); + }); + this.state.partialNext({ initialized: true, // An `overrideTFunction` call before init must not be undone by init. diff --git a/src/i18n/TranslationBuilder.ts b/src/i18n/TranslationBuilder.ts new file mode 100644 index 0000000000..80037f5fd6 --- /dev/null +++ b/src/i18n/TranslationBuilder.ts @@ -0,0 +1,165 @@ +import type { i18n as I18nInstance } from 'i18next'; + +import type { LooseTranslateFunction } from './types'; + +/** + * i18next post-processor plumbing, for copy that cannot be resolved from a key alone. + * + * The motivating case is a notification: what to render depends on a runtime object, not just the key, + * so `t('translationBuilderTopic.notification', { notification })` dispatches through a *topic* which + * picks a *translator* based on that object. This is only the mechanism — the topics and their + * translators are SDK-specific and stay in the UI SDKs, since they reference SDK key names. + * + * A UI SDK may not need this at all: dispatching on the object at the render site instead is perfectly + * valid, and the React Native SDK does exactly that. The plumbing lives here so either approach is + * available without a core change. + */ +type TopicName = string; +type TranslatorName = string; + +/** + * Resolves one case within a topic. Returning `null` means "not mine" and lets the next candidate try. + * + * `t` is loose rather than catalog-typed: a translator is handed keys by the post-processor at runtime, + * so it cannot be checked against a specific catalog. + */ +export type Translator = Record> = + (params: { + key: string; + options: O; + t: LooseTranslateFunction; + value: string; + }) => string | null; + +export type TranslationTopicOptions< + O extends Record = Record, +> = { + i18next: I18nInstance; + translators?: Record>; +}; + +export abstract class TranslationTopic< + O extends Record = Record, +> { + protected translators: Map> = new Map(); + protected i18next: I18nInstance; + + constructor(protected options: TranslationTopicOptions) { + this.i18next = options.i18next; + if (options.translators) { + Object.entries(options.translators).forEach(([name, translator]) => { + this.setTranslator(name, translator); + }); + } + } + + abstract translate(value: string, key: string, options: O): string; + + setTranslator = (name: string, translator: Translator) => { + this.translators.set(name, translator); + }; + + removeTranslator = (name: string) => { + this.translators.delete(name); + }; +} + +export type TranslationTopicConstructor = new ( + options: TranslationTopicOptions, +) => TranslationTopic; + +const forwardTranslation: Translator = ({ value }) => value; + +export class TranslationBuilder { + private topics = new Map(); + + /** + * Translators registered before their topic exists. + * + * Topics are only created during `StreamI18n.init()`, but an integrator registers translators against + * the constructed instance — so registrations that arrive first are buffered and flushed when the + * topic appears, rather than silently dropped. + */ + private translatorRegistrationsBuffer: Record< + TopicName, + Record + > = {}; + + constructor(private i18next: I18nInstance) {} + + registerTopic = (name: TopicName, Topic: TranslationTopicConstructor) => { + let topic = this.topics.get(name); + + if (!topic) { + topic = new Topic({ i18next: this.i18next }); + this.topics.set(name, topic); + this.i18next.use({ + name, + process: (value: string, key: string, options: Record) => { + // Re-read from the map rather than closing over `topic`, so `disableTopic` takes effect. + const registered = this.topics.get(name); + if (!registered) return value; + return registered.translate(value, key, options); + }, + type: 'postProcessor' as const, + }); + } + + const buffered = this.translatorRegistrationsBuffer[name]; + if (buffered) { + Object.entries(buffered).forEach(([translatorName, translator]) => { + topic.setTranslator(translatorName, translator); + }); + delete this.translatorRegistrationsBuffer[name]; + } + + return topic; + }; + + disableTopic = (topicName: TopicName) => { + const topic = this.topics.get(topicName); + if (!topic) return; + // i18next has no way to remove a post-processor, so it is replaced with a pass-through. + this.i18next.use({ + name: topicName, + process: forwardTranslation, + type: 'postProcessor', + }); + this.topics.delete(topicName); + }; + + getTopic = (topicName: TopicName) => this.topics.get(topicName); + + registerTranslators( + topicName: TopicName, + translators: Record, + ) { + const topic = this.getTopic(topicName); + + if (!topic) { + this.translatorRegistrationsBuffer[topicName] ??= {}; + Object.entries(translators).forEach(([translatorName, translator]) => { + this.translatorRegistrationsBuffer[topicName][translatorName] = translator; + }); + return; + } + + Object.entries(translators).forEach(([name, translator]) => { + topic.setTranslator(name, translator); + }); + } + + removeTranslators(topicName: TopicName, translators: TranslatorName[]) { + if (this.translatorRegistrationsBuffer[topicName]) { + translators.forEach((translatorName) => { + delete this.translatorRegistrationsBuffer[topicName][translatorName]; + }); + } + + const topic = this.getTopic(topicName); + if (!topic) return; + translators.forEach((name) => { + topic.removeTranslator(name); + }); + } +} diff --git a/src/i18n/index.ts b/src/i18n/index.ts index 8b7b80079a..f41edd412d 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -7,6 +7,9 @@ */ export * from './dayjs'; export * from './formatters'; +export * from './languageNames'; +export * from './notifications'; export * from './StreamI18n'; +export * from './TranslationBuilder'; export * from './translator'; export * from './types'; diff --git a/src/i18n/languageNames.ts b/src/i18n/languageNames.ts new file mode 100644 index 0000000000..b379c1e668 --- /dev/null +++ b/src/i18n/languageNames.ts @@ -0,0 +1,103 @@ +import type { TranslationLanguage } from '../types'; + +/** + * The human-readable name of each language the API can auto-translate a message into. + * + * These are display copy for a **core-owned** set: `message.i18n.language` is typed + * {@link TranslationLanguage}, so core defines which languages exist and therefore owns their names + * too. A UI SDK uses them to say "Translated from German" rather than "Translated from de". + * + * `satisfies Record` is the drift gate, and it works in both + * directions: adding a language to the API union fails to compile until a name is supplied here, and a + * name for a language the union does not contain is rejected as an excess property. Before this lived + * in core, each UI SDK hand-maintained its own copy with nothing tying it to the union — so a miss + * could only be detected at runtime, by comparing the rendered string against the key. + * + * Names are in English on purpose. A language picker conventionally shows each language endonymously + * ("Deutsch", not "German"), but this is the *source* language of an auto-translated message rendered + * inside a sentence in the reader's own language, so it has to agree with the surrounding copy. An + * integrator wanting endonyms overrides the `language.*` keys. + */ +export const LANGUAGE_NAMES = { + af: 'Afrikaans', + am: 'Amharic', + ar: 'Arabic', + az: 'Azerbaijani', + bg: 'Bulgarian', + bn: 'Bengali', + bs: 'Bosnian', + cs: 'Czech', + da: 'Danish', + de: 'German', + el: 'Greek', + en: 'English', + es: 'Spanish', + 'es-MX': 'Spanish (Mexico)', + et: 'Estonian', + fa: 'Persian', + 'fa-AF': 'Dari', + fi: 'Finnish', + fr: 'French', + 'fr-CA': 'French (Canada)', + ha: 'Hausa', + he: 'Hebrew', + hi: 'Hindi', + hr: 'Croatian', + ht: 'Haitian Creole', + hu: 'Hungarian', + id: 'Indonesian', + it: 'Italian', + ja: 'Japanese', + ka: 'Georgian', + ko: 'Korean', + lt: 'Lithuanian', + lv: 'Latvian', + ms: 'Malay', + nl: 'Dutch', + no: 'Norwegian', + pl: 'Polish', + ps: 'Pashto', + pt: 'Portuguese', + ro: 'Romanian', + ru: 'Russian', + sk: 'Slovak', + sl: 'Slovenian', + so: 'Somali', + sq: 'Albanian', + sr: 'Serbian', + sv: 'Swedish', + sw: 'Swahili', + ta: 'Tamil', + th: 'Thai', + tl: 'Tagalog', + tr: 'Turkish', + uk: 'Ukrainian', + ur: 'Urdu', + vi: 'Vietnamese', + zh: 'Chinese (Simplified)', + 'zh-TW': 'Chinese (Traditional)', +} as const satisfies Record; + +/** + * The `language.*` slice of a translation catalog. + * + * A UI SDK intersects this into its own generated catalog, which makes `t('language.de')` a checked + * key rather than something that has to go through `asDynamicKey()`: + * + * ```ts + * type TranslationCatalog = GeneratedCatalog & LanguageNameCatalog; + * ``` + */ +export type LanguageNameCatalog = { + [K in keyof typeof LANGUAGE_NAMES as `language.${K & string}`]: (typeof LANGUAGE_NAMES)[K]; +}; + +/** + * {@link LANGUAGE_NAMES} keyed the way a catalog is, ready to merge into an SDK's bundled defaults. + * + * These keys are resolved from a runtime value (the message's source language), so there is no call + * site to carry an inline default — which is exactly why they have to ship as data. + */ +export const languageNameDefaults: Record = Object.fromEntries( + Object.entries(LANGUAGE_NAMES).map(([code, name]) => [`language.${code}`, name]), +); diff --git a/src/i18n/notifications.ts b/src/i18n/notifications.ts new file mode 100644 index 0000000000..a3183bf6ed --- /dev/null +++ b/src/i18n/notifications.ts @@ -0,0 +1,72 @@ +import { CORE_NOTIFICATION_TYPE } from '../notifications'; +import type { CoreNotificationType, Notification } from '../notifications'; +import { asDynamicKey } from './translator'; +import type { LooseTranslateFunction } from './types'; + +/** + * The canonical translation key for each notification `stream-chat` emits. + * + * `Record` is the drift gate: adding an identifier to + * {@link CORE_NOTIFICATION_TYPE} fails to compile until a key is supplied here, and a key for an + * identifier that no longer exists is rejected. That is the check both UI SDKs were missing — they each + * hand-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 rendering + * untranslated English. + * + * Keys are shared rather than per-SDK so an integrator's notification dictionary is portable between + * the React and React Native SDKs. + */ +export const CORE_NOTIFICATION_TRANSLATION_KEY: Record = { + [CORE_NOTIFICATION_TYPE.attachmentFileMissing]: 'notification.attachmentFileMissing', + [CORE_NOTIFICATION_TYPE.attachmentIdMissing]: 'notification.attachmentIdMissing', + [CORE_NOTIFICATION_TYPE.attachmentUploadBlocked]: + 'notification.attachmentUploadBlocked', + [CORE_NOTIFICATION_TYPE.attachmentUploadFailed]: 'notification.attachmentUploadFailed', + [CORE_NOTIFICATION_TYPE.attachmentUploadInProgress]: + 'notification.attachmentUploadInProgress', + // Carries `metadata.reason` ('editing' | 'replying'), which the English message varies by. Copy for + // this key should interpolate `{{ reason }}` or the SDK should branch before calling in. + [CORE_NOTIFICATION_TYPE.commandDisabled]: 'notification.commandDisabled', + [CORE_NOTIFICATION_TYPE.commandNotReady]: 'notification.commandNotReady', + [CORE_NOTIFICATION_TYPE.locationCreateFailed]: 'notification.locationCreateFailed', + [CORE_NOTIFICATION_TYPE.messageJumpFailed]: 'notification.messageJumpFailed', + [CORE_NOTIFICATION_TYPE.messageJumpToLatestFailed]: + 'notification.messageJumpToLatestFailed', + [CORE_NOTIFICATION_TYPE.pollCastVoteLimit]: 'notification.pollCastVoteLimit', + [CORE_NOTIFICATION_TYPE.pollCreateFailed]: 'notification.pollCreateFailed', +}; + +/** The subset of a notification {@link translateNotification} reads. */ +export type TranslatableNotification = Pick & + Partial>; + +/** + * Resolves a notification to display copy. + * + * Dispatches on `notification.type` — the stable identifier — and never on `notification.message`, + * which is untranslated English whose wording is not part of core's public contract. Both UI SDKs + * previously fell back to matching that prose against a hand-maintained table of English sentences, + * which silently grew stale on every core upgrade. + * + * `metadata` is passed through as interpolation values, so copy can reference `{{ reason }}` and the + * like. + * + * An unrecognized identifier renders `message` verbatim rather than a blank or a raw dotted path: a + * newer core, or an SDK or integrator emitting its own identifier, must not produce an empty toast. + * Pass `translationKeys` to extend the map with the SDK's own identifiers. + */ +export const translateNotification = ({ + notification, + t, + translationKeys = CORE_NOTIFICATION_TRANSLATION_KEY, +}: { + notification: TranslatableNotification; + t: LooseTranslateFunction; + translationKeys?: Record; +}): string => { + const key = notification.type ? translationKeys[notification.type] : undefined; + if (!key) return notification.message; + + // `message` doubles as the default, so a mapped-but-untranslated key still renders English. + return t(asDynamicKey(key), notification.message, notification.metadata ?? {}); +}; diff --git a/test/unit/i18n/TranslationBuilder.test.ts b/test/unit/i18n/TranslationBuilder.test.ts new file mode 100644 index 0000000000..e9e1df7eb0 --- /dev/null +++ b/test/unit/i18n/TranslationBuilder.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { StreamI18n, TranslationTopic } from '../../../src/i18n'; +import type { Translator } from '../../../src/i18n'; + +/** + * i18next post-processors are configured **globally** (`postProcess: [...]`), so a topic is invoked for + * every key, not only the one named after it. A topic therefore has to recognise its own calls and pass + * everything else through untouched — which is what `options.kind` does here, and what a real topic does + * by checking for the object it dispatches on. + */ +class KindTopic extends TranslationTopic<{ kind?: string }> { + translate = (value: string, key: string, options: { kind?: string }) => { + if (!options.kind) return value; + const chosen = this.translators.get(options.kind) ?? this.translators.get('*'); + return chosen?.({ key, options, t: this.i18next.t, value }) ?? value; + }; +} + +/** The key's own value is only a fallback: a topic that handles the call replaces it wholesale. */ +const FALLBACK = 'FALLBACK'; + +const setup = (options: Record = {}) => + new StreamI18n({ + logger: () => {}, + runtimeDefaults: { 'translationBuilderTopic.kind': FALLBACK }, + translationBuilderTopics: { kind: KindTopic }, + ...options, + }); + +const render = ( + t: unknown, + options: Record = { kind: 'shout', value: 'hello' }, +) => + (t as (k: string, o?: Record) => string)( + 'translationBuilderTopic.kind', + options, + ); + +describe('TranslationBuilder', () => { + it('runs the topic as an i18next post-processor', async () => { + const i18n = setup(); + i18n.translationBuilder.registerTranslators('kind', { + shout: ({ options }) => String(options.value).toUpperCase(), + }); + const { t } = await i18n.init(); + + expect(render(t)).toBe('HELLO'); + }); + + /** + * The reason the registration buffer exists: topics are only constructed during `init()`, but an + * integrator registers translators against the instance they just built. Without buffering, anything + * registered first is silently dropped. + */ + it('flushes translators registered before init', async () => { + const i18n = setup(); + expect(i18n.translationBuilder.getTopic('kind')).toBeUndefined(); + i18n.translationBuilder.registerTranslators('kind', { + '*': ({ options }) => `[${options.value}]`, + }); + + const { t } = await i18n.init(); + + expect(i18n.translationBuilder.getTopic('kind')).toBeDefined(); + expect(render(t, { kind: 'anything', value: 'x' })).toBe('[x]'); + }); + + it('lets a later registration override an earlier one', async () => { + const i18n = setup(); + const { t } = await i18n.init(); + + i18n.translationBuilder.registerTranslators('kind', { '*': () => 'first' }); + expect(render(t)).toBe('first'); + + i18n.translationBuilder.registerTranslators('kind', { '*': () => 'second' }); + expect(render(t)).toBe('second'); + }); + + it('falls back to the key value when a translator declines', async () => { + const i18n = setup(); + const declines: Translator<{ kind?: string }> = () => null; + i18n.translationBuilder.registerTranslators('kind', { '*': declines as Translator }); + const { t } = await i18n.init(); + + expect(render(t)).toBe(FALLBACK); + }); + + /** A topic must not touch keys that are not its own, since post-processing is global. */ + it('passes through calls it does not recognise', async () => { + const i18n = setup({ + runtimeDefaults: { + 'timestamp.Unrelated': '{{ timestamp | timestampFormatter(format: LT) }}', + 'translationBuilderTopic.kind': FALLBACK, + }, + }); + i18n.translationBuilder.registerTranslators('kind', { '*': () => 'HANDLED' }); + const { t } = await i18n.init(); + + const unrelated = (t as (k: string, o?: Record) => string)( + 'timestamp.Unrelated', + { timestamp: '2026-03-13T14:32:00.000Z' }, + ); + expect(unrelated).toBe('2:32 PM'); + }); + + it('removeTranslators drops a registered translator', async () => { + const i18n = setup(); + i18n.translationBuilder.registerTranslators('kind', { '*': () => 'handled' }); + const { t } = await i18n.init(); + expect(render(t)).toBe('handled'); + + i18n.translationBuilder.removeTranslators('kind', ['*']); + + expect(render(t)).toBe(FALLBACK); + }); + + it('disableTopic turns the post-processor into a pass-through', async () => { + const i18n = setup(); + i18n.translationBuilder.registerTranslators('kind', { '*': () => 'handled' }); + const { t } = await i18n.init(); + expect(render(t)).toBe('handled'); + + i18n.translationBuilder.disableTopic('kind'); + + expect(i18n.translationBuilder.getTopic('kind')).toBeUndefined(); + expect(render(t)).toBe(FALLBACK); + }); + + it('registerTopic is idempotent', async () => { + const i18n = setup(); + await i18n.init(); + const first = i18n.translationBuilder.getTopic('kind'); + + i18n.translationBuilder.registerTopic('kind', KindTopic); + + expect(i18n.translationBuilder.getTopic('kind')).toBe(first); + }); + + it('configures no post-processing when no topics are supplied', async () => { + const i18n = new StreamI18n({ logger: vi.fn(), runtimeDefaults: {} }); + const { t } = await i18n.init(); + + expect((t as (k: string, d: string) => string)('common.thing', 'Thing')).toBe( + 'Thing', + ); + }); +}); diff --git a/test/unit/i18n/notifications.test.ts b/test/unit/i18n/notifications.test.ts new file mode 100644 index 0000000000..7b03455942 --- /dev/null +++ b/test/unit/i18n/notifications.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it } from 'vitest'; + +import { CORE_NOTIFICATION_TYPE } from '../../../src'; +import { + CORE_NOTIFICATION_TRANSLATION_KEY, + LANGUAGE_NAMES, + languageNameDefaults, + StreamI18n, + translateNotification, +} from '../../../src/i18n'; +import { fixtureRuntimeDefaults } from './fixtures'; + +const translatorFor = async (dictionary: Record = {}) => { + const i18n = new StreamI18n({ + logger: () => {}, + runtimeDefaults: { ...fixtureRuntimeDefaults, ...dictionary }, + }); + const { t } = await i18n.init(); + return t as unknown as ( + key: string, + d?: string | Record, + o?: Record, + ) => string; +}; + +describe('CORE_NOTIFICATION_TRANSLATION_KEY', () => { + /** + * The compile-time guard is `Record` in the source; this is the runtime + * half. Together they are what both UI SDKs lacked — each hand-maintained the same table, and the two + * copies drifted in both directions. + */ + it('covers every identifier core emits, and nothing else', () => { + expect(Object.keys(CORE_NOTIFICATION_TRANSLATION_KEY).sort()).toEqual( + Object.values(CORE_NOTIFICATION_TYPE).sort(), + ); + }); + + it('maps each identifier to a distinct key under the notification namespace', () => { + const keys = Object.values(CORE_NOTIFICATION_TRANSLATION_KEY); + expect(new Set(keys).size).toBe(keys.length); + keys.forEach((key) => expect(key).toMatch(/^notification\.[a-zA-Z]+$/)); + }); +}); + +describe('translateNotification', () => { + it('resolves a recognized identifier through its key', async () => { + const t = await translatorFor({ + 'notification.pollCreateFailed': 'Umfrage konnte nicht erstellt werden', + }); + + expect( + translateNotification({ + notification: { + message: 'Failed to create the poll', + type: CORE_NOTIFICATION_TYPE.pollCreateFailed, + }, + t, + }), + ).toBe('Umfrage konnte nicht erstellt werden'); + }); + + it('falls back to the English message for a mapped but untranslated key', async () => { + const t = await translatorFor(); + + expect( + translateNotification({ + notification: { + message: 'Failed to create the poll', + type: CORE_NOTIFICATION_TYPE.pollCreateFailed, + }, + t, + }), + ).toBe('Failed to create the poll'); + }); + + /** A newer core, or an SDK/integrator identifier, must not produce an empty toast. */ + it('renders the message verbatim for an unrecognized identifier', async () => { + const t = await translatorFor(); + + expect( + translateNotification({ + notification: { + message: 'Something new happened', + type: 'api:future:thing:failed', + }, + t, + }), + ).toBe('Something new happened'); + }); + + it('renders the message when there is no identifier at all', async () => { + const t = await translatorFor(); + + expect(translateNotification({ notification: { message: 'No type here' }, t })).toBe( + 'No type here', + ); + }); + + it('passes metadata through as interpolation values', async () => { + const t = await translatorFor({ + 'notification.commandDisabled': 'Not available while {{ reason }}', + }); + + expect( + translateNotification({ + notification: { + message: 'Command not available while editing', + metadata: { reason: 'editing' }, + type: CORE_NOTIFICATION_TYPE.commandDisabled, + }, + t, + }), + ).toBe('Not available while editing'); + }); + + it('accepts extra identifiers a UI SDK emits itself', async () => { + const t = await translatorFor({ + 'notification.audioFailed': 'Wiedergabe fehlgeschlagen', + }); + + expect( + translateNotification({ + notification: { + message: 'Audio playback failed', + type: 'browser:audio:playback:error', + }, + t, + translationKeys: { + ...CORE_NOTIFICATION_TRANSLATION_KEY, + 'browser:audio:playback:error': 'notification.audioFailed', + }, + }), + ).toBe('Wiedergabe fehlgeschlagen'); + }); +}); + +describe('LANGUAGE_NAMES', () => { + /** + * Exhaustiveness against `TranslationLanguage` is enforced at compile time by + * `satisfies Record`; this covers the runtime shape. + */ + it('exposes a non-empty English name for every language', () => { + const entries = Object.entries(LANGUAGE_NAMES); + expect(entries.length).toBeGreaterThan(50); + entries.forEach(([code, name]) => { + expect(name, `${code} has no name`).toBeTruthy(); + }); + }); + + it('prefixes the catalog-ready defaults with `language.`', () => { + expect(languageNameDefaults['language.de']).toBe('German'); + expect(languageNameDefaults['language.zh-TW']).toBe('Chinese (Traditional)'); + expect(Object.keys(languageNameDefaults)).toHaveLength( + Object.keys(LANGUAGE_NAMES).length, + ); + Object.keys(languageNameDefaults).forEach((key) => + expect(key.startsWith('language.')).toBe(true), + ); + }); + + it('renders through t() when merged into the bundled defaults', async () => { + const t = await translatorFor(languageNameDefaults); + expect(t('language.de')).toBe('German'); + }); +}); From 0537ae8466e2e032141bb8d6a9c35ede09df5468 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Tue, 18 Aug 2026 00:03:31 +0200 Subject: [PATCH 05/27] feat(i18n): share the catalog codegen as a stream-chat/i18n/codegen subpath 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. --- package.json | 11 + scripts/bundle.mjs | 80 +++++- src/i18n-codegen/callSites.ts | 113 ++++++++ src/i18n-codegen/generate.ts | 209 ++++++++++++++ src/i18n-codegen/guards.ts | 130 +++++++++ src/i18n-codegen/index.ts | 29 ++ src/i18n-codegen/stringMaps.ts | 91 ++++++ src/i18n-codegen/types.ts | 91 ++++++ test/unit/i18n-codegen/generate.test.ts | 360 ++++++++++++++++++++++++ 9 files changed, 1105 insertions(+), 9 deletions(-) create mode 100644 src/i18n-codegen/callSites.ts create mode 100644 src/i18n-codegen/generate.ts create mode 100644 src/i18n-codegen/guards.ts create mode 100644 src/i18n-codegen/index.ts create mode 100644 src/i18n-codegen/stringMaps.ts create mode 100644 src/i18n-codegen/types.ts create mode 100644 test/unit/i18n-codegen/generate.test.ts diff --git a/package.json b/package.json index 0425c86ff8..4ab6e1fa97 100644 --- a/package.json +++ b/package.json @@ -40,12 +40,23 @@ "node": "./dist/cjs/i18n.node.js", "default": "./dist/esm/i18n.mjs" }, + "./i18n/codegen": { + "types": "./dist/types/i18n-codegen/index.d.ts", + "node": { + "import": "./dist/esm/i18n-codegen.mjs", + "require": "./dist/cjs/i18n-codegen.node.js" + }, + "default": "./dist/esm/i18n-codegen.mjs" + }, "./package.json": "./package.json" }, "typesVersions": { "*": { "i18n": [ "./dist/types/i18n/index.d.ts" + ], + "i18n/codegen": [ + "./dist/types/i18n-codegen/index.d.ts" ] } }, diff --git a/scripts/bundle.mjs b/scripts/bundle.mjs index ff9b97e931..e1c45a58c2 100755 --- a/scripts/bundle.mjs +++ b/scripts/bundle.mjs @@ -49,12 +49,38 @@ const commonBuildOptions = { const I18N_ONLY_DEPENDENCIES = ['i18next', 'dayjs']; /** - * Fails the build if the entry-point boundaries have been crossed. + * What each entry point is forbidden from reaching. Keyed on the entry explicitly rather than by + * elimination, so a new entry gets no rule by accident (and the codegen entry is not told off for + * reaching its own source). + */ +const ENTRY_BOUNDARIES = [ + { + // The root bundle: no i18n at all, and none of its dependencies. + entry: 'src/index.ts', + forbiddenDeps: I18N_ONLY_DEPENDENCIES, + forbiddenSources: /(^|\/)src\/i18n(-codegen)?\//, + }, + { + // The runtime i18n layer must not pull in the Node-only build tooling. + entry: 'src/i18n/index.ts', + forbiddenDeps: [], + forbiddenSources: /(^|\/)src\/i18n-codegen\//, + }, + { + // The codegen is Node-only by design and has no restriction of its own. + entry: 'src/i18n-codegen/index.ts', + forbiddenDeps: [], + forbiddenSources: null, + }, +]; + +/** + * Fails the build if an entry point reached something it must not. * - * Two directions, both of which are a single careless `export * from './i18n'` away: + * Two directions, both a single careless `export * from './i18n'` away: * - the root bundle must not reach `src/i18n/` or its dependencies, or every consumer of * `stream-chat` pays for i18next and dayjs whether they translate anything or not; - * - the i18n bundle must not reach `src/i18n-codegen/`, which is Node-only build tooling. + * - the runtime i18n bundle must not reach `src/i18n-codegen/`, which is Node-only build tooling. * * Checked here rather than left to review, because the failure is invisible: everything still works, * the bundle is just quietly bigger. @@ -65,13 +91,25 @@ const assertBundleBoundaries = (metafile) => { for (const [outputFile, output] of Object.entries(metafile.outputs)) { if (!output.entryPoint) continue; - const forbidden = output.entryPoint.endsWith('src/index.ts') - ? { deps: I18N_ONLY_DEPENDENCIES, sources: /(^|\/)src\/i18n\// } - : { deps: [], sources: /(^|\/)src\/i18n-codegen\// }; - - const leakedSources = Object.keys(output.inputs).filter((input) => - forbidden.sources.test(input), + const boundary = ENTRY_BOUNDARIES.find(({ entry }) => + output.entryPoint.endsWith(entry), ); + if (!boundary) { + failures.push( + `${outputFile} (entry ${output.entryPoint}) has no declared boundary — add one to ` + + `ENTRY_BOUNDARIES in scripts/bundle.mjs.`, + ); + continue; + } + + const forbidden = { + deps: boundary.forbiddenDeps, + sources: boundary.forbiddenSources, + }; + + const leakedSources = forbidden.sources + ? Object.keys(output.inputs).filter((input) => forbidden.sources.test(input)) + : []; const leakedDeps = (output.imports ?? []) .map(({ path }) => path) .filter((path) => @@ -131,6 +169,30 @@ const bundles = [ 'process.env.CLIENT_BUNDLE': JSON.stringify('browser-esm'), }, }, + // Build-time codegen: Node only, so no browser variant. Kept a separate entry so it never becomes + // reachable from `stream-chat/i18n`, which `assertBundleBoundaries` enforces. + ['cjs', 'esm'].map((format) => ({ + entryPoints: { + 'i18n-codegen': resolve(__dirname, '../src/i18n-codegen/index.ts'), + }, + bundle: true, + metafile: true, + target: 'node18', + platform: 'node', + format, + external: nodeExternal, + sourcemap: watchModeEnabled ? 'inline' : 'linked', + define: { 'process.env.PKG_VERSION': JSON.stringify(version) }, + ...(format === 'cjs' + ? { + entryNames: '[dir]/[name].node', + outdir: resolve(__dirname, '../dist/cjs'), + } + : { + outExtension: { '.js': '.mjs' }, + outdir: resolve(__dirname, '../dist/esm'), + }), + })), ].flat(); if (watchModeEnabled) { diff --git a/src/i18n-codegen/callSites.ts b/src/i18n-codegen/callSites.ts new file mode 100644 index 0000000000..4c2dd54593 --- /dev/null +++ b/src/i18n-codegen/callSites.ts @@ -0,0 +1,113 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import type * as ts from 'typescript'; + +import type { CallSiteCopy, TypeScriptModule } from './types'; + +const DEFAULT_IGNORE_DIRS = ['__tests__', 'mock-builders']; + +/** + * Every `t()` call in the source is the catalog's source of truth. + * + * A prose key exists because a component asks for it and passes its English copy inline; delete the + * call and the key is gone. That is what removes the need for a checked-in `en.json` and for an + * extract / remove-unused-keys pass, and it makes a dead prose key structurally impossible. + * + * The only keys that cannot be described this way are the ones with no inline copy — a formatter + * expression, or a key built from a runtime value. Those come from `runtimeDefaults`, and the generator + * cross-checks the two. + */ +const isTCallee = (tsModule: TypeScriptModule, expr: ts.Expression): boolean => + (tsModule.isIdentifier(expr) && expr.text === 't') || + (tsModule.isPropertyAccessExpression(expr) && expr.name.text === 't'); + +export const sourceFiles = ({ + ignoreDirs = DEFAULT_IGNORE_DIRS, + srcRoot = 'src', +}: { + ignoreDirs?: string[]; + srcRoot?: string; +} = {}): string[] => { + const out: string[] = []; + const walk = (dir: string) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (ignoreDirs.includes(entry.name)) continue; + walk(full); + } else if (/\.tsx?$/.test(entry.name) && !entry.name.endsWith('.d.ts')) { + out.push(full); + } + } + }; + walk(srcRoot); + return out; +}; + +export const readCallSiteCopy = ({ + ignoreDirs, + srcRoot, + ts: tsModule, +}: { + ts: TypeScriptModule; + ignoreDirs?: string[]; + srcRoot?: string; +}): CallSiteCopy => { + const copy = new Map(); + const withoutCopy = new Map(); + const conflicts: CallSiteCopy['conflicts'] = []; + + const record = (key: string, value: string, file: string) => { + const existing = copy.get(key); + if (existing !== undefined && existing !== value) { + conflicts.push({ a: existing, b: value, file, key }); + return; + } + copy.set(key, value); + }; + + for (const file of sourceFiles({ ignoreDirs, srcRoot })) { + const sourceFile = tsModule.createSourceFile( + file, + fs.readFileSync(file, 'utf8'), + tsModule.ScriptTarget.Latest, + true, + file.endsWith('.tsx') ? tsModule.ScriptKind.TSX : tsModule.ScriptKind.TS, + ); + + const visit = (node: ts.Node) => { + if (tsModule.isCallExpression(node) && isTCallee(tsModule, node.expression)) { + const [keyArg, second] = node.arguments; + if (keyArg && tsModule.isStringLiteralLike(keyArg)) { + const key = keyArg.text; + if (second && tsModule.isStringLiteralLike(second)) { + // t('key', 'Copy') + record(key, second.text, file); + } else if (second && tsModule.isObjectLiteralExpression(second)) { + // t('key', { count, defaultValue_one, defaultValue_other }) — the catalog holds the + // `_one` / `_other` forms, never the bare key. + let plurals = 0; + for (const prop of second.properties) { + if (!tsModule.isPropertyAssignment(prop)) continue; + const name = prop.name.getText(sourceFile).replace(/['"]/g, ''); + const suffix = name.match(/^defaultValue_(\w+)$/)?.[1]; + if (suffix && tsModule.isStringLiteralLike(prop.initializer)) { + record(`${key}_${suffix}`, prop.initializer.text, file); + plurals++; + } + } + if (!plurals) withoutCopy.set(key, file); + } else { + // t('key') — no inline copy, so it has to resolve from runtimeDefaults. + withoutCopy.set(key, file); + } + } + } + tsModule.forEachChild(node, visit); + }; + + visit(sourceFile); + } + + return { conflicts, copy, withoutCopy }; +}; diff --git a/src/i18n-codegen/generate.ts b/src/i18n-codegen/generate.ts new file mode 100644 index 0000000000..c817a9d6ff --- /dev/null +++ b/src/i18n-codegen/generate.ts @@ -0,0 +1,209 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { readCallSiteCopy } from './callSites'; +import { + formatFailures, + guardConflictingCopy, + guardPrefixCollisions, + guardShadowedKeys, + guardUnresolvableKeys, +} from './guards'; +import { readStringMap } from './stringMaps'; +import type { GeneratedCatalog, GeneratorConfig } from './types'; + +/** Values under these prefixes are dayjs/i18next expressions, not copy. */ +const BUILTIN_FORMATTER_PREFIXES = ['timestamp.', 'duration.']; + +/** + * Two ways English hides inside a formatter expression, both of which have to be reported: a translator + * working from the JSON export never sees these keys otherwise. + */ +const hasEnglishWords = (value: string) => + // Day words baked into a `calendarFormats` argument, e.g. `[Yesterday]` — dayjs escapes literal text + // in brackets. + [...value.matchAll(/\[([^\]]+)\]/g)].some(([, literal]) => + /[A-Za-z]{2}/.test(literal), + ) || + // Prose sitting beside the interpolation, e.g. `Last seen {{ timestamp | … }}`. Each expression is + // matched individually — a greedy `{{[\s\S]*}}` would span from the first `{{` to the last `}}` and + // swallow the prose between two of them. Stopping at `}}` rather than any `}` is what keeps the + // nested braces of a `calendarFormats` argument inside the match. + /[A-Za-z]{2}/.test(value.replace(/\{\{(?:[^}]|\}(?!\}))*\}\}/g, '')); + +/** + * Builds the catalog and runs every guard, without writing anything. + * + * Separate from {@link generateI18nKeys} so a test — or a caller wanting to inspect before committing — + * can get the failures as data rather than as process output. + */ +export const buildCatalog = (config: GeneratorConfig): GeneratedCatalog => { + const { runtimeDefaultsPath, ts } = config; + + const runtimeDefaults = readStringMap({ + exportName: 'runtimeDefaults', + file: runtimeDefaultsPath, + ts, + }); + const { + conflicts, + copy: inlineCopy, + withoutCopy, + } = readCallSiteCopy({ + ignoreDirs: config.ignoreDirs, + srcRoot: config.srcRoot, + ts, + }); + + const catalogEntries = new Map([...inlineCopy, ...runtimeDefaults]); + const keys = [...catalogEntries.keys()].sort(); + const catalog = new Map(keys.map((key) => [key, catalogEntries.get(key) as string])); + + const failures = [ + guardConflictingCopy(conflicts), + guardUnresolvableKeys({ runtimeDefaults, runtimeDefaultsPath, withoutCopy }), + guardShadowedKeys({ inlineCopy, runtimeDefaults, runtimeDefaultsPath }), + guardPrefixCollisions(keys), + ].filter((failure): failure is NonNullable => failure !== null); + + return { bundledKeys: [...runtimeDefaults.keys()].sort(), catalog, failures }; +}; + +const renderKeysFile = ({ + bundledKeys, + catalog, + emitBundledKeyUnion, +}: { + bundledKeys: string[]; + catalog: Map; + emitBundledKeyUnion?: boolean; +}): string => { + const lines: string[] = [ + '// AUTO-GENERATED — do not edit by hand.', + '// Regenerate with `yarn build-translations`. CI fails if this file is out of sync.', + '//', + '// Type-only: no runtime value is emitted, so this adds nothing to the bundle.', + '', + '/**', + ' * Every translation entry shipped with the SDK, mapped to its English copy.', + ' *', + ' * Plural entries appear as `_one` / `_other`; call sites use the bare `` and', + ' * pass `count`.', + ' */', + 'export type TranslationCatalog = {', + ]; + + for (const [key, value] of catalog) { + lines.push(` ${JSON.stringify(key)}: ${JSON.stringify(value)};`); + } + lines.push('};', ''); + + if (emitBundledKeyUnion) { + lines.push( + '/**', + ' * Keys whose copy is bundled rather than passed inline at the call site.', + ' *', + ' * They reach `t()` as runtime values — a JSX prop, a ternary branch, a lookup table — so there', + ' * is nowhere to write a `defaultValue`. Call sites pass the key alone.', + ' */', + 'export type BundledTranslationKey =', + ); + for (const key of bundledKeys) lines.push(` | ${JSON.stringify(key)}`); + lines.push(';', ''); + } + + return lines.join('\n'); +}; + +/** + * Regenerates an SDK's translation catalog from its `t()` call sites and bundled defaults. + * + * Throws on a guard failure with every failure formatted, so the caller's script exits non-zero and CI + * fails. The catalog is written only when all guards pass. + */ +export const generateI18nKeys = (config: GeneratorConfig): GeneratedCatalog => { + const log = config.log ?? ((message: string) => console.log(message)); + const result = buildCatalog(config); + + if (result.failures.length) { + throw new Error(formatFailures(result.failures)); + } + + const { bundledKeys, catalog } = result; + const keys = [...catalog.keys()]; + + fs.mkdirSync(path.dirname(config.keysOut), { recursive: true }); + fs.writeFileSync( + config.keysOut, + renderKeysFile({ + bundledKeys, + catalog, + emitBundledKeyUnion: config.emitBundledKeyUnion, + }), + ); + + if (config.fixtureOut) { + fs.mkdirSync(path.dirname(config.fixtureOut), { recursive: true }); + fs.writeFileSync( + config.fixtureOut, + `${JSON.stringify(Object.fromEntries(catalog), null, 2)}\n`, + ); + } + + log( + `generated ${config.keysOut} (${keys.length} entries, type-only) — ` + + `${keys.length - bundledKeys.length} from inline defaults, ${bundledKeys.length} bundled`, + ); + + if (config.json) { + const formatterPrefixes = [ + ...BUILTIN_FORMATTER_PREFIXES, + ...(config.extraFormatterPrefixes ?? []), + ]; + const isFormatterKey = (key: string) => + formatterPrefixes.some((prefix) => key.startsWith(prefix)); + + const exported = config.json.includeFormats + ? keys + : keys.filter((k) => !isFormatterKey(k)); + fs.writeFileSync( + config.json.out, + `${JSON.stringify( + Object.fromEntries(exported.map((key) => [key, catalog.get(key)])), + null, + 2, + )}\n`, + ); + + log( + `wrote ${config.json.out} (${exported.length} ${ + config.json.includeFormats + ? 'entries, formatter expressions included' + : 'translatable entries' + })`, + ); + + const excluded = keys.filter((key) => !exported.includes(key)); + if (excluded.length) { + // Excluding formatter expressions does drop some translatable text: a few embed English day + // words. It is not translatable *as copy* — the format string has to be rewritten — so it is + // named here and handled by overriding the key. Detected rather than hardcoded, so the list + // cannot go stale. + const withEnglish = excluded.filter((key) => + hasEnglishWords(catalog.get(key) as string), + ); + log( + ` excluded ${excluded.length} formatter expressions (${formatterPrefixes.join(', ')}) — ` + + `not copy, and a TMS that translates them breaks date rendering. Pass --all to include ` + + `them.` + + (withEnglish.length + ? `\n ${withEnglish.length} of them do carry English copy and must be translated by ` + + `overriding the key:\n${withEnglish.map((key) => ` ${key}`).join('\n')}` + + (config.migrationGuideRef ? `\n see ${config.migrationGuideRef}.` : '') + : ''), + ); + } + } + + return result; +}; diff --git a/src/i18n-codegen/guards.ts b/src/i18n-codegen/guards.ts new file mode 100644 index 0000000000..ebec1f595e --- /dev/null +++ b/src/i18n-codegen/guards.ts @@ -0,0 +1,130 @@ +import type { CallSiteCopy, GuardFailure } from './types'; + +/** + * The four hard-fail checks the catalog has to pass. + * + * Each is a pure function returning failures as data. The fifth guard both UI SDKs carried — checking + * that an `EXTERNAL_STRING_KEYS` entry's wording matched the key's catalog copy — is gone, because the + * map it policed is gone: notifications now resolve through a stable identifier instead of by matching + * English prose. + */ + +/** A key must render one thing. */ +export const guardConflictingCopy = ( + conflicts: CallSiteCopy['conflicts'], +): GuardFailure | null => { + if (!conflicts.length) return null; + return { + entries: conflicts.map( + ({ a, b, file, key }) => + `${key}\n ${JSON.stringify(a)}\n ${JSON.stringify(b)} (${file})`, + ), + kind: 'conflicting-copy', + summary: + `${conflicts.length} key(s) used with conflicting inline copy — a key must render ` + + `one thing:`, + }; +}; + +/** + * A key called without inline copy resolves from the bundled data or not at all. + * + * Without this, i18next renders the raw dotted key in the UI — the failure mode is a user seeing + * `message.status.sent.text` where a word should be. + */ +export const guardUnresolvableKeys = ({ + runtimeDefaults, + runtimeDefaultsPath, + withoutCopy, +}: { + runtimeDefaults: Map; + runtimeDefaultsPath: string; + withoutCopy: CallSiteCopy['withoutCopy']; +}): GuardFailure | null => { + const unresolvable = [...withoutCopy].filter(([key]) => !runtimeDefaults.has(key)); + if (!unresolvable.length) return null; + return { + entries: unresolvable.map(([key, file]) => `${key} (${file})`), + kind: 'unresolvable-key', + summary: + `${unresolvable.length} key(s) are called with no inline default and are missing from ` + + `${runtimeDefaultsPath}.\nThey would render as the raw key. Either pass the English copy ` + + `inline — t('key', 'Copy') — or add an entry to ${runtimeDefaultsPath}:`, + }; +}; + +/** + * A key must not be in both places. + * + * The bundled value wins over a `defaultValue`, so a key in both silently renders the bundled string + * and ignores the call site — meaning an edit to the copy at the call site changes nothing, with no + * error. This is the bug class that used to hide behind a checked-in `en.json`. + */ +export const guardShadowedKeys = ({ + inlineCopy, + runtimeDefaults, + runtimeDefaultsPath, +}: { + inlineCopy: Map; + runtimeDefaults: Map; + runtimeDefaultsPath: string; +}): GuardFailure | null => { + const shadowed = [...runtimeDefaults.keys()].filter((key) => inlineCopy.has(key)); + if (!shadowed.length) return null; + return { + entries: shadowed.map( + (key) => + `${key}\n bundled: ${JSON.stringify(runtimeDefaults.get(key))}\n` + + ` call site: ${JSON.stringify(inlineCopy.get(key))}`, + ), + kind: 'shadowed-key', + summary: + `${shadowed.length} key(s) are in both ${runtimeDefaultsPath} and an inline default.\n` + + `The bundled value wins, so editing the call site would silently change nothing. Remove ` + + `the ${runtimeDefaultsPath} entry:`, + }; +}; + +/** + * A key cannot be both a leaf and a namespace. + * + * With i18next's default `keySeparator: '.'` the shorter key would resolve to an object, and a nested + * resource tree cannot represent both at once. The SDKs set `keySeparator: false` so this is latent + * rather than active — but it is a landmine for anyone who ever flips that, and cheap to prevent. + * + * Compared on segment boundaries, so `poll.title` / `poll.titleText` is fine while `poll.title` / + * `poll.title.text` is not. + */ +export const guardPrefixCollisions = (keys: string[]): GuardFailure | null => { + const keySet = new Set(keys); + const collisions: Array<{ leaf: string; nested: string }> = []; + + for (const key of keys) { + const segments = key.split('.'); + for (let i = 1; i < segments.length; i++) { + const ancestor = segments.slice(0, i).join('.'); + if (keySet.has(ancestor)) collisions.push({ leaf: ancestor, nested: key }); + } + } + + if (!collisions.length) return null; + return { + entries: collisions.map( + ({ leaf, nested }) => `${leaf}\n is a strict prefix of: ${nested}`, + ), + kind: 'prefix-collision', + summary: + `${collisions.length} key(s) are a strict prefix of another key — a key cannot be both a ` + + `leaf and a namespace. Rename one, usually by giving the shorter key a modality segment ` + + `(.label / .text / .title):`, + }; +}; + +/** Formats failures the way the generator prints them before exiting. */ +export const formatFailures = (failures: GuardFailure[]): string => + failures + .map( + ({ entries, summary }) => + `\n${summary}\n${entries.map((e) => ` ${e}`).join('\n')}`, + ) + .join('\n'); diff --git a/src/i18n-codegen/index.ts b/src/i18n-codegen/index.ts new file mode 100644 index 0000000000..e800164a33 --- /dev/null +++ b/src/i18n-codegen/index.ts @@ -0,0 +1,29 @@ +/** + * Build-time codegen for a UI SDK's translation catalog, published as `stream-chat/i18n/codegen`. + * + * **Node-only.** This reads the filesystem and uses the TypeScript parser API, so it must never be + * reachable from `stream-chat/i18n` — which is why it lives beside `src/i18n/` rather than inside it. + * `scripts/bundle.mjs` asserts that boundary at build time. + * + * `typescript` is injected through {@link GeneratorConfig.ts} rather than imported, so `stream-chat` + * does not depend on the compiler. + * + * Each SDK keeps a thin script that supplies its own paths: + * + * ```ts + * import ts from 'typescript'; + * import { generateI18nKeys } from 'stream-chat/i18n/codegen'; + * + * generateI18nKeys({ + * ts, + * runtimeDefaultsPath: 'src/i18n/runtimeDefaults.ts', + * keysOut: 'src/i18n/keys.ts', + * fixtureOut: 'src/i18n/__tests__/catalog.fixture.json', + * }); + * ``` + */ +export * from './callSites'; +export * from './generate'; +export * from './guards'; +export * from './stringMaps'; +export * from './types'; diff --git a/src/i18n-codegen/stringMaps.ts b/src/i18n-codegen/stringMaps.ts new file mode 100644 index 0000000000..16214bf809 --- /dev/null +++ b/src/i18n-codegen/stringMaps.ts @@ -0,0 +1,91 @@ +import fs from 'node:fs'; +import type * as ts from 'typescript'; + +import type { TypeScriptModule } from './types'; + +/** + * Reads a flat `Record` export out of a source file. + * + * Parsed rather than imported: `await import()` works under Node's type stripping but warns + * `MODULE_TYPELESS_PACKAGE_JSON` on every run, and the SDK packages cannot be `"type": "module"`. + * + * Throws rather than exiting, so a caller — including a test — can handle the failure. + */ +export const readStringMap = ({ + exportName, + file, + ts: tsModule, +}: { + exportName: string; + file: string; + ts: TypeScriptModule; +}): Map => { + if (!fs.existsSync(file)) { + throw new Error( + `i18n-codegen: could not read the file expected to export \`${exportName}\`: ${file}`, + ); + } + + const source = tsModule.createSourceFile( + file, + fs.readFileSync(file, 'utf8'), + tsModule.ScriptTarget.Latest, + true, + tsModule.ScriptKind.TS, + ); + + const out = new Map(); + let found = false; + + tsModule.forEachChild(source, (node) => { + if (!tsModule.isVariableStatement(node)) return; + + for (const declaration of node.declarationList.declarations) { + if ( + !tsModule.isIdentifier(declaration.name) || + declaration.name.text !== exportName || + !declaration.initializer + ) { + continue; + } + + // `= { … } as const` and `satisfies …` are both fine. + let initializer: ts.Expression = declaration.initializer; + while ( + tsModule.isAsExpression(initializer) || + tsModule.isSatisfiesExpression(initializer) + ) { + initializer = initializer.expression; + } + if (!tsModule.isObjectLiteralExpression(initializer)) continue; + + found = true; + for (const property of initializer.properties) { + if (!tsModule.isPropertyAssignment(property)) { + throw new Error( + `i18n-codegen: ${exportName} in ${file} must be a flat object of string literals, got: ` + + property.getText(source).slice(0, 80), + ); + } + if ( + !tsModule.isStringLiteralLike(property.name) || + !tsModule.isStringLiteralLike(property.initializer) + ) { + throw new Error( + `i18n-codegen: ${exportName} entries must be 'quoted.key': 'string literal', got: ` + + property.getText(source).slice(0, 80), + ); + } + out.set(property.name.text, property.initializer.text); + } + } + }); + + if (!found) { + throw new Error( + `i18n-codegen: could not find an exported \`${exportName}\` object literal in ${file}`, + ); + } + + return out; +}; diff --git a/src/i18n-codegen/types.ts b/src/i18n-codegen/types.ts new file mode 100644 index 0000000000..ca46165d97 --- /dev/null +++ b/src/i18n-codegen/types.ts @@ -0,0 +1,91 @@ +import type * as ts from 'typescript'; + +/** + * The TypeScript module, injected by the caller. + * + * Injected rather than imported so `stream-chat` never depends on the compiler. Only the parser API is + * used — no `Program`, no type checker — so this needs no tsconfig and is fast. Both UI SDKs already + * have `typescript` as a devDependency, which is the only place this runs. + */ +export type TypeScriptModule = typeof ts; + +export type GeneratorConfig = { + /** The TypeScript module. See {@link TypeScriptModule}. */ + ts: TypeScriptModule; + /** Path to the file exporting `runtimeDefaults`. */ + runtimeDefaultsPath: string; + /** Where to write the generated catalog. */ + keysOut: string; + /** Source root to scan for `t()` call sites. Default `'src'`. */ + srcRoot?: string; + /** Directory names to skip while scanning. Default `['__tests__', 'mock-builders']`. */ + ignoreDirs?: string[]; + /** + * Where to write a JSON data twin of the catalog. + * + * `keys.ts` is type-only, so no runtime test can iterate it. This is what lets a test render every + * key and assert none surfaces as its own dotted path — the strongest regression net in the i18n + * suite. Put it under `__tests__` so it never reaches the published build. + */ + fixtureOut?: string; + /** + * Emit a `BundledTranslationKey` union alongside `TranslationCatalog`. + * + * Prefix-matching `timestamp.` / `duration.` is not enough for an SDK whose bundled set also includes + * ordinary prose resolved by name at runtime (screen-reader labels, lookup-table entries). + */ + emitBundledKeyUnion?: boolean; + /** + * Extra prefixes whose values are expressions rather than copy, added to the built-in + * `timestamp.` / `duration.`. Excluded from the translator-facing JSON export. + */ + extraFormatterPrefixes?: string[]; + /** Write a translator-facing JSON export. */ + json?: { + out: string; + /** Include formatter expressions. Off by default: a TMS that translates them breaks dates. */ + includeFormats?: boolean; + }; + /** Doc reference quoted in the "these carry English copy" hint. */ + migrationGuideRef?: string; + /** Where to report progress. Defaults to `console.log`. */ + log?: (message: string) => void; +}; + +export type CallSiteCopy = { + /** `key -> English copy` for every key written with an inline default. */ + copy: Map; + /** + * `key -> file` for keys called with no inline copy — `t('timestamp.MessageTimestamp', {…})`. + * These must be present in `runtimeDefaults` or they render as the raw key. + */ + withoutCopy: Map; + /** Keys seen with two different inline copies — a key must render one thing. */ + conflicts: Array<{ key: string; a: string; b: string; file: string }>; +}; + +export type GuardFailureKind = + | 'conflicting-copy' + | 'unresolvable-key' + | 'shadowed-key' + | 'prefix-collision'; + +/** + * A guard failure, as data. + * + * Returned rather than printed so tests can assert on the failure itself instead of scraping stderr — + * which is most of why the fixture suite for this was as large as it was. + */ +export type GuardFailure = { + kind: GuardFailureKind; + summary: string; + entries: string[]; +}; + +export type GeneratedCatalog = { + /** Every key mapped to its English copy, sorted. */ + catalog: Map; + /** Keys resolved from `runtimeDefaults` rather than an inline default. */ + bundledKeys: string[]; + failures: GuardFailure[]; +}; diff --git a/test/unit/i18n-codegen/generate.test.ts b/test/unit/i18n-codegen/generate.test.ts new file mode 100644 index 0000000000..83ed946095 --- /dev/null +++ b/test/unit/i18n-codegen/generate.test.ts @@ -0,0 +1,360 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import ts from 'typescript'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { buildCatalog, generateI18nKeys, readStringMap } from '../../../src/i18n-codegen'; +import type { GeneratorConfig } from '../../../src/i18n-codegen'; + +/** + * Fixtures are written to a scratch directory and the generator runs in-process against them. + * + * In-process rather than by spawning the script: the failures come back as data, so a test asserts on + * the failure itself instead of scraping stderr — which is what most of the length of the SDK-side + * version of this suite was. It also means real stack traces on failure. + */ +const scratchDirs: string[] = []; + +const makeProject = (files: Record) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'i18n-codegen-')); + scratchDirs.push(dir); + for (const [relative, contents] of Object.entries(files)) { + const full = path.join(dir, relative); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, contents); + } + return dir; +}; + +const configFor = ( + dir: string, + overrides: Partial = {}, +): GeneratorConfig => ({ + keysOut: path.join(dir, 'src/i18n/keys.ts'), + log: () => {}, + runtimeDefaultsPath: path.join(dir, 'src/i18n/runtimeDefaults.ts'), + srcRoot: path.join(dir, 'src'), + ts, + ...overrides, +}); + +const runtimeDefaults = (entries: Record) => + `export const runtimeDefaults = {\n${Object.entries(entries) + .map(([k, v]) => ` ${JSON.stringify(k)}: ${JSON.stringify(v)},`) + .join('\n')}\n};\n`; + +afterEach(() => { + while (scratchDirs.length) { + fs.rmSync(scratchDirs.pop() as string, { force: true, recursive: true }); + } +}); + +describe('call-site reading', () => { + it('collects prose, plural and bundled keys', () => { + const dir = makeProject({ + 'src/Component.tsx': ` + const C = () => { + t('common.cancel.label', 'Cancel'); + t('channel.memberCount.title', { + count, + defaultValue_one: '{{ count }} member', + defaultValue_other: '{{ count }} members', + }); + t('timestamp.MessageTimestamp', { timestamp }); + return null; + }; + `, + 'src/i18n/runtimeDefaults.ts': runtimeDefaults({ + 'timestamp.MessageTimestamp': '{{ timestamp | timestampFormatter }}', + }), + }); + + const { catalog, failures } = buildCatalog(configFor(dir)); + + expect(failures).toEqual([]); + expect(Object.fromEntries(catalog)).toEqual({ + 'channel.memberCount.title_one': '{{ count }} member', + 'channel.memberCount.title_other': '{{ count }} members', + 'common.cancel.label': 'Cancel', + 'timestamp.MessageTimestamp': '{{ timestamp | timestampFormatter }}', + }); + }); + + it('recognises a `.t(...)` property call as well as a bare `t(...)`', () => { + const dir = makeProject({ + 'src/Component.tsx': `i18n.t('common.ok.label', 'OK');`, + 'src/i18n/runtimeDefaults.ts': runtimeDefaults({}), + }); + + const { catalog } = buildCatalog(configFor(dir)); + expect(catalog.get('common.ok.label')).toBe('OK'); + }); + + it('skips ignored directories', () => { + const dir = makeProject({ + 'src/__tests__/Component.test.tsx': `t('only.in.tests', 'Nope');`, + 'src/Component.tsx': `t('real.key', 'Yes');`, + 'src/i18n/runtimeDefaults.ts': runtimeDefaults({}), + }); + + const { catalog } = buildCatalog(configFor(dir)); + expect(catalog.has('only.in.tests')).toBe(false); + expect(catalog.has('real.key')).toBe(true); + }); +}); + +describe('guards', () => { + it('fails on a key used with two different inline copies', () => { + const dir = makeProject({ + 'src/A.tsx': `t('common.cancel.label', 'Cancel');`, + 'src/B.tsx': `t('common.cancel.label', 'Dismiss');`, + 'src/i18n/runtimeDefaults.ts': runtimeDefaults({}), + }); + + const { failures } = buildCatalog(configFor(dir)); + + expect(failures.map((f) => f.kind)).toEqual(['conflicting-copy']); + expect(failures[0].entries.join('\n')).toContain('common.cancel.label'); + }); + + /** Without this the key renders as a raw dotted path in the UI. */ + it('fails on a key with no inline default and no bundled entry', () => { + const dir = makeProject({ + 'src/A.tsx': `t('forgot.the.copy');`, + 'src/i18n/runtimeDefaults.ts': runtimeDefaults({}), + }); + + const { failures } = buildCatalog(configFor(dir)); + + expect(failures.map((f) => f.kind)).toEqual(['unresolvable-key']); + expect(failures[0].entries.join('\n')).toContain('forgot.the.copy'); + }); + + /** The bundled value wins, so the call site's copy would silently never render. */ + it('fails on a key present both inline and in the bundled defaults', () => { + const dir = makeProject({ + 'src/A.tsx': `t('common.cancel.label', 'Cancel');`, + 'src/i18n/runtimeDefaults.ts': runtimeDefaults({ + 'common.cancel.label': 'Abort', + }), + }); + + const { failures } = buildCatalog(configFor(dir)); + + expect(failures.map((f) => f.kind)).toEqual(['shadowed-key']); + expect(failures[0].entries.join('\n')).toContain('Abort'); + }); + + it('fails when one key is a strict dotted prefix of another', () => { + const dir = makeProject({ + 'src/A.tsx': ` + t('poll.title', 'Title'); + t('poll.title.text', 'Text'); + `, + 'src/i18n/runtimeDefaults.ts': runtimeDefaults({}), + }); + + const { failures } = buildCatalog(configFor(dir)); + + expect(failures.map((f) => f.kind)).toEqual(['prefix-collision']); + expect(failures[0].entries.join('\n')).toContain('is a strict prefix of'); + }); + + /** Compared on segment boundaries, so a shared word prefix is fine. */ + it('allows a shared prefix that is not a segment boundary', () => { + const dir = makeProject({ + 'src/A.tsx': ` + t('poll.title', 'Title'); + t('poll.titleText', 'Title text'); + `, + 'src/i18n/runtimeDefaults.ts': runtimeDefaults({}), + }); + + expect(buildCatalog(configFor(dir)).failures).toEqual([]); + }); + + it('throws with every failure formatted, and writes nothing', () => { + const dir = makeProject({ + 'src/A.tsx': `t('forgot.the.copy');`, + 'src/i18n/runtimeDefaults.ts': runtimeDefaults({}), + }); + const config = configFor(dir); + + expect(() => generateI18nKeys(config)).toThrow(/no inline default/); + expect(fs.existsSync(config.keysOut)).toBe(false); + }); +}); + +describe('output', () => { + it('writes a sorted, type-only catalog', () => { + const dir = makeProject({ + 'src/A.tsx': ` + t('z.last.label', 'Last'); + t('a.first.label', 'First'); + `, + 'src/i18n/runtimeDefaults.ts': runtimeDefaults({}), + }); + const config = configFor(dir); + + generateI18nKeys(config); + const written = fs.readFileSync(config.keysOut, 'utf8'); + + expect(written).toContain('export type TranslationCatalog = {'); + expect(written.indexOf('a.first.label')).toBeLessThan( + written.indexOf('z.last.label'), + ); + // Type-only: nothing that emits a runtime value. + expect(written).not.toMatch(/^(export )?const /m); + }); + + it('emits the bundled key union only when asked', () => { + const dir = makeProject({ + 'src/A.tsx': `t('a11y.close.label');`, + 'src/i18n/runtimeDefaults.ts': runtimeDefaults({ 'a11y.close.label': 'Close' }), + }); + + const withUnion = configFor(dir, { emitBundledKeyUnion: true }); + generateI18nKeys(withUnion); + expect(fs.readFileSync(withUnion.keysOut, 'utf8')).toContain( + 'export type BundledTranslationKey =', + ); + + const withoutUnion = configFor(dir, { + keysOut: path.join(dir, 'src/i18n/keys-no-union.ts'), + }); + generateI18nKeys(withoutUnion); + expect(fs.readFileSync(withoutUnion.keysOut, 'utf8')).not.toContain( + 'BundledTranslationKey', + ); + }); + + /** `keys.ts` is type-only, so a test cannot iterate it — this is its data twin. */ + it('writes a JSON fixture twin when configured', () => { + const dir = makeProject({ + 'src/A.tsx': `t('common.cancel.label', 'Cancel');`, + 'src/i18n/runtimeDefaults.ts': runtimeDefaults({}), + }); + const fixtureOut = path.join(dir, 'src/i18n/__tests__/catalog.fixture.json'); + + generateI18nKeys(configFor(dir, { fixtureOut })); + + expect(JSON.parse(fs.readFileSync(fixtureOut, 'utf8'))).toEqual({ + 'common.cancel.label': 'Cancel', + }); + }); + + it('excludes formatter expressions from the translator JSON export by default', () => { + const dir = makeProject({ + 'src/A.tsx': ` + t('common.cancel.label', 'Cancel'); + t('timestamp.MessageTimestamp', { timestamp }); + `, + 'src/i18n/runtimeDefaults.ts': runtimeDefaults({ + 'timestamp.MessageTimestamp': '{{ timestamp | timestampFormatter }}', + }), + }); + const jsonOut = path.join(dir, 'en.json'); + + generateI18nKeys(configFor(dir, { json: { out: jsonOut } })); + expect(Object.keys(JSON.parse(fs.readFileSync(jsonOut, 'utf8')))).toEqual([ + 'common.cancel.label', + ]); + + generateI18nKeys(configFor(dir, { json: { includeFormats: true, out: jsonOut } })); + expect(Object.keys(JSON.parse(fs.readFileSync(jsonOut, 'utf8'))).sort()).toEqual([ + 'common.cancel.label', + 'timestamp.MessageTimestamp', + ]); + }); + + it('names formatter keys that still hide English copy', () => { + const dir = makeProject({ + 'src/A.tsx': `t('timestamp.UserActivity', { timestamp });`, + 'src/i18n/runtimeDefaults.ts': runtimeDefaults({ + 'timestamp.UserActivity': 'Last seen {{ timestamp | fromNowFormatter }}', + }), + }); + const logged: string[] = []; + + generateI18nKeys( + configFor(dir, { + json: { out: path.join(dir, 'en.json') }, + log: (m) => logged.push(m), + }), + ); + + // The prose sits beside the interpolation, so a translator working from the export never sees it. + expect(logged.join('\n')).toContain('timestamp.UserActivity'); + expect(logged.join('\n')).toContain('do carry English copy'); + }); + + it('extends the formatter prefixes an SDK excludes', () => { + const dir = makeProject({ + 'src/A.tsx': `t('translationBuilderTopic.notification');`, + 'src/i18n/runtimeDefaults.ts': runtimeDefaults({ + 'translationBuilderTopic.notification': '{{value, notification}}', + }), + }); + const jsonOut = path.join(dir, 'en.json'); + + generateI18nKeys( + configFor(dir, { + extraFormatterPrefixes: ['translationBuilderTopic.'], + json: { out: jsonOut }, + }), + ); + + expect(JSON.parse(fs.readFileSync(jsonOut, 'utf8'))).toEqual({}); + }); +}); + +describe('readStringMap', () => { + it('reads through `as const` and `satisfies`', () => { + const dir = makeProject({ + 'src/i18n/runtimeDefaults.ts': `export const runtimeDefaults = {\n 'a.b': 'C',\n} as const satisfies Record;\n`, + }); + + const map = readStringMap({ + exportName: 'runtimeDefaults', + file: path.join(dir, 'src/i18n/runtimeDefaults.ts'), + ts, + }); + + expect(Object.fromEntries(map)).toEqual({ 'a.b': 'C' }); + }); + + it('throws a named error when the file is missing', () => { + expect(() => + readStringMap({ exportName: 'runtimeDefaults', file: '/nope/missing.ts', ts }), + ).toThrow(/could not read the file expected to export `runtimeDefaults`/); + }); + + it('throws when the export is absent', () => { + const dir = makeProject({ + 'src/i18n/runtimeDefaults.ts': `export const other = {};\n`, + }); + + expect(() => + readStringMap({ + exportName: 'runtimeDefaults', + file: path.join(dir, 'src/i18n/runtimeDefaults.ts'), + ts, + }), + ).toThrow(/could not find an exported `runtimeDefaults` object literal/); + }); + + it('throws when an entry is not a string literal', () => { + const dir = makeProject({ + 'src/i18n/runtimeDefaults.ts': `export const runtimeDefaults = { 'a.b': someVar };\n`, + }); + + expect(() => + readStringMap({ + exportName: 'runtimeDefaults', + file: path.join(dir, 'src/i18n/runtimeDefaults.ts'), + ts, + }), + ).toThrow(/must be 'quoted.key': 'string literal'/); + }); +}); From 1837be494d7538040e8a18e3006ec1a4adc6fba8 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Tue, 18 Aug 2026 00:12:55 +0200 Subject: [PATCH 06/27] docs(i18n): add the v10 i18n migration guide and initiative record MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- CLAUDE.md | 55 +++++- specs/i18n-to-core/decisions.md | 192 ++++++++++++++++++++ specs/i18n-to-core/plan.md | 96 ++++++++++ specs/i18n-to-core/spec.md | 81 +++++++++ specs/i18n-to-core/state.json | 19 ++ v9-to-v10-migration-guide-i18n.md | 282 +++++++++++++++++++++++++++++ v9-to-v10-migration-guide-other.md | 5 +- 7 files changed, 724 insertions(+), 6 deletions(-) create mode 100644 specs/i18n-to-core/decisions.md create mode 100644 specs/i18n-to-core/plan.md create mode 100644 specs/i18n-to-core/spec.md create mode 100644 specs/i18n-to-core/state.json create mode 100644 v9-to-v10-migration-guide-i18n.md diff --git a/CLAUDE.md b/CLAUDE.md index 765abf733c..5d406b7a43 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,12 +34,14 @@ Single test runs use Vitest's CLI directly: `yarn test-unit path/to/file.test.ts `yarn build` runs two things concurrently: 1. `tsc` — emits **declarations only** (`emitDeclarationOnly: true`) to `dist/types`. `rootDir` is `src/`. -2. `scripts/bundle.mjs` (esbuild) — produces three bundles: - - `dist/cjs/index.node.js` (Node CJS, externalizes deps + Node builtins) - - `dist/cjs/index.browser.js` (browser CJS) - - `dist/esm/index.mjs` (browser ESM) +2. `scripts/bundle.mjs` (esbuild) — produces bundles for **three entry points**: + - `index` (the root): `dist/cjs/index.node.js` (Node CJS, externalizes deps + Node builtins), `dist/cjs/index.browser.js` (browser CJS), `dist/esm/index.mjs` (browser ESM) + - `i18n` (`stream-chat/i18n`): the same three variants, `i18n.node.js` / `i18n.browser.js` / `i18n.mjs` + - `i18n-codegen` (`stream-chat/i18n/codegen`): Node only, CJS + ESM — it reads the filesystem, so there is deliberately no browser variant -`package.json#exports` routes consumers to the right bundle by condition: `node` → node-cjs, `browser`/`react-native` → browser-cjs (require) or esm (import), default → esm. There is **no `package.json#browser` field** — it used to zero Node-only deps (`crypto`, `https`, `jsonwebtoken`, `ws`, `zlib`) for browser/RN builds, but the SDK no longer imports any of them (`src/index.ts` is platform-agnostic: global `WebSocket`, global `FormData`, global `atob`). `scripts/bundle.mjs` keeps a `browserIgnoreModules` hook, currently an empty array, for the day that changes. Prefer a platform global or a browser-safe dep over reintroducing a Node-only one. + After building, `assertBundleBoundaries` reads esbuild's `metafile` and fails the build if an entry reached something it must not (see the i18n section). Adding a new entry point without declaring its boundary in `ENTRY_BOUNDARIES` is itself an error. + +`package.json#exports` routes consumers to the right bundle by condition: `node` → node-cjs, `browser`/`react-native` → browser-cjs (require) or esm (import), default → esm. The `react-native` + `require` branch must stay pointed at CJS — React Native's Jest runs CJS with `customConditions: ["react-native"]` and does not transform `node_modules`, so an `.mjs` there is a syntax error across every RN suite that touches the module. `typesVersions` mirrors the subpaths for consumers still on `moduleResolution: "node"`. There is **no `package.json#browser` field** — it used to zero Node-only deps (`crypto`, `https`, `jsonwebtoken`, `ws`, `zlib`) for browser/RN builds, but the SDK no longer imports any of them (`src/index.ts` is platform-agnostic: global `WebSocket`, global `FormData`, global `atob`). `scripts/bundle.mjs` keeps a `browserIgnoreModules` hook, currently an empty array, for the day that changes. Prefer a platform global or a browser-safe dep over reintroducing a Node-only one. esbuild `define` injects two compile-time constants: `process.env.PKG_VERSION` (read from `package.json`) and `process.env.CLIENT_BUNDLE` (one of `node-cjs`, `browser-cjs`, `browser-esm`). Both are consumed by `StreamChat.getUserAgent()` to produce a bundle-aware UA string. **`tsc`-only code paths do not get this substitution** — these env vars only resolve in the esbuild bundles, so don't gate runtime logic on them in code that callers might import directly via `src/`. @@ -70,6 +72,8 @@ This is a single-package SDK with **no monorepo**. The public surface is everyth - `pagination/` — `BasePaginator` (cursor-or-offset, debounced, exposes `state: StateStore`), plus `FilterBuilder` and `ReminderPaginator`. - `reminders/` — `Reminder`, `ReminderManager`, `ReminderTimer` (scheduled-offset reminders with debounced refresh). - `search/` — `BaseSearchSource` + concrete `MessageSearchSource`, `ChannelSearchSource`, `UserSearchSource` orchestrated by `SearchController`. + - `i18n/` — the translation layer shared by the React and React Native SDKs. **Not exported from `src/index.ts`** — see the i18n section below. + - `i18n-codegen/` — build-time translation-catalog generator. A **sibling** of `i18n/`, not a child, so the runtime layer physically cannot reach `node:fs`. - Top-level subsystem files: `poll`, `poll_manager`, `thread`, `thread_manager`, `moderation`, `campaign`, `segment`, `permissions`. - **`types.ts` (~5k lines) + `custom_types.ts` + `types.utility.ts`** — public type surface. **Custom data is extended via module augmentation on the `Custom*Data` interfaces in `custom_types.ts`** (generics were removed in v9; see README). When adding a field that callers may want to extend, expose it through a `Custom*Data` interface rather than reintroducing a generic. @@ -123,6 +127,47 @@ The canonical flow is: Aliases to be aware of: `setUser` → `connectUser`, `disconnect` → `disconnectUser`. Both are deprecated but still present; new code should use the long names. Server-side use (no `window`, or `secret` provided) prints a warning unless `options.allowServerSideConnect: true` is set. +## i18n + +`src/i18n/` holds the translation runtime shared by `stream-chat-react` and +`stream-chat-react-native` — one `StreamI18n`, one set of formatters, one date layer. Before this, both +SDKs carried ~1,300 lines of near-duplicate runtime plus a duplicated codegen. See +`specs/i18n-to-core/` for the initiative and `v9-to-v10-migration-guide-i18n.md` for the consumer delta. + +**Three entry points, and the boundaries between them are enforced by the build.** `src/index.ts` must +**never** `export * from './i18n'` — that is the one reflex to resist. `scripts/bundle.mjs` asserts from +esbuild's metafile that the root bundle cannot reach `src/i18n/`, `i18next` or `dayjs`, and that +`src/i18n/` cannot reach the Node-only `src/i18n-codegen/`. Both leaks fail invisibly (everything works, +the bundle is just bigger), which is why they are machine-checked. `dist/esm/index.mjs` is expected to +stay byte-identical when only i18n changes. + +- **`stream-chat/i18n`** — `StreamI18n`, four formatters, `getDateString`, catalog-generic type helpers, + `TranslationBuilder`, generated `LANGUAGE_NAMES`, `CORE_NOTIFICATION_TRANSLATION_KEY`. +- **`stream-chat/i18n/codegen`** — the catalog generator. `typescript` is **injected** via config, never + imported, so core does not depend on the compiler. + +Things that will bite: + +- **Core ships no catalog.** Each UI SDK generates its own `keys.ts` from its `t()` call sites, so the + type helpers are generic over it (`StreamTFunctionFor`). `Bundled` **must** default + to `never`; defaulting to `string` silently disables all key checking. +- **`runtimeDefaults` is a constructor option**, not an import — the catalog belongs to the UI layer. It + is layered under _every_ language, which is what stops a partial dictionary from knocking out formatter + keys. That is guarantee G1 in `test/unit/i18n/StreamI18nGuarantees.test.ts`, which is the acceptance + contract for this module: three behavioural guarantees, each written against a real bug. +- **No module-scope side effects.** Every `Dayjs.extend` goes through `ensureDayjsPlugins()`. This is + what makes `sideEffects: false` accurate — do not reintroduce a top-level `extend` or locale import. +- **`durationFormatter` must use the date library's `.duration()`**, not parse the value as a timestamp. + Parsing reads `600000` as ten minutes past the epoch and renders "57 years ago". This is why + `DateTimeParser` is the _module_, not a parse function. +- **i18next post-processing is global.** A `TranslationTopic` is invoked for every key and must pass + through calls it does not recognize, or it silently rewrites unrelated copy. +- **Vitest forces `TZ=UTC`** (`vite.config.ts`). Date assertions are timezone-sensitive; without it a + local run disagrees with CI by the host's offset. +- Notification identity lives in `src/notifications/types.ts` (`CORE_NOTIFICATION_TYPE`). Emit through + the map, never a raw literal — a test enforces both that and the reverse (every declared identifier + must actually be emitted, so a dead one cannot linger). + ## Conventions to preserve - ESLint uses the flat config (`eslint.config.mjs`); `yarn eslint` runs with `--max-warnings 0`. The pre-commit hook (`.husky/pre-commit` → `lint-staged`) enforces this on staged files. Don't disable rules broadly — scope and justify any `eslint-disable`. diff --git a/specs/i18n-to-core/decisions.md b/specs/i18n-to-core/decisions.md new file mode 100644 index 0000000000..bd25295dab --- /dev/null +++ b/specs/i18n-to-core/decisions.md @@ -0,0 +1,192 @@ +# i18n to core — decisions + +Decisions taken, with the reasoning that is not recoverable from the diff. Reversals are recorded +rather than rewritten, since the reason a rejected option was rejected is the useful part. + +## Packaging: a subpath, not the root barrel + +`stream-chat/i18n` is a separate entry point. The layer needs `i18next` and `dayjs`, and core had three +runtime dependencies; putting it in the root barrel would make every consumer — Node/SSR, custom UI, +other SDKs — pay for translation machinery they may never use. + +The boundary is asserted from esbuild's `metafile` at build time, in both directions, because a leak +fails invisibly: everything still works, the bundle is just quietly bigger. Verified by deliberately +adding `export * from './i18n'` to `src/index.ts` and confirming the build fails and names every leaked +file. + +## Dependencies: direct, not optional peers — **reversed** + +First built as optional `peerDependencies` to hold core at three runtime dependencies. **Rejected.** + +Optional peers are not installed. A project with only `stream-chat` installed threw +`MODULE_NOT_FOUND: Cannot find module 'dayjs'` on `require('stream-chat/i18n')`, so the requirement was +declared in core but satisfiable only somewhere else — pushing core's own import onto consumers, and +making the UI SDKs responsible for a dependency core is the one importing. Verified both ways against a +packed tarball. + +Accepted cost: ~2.3 MB unpacked in `node_modules` for a consumer who never translates, and five runtime +dependencies rather than three. Bundle size is unaffected either way — the subpath, not the dependency +kind, is what keeps them out of the root bundle. + +Consequence for the UI SDKs: they should **drop** their own `i18next`/`dayjs` declarations rather than +keep them, since two `i18next` instances mean dictionaries registered on one are read from the other. + +## Type layer: generic over the catalog, not module augmentation + +The derivations (`TranslationKeyOf`, `StreamTFunctionFor`, …) live in core while each SDK's generated +catalog stays upstream, so the helpers take the catalog as a type parameter. + +Module augmentation was rejected for two reasons: two catalogs must be able to coexist in one +TypeScript program (a monorepo typechecking both SDKs in one pass), which a single augmented interface +cannot express; and augmentation is ambient, so an SDK's key union would leak into an integrator's +unrelated `t()` calls — the same objection that kept this out of i18next's `CustomTypeOptions`. + +This does not reintroduce the v9-era generics problem. Those were on runtime domain types and infected +every signature; these are type-only aliases instantiated once per SDK, and the generic never appears +in a user-facing signature. + +Prototyped and typechecked before committing: 614-entry synthetic catalog compiles in 0.44 s with all +eight negative assertions firing. **`Bundled` must default to `never`** — defaulting to `string` would +collapse the prose overload and silently disable all key checking. + +Note the ported comment claiming `CopyFor` exceeds TypeScript's union limit (TS2590) **does +not reproduce** on TS 6.0.3 at real catalog size. The constraint is kept anyway — the payoff is small +and TS's limit is a heuristic on instantiation depth × union breadth, so it can trip on a more +interpolation-heavy catalog — but the justification was rewritten to stop citing an error we cannot +substantiate. + +## Reactivity: `StateStore`, not listeners + +React had a single `setLanguageCallback` that one caller clobbers for all others; RN had five listener +members. Both collapse to one `StateStore`, which both SDKs already consume via `useStateStore`. + +`subscribe` fires synchronously with the current value, which dissolves the queued-override race RN +needed `queuedTFunctionOverride` for: `overrideTFunction` before `init()` is just a store write, and a +later subscriber sees it immediately. `init()` must not clobber it, tracked with a flag. + +## `setLanguage` returns `void` + +It previously returned three different shapes (i18next's `TFunction`, `StreamTFunction`, `undefined`). +No call site in either SDK, either example app, or the docs used the value. Returning `t` is actively +misleading once the store exists: it hands out a value valid only until the next language change. + +## `init()` memoized and never cleared + +RN's `waitForInitializing` cleared its guard on completion, leaving a window where a third caller +re-entered initialization. `this.initPromise ??= this.#doInit()` is genuinely idempotent. Two +independent consumers calling this is the normal case, not an edge case — a UI SDK's chat root and its +overlay host both do. + +## `runtimeDefaults` injected, not imported + +The one addition the move forced. `StreamI18n` imported it from a sibling file, and that file is +per-SDK catalog data core cannot import. Each SDK's public export becomes a thin subclass injecting its +own, so `new StreamI18n(...)` keeps working verbatim for integrators while the layering guarantee (G1) +stays tested in core. + +## `TranslationBuilder`: plumbing down, topics stay up + +React routes notification translation through an i18next post-processor; RN dispatches at the render +site. Both are legitimate, and the choice is a UI-layer concern core should not make. Only the +mechanism moves, so RN can adopt the topic later with no core change. + +Non-obvious property worth keeping in mind: i18next post-processing is configured **globally**, so a +topic is invoked for _every_ key and must pass through calls it does not recognize. Getting that wrong +silently rewrites unrelated copy, so there is a test for it. + +## Formatters: four, with `relativeCompactDateFormatter` as an alias + +RN's standalone implementation hardcoded `'Today'` / `'Yesterday'` / `` `${n}d ago` ``, which no +dictionary could translate — and because the wording lived in a formatter body rather than a catalog +value, the codegen's English-prose guard never saw it. It is now an alias of +`timestampFormatter(relativeCompact: true)`, whose wording goes through `t()`. + +`durationFormatter` is typed `number | string`: dayjs accepts both at runtime, and its post-1.11 +signature narrowed to string only, which had forced a cast in React. + +A regression test names the failure mode found while building this: a duration must go through the date +library's `.duration()`. Parsing the number as a timestamp reads 600000 as ten minutes past the epoch +and renders "57 years ago". That also forced `DateTimeParser` to be the date library _module_ rather +than a parse function, since `.duration()` lives on the module. + +## dayjs: no module-scope side effects + +Every `Dayjs.extend` moved into `ensureDayjsPlugins()`, called from the constructor and from +`defaultDateTimeParser` — the latter is what keeps a standalone `getDateString()` working with no +instance in play. That makes `sideEffects: false` accurate for the first time. + +RN's module-scope `Dayjs.updateLocale('en', { calendar, format })` is **not** ported: it rewrote +`L`/`LL`/`LT` for the entire host app, which a chat SDK should not do. + +The `import 'dayjs/locale/en'` both SDKs carried is deleted — dayjs registers `en` before any import +runs, so it was a no-op. + +Explicit `.js` on dayjs subpath imports. `dayjs` stays external, so the specifier survives verbatim +into `dist/esm/i18n.mjs` and has to be valid Node ESM. + +The `formats: {}` / `relativeTime: {}` stubs in the English locale skeleton were initially removed as +cruft and **restored**: `Dayjs.locale()` takes an `ILocale`, which declares both as required, so +omitting them is a type error. Comment added, since their purpose is not self-evident. + +## `Intl.PluralRules` coverage is warned about, not polyfilled + +Hermes ships a partial ICU: the constructor exists but silently falls back to the root locale's rules — +`{ other }` only — for locales it lacks data for. A dictionary correctly supplying `_few` / `_many` then +renders none of them, with no error, locale-specific and order-dependent. + +Core cannot install the polyfill (`intl-pluralrules` is an RN-only need and would be wasted bytes +elsewhere), but it can detect the inadequate environment and say so. Checked inside `init()`, which is +the last moment a polyfill could still have been loaded in time — i18next caches an `Intl.PluralRules` +per language during init. + +## Codegen: `typescript` injected, four guards + +Injected via config so core does not depend on the compiler; only the parser API is used, so no +`Program` and no type checker. + +Four guards, not the five RN had. The dropped one policed `EXTERNAL_STRING_KEYS` — that map is gone, +because notifications resolve through a stable identifier instead of by matching English prose. + +Guards return failures as data with a thin printer on top, so tests assert on the failure rather than +scraping stderr and run in-process rather than spawning. That is most of why the SDK-side suite was 381 +lines. + +## Naming: `StreamI18n` + +Both SDKs spell it `Streami18n`. Core normalizes the initialism; each SDK re-exports `Streami18n` as a +deprecated alias for one cycle so no integrator code breaks on the rename alone. + +## Notification `type`, not `code` + +Keeping the field name. Renaming to match its own mislabelled JSDoc would touch ~120 emission sites +across three repos plus two dispatcher maps, for no benefit. The JSDoc was corrected instead. + +`type?: CoreNotificationType | (string & {})` keeps the field open, mirroring `NotificationSeverity` +two lines above it in the same file. Narrowing to a closed union would break the ~120 identifiers the +SDKs and integrators emit. + +## Poll validation errors keep `message` alongside `code` + +A bare code would be smaller but strands every consumer without an i18n layer. Keeping `message` means +a plain-JS integrator gets a compile error with a one-property fix (`errors.name.message`) rather than +a silently blank field, and an unrecognized code degrades to readable text. + +They are deliberately **not** notifications: field-level form state rendered inline next to an input, +where a toast per keystroke would be wrong. + +## Corrections to the initial analysis + +Recorded because each was asserted before being checked, and each changed the work: + +- `connection:lost` is **not** a notification type — it is `OfflineErrorType` in + `offline-support/types.ts`, a separate taxonomy. Out of scope for the notification union; three core + identifiers were unmapped, not four. +- **Every** core emission site already carried a `type`. `commandUtils.ts:65` appeared un-typed only + because its `type` sits more than eight lines from its `addWarning` call, outside a grep window. +- No eslint override is needed for `src/i18n/**`. `import/no-extraneous-dependencies` permits + `dependencies` outright — the override was only ever required for the rejected peer variant. +- Importing from the `notifications` barrel does **not** drag `NotificationManager` into the i18n + bundle; esbuild already tree-shakes it. A "fix" importing the module directly measured 27 bytes + _worse_ and was reverted. +- 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 — so a local run disagreed with CI by exactly the host's offset. diff --git a/specs/i18n-to-core/plan.md b/specs/i18n-to-core/plan.md new file mode 100644 index 0000000000..377157cb9f --- /dev/null +++ b/specs/i18n-to-core/plan.md @@ -0,0 +1,96 @@ +# i18n to core — plan + +Four phases. Core is done; the two UI SDK adoptions are not. + +## Phase 0 — Scoped identifiers in core ✅ + +Ships alone, because it is the only irreversible change in the initiative. + +- `CORE_NOTIFICATION_TYPE` + `CoreNotificationType`; every core emission site routed through the map. +- `api:messages:query:failed` / `api:message:query:failed` → `api:message:jump:failed` / + `api:message:jumpToLatest:failed`. +- `Notification.message` documented as a developer-facing fallback, not display copy. +- Poll-composer field errors carry `{ code, message, metadata? }`. +- Drift gates: every declared identifier must be emitted; no raw type literal may appear in `src/`. + +Commit `766b1ddb`. Gate: `yarn lint && yarn types && yarn test-unit --run && yarn build`. + +## Phase 1 — The `stream-chat/i18n` module ✅ + +Based on RN's implementation, which was the later and better of the two. + +- `StreamI18n` with a `StateStore`; catalog-generic type helpers; dayjs handling with no module-scope + side effects; four formatters; `getDateString`. +- Generated `languageNames`, the shared notification key registry, `TranslationBuilder` plumbing. +- `stream-chat/i18n` and `stream-chat/i18n/codegen` exports; second and third bundle entries; build-time + boundary assertion. +- `i18next` + `dayjs` as direct dependencies. + +Commits `8828c57e`, `3b573689`, `db7687d0`, `d9cac551`. 2,752 tests; root bundle byte-identical. + +## Phase 2 — `stream-chat-react` adopts + +**Blocker to clear first:** `src/i18n/types.ts:4` imports `MessageContextValue` from `'../context'` and +line 3 imports `Moment` from `moment-timezone`. Both must be cut before the type machinery can move, or +core gains a circular UI dependency and a devDependency type leak. + +- Delete `Streami18n.ts`, the formatter half of `utils.ts`, `TranslationBuilder/TranslationBuilder.ts`, + `externalStrings.ts` (~1,100 lines). +- `types.ts` → ~8-line instantiation of core's generics, intersecting `LanguageNameCatalog`. +- Delete the 57 `language.*` entries from `runtimeDefaults.ts`; merge core's `languageNameDefaults`. Drop + the `asDynamicKey` + string-compare fallback in `MessageTranslationIndicator.tsx:54`. +- Replace `translatorsByNotificationType.ts` with a `Record` — the + unmapped identifiers now fail to compile. +- Move the `Dayjs.extend` calls out of `context/TranslationContext.tsx`. **Highest-risk line in the + diff**: it fails silently, as malformed dates rather than a throw. +- Rewire to `useStateStore`; `useChat.ts:96` keeps its truthiness check. +- Codegen script 249 → ~15 lines. Delete the stale `sideEffects` entry. Drop `i18next`, `dayjs`, + `moment-timezone`. +- **Add a `catalogRenders` test** — React has no equivalent of RN's strongest regression net, and it is + the test most likely to catch a regression from this refactor. +- One-line PR first: add `release-v15` to `size.yml`'s branch filter, or the bundle-size check never + runs on this branch. + +Gate: `yarn build && tsc -p tsconfig.lib.json --noEmit && yarn lint && yarn test && yarn validate-translations`. +Note `yarn types` checks nothing (solution-file `tsconfig.json` with `files: []`). + +## Phase 3 — `stream-chat-react-native` adopts + +Prerequisites, each its own PR: + +1. **Manifests.** `stream-chat` is declared `^9.51.0` in `package/` and both examples, papered over by a + root `resolutions` entry — and **resolutions do not publish**, so a consumer installing the SDK today + gets `stream-chat@9.x` against v10 source. Bump the declared ranges and delete the resolutions entry. + Same PR: the RN 0.79 / Expo 53 baseline (all four ranges). +2. **Replace `instanceof Streami18n`** in `useStreami18n.ts:20` with a brand check plus a + `logger.warn` on the fallback. Today a cross-bundle-copy instance is silently discarded for a fresh + English default — every dictionary, formatter and registered language gone, no warning. RN has three + physical `stream-chat` copies. +3. Add `V10` to `check-pr.yml`'s branch filter, or every gate is manual. + +Then: delete `src/utils/i18n/**`, same type/codegen shrink as React, drop the module-scope +`Dayjs.updateLocale`, add the four `relativeTime.*` keys the translatable relative-compact formatter +needs, replace `useStreami18n` with the `useStateStore` version, migrate +`new Streami18n(opts, i18nextConfig)` call sites, and newly export `StreamI18nOptions` and the formatter +types — both unreachable today despite `options.formatters` referencing them. + +Optional, and a real gap: RN renders auto-translated message text +(`useTranslatedMessage.ts:24`) with no indicator of the source language, because it has no equivalent of +React's `MessageTranslationIndicator`. `languageNameDefaults` makes the data one merge away; the +component is a small feature to scope explicitly rather than assume. + +## Cross-repo validation + +Use a `yarn pack` tarball, not `link:`/`portal:`. `enableScripts: false` in all three repos means +`link:` never runs core's `prepare`, so a stale `dist/` gets validated — and they read the live +`package.json`, so they cannot prove the subpath actually shipped, which is the thing under test. + +`git diff --exit-code -- package.json yarn.lock` as an explicit exit gate. Precedent: RN commit +`9dc3f5f6f` committed `portal:/Users/isekovanic/Projects/stream-chat-js`, which resolved on one machine +and failed 399 tests everywhere else. + +End-to-end proof is `examples/SampleApp/src/i18n/` (German + Italian + switcher). Assert in order, +because each isolates a different failure: copy switches → registration and resolution work; month/day +names switch → the locale import reached _core's_ dayjs instance, not a second copy; relative dates read +"Gestern" not "Last Mittwoch" → the calendar plugin was extended on core's dayjs and the per-key +`calendarFormats` still lands. diff --git a/specs/i18n-to-core/spec.md b/specs/i18n-to-core/spec.md new file mode 100644 index 0000000000..003ae8b856 --- /dev/null +++ b/specs/i18n-to-core/spec.md @@ -0,0 +1,81 @@ +# i18n to core — one translation runtime for both UI SDKs + +Status: **core landed** (2026-08). Scope: `stream-chat-js` (this initiative), then +`stream-chat-react` and `stream-chat-react-native` adopt. + +## Why + +`stream-chat-react` (v15) and `stream-chat-react-native` (v10) independently converged on the same +i18n architecture — English-only bundle, stable dotted keys with the English copy inline as i18next's +`defaultValue`, a generated type-only key catalog, and a drift gate on it. Two separate ports: + +- React: `17c91bc70 feat(i18n): english-only bundle with namespaced, type-checked translation keys (#3261)` (305 files, +9865/−11234) +- RN: `a26851d79 feat(i18n): ship English only and rework the public i18n surface` (+683/−7447) + +That left ~1,300–1,500 lines of near-duplicate **runtime** in two repos, plus a duplicated codegen +toolchain that differed by five lines in one file and ~60 in the other. `stream-chat` had no +localization code at all. All three packages were shipping breaking releases, which made it the only +cheap window to move the shared layer down. + +The sharper motivation was not the duplication itself but what the duplication was working around: +**core emitted user-facing English prose, and both SDKs reverse-mapped it by exact string match** to +resolve a translation. Both carried the same comment — _"Renaming the notification messages at the +source needs a `stream-chat` change; until then this table is the seam"_. + +## What was actually wrong + +Investigation found the mechanism already existed and was mostly working, which changed the shape of +the work from greenfield to typing and gap-filling: + +- `Notification.type` already carried a `domain:entity:operation:result` identifier on every core + emission site. Its JSDoc documented a field named `code`, which does not exist — that mislabelling is + why the mechanism looked absent. +- Because `type` was a bare `string`, **both SDKs hand-maintained the same 16-entry `type → key` table** + and the copies had drifted in both directions: entries for identifiers nothing emits + (`api:reply:search:failed`, mapped by both, emitted by neither), and core identifiers neither mapped + (falling through to the English-string fallback, which is why React's map contained + `'Command not ready to be sent'`). +- Core's own naming had drifted too: `api:messages:query:failed` and `api:message:query:failed` were + two different operations distinguished only by a plural `s`, in the counterintuitive direction. +- **Poll-composer field errors had no identifier at all** — plain English in a `Record`. + That is the one place core genuinely emitted unkeyed prose. + +## Shipped + +- **`stream-chat/i18n` subpath** — `StreamI18n` (reactive via `StateStore`), four formatters, + `getDateString`, catalog-generic type helpers, `TranslationBuilder` plumbing, generated language + names, the shared notification key registry. +- **`stream-chat/i18n/codegen` subpath** — the catalog generator, Node-only, with `typescript` + injected rather than imported. Verified to reproduce both SDKs' real committed catalogs identically + (React 634/634, RN 408/408 + 97 bundled). +- **Scoped identifiers** — `CORE_NOTIFICATION_TYPE` / `CoreNotificationType` and + `POLL_VALIDATION_CODE` / `PollValidationError`, both exhaustiveness-checked. +- `i18next` and `dayjs` as direct dependencies of `stream-chat`. + +Consumer-facing delta: `v9-to-v10-migration-guide-i18n.md`. + +## Invariants the implementation has to hold + +Three behavioural guarantees, ported from RN's `Streami18nGuarantees.test.ts` where each was written +against a real bug found reviewing the web implementation. They are core's acceptance contract now, so +a third SDK cannot regress them and neither UI SDK has to keep a copy: + +- **G1** — the SDK's bundled defaults are layered under _every_ language, however it was selected. If + not, a formatter key renders as its own dotted path and a timestamp as an unformatted ISO string. +- **G2** — a partial dictionary is safe: an unsupplied key renders English, never a raw dotted path. + This includes not letting an integrator's `parseMissingKeyHandler` blank out prose keys, since + i18next counts every prose key as "missing". +- **G3** — selecting an unregistered language warns and continues. It must not silently reset to `en`, + which discards the integrator's choice and makes the cause very hard to see. + +Two structural invariants enforced by the build rather than by review, because both fail invisibly: + +- The **root bundle must not reach `src/i18n/`** or its dependencies. Asserted from esbuild's metafile; + `dist/esm/index.mjs` is byte-identical at 907,599 bytes. +- The **runtime i18n layer must not reach `src/i18n-codegen/`**, which is Node-only. + +## Not in scope here + +The catalogs themselves. Each UI SDK generates `keys.ts` from its own `t()` call sites, so they stay +upstream — which is what forced core's type helpers to be generic over the catalog rather than +augmentation-based (two catalogs must be able to coexist in one TypeScript program). diff --git a/specs/i18n-to-core/state.json b/specs/i18n-to-core/state.json new file mode 100644 index 0000000000..b84f27c53f --- /dev/null +++ b/specs/i18n-to-core/state.json @@ -0,0 +1,19 @@ +{ + "active_task": "Phase 2 — stream-chat-react adopts", + "tasks": { + "Phase 0 — scoped identifiers in core": "done", + "Phase 1 — the stream-chat/i18n module": "done", + "Phase 1 — migration guide and initiative record": "done", + "Phase 2 — stream-chat-react adopts": "todo", + "Phase 3 — stream-chat-react-native adopts": "todo" + }, + "flags": { + "blocked": false, + "needs-review": false + }, + "notes": [ + "Phase 2 is blocked on cutting two imports in stream-chat-react/src/i18n/types.ts: MessageContextValue from '../context' (circular UI dependency) and Moment from 'moment-timezone' (devDependency type leak).", + "Phase 3 is blocked on RN bumping its declared stream-chat range; the root resolutions entry masking it does not publish." + ], + "last_updated": "2026-08-18" +} diff --git a/v9-to-v10-migration-guide-i18n.md b/v9-to-v10-migration-guide-i18n.md new file mode 100644 index 0000000000..ec9933d20d --- /dev/null +++ b/v9-to-v10-migration-guide-i18n.md @@ -0,0 +1,282 @@ +# v9 → v10 Migration Guide — Notifications, Poll Validation & i18n + +> Scope: this guide covers **notification identity** (`Notification.type` and `Notification.message`), the **shape of poll-composer field errors**, and the new **`stream-chat/i18n`** and **`stream-chat/i18n/codegen`** subpath exports. It is relevant to you even if you never translate anything: the notification and poll-error changes affect any app that renders either. +> +> Sibling guides: +> +> - `v9-to-v10-migration-guide-client-construction.md` (constructor & options) +> - `v9-to-v10-migration-guide-logging.md` (`chatLoggerSystem`, sinks, scopes) +> - `v9-to-v10-migration-guide-methods.md` (per-method signatures) +> - `v9-to-v10-migration-guide-server-side.md` (server-side surface removal) +> - `v9-to-v10-migration-guide-sort.md` (`SortParamRequest[]` shape) +> - `v9-to-v10-migration-guide-type-renames.md` (type aliases → generated names) +> - `v9-to-v10-migration-guide-other.md` (everything else) + +## TL;DR + +- **Two notification identifiers were renamed.** `api:messages:query:failed` → `api:message:jump:failed`, and `api:message:query:failed` → `api:message:jumpToLatest:failed`. They were a singular/plural split describing two _different_ operations, which made the pair impossible to grep for reliably. **Breaking** if you switch on either. +- **`PollComposerFieldErrors` values are now objects**, not bare English strings: `{ code, message, metadata? }`. Read `.message` for the previous value, or switch on `.code` to localize. **Breaking.** +- **`Notification.type` is now typed** as `CoreNotificationType | (string & {})` and enumerated in the exported `CORE_NOTIFICATION_TYPE` map. Additive — your own identifiers still pass. +- **`Notification.message` is now documented as a developer-facing fallback, not display copy.** Its wording is not part of the public contract and may change in a minor release. Nothing breaks today, but anything user-facing should switch on `type`. See [Rendering notifications](#rendering-notifications). +- **New subpath `stream-chat/i18n`** carries the shared translation runtime (`StreamI18n`, formatters, date handling). Nothing is re-exported from `stream-chat`'s root, so the root bundle is unchanged. +- **New subpath `stream-chat/i18n/codegen`** carries the build-time translation-catalog generator. Node-only. +- **`stream-chat` now depends on `i18next` and `dayjs`.** Install footprint grows ~2.3 MB; **bundle size is unaffected** unless you import `stream-chat/i18n`. +- Nothing in the JSDoc ever described a `Notification.code` field. There is no such field and never was — the block documenting the `domain:entity:operation:result` scheme was attached to `type` and mislabelled. It has been corrected. + +## Notification identity + +### `type` is the stable identifier; `message` is not + +Every notification `stream-chat` emits carries a `type`: a stable +`domain:entity:operation:result` identifier. That has been true since v10 rc, but it was typed as a bare +`string`, so nothing checked it and nothing enumerated it. + +v10 exports the full set, so you can switch on it with autocomplete and have a typo caught at compile +time: + +```ts +import { CORE_NOTIFICATION_TYPE } from 'stream-chat'; +import type { CoreNotificationType } from 'stream-chat'; + +client.notifications.state.subscribe(({ notifications }) => { + for (const notification of notifications) { + if (notification.type === CORE_NOTIFICATION_TYPE.attachmentUploadFailed) { + // … + } + } +}); +``` + +The field stays open (`CoreNotificationType | (string & {})`), so identifiers emitted by a UI SDK or by +your own code are still valid — you only lose autocomplete for them. + +### Every identifier core emits + +| Identifier | Suggested translation key | +| ------------------------------------------ | ----------------------------------------- | +| `validation:attachment:file:missing` | `notification.attachmentFileMissing` | +| `validation:attachment:id:missing` | `notification.attachmentIdMissing` | +| `validation:attachment:upload:blocked` | `notification.attachmentUploadBlocked` | +| `api:attachment:upload:failed` | `notification.attachmentUploadFailed` | +| `validation:attachment:upload:in-progress` | `notification.attachmentUploadInProgress` | +| `validation:command:disabled` | `notification.commandDisabled` | +| `validation:command:not-ready` | `notification.commandNotReady` | +| `api:location:create:failed` | `notification.locationCreateFailed` | +| `api:message:jump:failed` | `notification.messageJumpFailed` | +| `api:message:jumpToLatest:failed` | `notification.messageJumpToLatestFailed` | +| `validation:poll:castVote:limit` | `notification.pollCastVoteLimit` | +| `api:poll:create:failed` | `notification.pollCreateFailed` | + +The right-hand column is `CORE_NOTIFICATION_TRANSLATION_KEY`, exported from `stream-chat/i18n`. It is +what the React and React Native SDKs both key on, so a dictionary written against these keys is portable +between them. + +`validation:command:disabled` additionally carries `metadata.reason` (`'editing' | 'replying'`), which +its English message varies by. Copy for that key should interpolate `{{ reason }}`. + +### Renamed identifiers + +**Breaking.** Two identifiers described two different operations under near-identical names: + +| v9 / earlier v10 rc | v10 | What it means | +| --------------------------- | --------------------------------- | ------------------------------------ | +| `api:messages:query:failed` | `api:message:jump:failed` | jumping to a specific message failed | +| `api:message:query:failed` | `api:message:jumpToLatest:failed` | jumping to the latest message failed | + +The old pair differed only by a plural `s`, in the opposite order from what you would guess — the +_plural_ name was the single-message jump. Neither UI SDK had ever mapped either one, which is how the +mismatch survived. + +```ts +// v9 / earlier v10 rc +if (notification.type === 'api:messages:query:failed') showJumpError(); + +// v10 +if (notification.type === CORE_NOTIFICATION_TYPE.messageJumpFailed) showJumpError(); +``` + +### Rendering notifications + +`Notification.message` is untranslated English intended as a **developer-facing fallback**. Its exact +wording is not part of the public contract and can be reworded in a minor release. + +This is a contract change rather than an immediate break: the field still exists and still contains the +same text today. But if you render it directly, you are relying on something now documented as unstable, +and you have no way to localize it. + +```ts +// Before — the English sentence is the only thing identifying the notification +toast(notification.message); + +// After — resolve the identifier, and fall back to `message` for one you do not recognize +import { translateNotification } from 'stream-chat/i18n'; + +toast(translateNotification({ notification, t })); +``` + +`translateNotification` resolves `type` through `CORE_NOTIFICATION_TRANSLATION_KEY`, passes `metadata` +as interpolation values, and returns `message` verbatim for an unrecognized identifier — so a newer +`stream-chat` can never produce an empty toast. Pass `translationKeys` to extend the map with your own +identifiers. + +If you are using `stream-chat-react` or `stream-chat-react-native`, this is handled for you; see that +SDK's own i18n guide. + +## Poll-composer field errors + +**Breaking.** Field validation errors on the poll composer were bare English strings, which meant a UI +had to match on prose to localize them. They now carry a stable code: + +```ts +// v9 +type PollComposerFieldErrors = Partial< + Omit, 'options'> & { + options?: Record; + } +>; + +// v10 +type PollValidationError = { + code: PollValidationCode; + /** Untranslated English fallback. Not part of the public contract. */ + message: string; + metadata?: Record; +}; + +type PollComposerFieldErrors = Partial< + Omit, 'options'> & { + options?: Record; + } +>; +``` + +The one-property migration, if you do not want to localize: + +```ts +// v9 +{errors.name} +{errors.options?.[option.id]} + +// v10 +{errors.name?.message} +{errors.options?.[option.id]?.message} +``` + +To localize, switch on `code`: + +```ts +import { POLL_VALIDATION_CODE } from 'stream-chat'; + +const copy: Record = { + [POLL_VALIDATION_CODE.maxVotesNotNumeric]: t('poll.maxVotes.notNumeric'), + // … +}; +const text = errors.name ? (copy[errors.name.code] ?? errors.name.message) : undefined; +``` + +`message` is kept alongside `code` deliberately: a plain-JS integrator gets a compile error with a +one-property fix rather than a silently blank field, and an unrecognized code still renders readable +text. + +### Every poll validation code + +| Code | English fallback | +| --------------------------------------------- | ------------------------------ | +| `validation:poll:maxVotes:notNumeric` | Only numbers are allowed | +| `validation:poll:maxVotes:outOfRange` | Type a number from 2 to 10 | +| `validation:poll:maxVotes:uniqueVoteEnforced` | Enforce unique vote is enabled | +| `validation:poll:name:required` | Question is required | +| `validation:poll:option:duplicate` | Option already exists | +| `validation:poll:option:empty` | Option is empty | + +These are **not** notifications and are deliberately not routed through `NotificationManager` — they are +field-level form state rendered inline next to an input, and a toast per keystroke would be wrong. + +## New subpath: `stream-chat/i18n` + +The translation runtime shared by the React and React Native SDKs now lives in core. If you use a UI +SDK, you do not need to import this directly — the SDK re-exports what you need, bound to its own key +catalog. + +```ts +import { StreamI18n, getDateString, predefinedFormatters } from 'stream-chat/i18n'; +``` + +It is a separate entry point, not part of `stream-chat`'s root barrel, because it pulls in `i18next` and +`dayjs`. **The root bundle is unchanged** — the build fails if anything in `src/i18n/` becomes reachable +from it. + +Notable if you are building custom UI directly on `stream-chat`: + +- `StreamI18n` is generic over your translation catalog: `new StreamI18n(…)`. +- Reactivity goes through `i18n.state`, a `StateStore`. `subscribe` fires synchronously with the current + value, so there is no listener-registration ordering to get right. +- `setLanguage()` returns `Promise`. The new `t` is published to `state`; a returned translator + would go stale on the next language change. +- `init()` is idempotent and safe to call concurrently. +- The keys with no inline default at their call site are injected via the `runtimeDefaults` option, + because the catalog belongs to the UI layer rather than to core. + +## New subpath: `stream-chat/i18n/codegen` + +Build-time only, and **Node-only**: it reads the filesystem and uses the TypeScript parser API. It +generates a type-only translation-key catalog from your `t()` call sites, which is how a mistyped key +becomes a compile error. + +`typescript` is injected rather than imported, so `stream-chat` does not depend on the compiler: + +```ts +import ts from 'typescript'; +import { generateI18nKeys } from 'stream-chat/i18n/codegen'; + +generateI18nKeys({ + ts, + runtimeDefaultsPath: 'src/i18n/runtimeDefaults.ts', + keysOut: 'src/i18n/keys.ts', +}); +``` + +This is primarily for the UI SDKs. You only need it if you maintain your own translation catalog with +the same call-site-as-source-of-truth approach. + +## New dependencies + +`stream-chat` now depends on: + +| Package | Range | Why | +| --------- | ---------- | ------------------------------------------------- | +| `i18next` | `^26.3.6` | the translation runtime behind `stream-chat/i18n` | +| `dayjs` | `^1.11.13` | date and duration formatting | + +Direct dependencies rather than optional peers, so importing `stream-chat/i18n` works without you +installing anything extra. + +Two things to note: + +- **Bundle size is unaffected** if you do not import `stream-chat/i18n`. Both are externalized and the + root bundle is byte-identical. +- **Install footprint grows ~2.3 MB unpacked** (`i18next` ~416 KB, `dayjs` ~1.9 MB) even if you never + translate. This takes `stream-chat` from three runtime dependencies to five, which is a deliberate + trade: a package that imports something should depend on it rather than push the requirement onto + consumers. + +If you already declared `i18next` or `dayjs` because a UI SDK needed them, you can drop them — but check +that only one copy resolves, since two `i18next` instances mean dictionaries registered on one are read +from the other: + +```bash +find . -maxdepth 4 -name i18next -type d -path '*node_modules*' +``` + +## Mechanical migration checklist + +1. `grep -rn "api:messages:query:failed\|api:message:query:failed"` → replace with + `CORE_NOTIFICATION_TYPE.messageJumpFailed` / `.messageJumpToLatestFailed`. +2. `grep -rn "notification.message"` → for anything user-facing, switch on `notification.type` (use + `translateNotification` from `stream-chat/i18n` if you want the mapping done for you). Keep `message` + only as the unrecognized-identifier fallback. +3. Typecheck. Every `PollComposerFieldErrors` read will fail: append `?.message`, or switch on `.code`. +4. If you match notification identifiers anywhere, retype the local as `CoreNotificationType` to get the + set checked. +5. If you declared `i18next` or `dayjs` only for a Stream SDK, remove them and verify a single copy + resolves. diff --git a/v9-to-v10-migration-guide-other.md b/v9-to-v10-migration-guide-other.md index 6e614d9443..98303864df 100644 --- a/v9-to-v10-migration-guide-other.md +++ b/v9-to-v10-migration-guide-other.md @@ -1,11 +1,14 @@ # v9 → v10 Migration Guide — Everything Else -> Scope: this guide catches breaking changes **not** covered by the four topic-specific guides: +> Scope: this guide catches breaking changes **not** covered by the topic-specific guides: > > - `v9-to-v10-migration-guide-client-construction.md` (constructor & options) > - `v9-to-v10-migration-guide-logging.md` (`chatLoggerSystem`, sinks, scopes) > - `v9-to-v10-migration-guide-methods.md` (per-method signatures on `StreamChat`, `Channel`, `ChannelState`, `Moderation`, `StableWSConnection`) > - `v9-to-v10-migration-guide-sort.md` (`SortParamRequest[]` shape) +> - `v9-to-v10-migration-guide-server-side.md` (server-side surface removal, dropped Node-only deps) +> - `v9-to-v10-migration-guide-type-renames.md` (hand-rolled type aliases → generated names) +> - `v9-to-v10-migration-guide-i18n.md` (notification identity, poll-composer field errors, the `stream-chat/i18n` subpath) > > Read those first. This guide covers **exports, removed feature modules, event-type shape, filter constraints, small state/composer shape changes, and residual type/property renames** that the topic guides do not. From bfb6f2a4d22e0a251b228eeeeb463777e67fc28d Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Tue, 18 Aug 2026 01:10:18 +0200 Subject: [PATCH 07/27] fix(i18n): correct five defects the React adoption surfaced 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. --- src/i18n/StreamI18n.ts | 1 + src/i18n/formatters.ts | 108 +++++++++++++++--- src/i18n/types.ts | 38 +++++-- test/unit/i18n/StreamI18n.test.ts | 82 +++++++++++++- test/unit/i18n/getDateString.test.ts | 157 +++++++++++++++++++++++++++ 5 files changed, 358 insertions(+), 28 deletions(-) create mode 100644 test/unit/i18n/getDateString.test.ts diff --git a/src/i18n/StreamI18n.ts b/src/i18n/StreamI18n.ts index 1fcd5b23ea..ecc54f7fb8 100644 --- a/src/i18n/StreamI18n.ts +++ b/src/i18n/StreamI18n.ts @@ -336,6 +336,7 @@ export class StreamI18n< const formatter = factory({ currentLanguage: this.currentLanguage, dateTimeParser: this.DateTimeParser, + logger: this.logger, tDateTimeParser: this.tDateTimeParser, timezone: this.timezone, translate: this.translate, diff --git a/src/i18n/formatters.ts b/src/i18n/formatters.ts index 23ae651035..bffd2cc361 100644 --- a/src/i18n/formatters.ts +++ b/src/i18n/formatters.ts @@ -10,6 +10,29 @@ import type { TimestampFormatterOptions, } from './types'; +/** + * The `relativeTime.*` keys this module renders, with their English copy. + * + * Exported as a catalog fragment because the *call sites* are here, in core, while the *catalog* is + * generated from each UI SDK's own source. Without this an SDK's codegen cannot see these keys, so they + * would drop out of its `TranslationCatalog` and an integrator could no longer type them in a + * dictionary — i.e. could no longer translate relative dates at all. A UI SDK intersects this into its + * catalog type. + * + * Plurals appear as `_one` / `_other` because that is how a dictionary supplies them; English needs no + * distinction, but a language with different forms does. + */ +export const RELATIVE_TIME_CATALOG = { + 'relativeTime.daysAgo_one': '{{ count }}d ago', + 'relativeTime.daysAgo_other': '{{ count }}d ago', + 'relativeTime.today': 'Today', + 'relativeTime.weeksAgo_one': '{{ count }}w ago', + 'relativeTime.weeksAgo_other': '{{ count }}w ago', + 'relativeTime.yesterday': 'Yesterday', +} as const; + +export type RelativeTimeCatalog = typeof RELATIVE_TIME_CATALOG; + /** Defaults for the relative-compact window, matching what both UI SDKs shipped. */ const DEFAULT_RELATIVE_COMPACT_MAX_DAYS = 6; const DEFAULT_RELATIVE_COMPACT_MAX_WEEKS = 3; @@ -36,16 +59,18 @@ const asNumber = (value: unknown, fallback: number) => { */ const parseCalendarFormats = ( value: TimestampFormatterOptions['calendarFormats'], - translate: LooseTranslateFunction, + logger: (message?: string) => void, ): Record | undefined => { if (!value) return undefined; if (typeof value !== 'string') return value; try { return JSON.parse(value) as Record; - } catch { - translate( - asDynamicKey('__invalidCalendarFormats'), - `StreamI18n: calendarFormats is not valid JSON, ignoring it: ${value}`, + } catch (error) { + // Reported through the instance's logger, not through `translate` -- a diagnostic is not copy. + logger( + `StreamI18n: calendarFormats is not valid JSON, ignoring it: ${value} (${ + error instanceof Error ? error.message : String(error) + })`, ); return undefined; } @@ -79,22 +104,42 @@ const relativeCompactDateString = ({ const daysAgo = now.startOf('day').diff(parsed.startOf('day'), 'day'); - if (daysAgo <= 0) return translate('relativeTime.today', 'Today'); - if (daysAgo === 1) return translate('relativeTime.yesterday', 'Yesterday'); + // A future timestamp is not "Today" — fall straight through to a date. + if (daysAgo < 0) return parsed.format('DD/MM/YY'); + + if (daysAgo === 0) + return translate('relativeTime.today', RELATIVE_TIME_CATALOG['relativeTime.today']); + if (daysAgo === 1) + return translate( + 'relativeTime.yesterday', + RELATIVE_TIME_CATALOG['relativeTime.yesterday'], + ); + // Plural defaults rather than one `defaultValue`: English needs no distinction here, but a language + // whose plural categories differ has to be able to supply `_one` / `_other` and have i18next select. if (daysAgo <= maxDays) { - return translate('relativeTime.daysAgo', '{{ count }}d ago', { count: daysAgo }); + return translate('relativeTime.daysAgo', { + count: daysAgo, + defaultValue_one: RELATIVE_TIME_CATALOG['relativeTime.daysAgo_one'], + defaultValue_other: RELATIVE_TIME_CATALOG['relativeTime.daysAgo_other'], + }); } + // `maxWeeks > 0` and a full week elapsed, both required: with `maxWeeks: 0` a 3-day-old timestamp + // has `Math.floor(3 / 7) === 0`, which would otherwise match and render "0w ago". const weeksAgo = Math.floor(daysAgo / 7); - if (weeksAgo <= maxWeeks) { - return translate('relativeTime.weeksAgo', '{{ count }}w ago', { count: weeksAgo }); + if (maxWeeks > 0 && daysAgo >= 7 && weeksAgo <= maxWeeks) { + return translate('relativeTime.weeksAgo', { + count: weeksAgo, + defaultValue_one: RELATIVE_TIME_CATALOG['relativeTime.weeksAgo_one'], + defaultValue_other: RELATIVE_TIME_CATALOG['relativeTime.weeksAgo_other'], + }); } return parsed.format('DD/MM/YY'); }; const timestampFormatter: FormatterFactory = - ({ tDateTimeParser, translate }: FormatterContext) => + ({ logger, tDateTimeParser, translate }: FormatterContext) => (value, _lng, options) => { const { calendar, @@ -122,10 +167,7 @@ const timestampFormatter: FormatterFactory = if (isDayOrMoment(parsed)) { if (calendar && typeof parsed.calendar === 'function') { - return parsed.calendar( - undefined, - parseCalendarFormats(calendarFormats, translate), - ); + return parsed.calendar(undefined, parseCalendarFormats(calendarFormats, logger)); } return parsed.format(format); } @@ -222,15 +264,47 @@ export const getDateString = ({ if (formatDate) return formatDate(new Date(messageCreatedAt)); + // Before the translation-key path, so a caller can ask for relative-compact rendering directly + // rather than only through a key whose expression sets it. Falls through when it declines (a future + // date, or no dayjs-like parser), so the normal formatting still applies. + if (relativeCompact && t && tDateTimeParser) { + const relative = relativeCompactDateString({ + maxDays: asNumber(relativeCompactMaxDays, DEFAULT_RELATIVE_COMPACT_MAX_DAYS), + maxWeeks: asNumber(relativeCompactMaxWeeks, DEFAULT_RELATIVE_COMPACT_MAX_WEEKS), + tDateTimeParser, + timestamp: messageCreatedAt, + translate: t, + }); + if (relative) return relative; + } + if (t && timestampTranslationKey) { - const translated = t(asDynamicKey(timestampTranslationKey), { + // Only forward options that were actually supplied. + // + // These reach i18next as interpolation values and are merged over the arguments the key's own + // formatter expression declares — so passing `format: undefined` explicitly *overrides* + // `timestampFormatter(format: HH:mm)` with nothing, and the timestamp renders as a raw ISO string. + // The caller is usually a component forwarding optional props, so most of these are undefined most + // of the time. + const overrides: Record = {}; + const supplied = { calendar, calendarFormats, format, relativeCompact, relativeCompactMaxDays, relativeCompactMaxWeeks, - timestamp: messageCreatedAt, + }; + for (const [key, value] of Object.entries(supplied)) { + if (value !== undefined) overrides[key] = value; + } + + const translated = t(asDynamicKey(timestampTranslationKey), { + ...overrides, + // A `Date`, not the raw value. Integrators override a `timestamp.*` key with their own + // formatter, and those read `options.timestamp` expecting a Date — passing the string through + // breaks them with `timestamp.toISOString is not a function`. + timestamp: new Date(messageCreatedAt), }); // i18next echoes the key back when nothing resolved it, which is how a miss is detected. if (translated !== timestampTranslationKey) return translated; diff --git a/src/i18n/types.ts b/src/i18n/types.ts index a8a2cdfde1..27f1387b22 100644 --- a/src/i18n/types.ts +++ b/src/i18n/types.ts @@ -205,16 +205,25 @@ export type StreamTFunctionFor< * Structural on purpose: naming `moment-timezone` here would leak a type-only dependency into the * published `.d.ts`, so consumers without it installed got unresolved types. Bring-your-own-Moment * still works — it satisfies this shape. + * + * Declared with **method shorthand**, not property-with-function-type, and that is load-bearing: under + * `strictFunctionTypes` a function *property* is checked contravariantly, so `calendar?: (ref?: + * unknown) => string` demands an implementation accepting literally anything and Dayjs — whose + * `calendar` takes a narrower union — is not assignable. Method syntax is checked bivariantly, which is + * what duck-typing across two date libraries needs. */ export type DateTimeLike = { - format: (template?: string) => string; - calendar?: (referenceTime?: unknown, formats?: Record) => string; - fromNow?: (withoutSuffix?: boolean) => string; - diff: (other: unknown, unit?: string) => number; - startOf: (unit: string) => DateTimeLike; - valueOf: () => number; + format(template?: string): string; + calendar?(referenceTime?: DateTimeReference, formats?: Record): string; + fromNow?(withoutSuffix?: boolean): string; + diff(other: DateTimeReference, unit?: string): number; + startOf(unit: string): DateTimeLike; + valueOf(): number; }; +/** Anything a date library will accept as a point in time. */ +type DateTimeReference = DateTimeLike | Date | string | number | null | undefined; + export type TDateTimeParserInput = string | number | Date; export type TDateTimeParserOutput = string | number | Date | DateTimeLike; @@ -251,15 +260,22 @@ export type DateTimeParserModule = ((input?: TDateTimeParserInput) => DateTimeLi * ---------------------------------------------------------------------------------------------- */ /** - * A translate function loose enough for formatter internals. + * A translate function loose enough for formatter internals and for accepting any SDK's narrowed `t`. * * Formatters resolve keys they are handed at runtime (and their own `relativeTime.*` copy), so they * cannot be typed against a specific catalog. + * + * The parameters are `any` deliberately, and narrowing them breaks callers. An SDK's `t` is a + * four-overload callable whose key parameter is a union of its own catalog keys; under + * `strictFunctionTypes` a function *parameter* is checked contravariantly, so a `t` accepting only its + * own keys is **not** assignable to one declared as accepting any `string`. Typing these as `string` / + * `Record` therefore forces a cast at every call site that passes a real `t` in — + * which was nine of them in `stream-chat-react` alone. */ export type LooseTranslateFunction = ( - key: string, - defaultValueOrOptions?: string | Record, - options?: Record, + key: any, + defaultValueOrOptions?: any, + options?: any, ) => string; /** @@ -270,6 +286,8 @@ export type LooseTranslateFunction = ( */ export type FormatterContext = { currentLanguage: string; + /** The instance's logger, for diagnostics. A malformed formatter argument is not copy. */ + logger: (message?: string) => void; /** The date library module. `durationFormatter` needs `.duration()`, which lives here. */ dateTimeParser: DateTimeParserModule; /** Parses a single timestamp, with the active locale and timezone already applied. */ diff --git a/test/unit/i18n/StreamI18n.test.ts b/test/unit/i18n/StreamI18n.test.ts index a81f40a70b..f2ac7960c7 100644 --- a/test/unit/i18n/StreamI18n.test.ts +++ b/test/unit/i18n/StreamI18n.test.ts @@ -1,6 +1,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { StreamI18n, type StreamI18nState } from '../../../src/i18n'; +import { + isDayOrMoment, + RELATIVE_TIME_CATALOG, + StreamI18n, + type StreamI18nState, + type TDateTimeParserOutput, +} from '../../../src/i18n'; import { fixtureRuntimeDefaults, type FixtureBundledKey, @@ -243,3 +249,77 @@ describe('StreamI18n', () => { }); }); }); + +describe('DateTimeLike', () => { + /** + * Regression: `DateTimeLike` must be declared with **method shorthand**, not + * property-with-function-type. Under `strictFunctionTypes` a function property is checked + * contravariantly, which makes a real Dayjs instance unassignable — its `calendar` takes a narrower + * reference type than a permissive structural signature demands. Method syntax is bivariant, which is + * what duck-typing across dayjs and moment needs. + * + * A type-level assertion, so this fails at `yarn types` rather than at runtime. + */ + it('accepts a real dayjs instance', async () => { + const i18n = setup(); + const { tDateTimeParser } = await i18n.init(); + const parsed = tDateTimeParser('2026-03-13T14:32:00.000Z'); + + // If `DateTimeLike` regresses to property syntax, assigning the parser output fails to compile. + const asDateTimeLike: TDateTimeParserOutput = parsed; + expect(asDateTimeLike).toBeDefined(); + expect(isDayOrMoment(asDateTimeLike)).toBe(true); + }); +}); + +describe('RELATIVE_TIME_CATALOG', () => { + /** + * These keys are rendered by core but declared in each SDK's catalog, so the two have to agree. If a + * key here stops being emitted, an integrator silently loses the ability to translate it — the English + * default still renders, so nothing looks broken. + */ + it('declares exactly the keys the relative-compact formatter renders', async () => { + const i18n = setup({ + runtimeDefaults: { + ...fixtureRuntimeDefaults, + 'timestamp.Relative': + '{{ timestamp | timestampFormatter(relativeCompact: true) }}', + }, + }); + const { t } = await i18n.init(); + const render = (daysAgo: number) => + (t as unknown as (k: string, o: Record) => string)( + 'timestamp.Relative', + { timestamp: new Date(Date.now() - daysAgo * 86_400_000).toISOString() }, + ); + + expect(render(0)).toBe(RELATIVE_TIME_CATALOG['relativeTime.today']); + expect(render(1)).toBe(RELATIVE_TIME_CATALOG['relativeTime.yesterday']); + expect(render(3)).toBe('3d ago'); + expect(render(14)).toBe('2w ago'); + }); + + it('is translatable through a dictionary', async () => { + const i18n = setup({ + language: 'de', + runtimeDefaults: { + ...fixtureRuntimeDefaults, + 'timestamp.Relative': + '{{ timestamp | timestampFormatter(relativeCompact: true) }}', + }, + }); + i18n.registerTranslation('de', { + 'relativeTime.daysAgo_other': 'vor {{ count }} Tagen', + 'relativeTime.today': 'Heute', + } as never); + const { t } = await i18n.init(); + const render = (daysAgo: number) => + (t as unknown as (k: string, o: Record) => string)( + 'timestamp.Relative', + { timestamp: new Date(Date.now() - daysAgo * 86_400_000).toISOString() }, + ); + + expect(render(0)).toBe('Heute'); + expect(render(3)).toBe('vor 3 Tagen'); + }); +}); diff --git a/test/unit/i18n/getDateString.test.ts b/test/unit/i18n/getDateString.test.ts new file mode 100644 index 0000000000..7329573960 --- /dev/null +++ b/test/unit/i18n/getDateString.test.ts @@ -0,0 +1,157 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { getDateString, StreamI18n } from '../../../src/i18n'; + +/** + * A key whose formatter expression carries its own arguments — the shape a UI SDK actually ships. + * + * Multi-argument, because that is where the bug this suite pins was: a single argument masked it. + */ +const KEY = 'timestamp.MessageTimestamp'; +const KEY_VALUE = '{{ timestamp | timestampFormatter(calendar: false; format: HH:mm) }}'; +const AT = '2019-04-03T14:42:47.087Z'; + +const setup = async (runtimeDefaults: Record = { [KEY]: KEY_VALUE }) => { + const i18n = new StreamI18n({ logger: () => {}, runtimeDefaults }); + return i18n.init(); +}; + +describe('getDateString', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2019-04-03T18:00:00.000Z')); + }); + + /** + * Regression, and the reason this file exists. + * + * The options `getDateString` forwards reach i18next as interpolation values and are merged *over* + * the arguments the key's own formatter expression declares. Forwarding `format: undefined` — which + * is the normal case, since callers are components passing optional props straight through — + * therefore overrode `timestampFormatter(format: HH:mm)` with nothing, and the timestamp rendered as + * a raw ISO string (`2019-04-03T14:42:47+00:00`) instead of `14:42`. + * + * It fails only through this path: calling `t(KEY, { timestamp })` directly renders correctly, which + * is what made it invisible in the unit tests for the formatter itself. + */ + it('does not let undefined options override the key’s own formatter arguments', async () => { + const { t, tDateTimeParser } = await setup(); + + expect( + getDateString({ + calendar: undefined, + calendarFormats: undefined, + format: undefined, + formatDate: undefined, + messageCreatedAt: AT, + t: t as never, + tDateTimeParser, + timestampTranslationKey: KEY, + }), + ).toBe('14:42'); + }); + + it('still lets a caller override the key’s arguments when it supplies them', async () => { + const { t, tDateTimeParser } = await setup(); + + expect( + getDateString({ + format: 'YYYY', + messageCreatedAt: AT, + t: t as never, + tDateTimeParser, + timestampTranslationKey: KEY, + }), + ).toBe('2019'); + }); + + it('renders the same through t() and through getDateString', async () => { + const { t, tDateTimeParser } = await setup(); + const direct = (t as unknown as (k: string, o: Record) => string)( + KEY, + { + timestamp: AT, + }, + ); + + expect( + getDateString({ + messageCreatedAt: AT, + t: t as never, + tDateTimeParser, + timestampTranslationKey: KEY, + }), + ).toBe(direct); + }); + + it('lets an integrator formatDate win over everything', async () => { + const { t, tDateTimeParser } = await setup(); + + expect( + getDateString({ + formatDate: () => 'CUSTOM', + messageCreatedAt: AT, + t: t as never, + tDateTimeParser, + timestampTranslationKey: KEY, + }), + ).toBe('CUSTOM'); + }); + + it('falls through to the parser when the key resolves to nothing', async () => { + const { t, tDateTimeParser } = await setup({}); + + expect( + getDateString({ + format: 'YYYY', + messageCreatedAt: AT, + t: t as never, + tDateTimeParser, + timestampTranslationKey: 'timestamp.NotDeclared', + }), + ).toBe('2019'); + }); + + it('returns null for a missing or unparseable timestamp', async () => { + const { t, tDateTimeParser } = await setup(); + + expect( + getDateString({ messageCreatedAt: undefined, t: t as never, tDateTimeParser }), + ).toBe(null); + expect( + getDateString({ messageCreatedAt: 'not a date', t: t as never, tDateTimeParser }), + ).toBe(null); + }); +}); + +describe('getDateString — options handed to a custom formatter', () => { + /** + * Integrators override a `timestamp.*` key with their own formatter and read `options.timestamp`, + * which they expect to be a `Date`. Forwarding the raw value breaks them with + * `timestamp.toISOString is not a function`. + */ + it('passes the timestamp as a Date', async () => { + const seen: Record[] = []; + const i18n = new StreamI18n({ + logger: () => {}, + runtimeDefaults: { [KEY]: KEY_VALUE }, + formatters: { + timestampFormatter: () => (v, l, o) => { + seen.push(o as never); + return 'SPY'; + }, + }, + }); + const { t, tDateTimeParser } = await i18n.init(); + + getDateString({ + messageCreatedAt: AT, + t: t as never, + tDateTimeParser, + timestampTranslationKey: KEY, + }); + + expect(seen[0].timestamp).toBeInstanceOf(Date); + expect((seen[0].timestamp as Date).toISOString()).toBe(AT); + }); +}); From fad85a0f8b39c5c6cdbe8e175448f471ede70039 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Tue, 18 Aug 2026 09:46:36 +0200 Subject: [PATCH 08/27] fix(i18n): warn about an unregistered language at init, not at construction 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. --- src/i18n/StreamI18n.ts | 15 ++++++- test/unit/i18n/StreamI18nGuarantees.test.ts | 45 +++++++++++++++++++++ 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/src/i18n/StreamI18n.ts b/src/i18n/StreamI18n.ts index ecc54f7fb8..5cab06b7de 100644 --- a/src/i18n/StreamI18n.ts +++ b/src/i18n/StreamI18n.ts @@ -258,8 +258,10 @@ export class StreamI18n< }, }; - this.validateCurrentLanguage(); - + // Deliberately *not* validating the language here. `registerTranslation()` legitimately runs after + // construction, so warning now would fire for every integrator who registers a dictionary the normal + // way. The check belongs in `init()`, which is the first moment the set of registered languages is + // final. if (options.dayjsLocaleConfigForLanguage) { this.addOrUpdateLocale(language, options.dayjsLocaleConfigForLanguage); } else if (!this.localeExists(language)) { @@ -512,6 +514,15 @@ export class StreamI18n< /** Languages with a dictionary, including those carrying only the bundled defaults. */ getAvailableLanguages = () => Object.keys(this.translations); + /** + * The resource dictionaries handed to i18next, keyed by language. + * + * Not the full English catalog: prose keys are never bundled — they render from the inline + * `defaultValue` at their call site — so `en` holds the bundled defaults plus whatever has been + * registered. + */ + getTranslations = () => this.translations; + /** * A loose translate used by formatters, which resolve keys handed to them at runtime. * diff --git a/test/unit/i18n/StreamI18nGuarantees.test.ts b/test/unit/i18n/StreamI18nGuarantees.test.ts index 3465915701..f740bdfffe 100644 --- a/test/unit/i18n/StreamI18nGuarantees.test.ts +++ b/test/unit/i18n/StreamI18nGuarantees.test.ts @@ -168,3 +168,48 @@ describe('G3 — an unregistered language warns and continues', () => { expect(t('common.loading.text', 'Loading...')).toBe('Loading...'); }); }); + +describe('G3 — when the warning fires', () => { + /** + * Timing matters as much as the message. `registerTranslation()` legitimately runs *after* + * construction — it is the documented way to add a language — so warning in the constructor fires for + * every integrator doing the normal thing, and trains them to ignore it. + */ + it('does not warn at construction, before registerTranslation has had a chance to run', () => { + const logger = vi.fn(); + + new StreamI18n({ + language: 'de', + logger, + runtimeDefaults: fixtureRuntimeDefaults, + }); + + expect(logger).not.toHaveBeenCalledWith( + expect.stringMatching(/no translation dictionary is registered/i), + ); + }); + + it('warns exactly once, at init, when no dictionary ever arrives', async () => { + const logger = vi.fn(); + const i18n = setup({ language: 'de', logger }); + + await i18n.init(); + + const warnings = logger.mock.calls.filter(([message]) => + /no translation dictionary is registered/i.test(String(message)), + ); + expect(warnings).toHaveLength(1); + }); + + it('does not warn when a dictionary was registered before init', async () => { + const logger = vi.fn(); + const i18n = setup({ language: 'de', logger }); + i18n.registerTranslation('de', { 'common.cancel.label': 'Abbrechen' }); + + await i18n.init(); + + expect(logger).not.toHaveBeenCalledWith( + expect.stringMatching(/no translation dictionary is registered/i), + ); + }); +}); From 5a2515d113bc7086c2c8db3b1fce4ceeadaa7816 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Tue, 18 Aug 2026 09:50:14 +0200 Subject: [PATCH 09/27] docs(specs): record Phase 2 completion and the defects adoption surfaced --- specs/i18n-to-core/state.json | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/specs/i18n-to-core/state.json b/specs/i18n-to-core/state.json index b84f27c53f..d343c15ad4 100644 --- a/specs/i18n-to-core/state.json +++ b/specs/i18n-to-core/state.json @@ -1,19 +1,21 @@ { - "active_task": "Phase 2 — stream-chat-react adopts", + "active_task": "Phase 3 \u2014 stream-chat-react-native adopts", "tasks": { - "Phase 0 — scoped identifiers in core": "done", - "Phase 1 — the stream-chat/i18n module": "done", - "Phase 1 — migration guide and initiative record": "done", - "Phase 2 — stream-chat-react adopts": "todo", - "Phase 3 — stream-chat-react-native adopts": "todo" + "Phase 0 \u2014 scoped identifiers in core": "done", + "Phase 1 \u2014 the stream-chat/i18n module": "done", + "Phase 1 \u2014 migration guide and initiative record": "done", + "Phase 2 \u2014 stream-chat-react adopts": "done", + "Phase 3 \u2014 stream-chat-react-native adopts": "todo" }, "flags": { "blocked": false, "needs-review": false }, "notes": [ - "Phase 2 is blocked on cutting two imports in stream-chat-react/src/i18n/types.ts: MessageContextValue from '../context' (circular UI dependency) and Moment from 'moment-timezone' (devDependency type leak).", - "Phase 3 is blocked on RN bumping its declared stream-chat range; the root resolutions entry masking it does not publish." + "Phase 2 landed on stream-chat-react branch feat/i18n-adopt-shared-core (200de6eef). It needs stream-chat@10.0.0-rc.3 for the i18n subpath, so it cannot merge until core publishes; the lockfile was deliberately left untouched and must be regenerated then.", + "React's typecheck sits at a 3-error baseline unrelated to i18n: APIErrorResponse / EventAPIResponse were removed from core by 5073c676, after rc.2 published. React catches up when it bumps.", + "Phase 3 is blocked on RN bumping its declared stream-chat range; the root resolutions entry masking it does not publish.", + "Adopting in React found seven real defects in the shared layer, all fixed in 438b420b and 08a3fe58. Running a UI SDK's existing suite against the port is what found them -- none would have been caught by types, and all render wrong rather than throwing." ], "last_updated": "2026-08-18" } From 6169d237cf67cef3e5e0ad386e5df074c5763675 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Tue, 18 Aug 2026 10:04:06 +0200 Subject: [PATCH 10/27] fix(i18n): render nothing for a timestamp that cannot be formatted 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. --- src/i18n/formatters.ts | 4 ++++ test/unit/i18n/getDateString.test.ts | 28 ++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/src/i18n/formatters.ts b/src/i18n/formatters.ts index bffd2cc361..c911d60320 100644 --- a/src/i18n/formatters.ts +++ b/src/i18n/formatters.ts @@ -150,7 +150,11 @@ const timestampFormatter: FormatterFactory = relativeCompactMaxWeeks, } = options as TimestampFormatterOptions; + // Nothing renderable: empty rather than the stringified value. `null` used to come out as the + // literal text "null" and an unparseable string as "Invalid Date", both of which are junk a user + // can see. `getDateString` has always guarded this; the formatter is a separate path and did not. if (value === null || value === undefined) return ''; + if (typeof value === 'string' && !Date.parse(value)) return ''; if (relativeCompact) { const relative = relativeCompactDateString({ diff --git a/test/unit/i18n/getDateString.test.ts b/test/unit/i18n/getDateString.test.ts index 7329573960..4a8d5f3063 100644 --- a/test/unit/i18n/getDateString.test.ts +++ b/test/unit/i18n/getDateString.test.ts @@ -155,3 +155,31 @@ describe('getDateString — options handed to a custom formatter', () => { expect((seen[0].timestamp as Date).toISOString()).toBe(AT); }); }); + +describe('timestampFormatter — nothing renderable', () => { + /** + * `null` used to render the literal text "null" and an unparseable string "Invalid Date". Both are + * junk a user can see, and both reached the UI because the formatter is a separate path from + * `getDateString`, which has always guarded this. + */ + // `undefined` is deliberately absent: i18next skips interpolation when the value is undefined, so it + // never reaches the formatter and the raw expression comes through. That is unchanged behaviour, and a + // sign the option name is misspelled at the call site. + it.each([ + ['null', null], + ['an unparseable string', 'not a date'], + ['an empty string', ''], + ])('renders empty for %s', async (_label, value) => { + const i18n = new StreamI18n({ + logger: () => {}, + runtimeDefaults: { [KEY]: KEY_VALUE }, + }); + const { t } = await i18n.init(); + + expect( + (t as unknown as (k: string, o: Record) => string)(KEY, { + timestamp: value, + }), + ).toBe(''); + }); +}); From e6b452d894ad43b59740e84946d3500d0f707feb Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Tue, 18 Aug 2026 11:12:20 +0200 Subject: [PATCH 11/27] fix(i18n)!: make the date types accept a real Moment, split the two a11y date helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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` 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` on `CORE_NOTIFICATION_TYPE` instead. --- CLAUDE.md | 2 +- specs/i18n-to-core/state.json | 12 +- src/i18n/formatters.ts | 79 +++++++++++++ src/i18n/index.ts | 1 - src/i18n/notifications.ts | 72 ------------ src/i18n/types.ts | 37 +++++- test/unit/i18n/StreamI18n.test.ts | 40 +++++++ test/unit/i18n/getDateString.test.ts | 64 ++++++++++- test/unit/i18n/notifications.test.ts | 165 --------------------------- v9-to-v10-migration-guide-i18n.md | 37 ++++-- 10 files changed, 248 insertions(+), 261 deletions(-) delete mode 100644 src/i18n/notifications.ts delete mode 100644 test/unit/i18n/notifications.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 5d406b7a43..4b6e3844a3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -142,7 +142,7 @@ the bundle is just bigger), which is why they are machine-checked. `dist/esm/ind stay byte-identical when only i18n changes. - **`stream-chat/i18n`** — `StreamI18n`, four formatters, `getDateString`, catalog-generic type helpers, - `TranslationBuilder`, generated `LANGUAGE_NAMES`, `CORE_NOTIFICATION_TRANSLATION_KEY`. + `TranslationBuilder`, generated `LANGUAGE_NAMES`. - **`stream-chat/i18n/codegen`** — the catalog generator. `typescript` is **injected** via config, never imported, so core does not depend on the compiler. diff --git a/specs/i18n-to-core/state.json b/specs/i18n-to-core/state.json index d343c15ad4..bc5b8bf216 100644 --- a/specs/i18n-to-core/state.json +++ b/specs/i18n-to-core/state.json @@ -1,21 +1,23 @@ { - "active_task": "Phase 3 \u2014 stream-chat-react-native adopts", + "active_task": "Phase 3 \u2014 awaiting review", "tasks": { "Phase 0 \u2014 scoped identifiers in core": "done", "Phase 1 \u2014 the stream-chat/i18n module": "done", "Phase 1 \u2014 migration guide and initiative record": "done", "Phase 2 \u2014 stream-chat-react adopts": "done", - "Phase 3 \u2014 stream-chat-react-native adopts": "todo" + "Phase 3 \u2014 stream-chat-react-native adopts": "done" }, "flags": { "blocked": false, "needs-review": false }, "notes": [ - "Phase 2 landed on stream-chat-react branch feat/i18n-adopt-shared-core (200de6eef). It needs stream-chat@10.0.0-rc.3 for the i18n subpath, so it cannot merge until core publishes; the lockfile was deliberately left untouched and must be regenerated then.", + "Phase 2 landed on stream-chat-react branch feat/i18n-adopt-shared-core (200de6eef, f78b42c67). It needs stream-chat@10.0.0-rc.3 for the i18n subpath, so it cannot merge until core publishes; the lockfile was deliberately left untouched and must be regenerated then.", "React's typecheck sits at a 3-error baseline unrelated to i18n: APIErrorResponse / EventAPIResponse were removed from core by 5073c676, after rc.2 published. React catches up when it bumps.", - "Phase 3 is blocked on RN bumping its declared stream-chat range; the root resolutions entry masking it does not publish.", - "Adopting in React found seven real defects in the shared layer, all fixed in 438b420b and 08a3fe58. Running a UI SDK's existing suite against the port is what found them -- none would have been caught by types, and all render wrong rather than throwing." + "Phase 3 is on stream-chat-react-native branch feat/i18n-adopt-shared-core, uncommitted at time of writing. Same publish dependency: the manifests now declare ^10.0.0-rc.3 and the root `resolutions` entry for stream-chat is deleted, so `yarn install --immutable` fails until core publishes.", + "RN's suite has a 38-suite / 289-test pre-existing baseline, unrelated to i18n: the mock builders spy on `client.axiosInstance`, but core routes most endpoints through the generated OpenAPI client since 0776bc46 (shipped in rc.1). Measured before and after the i18n port -- identical failing set. RN's own `yarn typecheck` also fails at 117 errors in the two example apps for the same reason; the port reduces it to 114.", + "Adopting in React found seven real defects in the shared layer, all fixed in 438b420b and 08a3fe58. Adopting in RN found four more: DateTimeLike was circular through startOf so a real Moment never satisfied it; DateTimeParserModule's members were contravariant function properties so moment's overloaded `locale` was rejected; getDateStringForA11y had flattened two genuinely different functions into React's (RN's calendar/VoiceOver variant is now getCalendarDateStringForA11y); and CalendarFormats / DayjsLocaleConfig silently stopped being exported from RN. A publicExports test now pins that surface.", + "src/i18n/notifications.ts (CORE_NOTIFICATION_TRANSLATION_KEY + translateNotification) was deleted before publishing: neither UI SDK used it, and it could not be used -- 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 instead, which both SDKs key a Record on. Key *names* stay per-SDK: each has shipped and integrators' dictionaries depend on them. v9-to-v10-migration-guide-i18n.md now shows the Record pattern directly and labels its key column a suggestion." ], "last_updated": "2026-08-18" } diff --git a/src/i18n/formatters.ts b/src/i18n/formatters.ts index c911d60320..60e922e857 100644 --- a/src/i18n/formatters.ts +++ b/src/i18n/formatters.ts @@ -1,4 +1,5 @@ import { isDate, isDayOrMoment, isNumberOrString } from './dayjs'; +import type { CalendarFormats } from './dayjs'; import { asDynamicKey } from './translator'; import type { DurationFormatterOptions, @@ -355,3 +356,81 @@ export const getDateStringForA11y = ({ tDateTimeParser, timestampTranslationKey, }); + +/** + * Calendar wording used by {@link getCalendarDateStringForA11y}, for the one bundled locale. + * + * Only English ships. A language an integrator registers supplies its own wording through + * `dayjsLocaleConfigForLanguage` (or `registerTranslation`'s third argument) — a per-locale block here + * is useless on its own without the matching `dayjs/locale/xx` beside it. + */ +export const A11Y_CALENDAR_FORMATS: Record = { + en: { + lastDay: '[Yesterday]', + lastWeek: 'dddd', + nextDay: '[Tomorrow]', + nextWeek: 'dddd [at] LT', + sameDay: '[Today]', + sameElse: 'L', + }, +}; + +export type GetCalendarDateStringForA11yParams = { + /** + * Calendar-format overrides applied over the locale defaults and the `sameElse: 'LL'` substitution. + * Use it where the visible date deliberately diverges — a channel preview shows `sameDay: 'LT'`, the + * time rather than "Today". + */ + calendarFormatOverrides?: Partial; + /** Calendar wording per language. Defaults to {@link A11Y_CALENDAR_FORMATS}. */ + calendarFormats?: Record; + messageCreatedAt?: string | Date; + tDateTimeParser?: TDateTimeParser; + /** + * The UI language, used to pick calendar wording. Plain `string`: it indexes `calendarFormats`, which + * an integrator extends for whatever language they registered — not `stream-chat`'s + * auto-translation `TranslationLanguage` union. + */ + userLanguage?: string; +}; + +/** + * A TTS-friendly calendar string, preserving relative wording. + * + * Distinct from {@link getDateStringForA11y}, which spells the date out in full via `LLLL`. Both exist + * because the two UI SDKs arrived at different answers and both are defensible: this one keeps + * "Today"/"Yesterday"/weekday names from the locale's calendar and substitutes `LL` ("April 8, 2026") + * only into the `sameElse` slot, because iOS VoiceOver reads a numeric date like "04/08/2026" + * character by character. Do not collapse them into one — that would silently change one SDK's + * announced labels. + * + * Returns `undefined` when there is nothing to announce, including when the parser has no calendar + * plugin, so the caller omits the label rather than announcing a malformed date. + */ +export const getCalendarDateStringForA11y = ({ + calendarFormatOverrides, + calendarFormats = A11Y_CALENDAR_FORMATS, + messageCreatedAt, + tDateTimeParser, + userLanguage, +}: GetCalendarDateStringForA11yParams): string | undefined => { + if ( + !messageCreatedAt || + (typeof messageCreatedAt === 'string' && !Date.parse(messageCreatedAt)) || + !tDateTimeParser + ) { + return undefined; + } + + const parsed = tDateTimeParser(messageCreatedAt); + if (!isDayOrMoment(parsed) || !parsed.calendar) return undefined; + + const localeFormats = + (userLanguage && calendarFormats[userLanguage]) || calendarFormats.en; + + return parsed.calendar(undefined, { + ...localeFormats, + sameElse: 'LL', + ...calendarFormatOverrides, + }); +}; diff --git a/src/i18n/index.ts b/src/i18n/index.ts index f41edd412d..7aa907f808 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -8,7 +8,6 @@ export * from './dayjs'; export * from './formatters'; export * from './languageNames'; -export * from './notifications'; export * from './StreamI18n'; export * from './TranslationBuilder'; export * from './translator'; diff --git a/src/i18n/notifications.ts b/src/i18n/notifications.ts deleted file mode 100644 index a3183bf6ed..0000000000 --- a/src/i18n/notifications.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { CORE_NOTIFICATION_TYPE } from '../notifications'; -import type { CoreNotificationType, Notification } from '../notifications'; -import { asDynamicKey } from './translator'; -import type { LooseTranslateFunction } from './types'; - -/** - * The canonical translation key for each notification `stream-chat` emits. - * - * `Record` is the drift gate: adding an identifier to - * {@link CORE_NOTIFICATION_TYPE} fails to compile until a key is supplied here, and a key for an - * identifier that no longer exists is rejected. That is the check both UI SDKs were missing — they each - * hand-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 rendering - * untranslated English. - * - * Keys are shared rather than per-SDK so an integrator's notification dictionary is portable between - * the React and React Native SDKs. - */ -export const CORE_NOTIFICATION_TRANSLATION_KEY: Record = { - [CORE_NOTIFICATION_TYPE.attachmentFileMissing]: 'notification.attachmentFileMissing', - [CORE_NOTIFICATION_TYPE.attachmentIdMissing]: 'notification.attachmentIdMissing', - [CORE_NOTIFICATION_TYPE.attachmentUploadBlocked]: - 'notification.attachmentUploadBlocked', - [CORE_NOTIFICATION_TYPE.attachmentUploadFailed]: 'notification.attachmentUploadFailed', - [CORE_NOTIFICATION_TYPE.attachmentUploadInProgress]: - 'notification.attachmentUploadInProgress', - // Carries `metadata.reason` ('editing' | 'replying'), which the English message varies by. Copy for - // this key should interpolate `{{ reason }}` or the SDK should branch before calling in. - [CORE_NOTIFICATION_TYPE.commandDisabled]: 'notification.commandDisabled', - [CORE_NOTIFICATION_TYPE.commandNotReady]: 'notification.commandNotReady', - [CORE_NOTIFICATION_TYPE.locationCreateFailed]: 'notification.locationCreateFailed', - [CORE_NOTIFICATION_TYPE.messageJumpFailed]: 'notification.messageJumpFailed', - [CORE_NOTIFICATION_TYPE.messageJumpToLatestFailed]: - 'notification.messageJumpToLatestFailed', - [CORE_NOTIFICATION_TYPE.pollCastVoteLimit]: 'notification.pollCastVoteLimit', - [CORE_NOTIFICATION_TYPE.pollCreateFailed]: 'notification.pollCreateFailed', -}; - -/** The subset of a notification {@link translateNotification} reads. */ -export type TranslatableNotification = Pick & - Partial>; - -/** - * Resolves a notification to display copy. - * - * Dispatches on `notification.type` — the stable identifier — and never on `notification.message`, - * which is untranslated English whose wording is not part of core's public contract. Both UI SDKs - * previously fell back to matching that prose against a hand-maintained table of English sentences, - * which silently grew stale on every core upgrade. - * - * `metadata` is passed through as interpolation values, so copy can reference `{{ reason }}` and the - * like. - * - * An unrecognized identifier renders `message` verbatim rather than a blank or a raw dotted path: a - * newer core, or an SDK or integrator emitting its own identifier, must not produce an empty toast. - * Pass `translationKeys` to extend the map with the SDK's own identifiers. - */ -export const translateNotification = ({ - notification, - t, - translationKeys = CORE_NOTIFICATION_TRANSLATION_KEY, -}: { - notification: TranslatableNotification; - t: LooseTranslateFunction; - translationKeys?: Record; -}): string => { - const key = notification.type ? translationKeys[notification.type] : undefined; - if (!key) return notification.message; - - // `message` doubles as the default, so a mapped-but-untranslated key still renders English. - return t(asDynamicKey(key), notification.message, notification.metadata ?? {}); -}; diff --git a/src/i18n/types.ts b/src/i18n/types.ts index 27f1387b22..69903c05c1 100644 --- a/src/i18n/types.ts +++ b/src/i18n/types.ts @@ -211,16 +211,38 @@ export type StreamTFunctionFor< * unknown) => string` demands an implementation accepting literally anything and Dayjs — whose * `calendar` takes a narrower union — is not assignable. Method syntax is checked bivariantly, which is * what duck-typing across two date libraries needs. + * + * `startOf` deliberately does **not** return `DateTimeLike`. Doing so made the whole type circular, and + * a real Moment then failed to satisfy it: checking `Moment` against `DateTimeLike` required + * `startOf`'s return `Moment` to satisfy `DateTimeLike`, which required `startOf` again. Method + * bivariance could not rescue it, and the failure was silent until a UI SDK's test passed `moment` in. + * Only `.diff()` is ever called on the result, so that is all the return type promises. */ export type DateTimeLike = { format(template?: string): string; calendar?(referenceTime?: DateTimeReference, formats?: Record): string; fromNow?(withoutSuffix?: boolean): string; - diff(other: DateTimeReference, unit?: string): number; - startOf(unit: string): DateTimeLike; + diff(other: DateTimeOperand, unit?: DateTimeUnit): number; + startOf(unit: DateTimeUnit): { + diff(other: DateTimeOperand, unit?: DateTimeUnit): number; + }; valueOf(): number; }; +/** + * A calendar-unit name (`'day'`, `'week'`, …) and a `diff` operand. + * + * Both are `any`, and deliberately so: dayjs and moment each declare their own narrow unions here + * (`OpUnitType` vs `unitOfTime.Diff`, `ConfigType` vs `MomentInput`), and a structural bridge that must + * accept either library cannot name one without excluding the other. Narrowing them is what made a real + * Moment fail to satisfy this type. Core only ever passes literals both libraries accept, and these + * arguments are inputs — nothing downstream depends on their type. + */ + +type DateTimeUnit = any; + +type DateTimeOperand = any; + /** Anything a date library will accept as a point in time. */ type DateTimeReference = DateTimeLike | Date | string | number | null | undefined; @@ -247,12 +269,17 @@ export type DurationLike = { * Structural for the same reason as {@link DateTimeLike}: this admits `dayjs` and `moment` without * naming either. It is the module rather than a parse function because `durationFormatter` needs * `.duration()`, which lives on the module. + * + * Every member is **method shorthand**, and as with `DateTimeLike` that is load-bearing. As function + * properties they are checked contravariantly, so `locale?: (...args: unknown[]) => unknown` demanded + * an implementation accepting anything at all and rejected moment's overloaded, narrower `locale`. + * Method syntax is bivariant, which is what duck-typing across two libraries needs. */ export type DateTimeParserModule = ((input?: TDateTimeParserInput) => DateTimeLike) & { - duration?: (input: number | string) => DurationLike; - extend?: (plugin: unknown, option?: unknown) => unknown; + duration?(input: number | string): DurationLike; + extend?(plugin: unknown, option?: unknown): unknown; tz?: unknown; - locale?: (...args: unknown[]) => unknown; + locale?(...args: never[]): unknown; }; /* ------------------------------------------------------------------------------------------------ diff --git a/test/unit/i18n/StreamI18n.test.ts b/test/unit/i18n/StreamI18n.test.ts index f2ac7960c7..de16911d5f 100644 --- a/test/unit/i18n/StreamI18n.test.ts +++ b/test/unit/i18n/StreamI18n.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { + type DateTimeParserModule, isDayOrMoment, RELATIVE_TIME_CATALOG, StreamI18n, @@ -270,6 +271,45 @@ describe('DateTimeLike', () => { expect(asDateTimeLike).toBeDefined(); expect(isDayOrMoment(asDateTimeLike)).toBe(true); }); + + /** + * Regression: a Moment must satisfy `DateTimeLike` too. The docs promise bring-your-own-Moment, and + * it was broken — `startOf` returned `DateTimeLike`, so checking Moment against it required Moment's + * `startOf` return (a Moment) to satisfy `DateTimeLike`, requiring `startOf` again. Method bivariance + * does not break that cycle. Narrow unit unions on `diff`/`startOf` compounded it. + * + * Moment itself is not a dependency here, so this is a hand-written stand-in reproducing the two + * properties that actually broke: narrow unit unions, and a self-returning `startOf`. It is a + * type-level assertion — it fails at `yarn types`, not at runtime. The React Native SDK's suite, + * which passes the real `moment` in, is the end-to-end check. + */ + it('accepts a moment-shaped parser output', () => { + type MomentUnit = 'day' | 'week' | 'month' | 'year'; + type MomentInput = MomentLike | Date | string | number; + type MomentLike = { + calendar(referenceTime?: MomentInput, formats?: Record): string; + diff(other: MomentInput, unit?: MomentUnit): number; + format(template?: string): string; + fromNow(withoutSuffix?: boolean): string; + startOf(unit: MomentUnit): MomentLike; + valueOf(): number; + }; + + const momentLike = {} as MomentLike; + const asDateTimeLike: TDateTimeParserOutput = momentLike; + expect(asDateTimeLike).toBeDefined(); + + // The same failure mode one level up: `DateTimeParserModule`'s members must be method shorthand + // too, or moment's overloaded `locale` is rejected as a contravariant function property. + type MomentModuleLike = ((input?: string | number | Date) => MomentLike) & { + duration(input: number | string): { humanize(withSuffix?: boolean): string }; + locale(language?: string, definition?: Record | null): string; + tz?: unknown; + }; + + const asParserModule: DateTimeParserModule = {} as MomentModuleLike; + expect(asParserModule).toBeDefined(); + }); }); describe('RELATIVE_TIME_CATALOG', () => { diff --git a/test/unit/i18n/getDateString.test.ts b/test/unit/i18n/getDateString.test.ts index 4a8d5f3063..47f8449880 100644 --- a/test/unit/i18n/getDateString.test.ts +++ b/test/unit/i18n/getDateString.test.ts @@ -1,6 +1,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { getDateString, StreamI18n } from '../../../src/i18n'; +import { + defaultDateTimeParser, + getCalendarDateStringForA11y, + getDateString, + StreamI18n, +} from '../../../src/i18n'; +import type { TDateTimeParserInput } from '../../../src/i18n'; /** * A key whose formatter expression carries its own arguments — the shape a UI SDK actually ships. @@ -183,3 +189,59 @@ describe('timestampFormatter — nothing renderable', () => { ).toBe(''); }); }); + +/** + * The React Native SDK's a11y variant, which is deliberately *not* the same function as + * `getDateStringForA11y`. It keeps the locale's relative wording and substitutes `LL` only into + * `sameElse`, because iOS VoiceOver reads a numeric date character by character. Consolidating the two + * SDKs' i18n layers initially collapsed both into the `LLLL` variant, which would have silently + * changed every announced date label in the RN SDK. + */ +describe('getCalendarDateStringForA11y', () => { + const parser = (input?: TDateTimeParserInput) => defaultDateTimeParser(input); + + // The suites above freeze the clock to `AT`; these assertions are about the distance between now and + // the timestamp, so they need the real one back. + beforeEach(() => { + vi.useRealTimers(); + }); + + it('keeps relative wording for a recent date', () => { + const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000); + expect( + getCalendarDateStringForA11y({ + messageCreatedAt: yesterday, + tDateTimeParser: parser, + }), + ).toBe('Yesterday'); + }); + + it('spells an older date out rather than leaving it numeric', () => { + expect( + getCalendarDateStringForA11y({ messageCreatedAt: AT, tDateTimeParser: parser }), + ).toBe('April 3, 2019'); + }); + + it('applies calendarFormatOverrides over the locale defaults', () => { + const now = new Date(); + // What ChannelPreviewStatus does: show the time, not the word "Today". + const rendered = getCalendarDateStringForA11y({ + calendarFormatOverrides: { sameDay: 'LT' }, + messageCreatedAt: now, + tDateTimeParser: parser, + }); + expect(rendered).not.toBe('Today'); + expect(rendered).toMatch(/\d{1,2}:\d{2}/); + }); + + it('returns undefined rather than a malformed date when there is nothing to announce', () => { + expect(getCalendarDateStringForA11y({ tDateTimeParser: parser })).toBeUndefined(); + expect( + getCalendarDateStringForA11y({ + messageCreatedAt: 'not a date', + tDateTimeParser: parser, + }), + ).toBeUndefined(); + expect(getCalendarDateStringForA11y({ messageCreatedAt: AT })).toBeUndefined(); + }); +}); diff --git a/test/unit/i18n/notifications.test.ts b/test/unit/i18n/notifications.test.ts deleted file mode 100644 index 7b03455942..0000000000 --- a/test/unit/i18n/notifications.test.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { CORE_NOTIFICATION_TYPE } from '../../../src'; -import { - CORE_NOTIFICATION_TRANSLATION_KEY, - LANGUAGE_NAMES, - languageNameDefaults, - StreamI18n, - translateNotification, -} from '../../../src/i18n'; -import { fixtureRuntimeDefaults } from './fixtures'; - -const translatorFor = async (dictionary: Record = {}) => { - const i18n = new StreamI18n({ - logger: () => {}, - runtimeDefaults: { ...fixtureRuntimeDefaults, ...dictionary }, - }); - const { t } = await i18n.init(); - return t as unknown as ( - key: string, - d?: string | Record, - o?: Record, - ) => string; -}; - -describe('CORE_NOTIFICATION_TRANSLATION_KEY', () => { - /** - * The compile-time guard is `Record` in the source; this is the runtime - * half. Together they are what both UI SDKs lacked — each hand-maintained the same table, and the two - * copies drifted in both directions. - */ - it('covers every identifier core emits, and nothing else', () => { - expect(Object.keys(CORE_NOTIFICATION_TRANSLATION_KEY).sort()).toEqual( - Object.values(CORE_NOTIFICATION_TYPE).sort(), - ); - }); - - it('maps each identifier to a distinct key under the notification namespace', () => { - const keys = Object.values(CORE_NOTIFICATION_TRANSLATION_KEY); - expect(new Set(keys).size).toBe(keys.length); - keys.forEach((key) => expect(key).toMatch(/^notification\.[a-zA-Z]+$/)); - }); -}); - -describe('translateNotification', () => { - it('resolves a recognized identifier through its key', async () => { - const t = await translatorFor({ - 'notification.pollCreateFailed': 'Umfrage konnte nicht erstellt werden', - }); - - expect( - translateNotification({ - notification: { - message: 'Failed to create the poll', - type: CORE_NOTIFICATION_TYPE.pollCreateFailed, - }, - t, - }), - ).toBe('Umfrage konnte nicht erstellt werden'); - }); - - it('falls back to the English message for a mapped but untranslated key', async () => { - const t = await translatorFor(); - - expect( - translateNotification({ - notification: { - message: 'Failed to create the poll', - type: CORE_NOTIFICATION_TYPE.pollCreateFailed, - }, - t, - }), - ).toBe('Failed to create the poll'); - }); - - /** A newer core, or an SDK/integrator identifier, must not produce an empty toast. */ - it('renders the message verbatim for an unrecognized identifier', async () => { - const t = await translatorFor(); - - expect( - translateNotification({ - notification: { - message: 'Something new happened', - type: 'api:future:thing:failed', - }, - t, - }), - ).toBe('Something new happened'); - }); - - it('renders the message when there is no identifier at all', async () => { - const t = await translatorFor(); - - expect(translateNotification({ notification: { message: 'No type here' }, t })).toBe( - 'No type here', - ); - }); - - it('passes metadata through as interpolation values', async () => { - const t = await translatorFor({ - 'notification.commandDisabled': 'Not available while {{ reason }}', - }); - - expect( - translateNotification({ - notification: { - message: 'Command not available while editing', - metadata: { reason: 'editing' }, - type: CORE_NOTIFICATION_TYPE.commandDisabled, - }, - t, - }), - ).toBe('Not available while editing'); - }); - - it('accepts extra identifiers a UI SDK emits itself', async () => { - const t = await translatorFor({ - 'notification.audioFailed': 'Wiedergabe fehlgeschlagen', - }); - - expect( - translateNotification({ - notification: { - message: 'Audio playback failed', - type: 'browser:audio:playback:error', - }, - t, - translationKeys: { - ...CORE_NOTIFICATION_TRANSLATION_KEY, - 'browser:audio:playback:error': 'notification.audioFailed', - }, - }), - ).toBe('Wiedergabe fehlgeschlagen'); - }); -}); - -describe('LANGUAGE_NAMES', () => { - /** - * Exhaustiveness against `TranslationLanguage` is enforced at compile time by - * `satisfies Record`; this covers the runtime shape. - */ - it('exposes a non-empty English name for every language', () => { - const entries = Object.entries(LANGUAGE_NAMES); - expect(entries.length).toBeGreaterThan(50); - entries.forEach(([code, name]) => { - expect(name, `${code} has no name`).toBeTruthy(); - }); - }); - - it('prefixes the catalog-ready defaults with `language.`', () => { - expect(languageNameDefaults['language.de']).toBe('German'); - expect(languageNameDefaults['language.zh-TW']).toBe('Chinese (Traditional)'); - expect(Object.keys(languageNameDefaults)).toHaveLength( - Object.keys(LANGUAGE_NAMES).length, - ); - Object.keys(languageNameDefaults).forEach((key) => - expect(key.startsWith('language.')).toBe(true), - ); - }); - - it('renders through t() when merged into the bundled defaults', async () => { - const t = await translatorFor(languageNameDefaults); - expect(t('language.de')).toBe('German'); - }); -}); diff --git a/v9-to-v10-migration-guide-i18n.md b/v9-to-v10-migration-guide-i18n.md index ec9933d20d..9408c4ef3d 100644 --- a/v9-to-v10-migration-guide-i18n.md +++ b/v9-to-v10-migration-guide-i18n.md @@ -67,9 +67,11 @@ your own code are still valid — you only lose autocomplete for them. | `validation:poll:castVote:limit` | `notification.pollCastVoteLimit` | | `api:poll:create:failed` | `notification.pollCreateFailed` | -The right-hand column is `CORE_NOTIFICATION_TRANSLATION_KEY`, exported from `stream-chat/i18n`. It is -what the React and React Native SDKs both key on, so a dictionary written against these keys is portable -between them. +The right-hand column is a suggestion, not an export. Each UI SDK uses its own key names — they predate +this table and integrators' dictionaries are already written against them — so there is no single +canonical set to publish. What _is_ exported, and what makes the mapping safe, is the +`CORE_NOTIFICATION_TYPE` union: keying a `Record` on it turns a new identifier +into a compile error until you map it, and rejects an entry for one that no longer exists. `validation:command:disabled` additionally carries `metadata.reason` (`'editing' | 'replying'`), which its English message varies by. Copy for that key should interpolate `{{ reason }}`. @@ -108,16 +110,29 @@ and you have no way to localize it. // Before — the English sentence is the only thing identifying the notification toast(notification.message); -// After — resolve the identifier, and fall back to `message` for one you do not recognize -import { translateNotification } from 'stream-chat/i18n'; +// After — dispatch on the identifier, and fall back to `message` for one you do not recognize +import { CORE_NOTIFICATION_TYPE } from 'stream-chat'; +import type { CoreNotificationType, Notification } from 'stream-chat'; + +const copy: Record string> = { + [CORE_NOTIFICATION_TYPE.attachmentUploadFailed]: () => t('notification.uploadFailed'), + // `validation:command:disabled` carries metadata.reason, so branch on it here + [CORE_NOTIFICATION_TYPE.commandDisabled]: (n) => + t('notification.commandDisabled', { reason: n.metadata?.reason }), + // …one entry per identifier; TypeScript will tell you which are missing +}; -toast(translateNotification({ notification, t })); +// `message` verbatim for anything unmapped, so a newer `stream-chat` cannot produce an empty toast. +toast( + notification.type && copy[notification.type as CoreNotificationType] + ? copy[notification.type as CoreNotificationType](notification) + : notification.message, +); ``` -`translateNotification` resolves `type` through `CORE_NOTIFICATION_TRANSLATION_KEY`, passes `metadata` -as interpolation values, and returns `message` verbatim for an unrecognized identifier — so a newer -`stream-chat` can never produce an empty toast. Pass `translationKeys` to extend the map with your own -identifiers. +Type the record as `Record` rather than `Record` — that is the whole +point, and it is why core does not ship a ready-made resolver: your keys are yours, and a helper that +resolved them from a table would be invisible to a key-extraction step like the one both UI SDKs run. If you are using `stream-chat-react` or `stream-chat-react-native`, this is handled for you; see that SDK's own i18n guide. @@ -273,7 +288,7 @@ find . -maxdepth 4 -name i18next -type d -path '*node_modules*' 1. `grep -rn "api:messages:query:failed\|api:message:query:failed"` → replace with `CORE_NOTIFICATION_TYPE.messageJumpFailed` / `.messageJumpToLatestFailed`. 2. `grep -rn "notification.message"` → for anything user-facing, switch on `notification.type` (use - `translateNotification` from `stream-chat/i18n` if you want the mapping done for you). Keep `message` + `CORE_NOTIFICATION_TYPE`). Keep `message` only as the unrecognized-identifier fallback. 3. Typecheck. Every `PollComposerFieldErrors` read will fail: append `?.message`, or switch on `.code`. 4. If you match notification identifiers anywhere, retype the local as `CoreNotificationType` to get the From 9f2efb47be7bc8531443d9543547e082a8b05f3a Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Tue, 18 Aug 2026 11:40:44 +0200 Subject: [PATCH 12/27] refactor(i18n)!: name the class Streami18n and remove the two deprecated 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) }}`. --- CLAUDE.md | 6 +-- specs/i18n-to-core/decisions.md | 36 +++++++------ specs/i18n-to-core/plan.md | 6 +-- specs/i18n-to-core/spec.md | 2 +- src/i18n/{StreamI18n.ts => Streami18n.ts} | 51 +++++++++---------- src/i18n/TranslationBuilder.ts | 2 +- src/i18n/dayjs.ts | 4 +- src/i18n/formatters.ts | 11 +--- src/i18n/index.ts | 2 +- src/i18n/types.ts | 15 +++--- ...{StreamI18n.test.ts => Streami18n.test.ts} | 10 ++-- ...s.test.ts => Streami18nGuarantees.test.ts} | 36 ++++++------- test/unit/i18n/TranslationBuilder.test.ts | 6 +-- test/unit/i18n/getDateString.test.ts | 8 +-- v9-to-v10-migration-guide-i18n.md | 6 +-- 15 files changed, 98 insertions(+), 103 deletions(-) rename src/i18n/{StreamI18n.ts => Streami18n.ts} (93%) rename test/unit/i18n/{StreamI18n.test.ts => Streami18n.test.ts} (98%) rename test/unit/i18n/{StreamI18nGuarantees.test.ts => Streami18nGuarantees.test.ts} (89%) diff --git a/CLAUDE.md b/CLAUDE.md index 4b6e3844a3..fc3fa3f1a8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -130,7 +130,7 @@ Aliases to be aware of: `setUser` → `connectUser`, `disconnect` → `disconnec ## i18n `src/i18n/` holds the translation runtime shared by `stream-chat-react` and -`stream-chat-react-native` — one `StreamI18n`, one set of formatters, one date layer. Before this, both +`stream-chat-react-native` — one `Streami18n`, one set of formatters, one date layer. Before this, both SDKs carried ~1,300 lines of near-duplicate runtime plus a duplicated codegen. See `specs/i18n-to-core/` for the initiative and `v9-to-v10-migration-guide-i18n.md` for the consumer delta. @@ -141,7 +141,7 @@ esbuild's metafile that the root bundle cannot reach `src/i18n/`, `i18next` or ` the bundle is just bigger), which is why they are machine-checked. `dist/esm/index.mjs` is expected to stay byte-identical when only i18n changes. -- **`stream-chat/i18n`** — `StreamI18n`, four formatters, `getDateString`, catalog-generic type helpers, +- **`stream-chat/i18n`** — `Streami18n`, three formatters, `getDateString`, catalog-generic type helpers, `TranslationBuilder`, generated `LANGUAGE_NAMES`. - **`stream-chat/i18n/codegen`** — the catalog generator. `typescript` is **injected** via config, never imported, so core does not depend on the compiler. @@ -153,7 +153,7 @@ Things that will bite: to `never`; defaulting to `string` silently disables all key checking. - **`runtimeDefaults` is a constructor option**, not an import — the catalog belongs to the UI layer. It is layered under _every_ language, which is what stops a partial dictionary from knocking out formatter - keys. That is guarantee G1 in `test/unit/i18n/StreamI18nGuarantees.test.ts`, which is the acceptance + keys. That is guarantee G1 in `test/unit/i18n/Streami18nGuarantees.test.ts`, which is the acceptance contract for this module: three behavioural guarantees, each written against a real bug. - **No module-scope side effects.** Every `Dayjs.extend` goes through `ensureDayjsPlugins()`. This is what makes `sideEffects: false` accurate — do not reintroduce a top-level `extend` or locale import. diff --git a/specs/i18n-to-core/decisions.md b/specs/i18n-to-core/decisions.md index bd25295dab..410a6b67bb 100644 --- a/specs/i18n-to-core/decisions.md +++ b/specs/i18n-to-core/decisions.md @@ -79,9 +79,9 @@ overlay host both do. ## `runtimeDefaults` injected, not imported -The one addition the move forced. `StreamI18n` imported it from a sibling file, and that file is +The one addition the move forced. `Streami18n` imported it from a sibling file, and that file is per-SDK catalog data core cannot import. Each SDK's public export becomes a thin subclass injecting its -own, so `new StreamI18n(...)` keeps working verbatim for integrators while the layering guarantee (G1) +own, so `new Streami18n(...)` keeps working verbatim for integrators while the layering guarantee (G1) stays tested in core. ## `TranslationBuilder`: plumbing down, topics stay up @@ -94,20 +94,19 @@ Non-obvious property worth keeping in mind: i18next post-processing is configure topic is invoked for _every_ key and must pass through calls it does not recognize. Getting that wrong silently rewrites unrelated copy, so there is a test for it. -## Formatters: four, with `relativeCompactDateFormatter` as an alias +## Formatters: three, with no `relativeCompactDateFormatter` -RN's standalone implementation hardcoded `'Today'` / `'Yesterday'` / `` `${n}d ago` ``, which no -dictionary could translate — and because the wording lived in a formatter body rather than a catalog -value, the codegen's English-prose guard never saw it. It is now an alias of -`timestampFormatter(relativeCompact: true)`, whose wording goes through `t()`. +The React Native SDK shipped a standalone `relativeCompactDateFormatter` that hardcoded `'Today'` and +`` `${n}d ago` `` — untranslatable by any dictionary, and invisible to the English-prose guard. Its +behaviour is `timestampFormatter` with `relativeCompact: true`, whose wording goes through `t()`. -`durationFormatter` is typed `number | string`: dayjs accepts both at runtime, and its post-1.11 -signature narrowed to string only, which had forced a cast in React. +It was initially kept as a `@deprecated` alias so existing `timestamp.*` expressions would keep working. +That was rejected: a breaking release should not ship a second name for one behaviour with a countdown +on it. The alias is gone, and the one expression that used it — the React Native SDK's +`timestamp.PollVote` — becomes `{{ timestamp | timestampFormatter(relativeCompact: true) }}`. -A regression test names the failure mode found while building this: a duration must go through the date -library's `.duration()`. Parsing the number as a timestamp reads 600000 as ten minutes past the epoch -and renders "57 years ago". That also forced `DateTimeParser` to be the date library _module_ rather -than a parse function, since `.duration()` lives on the module. +`durationFormatter` is also fixed to `FormatterFactory`; the `` / `` +split was a type bug that forced a cast in the React SDK. ## dayjs: no module-scope side effects @@ -151,10 +150,15 @@ Guards return failures as data with a thin printer on top, so tests assert on th scraping stderr and run in-process rather than spawning. That is most of why the SDK-side suite was 381 lines. -## Naming: `StreamI18n` +## Naming: `Streami18n`, matching what both SDKs already ship -Both SDKs spell it `Streami18n`. Core normalizes the initialism; each SDK re-exports `Streami18n` as a -deprecated alias for one cycle so no integrator code breaks on the rename alone. +Core first named the class `StreamI18n` and each SDK re-exported `Streami18n` as a `@deprecated` alias +for one cycle. That was rejected: the capital `I` is cosmetic, `Streami18n` is the name both SDKs have +shipped and documented for years, and a deprecated alias in a breaking release is cruft with a countdown +attached. Core is named `Streami18n`, so integrators rename nothing and no alias exists. + +`getTranslators()` went the same way — it was a `@deprecated` alias for `init()`. `init()` is the better +name (it initializes; it is not a getter), so the old one is removed outright rather than carried. ## Notification `type`, not `code` diff --git a/specs/i18n-to-core/plan.md b/specs/i18n-to-core/plan.md index 377157cb9f..ad87f009ec 100644 --- a/specs/i18n-to-core/plan.md +++ b/specs/i18n-to-core/plan.md @@ -19,8 +19,8 @@ Commit `766b1ddb`. Gate: `yarn lint && yarn types && yarn test-unit --run && yar Based on RN's implementation, which was the later and better of the two. -- `StreamI18n` with a `StateStore`; catalog-generic type helpers; dayjs handling with no module-scope - side effects; four formatters; `getDateString`. +- `Streami18n` with a `StateStore`; catalog-generic type helpers; dayjs handling with no module-scope + side effects; three formatters; `getDateString`. - Generated `languageNames`, the shared notification key registry, `TranslationBuilder` plumbing. - `stream-chat/i18n` and `stream-chat/i18n/codegen` exports; second and third bundle entries; build-time boundary assertion. @@ -71,7 +71,7 @@ Prerequisites, each its own PR: Then: delete `src/utils/i18n/**`, same type/codegen shrink as React, drop the module-scope `Dayjs.updateLocale`, add the four `relativeTime.*` keys the translatable relative-compact formatter needs, replace `useStreami18n` with the `useStateStore` version, migrate -`new Streami18n(opts, i18nextConfig)` call sites, and newly export `StreamI18nOptions` and the formatter +`new Streami18n(opts, i18nextConfig)` call sites, and newly export `Streami18nOptions` and the formatter types — both unreachable today despite `options.formatters` referencing them. Optional, and a real gap: RN renders auto-translated message text diff --git a/specs/i18n-to-core/spec.md b/specs/i18n-to-core/spec.md index 003ae8b856..875fcd4fe4 100644 --- a/specs/i18n-to-core/spec.md +++ b/specs/i18n-to-core/spec.md @@ -42,7 +42,7 @@ the work from greenfield to typing and gap-filling: ## Shipped -- **`stream-chat/i18n` subpath** — `StreamI18n` (reactive via `StateStore`), four formatters, +- **`stream-chat/i18n` subpath** — `Streami18n` (reactive via `StateStore`), three formatters, `getDateString`, catalog-generic type helpers, `TranslationBuilder` plumbing, generated language names, the shared notification key registry. - **`stream-chat/i18n/codegen` subpath** — the catalog generator, Node-only, with `typescript` diff --git a/src/i18n/StreamI18n.ts b/src/i18n/Streami18n.ts similarity index 93% rename from src/i18n/StreamI18n.ts rename to src/i18n/Streami18n.ts index 5cab06b7de..1eb544fae9 100644 --- a/src/i18n/StreamI18n.ts +++ b/src/i18n/Streami18n.ts @@ -33,7 +33,7 @@ import type { const DEFAULT_NAMESPACE = 'translation'; const DEFAULT_LANGUAGE = 'en'; -export type StreamI18nOptions = { +export type Streami18nOptions = { /** A dayjs or moment module. Defaults to dayjs with the required plugins registered. */ DateTimeParser?: DateTimeParserModule; dayjsLocaleConfigForLanguage?: DayjsLocaleConfig; @@ -74,7 +74,7 @@ export type StreamI18nOptions; }; -export type StreamI18nState< +export type Streami18nState< C extends AnyTranslationCatalog = AnyTranslationCatalog, Bundled extends string = never, > = { @@ -92,14 +92,14 @@ export type StreamI18nState< * at each call site, so the bundled data is just formatter expressions and the handful of keys * resolved by name at runtime. Every other language comes from the integrator. * - * Reactivity goes through {@link StreamI18n.state}, a {@link StateStore}. `subscribe` fires + * Reactivity goes through {@link Streami18n.state}, a {@link StateStore}. `subscribe` fires * synchronously with the current value, so a consumer that attaches after `init()` still sees the live * `t` immediately and there is no callback-registration ordering to get wrong. * * ## Overriding some of the English copy * * ```ts - * const i18n = new StreamI18n({ + * const i18n = new Streami18n({ * translationsForLanguage: { 'autoCompleteInput.placeholder': 'Write something…' }, * }); * ``` @@ -109,7 +109,7 @@ export type StreamI18nState< * ```ts * import 'dayjs/locale/de'; * - * const i18n = new StreamI18n({ language: 'de' }); + * const i18n = new Streami18n({ language: 'de' }); * i18n.registerTranslation('de', de, { * calendar: { sameDay: '[heute um] LT', lastDay: '[gestern um] LT', ... }, * }); @@ -123,16 +123,16 @@ export type StreamI18nState< * new language needs both `import 'dayjs/locale/xx'` and a `calendar` config, or relative dates render * English scaffolding around translated day names. */ -export class StreamI18n< +export class Streami18n< C extends AnyTranslationCatalog = AnyTranslationCatalog, Bundled extends string = never, > { /** Marks instances across bundle copies, where `instanceof` silently fails. */ - static readonly brand = Symbol.for('stream-chat.StreamI18n'); + static readonly brand = Symbol.for('stream-chat.Streami18n'); readonly i18nInstance: I18nInstance = i18next.createInstance(); - readonly state: StateStore>; + readonly state: StateStore>; readonly translationBuilder: TranslationBuilder; @@ -165,11 +165,11 @@ export class StreamI18n< private readonly runtimeDefaults: Record; private readonly disableDateTimeTranslations: boolean; private readonly i18nextConfig: InitOptions; - private initPromise?: Promise>; + private initPromise?: Promise>; /** Set by {@link overrideTFunction}, so `init()` does not clobber a swapped-in implementation. */ private tOverridden = false; - constructor(options: StreamI18nOptions = {}) { + constructor(options: Streami18nOptions = {}) { this.logger = options.logger ?? ((message?: string) => console.warn(message)); this.runtimeDefaults = options.runtimeDefaults ?? {}; this.disableDateTimeTranslations = options.disableDateTimeTranslations ?? false; @@ -206,7 +206,7 @@ export class StreamI18n< ); }; - this.state = new StateStore>({ + this.state = new StateStore>({ initialized: false, language, t: createDefaultTranslatorFunction(), @@ -253,7 +253,7 @@ export class StreamI18n< ? guardMissingKeyHandler(missingKeyHandler) : (key: string, defaultValue?: string) => { if (typeof defaultValue === 'string') return defaultValue; - this.logger(`StreamI18n: missing translation for key: ${key}`); + this.logger(`Streami18n: missing translation for key: ${key}`); return key; }, }; @@ -266,7 +266,7 @@ export class StreamI18n< this.addOrUpdateLocale(language, options.dayjsLocaleConfigForLanguage); } else if (!this.localeExists(language)) { this.logger( - `StreamI18n: no dayjs locale is registered for '${language}', so dates render with the ` + + `Streami18n: no dayjs locale is registered for '${language}', so dates render with the ` + `English locale. Import it with "import 'dayjs/locale/${language}';" in your app, or pass ` + `a config via registerTranslation('${language}', translation, dayjsLocaleConfig).`, ); @@ -309,17 +309,12 @@ export class StreamI18n< * overlay host, say) both call this, and clearing it on completion would leave a window where a * third caller re-entered initialization. */ - init(): Promise> { + init(): Promise> { this.initPromise ??= this.runInit(); return this.initPromise; } - /** @deprecated Use {@link init}, which returns the same state. */ - getTranslators(): Promise> { - return this.init(); - } - - private async runInit(): Promise> { + private async runInit(): Promise> { this.validateCurrentLanguage(); this.assertPluralRulesCoverage(this.currentLanguage); @@ -366,7 +361,7 @@ export class StreamI18n< : { t: t as unknown as StreamTFunctionFor }), }); } catch (error) { - this.logger(`StreamI18n: initialization failed: ${describeError(error)}`); + this.logger(`Streami18n: initialization failed: ${describeError(error)}`); this.state.partialNext({ initialized: true }); } @@ -414,7 +409,7 @@ export class StreamI18n< ) { if (!translation) { this.logger( - 'StreamI18n: registerTranslation called without a translation dictionary', + 'Streami18n: registerTranslation called without a translation dictionary', ); return; } @@ -432,7 +427,7 @@ export class StreamI18n< this.dayjsLocales[language] = { ...dayjsLocaleConfig }; } else if (!this.localeExists(language)) { this.logger( - `StreamI18n: no dayjs locale is registered for '${language}'. Import it with ` + + `Streami18n: no dayjs locale is registered for '${language}'. Import it with ` + `"import 'dayjs/locale/${language}';" in your app, or pass a config as the third ` + `argument to registerTranslation.`, ); @@ -448,7 +443,7 @@ export class StreamI18n< /** * Changes the active language. * - * Returns nothing: the new `t` is published to {@link StreamI18n.state}, which is the single source + * Returns nothing: the new `t` is published to {@link Streami18n.state}, which is the single source * of the current translator. Handing one back would offer a value that goes stale on the next * language change and invite callers to cache it. */ @@ -469,7 +464,7 @@ export class StreamI18n< this.state.partialNext({ t: t as unknown as StreamTFunctionFor }); } } catch (error) { - this.logger(`StreamI18n: failed to set language: ${describeError(error)}`); + this.logger(`Streami18n: failed to set language: ${describeError(error)}`); } } @@ -495,7 +490,7 @@ export class StreamI18n< if (this.registeredLanguages.has(language)) return; this.logger( - `StreamI18n: no translation dictionary is registered for '${language}', so the SDK's copy ` + + `Streami18n: no translation dictionary is registered for '${language}', so the SDK's copy ` + `renders in English. Call registerTranslation('${language}', {...}) to translate it. ` + `Registered: ${[...this.registeredLanguages].join(', ')}`, ); @@ -554,13 +549,13 @@ export class StreamI18n< const resolved = new Intl.PluralRules(language).resolvedOptions().locale; if (resolved.split('-')[0] === language.split('-')[0]) return; this.logger( - `StreamI18n: Intl.PluralRules has no data for '${language}' (it resolved to ` + + `Streami18n: Intl.PluralRules has no data for '${language}' (it resolved to ` + `'${resolved}'), so every count selects the '_other' form. On React Native, import ` + `'intl-pluralrules' before anything else in your entry file.`, ); } catch { this.logger( - `StreamI18n: Intl.PluralRules is unavailable, so plural selection will not work. On React ` + + `Streami18n: Intl.PluralRules is unavailable, so plural selection will not work. On React ` + `Native, import 'intl-pluralrules' before anything else in your entry file.`, ); } diff --git a/src/i18n/TranslationBuilder.ts b/src/i18n/TranslationBuilder.ts index 80037f5fd6..d70a3b18a3 100644 --- a/src/i18n/TranslationBuilder.ts +++ b/src/i18n/TranslationBuilder.ts @@ -76,7 +76,7 @@ export class TranslationBuilder { /** * Translators registered before their topic exists. * - * Topics are only created during `StreamI18n.init()`, but an integrator registers translators against + * Topics are only created during `Streami18n.init()`, but an integrator registers translators against * the constructed instance — so registrations that arrive first are buffered and flushed when the * topic appears, rather than silently dropped. */ diff --git a/src/i18n/dayjs.ts b/src/i18n/dayjs.ts index ccd23f473d..8afc658d7a 100644 --- a/src/i18n/dayjs.ts +++ b/src/i18n/dayjs.ts @@ -86,7 +86,7 @@ let pluginsRegistered = false; * would force `stream-chat` to declare `sideEffects` and would make importing this module do work * whether or not anything uses it. Calling it from both the constructor and `defaultDateTimeParser` * covers the two ways the formatters can be reached, including a standalone `getDateString()` call - * with no `StreamI18n` instance in play. + * with no `Streami18n` instance in play. * * Idempotent twice over: guarded here, and dayjs itself no-ops a repeated `extend` via the plugin's * `$i` marker. @@ -125,7 +125,7 @@ export const defaultDateTimeParser = (input?: TDateTimeParserInput) => { /** * The dayjs module itself, with plugins registered. * - * `StreamI18n.DateTimeParser` has to be the *module*, not a parse function, because + * `Streami18n.DateTimeParser` has to be the *module*, not a parse function, because * `durationFormatter` calls `.duration()` — which lives on the module, not on a parsed instance. */ export const getDefaultDateTimeParserModule = (): DateTimeParserModule => { diff --git a/src/i18n/formatters.ts b/src/i18n/formatters.ts index 60e922e857..6e412b13fb 100644 --- a/src/i18n/formatters.ts +++ b/src/i18n/formatters.ts @@ -69,7 +69,7 @@ const parseCalendarFormats = ( } catch (error) { // Reported through the instance's logger, not through `translate` -- a diagnostic is not copy. logger( - `StreamI18n: calendarFormats is not valid JSON, ignoring it: ${value} (${ + `Streami18n: calendarFormats is not valid JSON, ignoring it: ${value} (${ error instanceof Error ? error.message : String(error) })`, ); @@ -210,17 +210,10 @@ const fromNowFormatter: FormatterFactory = ); }; -/** - * The formatters registered with i18next by default. - * - * `relativeCompactDateFormatter` is an alias rather than its own implementation — see the deprecation - * note on {@link PredefinedFormatters}. - */ +/** The formatters registered with i18next by default. */ export const predefinedFormatters: PredefinedFormatters = { durationFormatter, fromNowFormatter, - relativeCompactDateFormatter: (context) => (value, lng, options) => - timestampFormatter(context)(value, lng, { ...options, relativeCompact: true }), timestampFormatter, }; diff --git a/src/i18n/index.ts b/src/i18n/index.ts index 7aa907f808..8166e51b86 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -8,7 +8,7 @@ export * from './dayjs'; export * from './formatters'; export * from './languageNames'; -export * from './StreamI18n'; +export * from './Streami18n'; export * from './TranslationBuilder'; export * from './translator'; export * from './types'; diff --git a/src/i18n/types.ts b/src/i18n/types.ts index 69903c05c1..a419b27586 100644 --- a/src/i18n/types.ts +++ b/src/i18n/types.ts @@ -264,7 +264,7 @@ export type DurationLike = { }; /** - * A date/time library *module*, as accepted by `StreamI18nOptions.DateTimeParser`. + * A date/time library *module*, as accepted by `Streami18nOptions.DateTimeParser`. * * Structural for the same reason as {@link DateTimeLike}: this admits `dayjs` and `moment` without * naming either. It is the module rather than a parse function because `durationFormatter` needs @@ -308,7 +308,7 @@ export type LooseTranslateFunction = ( /** * What a formatter is given about the instance it belongs to. * - * Structural rather than the concrete class, so `types.ts` does not have to import `StreamI18n.ts` + * Structural rather than the concrete class, so `types.ts` does not have to import `Streami18n.ts` * and formatter factories stay testable in isolation. */ export type FormatterContext = { @@ -349,11 +349,14 @@ export type PredefinedFormatters = { durationFormatter: FormatterFactory; fromNowFormatter: FormatterFactory; /** - * @deprecated Use `timestampFormatter` with `relativeCompact: true`, which routes the wording - * through `t()` and is therefore translatable. Kept as an alias so existing `timestamp.*` bundled - * defaults keep working. + * Renders a timestamp. `relativeCompact: true` selects the "Today" / "3d ago" wording, which routes + * through `t()` and is therefore translatable. + * + * The React Native SDK's separate `relativeCompactDateFormatter` is gone rather than aliased here: it + * hardcoded English that no dictionary could reach, and an alias would have been a second name for + * one behaviour. A `timestamp.*` expression that used it becomes + * `{{ timestamp | timestampFormatter(relativeCompact: true) }}`. */ - relativeCompactDateFormatter: FormatterFactory; timestampFormatter: FormatterFactory; }; diff --git a/test/unit/i18n/StreamI18n.test.ts b/test/unit/i18n/Streami18n.test.ts similarity index 98% rename from test/unit/i18n/StreamI18n.test.ts rename to test/unit/i18n/Streami18n.test.ts index de16911d5f..1495283022 100644 --- a/test/unit/i18n/StreamI18n.test.ts +++ b/test/unit/i18n/Streami18n.test.ts @@ -4,8 +4,8 @@ import { type DateTimeParserModule, isDayOrMoment, RELATIVE_TIME_CATALOG, - StreamI18n, - type StreamI18nState, + Streami18n, + type Streami18nState, type TDateTimeParserOutput, } from '../../../src/i18n'; import { @@ -15,13 +15,13 @@ import { } from './fixtures'; const setup = (options: Record = {}) => - new StreamI18n({ + new Streami18n({ logger: () => {}, runtimeDefaults: fixtureRuntimeDefaults, ...options, }); -describe('StreamI18n', () => { +describe('Streami18n', () => { beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(new Date('2026-03-13T14:32:00.000Z')); @@ -102,7 +102,7 @@ describe('StreamI18n', () => { const i18n = setup(); await i18n.init(); - const seen: StreamI18nState[] = []; + const seen: Streami18nState[] = []; // `subscribe` fires synchronously with the current value — that is what removes the // callback-registration ordering problem the listener-based API had. const unsubscribe = i18n.state.subscribe((state) => seen.push(state)); diff --git a/test/unit/i18n/StreamI18nGuarantees.test.ts b/test/unit/i18n/Streami18nGuarantees.test.ts similarity index 89% rename from test/unit/i18n/StreamI18nGuarantees.test.ts rename to test/unit/i18n/Streami18nGuarantees.test.ts index f740bdfffe..0b63a09982 100644 --- a/test/unit/i18n/StreamI18nGuarantees.test.ts +++ b/test/unit/i18n/Streami18nGuarantees.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; -import { asDynamicKey, StreamI18n } from '../../../src/i18n'; +import { asDynamicKey, Streami18n } from '../../../src/i18n'; import { FORMATTER_KEY, fixtureRuntimeDefaults, @@ -13,7 +13,7 @@ import { * * Ported from `stream-chat-react-native`'s `Streami18nGuarantees.test.ts`, where each one was written * against a real bug found reviewing the web implementation. They live here now because they describe - * `StreamI18n` behaviour rather than anything React- or RN-specific, which means a third SDK cannot + * `Streami18n` behaviour rather than anything React- or RN-specific, which means a third SDK cannot * regress them and neither UI SDK has to keep its own copy. * * G1 — every language is layered over the SDK's bundled defaults, however it was selected. @@ -25,7 +25,7 @@ type Dictionary = Partial>; /** Core has no catalog, so every instance is handed the fixture's bundled defaults. */ const setup = (options: Record = {}) => - new StreamI18n({ + new Streami18n({ logger: () => {}, runtimeDefaults: fixtureRuntimeDefaults, ...options, @@ -34,7 +34,7 @@ const setup = (options: Record = {}) => describe('G1 — bundled defaults are layered under every language', () => { it('applies to a language selected via the `language` option', async () => { const i18n = setup({ language: 'de' }); - const { t } = await i18n.getTranslators(); + const { t } = await i18n.init(); expect(t(FORMATTER_KEY)).not.toBe(FORMATTER_KEY); }); @@ -45,14 +45,14 @@ describe('G1 — bundled defaults are layered under every language', () => { 'common.cancel.label': 'Abbrechen', } satisfies Dictionary); await i18n.setLanguage('de'); - const { t } = await i18n.getTranslators(); + const { t } = await i18n.init(); expect(t(FORMATTER_KEY)).not.toBe(FORMATTER_KEY); }); it('applies to `en` when no dictionary is supplied at all', async () => { const i18n = setup(); - const { t } = await i18n.getTranslators(); + const { t } = await i18n.init(); expect(t(FORMATTER_KEY)).not.toBe(FORMATTER_KEY); }); @@ -63,7 +63,7 @@ describe('G1 — bundled defaults are layered under every language', () => { 'common.cancel.label': 'Abbrechen', } satisfies Dictionary); i18n.registerTranslation('de', { 'common.loading.text': 'Lädt...' }); - const { t } = await i18n.getTranslators(); + const { t } = await i18n.init(); // Registering twice must accumulate, and must not knock out the bundled formatter keys. expect(t(FORMATTER_KEY)).not.toBe(FORMATTER_KEY); @@ -76,7 +76,7 @@ describe('G1 — bundled defaults are layered under every language', () => { language: 'de', translationsForLanguage: { 'common.cancel.label': 'Abbrechen' }, }); - const { t } = await i18n.getTranslators(); + const { t } = await i18n.init(); expect(t(FORMATTER_KEY)).toBe(fixtureRuntimeDefaults[FORMATTER_KEY]); }); @@ -88,7 +88,7 @@ describe('G2 — a partial dictionary renders English, not a dotted path', () => i18n.registerTranslation('de', { 'common.cancel.label': 'Abbrechen', } satisfies Dictionary); - const { t } = await i18n.getTranslators(); + const { t } = await i18n.init(); expect(t('common.loading.text', 'Loading...')).toBe('Loading...'); }); @@ -98,14 +98,14 @@ describe('G2 — a partial dictionary renders English, not a dotted path', () => i18n.registerTranslation('de', { 'common.cancel.label': 'Abbrechen', } satisfies Dictionary); - const { t } = await i18n.getTranslators(); + const { t } = await i18n.init(); expect(t('common.cancel.label', 'Cancel')).toBe('Abbrechen'); }); it('never renders a raw dotted key for a prose key', async () => { const i18n = setup({ language: 'de' }); - const { t } = await i18n.getTranslators(); + const { t } = await i18n.init(); const rendered = t('common.loading.text', 'Loading...'); expect(rendered).not.toMatch(/^[a-z][a-zA-Z]*(\.[a-zA-Z]+)+$/); @@ -116,7 +116,7 @@ describe('G2 — a partial dictionary renders English, not a dotted path', () => // resource bundle — and the handler's return value replaces the rendered string. An unguarded // handler therefore blanks out most of the UI. const i18n = setup({ i18nextConfigOverrides: { parseMissingKeyHandler: () => '' } }); - const { t } = await i18n.getTranslators(); + const { t } = await i18n.init(); expect(t('common.loading.text', 'Loading...')).toBe('Loading...'); }); @@ -124,7 +124,7 @@ describe('G2 — a partial dictionary renders English, not a dotted path', () => it('still reports a genuinely missing key to an integrator handler', async () => { const parseMissingKeyHandler = vi.fn(() => 'MISSING'); const i18n = setup({ i18nextConfigOverrides: { parseMissingKeyHandler } }); - const { t } = await i18n.getTranslators(); + const { t } = await i18n.init(); // No inline default and not in runtimeDefaults — this one really is missing. expect(t(asDynamicKey('nothing.declares.this'))).toBe('MISSING'); @@ -135,7 +135,7 @@ describe('G2 — a partial dictionary renders English, not a dotted path', () => describe('G3 — an unregistered language warns and continues', () => { it('does not silently reset the language to en', async () => { const i18n = setup({ language: 'de' }); - await i18n.getTranslators(); + await i18n.init(); expect(i18n.currentLanguage).toBe('de'); }); @@ -143,7 +143,7 @@ describe('G3 — an unregistered language warns and continues', () => { it('warns that the language has no dictionary', async () => { const logger = vi.fn(); const i18n = setup({ language: 'de', logger }); - await i18n.getTranslators(); + await i18n.init(); // Specifically the *translation* warning — not an unrelated dayjs "locale config for de does not // exist" message, which would let this pass for the wrong reason. @@ -155,7 +155,7 @@ describe('G3 — an unregistered language warns and continues', () => { it('keeps the language after setLanguage to an unregistered one', async () => { const i18n = setup(); - await i18n.getTranslators(); + await i18n.init(); await i18n.setLanguage('de'); expect(i18n.currentLanguage).toBe('de'); @@ -163,7 +163,7 @@ describe('G3 — an unregistered language warns and continues', () => { it('still renders English copy in the unregistered language', async () => { const i18n = setup({ language: 'de' }); - const { t } = await i18n.getTranslators(); + const { t } = await i18n.init(); expect(t('common.loading.text', 'Loading...')).toBe('Loading...'); }); @@ -178,7 +178,7 @@ describe('G3 — when the warning fires', () => { it('does not warn at construction, before registerTranslation has had a chance to run', () => { const logger = vi.fn(); - new StreamI18n({ + new Streami18n({ language: 'de', logger, runtimeDefaults: fixtureRuntimeDefaults, diff --git a/test/unit/i18n/TranslationBuilder.test.ts b/test/unit/i18n/TranslationBuilder.test.ts index e9e1df7eb0..a2b1f422be 100644 --- a/test/unit/i18n/TranslationBuilder.test.ts +++ b/test/unit/i18n/TranslationBuilder.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; -import { StreamI18n, TranslationTopic } from '../../../src/i18n'; +import { Streami18n, TranslationTopic } from '../../../src/i18n'; import type { Translator } from '../../../src/i18n'; /** @@ -21,7 +21,7 @@ class KindTopic extends TranslationTopic<{ kind?: string }> { const FALLBACK = 'FALLBACK'; const setup = (options: Record = {}) => - new StreamI18n({ + new Streami18n({ logger: () => {}, runtimeDefaults: { 'translationBuilderTopic.kind': FALLBACK }, translationBuilderTopics: { kind: KindTopic }, @@ -138,7 +138,7 @@ describe('TranslationBuilder', () => { }); it('configures no post-processing when no topics are supplied', async () => { - const i18n = new StreamI18n({ logger: vi.fn(), runtimeDefaults: {} }); + const i18n = new Streami18n({ logger: vi.fn(), runtimeDefaults: {} }); const { t } = await i18n.init(); expect((t as (k: string, d: string) => string)('common.thing', 'Thing')).toBe( diff --git a/test/unit/i18n/getDateString.test.ts b/test/unit/i18n/getDateString.test.ts index 47f8449880..38d3a872fb 100644 --- a/test/unit/i18n/getDateString.test.ts +++ b/test/unit/i18n/getDateString.test.ts @@ -4,7 +4,7 @@ import { defaultDateTimeParser, getCalendarDateStringForA11y, getDateString, - StreamI18n, + Streami18n, } from '../../../src/i18n'; import type { TDateTimeParserInput } from '../../../src/i18n'; @@ -18,7 +18,7 @@ const KEY_VALUE = '{{ timestamp | timestampFormatter(calendar: false; format: HH const AT = '2019-04-03T14:42:47.087Z'; const setup = async (runtimeDefaults: Record = { [KEY]: KEY_VALUE }) => { - const i18n = new StreamI18n({ logger: () => {}, runtimeDefaults }); + const i18n = new Streami18n({ logger: () => {}, runtimeDefaults }); return i18n.init(); }; @@ -138,7 +138,7 @@ describe('getDateString — options handed to a custom formatter', () => { */ it('passes the timestamp as a Date', async () => { const seen: Record[] = []; - const i18n = new StreamI18n({ + const i18n = new Streami18n({ logger: () => {}, runtimeDefaults: { [KEY]: KEY_VALUE }, formatters: { @@ -176,7 +176,7 @@ describe('timestampFormatter — nothing renderable', () => { ['an unparseable string', 'not a date'], ['an empty string', ''], ])('renders empty for %s', async (_label, value) => { - const i18n = new StreamI18n({ + const i18n = new Streami18n({ logger: () => {}, runtimeDefaults: { [KEY]: KEY_VALUE }, }); diff --git a/v9-to-v10-migration-guide-i18n.md b/v9-to-v10-migration-guide-i18n.md index 9408c4ef3d..963e77ff11 100644 --- a/v9-to-v10-migration-guide-i18n.md +++ b/v9-to-v10-migration-guide-i18n.md @@ -18,7 +18,7 @@ - **`PollComposerFieldErrors` values are now objects**, not bare English strings: `{ code, message, metadata? }`. Read `.message` for the previous value, or switch on `.code` to localize. **Breaking.** - **`Notification.type` is now typed** as `CoreNotificationType | (string & {})` and enumerated in the exported `CORE_NOTIFICATION_TYPE` map. Additive — your own identifiers still pass. - **`Notification.message` is now documented as a developer-facing fallback, not display copy.** Its wording is not part of the public contract and may change in a minor release. Nothing breaks today, but anything user-facing should switch on `type`. See [Rendering notifications](#rendering-notifications). -- **New subpath `stream-chat/i18n`** carries the shared translation runtime (`StreamI18n`, formatters, date handling). Nothing is re-exported from `stream-chat`'s root, so the root bundle is unchanged. +- **New subpath `stream-chat/i18n`** carries the shared translation runtime (`Streami18n`, formatters, date handling). Nothing is re-exported from `stream-chat`'s root, so the root bundle is unchanged. - **New subpath `stream-chat/i18n/codegen`** carries the build-time translation-catalog generator. Node-only. - **`stream-chat` now depends on `i18next` and `dayjs`.** Install footprint grows ~2.3 MB; **bundle size is unaffected** unless you import `stream-chat/i18n`. - Nothing in the JSDoc ever described a `Notification.code` field. There is no such field and never was — the block documenting the `domain:entity:operation:result` scheme was attached to `type` and mislabelled. It has been corrected. @@ -214,7 +214,7 @@ SDK, you do not need to import this directly — the SDK re-exports what you nee catalog. ```ts -import { StreamI18n, getDateString, predefinedFormatters } from 'stream-chat/i18n'; +import { Streami18n, getDateString, predefinedFormatters } from 'stream-chat/i18n'; ``` It is a separate entry point, not part of `stream-chat`'s root barrel, because it pulls in `i18next` and @@ -223,7 +223,7 @@ from it. Notable if you are building custom UI directly on `stream-chat`: -- `StreamI18n` is generic over your translation catalog: `new StreamI18n(…)`. +- `Streami18n` is generic over your translation catalog: `new Streami18n(…)`. - Reactivity goes through `i18n.state`, a `StateStore`. `subscribe` fires synchronously with the current value, so there is no listener-registration ordering to get right. - `setLanguage()` returns `Promise`. The new `t` is published to `state`; a returned translator From aca20aaa8918993e29eb2f9e81dbd8d43346e113 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Tue, 18 Aug 2026 11:49:53 +0200 Subject: [PATCH 13/27] docs(specs): record the naming reversal and what remains before merge --- specs/i18n-to-core/state.json | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/specs/i18n-to-core/state.json b/specs/i18n-to-core/state.json index bc5b8bf216..78269cf91e 100644 --- a/specs/i18n-to-core/state.json +++ b/specs/i18n-to-core/state.json @@ -1,5 +1,5 @@ { - "active_task": "Phase 3 \u2014 awaiting review", + "active_task": "Blocked on publishing stream-chat 10.0.0-rc.3", "tasks": { "Phase 0 \u2014 scoped identifiers in core": "done", "Phase 1 \u2014 the stream-chat/i18n module": "done", @@ -8,16 +8,18 @@ "Phase 3 \u2014 stream-chat-react-native adopts": "done" }, "flags": { - "blocked": false, + "blocked": true, "needs-review": false }, "notes": [ - "Phase 2 landed on stream-chat-react branch feat/i18n-adopt-shared-core (200de6eef, f78b42c67). It needs stream-chat@10.0.0-rc.3 for the i18n subpath, so it cannot merge until core publishes; the lockfile was deliberately left untouched and must be regenerated then.", - "React's typecheck sits at a 3-error baseline unrelated to i18n: APIErrorResponse / EventAPIResponse were removed from core by 5073c676, after rc.2 published. React catches up when it bumps.", - "Phase 3 is on stream-chat-react-native branch feat/i18n-adopt-shared-core, uncommitted at time of writing. Same publish dependency: the manifests now declare ^10.0.0-rc.3 and the root `resolutions` entry for stream-chat is deleted, so `yarn install --immutable` fails until core publishes.", - "RN's suite has a 38-suite / 289-test pre-existing baseline, unrelated to i18n: the mock builders spy on `client.axiosInstance`, but core routes most endpoints through the generated OpenAPI client since 0776bc46 (shipped in rc.1). Measured before and after the i18n port -- identical failing set. RN's own `yarn typecheck` also fails at 117 errors in the two example apps for the same reason; the port reduces it to 114.", - "Adopting in React found seven real defects in the shared layer, all fixed in 438b420b and 08a3fe58. Adopting in RN found four more: DateTimeLike was circular through startOf so a real Moment never satisfied it; DateTimeParserModule's members were contravariant function properties so moment's overloaded `locale` was rejected; getDateStringForA11y had flattened two genuinely different functions into React's (RN's calendar/VoiceOver variant is now getCalendarDateStringForA11y); and CalendarFormats / DayjsLocaleConfig silently stopped being exported from RN. A publicExports test now pins that surface.", - "src/i18n/notifications.ts (CORE_NOTIFICATION_TRANSLATION_KEY + translateNotification) was deleted before publishing: neither UI SDK used it, and it could not be used -- 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 instead, which both SDKs key a Record on. Key *names* stay per-SDK: each has shipped and integrators' dictionaries depend on them. v9-to-v10-migration-guide-i18n.md now shows the Record pattern directly and labels its key column a suggestion." + "All three branches are committed and green on their own gates. Nothing can merge until stream-chat 10.0.0-rc.3 publishes from feat/i18n-core-module: both UI SDKs declare ^10.0.0-rc.3, RN deleted the root `resolutions` override that was masking it, and neither lockfile can be regenerated until the version exists. `yarn install --immutable` fails in both consumers today, which also means RN's pre-commit hook cannot run (every yarn command fails at resolution) -- its commits used --no-verify with prettier, binaries and commitlint checked by hand.", + "Naming reversed late: core is `Streami18n`, not `StreamI18n`, and no `@deprecated` remains anywhere in any of the three i18n surfaces. `getTranslators()` (alias for `init()`) and `relativeCompactDateFormatter` (alias for timestampFormatter with relativeCompact) were removed outright rather than carried. Recorded in decisions.md; the rule is now stated in RN's AGENTS.md so it is not reintroduced.", + "Packaging verified against the packed tarball from a clean npm install with no separate i18next/dayjs: all 19 exports-map targets exist, all four conditions resolve, `Streami18n` initializes and renders, the codegen subpath loads, and the root barrel does not expose it. Node's `import` resolves via the `node` condition to CJS, so its namespace carries `default` + `module.exports` -- expected, same shape as the root entry.", + "React's `yarn build` is RED on 3 pre-existing errors unrelated to i18n: APIErrorResponse / EventAPIResponse were removed from core by 5073c676 after rc.2 published. The vite/translations/styling steps pass; only the tsc step fails. React must fix those 3 imports before its branch can build.", + "RN has a 38-suite / 289-test unit baseline and 117 example-app typecheck errors, both pre-existing and unrelated: the mock builders spy on `client.axiosInstance` but core routes most endpoints through its generated OpenAPI client since 0776bc46 (shipped in rc.1). Measured with the i18n port stashed and unstashed -- identical failing set. Must be fixed before v10 ships; not part of this initiative.", + "Not done, and each needs something this environment cannot provide: Metro resolution of stream-chat/i18n from a real RN 0.79 fixture and from ExpoMessaging on SDK 53; the SampleApp end-to-end language-switch proof (drawer -> tap name 7x -> Language -> Deutsch, then the four ordered assertions in the plan); and the published-package size diff, now that React's size.yml and RN's sdk-size-metrics.yml both run on their release branches.", + "Adopting in React found seven core defects, adopting in RN found four more. All fixed with regression tests. None would have been caught by types alone and all rendered wrong rather than throwing -- running each UI SDK's existing suite against the shared layer is what found them.", + "Open scope decision: RN still has no MessageTranslationIndicator, so it renders auto-translated text with no signal and no original/translated toggle. `language.*` is wired in and compile-checked now, so only the component is missing." ], "last_updated": "2026-08-18" } From 7d69aa58d5b1ce6ca56493881a5e7f5fcdf3be9a Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Tue, 18 Aug 2026 12:09:20 +0200 Subject: [PATCH 14/27] docs: correct the v10 prerelease branch in CLAUDE.md 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. --- CLAUDE.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index fc3fa3f1a8..06b4ecd3da 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -208,7 +208,14 @@ Release branches (`.releaserc.json`): - `master` → `latest` dist-tag (current major: v9). - `release-v8` → `v8` dist-tag, locked to `8.x` range. -- `rc` → prerelease channel. +- `release-v10` → `rc` dist-tag, `prerelease: "rc"`. **This is the branch v10 prereleases are cut + from** — not a branch literally named `rc`. `release.yml` is `workflow_dispatch` and releases from + whatever branch you dispatch it on, gated by + `startsWith(github.ref_name, 'release')`, so a v10 change has to land on `release-v10` before it can + reach npm. The `rc` name survives only as a legacy allowance in that gate. + +Unlike the React and React Native repos, the PR workflows here (`lint`, `unit`, `type`, `size`) carry +**no branch filter**, so a PR into `release-v10` is fully gated with no workflow change needed. ## Things to double-check before claiming done From 3ace83cbc3e0fccda965bab502c5c5c47ac2a0bd Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Tue, 18 Aug 2026 12:59:25 +0200 Subject: [PATCH 15/27] refactor(i18n): move the catalog generator out of src to codegen/i18n 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. --- CLAUDE.md | 26 +++++++++++++++---- .../i18n}/callSites.ts | 0 .../i18n-codegen => codegen/i18n}/generate.ts | 0 {src/i18n-codegen => codegen/i18n}/guards.ts | 0 {src/i18n-codegen => codegen/i18n}/index.ts | 0 .../i18n}/stringMaps.ts | 0 {src/i18n-codegen => codegen/i18n}/types.ts | 0 eslint.config.mjs | 4 +-- package.json | 5 ++-- scripts/bundle.mjs | 19 ++++++++++---- specs/i18n-to-core/spec.md | 3 ++- .../i18n}/generate.test.ts | 4 +-- tsconfig.codegen.json | 20 ++++++++++++++ 13 files changed, 64 insertions(+), 17 deletions(-) rename {src/i18n-codegen => codegen/i18n}/callSites.ts (100%) rename {src/i18n-codegen => codegen/i18n}/generate.ts (100%) rename {src/i18n-codegen => codegen/i18n}/guards.ts (100%) rename {src/i18n-codegen => codegen/i18n}/index.ts (100%) rename {src/i18n-codegen => codegen/i18n}/stringMaps.ts (100%) rename {src/i18n-codegen => codegen/i18n}/types.ts (100%) rename test/unit/{i18n-codegen => codegen/i18n}/generate.test.ts (99%) create mode 100644 tsconfig.codegen.json diff --git a/CLAUDE.md b/CLAUDE.md index 06b4ecd3da..acdfeaab2a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,7 +37,7 @@ Single test runs use Vitest's CLI directly: `yarn test-unit path/to/file.test.ts 2. `scripts/bundle.mjs` (esbuild) — produces bundles for **three entry points**: - `index` (the root): `dist/cjs/index.node.js` (Node CJS, externalizes deps + Node builtins), `dist/cjs/index.browser.js` (browser CJS), `dist/esm/index.mjs` (browser ESM) - `i18n` (`stream-chat/i18n`): the same three variants, `i18n.node.js` / `i18n.browser.js` / `i18n.mjs` - - `i18n-codegen` (`stream-chat/i18n/codegen`): Node only, CJS + ESM — it reads the filesystem, so there is deliberately no browser variant + - `i18n-codegen` (`stream-chat/i18n/codegen`), built from `codegen/i18n/`: Node only, CJS + ESM — it reads the filesystem, so there is deliberately no browser variant After building, `assertBundleBoundaries` reads esbuild's `metafile` and fails the build if an entry reached something it must not (see the i18n section). Adding a new entry point without declaring its boundary in `ENTRY_BOUNDARIES` is itself an error. @@ -73,7 +73,7 @@ This is a single-package SDK with **no monorepo**. The public surface is everyth - `reminders/` — `Reminder`, `ReminderManager`, `ReminderTimer` (scheduled-offset reminders with debounced refresh). - `search/` — `BaseSearchSource` + concrete `MessageSearchSource`, `ChannelSearchSource`, `UserSearchSource` orchestrated by `SearchController`. - `i18n/` — the translation layer shared by the React and React Native SDKs. **Not exported from `src/index.ts`** — see the i18n section below. - - `i18n-codegen/` — build-time translation-catalog generator. A **sibling** of `i18n/`, not a child, so the runtime layer physically cannot reach `node:fs`. + - the build-time translation-catalog generator is **not here** — it lives at `codegen/i18n/`, outside `src/` entirely, so the runtime layer physically cannot reach `node:fs`. See the i18n section. - Top-level subsystem files: `poll`, `poll_manager`, `thread`, `thread_manager`, `moderation`, `campaign`, `segment`, `permissions`. - **`types.ts` (~5k lines) + `custom_types.ts` + `types.utility.ts`** — public type surface. **Custom data is extended via module augmentation on the `Custom*Data` interfaces in `custom_types.ts`** (generics were removed in v9; see README). When adding a field that callers may want to extend, expose it through a `Custom*Data` interface rather than reintroducing a generic. @@ -137,9 +137,25 @@ SDKs carried ~1,300 lines of near-duplicate runtime plus a duplicated codegen. S **Three entry points, and the boundaries between them are enforced by the build.** `src/index.ts` must **never** `export * from './i18n'` — that is the one reflex to resist. `scripts/bundle.mjs` asserts from esbuild's metafile that the root bundle cannot reach `src/i18n/`, `i18next` or `dayjs`, and that -`src/i18n/` cannot reach the Node-only `src/i18n-codegen/`. Both leaks fail invisibly (everything works, -the bundle is just bigger), which is why they are machine-checked. `dist/esm/index.mjs` is expected to -stay byte-identical when only i18n changes. +`src/i18n/` cannot reach the Node-only `codegen/`. Both leaks fail invisibly (everything works, the +bundle is just bigger), which is why they are machine-checked. `dist/esm/index.mjs` is expected to stay +byte-identical when only i18n changes. + +**The generator lives at `codegen/i18n/`, outside `src/`.** It is Node-only build tooling that reads the +filesystem — the one thing the SDK's own source must never do — so it is not library source, even though +it _is_ published (two other repos import `stream-chat/i18n/codegen` from their build scripts). Being +outside the library tsconfig is what makes the boundary type-enforced: an import from `src/i18n/` fails +at `tsc` before the metafile assertion ever runs, though the error is an oblique TS6059 "not under +rootDir" rather than something self-explanatory. + +Three things are scoped to `src/` by default and had to be widened for it — check all three if you ever +add another directory beside it, because each fails silently: + +- `tsconfig.codegen.json` emits its declarations to `dist/types/i18n-codegen/`, where `exports` and + `typesVersions` point. `rootDir` must stay `./codegen/i18n` or that path shifts. +- `yarn types` runs **both** projects; `yarn build` runs both `tsc` invocations. +- `eslint.config.mjs` rule blocks list `codegen/**/*.{js,ts}` alongside `src/**/*.{js,ts}`. Without it + the generator inherits no rules at all. - **`stream-chat/i18n`** — `Streami18n`, three formatters, `getDateString`, catalog-generic type helpers, `TranslationBuilder`, generated `LANGUAGE_NAMES`. diff --git a/src/i18n-codegen/callSites.ts b/codegen/i18n/callSites.ts similarity index 100% rename from src/i18n-codegen/callSites.ts rename to codegen/i18n/callSites.ts diff --git a/src/i18n-codegen/generate.ts b/codegen/i18n/generate.ts similarity index 100% rename from src/i18n-codegen/generate.ts rename to codegen/i18n/generate.ts diff --git a/src/i18n-codegen/guards.ts b/codegen/i18n/guards.ts similarity index 100% rename from src/i18n-codegen/guards.ts rename to codegen/i18n/guards.ts diff --git a/src/i18n-codegen/index.ts b/codegen/i18n/index.ts similarity index 100% rename from src/i18n-codegen/index.ts rename to codegen/i18n/index.ts diff --git a/src/i18n-codegen/stringMaps.ts b/codegen/i18n/stringMaps.ts similarity index 100% rename from src/i18n-codegen/stringMaps.ts rename to codegen/i18n/stringMaps.ts diff --git a/src/i18n-codegen/types.ts b/codegen/i18n/types.ts similarity index 100% rename from src/i18n-codegen/types.ts rename to codegen/i18n/types.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index e51175f3d6..d9815c5a66 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -13,7 +13,7 @@ export default tseslint.config( { name: 'default', extends: [js.configs.recommended, ...tseslint.configs.recommended], - files: ['src/**/*.{js,ts}'], + files: ['src/**/*.{js,ts}', 'codegen/**/*.{js,ts}'], languageOptions: { ecmaVersion: 2020, globals: globals.browser, @@ -100,7 +100,7 @@ export default tseslint.config( }, { ignores: ['src/gen/**'], - files: ['src/**/*.{js,ts}'], + files: ['src/**/*.{js,ts}', 'codegen/**/*.{js,ts}'], plugins: { jsdoc, }, diff --git a/package.json b/package.json index 4ab6e1fa97..6eb9b1feb7 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ ], "files": [ "/dist", + "/codegen", "/src" ], "dependencies": { @@ -110,9 +111,9 @@ "vitest": "^4.1.10" }, "scripts": { - "build": "rm -rf dist && concurrently 'tsc' './scripts/bundle.mjs'", + "build": "rm -rf dist && concurrently 'tsc' 'tsc -p tsconfig.codegen.json' './scripts/bundle.mjs'", "start": "concurrently 'tsc --watch' './scripts/bundle.mjs --watch'", - "types": "tsc --noEmit", + "types": "tsc --noEmit && tsc -p tsconfig.codegen.json --noEmit", "lint": "yarn run prettier && yarn run eslint", "lint-fix": "yarn run eslint-fix; yarn run prettier-fix", "prettier": "prettier '**/*.{json,js,mjs,ts,yml,md}' --check", diff --git a/scripts/bundle.mjs b/scripts/bundle.mjs index e1c45a58c2..5a5e733ccc 100755 --- a/scripts/bundle.mjs +++ b/scripts/bundle.mjs @@ -53,22 +53,27 @@ const I18N_ONLY_DEPENDENCIES = ['i18next', 'dayjs']; * elimination, so a new entry gets no rule by accident (and the codegen entry is not told off for * reaching its own source). */ +/** The generator's source, which now lives outside `src/`. */ +const CODEGEN_SOURCES = /(^|\/)codegen\//; + const ENTRY_BOUNDARIES = [ { // The root bundle: no i18n at all, and none of its dependencies. entry: 'src/index.ts', forbiddenDeps: I18N_ONLY_DEPENDENCIES, - forbiddenSources: /(^|\/)src\/i18n(-codegen)?\//, + // Two directories rather than the one `src/i18n(-codegen)?/` pattern this used to be, now that the + // generator sits outside `src/`. + forbiddenSources: /(^|\/)(src\/i18n|codegen)\//, }, { // The runtime i18n layer must not pull in the Node-only build tooling. entry: 'src/i18n/index.ts', forbiddenDeps: [], - forbiddenSources: /(^|\/)src\/i18n-codegen\//, + forbiddenSources: CODEGEN_SOURCES, }, { // The codegen is Node-only by design and has no restriction of its own. - entry: 'src/i18n-codegen/index.ts', + entry: 'codegen/i18n/index.ts', forbiddenDeps: [], forbiddenSources: null, }, @@ -80,10 +85,14 @@ const ENTRY_BOUNDARIES = [ * Two directions, both a single careless `export * from './i18n'` away: * - the root bundle must not reach `src/i18n/` or its dependencies, or every consumer of * `stream-chat` pays for i18next and dayjs whether they translate anything or not; - * - the runtime i18n bundle must not reach `src/i18n-codegen/`, which is Node-only build tooling. + * - the runtime i18n bundle must not reach `codegen/`, which is Node-only build tooling. * * Checked here rather than left to review, because the failure is invisible: everything still works, * the bundle is just quietly bigger. + * + * The second direction is now *also* enforced by the type system, since `codegen/` sits outside the + * library tsconfig — an import from `src/i18n/` fails at `tsc` first, with a better error. This stays + * as the backstop for a deliberate `require`, which `tsc` would not see. */ const assertBundleBoundaries = (metafile) => { const failures = []; @@ -173,7 +182,7 @@ const bundles = [ // reachable from `stream-chat/i18n`, which `assertBundleBoundaries` enforces. ['cjs', 'esm'].map((format) => ({ entryPoints: { - 'i18n-codegen': resolve(__dirname, '../src/i18n-codegen/index.ts'), + 'i18n-codegen': resolve(__dirname, '../codegen/i18n/index.ts'), }, bundle: true, metafile: true, diff --git a/specs/i18n-to-core/spec.md b/specs/i18n-to-core/spec.md index 875fcd4fe4..8c009ab3e8 100644 --- a/specs/i18n-to-core/spec.md +++ b/specs/i18n-to-core/spec.md @@ -72,7 +72,8 @@ Two structural invariants enforced by the build rather than by review, because b - The **root bundle must not reach `src/i18n/`** or its dependencies. Asserted from esbuild's metafile; `dist/esm/index.mjs` is byte-identical at 907,599 bytes. -- The **runtime i18n layer must not reach `src/i18n-codegen/`**, which is Node-only. +- The **runtime i18n layer must not reach `codegen/`**, which is Node-only build tooling living + outside `src/`. ## Not in scope here diff --git a/test/unit/i18n-codegen/generate.test.ts b/test/unit/codegen/i18n/generate.test.ts similarity index 99% rename from test/unit/i18n-codegen/generate.test.ts rename to test/unit/codegen/i18n/generate.test.ts index 83ed946095..5878737c0d 100644 --- a/test/unit/i18n-codegen/generate.test.ts +++ b/test/unit/codegen/i18n/generate.test.ts @@ -4,8 +4,8 @@ import path from 'node:path'; import ts from 'typescript'; import { afterEach, describe, expect, it } from 'vitest'; -import { buildCatalog, generateI18nKeys, readStringMap } from '../../../src/i18n-codegen'; -import type { GeneratorConfig } from '../../../src/i18n-codegen'; +import { buildCatalog, generateI18nKeys, readStringMap } from '../../../../codegen/i18n'; +import type { GeneratorConfig } from '../../../../codegen/i18n'; /** * Fixtures are written to a scratch directory and the generator runs in-process against them. diff --git a/tsconfig.codegen.json b/tsconfig.codegen.json new file mode 100644 index 0000000000..1b845d830b --- /dev/null +++ b/tsconfig.codegen.json @@ -0,0 +1,20 @@ +{ + // The i18n catalog generator, published as `stream-chat/i18n/codegen`. + // + // A separate project because the generator lives outside `src/`: it is Node-only build tooling that + // reads the filesystem, which is the one thing the SDK's own source must never do. Keeping it out of + // the library project is what makes that boundary type-enforced -- an accidental import from + // `src/i18n/` now fails at `tsc` rather than at the metafile assertion in `scripts/bundle.mjs`. + // + // `rootDir` is `./codegen/i18n` rather than `./codegen` so the emitted declarations land at + // `dist/types/i18n-codegen/`, exactly where `package.json`'s `exports` and `typesVersions` point. The + // published layout is unchanged by the move. + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./dist/types/i18n-codegen", + "rootDir": "./codegen/i18n", + // The generator runs under Node, not in a browser or a bundler. + "lib": ["ES2022"] + }, + "include": ["./codegen/**/*"] +} From 6529cc8fe3c483de4ae5abcbd60a6fa1b8c39044 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Tue, 18 Aug 2026 13:07:45 +0200 Subject: [PATCH 16/27] build(i18n)!: drop the CommonJS build of the catalog generator 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()`. --- CLAUDE.md | 2 +- package.json | 4 ---- scripts/bundle.mjs | 33 ++++++++++++++++++------------- specs/i18n-to-core/spec.md | 2 +- v9-to-v10-migration-guide-i18n.md | 12 +++++++---- 5 files changed, 29 insertions(+), 24 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index acdfeaab2a..6e3e919a6e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,7 +37,7 @@ Single test runs use Vitest's CLI directly: `yarn test-unit path/to/file.test.ts 2. `scripts/bundle.mjs` (esbuild) — produces bundles for **three entry points**: - `index` (the root): `dist/cjs/index.node.js` (Node CJS, externalizes deps + Node builtins), `dist/cjs/index.browser.js` (browser CJS), `dist/esm/index.mjs` (browser ESM) - `i18n` (`stream-chat/i18n`): the same three variants, `i18n.node.js` / `i18n.browser.js` / `i18n.mjs` - - `i18n-codegen` (`stream-chat/i18n/codegen`), built from `codegen/i18n/`: Node only, CJS + ESM — it reads the filesystem, so there is deliberately no browser variant + - `i18n-codegen` (`stream-chat/i18n/codegen`), built from `codegen/i18n/`: **ESM only, Node only** — one artifact, `dist/esm/i18n-codegen.mjs`. No browser variant because it reads the filesystem; no CJS variant because nothing needs one (it is invoked by a build script, never bundled, never loaded by a test runner). The CJS flavours of the other two entries exist for React Native's Jest, which loads the _runtime_ in CJS; the generator never enters that path. After building, `assertBundleBoundaries` reads esbuild's `metafile` and fails the build if an entry reached something it must not (see the i18n section). Adding a new entry point without declaring its boundary in `ENTRY_BOUNDARIES` is itself an error. diff --git a/package.json b/package.json index 6eb9b1feb7..e877a5a046 100644 --- a/package.json +++ b/package.json @@ -42,10 +42,6 @@ }, "./i18n/codegen": { "types": "./dist/types/i18n-codegen/index.d.ts", - "node": { - "import": "./dist/esm/i18n-codegen.mjs", - "require": "./dist/cjs/i18n-codegen.node.js" - }, "default": "./dist/esm/i18n-codegen.mjs" }, "./package.json": "./package.json" diff --git a/scripts/bundle.mjs b/scripts/bundle.mjs index 5a5e733ccc..7e3a38aad5 100755 --- a/scripts/bundle.mjs +++ b/scripts/bundle.mjs @@ -178,9 +178,21 @@ const bundles = [ 'process.env.CLIENT_BUNDLE': JSON.stringify('browser-esm'), }, }, - // Build-time codegen: Node only, so no browser variant. Kept a separate entry so it never becomes - // reachable from `stream-chat/i18n`, which `assertBundleBoundaries` enforces. - ['cjs', 'esm'].map((format) => ({ + // Build-time codegen: **ESM only, and Node only.** + // + // No browser variant because it reads the filesystem. No CJS variant because nothing needs one: it is + // invoked by a build script, never bundled and never loaded by a test runner. Both UI SDKs run it as + // `node scripts/generate-i18n-keys.mts`, and `.mts` is unambiguously ESM. The CJS flavours elsewhere + // in this file exist for React Native's Jest, which loads the *runtime* in CJS and does not transform + // `node_modules` — the generator never enters that path. Shipping a second flavour nothing exercises + // is worse than not shipping it. + // + // A CJS caller is still fine on `await import('stream-chat/i18n/codegen')`, and on plain `require()` + // from Node 20.19 / 22.12 onward. + // + // Kept a separate entry so it never becomes reachable from `stream-chat/i18n`, which + // `assertBundleBoundaries` enforces. + { entryPoints: { 'i18n-codegen': resolve(__dirname, '../codegen/i18n/index.ts'), }, @@ -188,20 +200,13 @@ const bundles = [ metafile: true, target: 'node18', platform: 'node', - format, + format: 'esm', external: nodeExternal, sourcemap: watchModeEnabled ? 'inline' : 'linked', define: { 'process.env.PKG_VERSION': JSON.stringify(version) }, - ...(format === 'cjs' - ? { - entryNames: '[dir]/[name].node', - outdir: resolve(__dirname, '../dist/cjs'), - } - : { - outExtension: { '.js': '.mjs' }, - outdir: resolve(__dirname, '../dist/esm'), - }), - })), + outExtension: { '.js': '.mjs' }, + outdir: resolve(__dirname, '../dist/esm'), + }, ].flat(); if (watchModeEnabled) { diff --git a/specs/i18n-to-core/spec.md b/specs/i18n-to-core/spec.md index 8c009ab3e8..7a0f9f5e67 100644 --- a/specs/i18n-to-core/spec.md +++ b/specs/i18n-to-core/spec.md @@ -45,7 +45,7 @@ the work from greenfield to typing and gap-filling: - **`stream-chat/i18n` subpath** — `Streami18n` (reactive via `StateStore`), three formatters, `getDateString`, catalog-generic type helpers, `TranslationBuilder` plumbing, generated language names, the shared notification key registry. -- **`stream-chat/i18n/codegen` subpath** — the catalog generator, Node-only, with `typescript` +- **`stream-chat/i18n/codegen` subpath** — the catalog generator, Node-only and ESM-only, with `typescript` injected rather than imported. Verified to reproduce both SDKs' real committed catalogs identically (React 634/634, RN 408/408 + 97 bundled). - **Scoped identifiers** — `CORE_NOTIFICATION_TYPE` / `CoreNotificationType` and diff --git a/v9-to-v10-migration-guide-i18n.md b/v9-to-v10-migration-guide-i18n.md index 963e77ff11..892ad7d86f 100644 --- a/v9-to-v10-migration-guide-i18n.md +++ b/v9-to-v10-migration-guide-i18n.md @@ -19,7 +19,7 @@ - **`Notification.type` is now typed** as `CoreNotificationType | (string & {})` and enumerated in the exported `CORE_NOTIFICATION_TYPE` map. Additive — your own identifiers still pass. - **`Notification.message` is now documented as a developer-facing fallback, not display copy.** Its wording is not part of the public contract and may change in a minor release. Nothing breaks today, but anything user-facing should switch on `type`. See [Rendering notifications](#rendering-notifications). - **New subpath `stream-chat/i18n`** carries the shared translation runtime (`Streami18n`, formatters, date handling). Nothing is re-exported from `stream-chat`'s root, so the root bundle is unchanged. -- **New subpath `stream-chat/i18n/codegen`** carries the build-time translation-catalog generator. Node-only. +- **New subpath `stream-chat/i18n/codegen`** carries the build-time translation-catalog generator. Node-only, and ESM-only — `await import()` it from a CommonJS script, or `require()` it on Node 20.19+. - **`stream-chat` now depends on `i18next` and `dayjs`.** Install footprint grows ~2.3 MB; **bundle size is unaffected** unless you import `stream-chat/i18n`. - Nothing in the JSDoc ever described a `Notification.code` field. There is no such field and never was — the block documenting the `domain:entity:operation:result` scheme was attached to `type` and mislabelled. It has been corrected. @@ -234,9 +234,13 @@ Notable if you are building custom UI directly on `stream-chat`: ## New subpath: `stream-chat/i18n/codegen` -Build-time only, and **Node-only**: it reads the filesystem and uses the TypeScript parser API. It -generates a type-only translation-key catalog from your `t()` call sites, which is how a mistyped key -becomes a compile error. +Build-time only, **Node-only** and **ESM-only**: it reads the filesystem and uses the TypeScript parser +API. It generates a type-only translation-key catalog from your `t()` call sites, which is how a +mistyped key becomes a compile error. + +There is one artifact and no CommonJS build, since the caller is always a build script you control. From +an ESM script (`.mjs`, `.mts`, or a `"type": "module"` package) import it directly; from CommonJS use +`await import('stream-chat/i18n/codegen')`, or a plain `require()` on Node 20.19+. `typescript` is injected rather than imported, so `stream-chat` does not depend on the compiler: From efb92750fbb8fbe4cfe45ec034a00458b07fb545 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Tue, 18 Aug 2026 13:21:31 +0200 Subject: [PATCH 17/27] build: convert scripts/bundle.mjs to TypeScript and typecheck the build 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`. --- CLAUDE.md | 15 ++++- package.json | 11 ++-- scripts/apply-custom-data-types.mts | 18 ++---- scripts/{bundle.mjs => bundle.mts} | 93 +++++++++++++++++------------ tsconfig.scripts.json | 24 ++++++++ 5 files changed, 103 insertions(+), 58 deletions(-) rename scripts/{bundle.mjs => bundle.mts} (71%) create mode 100644 tsconfig.scripts.json diff --git a/CLAUDE.md b/CLAUDE.md index 6e3e919a6e..80e8689c3f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,6 +19,7 @@ Companion docs that apply to all agents: `AGENTS.md` (general agent rules) and ` | Build (types + bundles) | `yarn build` | | Watch dev build | `yarn start` | | Typecheck only | `yarn types` | +| Typecheck the build scripts only | `yarn types:scripts` | | Lint (prettier + eslint, zero warnings) | `yarn lint` | | Auto-fix lint/format | `yarn lint-fix` | | Unit tests (Vitest) | `yarn test` (alias for `yarn test-unit`) | @@ -34,19 +35,27 @@ Single test runs use Vitest's CLI directly: `yarn test-unit path/to/file.test.ts `yarn build` runs two things concurrently: 1. `tsc` — emits **declarations only** (`emitDeclarationOnly: true`) to `dist/types`. `rootDir` is `src/`. -2. `scripts/bundle.mjs` (esbuild) — produces bundles for **three entry points**: +2. `scripts/bundle.mts` (esbuild) — produces bundles for **three entry points**: - `index` (the root): `dist/cjs/index.node.js` (Node CJS, externalizes deps + Node builtins), `dist/cjs/index.browser.js` (browser CJS), `dist/esm/index.mjs` (browser ESM) - `i18n` (`stream-chat/i18n`): the same three variants, `i18n.node.js` / `i18n.browser.js` / `i18n.mjs` - `i18n-codegen` (`stream-chat/i18n/codegen`), built from `codegen/i18n/`: **ESM only, Node only** — one artifact, `dist/esm/i18n-codegen.mjs`. No browser variant because it reads the filesystem; no CJS variant because nothing needs one (it is invoked by a build script, never bundled, never loaded by a test runner). The CJS flavours of the other two entries exist for React Native's Jest, which loads the _runtime_ in CJS; the generator never enters that path. After building, `assertBundleBoundaries` reads esbuild's `metafile` and fails the build if an entry reached something it must not (see the i18n section). Adding a new entry point without declaring its boundary in `ENTRY_BOUNDARIES` is itself an error. -`package.json#exports` routes consumers to the right bundle by condition: `node` → node-cjs, `browser`/`react-native` → browser-cjs (require) or esm (import), default → esm. The `react-native` + `require` branch must stay pointed at CJS — React Native's Jest runs CJS with `customConditions: ["react-native"]` and does not transform `node_modules`, so an `.mjs` there is a syntax error across every RN suite that touches the module. `typesVersions` mirrors the subpaths for consumers still on `moduleResolution: "node"`. There is **no `package.json#browser` field** — it used to zero Node-only deps (`crypto`, `https`, `jsonwebtoken`, `ws`, `zlib`) for browser/RN builds, but the SDK no longer imports any of them (`src/index.ts` is platform-agnostic: global `WebSocket`, global `FormData`, global `atob`). `scripts/bundle.mjs` keeps a `browserIgnoreModules` hook, currently an empty array, for the day that changes. Prefer a platform global or a browser-safe dep over reintroducing a Node-only one. +`package.json#exports` routes consumers to the right bundle by condition: `node` → node-cjs, `browser`/`react-native` → browser-cjs (require) or esm (import), default → esm. The `react-native` + `require` branch must stay pointed at CJS — React Native's Jest runs CJS with `customConditions: ["react-native"]` and does not transform `node_modules`, so an `.mjs` there is a syntax error across every RN suite that touches the module. `typesVersions` mirrors the subpaths for consumers still on `moduleResolution: "node"`. There is **no `package.json#browser` field** — it used to zero Node-only deps (`crypto`, `https`, `jsonwebtoken`, `ws`, `zlib`) for browser/RN builds, but the SDK no longer imports any of them (`src/index.ts` is platform-agnostic: global `WebSocket`, global `FormData`, global `atob`). `scripts/bundle.mts` keeps a `browserIgnoreModules` hook, currently an empty array, for the day that changes. Prefer a platform global or a browser-safe dep over reintroducing a Node-only one. esbuild `define` injects two compile-time constants: `process.env.PKG_VERSION` (read from `package.json`) and `process.env.CLIENT_BUNDLE` (one of `node-cjs`, `browser-cjs`, `browser-esm`). Both are consumed by `StreamChat.getUserAgent()` to produce a bundle-aware UA string. **`tsc`-only code paths do not get this substitution** — these env vars only resolve in the esbuild bundles, so don't gate runtime logic on them in code that callers might import directly via `src/`. `postinstall` installs husky hooks; `prepare` runs `yarn run build` (so consumers installing from a git ref get a built package). +**The build scripts are `.mts`, run by `node` with no loader** — Node strips the types itself, which needs Node 22.6+ (`--experimental-strip-types`) or 23.6+/24 where it is on by default. That is fine for this repo and CI (both `.nvmrc`-pinned to 24), but note it narrows the `prepare` path: a **git-ref** install on a Node older than that cannot build, even though `engines.node` still says `>=18`. Registry installs are unaffected — they get the prebuilt `dist/`. + +Three separate things cover `scripts/`, and each was scoped to miss it at some point: + +- **Types:** `tsconfig.scripts.json`, run by `yarn types:scripts` and folded into `yarn types`. Without it `.mts` annotations are stripped but never checked, which is worse than the JSDoc `@type` comments they replaced. +- **Format:** the `yarn prettier` glob had to gain `mts` — it listed `js,mjs,ts` only, so every `.mts` in the repo silently escaped the format gate. +- **Lint:** `scripts/` is **still not linted.** `eslint.config.mjs` scopes every rule block to `src/**` and `codegen/**`. Widening it is a worthwhile follow-up; be aware the rules have never run there. + ## Architecture This is a single-package SDK with **no monorepo**. The public surface is everything re-exported from `src/index.ts` — treat additions there as public API and follow semver carefully (downstream React/Angular/RN SDKs depend on it). @@ -135,7 +144,7 @@ SDKs carried ~1,300 lines of near-duplicate runtime plus a duplicated codegen. S `specs/i18n-to-core/` for the initiative and `v9-to-v10-migration-guide-i18n.md` for the consumer delta. **Three entry points, and the boundaries between them are enforced by the build.** `src/index.ts` must -**never** `export * from './i18n'` — that is the one reflex to resist. `scripts/bundle.mjs` asserts from +**never** `export * from './i18n'` — that is the one reflex to resist. `scripts/bundle.mts` asserts from esbuild's metafile that the root bundle cannot reach `src/i18n/`, `i18next` or `dayjs`, and that `src/i18n/` cannot reach the Node-only `codegen/`. Both leaks fail invisibly (everything works, the bundle is just bigger), which is why they are machine-checked. `dist/esm/index.mjs` is expected to stay diff --git a/package.json b/package.json index e877a5a046..a7ba5f1fd0 100644 --- a/package.json +++ b/package.json @@ -107,12 +107,12 @@ "vitest": "^4.1.10" }, "scripts": { - "build": "rm -rf dist && concurrently 'tsc' 'tsc -p tsconfig.codegen.json' './scripts/bundle.mjs'", - "start": "concurrently 'tsc --watch' './scripts/bundle.mjs --watch'", - "types": "tsc --noEmit && tsc -p tsconfig.codegen.json --noEmit", + "build": "rm -rf dist && concurrently 'tsc' 'tsc -p tsconfig.codegen.json' './scripts/bundle.mts'", + "start": "concurrently 'tsc --watch' './scripts/bundle.mts --watch'", + "types": "tsc --noEmit && tsc -p tsconfig.codegen.json --noEmit && yarn run types:scripts", "lint": "yarn run prettier && yarn run eslint", "lint-fix": "yarn run eslint-fix; yarn run prettier-fix", - "prettier": "prettier '**/*.{json,js,mjs,ts,yml,md}' --check", + "prettier": "prettier '**/*.{json,js,mjs,mts,cjs,ts,yml,md}' --check", "prettier-fix": "yarn run prettier --write", "eslint": "eslint --max-warnings 0", "eslint-fix": "yarn run eslint --fix", @@ -126,7 +126,8 @@ "semantic-release": "semantic-release", "postinstall": "node -e \"require('fs').existsSync('scripts/install-husky.mjs') && import('./scripts/install-husky.mjs')\"", "prepare": "yarn run build", - "generate-client": "./scripts/generate-client.sh" + "generate-client": "./scripts/generate-client.sh", + "types:scripts": "tsc -p tsconfig.scripts.json" }, "engines": { "node": ">=18" diff --git a/scripts/apply-custom-data-types.mts b/scripts/apply-custom-data-types.mts index 0114268273..5967b1a3ab 100644 --- a/scripts/apply-custom-data-types.mts +++ b/scripts/apply-custom-data-types.mts @@ -187,9 +187,7 @@ const { values } = parseArgs({ const inputPath = values.input; if (!inputPath) { - console.error( - 'Usage: node scripts/apply-custom-data-types.mts -i ', - ); + console.error('Usage: node scripts/apply-custom-data-types.mts -i '); process.exit(1); } @@ -197,8 +195,7 @@ const absoluteInputPath = resolve(process.cwd(), inputPath); const source = readFileSync(absoluteInputPath, 'utf8'); const CUSTOM_FIELD_RE = /^(\s*)custom(\??):\s*Record;\s*$/; -const CHANNEL_CUSTOM_FIELD_RE = - /^(\s*)channel_custom(\??):\s*Record;\s*$/; +const CHANNEL_CUSTOM_FIELD_RE = /^(\s*)channel_custom(\??):\s*Record;\s*$/; const INTERFACE_OPEN_RE = /^export interface (\w+)\s*(?:extends [^{]+)?\{\s*$/; const INTERFACE_CLOSE_RE = /^\}\s*$/; const FILTER_OPEN_RE = /^\s*(\w+)\??:\s*Filters<\{\s*$/; @@ -224,9 +221,8 @@ const skippedFilterKeys = new Set(); // (starts at 1 for the `Filters<{` itself); `inCustomEntry` tracks whether // we're currently inside the `custom: { ... }` sub-block that carries the // rewritable `type: Record;` line. -let filterContext: - | { key: string; braceDepth: number; inCustomEntry: boolean } - | null = null; +let filterContext: { key: string; braceDepth: number; inCustomEntry: boolean } | null = + null; const countBraces = (line: string) => { let opens = 0; @@ -380,11 +376,7 @@ function computeImportSpecifier(fromFileAbs: string, toRepoRelative: string) { * the top of the file, merging into an existing import from the same module * (deduped and sorted). */ -function applyImport( - fileLines: string[], - identifiers: string[], - specifier: string, -) { +function applyImport(fileLines: string[], identifiers: string[], specifier: string) { const importRe = new RegExp( `^import\\s+type\\s+\\{([^}]*)\\}\\s+from\\s+['"]${escapeRegex(specifier)}['"];?\\s*$`, ); diff --git a/scripts/bundle.mjs b/scripts/bundle.mts similarity index 71% rename from scripts/bundle.mjs rename to scripts/bundle.mts index 7e3a38aad5..299578076f 100755 --- a/scripts/bundle.mjs +++ b/scripts/bundle.mts @@ -13,21 +13,24 @@ const watchModeEnabled = process.argv.includes('--watch') || process.argv.includ const version = getPackageVersion(); -const modules = Object.keys({ - ...packageJson.dependencies, - ...packageJson.peerDependencies, -}); +const { dependencies = {}, peerDependencies = {} } = packageJson as { + dependencies?: Record; + // There are none today. Kept in the spread so that adding one externalizes it automatically rather + // than silently bundling it — `tsc` rejected reading the absent field, which is how this surfaced. + peerDependencies?: Record; +}; + +const modules = Object.keys({ ...dependencies, ...peerDependencies }); // do not externalize modules that are ignored in browser field // externalizing them will cause esbuild to not replace the imports // in the bundles -const browserIgnoreModules = []; // Object.keys(packageJson.browser); +const browserIgnoreModules: string[] = []; // Object.keys(packageJson.browser); const browserExternal = modules.filter( (module) => !browserIgnoreModules.includes(module), ); const nodeExternal = [...modules, ...builtinModules]; -/** @type esbuild.BuildOptions */ const commonBuildOptions = { // Name-keyed so `[name]` stays stable per entry. `i18n` is a separate entry point on purpose: it // pulls in i18next and dayjs, and keeping those out of the root bundle is the whole reason @@ -43,20 +46,33 @@ const commonBuildOptions = { define: { 'process.env.PKG_VERSION': JSON.stringify(version), }, -}; + // `satisfies` rather than a `: esbuild.BuildOptions` annotation. The annotation would widen every + // field to its optional declared type, so `...commonBuildOptions.define` below would spread a + // possibly-`undefined` value and stop typechecking. This checks the literal against `BuildOptions` + // while keeping its exact shape. +} satisfies esbuild.BuildOptions; /** Dependencies that must never be reachable from the root bundle. */ const I18N_ONLY_DEPENDENCIES = ['i18next', 'dayjs']; +/** The generator's source, which lives outside `src/`. */ +const CODEGEN_SOURCES = /(^|\/)codegen\//; + +type EntryBoundary = { + /** Suffix-matched against esbuild's `entryPoint`. */ + entry: string; + /** Bare module specifiers this entry must not import, matched exactly or as a subpath prefix. */ + forbiddenDeps: string[]; + /** Input paths this entry must not reach. `null` means no restriction. */ + forbiddenSources: RegExp | null; +}; + /** * What each entry point is forbidden from reaching. Keyed on the entry explicitly rather than by * elimination, so a new entry gets no rule by accident (and the codegen entry is not told off for * reaching its own source). */ -/** The generator's source, which now lives outside `src/`. */ -const CODEGEN_SOURCES = /(^|\/)codegen\//; - -const ENTRY_BOUNDARIES = [ +const ENTRY_BOUNDARIES: EntryBoundary[] = [ { // The root bundle: no i18n at all, and none of its dependencies. entry: 'src/index.ts', @@ -94,19 +110,19 @@ const ENTRY_BOUNDARIES = [ * library tsconfig — an import from `src/i18n/` fails at `tsc` first, with a better error. This stays * as the backstop for a deliberate `require`, which `tsc` would not see. */ -const assertBundleBoundaries = (metafile) => { - const failures = []; +const assertBundleBoundaries = (metafile: esbuild.Metafile) => { + const failures: string[] = []; for (const [outputFile, output] of Object.entries(metafile.outputs)) { - if (!output.entryPoint) continue; + const { entryPoint } = output; + if (!entryPoint) continue; - const boundary = ENTRY_BOUNDARIES.find(({ entry }) => - output.entryPoint.endsWith(entry), - ); + // Hoisted out of `output` because a narrowing does not survive into the callback below. + const boundary = ENTRY_BOUNDARIES.find(({ entry }) => entryPoint.endsWith(entry)); if (!boundary) { failures.push( - `${outputFile} (entry ${output.entryPoint}) has no declared boundary — add one to ` + - `ENTRY_BOUNDARIES in scripts/bundle.mjs.`, + `${outputFile} (entry ${entryPoint}) has no declared boundary — add one to ` + + `ENTRY_BOUNDARIES in scripts/bundle.mts.`, ); continue; } @@ -116,8 +132,9 @@ const assertBundleBoundaries = (metafile) => { sources: boundary.forbiddenSources, }; - const leakedSources = forbidden.sources - ? Object.keys(output.inputs).filter((input) => forbidden.sources.test(input)) + const { sources: forbiddenSources } = forbidden; + const leakedSources = forbiddenSources + ? Object.keys(output.inputs).filter((input) => forbiddenSources.test(input)) : []; const leakedDeps = (output.imports ?? []) .map(({ path }) => path) @@ -127,7 +144,7 @@ const assertBundleBoundaries = (metafile) => { if (leakedSources.length || leakedDeps.length) { failures.push( - `${outputFile} (entry ${output.entryPoint}) must not reach: ` + + `${outputFile} (entry ${entryPoint}) must not reach: ` + [...new Set([...leakedSources, ...leakedDeps])].join(', '), ); } @@ -152,18 +169,20 @@ const assertBundleBoundaries = (metafile) => { // nice for import not to break on server). const bundles = [ // CJS (browser & Node) - ['browser', 'node'].map((platform) => ({ - ...commonBuildOptions, - format: 'cjs', - external: platform === 'browser' ? browserExternal : nodeExternal, - entryNames: `[dir]/[name].${platform}`, - outdir: resolve(__dirname, '../dist/cjs'), - platform, - define: { - ...commonBuildOptions.define, - 'process.env.CLIENT_BUNDLE': JSON.stringify(`${platform}-cjs`), - }, - })), + (['browser', 'node'] as const).map( + (platform): esbuild.BuildOptions => ({ + ...commonBuildOptions, + format: 'cjs', + external: platform === 'browser' ? browserExternal : nodeExternal, + entryNames: `[dir]/[name].${platform}`, + outdir: resolve(__dirname, '../dist/cjs'), + platform, + define: { + ...commonBuildOptions.define, + 'process.env.CLIENT_BUNDLE': JSON.stringify(`${platform}-cjs`), + }, + }), + ), // ESM (browser only) { ...commonBuildOptions, @@ -177,7 +196,7 @@ const bundles = [ ...commonBuildOptions.define, 'process.env.CLIENT_BUNDLE': JSON.stringify('browser-esm'), }, - }, + } satisfies esbuild.BuildOptions, // Build-time codegen: **ESM only, and Node only.** // // No browser variant because it reads the filesystem. No CJS variant because nothing needs one: it is @@ -198,7 +217,7 @@ const bundles = [ }, bundle: true, metafile: true, - target: 'node18', + target: 'es2022', platform: 'node', format: 'esm', external: nodeExternal, @@ -206,7 +225,7 @@ const bundles = [ define: { 'process.env.PKG_VERSION': JSON.stringify(version) }, outExtension: { '.js': '.mjs' }, outdir: resolve(__dirname, '../dist/esm'), - }, + } satisfies esbuild.BuildOptions, ].flat(); if (watchModeEnabled) { diff --git a/tsconfig.scripts.json b/tsconfig.scripts.json new file mode 100644 index 0000000000..649748abbe --- /dev/null +++ b/tsconfig.scripts.json @@ -0,0 +1,24 @@ +{ + // The repo's build and codegen scripts, typecheck-only. + // + // Node strips types from a `.mts` file; it does not check them. Without this project the annotations + // in `scripts/*.mts` could be wrong with no signal anywhere -- worse than the JSDoc `@type` comments + // they replaced, which at least were inert. `yarn types` runs it. + // + // `rootDir` is widened to the repo root because the base config pins it to `./src` for declaration + // emit; nothing is emitted here, so it only needs to contain the inputs. + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "rootDir": ".", + "emitDeclarationOnly": false, + "declaration": false, + // These scripts run under Node directly, not through a bundler, so resolution has to match Node's + // own -- which is what makes `import ... with { type: 'json' }` and `.mjs` specifiers resolve. + "module": "nodenext", + "moduleResolution": "nodenext", + "lib": ["ES2023"], + "types": ["node"] + }, + "include": ["./scripts/**/*.mts"] +} From e1f8c7246d58eec39f568a690dba5b3e376dee37 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Tue, 18 Aug 2026 13:24:07 +0200 Subject: [PATCH 18/27] build: lint the build scripts, and declare the yaml dependency they need MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .lintstagedrc.json | 4 ++-- CLAUDE.md | 2 +- eslint.config.mjs | 4 ++-- package.json | 3 ++- scripts/generate-filter-types.mts | 2 +- yarn.lock | 1 + 6 files changed, 9 insertions(+), 7 deletions(-) diff --git a/.lintstagedrc.json b/.lintstagedrc.json index 92e367ee66..58c0db7d9e 100644 --- a/.lintstagedrc.json +++ b/.lintstagedrc.json @@ -1,4 +1,4 @@ { - "**/*.{json,js,mjs,ts,yml,md}": "prettier --list-different", - "**/*.{js,mjs,ts}, !test": "eslint --max-warnings 0" + "**/*.{json,js,mjs,mts,cjs,ts,yml,md}": "prettier --list-different", + "**/*.{js,mjs,mts,cjs,ts}, !test": "eslint --max-warnings 0" } diff --git a/CLAUDE.md b/CLAUDE.md index 80e8689c3f..f1368562f2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,7 +54,7 @@ Three separate things cover `scripts/`, and each was scoped to miss it at some p - **Types:** `tsconfig.scripts.json`, run by `yarn types:scripts` and folded into `yarn types`. Without it `.mts` annotations are stripped but never checked, which is worse than the JSDoc `@type` comments they replaced. - **Format:** the `yarn prettier` glob had to gain `mts` — it listed `js,mjs,ts` only, so every `.mts` in the repo silently escaped the format gate. -- **Lint:** `scripts/` is **still not linted.** `eslint.config.mjs` scopes every rule block to `src/**` and `codegen/**`. Widening it is a worthwhile follow-up; be aware the rules have never run there. +- **Lint:** `eslint.config.mjs`'s rule blocks list `scripts/**/*.mts` alongside `src/**` and `codegen/**`. Turning this on found `generate-filter-types.mts` importing `yaml` while nothing declared it — it resolved only because `lint-staged` happens to depend on it. `.lintstagedrc.json` has its **own** globs, which also omitted `mts`; both are widened, and note the eslint entry runs with `--max-warnings 0`, so a file matching no config block fails the hook with "no matching configuration was supplied" rather than passing silently. ## Architecture diff --git a/eslint.config.mjs b/eslint.config.mjs index d9815c5a66..fe35aae7d3 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -13,7 +13,7 @@ export default tseslint.config( { name: 'default', extends: [js.configs.recommended, ...tseslint.configs.recommended], - files: ['src/**/*.{js,ts}', 'codegen/**/*.{js,ts}'], + files: ['src/**/*.{js,ts}', 'codegen/**/*.{js,ts}', 'scripts/**/*.mts'], languageOptions: { ecmaVersion: 2020, globals: globals.browser, @@ -100,7 +100,7 @@ export default tseslint.config( }, { ignores: ['src/gen/**'], - files: ['src/**/*.{js,ts}', 'codegen/**/*.{js,ts}'], + files: ['src/**/*.{js,ts}', 'codegen/**/*.{js,ts}', 'scripts/**/*.mts'], plugins: { jsdoc, }, diff --git a/package.json b/package.json index a7ba5f1fd0..ae4c826a91 100644 --- a/package.json +++ b/package.json @@ -104,7 +104,8 @@ "sinon": "^12.0.1", "typescript": "^6.0.3", "typescript-eslint": "^8.59.4", - "vitest": "^4.1.10" + "vitest": "^4.1.10", + "yaml": "^2.8.4" }, "scripts": { "build": "rm -rf dist && concurrently 'tsc' 'tsc -p tsconfig.codegen.json' './scripts/bundle.mts'", diff --git a/scripts/generate-filter-types.mts b/scripts/generate-filter-types.mts index 8fc9f4f30c..500b3bdc46 100644 --- a/scripts/generate-filter-types.mts +++ b/scripts/generate-filter-types.mts @@ -90,7 +90,7 @@ for (const [schemaName, schema] of Object.entries(schemas)) { const filterFields = propertyDef['x-stream-filter-fields']; - let typeName = `${schemaName}${snakeToCamelCase(propertyName)}`; + const typeName = `${schemaName}${snakeToCamelCase(propertyName)}`; const fieldEntries = Object.entries(filterFields).map( ([fieldName, fieldDefinition]) => { diff --git a/yarn.lock b/yarn.lock index 0f0e257960..e426f22a1f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7323,6 +7323,7 @@ __metadata: typescript: "npm:^6.0.3" typescript-eslint: "npm:^8.59.4" vitest: "npm:^4.1.10" + yaml: "npm:^2.8.4" dependenciesMeta: esbuild: built: true From e83c13540141022dc4c4c5dfda40c6acfa8a0ae9 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Tue, 18 Aug 2026 13:48:30 +0200 Subject: [PATCH 19/27] build!: raise engines.node to >=22.18.0 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. --- CLAUDE.md | 4 ++-- package.json | 2 +- v9-to-v10-migration-guide-i18n.md | 7 +++--- v9-to-v10-migration-guide-other.md | 27 ++++++++++++++++++++++++ v9-to-v10-migration-guide-server-side.md | 5 +++++ 5 files changed, 39 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f1368562f2..9bfd17cf47 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ Companion docs that apply to all agents: `AGENTS.md` (general agent rules) and ` ## Toolchain -- Node version is pinned in `.nvmrc` (use `nvm use`). `engines.node` requires `>=18`. +- Node version is pinned in `.nvmrc` (use `nvm use`). `engines.node` requires `>=22.18.0` — the release that unflagged type stripping, which the `.mts` build scripts need. Node 22.12 is a different milestone (`require(esm)`) and is **not** enough to run them. - Package manager is **Yarn 4 (Berry)**, version pinned via `packageManager` in `package.json` and `yarnPath` in `.yarnrc.yml` (binary committed under `.yarn/releases/`). Any globally installed `yarn` launcher delegates to it. No Corepack setup needed. - `.yarnrc.yml` enables hardening: `enableHardenedMode: true`, `enableScripts: false`, `npmMinimalAgeGate: 3d`. Lifecycle scripts are blocked by default — only packages allowlisted in `package.json#dependenciesMeta` (currently `esbuild`, `husky`) may run install scripts. If a new dep needs lifecycle scripts, add it to `dependenciesMeta` rather than relaxing the global setting. - Clean installs (CI and local sanity checks): `yarn install --immutable`. @@ -48,7 +48,7 @@ esbuild `define` injects two compile-time constants: `process.env.PKG_VERSION` ( `postinstall` installs husky hooks; `prepare` runs `yarn run build` (so consumers installing from a git ref get a built package). -**The build scripts are `.mts`, run by `node` with no loader** — Node strips the types itself, which needs Node 22.6+ (`--experimental-strip-types`) or 23.6+/24 where it is on by default. That is fine for this repo and CI (both `.nvmrc`-pinned to 24), but note it narrows the `prepare` path: a **git-ref** install on a Node older than that cannot build, even though `engines.node` still says `>=18`. Registry installs are unaffected — they get the prebuilt `dist/`. +**The build scripts are `.mts`, run by `node` with no loader** — Node strips the types itself, which is unflagged from **22.18.0** (and 24.3.0 on the 24 line; 23.6.0 on the 23 line). `engines.node` is set to that floor deliberately, because `prepare` runs `yarn build`: a **git-ref** install has to be able to execute these scripts. Registry installs never run the build — they get the prebuilt `dist/`. Three separate things cover `scripts/`, and each was scoped to miss it at some point: diff --git a/package.json b/package.json index ae4c826a91..05cd41e913 100644 --- a/package.json +++ b/package.json @@ -131,7 +131,7 @@ "types:scripts": "tsc -p tsconfig.scripts.json" }, "engines": { - "node": ">=18" + "node": ">=22.18.0" }, "packageManager": "yarn@4.15.0", "dependenciesMeta": { diff --git a/v9-to-v10-migration-guide-i18n.md b/v9-to-v10-migration-guide-i18n.md index 892ad7d86f..9fb44a02cd 100644 --- a/v9-to-v10-migration-guide-i18n.md +++ b/v9-to-v10-migration-guide-i18n.md @@ -19,7 +19,7 @@ - **`Notification.type` is now typed** as `CoreNotificationType | (string & {})` and enumerated in the exported `CORE_NOTIFICATION_TYPE` map. Additive — your own identifiers still pass. - **`Notification.message` is now documented as a developer-facing fallback, not display copy.** Its wording is not part of the public contract and may change in a minor release. Nothing breaks today, but anything user-facing should switch on `type`. See [Rendering notifications](#rendering-notifications). - **New subpath `stream-chat/i18n`** carries the shared translation runtime (`Streami18n`, formatters, date handling). Nothing is re-exported from `stream-chat`'s root, so the root bundle is unchanged. -- **New subpath `stream-chat/i18n/codegen`** carries the build-time translation-catalog generator. Node-only, and ESM-only — `await import()` it from a CommonJS script, or `require()` it on Node 20.19+. +- **New subpath `stream-chat/i18n/codegen`** carries the build-time translation-catalog generator. Node-only, and ESM-only — but `engines.node` is now `>=22.18.0`, and `require(esm)` has been unflagged since 22.12, so `require()` works on every supported Node as well as `import`. - **`stream-chat` now depends on `i18next` and `dayjs`.** Install footprint grows ~2.3 MB; **bundle size is unaffected** unless you import `stream-chat/i18n`. - Nothing in the JSDoc ever described a `Notification.code` field. There is no such field and never was — the block documenting the `domain:entity:operation:result` scheme was attached to `type` and mislabelled. It has been corrected. @@ -239,8 +239,9 @@ API. It generates a type-only translation-key catalog from your `t()` call sites mistyped key becomes a compile error. There is one artifact and no CommonJS build, since the caller is always a build script you control. From -an ESM script (`.mjs`, `.mts`, or a `"type": "module"` package) import it directly; from CommonJS use -`await import('stream-chat/i18n/codegen')`, or a plain `require()` on Node 20.19+. +an ESM script (`.mjs`, `.mts`, or a `"type": "module"` package) import it directly. A CommonJS script can +`require()` it too: `require(esm)` was unflagged in Node 22.12 and this package's floor is now 22.18.0. +`await import('stream-chat/i18n/codegen')` also works, on any Node. `typescript` is injected rather than imported, so `stream-chat` does not depend on the compiler: diff --git a/v9-to-v10-migration-guide-other.md b/v9-to-v10-migration-guide-other.md index 98303864df..a817c7dc05 100644 --- a/v9-to-v10-migration-guide-other.md +++ b/v9-to-v10-migration-guide-other.md @@ -14,6 +14,12 @@ ## TL;DR +- **`engines.node` is now `>=22.18.0`** (was `>=18`). Node 22.18 is the release that unflagged + TypeScript type stripping, which the package's own build scripts need — `prepare` runs the build, so a + git-ref install has to be able to execute them. A registry install never builds, so if you are pinned + to an older Node the runtime code itself is unlikely to care; `engines` is advisory and most package + managers warn rather than fail. But 18 and 20 are no longer tested. See the note below on what this + means if you deploy the WebSocket client on Node 18 or 20. - **Server-side is gone.** If you construct with a `secret` or call server-only admin endpoints, switch to `@stream-io/node-sdk`. The construction guide has the full list — every feature module below that was server-only is dropped for the same reason. - Two barrels removed from the package root, one added: **`./events` and `./base64` are gone; `./logger` is new.** `./signing` survives with exactly one export left, `UserFromToken`. The `./campaign`, `./channel_batch_updater`, and `./segment` barrels are still exported but the modules are emptied (they contain only a comment pointing at the server SDK) — importing anything by name from them will fail. - `Event` (type name) is kept, but its shape widened: `Event = WSEvent | LocalEvent | keyof CustomEventTypes`. `EventPayload<''>` narrows to a specific event. @@ -26,6 +32,27 @@ --- +## Node version floor + +`engines.node` moves from `>=18` to `>=22.18.0`. + +The driver is the build, not the runtime: the package's build scripts are `.mts`, executed by `node` +with no loader, and unflagged type stripping landed in **22.18.0** (24.3.0 on the 24 line, 23.6.0 on +the 23 line). Because `prepare` runs `yarn build`, anyone installing from a git ref has to be able to +run them. Note that 22.12 — the `require(esm)` milestone — is _not_ sufficient for this; the two are +often conflated. + +**If you install from the npm registry, nothing in the shipped runtime is known to need 22.18.** You get +a prebuilt `dist/` and never run the build. `engines` is advisory, and npm/yarn warn rather than fail by +default. Treat the bump as "18 and 20 are no longer tested" rather than "the code will not run". + +**One place this needs care:** [`v9-to-v10-migration-guide-server-side.md`](./v9-to-v10-migration-guide-server-side.md) +documents running the WebSocket client on Node 18/20 by injecting a `WebSocketImpl` (Node only gained a +global `WebSocket` in 22). That guidance still works mechanically, and is still the right answer if you +are stuck on an older runtime — but it is now below the declared floor, so it is unsupported rather than +supported. If you are on Node 18 or 20 and rely on that path, plan the upgrade to 22.18+, where no +`WebSocketImpl` is needed at all. + ## Public export surface `src/index.ts` barrel changes: diff --git a/v9-to-v10-migration-guide-server-side.md b/v9-to-v10-migration-guide-server-side.md index 9aa240a660..d7b9642b51 100644 --- a/v9-to-v10-migration-guide-server-side.md +++ b/v9-to-v10-migration-guide-server-side.md @@ -562,6 +562,11 @@ The cast is because `ws` types its constructor with a slightly different `Messag One internal detail that matters if you inject `ws`: v9 called `ws.removeAllListeners()` during disconnect and teardown, an `EventEmitter` method the DOM `WebSocket` interface does not have. Those calls are gone. Teardown now relies on `close()` plus an internal `wsID` generation guard that makes callbacks from a superseded socket no-ops, so a `WebSocketImpl` only has to implement the four `on*` properties — it does **not** need `removeAllListeners`, `addEventListener`, or `off`. +> **`engines.node` is `>=22.18.0` as of v10**, so Node 18 and 20 are below the declared floor and no +> longer tested — see [the Node version floor](./v9-to-v10-migration-guide-other.md#node-version-floor). +> Everything in this section still works mechanically, and is still the right answer if you are stuck on +> an older runtime, but treat it as an unsupported bridge rather than a supported configuration. +> > **Officially, `WebSocketImpl` is documented as "purely for testing."** In practice it is also the escape hatch for Node <22 until the LTS ships a native `WebSocket`. If you rely on it in production, pin the `ws` version (it's stable, but its lifecycle isn't tied to `stream-chat`'s releases) and keep an eye on the SDK changelog in case the option gains stricter typing. ### Simplifying the hybrid example on Node 22+ From 6082c9dac39776e5a5b90c9108ffcd0555b24667 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Tue, 18 Aug 2026 14:20:24 +0200 Subject: [PATCH 20/27] feat(i18n): re-export the i18next instance type as I18nInstance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- src/i18n/TranslationBuilder.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/i18n/TranslationBuilder.ts b/src/i18n/TranslationBuilder.ts index d70a3b18a3..1ed8bc0cb8 100644 --- a/src/i18n/TranslationBuilder.ts +++ b/src/i18n/TranslationBuilder.ts @@ -1,5 +1,16 @@ import type { i18n as I18nInstance } from 'i18next'; +/** + * An i18next instance, as accepted by {@link TranslationTopic} and exposed as + * `Streami18n.i18nInstance`. + * + * Re-exported because it is part of this module's public surface: a consumer implementing a topic, or + * mocking one in a test, has to be able to name the type. Without this they would reach past + * `stream-chat` into `i18next` directly and have to declare it themselves — the same mistake the + * `moment-timezone` type leak was. + */ +export type { I18nInstance }; + import type { LooseTranslateFunction } from './types'; /** From fb6c6cddb850304a31bbe00df447a0a608abe551 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Tue, 18 Aug 2026 14:53:18 +0200 Subject: [PATCH 21/27] test(i18n): cover the behaviours the UI SDKs' suites were asserting for us MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- test/unit/i18n/Streami18n.test.ts | 92 +++++++++++++++++++ test/unit/i18n/getDateString.test.ts | 132 +++++++++++++++++++++++++++ 2 files changed, 224 insertions(+) diff --git a/test/unit/i18n/Streami18n.test.ts b/test/unit/i18n/Streami18n.test.ts index 1495283022..9bf1992c83 100644 --- a/test/unit/i18n/Streami18n.test.ts +++ b/test/unit/i18n/Streami18n.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { type DateTimeParserModule, + defaultDateTimeParser, isDayOrMoment, RELATIVE_TIME_CATALOG, Streami18n, @@ -251,6 +252,97 @@ describe('Streami18n', () => { }); }); +/** + * Behaviours the React SDK's suite owned before the runtime moved here. They were asserting this + * module through a thin subclass, so they belong on this side of the boundary — and none of them was + * covered here. + */ +describe('Streami18n — locale and timezone wiring', () => { + it('registers a dayjs locale config supplied at construction', async () => { + const i18n = setup({ + dayjsLocaleConfigForLanguage: { calendar: { sameDay: '[custom today] LT' } }, + language: 'nl', + }); + const { tDateTimeParser } = await i18n.init(); + + const parsed = tDateTimeParser(new Date()); + expect(isDayOrMoment(parsed)).toBe(true); + expect((parsed as { calendar: () => string }).calendar()).toContain('custom today'); + }); + + it('registers a dayjs locale config supplied through registerTranslation', async () => { + const i18n = setup({ language: 'de' }); + i18n.registerTranslation('de', { 'fixture.prose': 'Hallo' } as never, { + calendar: { sameDay: '[heute um] LT' }, + }); + const { tDateTimeParser } = await i18n.init(); + + expect( + (tDateTimeParser(new Date()) as { calendar: () => string }).calendar(), + ).toContain('heute um'); + }); + + it('defaults to the local timezone', async () => { + const i18n = setup(); + const { tDateTimeParser } = await i18n.init(); + const date = new Date(); + + expect((tDateTimeParser(date) as { format: (t: string) => string }).format('H')).toBe( + date.getHours().toString(), + ); + }); + + it('ignores a timezone when the parser cannot apply one', async () => { + // dayjs without the timezone plugin, i.e. no `.tz` on the module. The option must degrade to local + // time rather than throwing or silently producing a wrong hour. + const parserWithoutTz = Object.assign( + (input?: string | number | Date) => defaultDateTimeParser(input), + { duration: undefined, extend: undefined, locale: undefined }, + ); + const i18n = new Streami18n({ + DateTimeParser: parserWithoutTz as never, + logger: () => {}, + runtimeDefaults: fixtureRuntimeDefaults, + timezone: 'Europe/Prague', + }); + const { tDateTimeParser } = await i18n.init(); + const date = new Date(); + + expect((tDateTimeParser(date) as { format: (t: string) => string }).format('H')).toBe( + date.getHours().toString(), + ); + }); +}); + +describe('Streami18n — registerTranslation does not clobber', () => { + it('keeps a dictionary when setLanguage moves away and back', async () => { + const i18n = setup({ language: 'en' }); + i18n.registerTranslation('de', { 'fixture.prose': 'Hallo' } as never); + i18n.registerTranslation('fr', { 'fixture.prose': 'Bonjour' } as never); + await i18n.init(); + + await i18n.setLanguage('de'); + expect(i18n.t('fixture.prose', 'Hello')).toBe('Hallo'); + + await i18n.setLanguage('fr'); + expect(i18n.t('fixture.prose', 'Hello')).toBe('Bonjour'); + + // Back again: switching must not have dropped the first dictionary. + await i18n.setLanguage('de'); + expect(i18n.t('fixture.prose', 'Hello')).toBe('Hallo'); + }); + + it('keeps a registered dictionary when setLanguage targets an unregistered language', async () => { + const i18n = setup({ language: 'en' }); + i18n.registerTranslation('de', { 'fixture.prose': 'Hallo' } as never); + await i18n.init(); + + await i18n.setLanguage('ja'); + await i18n.setLanguage('de'); + expect(i18n.t('fixture.prose', 'Hello')).toBe('Hallo'); + }); +}); + describe('DateTimeLike', () => { /** * Regression: `DateTimeLike` must be declared with **method shorthand**, not diff --git a/test/unit/i18n/getDateString.test.ts b/test/unit/i18n/getDateString.test.ts index 38d3a872fb..8c94f7a737 100644 --- a/test/unit/i18n/getDateString.test.ts +++ b/test/unit/i18n/getDateString.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { + createDefaultTranslatorFunction, defaultDateTimeParser, getCalendarDateStringForA11y, getDateString, @@ -17,6 +18,12 @@ const KEY = 'timestamp.MessageTimestamp'; const KEY_VALUE = '{{ timestamp | timestampFormatter(calendar: false; format: HH:mm) }}'; const AT = '2019-04-03T14:42:47.087Z'; +/** `Streami18n` with the catalog left open, so a test can supply an arbitrary formatter expression. */ +const StreamI18nForLogger = Streami18n as unknown as new (options: { + logger: (message?: string) => void; + runtimeDefaults: Record; +}) => Streami18n; + const setup = async (runtimeDefaults: Record = { [KEY]: KEY_VALUE }) => { const i18n = new Streami18n({ logger: () => {}, runtimeDefaults }); return i18n.init(); @@ -245,3 +252,128 @@ describe('getCalendarDateStringForA11y', () => { expect(getCalendarDateStringForA11y({ messageCreatedAt: AT })).toBeUndefined(); }); }); + +/** + * The relative-compact branch matrix. + * + * Ported from the React SDK, which owned it before the runtime moved here — it was asserting this + * module's behaviour through a thin re-export. Four of these boundaries are regressions found while + * porting: a future timestamp rendering as "Today", `relativeCompactMaxWeeks: 0` rendering "0w ago", + * `relativeCompact` being ignored on the direct `getDateString` path, and the weeks branch firing + * before a full week had elapsed. + * + * `createDefaultTranslatorFunction` stands in for `t`: it honours the inline defaults and the + * `defaultValue_one` / `defaultValue_other` pair exactly as i18next would, which is the shape the + * formatter passes for the plural cases. + */ +describe('getDateString — relativeCompact', () => { + const FIXED_NOW = new Date('2025-02-19T12:00:00.000Z'); + const t = createDefaultTranslatorFunction(); + const tDateTimeParser = (input?: TDateTimeParserInput) => defaultDateTimeParser(input); + const daysBefore = (n: number) => + new Date(FIXED_NOW.getTime() - n * 24 * 60 * 60 * 1000).toISOString(); + + const render = (messageCreatedAt: string, options: Record = {}) => + getDateString({ + messageCreatedAt, + relativeCompact: true, + t, + tDateTimeParser, + ...options, + }); + + beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + vi.setSystemTime(FIXED_NOW); + }); + + it('renders today and yesterday as words', () => { + expect(render(FIXED_NOW.toISOString())).toBe('Today'); + expect(render(daysBefore(1))).toBe('Yesterday'); + }); + + it('renders 2–6 days as a day count', () => { + expect(render(daysBefore(2))).toBe('2d ago'); + expect(render(daysBefore(6))).toBe('6d ago'); + }); + + it('renders 1–3 weeks as a week count', () => { + expect(render(daysBefore(7))).toBe('1w ago'); + expect(render(daysBefore(21))).toBe('3w ago'); + }); + + it('falls back to a date beyond the week window', () => { + expect(render(daysBefore(28))).toBe('22/01/25'); + }); + + it('renders a future timestamp as a date, not as "Today"', () => { + const tomorrow = new Date(FIXED_NOW.getTime() + 24 * 60 * 60 * 1000).toISOString(); + expect(render(tomorrow)).toBe('20/02/25'); + }); + + it('never renders "0w ago" when relativeCompactMaxWeeks is 0', () => { + // `Math.floor(3 / 7) === 0`, which matched the weeks branch before the guard was added. + expect(render(daysBefore(3), { relativeCompactMaxWeeks: 0 })).toBe('3d ago'); + expect(render(daysBefore(9), { relativeCompactMaxWeeks: 0 })).toBe('10/02/25'); + }); + + it('honours relativeCompactMaxDays', () => { + expect(render(daysBefore(4), { relativeCompactMaxDays: 3 })).not.toBe('4d ago'); + expect(render(daysBefore(3), { relativeCompactMaxDays: 3 })).toBe('3d ago'); + }); + + it('is inert without both a translator and a parser', () => { + expect(render(daysBefore(1), { t: undefined })).not.toBe('Yesterday'); + expect(render(daysBefore(1), { tDateTimeParser: undefined })).not.toBe('Yesterday'); + }); +}); + +/** + * `calendarFormats` arriving as a string is not a quirk: a bundled default embeds the config inside the + * i18next expression, so the formatter receives text. Malformed text is a developer mistake, and the + * report goes to the instance logger rather than through `translate` — a diagnostic is not copy, and + * routing it through the translator was the original bug here. + */ +describe('timestampFormatter — malformed calendarFormats', () => { + const KEY_BAD = 'timestamp.MessageTimestamp'; + + it('reports invalid JSON through the instance logger and still renders', async () => { + const logger = vi.fn(); + const i18n = new StreamI18nForLogger({ + logger, + runtimeDefaults: { + // A bare non-JSON word. A brace-wrapped malformation never reaches the formatter at all -- + // i18next's own argument parser drops the whole argument first, so nothing is logged and the + // timestamp silently renders unformatted. Worth knowing: this guard only catches the subset + // i18next hands through. + [KEY_BAD]: + '{{ timestamp | timestampFormatter(calendar: true; calendarFormats: notjson) }}', + }, + }); + const { t } = await i18n.init(); + + const rendered = t(KEY_BAD, { timestamp: AT }); + + expect(logger).toHaveBeenCalledWith( + expect.stringContaining('calendarFormats is not valid JSON'), + ); + // The malformed argument is dropped, not fatal — the calendar still renders, just with the + // locale's own formats. + expect(rendered).toBe('04/03/2019'); + }); + + it('accepts a well-formed JSON string', async () => { + const logger = vi.fn(); + const i18n = new StreamI18nForLogger({ + logger, + runtimeDefaults: { + [KEY_BAD]: + '{{ timestamp | timestampFormatter(calendar: true; calendarFormats: {"sameElse":"YYYY"}) }}', + }, + }); + const { t } = await i18n.init(); + + expect(t(KEY_BAD, { timestamp: AT })).toBe('2019'); + expect(logger).not.toHaveBeenCalled(); + }); +}); From 1cde961f126a9d84ccdae13e60fbf0158296f9a1 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Tue, 18 Aug 2026 16:05:34 +0200 Subject: [PATCH 22/27] test(i18n): cover region-coded languages 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. --- test/unit/i18n/Streami18n.test.ts | 44 +++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/test/unit/i18n/Streami18n.test.ts b/test/unit/i18n/Streami18n.test.ts index 9bf1992c83..91cedde415 100644 --- a/test/unit/i18n/Streami18n.test.ts +++ b/test/unit/i18n/Streami18n.test.ts @@ -252,6 +252,50 @@ describe('Streami18n', () => { }); }); +/** + * Region-coded languages. Ported from the React Native SDK's suite, which owned these before the + * runtime moved here. + * + * The hyphen must not be read as a separator of any kind — `keySeparator: false` and + * `nsSeparator: false` are what keep `pt-BR` a single language name rather than a namespace lookup, + * and a base-language dictionary must not shadow the region-coded one. + */ +describe('Streami18n — region-coded languages', () => { + it.each(['pt-BR', 'zh-TW', 'fr-CA', 'es-MX'])( + 'resolves a dictionary for %s', + async (language) => { + const i18n = setup({ language }); + i18n.registerTranslation(language, { + 'fixture.prose': `cancel-${language}`, + } as never); + const { t } = await i18n.init(); + + expect(t('fixture.prose', 'Cancel')).toBe(`cancel-${language}`); + expect(i18n.currentLanguage).toBe(language); + expect(i18n.getAvailableLanguages()).toContain(language); + }, + ); + + it('keeps a region-coded language distinct from its base language', async () => { + const i18n = setup({ language: 'pt-BR' }); + i18n.registerTranslation('pt', { 'fixture.prose': 'Cancelar-pt' } as never); + i18n.registerTranslation('pt-BR', { 'fixture.prose': 'Cancelar-ptBR' } as never); + const { t } = await i18n.init(); + + expect(t('fixture.prose', 'Cancel')).toBe('Cancelar-ptBR'); + }); + + it('still layers the bundled defaults under a region-coded language', async () => { + const i18n = setup({ language: 'pt-BR' }); + const { t } = await i18n.init(); + + // Would render as the raw key if runtimeDefaults had not been layered under `pt-BR`. + expect(t('timestamp.MessageTimestamp', { timestamp: new Date(0) })).not.toBe( + 'timestamp.MessageTimestamp', + ); + }); +}); + /** * Behaviours the React SDK's suite owned before the runtime moved here. They were asserting this * module through a thin subclass, so they belong on this side of the boundary — and none of them was From f4e503f6c33c6758cefd7cd3ccfc9541d85a076e Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Tue, 18 Aug 2026 17:07:00 +0200 Subject: [PATCH 23/27] fix(i18n): close seven defects found reviewing the shared layer 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 `_`, 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. --- codegen/i18n/callSites.ts | 17 +- codegen/i18n/generate.ts | 21 ++- codegen/i18n/guards.ts | 60 ++++++- codegen/i18n/index.ts | 2 +- codegen/i18n/types.ts | 11 +- scripts/get-package-version.mjs | 2 +- src/i18n/Streami18n.ts | 116 +++++++++---- src/i18n/dayjs.ts | 61 +++++-- src/i18n/formatters.ts | 15 +- src/i18n/index.ts | 2 +- test/unit/codegen/i18n/generate.test.ts | 104 +++++++++++ test/unit/i18n/Streami18n.test.ts | 202 ++++++++++++++++++++++ test/unit/i18n/TranslationBuilder.test.ts | 23 +++ tsconfig.codegen.json | 2 +- 14 files changed, 574 insertions(+), 64 deletions(-) diff --git a/codegen/i18n/callSites.ts b/codegen/i18n/callSites.ts index 4c2dd54593..4c6e65586a 100644 --- a/codegen/i18n/callSites.ts +++ b/codegen/i18n/callSites.ts @@ -55,6 +55,7 @@ export const readCallSiteCopy = ({ }): CallSiteCopy => { const copy = new Map(); const withoutCopy = new Map(); + const pluralWithoutCopy = new Map(); const conflicts: CallSiteCopy['conflicts'] = []; const record = (key: string, value: string, file: string) => { @@ -87,16 +88,28 @@ export const readCallSiteCopy = ({ // t('key', { count, defaultValue_one, defaultValue_other }) — the catalog holds the // `_one` / `_other` forms, never the bare key. let plurals = 0; + let hasCount = false; for (const prop of second.properties) { + // `count` can arrive shorthand (`{ count }`), which is not a PropertyAssignment. + if ( + tsModule.isShorthandPropertyAssignment(prop) && + prop.name.text === 'count' + ) { + hasCount = true; + continue; + } if (!tsModule.isPropertyAssignment(prop)) continue; const name = prop.name.getText(sourceFile).replace(/['"]/g, ''); + if (name === 'count') hasCount = true; const suffix = name.match(/^defaultValue_(\w+)$/)?.[1]; if (suffix && tsModule.isStringLiteralLike(prop.initializer)) { record(`${key}_${suffix}`, prop.initializer.text, file); plurals++; } } - if (!plurals) withoutCopy.set(key, file); + // A `count` with no inline plural copy is a *bundled plural*: i18next will look up + // `_`, so the guard has to demand that shape rather than the bare key. + if (!plurals) (hasCount ? pluralWithoutCopy : withoutCopy).set(key, file); } else { // t('key') — no inline copy, so it has to resolve from runtimeDefaults. withoutCopy.set(key, file); @@ -109,5 +122,5 @@ export const readCallSiteCopy = ({ visit(sourceFile); } - return { conflicts, copy, withoutCopy }; + return { conflicts, copy, pluralWithoutCopy, withoutCopy }; }; diff --git a/codegen/i18n/generate.ts b/codegen/i18n/generate.ts index c817a9d6ff..14a7957def 100644 --- a/codegen/i18n/generate.ts +++ b/codegen/i18n/generate.ts @@ -4,6 +4,7 @@ import path from 'node:path'; import { readCallSiteCopy } from './callSites'; import { formatFailures, + guardBundledPluralShape, guardConflictingCopy, guardPrefixCollisions, guardShadowedKeys, @@ -15,6 +16,15 @@ import type { GeneratedCatalog, GeneratorConfig } from './types'; /** Values under these prefixes are dayjs/i18next expressions, not copy. */ const BUILTIN_FORMATTER_PREFIXES = ['timestamp.', 'duration.']; +/** + * A catalog entry that is one plural category of a key, e.g. `x.y_other`. + * + * These are excluded from the emitted `BundledTranslationKey` union: a call site passes the *bare* key + * and `StreamTFunctionFor`'s plural overload already accepts it, so listing the suffixed forms as + * bundled keys would offer `t('x.y_other')` — a call that resolves nothing. + */ +const PLURAL_CATEGORY_SUFFIX = /_(zero|one|two|few|many|other)$/; + /** * Two ways English hides inside a formatter expression, both of which have to be reported: a translator * working from the JSON export never sees these keys otherwise. @@ -48,6 +58,7 @@ export const buildCatalog = (config: GeneratorConfig): GeneratedCatalog => { const { conflicts, copy: inlineCopy, + pluralWithoutCopy, withoutCopy, } = readCallSiteCopy({ ignoreDirs: config.ignoreDirs, @@ -61,7 +72,13 @@ export const buildCatalog = (config: GeneratorConfig): GeneratedCatalog => { const failures = [ guardConflictingCopy(conflicts), - guardUnresolvableKeys({ runtimeDefaults, runtimeDefaultsPath, withoutCopy }), + guardUnresolvableKeys({ + pluralWithoutCopy, + runtimeDefaults, + runtimeDefaultsPath, + withoutCopy, + }), + guardBundledPluralShape({ pluralWithoutCopy, runtimeDefaults, runtimeDefaultsPath }), guardShadowedKeys({ inlineCopy, runtimeDefaults, runtimeDefaultsPath }), guardPrefixCollisions(keys), ].filter((failure): failure is NonNullable => failure !== null); @@ -136,7 +153,7 @@ export const generateI18nKeys = (config: GeneratorConfig): GeneratedCatalog => { fs.writeFileSync( config.keysOut, renderKeysFile({ - bundledKeys, + bundledKeys: bundledKeys.filter((key) => !PLURAL_CATEGORY_SUFFIX.test(key)), catalog, emitBundledKeyUnion: config.emitBundledKeyUnion, }), diff --git a/codegen/i18n/guards.ts b/codegen/i18n/guards.ts index ebec1f595e..d3e059a6bd 100644 --- a/codegen/i18n/guards.ts +++ b/codegen/i18n/guards.ts @@ -1,12 +1,12 @@ import type { CallSiteCopy, GuardFailure } from './types'; /** - * The four hard-fail checks the catalog has to pass. + * The five hard-fail checks the catalog has to pass. * - * Each is a pure function returning failures as data. The fifth guard both UI SDKs carried — checking - * that an `EXTERNAL_STRING_KEYS` entry's wording matched the key's catalog copy — is gone, because the - * map it policed is gone: notifications now resolve through a stable identifier instead of by matching - * English prose. + * Each is a pure function returning failures as data. The guard both UI SDKs carried that is *not* here + * — checking that an `EXTERNAL_STRING_KEYS` entry's wording matched the key's catalog copy — is gone + * because the map it policed is gone: notifications now resolve through a stable identifier instead of + * by matching English prose. */ /** A key must render one thing. */ @@ -33,15 +33,30 @@ export const guardConflictingCopy = ( * `message.status.sent.text` where a word should be. */ export const guardUnresolvableKeys = ({ + pluralWithoutCopy, runtimeDefaults, runtimeDefaultsPath, withoutCopy, }: { + pluralWithoutCopy: CallSiteCopy['pluralWithoutCopy']; runtimeDefaults: Map; runtimeDefaultsPath: string; withoutCopy: CallSiteCopy['withoutCopy']; }): GuardFailure | null => { - const unresolvable = [...withoutCopy].filter(([key]) => !runtimeDefaults.has(key)); + const unresolvable = [ + ...[...withoutCopy].filter(([key]) => !runtimeDefaults.has(key)), + // A plural resolves as `_`, so `_other` — the one category every language has — is + // what has to be bundled. Demanding the bare key here is what used to reject a correct catalog. + // + // A key bundled under the *bare* name is skipped here on purpose: it is not unresolvable, it is + // the wrong shape, and `guardBundledPluralShape` says so precisely. Reporting both would give two + // failures with different advice for one mistake. + ...[...pluralWithoutCopy] + .filter( + ([key]) => !runtimeDefaults.has(`${key}_other`) && !runtimeDefaults.has(key), + ) + .map(([key, file]): [string, string] => [`${key}_other`, file]), + ]; if (!unresolvable.length) return null; return { entries: unresolvable.map(([key, file]) => `${key} (${file})`), @@ -53,6 +68,39 @@ export const guardUnresolvableKeys = ({ }; }; +/** + * A bundled plural must be stored under its category suffixes, not under the bare key. + * + * i18next falls back to an unsuffixed entry when no `_` exists, so the bare form does + * render — it just renders the same string for every count, with plural selection silently dead and no + * error anywhere. Verified against i18next directly: a bundled `'x.y': '{{count}} items'` answers + * `count: 1` with "1 items". + */ +export const guardBundledPluralShape = ({ + pluralWithoutCopy, + runtimeDefaults, + runtimeDefaultsPath, +}: { + pluralWithoutCopy: CallSiteCopy['pluralWithoutCopy']; + runtimeDefaults: Map; + runtimeDefaultsPath: string; +}): GuardFailure | null => { + const bare = [...pluralWithoutCopy].filter(([key]) => runtimeDefaults.has(key)); + if (!bare.length) return null; + return { + entries: bare.map( + ([key, file]) => + `${key}\n bundled as: ${JSON.stringify(runtimeDefaults.get(key))}\n` + + ` called as: t('${key}', { count }) (${file})`, + ), + kind: 'bundled-plural-shape', + summary: + `${bare.length} key(s) are called with \`count\` but bundled under the bare key in ` + + `${runtimeDefaultsPath}.\ni18next resolves plurals as \`_\`, so this renders ` + + `one form for every count with no error. Split the entry into \`_one\` / \`_other\`:`, + }; +}; + /** * A key must not be in both places. * diff --git a/codegen/i18n/index.ts b/codegen/i18n/index.ts index e800164a33..dfa22c2d54 100644 --- a/codegen/i18n/index.ts +++ b/codegen/i18n/index.ts @@ -3,7 +3,7 @@ * * **Node-only.** This reads the filesystem and uses the TypeScript parser API, so it must never be * reachable from `stream-chat/i18n` — which is why it lives beside `src/i18n/` rather than inside it. - * `scripts/bundle.mjs` asserts that boundary at build time. + * `scripts/bundle.mts` asserts that boundary at build time. * * `typescript` is injected through {@link GeneratorConfig.ts} rather than imported, so `stream-chat` * does not depend on the compiler. diff --git a/codegen/i18n/types.ts b/codegen/i18n/types.ts index ca46165d97..dec8fafa4d 100644 --- a/codegen/i18n/types.ts +++ b/codegen/i18n/types.ts @@ -60,6 +60,14 @@ export type CallSiteCopy = { * These must be present in `runtimeDefaults` or they render as the raw key. */ withoutCopy: Map; + /** + * `key -> file` for *plural* keys called with no inline copy — `t('x.y', { count })`. + * + * Tracked apart from {@link CallSiteCopy.withoutCopy} because i18next resolves these as + * `_`, never as the bare key. Checking them the same way demanded exactly the entry + * shape that does not work at runtime while accepting the one that silently never pluralizes. + */ + pluralWithoutCopy: Map; /** Keys seen with two different inline copies — a key must render one thing. */ conflicts: Array<{ key: string; a: string; b: string; file: string }>; }; @@ -68,7 +76,8 @@ export type GuardFailureKind = | 'conflicting-copy' | 'unresolvable-key' | 'shadowed-key' - | 'prefix-collision'; + | 'prefix-collision' + | 'bundled-plural-shape'; /** * A guard failure, as data. diff --git a/scripts/get-package-version.mjs b/scripts/get-package-version.mjs index 28a9439e20..d0a0bc7787 100644 --- a/scripts/get-package-version.mjs +++ b/scripts/get-package-version.mjs @@ -1,7 +1,7 @@ import { execSync } from 'node:child_process'; import packageJson from '../package.json' with { type: 'json' }; -// get the latest version so that "process.env.PKG_VERSION" can be replaced with it in the source code (used for reporting purposes), see bundle.mjs for source +// get the latest version so that "process.env.PKG_VERSION" can be replaced with it in the source code (used for reporting purposes), see bundle.mts for source export default function getPackageVersion() { // "build" script ("prepare" hook) gets invoked when semantic-release runs "npm publish", at that point package.json#version already contains updated next version which we can use let version = packageJson.version; diff --git a/src/i18n/Streami18n.ts b/src/i18n/Streami18n.ts index 1eb544fae9..49f43c1518 100644 --- a/src/i18n/Streami18n.ts +++ b/src/i18n/Streami18n.ts @@ -23,6 +23,7 @@ import type { AnyTranslationCatalog, CustomFormatters, DateTimeParserModule, + FormatterContext, LooseTranslateFunction, PredefinedFormatters, StreamTFunctionFor, @@ -183,8 +184,9 @@ export class Streami18n< if (options.DateTimeParser) { this.DateTimeParser = options.DateTimeParser; - // A dayjs module the integrator supplied needs the same plugins as ours. - if (isDayjsLike(this.DateTimeParser)) ensureDayjsPlugins(); + // The supplied module, not ours: it may be a second physical copy of dayjs, in which case + // extending ours leaves theirs plugin-less and every `LT` / `LLLL` token renders literally. + if (isDayjsLike(this.DateTimeParser)) ensureDayjsPlugins(this.DateTimeParser); } else { this.DateTimeParser = getDefaultDateTimeParserModule(); } @@ -305,47 +307,41 @@ export class Streami18n< /** * Initializes i18next. Idempotent and safe to call concurrently. * - * The promise is memoized and never cleared: two independent consumers (a UI SDK's chat root and its - * overlay host, say) both call this, and clearing it on completion would leave a window where a - * third caller re-entered initialization. + * The promise is memoized and never cleared **on success**: two independent consumers (a UI SDK's + * chat root and its overlay host, say) both call this, and clearing it on completion would leave a + * window where a third caller re-entered initialization. + * + * A rejection *is* cleared, so a retry is possible. `runInit` guards everything it does, so the only + * way to get here is something genuinely unexpected — most plausibly an integrator-supplied `logger` + * that throws. Latching that permanently would leave the instance uninitialized for the process + * lifetime, rendering the default English translator with no way back. */ init(): Promise> { - this.initPromise ??= this.runInit(); + this.initPromise ??= this.runInit().catch((error: unknown) => { + this.initPromise = undefined; + throw error; + }); return this.initPromise; } private async runInit(): Promise> { - this.validateCurrentLanguage(); - this.assertPluralRulesCoverage(this.currentLanguage); + try { + // Inside the `try`, all three of them. These log and touch dayjs, so each can throw for reasons + // that have nothing to do with i18next — and a throw out of `runInit` is an unhandled rejection + // at both UI SDKs' call sites, which do not await this. + this.validateCurrentLanguage(); + this.assertPluralRulesCoverage(this.currentLanguage); - const dayjsLocale = this.dayjsLocales[this.currentLanguage]; - if (dayjsLocale) this.addOrUpdateLocale(this.currentLanguage, dayjsLocale); + const dayjsLocale = this.dayjsLocales[this.currentLanguage]; + if (dayjsLocale) this.addOrUpdateLocale(this.currentLanguage, dayjsLocale); - try { const t = await this.i18nInstance.init({ ...this.i18nextConfig, lng: this.currentLanguage, resources: this.translations, }); - Object.entries(this.formatters).forEach(([name, factory]) => { - if (!factory) return; - const formatter = factory({ - currentLanguage: this.currentLanguage, - dateTimeParser: this.DateTimeParser, - logger: this.logger, - tDateTimeParser: this.tDateTimeParser, - timezone: this.timezone, - translate: this.translate, - }); - // A custom formatter's value type is declared `never` so that any implementation is - // assignable to it (parameters are contravariant). i18next's own signature takes `any`, so - // the widening happens here rather than weakening the public type. - this.i18nInstance.services.formatter?.add( - name, - formatter as (value: any, lng: string | undefined, options: any) => string, - ); - }); + this.registerFormatters(); // After init, so the topics' post-processors are attached to a live instance and any buffered // translator registrations flush. @@ -368,6 +364,59 @@ export class Streami18n< return this.state.getLatestValue(); } + /** + * Builds each formatter from its factory and hands it to i18next. + * + * Re-run on every language change, not just at `init()`. Factories receive the language through their + * context and virtually all of them **destructure** it, which snapshots the value — so a factory run + * once at initialization keeps formatting in the initial language forever, with no error. i18next's + * `formatter.add` replaces an existing name, so re-registering is the whole fix. + */ + private registerFormatters = () => { + const context = this.createFormatterContext(); + + Object.entries(this.formatters).forEach(([name, factory]) => { + if (!factory) return; + const formatter = factory(context); + // A custom formatter's value type is declared `never` so that any implementation is assignable + // to it (parameters are contravariant). i18next's own signature takes `any`, so the widening + // happens here rather than weakening the public type. + this.i18nInstance.services.formatter?.add( + name, + formatter as (value: any, lng: string | undefined, options: any) => string, + ); + }); + }; + + /** + * What each formatter factory is handed. + * + * `currentLanguage` and `tDateTimeParser` are accessors rather than snapshots, which covers a + * formatter that holds the context and reads a property per call. A formatter that *destructures* the + * context still snapshots, which is why {@link registerFormatters} also re-runs on a language change + * — the two together are what make both styles correct. + * + * Nested arrow functions rather than an aliased `this`: a getter in an object literal binds `this` to + * the literal. + */ + private createFormatterContext = (): FormatterContext => { + const readLanguage = () => this.currentLanguage; + const readDateTimeParser = () => this.tDateTimeParser; + + return { + get currentLanguage() { + return readLanguage(); + }, + dateTimeParser: this.DateTimeParser, + logger: this.logger, + get tDateTimeParser() { + return readDateTimeParser(); + }, + timezone: this.timezone, + translate: this.translate, + }; + }; + /* --------------------------------------------------------------------------------------------- * Languages and dictionaries * ------------------------------------------------------------------------------------------- */ @@ -448,6 +497,12 @@ export class Streami18n< * language change and invite callers to cache it. */ async setLanguage(language: string): Promise { + const previousLanguage = this.state.getLatestValue().language; + + // Published before the switch so `validateCurrentLanguage` reports on the language being adopted, + // and rolled back below if the switch does not happen -- otherwise the store advertises a language + // i18next never adopted, and `tDateTimeParser` starts formatting dates in a locale whose copy is + // not loaded. this.state.partialNext({ language }); this.ensureLanguage(language); @@ -460,10 +515,13 @@ export class Streami18n< const t = await this.i18nInstance.changeLanguage(language); const dayjsLocale = this.dayjsLocales[language]; if (dayjsLocale) this.addOrUpdateLocale(language, dayjsLocale); + // Rebuilt against the new language -- see `registerFormatters`. + this.registerFormatters(); if (!this.tOverridden) { this.state.partialNext({ t: t as unknown as StreamTFunctionFor }); } } catch (error) { + this.state.partialNext({ language: previousLanguage }); this.logger(`Streami18n: failed to set language: ${describeError(error)}`); } } diff --git a/src/i18n/dayjs.ts b/src/i18n/dayjs.ts index 8afc658d7a..32d13b5129 100644 --- a/src/i18n/dayjs.ts +++ b/src/i18n/dayjs.ts @@ -40,6 +40,13 @@ export type CalendarFormats = { */ export type DayjsLocaleConfig = Partial & { calendar?: CalendarFormats }; +/** + * Anything `ensureDayjsPlugins` can register plugins on: our own `dayjs`, or a module an integrator + * supplied through `DateTimeParser`. Method shorthand, so a real `typeof dayjs` satisfies it -- as a + * function property it would be checked contravariantly and rejected. + */ +type DayjsExtendable = object & { extend?(plugin: unknown, option?: unknown): unknown }; + /** * The English locale skeleton a custom locale is merged over, so a partial config still has month and * weekday names to fall back on. @@ -77,10 +84,34 @@ const EN_LOCALE_FALLBACK = { ], }; -let pluginsRegistered = false; +/** + * The plugins the formatters depend on, in dependency order: `timezone` builds on `utc`. + */ +const REQUIRED_PLUGINS = [ + updateLocale, + utc, + timezone, + localizedFormat, + calendar, + localeData, + relativeTime, + duration, +]; /** - * Registers the dayjs plugins the formatters need, once. + * Modules already extended. A `WeakSet` rather than a boolean because the module to extend is not + * always ours -- see {@link ensureDayjsPlugins}. + */ +const extendedModules = new WeakSet(); + +/** + * Registers the dayjs plugins the formatters need, once per module. + * + * Takes the module to extend, defaulting to our own `dayjs`. Passing it matters: an integrator + * supplying `DateTimeParser` may hand over a *different physical copy* of dayjs, and extending ours + * leaves theirs without the plugins. That failure is silent and total -- `.calendar()` is simply + * absent, and `format('LT')` returns the literal string `"LT"` because `localizedFormat` never + * registered the token. * * Deliberately **not** done at module scope. Module-scope `Dayjs.extend(...)` is a side effect, which * would force `stream-chat` to declare `sideEffects` and would make importing this module do work @@ -88,26 +119,22 @@ let pluginsRegistered = false; * covers the two ways the formatters can be reached, including a standalone `getDateString()` call * with no `Streami18n` instance in play. * - * Idempotent twice over: guarded here, and dayjs itself no-ops a repeated `extend` via the plugin's - * `$i` marker. + * Idempotent twice over: tracked here, and dayjs itself no-ops a repeated `extend` via the plugin's + * `$i` marker. The module is recorded *after* the extends run, so a throw does not leave it marked as + * done. * * `timezone` is included because it depends on `utc` and callers can set `timezone` at any point; * registering it lazily on first use would leave the plugin missing for an instance that only sets * `timezone` later. */ -export const ensureDayjsPlugins = () => { - if (pluginsRegistered) return; - pluginsRegistered = true; - - // `updateLocale` and `utc` first: `timezone` builds on `utc`. - Dayjs.extend(updateLocale); - Dayjs.extend(utc); - Dayjs.extend(timezone); - Dayjs.extend(localizedFormat); - Dayjs.extend(calendar); - Dayjs.extend(localeData); - Dayjs.extend(relativeTime); - Dayjs.extend(duration); +export const ensureDayjsPlugins = ( + module: DayjsExtendable = Dayjs as unknown as DayjsExtendable, +) => { + if (extendedModules.has(module)) return; + if (typeof module.extend !== 'function') return; + + for (const plugin of REQUIRED_PLUGINS) module.extend(plugin); + extendedModules.add(module); }; /** diff --git a/src/i18n/formatters.ts b/src/i18n/formatters.ts index 6e412b13fb..7fda16b2b6 100644 --- a/src/i18n/formatters.ts +++ b/src/i18n/formatters.ts @@ -38,6 +38,15 @@ export type RelativeTimeCatalog = typeof RELATIVE_TIME_CATALOG; const DEFAULT_RELATIVE_COMPACT_MAX_DAYS = 6; const DEFAULT_RELATIVE_COMPACT_MAX_WEEKS = 3; +/** + * Whether a string is not a date this module can render. + * + * `!Date.parse(value)` would be the obvious spelling and is wrong: `Date.parse` returns `0` for the + * Unix epoch, which is falsy, so a perfectly valid `'1970-01-01T00:00:00.000Z'` was classified as junk + * and dropped -- rendered as `''` by the formatter and as `null` by `getDateString`. + */ +const isUnparseableDateString = (value: string) => Number.isNaN(Date.parse(value)); + /** * Coerces a numeric formatter option. * @@ -155,7 +164,7 @@ const timestampFormatter: FormatterFactory = // literal text "null" and an unparseable string as "Invalid Date", both of which are junk a user // can see. `getDateString` has always guarded this; the formatter is a separate path and did not. if (value === null || value === undefined) return ''; - if (typeof value === 'string' && !Date.parse(value)) return ''; + if (typeof value === 'string' && isUnparseableDateString(value)) return ''; if (relativeCompact) { const relative = relativeCompactDateString({ @@ -255,7 +264,7 @@ export const getDateString = ({ }: GetDateStringParams): string | number | null => { if ( !messageCreatedAt || - (typeof messageCreatedAt === 'string' && !Date.parse(messageCreatedAt)) + (typeof messageCreatedAt === 'string' && isUnparseableDateString(messageCreatedAt)) ) { return null; } @@ -409,7 +418,7 @@ export const getCalendarDateStringForA11y = ({ }: GetCalendarDateStringForA11yParams): string | undefined => { if ( !messageCreatedAt || - (typeof messageCreatedAt === 'string' && !Date.parse(messageCreatedAt)) || + (typeof messageCreatedAt === 'string' && isUnparseableDateString(messageCreatedAt)) || !tDateTimeParser ) { return undefined; diff --git a/src/i18n/index.ts b/src/i18n/index.ts index 8166e51b86..1c011ef0d8 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -3,7 +3,7 @@ * * Deliberately **not** re-exported from `stream-chat`'s root barrel: this module pulls in `i18next` and * `dayjs`, and keeping them out of the root bundle is the entire reason it is a separate entry point. - * `scripts/bundle.mjs` asserts that boundary at build time. + * `scripts/bundle.mts` asserts that boundary at build time. */ export * from './dayjs'; export * from './formatters'; diff --git a/test/unit/codegen/i18n/generate.test.ts b/test/unit/codegen/i18n/generate.test.ts index 5878737c0d..d4f65ab557 100644 --- a/test/unit/codegen/i18n/generate.test.ts +++ b/test/unit/codegen/i18n/generate.test.ts @@ -131,6 +131,81 @@ describe('guards', () => { expect(failures[0].entries.join('\n')).toContain('forgot.the.copy'); }); + /** + * A bundled plural, both ways round. + * + * i18next resolves `t('x.y', { count })` as `x.y_`, never as `x.y`. The guards used to + * check the bare key for every no-inline-copy call site, which got both cases exactly backwards: + * the correct catalog was rejected and the broken one waved through. + */ + it('accepts a bundled plural stored under its category suffixes', () => { + const dir = makeProject({ + 'src/Component.tsx': `const C = () => t('channel.unread.label', { count });`, + 'src/i18n/runtimeDefaults.ts': runtimeDefaults({ + 'channel.unread.label_one': '{{count}} unread', + 'channel.unread.label_other': '{{count}} unread', + }), + }); + + const { catalog, failures } = buildCatalog(configFor(dir)); + + expect(failures).toEqual([]); + expect([...catalog.keys()]).toEqual([ + 'channel.unread.label_one', + 'channel.unread.label_other', + ]); + }); + + it('fails on a bundled plural stored under the bare key', () => { + const dir = makeProject({ + 'src/Component.tsx': `const C = () => t('channel.unread.label', { count });`, + 'src/i18n/runtimeDefaults.ts': runtimeDefaults({ + 'channel.unread.label': '{{count}} unread', + }), + }); + + const { failures } = buildCatalog(configFor(dir)); + + expect(failures).toHaveLength(1); + expect(failures[0].kind).toBe('bundled-plural-shape'); + expect(failures[0].entries[0]).toContain('channel.unread.label'); + expect(failures[0].summary).toContain('_one'); + }); + + it('names the suffixed key when a bundled plural is missing entirely', () => { + const dir = makeProject({ + 'src/Component.tsx': `const C = () => t('channel.unread.label', { count });`, + 'src/i18n/runtimeDefaults.ts': runtimeDefaults({}), + }); + + const { failures } = buildCatalog(configFor(dir)); + + expect(failures).toHaveLength(1); + expect(failures[0].kind).toBe('unresolvable-key'); + // The suffixed form, since that is what has to be added -- not the bare key the call site uses. + expect(failures[0].entries[0]).toContain('channel.unread.label_other'); + }); + + it('reads `count` whether it is shorthand or written out', () => { + const dir = makeProject({ + 'src/Component.tsx': ` + const C = () => { + t('a.one.label', { count }); + t('b.two.label', { count: total }); + }; + `, + 'src/i18n/runtimeDefaults.ts': runtimeDefaults({}), + }); + + const { failures } = buildCatalog(configFor(dir)); + + // Both treated as plurals, so both are reported under their `_other` form. + expect(failures[0].entries).toEqual([ + expect.stringContaining('a.one.label_other'), + expect.stringContaining('b.two.label_other'), + ]); + }); + /** The bundled value wins, so the call site's copy would silently never render. */ it('fails on a key present both inline and in the bundled defaults', () => { const dir = makeProject({ @@ -230,6 +305,35 @@ describe('output', () => { }); /** `keys.ts` is type-only, so a test cannot iterate it — this is its data twin. */ + it('leaves plural categories out of the bundled key union', () => { + const dir = makeProject({ + 'src/Component.tsx': ` + const C = () => { + t('channel.unread.label', { count }); + t('timestamp.Message', {}); + }; + `, + 'src/i18n/runtimeDefaults.ts': runtimeDefaults({ + 'channel.unread.label_one': '{{count}} unread', + 'channel.unread.label_other': '{{count}} unread', + 'timestamp.Message': '{{ timestamp | timestampFormatter }}', + }), + }); + const config = configFor(dir, { emitBundledKeyUnion: true }); + + generateI18nKeys(config); + const written = fs.readFileSync(config.keysOut, 'utf8'); + + // The plural overload already accepts the bare key; offering `t('…_other')` would resolve nothing. + expect(written).toContain( + 'export type BundledTranslationKey =\n | "timestamp.Message"', + ); + expect(written).not.toContain('| "channel.unread.label_one"'); + expect(written).not.toContain('| "channel.unread.label_other"'); + // Still present in the catalog itself -- a dictionary has to be able to supply them. + expect(written).toContain('"channel.unread.label_other":'); + }); + it('writes a JSON fixture twin when configured', () => { const dir = makeProject({ 'src/A.tsx': `t('common.cancel.label', 'Cancel');`, diff --git a/test/unit/i18n/Streami18n.test.ts b/test/unit/i18n/Streami18n.test.ts index 91cedde415..06b5a95f05 100644 --- a/test/unit/i18n/Streami18n.test.ts +++ b/test/unit/i18n/Streami18n.test.ts @@ -499,3 +499,205 @@ describe('RELATIVE_TIME_CATALOG', () => { expect(render(3)).toBe('vor 3 Tagen'); }); }); + +/** + * A dayjs module the integrator supplied gets the plugins too. + * + * `ensureDayjsPlugins()` used to always extend *our* `dayjs` import, whatever module was passed in. + * With a second physical copy of dayjs -- the normal case for an integrator who imports their own + * locales -- that left theirs plugin-less, and the failure was silent and total: `format('LT')` echoed + * the literal token back and `.calendar()` was simply absent. + */ +describe('Streami18n — an integrator-supplied dayjs module', () => { + beforeEach(() => { + vi.useRealTimers(); + }); + + /** A stand-in for a second dayjs copy: records what was registered on it. */ + const makeUnextendedDayjsLike = () => { + const registered: unknown[] = []; + const parser = ((input?: unknown) => ({ + calendar: () => 'calendar', + diff: () => 0, + format: (template?: string) => `formatted:${template ?? ''}:${String(input)}`, + locale: () => parser(input), + startOf: () => ({ diff: () => 0 }), + valueOf: () => 0, + })) as unknown as DateTimeParserModule & { extend: (plugin: unknown) => unknown }; + + parser.extend = (plugin: unknown) => { + registered.push(plugin); + return parser; + }; + + return { parser, registered }; + }; + + it('registers the plugins on the supplied module, not only on ours', () => { + const { parser, registered } = makeUnextendedDayjsLike(); + + setup({ DateTimeParser: parser }); + + // The eight the formatters need: updateLocale, utc, timezone, localizedFormat, calendar, + // localeData, relativeTime, duration. + expect(registered).toHaveLength(8); + expect(registered.every((plugin) => typeof plugin === 'function')).toBe(true); + }); + + it('does not re-register on a second instance sharing the module', () => { + const { parser, registered } = makeUnextendedDayjsLike(); + + setup({ DateTimeParser: parser }); + setup({ DateTimeParser: parser }); + + expect(registered).toHaveLength(8); + }); + + it('leaves a module without `extend` alone rather than throwing', () => { + const momentish = ((input?: unknown) => ({ + diff: () => 0, + format: () => String(input), + startOf: () => ({ diff: () => 0 }), + valueOf: () => 0, + })) as unknown as DateTimeParserModule; + + expect(() => setup({ DateTimeParser: momentish })).not.toThrow(); + }); +}); + +/** + * `Date.parse` returns `0` for the epoch, so `!Date.parse(value)` classified a valid timestamp as junk. + */ +describe('Streami18n — the Unix epoch is a valid timestamp', () => { + beforeEach(() => { + vi.useRealTimers(); + }); + + it('formats an epoch timestamp string rather than rendering nothing', async () => { + const { t } = await setup().init(); + + expect( + t('timestamp.MessageTimestamp', { timestamp: '1970-01-01T00:00:00.000Z' }), + ).toBe('12:00 AM'); + }); + + it('still renders nothing for a string that is genuinely not a date', async () => { + const { t } = await setup().init(); + + expect(t('timestamp.MessageTimestamp', { timestamp: 'not a date' })).toBe(''); + }); +}); + +/** + * Formatter factories run once, during `init()`, and are never re-run on a language change -- so the + * context has to expose accessors rather than the values it had at initialization. + */ +describe('Streami18n — the formatter context follows the active language', () => { + beforeEach(() => { + vi.useRealTimers(); + }); + + it('reports the language in force at call time, not at init time', async () => { + const seen: string[] = []; + const i18n = setup({ + formatters: { + languageProbe: + ({ currentLanguage }: { currentLanguage: string }) => + () => { + seen.push(currentLanguage); + return currentLanguage; + }, + }, + translationsForLanguage: { + 'fixture.probe': '{{ value | languageProbe }}', + }, + }); + i18n.registerTranslation('de', { + 'fixture.probe': '{{ value | languageProbe }}', + } as never); + + const { t } = await i18n.init(); + (t as (key: string, options: object) => string)('fixture.probe', { value: 'x' }); + + await i18n.setLanguage('de'); + const after = i18n.state.getLatestValue().t as unknown as ( + key: string, + options: object, + ) => string; + after('fixture.probe', { value: 'x' }); + + expect(seen).toEqual(['en', 'de']); + }); +}); + +/** + * A failed language switch must not leave the store advertising a language i18next never adopted -- + * `tDateTimeParser` reads it on every call, so dates would format in a locale whose copy is absent. + */ +describe('Streami18n — a failed setLanguage rolls back', () => { + beforeEach(() => { + vi.useRealTimers(); + }); + + it('restores the previous language when changeLanguage rejects', async () => { + const logger = vi.fn(); + const i18n = setup({ logger }); + await i18n.init(); + expect(i18n.currentLanguage).toBe('en'); + + vi.spyOn(i18n.i18nInstance, 'changeLanguage').mockRejectedValue(new Error('nope')); + await i18n.setLanguage('de'); + + expect(i18n.currentLanguage).toBe('en'); + expect(logger).toHaveBeenCalledWith( + expect.stringContaining('failed to set language: nope'), + ); + }); + + it('keeps the new language when the switch succeeds', async () => { + const i18n = setup(); + await i18n.init(); + await i18n.setLanguage('de'); + + expect(i18n.currentLanguage).toBe('de'); + }); +}); + +/** + * A rejected `init()` must not be latched: both UI SDKs call `init()` without awaiting it, so a + * permanently rejected memo leaves the instance uninitialized for the process lifetime. + */ +describe('Streami18n — init() is retryable after a genuine failure', () => { + beforeEach(() => { + vi.useRealTimers(); + }); + + it('recovers when the cause is gone', async () => { + let shouldThrow = true; + const i18n = setup({ + logger: (message?: string) => { + if (shouldThrow) throw new Error('logger exploded'); + void message; + }, + // Unregistered, so `validateCurrentLanguage` logs -- and the logger throws. + language: 'de', + }); + + await expect(i18n.init()).rejects.toThrow('logger exploded'); + expect(i18n.initialized).toBe(false); + + shouldThrow = false; + const state = await i18n.init(); + + expect(state.initialized).toBe(true); + }); + + it('hands the same promise to concurrent callers on the happy path', async () => { + const i18n = setup(); + const first = i18n.init(); + + expect(i18n.init()).toBe(first); + await first; + expect(i18n.init()).toBe(first); + }); +}); diff --git a/test/unit/i18n/TranslationBuilder.test.ts b/test/unit/i18n/TranslationBuilder.test.ts index a2b1f422be..1e7e700b6a 100644 --- a/test/unit/i18n/TranslationBuilder.test.ts +++ b/test/unit/i18n/TranslationBuilder.test.ts @@ -66,6 +66,29 @@ describe('TranslationBuilder', () => { expect(render(t, { kind: 'anything', value: 'x' })).toBe('[x]'); }); + /** + * Removal has to reach the buffer too, not just a live topic. + * + * Registering and then removing before `init()` is a real sequence — an integrator swapping one + * translator out during setup — and if `removeTranslators` only looked at constructed topics, the + * removed translator would come back when the buffer flushed. Ported from the React SDK's suite, + * which owned this case before the plumbing moved here. + */ + it('removes a buffered translator before the topic exists', async () => { + const i18n = setup(); + i18n.translationBuilder.registerTranslators('kind', { + quiet: ({ options }) => String(options.value).toLowerCase(), + shout: ({ options }) => String(options.value).toUpperCase(), + }); + i18n.translationBuilder.removeTranslators('kind', ['shout']); + + const { t } = await i18n.init(); + + // `quiet` survived the flush; `shout` did not come back with it. + expect(render(t, { kind: 'quiet', value: 'HeLLo' })).toBe('hello'); + expect(render(t, { kind: 'shout', value: 'HeLLo' })).toBe(FALLBACK); + }); + it('lets a later registration override an earlier one', async () => { const i18n = setup(); const { t } = await i18n.init(); diff --git a/tsconfig.codegen.json b/tsconfig.codegen.json index 1b845d830b..ec98725645 100644 --- a/tsconfig.codegen.json +++ b/tsconfig.codegen.json @@ -4,7 +4,7 @@ // A separate project because the generator lives outside `src/`: it is Node-only build tooling that // reads the filesystem, which is the one thing the SDK's own source must never do. Keeping it out of // the library project is what makes that boundary type-enforced -- an accidental import from - // `src/i18n/` now fails at `tsc` rather than at the metafile assertion in `scripts/bundle.mjs`. + // `src/i18n/` now fails at `tsc` rather than at the metafile assertion in `scripts/bundle.mts`. // // `rootDir` is `./codegen/i18n` rather than `./codegen` so the emitted declarations land at // `dist/types/i18n-codegen/`, exactly where `package.json`'s `exports` and `typesVersions` point. The From 8009e52f3cdcc44a31da6dbcb37f02ec75ae41b5 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Tue, 18 Aug 2026 17:33:49 +0200 Subject: [PATCH 24/27] refactor(i18n)!: extract the dictionary store, drop two unused accessors `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`; 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`. --- CLAUDE.md | 4 + src/i18n/Streami18n.ts | 292 +++++++----------------- src/i18n/TranslationStore.ts | 96 ++++++++ src/i18n/index.ts | 1 + test/unit/i18n/Streami18n.test.ts | 26 ++- test/unit/i18n/TranslationStore.test.ts | 119 ++++++++++ v9-to-v10-migration-guide-i18n.md | 20 ++ 7 files changed, 348 insertions(+), 210 deletions(-) create mode 100644 src/i18n/TranslationStore.ts create mode 100644 test/unit/i18n/TranslationStore.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 9bfd17cf47..4c7b82b95f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -180,6 +180,10 @@ Things that will bite: is layered under _every_ language, which is what stops a partial dictionary from knocking out formatter keys. That is guarantee G1 in `test/unit/i18n/Streami18nGuarantees.test.ts`, which is the acceptance contract for this module: three behavioural guarantees, each written against a real bug. +- **The layering itself lives in `TranslationStore`**, not in `Streami18n` — it needs neither i18next nor + dayjs, so it is tested directly (`test/unit/i18n/TranslationStore.test.ts`) rather than only through an + initialized instance. The store holds flat dictionaries; `Streami18n` adapts them to i18next's nested + `resources` shape, so nothing in the store has to know about namespaces. - **No module-scope side effects.** Every `Dayjs.extend` goes through `ensureDayjsPlugins()`. This is what makes `sideEffects: false` accurate — do not reintroduce a top-level `extend` or locale import. - **`durationFormatter` must use the date library's `.duration()`**, not parse the value as a timestamp. diff --git a/src/i18n/Streami18n.ts b/src/i18n/Streami18n.ts index 49f43c1518..9c8d794017 100644 --- a/src/i18n/Streami18n.ts +++ b/src/i18n/Streami18n.ts @@ -14,6 +14,7 @@ import type { DayjsLocaleConfig } from './dayjs'; import { predefinedFormatters } from './formatters'; import { TranslationBuilder } from './TranslationBuilder'; import type { TranslationTopicConstructor } from './TranslationBuilder'; +import { DEFAULT_LANGUAGE, TranslationStore } from './TranslationStore'; import { asDynamicKey, createDefaultTranslatorFunction, @@ -32,7 +33,6 @@ import type { } from './types'; const DEFAULT_NAMESPACE = 'translation'; -const DEFAULT_LANGUAGE = 'en'; export type Streami18nOptions = { /** A dayjs or moment module. Defaults to dayjs with the required plugins registered. */ @@ -87,42 +87,19 @@ export type Streami18nState< /** * Wrapper around [i18next](https://www.i18next.com/) for Stream's translations. A UI SDK passes an - * instance to its `` component to control language and copy. - * - * Only English ships, and only as much of it as has to: prose renders from the inline `defaultValue` - * at each call site, so the bundled data is just formatter expressions and the handful of keys - * resolved by name at runtime. Every other language comes from the integrator. - * - * Reactivity goes through {@link Streami18n.state}, a {@link StateStore}. `subscribe` fires - * synchronously with the current value, so a consumer that attaches after `init()` still sees the live - * `t` immediately and there is no callback-registration ordering to get wrong. - * - * ## Overriding some of the English copy - * - * ```ts - * const i18n = new Streami18n({ - * translationsForLanguage: { 'autoCompleteInput.placeholder': 'Write something…' }, - * }); - * ``` - * - * ## Registering a language + * instance to its `` component to control language and copy. Only English ships; every other + * language comes from the integrator, and a partial dictionary is safe. * * ```ts * import 'dayjs/locale/de'; * * const i18n = new Streami18n({ language: 'de' }); - * i18n.registerTranslation('de', de, { - * calendar: { sameDay: '[heute um] LT', lastDay: '[gestern um] LT', ... }, - * }); + * i18n.registerTranslation('de', de, { calendar: { sameDay: '[heute um] LT', ... } }); * ``` * - * A partial dictionary is safe: keys you do not supply render their English copy, never a raw dotted - * path. Plurals are stored as `_one` / `_other`; supply whichever categories your language - * needs and `Intl.PluralRules` selects between them. - * - * Note that no dayjs locale file defines `calendar` — that field belongs to the calendar plugin — so a - * new language needs both `import 'dayjs/locale/xx'` and a `calendar` config, or relative dates render - * English scaffolding around translated day names. + * No dayjs locale file defines `calendar` — that field belongs to the calendar plugin — so a new + * language needs both the locale import and a `calendar` config, or relative dates render English + * scaffolding around translated day names. */ export class Streami18n< C extends AnyTranslationCatalog = AnyTranslationCatalog, @@ -137,33 +114,18 @@ export class Streami18n< readonly translationBuilder: TranslationBuilder; - /** The resource dictionaries handed to i18next, keyed by language. */ - translations: Record>> = {}; - - /** - * Languages an integrator actually supplied a dictionary for. - * - * Deliberately narrower than `Object.keys(this.translations)`, which also contains every language - * created just to carry the bundled defaults. Without the distinction the unregistered-language - * warning could never fire. - */ - readonly registeredLanguages = new Set([DEFAULT_LANGUAGE]); - - /** - * Locale configs supplied through `registerTranslation`, applied when the language becomes active. - * - * `Dayjs.locale()` also changes the *global* locale, which registering a translation must not do. - */ - readonly dayjsLocales: Record = {}; - readonly logger: (message?: string) => void; readonly DateTimeParser: DateTimeParserModule; - readonly isCustomDateTimeParser: boolean; readonly formatters: PredefinedFormatters & CustomFormatters; readonly timezone?: string; + private readonly translations: TranslationStore; + + /** Applied when the language becomes active, not on registration: `Dayjs.locale()` is global. */ + private readonly dayjsLocales: Record = {}; + + private readonly isCustomDateTimeParser: boolean; private readonly translationBuilderTopics: Record; - private readonly runtimeDefaults: Record; private readonly disableDateTimeTranslations: boolean; private readonly i18nextConfig: InitOptions; private initPromise?: Promise>; @@ -172,7 +134,7 @@ export class Streami18n< constructor(options: Streami18nOptions = {}) { this.logger = options.logger ?? ((message?: string) => console.warn(message)); - this.runtimeDefaults = options.runtimeDefaults ?? {}; + this.translations = new TranslationStore(options.runtimeDefaults); this.disableDateTimeTranslations = options.disableDateTimeTranslations ?? false; this.timezone = options.timezone; this.formatters = { ...predefinedFormatters, ...options.formatters }; @@ -184,8 +146,8 @@ export class Streami18n< if (options.DateTimeParser) { this.DateTimeParser = options.DateTimeParser; - // The supplied module, not ours: it may be a second physical copy of dayjs, in which case - // extending ours leaves theirs plugin-less and every `LT` / `LLLL` token renders literally. + // The supplied module, not ours -- it may be a second copy of dayjs, and extending ours would + // leave theirs plugin-less, rendering every `LT` / `LLLL` token literally. if (isDayjsLike(this.DateTimeParser)) ensureDayjsPlugins(this.DateTimeParser); } else { this.DateTimeParser = getDefaultDateTimeParserModule(); @@ -215,20 +177,15 @@ export class Streami18n< tDateTimeParser, }); - // `en` always exists so the bundled keys resolve, and so does the active language — including one - // nobody registered, which then renders the SDK's English copy from the inline defaults rather - // than dotted key paths. - this.ensureLanguage(DEFAULT_LANGUAGE); - this.ensureLanguage(language); + // Both always exist, so an unregistered language still renders English copy rather than dotted keys. + this.translations.ensure(DEFAULT_LANGUAGE); + this.translations.ensure(language); if (options.translationsForLanguage) { - this.translations[language] = { - [DEFAULT_NAMESPACE]: this.mergeWithRuntimeDefaults( - language, - options.translationsForLanguage as Record, - ), - }; - this.registeredLanguages.add(language); + this.translations.register( + language, + options.translationsForLanguage as Record, + ); } const missingKeyHandler = @@ -239,8 +196,7 @@ export class Streami18n< debug: options.debug ?? false, fallbackLng: false, interpolation: { escapeValue: false, formatSeparator: '|' }, - // Keys are flat strings that happen to contain dots, and several contain `...` in their copy, - // which `keySeparator: '.'` would mis-resolve. This must stay false. + // Must stay false: keys are flat strings containing dots, and some copy contains `...`. keySeparator: false, lng: language, nsSeparator: false, @@ -249,8 +205,7 @@ export class Streami18n< ? { postProcess: Object.keys(this.translationBuilderTopics) } : {}), ...options.i18nextConfigOverrides, - // An integrator handler replaces ours wholesale, so it has to be guarded too — otherwise - // supplying one silently blanks every prose key. + // Guarded even when integrator-supplied: an unguarded handler silently blanks every prose key. parseMissingKeyHandler: missingKeyHandler ? guardMissingKeyHandler(missingKeyHandler) : (key: string, defaultValue?: string) => { @@ -260,10 +215,8 @@ export class Streami18n< }, }; - // Deliberately *not* validating the language here. `registerTranslation()` legitimately runs after - // construction, so warning now would fire for every integrator who registers a dictionary the normal - // way. The check belongs in `init()`, which is the first moment the set of registered languages is - // final. + // No dictionary check here -- `registerTranslation()` legitimately runs after construction, so + // `init()` is the first moment the registered set is final. if (options.dayjsLocaleConfigForLanguage) { this.addOrUpdateLocale(language, options.dayjsLocaleConfigForLanguage); } else if (!this.localeExists(language)) { @@ -275,10 +228,6 @@ export class Streami18n< } } - /* --------------------------------------------------------------------------------------------- - * State-backed accessors - * ------------------------------------------------------------------------------------------- */ - get t(): StreamTFunctionFor { return this.state.getLatestValue().t; } @@ -300,21 +249,12 @@ export class Streami18n< return this.state?.getLatestValue().language ?? DEFAULT_LANGUAGE; } - /* --------------------------------------------------------------------------------------------- - * Lifecycle - * ------------------------------------------------------------------------------------------- */ - /** * Initializes i18next. Idempotent and safe to call concurrently. * - * The promise is memoized and never cleared **on success**: two independent consumers (a UI SDK's - * chat root and its overlay host, say) both call this, and clearing it on completion would leave a - * window where a third caller re-entered initialization. - * - * A rejection *is* cleared, so a retry is possible. `runInit` guards everything it does, so the only - * way to get here is something genuinely unexpected — most plausibly an integrator-supplied `logger` - * that throws. Latching that permanently would leave the instance uninitialized for the process - * lifetime, rendering the default English translator with no way back. + * Memoized on success, so two independent consumers (a chat root and its overlay host) share one + * initialization. Cleared on rejection, so a retry is possible rather than the instance staying + * uninitialized for the process lifetime. */ init(): Promise> { this.initPromise ??= this.runInit().catch((error: unknown) => { @@ -325,10 +265,9 @@ export class Streami18n< } private async runInit(): Promise> { + // Everything is inside the `try`: neither UI SDK awaits `init()`, so a throw escaping here is an + // unhandled rejection. try { - // Inside the `try`, all three of them. These log and touch dayjs, so each can throw for reasons - // that have nothing to do with i18next — and a throw out of `runInit` is an unhandled rejection - // at both UI SDKs' call sites, which do not await this. this.validateCurrentLanguage(); this.assertPluralRulesCoverage(this.currentLanguage); @@ -338,20 +277,18 @@ export class Streami18n< const t = await this.i18nInstance.init({ ...this.i18nextConfig, lng: this.currentLanguage, - resources: this.translations, + resources: this.i18nextResources(), }); this.registerFormatters(); - // After init, so the topics' post-processors are attached to a live instance and any buffered - // translator registrations flush. + // After init, so post-processors attach to a live instance and buffered translators flush. Object.entries(this.translationBuilderTopics).forEach(([topic, Topic]) => { this.translationBuilder.registerTopic(topic, Topic); }); this.state.partialNext({ initialized: true, - // An `overrideTFunction` call before init must not be undone by init. ...(this.tOverridden ? {} : { t: t as unknown as StreamTFunctionFor }), @@ -365,12 +302,9 @@ export class Streami18n< } /** - * Builds each formatter from its factory and hands it to i18next. - * - * Re-run on every language change, not just at `init()`. Factories receive the language through their - * context and virtually all of them **destructure** it, which snapshots the value — so a factory run - * once at initialization keeps formatting in the initial language forever, with no error. i18next's - * `formatter.add` replaces an existing name, so re-registering is the whole fix. + * Re-run on every language change, not just at `init()`: factories destructure the language out of + * their context, so one built once keeps formatting in the initial language forever. `formatter.add` + * replaces by name, which is what makes re-registering sufficient. */ private registerFormatters = () => { const context = this.createFormatterContext(); @@ -378,9 +312,8 @@ export class Streami18n< Object.entries(this.formatters).forEach(([name, factory]) => { if (!factory) return; const formatter = factory(context); - // A custom formatter's value type is declared `never` so that any implementation is assignable - // to it (parameters are contravariant). i18next's own signature takes `any`, so the widening - // happens here rather than weakening the public type. + // Widened here rather than in the public type: a custom formatter's value is `never` so any + // implementation is assignable (parameters are contravariant), while i18next's takes `any`. this.i18nInstance.services.formatter?.add( name, formatter as (value: any, lng: string | undefined, options: any) => string, @@ -389,15 +322,10 @@ export class Streami18n< }; /** - * What each formatter factory is handed. - * - * `currentLanguage` and `tDateTimeParser` are accessors rather than snapshots, which covers a - * formatter that holds the context and reads a property per call. A formatter that *destructures* the - * context still snapshots, which is why {@link registerFormatters} also re-runs on a language change - * — the two together are what make both styles correct. + * Accessors rather than snapshots, for a formatter that holds the context and reads per call; one + * that destructures is covered by {@link registerFormatters} re-running instead. * - * Nested arrow functions rather than an aliased `this`: a getter in an object literal binds `this` to - * the literal. + * The nested arrows are load-bearing: a getter in an object literal binds `this` to the literal. */ private createFormatterContext = (): FormatterContext => { const readLanguage = () => this.currentLanguage; @@ -417,38 +345,22 @@ export class Streami18n< }; }; - /* --------------------------------------------------------------------------------------------- - * Languages and dictionaries - * ------------------------------------------------------------------------------------------- */ - - /** - * A dictionary layered over the bundled defaults. - * - * Every write into `this.translations` goes through here: bundled keys have no inline `defaultValue` - * at their call site and `fallbackLng` is false, so a language missing them renders raw dotted keys - * and unformatted ISO timestamps. - */ - private mergeWithRuntimeDefaults = ( - language: string, - translation?: Record, - ): Record => ({ - ...this.runtimeDefaults, - ...this.translations[language]?.[DEFAULT_NAMESPACE], - ...translation, - }); + /** The store's flat dictionaries in i18next's nested `resources` shape. */ + private i18nextResources = (): Record>> => + Object.fromEntries( + this.translations + .entries() + .map(([language, dictionary]) => [language, { [DEFAULT_NAMESPACE]: dictionary }]), + ); - /** - * Guarantees `language` has a dictionary, so a language nobody registered still formats dates and - * renders the SDK's copy in English. Writes into i18next's store too when already initialized — the - * only route for a language added after `init()`. - */ + /** The only route for a language added after `init()`. */ private ensureLanguage = (language: string) => { - const translation = this.mergeWithRuntimeDefaults(language); - this.translations[language] = { [DEFAULT_NAMESPACE]: translation }; + this.addResources(language, this.translations.ensure(language)); + }; - if (this.initialized) { - this.i18nInstance.addResources(language, DEFAULT_NAMESPACE, translation); - } + private addResources = (language: string, dictionary: Record) => { + if (!this.initialized) return; + this.i18nInstance.addResources(language, DEFAULT_NAMESPACE, dictionary); }; registerTranslation( @@ -463,14 +375,10 @@ export class Streami18n< return; } - // Merged, not replaced, so repeated calls for one language accumulate and the bundled keys - // survive a partial dictionary. - const merged = this.mergeWithRuntimeDefaults( + const merged = this.translations.register( language, translation as Record, ); - this.translations[language] = { [DEFAULT_NAMESPACE]: merged }; - this.registeredLanguages.add(language); if (dayjsLocaleConfig) { this.dayjsLocales[language] = { ...dayjsLocaleConfig }; @@ -482,27 +390,20 @@ export class Streami18n< ); } - if (this.initialized) { - // `merged`, not `translation`: for a language registered after init this is the only write into - // i18next's store, so passing the partial would leave the bundled defaults absent there. - this.i18nInstance.addResources(language, DEFAULT_NAMESPACE, merged); - } + // `merged`, not `translation`: for a post-init language this is the only write into i18next's + // store, so the partial would leave the bundled defaults absent there. + this.addResources(language, merged); } /** - * Changes the active language. - * - * Returns nothing: the new `t` is published to {@link Streami18n.state}, which is the single source - * of the current translator. Handing one back would offer a value that goes stale on the next - * language change and invite callers to cache it. + * Returns nothing: the new `t` is published to {@link Streami18n.state}. Handing one back would offer + * a value that goes stale on the next language change. */ async setLanguage(language: string): Promise { const previousLanguage = this.state.getLatestValue().language; - // Published before the switch so `validateCurrentLanguage` reports on the language being adopted, - // and rolled back below if the switch does not happen -- otherwise the store advertises a language - // i18next never adopted, and `tDateTimeParser` starts formatting dates in a locale whose copy is - // not loaded. + // Published up front so the warnings below name the language being adopted, and rolled back in the + // `catch` -- otherwise the store advertises one i18next never switched to. this.state.partialNext({ language }); this.ensureLanguage(language); @@ -515,7 +416,6 @@ export class Streami18n< const t = await this.i18nInstance.changeLanguage(language); const dayjsLocale = this.dayjsLocales[language]; if (dayjsLocale) this.addOrUpdateLocale(language, dayjsLocale); - // Rebuilt against the new language -- see `registerFormatters`. this.registerFormatters(); if (!this.tOverridden) { this.state.partialNext({ t: t as unknown as StreamTFunctionFor }); @@ -527,9 +427,8 @@ export class Streami18n< } /** - * Swaps in a different translation implementation, for an app that already has an i18n layer. - * - * Safe before `init()`: the store holds it and initialization will not overwrite it. + * Swaps in a different translation implementation, for an app that already has an i18n layer. Safe + * before `init()`, which will not overwrite it. */ overrideTFunction(t: StreamTFunctionFor) { this.tOverridden = true; @@ -537,50 +436,41 @@ export class Streami18n< } /** - * Warns when the active language has no registered dictionary. - * - * Not an error, and not a reason to fall back to `en`: the language renders the SDK's English copy - * from the inline defaults while keeping its own date formats. Silently resetting the language - * instead discards the integrator's choice and makes the cause very hard to see. + * Languages an integrator supplied a dictionary for. Read-only because adding to it would claim a + * language is registered with no dictionary behind it; use `registerTranslation`. */ - validateCurrentLanguage = () => { + get registeredLanguages(): ReadonlySet { + return this.translations.registeredLanguages; + } + + /** + * Warns rather than falling back to `en`: the language renders English copy from the inline defaults + * while keeping its own date formats, and resetting it would discard the integrator's choice. + */ + private validateCurrentLanguage = () => { const language = this.currentLanguageValue; - if (this.registeredLanguages.has(language)) return; + if (this.translations.isRegistered(language)) return; this.logger( `Streami18n: no translation dictionary is registered for '${language}', so the SDK's copy ` + `renders in English. Call registerTranslation('${language}', {...}) to translate it. ` + - `Registered: ${[...this.registeredLanguages].join(', ')}`, + `Registered: ${[...this.translations.registeredLanguages].join(', ')}`, ); }; - /** Whether the date library has locale data for `language`. */ - localeExists = (language: string) => { + /** Always true for a supplied parser -- we cannot inspect a foreign module's locale registry. */ + private localeExists = (language: string) => { if (this.isCustomDateTimeParser) return true; return dayjsLocaleExists(language); }; - addOrUpdateLocale(language: string, config: DayjsLocaleConfig) { + private addOrUpdateLocale(language: string, config: DayjsLocaleConfig) { addOrUpdateDayjsLocale(language, config); } - /** Languages with a dictionary, including those carrying only the bundled defaults. */ - getAvailableLanguages = () => Object.keys(this.translations); - /** - * The resource dictionaries handed to i18next, keyed by language. - * - * Not the full English catalog: prose keys are never bundled — they render from the inline - * `defaultValue` at their call site — so `en` holds the bundled defaults plus whatever has been - * registered. - */ - getTranslations = () => this.translations; - - /** - * A loose translate used by formatters, which resolve keys handed to them at runtime. - * - * Bound to i18next rather than to the typed `t` so a formatter can reach its own `relativeTime.*` - * copy without the catalog having to declare it. + * For formatters, which resolve keys handed to them at runtime -- including their own + * `relativeTime.*` copy, which no catalog declares. */ private translate: LooseTranslateFunction = (key, defaultValueOrOptions, options) => (this.t as LooseTranslateFunction)( @@ -590,17 +480,11 @@ export class Streami18n< ) as string; /** - * Warns when `Intl.PluralRules` has no data for a language. - * - * Hermes ships a partial ICU: the constructor exists but silently falls back to the root locale's - * rules — `{ other }` only — for locales it lacks data for. A dictionary correctly supplying - * `_few` / `_many` then renders none of them, with no error anywhere. React Native apps load - * `intl-pluralrules` to fix this; this check is what turns the silent version into a visible one for - * anyone who has not. + * Hermes ships a partial ICU: `Intl.PluralRules` silently falls back to root rules (`other` only) for + * locales it lacks, so a dictionary's `_few` / `_many` never render and nothing errors. * - * Checked here rather than at module scope because i18next builds its plural resolver during - * `init()`, caching an `Intl.PluralRules` per language — so this is the last moment a polyfill could - * still have been loaded in time. + * Called from `init()` because i18next caches a resolver per language there — the last moment + * `intl-pluralrules` could still have been loaded in time. */ private assertPluralRulesCoverage = (language: string) => { try { @@ -620,6 +504,6 @@ export class Streami18n< }; } -/** `JSON.stringify(error)` renders an `Error` as `{}`, which is how these used to get logged. */ +/** `JSON.stringify(error)` renders an `Error` as `{}`. */ const describeError = (error: unknown) => error instanceof Error ? error.message : String(error); diff --git a/src/i18n/TranslationStore.ts b/src/i18n/TranslationStore.ts new file mode 100644 index 0000000000..a4cce6f8cc --- /dev/null +++ b/src/i18n/TranslationStore.ts @@ -0,0 +1,96 @@ +/** + * The translation dictionaries an instance holds, and the one rule that governs them. + * + * Extracted from `Streami18n` because it is a self-contained concern with no dependency on i18next or + * dayjs: given the SDK's bundled defaults and whatever dictionaries an integrator supplies, produce the + * dictionary for a language. That makes the layering rule testable on its own rather than only through + * a fully initialized instance. + * + * Deliberately free of i18next concepts — no namespaces, no resource nesting. `Streami18n` adapts these + * flat dictionaries to i18next's shape, so the rule below stays readable without knowing that library. + */ + +/** The one language whose dictionary always exists, because the bundled copy is English. */ +export const DEFAULT_LANGUAGE = 'en'; + +export class TranslationStore { + /** + * The SDK's bundled translation data: the keys that cannot carry an inline `defaultValue` at their + * call site — formatter expressions, and prose reaching `t()` as a runtime value. + */ + private readonly runtimeDefaults: Record; + + private readonly dictionaries = new Map>(); + + private readonly registered = new Set([DEFAULT_LANGUAGE]); + + constructor(runtimeDefaults: Record = {}) { + this.runtimeDefaults = runtimeDefaults; + } + + /** + * Languages an integrator actually supplied a dictionary for. + * + * Deliberately narrower than {@link TranslationStore.languages}, which also counts every language + * created just to carry the bundled defaults. Without the distinction there would be no way to warn + * that the active language has no translations — every language would look registered. + */ + get registeredLanguages(): ReadonlySet { + return this.registered; + } + + /** Every language with a dictionary, including those carrying only the bundled defaults. */ + get languages(): string[] { + return [...this.dictionaries.keys()]; + } + + /** `language -> dictionary`, for a caller that has to hand them all over at once. */ + entries(): Array<[string, Record]> { + return [...this.dictionaries]; + } + + isRegistered(language: string) { + return this.registered.has(language); + } + + /** + * Guarantees `language` has a dictionary, and returns it. + * + * Called for a language nobody registered, so that it still formats dates and renders the SDK's copy + * in English rather than raw dotted keys. + */ + ensure(language: string): Record { + return this.merge(language); + } + + /** + * Layers a dictionary over what `language` already has, and marks it registered. + * + * **Merged, not replaced.** Repeated calls for one language accumulate, and — the reason this class + * exists — the bundled defaults survive a partial dictionary. A bundled key has no inline + * `defaultValue` at its call site and `fallbackLng` is false, so a language that loses them renders + * raw dotted keys and unformatted ISO timestamps. That is guarantee G1 of the i18n suite. + */ + register(language: string, dictionary: Record): Record { + const merged = this.merge(language, dictionary); + this.registered.add(language); + return merged; + } + + /** + * Bundled defaults first, then whatever the language already had, then the incoming dictionary — so + * an integrator can override a bundled key, and a later registration wins over an earlier one. + */ + private merge( + language: string, + dictionary?: Record, + ): Record { + const merged = { + ...this.runtimeDefaults, + ...this.dictionaries.get(language), + ...dictionary, + }; + this.dictionaries.set(language, merged); + return merged; + } +} diff --git a/src/i18n/index.ts b/src/i18n/index.ts index 1c011ef0d8..aee7c367fb 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -10,5 +10,6 @@ export * from './formatters'; export * from './languageNames'; export * from './Streami18n'; export * from './TranslationBuilder'; +export * from './TranslationStore'; export * from './translator'; export * from './types'; diff --git a/test/unit/i18n/Streami18n.test.ts b/test/unit/i18n/Streami18n.test.ts index 06b5a95f05..d2240beab4 100644 --- a/test/unit/i18n/Streami18n.test.ts +++ b/test/unit/i18n/Streami18n.test.ts @@ -241,13 +241,27 @@ describe('Streami18n', () => { }); }); - describe('getAvailableLanguages', () => { - it('includes languages carrying only the bundled defaults', async () => { + describe('registeredLanguages', () => { + it('excludes a language carrying only the bundled defaults', async () => { const i18n = setup({ language: 'de' }); - await i18n.init(); - expect(i18n.getAvailableLanguages()).toContain('de'); - // ...while registeredLanguages stays narrower, which is what makes the G3 warning possible. + const { t } = await i18n.init(); + + // The dictionary exists -- a bundled formatter key resolves rather than rendering its own + // dotted path... + expect(t('timestamp.MessageTimestamp', { timestamp: new Date(0) })).not.toBe( + 'timestamp.MessageTimestamp', + ); + // ...while `registeredLanguages` stays narrower, which is what makes the G3 warning possible. expect(i18n.registeredLanguages.has('de')).toBe(false); + expect(i18n.registeredLanguages.has('en')).toBe(true); + }); + + it('includes a language once a dictionary is registered for it', async () => { + const i18n = setup({ language: 'de' }); + i18n.registerTranslation('de', { 'fixture.prose': 'Abbrechen' } as never); + await i18n.init(); + + expect(i18n.registeredLanguages.has('de')).toBe(true); }); }); }); @@ -272,7 +286,7 @@ describe('Streami18n — region-coded languages', () => { expect(t('fixture.prose', 'Cancel')).toBe(`cancel-${language}`); expect(i18n.currentLanguage).toBe(language); - expect(i18n.getAvailableLanguages()).toContain(language); + expect(i18n.registeredLanguages.has(language)).toBe(true); }, ); diff --git a/test/unit/i18n/TranslationStore.test.ts b/test/unit/i18n/TranslationStore.test.ts new file mode 100644 index 0000000000..0fb921d477 --- /dev/null +++ b/test/unit/i18n/TranslationStore.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest'; + +import { DEFAULT_LANGUAGE, TranslationStore } from '../../../src/i18n'; + +/** + * The layering rule, tested directly. + * + * It used to be reachable only through a fully initialized `Streami18n`, which meant asserting on it + * required i18next, dayjs and an async `init()` — so the rule that actually matters (bundled defaults + * survive a partial dictionary) was only ever verified as a side effect of rendering. + */ +const BUNDLED = { + 'a11y.close.label': 'Close', + 'timestamp.MessageTimestamp': '{{ timestamp | timestampFormatter(format: LT) }}', +}; + +describe('TranslationStore', () => { + it('starts with English registered and nothing else', () => { + const store = new TranslationStore(BUNDLED); + + expect(store.registeredLanguages.has(DEFAULT_LANGUAGE)).toBe(true); + expect([...store.registeredLanguages]).toEqual([DEFAULT_LANGUAGE]); + // No dictionary is created until one is asked for. + expect(store.languages).toEqual([]); + }); + + it('layers the bundled defaults under a language nobody registered', () => { + const store = new TranslationStore(BUNDLED); + + expect(store.ensure('de')).toEqual(BUNDLED); + expect(store.languages).toEqual(['de']); + // Present, but not *registered* -- the distinction the unregistered-language warning needs. + expect(store.isRegistered('de')).toBe(false); + }); + + /** Guarantee G1: a partial dictionary must not knock out the bundled formatter keys. */ + it('keeps the bundled keys when a partial dictionary is registered', () => { + const store = new TranslationStore(BUNDLED); + + const merged = store.register('de', { 'a11y.close.label': 'Schließen' }); + + expect(merged['a11y.close.label']).toBe('Schließen'); + expect(merged['timestamp.MessageTimestamp']).toBe( + '{{ timestamp | timestampFormatter(format: LT) }}', + ); + expect(store.isRegistered('de')).toBe(true); + }); + + it('accumulates repeated registrations for one language', () => { + const store = new TranslationStore(BUNDLED); + + store.register('de', { 'a11y.close.label': 'Schließen' }); + const merged = store.register('de', { 'fixture.prose': 'Abbrechen' }); + + expect(merged['a11y.close.label']).toBe('Schließen'); + expect(merged['fixture.prose']).toBe('Abbrechen'); + }); + + it('lets a later registration win over an earlier one', () => { + const store = new TranslationStore(BUNDLED); + + store.register('de', { 'a11y.close.label': 'Erste' }); + const merged = store.register('de', { 'a11y.close.label': 'Zweite' }); + + expect(merged['a11y.close.label']).toBe('Zweite'); + }); + + it('lets an integrator override a bundled key', () => { + const store = new TranslationStore(BUNDLED); + + const merged = store.register('en', { + 'timestamp.MessageTimestamp': '{{ timestamp | timestampFormatter(format: HH:mm) }}', + }); + + expect(merged['timestamp.MessageTimestamp']).toBe( + '{{ timestamp | timestampFormatter(format: HH:mm) }}', + ); + }); + + it('does not mutate the bundled defaults it was handed', () => { + const runtimeDefaults = { ...BUNDLED }; + const store = new TranslationStore(runtimeDefaults); + + store.register('de', { 'a11y.close.label': 'Schließen' }); + store.ensure('fr'); + + expect(runtimeDefaults).toEqual(BUNDLED); + }); + + it('keeps a region-coded language separate from its base', () => { + const store = new TranslationStore(BUNDLED); + + store.register('pt', { 'fixture.prose': 'pt' }); + const ptBR = store.register('pt-BR', { 'fixture.prose': 'pt-BR' }); + + expect(ptBR['fixture.prose']).toBe('pt-BR'); + expect(store.entries()).toHaveLength(2); + }); + + it('ensure() is idempotent and preserves what was registered', () => { + const store = new TranslationStore(BUNDLED); + + store.register('de', { 'fixture.prose': 'Abbrechen' }); + const ensured = store.ensure('de'); + + expect(ensured['fixture.prose']).toBe('Abbrechen'); + expect(store.languages).toEqual(['de']); + expect(store.isRegistered('de')).toBe(true); + }); + + it('works with no bundled defaults at all', () => { + const store = new TranslationStore(); + + expect(store.ensure('de')).toEqual({}); + expect(store.register('de', { 'fixture.prose': 'Abbrechen' })).toEqual({ + 'fixture.prose': 'Abbrechen', + }); + }); +}); diff --git a/v9-to-v10-migration-guide-i18n.md b/v9-to-v10-migration-guide-i18n.md index 9fb44a02cd..a9a8c6b507 100644 --- a/v9-to-v10-migration-guide-i18n.md +++ b/v9-to-v10-migration-guide-i18n.md @@ -232,6 +232,26 @@ Notable if you are building custom UI directly on `stream-chat`: - The keys with no inline default at their call site are injected via the `runtimeDefaults` option, because the catalog belongs to the UI layer rather than to core. +### Removed from the `Streami18n` surface + +Both UI SDKs' v9 classes exposed these. They are gone rather than deprecated — v10 is a breaking +release, so an old name is removed rather than carried with a countdown on it. + +| Removed | Why, and what to do instead | +| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `getTranslations()` | Returned the raw i18next resource map, which is internal bookkeeping rather than a catalog: prose keys are never bundled, so it never held the SDK's English copy. It had no consumer in either SDK. To check that a key resolves, render it: `i18n.t('some.key')`. | +| `getAvailableLanguages()` | Returned every language with a dictionary, **including** ones created only to carry the bundled defaults — so a language nobody registered appeared "available". Use `i18n.registeredLanguages`, which answers the question people were actually asking. | + +`registeredLanguages` is now a `ReadonlySet`. Reading it (`.has(code)`, spreading it) is +unchanged; `.add()` no longer compiles, because adding to it would claim a language is registered with no +dictionary behind it — exactly the state the unregistered-language warning exists to report. Call +`registerTranslation()` instead. + +These are now internal (`private`), having never been part of either SDK's documented API: +`translations`, `dayjsLocales`, `isCustomDateTimeParser`, `localeExists()`, `addOrUpdateLocale()`, +`validateCurrentLanguage()`. To register a dayjs locale directly, `stream-chat/i18n` exports +`addOrUpdateDayjsLocale()` and `dayjsLocaleExists()`. + ## New subpath: `stream-chat/i18n/codegen` Build-time only, **Node-only** and **ESM-only**: it reads the filesystem and uses the TypeScript parser From 4ab62e0da573cc88e41f9320cb50c80136d03b00 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Wed, 19 Aug 2026 11:23:03 +0200 Subject: [PATCH 25/27] refactor(pollComposer)!: rename the poll validation code family 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. --- specs/i18n-to-core/spec.md | 2 +- .../middleware/pollComposer/state.ts | 47 ++++++++++------ .../middleware/pollComposer/types.ts | 10 ++-- .../middleware/pollComposer/validation.ts | 50 +++++++++-------- .../middleware/pollComposer/state.test.ts | 16 +++--- .../notifications/notificationTypes.test.ts | 53 +++++++++++-------- v9-to-v10-migration-guide-i18n.md | 15 +++--- 7 files changed, 111 insertions(+), 82 deletions(-) diff --git a/specs/i18n-to-core/spec.md b/specs/i18n-to-core/spec.md index 7a0f9f5e67..d8db0ca8f7 100644 --- a/specs/i18n-to-core/spec.md +++ b/specs/i18n-to-core/spec.md @@ -49,7 +49,7 @@ the work from greenfield to typing and gap-filling: injected rather than imported. Verified to reproduce both SDKs' real committed catalogs identically (React 634/634, RN 408/408 + 97 bundled). - **Scoped identifiers** — `CORE_NOTIFICATION_TYPE` / `CoreNotificationType` and - `POLL_VALIDATION_CODE` / `PollValidationError`, both exhaustiveness-checked. + `POLL_COMPOSER_VALIDATION_CODE` / `PollComposerValidationError`, both exhaustiveness-checked. - `i18next` and `dayjs` as direct dependencies of `stream-chat`. Consumer-facing delta: `v9-to-v10-migration-guide-i18n.md`. diff --git a/src/messageComposer/middleware/pollComposer/state.ts b/src/messageComposer/middleware/pollComposer/state.ts index efe863eba8..3061a50e8d 100644 --- a/src/messageComposer/middleware/pollComposer/state.ts +++ b/src/messageComposer/middleware/pollComposer/state.ts @@ -6,11 +6,11 @@ import type { PollComposerStateChangeMiddlewareValue, TargetedPollOptionTextUpdate, } from './types'; -import type { PollValidationError } from './validation'; +import type { PollComposerValidationError } from './validation'; import { - isPollValidationError, - POLL_VALIDATION_CODE, - pollValidationError, + isPollComposerValidationError, + POLL_COMPOSER_VALIDATION_CODE, + pollComposerValidationError, } from './validation'; export const VALID_MAX_VOTES_VALUE_REGEX = /^([2-9]|10)$/; @@ -20,8 +20,11 @@ export const MAX_POLL_OPTIONS = 100 as const; const textFieldIsEmpty = (text: string) => !text.trim(); export type PollStateValidationOutput = Partial< - Omit, 'options'> & { - options?: Record; + Omit< + Record, + 'options' + > & { + options?: Record; } >; @@ -38,29 +41,35 @@ export const pollStateChangeValidators: Partial< max_votes_allowed: ({ data, value }) => { if (data.enforce_unique_vote && value) return { - max_votes_allowed: pollValidationError( - POLL_VALIDATION_CODE.maxVotesUniqueVoteEnforced, + max_votes_allowed: pollComposerValidationError( + POLL_COMPOSER_VALIDATION_CODE.maxVotesUniqueVoteEnforced, ), }; const numericMatch = value.match(/^[0-9]+$/); if (!numericMatch && value) { return { - max_votes_allowed: pollValidationError(POLL_VALIDATION_CODE.maxVotesNotNumeric), + max_votes_allowed: pollComposerValidationError( + POLL_COMPOSER_VALIDATION_CODE.maxVotesNotNumeric, + ), }; } if (value?.length > 1 && !value.match(VALID_MAX_VOTES_VALUE_REGEX)) return { - max_votes_allowed: pollValidationError(POLL_VALIDATION_CODE.maxVotesOutOfRange), + max_votes_allowed: pollComposerValidationError( + POLL_COMPOSER_VALIDATION_CODE.maxVotesOutOfRange, + ), }; return { max_votes_allowed: undefined }; }, options: ({ value: options }) => { - const errors: Record = {}; + const errors: Record = {}; const seenOptions = new Set(); options.forEach((option: { id: string; text: string }) => { if (seenOptions.has(option.text) && option.text.length) { - errors[option.id] = pollValidationError(POLL_VALIDATION_CODE.optionDuplicate); + errors[option.id] = pollComposerValidationError( + POLL_COMPOSER_VALIDATION_CODE.optionDuplicate, + ); } else { seenOptions.add(option.text); } @@ -76,7 +85,7 @@ export const defaultPollFieldChangeEventValidators: Partial< name: ({ currentError, value }) => value && currentError ? { name: undefined } - : { name: isPollValidationError(currentError) ? currentError : undefined }, + : { name: isPollComposerValidationError(currentError) ? currentError : undefined }, }; export const defaultPollFieldBlurEventValidators: Partial< @@ -85,13 +94,17 @@ export const defaultPollFieldBlurEventValidators: Partial< max_votes_allowed: ({ value }) => { if (value && !value.match(VALID_MAX_VOTES_VALUE_REGEX)) return { - max_votes_allowed: pollValidationError(POLL_VALIDATION_CODE.maxVotesOutOfRange), + max_votes_allowed: pollComposerValidationError( + POLL_COMPOSER_VALIDATION_CODE.maxVotesOutOfRange, + ), }; return { max_votes_allowed: undefined }; }, name: ({ value }) => { if (textFieldIsEmpty(value)) - return { name: pollValidationError(POLL_VALIDATION_CODE.nameRequired) }; + return { + name: pollComposerValidationError(POLL_COMPOSER_VALIDATION_CODE.nameRequired), + }; return { name: undefined }; }, options: (params) => { @@ -100,7 +113,9 @@ export const defaultPollFieldBlurEventValidators: Partial< params.value.forEach((option: { id: string; text: string }, index: number) => { const isTheLastOption = index === params.value.length - 1; if (textFieldIsEmpty(option.text) && !isTheLastOption) { - errors[option.id] = pollValidationError(POLL_VALIDATION_CODE.optionEmpty); + errors[option.id] = pollComposerValidationError( + POLL_COMPOSER_VALIDATION_CODE.optionEmpty, + ); } }); return Object.keys(errors).length > 0 ? { options: errors } : { options: undefined }; diff --git a/src/messageComposer/middleware/pollComposer/types.ts b/src/messageComposer/middleware/pollComposer/types.ts index 3e9274ca7e..6ff2aac7d1 100644 --- a/src/messageComposer/middleware/pollComposer/types.ts +++ b/src/messageComposer/middleware/pollComposer/types.ts @@ -1,6 +1,6 @@ import type { MiddlewareExecutionResult } from '../../../middleware'; import type { CreatePollRequest, VotingVisibility } from '../../../types'; -import type { PollValidationError } from './validation'; +import type { PollComposerValidationError } from './validation'; export type PollComposerOption = { id: string; @@ -23,12 +23,12 @@ export type UpdateFieldsData = Partial, 'options'> & { - options?: Record; + Omit, 'options'> & { + options?: Record; } >; diff --git a/src/messageComposer/middleware/pollComposer/validation.ts b/src/messageComposer/middleware/pollComposer/validation.ts index 61d50cc432..506001c229 100644 --- a/src/messageComposer/middleware/pollComposer/validation.ts +++ b/src/messageComposer/middleware/pollComposer/validation.ts @@ -1,14 +1,15 @@ /** * Stable identifiers for poll-composer field validation failures. * - * Same `domain:entity:operation:result` convention as `CORE_NOTIFICATION_TYPE`, but these are *field* - * errors rendered inline next to an input, so they deliberately do not go through - * `NotificationManager` — that would surface a toast per keystroke. + * **Not notifications, despite the shared `domain:entity:operation:result` shape.** These are *field* + * errors, rendered inline beside the input that produced them, and they never reach + * `NotificationManager` — routing them there would raise a toast per keystroke. `CORE_NOTIFICATION_TYPE` + * is the notification counterpart; the two sets are disjoint and neither substitutes for the other. * * **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 = { +export const POLL_COMPOSER_VALIDATION_CODE = { maxVotesNotNumeric: 'validation:poll:maxVotes:notNumeric', maxVotesOutOfRange: 'validation:poll:maxVotes:outOfRange', maxVotesUniqueVoteEnforced: 'validation:poll:maxVotes:uniqueVoteEnforced', @@ -17,8 +18,8 @@ export const POLL_VALIDATION_CODE = { optionEmpty: 'validation:poll:option:empty', } as const; -export type PollValidationCode = - (typeof POLL_VALIDATION_CODE)[keyof typeof POLL_VALIDATION_CODE]; +export type PollComposerValidationCode = + (typeof POLL_COMPOSER_VALIDATION_CODE)[keyof typeof POLL_COMPOSER_VALIDATION_CODE]; /** * Untranslated English for each code. @@ -27,13 +28,14 @@ export type PollValidationCode = * so the whole set is reviewable in one place. This is a developer-facing fallback — the wording is * not part of the public contract and may change in a minor release. */ -const POLL_VALIDATION_MESSAGE: Record = { - [POLL_VALIDATION_CODE.maxVotesNotNumeric]: 'Only numbers are allowed', - [POLL_VALIDATION_CODE.maxVotesOutOfRange]: 'Type a number from 2 to 10', - [POLL_VALIDATION_CODE.maxVotesUniqueVoteEnforced]: 'Enforce unique vote is enabled', - [POLL_VALIDATION_CODE.nameRequired]: 'Question is required', - [POLL_VALIDATION_CODE.optionDuplicate]: 'Option already exists', - [POLL_VALIDATION_CODE.optionEmpty]: 'Option is empty', +const POLL_COMPOSER_VALIDATION_MESSAGE: Record = { + [POLL_COMPOSER_VALIDATION_CODE.maxVotesNotNumeric]: 'Only numbers are allowed', + [POLL_COMPOSER_VALIDATION_CODE.maxVotesOutOfRange]: 'Type a number from 2 to 10', + [POLL_COMPOSER_VALIDATION_CODE.maxVotesUniqueVoteEnforced]: + 'Enforce unique vote is enabled', + [POLL_COMPOSER_VALIDATION_CODE.nameRequired]: 'Question is required', + [POLL_COMPOSER_VALIDATION_CODE.optionDuplicate]: 'Option already exists', + [POLL_COMPOSER_VALIDATION_CODE.optionEmpty]: 'Option is empty', }; /** @@ -43,30 +45,32 @@ const POLL_VALIDATION_MESSAGE: Record = { * English alongside it so a consumer with no i18n layer still renders something, and so an * identifier a consumer does not recognize degrades to readable text instead of a blank field. */ -export type PollValidationError = { - /** Stable identifier. See {@link POLL_VALIDATION_CODE}. */ - code: PollValidationCode; +export type PollComposerValidationError = { + /** Stable identifier. See {@link POLL_COMPOSER_VALIDATION_CODE}. */ + code: PollComposerValidationCode; /** Untranslated English fallback. Not part of the public contract. */ message: string; /** Extra context for interpolation, e.g. the offending value. */ metadata?: Record; }; -/** Builds a {@link PollValidationError}, filling in the English fallback for `code`. */ -export const pollValidationError = ( - code: PollValidationCode, +/** Builds a {@link PollComposerValidationError}, filling in the English fallback for `code`. */ +export const pollComposerValidationError = ( + code: PollComposerValidationCode, metadata?: Record, -): PollValidationError => ({ +): PollComposerValidationError => ({ code, - message: POLL_VALIDATION_MESSAGE[code], + message: POLL_COMPOSER_VALIDATION_MESSAGE[code], ...(metadata ? { metadata } : {}), }); /** * Narrows a field's error to a single failure. * - * `options` errors are keyed by option id, so a field error is either one `PollValidationError` or a + * `options` errors are keyed by option id, so a field error is either one `PollComposerValidationError` or a * record of them; this distinguishes the two. */ -export const isPollValidationError = (value: unknown): value is PollValidationError => +export const isPollComposerValidationError = ( + value: unknown, +): value is PollComposerValidationError => typeof value === 'object' && value !== null && 'code' in value && 'message' in value; diff --git a/test/unit/MessageComposer/middleware/pollComposer/state.test.ts b/test/unit/MessageComposer/middleware/pollComposer/state.test.ts index 3ad2bb0400..e5a2da8233 100644 --- a/test/unit/MessageComposer/middleware/pollComposer/state.test.ts +++ b/test/unit/MessageComposer/middleware/pollComposer/state.test.ts @@ -10,8 +10,8 @@ import { PollComposerStateMiddlewareFactoryOptions, } from '../../../../../src/messageComposer/middleware/pollComposer/state'; import { - POLL_VALIDATION_CODE, - pollValidationError, + POLL_COMPOSER_VALIDATION_CODE, + pollComposerValidationError, } from '../../../../../src/messageComposer/middleware/pollComposer/validation'; import { VotingVisibility } from '../../../../../src/types'; @@ -218,7 +218,7 @@ describe('PollComposerStateMiddleware', () => { ); expect(result.state.nextState.errors.max_votes_allowed?.code).toBe( - POLL_VALIDATION_CODE.maxVotesUniqueVoteEnforced, + POLL_COMPOSER_VALIDATION_CODE.maxVotesUniqueVoteEnforced, ); expect(result.state.nextState.data.max_votes_allowed).toBe('5'); expect(result.status).toBeUndefined; @@ -523,7 +523,7 @@ describe('PollComposerStateMiddleware', () => { expect(result.state.nextState.errors.options).toBeDefined(); expect(Object.keys(result.state.nextState.errors.options!)).toHaveLength(1); expect(result.state.nextState.errors.options!['option-id1'].code).toBe( - POLL_VALIDATION_CODE.optionEmpty, + POLL_COMPOSER_VALIDATION_CODE.optionEmpty, ); }); it('should not validate options with only white spaces on blur', async () => { @@ -544,10 +544,10 @@ describe('PollComposerStateMiddleware', () => { expect(result.state.nextState.errors.options).toBeDefined(); expect(Object.keys(result.state.nextState.errors.options!)).toHaveLength(2); expect(result.state.nextState.errors.options!['option-id1'].code).toBe( - POLL_VALIDATION_CODE.optionEmpty, + POLL_COMPOSER_VALIDATION_CODE.optionEmpty, ); expect(result.state.nextState.errors.options!['option-id2'].code).toBe( - POLL_VALIDATION_CODE.optionDuplicate, + POLL_COMPOSER_VALIDATION_CODE.optionDuplicate, ); }); @@ -585,7 +585,9 @@ describe('PollComposerStateMiddleware', () => { ); expect(result.state.nextState.errors.options).toEqual({ - 'option-2': pollValidationError(POLL_VALIDATION_CODE.optionDuplicate), + 'option-2': pollComposerValidationError( + POLL_COMPOSER_VALIDATION_CODE.optionDuplicate, + ), }); }); diff --git a/test/unit/notifications/notificationTypes.test.ts b/test/unit/notifications/notificationTypes.test.ts index 68843c4efa..fa1d671eee 100644 --- a/test/unit/notifications/notificationTypes.test.ts +++ b/test/unit/notifications/notificationTypes.test.ts @@ -4,14 +4,14 @@ import { describe, expect, it } from 'vitest'; import { CORE_NOTIFICATION_TYPE, - isPollValidationError, - POLL_VALIDATION_CODE, - pollValidationError, + isPollComposerValidationError, + POLL_COMPOSER_VALIDATION_CODE, + pollComposerValidationError, } from '../../../src'; import type { CoreNotificationType, - PollValidationCode, - PollValidationError, + PollComposerValidationCode, + PollComposerValidationError, } from '../../../src'; const SRC = join(__dirname, '../../../src'); @@ -83,18 +83,18 @@ describe('CORE_NOTIFICATION_TYPE', () => { }); }); -describe('POLL_VALIDATION_CODE', () => { +describe('POLL_COMPOSER_VALIDATION_CODE', () => { it('is exported from the public barrel with its type and helpers', () => { - const code: PollValidationCode = POLL_VALIDATION_CODE.nameRequired; - const error: PollValidationError = pollValidationError(code); + const code: PollComposerValidationCode = POLL_COMPOSER_VALIDATION_CODE.nameRequired; + const error: PollComposerValidationError = pollComposerValidationError(code); expect(error).toEqual({ code, message: 'Question is required' }); - expect(isPollValidationError(error)).toBe(true); + expect(isPollComposerValidationError(error)).toBe(true); }); it('follows the same convention and has no duplicates', () => { - const values = Object.values(POLL_VALIDATION_CODE); + const values = Object.values(POLL_COMPOSER_VALIDATION_CODE); expect(new Set(values).size).toBe(values.length); - for (const [key, code] of Object.entries(POLL_VALIDATION_CODE)) { + for (const [key, code] of Object.entries(POLL_COMPOSER_VALIDATION_CODE)) { expect(code, `${key} must be validation:poll::`).toMatch( /^validation:poll:[a-zA-Z][\w-]*:[a-zA-Z][\w-]*$/, ); @@ -102,34 +102,41 @@ describe('POLL_VALIDATION_CODE', () => { }); it('pairs every code with a non-empty English fallback', () => { - for (const code of Object.values(POLL_VALIDATION_CODE)) { - expect(pollValidationError(code).message, `${code} has no fallback`).toBeTruthy(); + for (const code of Object.values(POLL_COMPOSER_VALIDATION_CODE)) { + expect( + pollComposerValidationError(code).message, + `${code} has no fallback`, + ).toBeTruthy(); } }); it('emits every code it declares', () => { - const unused = Object.keys(POLL_VALIDATION_CODE).filter( - (key) => !allSource.includes(`POLL_VALIDATION_CODE.${key}`), + const unused = Object.keys(POLL_COMPOSER_VALIDATION_CODE).filter( + (key) => !allSource.includes(`POLL_COMPOSER_VALIDATION_CODE.${key}`), ); expect(unused, 'declared but never emitted').toEqual([]); }); it('attaches metadata only when supplied', () => { - expect(pollValidationError(POLL_VALIDATION_CODE.optionEmpty)).not.toHaveProperty( - 'metadata', - ); expect( - pollValidationError(POLL_VALIDATION_CODE.optionEmpty, { optionId: 'a' }).metadata, + pollComposerValidationError(POLL_COMPOSER_VALIDATION_CODE.optionEmpty), + ).not.toHaveProperty('metadata'); + expect( + pollComposerValidationError(POLL_COMPOSER_VALIDATION_CODE.optionEmpty, { + optionId: 'a', + }).metadata, ).toEqual({ optionId: 'a' }); }); it('rejects non-errors in the narrowing guard', () => { - expect(isPollValidationError(undefined)).toBe(false); - expect(isPollValidationError('Option is empty')).toBe(false); + expect(isPollComposerValidationError(undefined)).toBe(false); + expect(isPollComposerValidationError('Option is empty')).toBe(false); // an `options` error record, which is the other shape a field error can take expect( - isPollValidationError({ - 'option-1': pollValidationError(POLL_VALIDATION_CODE.optionEmpty), + isPollComposerValidationError({ + 'option-1': pollComposerValidationError( + POLL_COMPOSER_VALIDATION_CODE.optionEmpty, + ), }), ).toBe(false); }); diff --git a/v9-to-v10-migration-guide-i18n.md b/v9-to-v10-migration-guide-i18n.md index a9a8c6b507..fb1fead654 100644 --- a/v9-to-v10-migration-guide-i18n.md +++ b/v9-to-v10-migration-guide-i18n.md @@ -151,16 +151,16 @@ type PollComposerFieldErrors = Partial< >; // v10 -type PollValidationError = { - code: PollValidationCode; +type PollComposerValidationError = { + code: PollComposerValidationCode; /** Untranslated English fallback. Not part of the public contract. */ message: string; metadata?: Record; }; type PollComposerFieldErrors = Partial< - Omit, 'options'> & { - options?: Record; + Omit, 'options'> & { + options?: Record; } >; ``` @@ -180,10 +180,11 @@ The one-property migration, if you do not want to localize: To localize, switch on `code`: ```ts -import { POLL_VALIDATION_CODE } from 'stream-chat'; +import { POLL_COMPOSER_VALIDATION_CODE } from 'stream-chat'; +import type { PollComposerValidationCode } from 'stream-chat'; -const copy: Record = { - [POLL_VALIDATION_CODE.maxVotesNotNumeric]: t('poll.maxVotes.notNumeric'), +const copy: Record = { + [POLL_COMPOSER_VALIDATION_CODE.maxVotesNotNumeric]: t('poll.maxVotes.notNumeric'), // … }; const text = errors.name ? (copy[errors.name.code] ?? errors.name.message) : undefined; From 56e8f9e7bfc7e150a8a519c30ad79f181dfb96c7 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Thu, 20 Aug 2026 11:26:52 +0200 Subject: [PATCH 26/27] fix(i18n): register locales on the supplied dayjs, keep a failed init safe Two findings from an adversarial review pass. `addOrUpdateDayjsLocale` and `dayjsLocaleExists` now take the module to act on, defaulting to ours, and `Streami18n` passes `this.DateTimeParser`. `ensureDayjsPlugins` was fixed earlier to extend a supplied module, but locale registration still wrote to *our* dayjs -- so an integrator's second physical copy got the plugins and none of the wording, and a `calendar` config or `dayjsLocaleConfigForLanguage` was silently inert. `localeExists` consults that module's registry too, rather than returning `true` for any custom parser, and a locale config for a non-dayjs parser is now reported instead of written somewhere nothing reads. `isCustomDateTimeParser` goes with it. A failed `init()` no longer reports success. The catch set `initialized: true`, which two callers read as "i18next is usable": `registerTranslation` then wrote through `addResources` into an instance whose own init had rejected -- throwing -- and `setLanguage` called `changeLanguage` on the same dead instance. Leaving the flag false turns both into no-ops, so the instance stays degraded but safe: the dictionary and language are still recorded, and `t` remains the default translator, so every call site renders its inline English. The dead `.catch()` in `init()` is gone with it; an i18next failure resolves rather than rejecting, because neither UI SDK awaits this. A logger that throws is the one path that still escapes, recorded in a test rather than guarded. Also adds `relativeCompactWeekRounding` to the timestamp formatter. `floor` stays the default and is what this module has always done; `ceil` exists because it is what `stream-chat-react` rendered before its formatter moved here, and the two disagree visibly -- 8 days is "1w ago" under floor and "2w ago" under ceil, and 22-27 days is "3w ago" under floor but falls through to a date under ceil. Making it explicit is what lets each UI SDK keep the labels it shipped. Tests: 14 added -- locale registration on a supplied module, the failed-init path that nothing covered, and week-label boundaries at 7/8/13/14/15/21/22/27/28 in both roundings. --- src/i18n/Streami18n.ts | 51 +++++--- src/i18n/dayjs.ts | 50 +++++-- src/i18n/formatters.ts | 27 +++- src/i18n/types.ts | 12 ++ test/unit/i18n/Streami18n.test.ts | 189 ++++++++++++++++++++++++--- test/unit/i18n/getDateString.test.ts | 63 +++++++++ 6 files changed, 346 insertions(+), 46 deletions(-) diff --git a/src/i18n/Streami18n.ts b/src/i18n/Streami18n.ts index 9c8d794017..f7c7c6fc6e 100644 --- a/src/i18n/Streami18n.ts +++ b/src/i18n/Streami18n.ts @@ -124,7 +124,6 @@ export class Streami18n< /** Applied when the language becomes active, not on registration: `Dayjs.locale()` is global. */ private readonly dayjsLocales: Record = {}; - private readonly isCustomDateTimeParser: boolean; private readonly translationBuilderTopics: Record; private readonly disableDateTimeTranslations: boolean; private readonly i18nextConfig: InitOptions; @@ -138,7 +137,6 @@ export class Streami18n< this.disableDateTimeTranslations = options.disableDateTimeTranslations ?? false; this.timezone = options.timezone; this.formatters = { ...predefinedFormatters, ...options.formatters }; - this.isCustomDateTimeParser = Boolean(options.DateTimeParser); this.translationBuilder = new TranslationBuilder(this.i18nInstance); this.translationBuilderTopics = options.translationBuilderTopics ?? {}; @@ -252,21 +250,25 @@ export class Streami18n< /** * Initializes i18next. Idempotent and safe to call concurrently. * - * Memoized on success, so two independent consumers (a chat root and its overlay host) share one - * initialization. Cleared on rejection, so a retry is possible rather than the instance staying - * uninitialized for the process lifetime. + * Memoized, so two independent consumers — a UI SDK's chat root and its overlay host — share one + * initialization. + * + * An i18next failure does **not** reject: neither UI SDK awaits this, so a rejection would surface + * as an unhandled rejection and the memo would latch it for the process lifetime. It is logged + * instead, leaving the instance *degraded but safe*: `state.initialized` stays false, which is what + * keeps the methods below off a dead i18next instance, and `t` remains the default translator, so + * every call site still renders its inline English. There is no retry — construct a new instance. + * + * One path does escape: an integrator `logger` that throws is called from the `catch` itself, so it + * rejects out of here. Rare enough not to guard, but it is why this is not an absolute guarantee. */ init(): Promise> { - this.initPromise ??= this.runInit().catch((error: unknown) => { - this.initPromise = undefined; - throw error; - }); + this.initPromise ??= this.runInit(); return this.initPromise; } private async runInit(): Promise> { - // Everything is inside the `try`: neither UI SDK awaits `init()`, so a throw escaping here is an - // unhandled rejection. + // Everything is inside the `try` -- see `init()` for why an i18next failure must not reject. try { this.validateCurrentLanguage(); this.assertPluralRulesCoverage(this.currentLanguage); @@ -295,7 +297,6 @@ export class Streami18n< }); } catch (error) { this.logger(`Streami18n: initialization failed: ${describeError(error)}`); - this.state.partialNext({ initialized: true }); } return this.state.getLatestValue(); @@ -458,14 +459,32 @@ export class Streami18n< ); }; - /** Always true for a supplied parser -- we cannot inspect a foreign module's locale registry. */ + /** + * Checked against the module that actually formats the dates. + * + * A supplied dayjs copy has its own locale registry, so consulting ours would answer for the wrong + * one. True for a non-dayjs parser, whose registry we cannot inspect. + */ private localeExists = (language: string) => { - if (this.isCustomDateTimeParser) return true; - return dayjsLocaleExists(language); + if (!isDayjsLike(this.DateTimeParser)) return true; + return dayjsLocaleExists(language, this.DateTimeParser); }; + /** + * Registers a locale on the module that formats the dates, not on ours. + * + * Only dayjs has a registry we can write to. For a Moment the config cannot be applied at all, so it + * is reported -- previously it was written to core's own dayjs, where nothing would ever read it. + */ private addOrUpdateLocale(language: string, config: DayjsLocaleConfig) { - addOrUpdateDayjsLocale(language, config); + if (!isDayjsLike(this.DateTimeParser)) { + this.logger( + `Streami18n: a dayjs locale config was supplied for '${language}', but DateTimeParser is ` + + `not dayjs, so it cannot be applied. Configure the locale on your own date library instead.`, + ); + return; + } + addOrUpdateDayjsLocale(language, config, this.DateTimeParser); } /** diff --git a/src/i18n/dayjs.ts b/src/i18n/dayjs.ts index 32d13b5129..ab2c605d6b 100644 --- a/src/i18n/dayjs.ts +++ b/src/i18n/dayjs.ts @@ -41,11 +41,22 @@ export type CalendarFormats = { export type DayjsLocaleConfig = Partial & { calendar?: CalendarFormats }; /** - * Anything `ensureDayjsPlugins` can register plugins on: our own `dayjs`, or a module an integrator - * supplied through `DateTimeParser`. Method shorthand, so a real `typeof dayjs` satisfies it -- as a - * function property it would be checked contravariantly and rejected. + * The dayjs module surface this file drives: our own `dayjs`, or a module an integrator supplied + * through `DateTimeParser`. Method shorthand, so a real `typeof dayjs` satisfies it -- as function + * properties these would be checked contravariantly and rejected. + * + * `Ls`, `locale` and `updateLocale` are optional because a module only has them once the plugins are + * registered, and because a non-dayjs parser (a Moment) has a different shape entirely. */ -type DayjsExtendable = object & { extend?(plugin: unknown, option?: unknown): unknown }; +type DayjsExtendable = object & { + extend?(plugin: unknown, option?: unknown): unknown; + Ls?: Record; + locale?(preset: unknown, object?: unknown, isLocal?: boolean): unknown; + updateLocale?(name: string, config: unknown): unknown; +}; + +/** The module locale helpers default to, when a caller does not name one. */ +const ownDayjs = () => Dayjs as unknown as DayjsExtendable; /** * The English locale skeleton a custom locale is merged over, so a partial config still has month and @@ -160,19 +171,34 @@ export const getDefaultDateTimeParserModule = (): DateTimeParserModule => { return Dayjs as unknown as DateTimeParserModule; }; -/** Registers or updates a dayjs locale without changing the global locale. */ -export const addOrUpdateDayjsLocale = (language: string, config: DayjsLocaleConfig) => { - ensureDayjsPlugins(); - if (dayjsLocaleExists(language)) { - Dayjs.updateLocale(language, { ...config }); +/** + * Registers or updates a dayjs locale without changing the global locale. + * + * Takes the module to register on, defaulting to ours. Passing it matters for the same reason + * {@link ensureDayjsPlugins} takes one: an integrator supplying `DateTimeParser` may hand over a second + * physical copy of dayjs, and registering on ours would leave the locale absent from the module that + * actually formats the dates -- so a `calendar` config or a `dayjsLocaleConfigForLanguage` would be + * silently ignored. + */ +export const addOrUpdateDayjsLocale = ( + language: string, + config: DayjsLocaleConfig, + module: DayjsExtendable = ownDayjs(), +) => { + ensureDayjsPlugins(module); + + if (dayjsLocaleExists(language, module)) { + module.updateLocale?.(language, { ...config }); return; } // Merged over the English skeleton so missing keys still resolve. - Dayjs.locale({ name: language, ...EN_LOCALE_FALLBACK, ...config }, undefined, true); + module.locale?.({ name: language, ...EN_LOCALE_FALLBACK, ...config }, undefined, true); }; -export const dayjsLocaleExists = (language: string) => - Object.keys(Dayjs.Ls).includes(language); +export const dayjsLocaleExists = ( + language: string, + module: DayjsExtendable = ownDayjs(), +) => Object.keys(module.Ls ?? {}).includes(language); /** * Whether a parser is dayjs, as opposed to a Moment the integrator brought. diff --git a/src/i18n/formatters.ts b/src/i18n/formatters.ts index 7fda16b2b6..e5aa537675 100644 --- a/src/i18n/formatters.ts +++ b/src/i18n/formatters.ts @@ -47,6 +47,15 @@ const DEFAULT_RELATIVE_COMPACT_MAX_WEEKS = 3; */ const isUnparseableDateString = (value: string) => Number.isNaN(Date.parse(value)); +/** + * Coerces the week-rounding option, which arrives as text from an i18next format expression. + * + * Anything unrecognised falls back to `floor`, so a typo degrades to the default rather than throwing + * inside a formatter, where the only visible symptom would be a blank timestamp. + */ +const asWeekRounding = (value: unknown): 'ceil' | 'floor' => + value === 'ceil' || value === true ? 'ceil' : 'floor'; + /** * Coerces a numeric formatter option. * @@ -99,12 +108,14 @@ const relativeCompactDateString = ({ tDateTimeParser, timestamp, translate, + weekRounding, }: { maxDays: number; maxWeeks: number; tDateTimeParser: TDateTimeParser; timestamp: string | Date; translate: LooseTranslateFunction; + weekRounding: 'ceil' | 'floor'; }): string | null => { const parsed = tDateTimeParser(timestamp as string); if (!isDayOrMoment(parsed)) return null; @@ -136,8 +147,15 @@ const relativeCompactDateString = ({ // `maxWeeks > 0` and a full week elapsed, both required: with `maxWeeks: 0` a 3-day-old timestamp // has `Math.floor(3 / 7) === 0`, which would otherwise match and render "0w ago". - const weeksAgo = Math.floor(daysAgo / 7); - if (maxWeeks > 0 && daysAgo >= 7 && weeksAgo <= maxWeeks) { + // + // The two roundings bound the window differently, which is the whole reason both exist: `floor` + // stops on the week *count*, `ceil` on the day count. See `relativeCompactWeekRounding`. + const weeksAgo = + weekRounding === 'ceil' ? Math.ceil(daysAgo / 7) : Math.floor(daysAgo / 7); + const withinWindow = + weekRounding === 'ceil' ? daysAgo <= maxWeeks * 7 : weeksAgo <= maxWeeks; + + if (maxWeeks > 0 && daysAgo >= 7 && withinWindow) { return translate('relativeTime.weeksAgo', { count: weeksAgo, defaultValue_one: RELATIVE_TIME_CATALOG['relativeTime.weeksAgo_one'], @@ -158,6 +176,7 @@ const timestampFormatter: FormatterFactory = relativeCompact, relativeCompactMaxDays, relativeCompactMaxWeeks, + relativeCompactWeekRounding, } = options as TimestampFormatterOptions; // Nothing renderable: empty rather than the stringified value. `null` used to come out as the @@ -173,6 +192,7 @@ const timestampFormatter: FormatterFactory = tDateTimeParser, timestamp: value, translate, + weekRounding: asWeekRounding(relativeCompactWeekRounding), }); if (relative !== null) return relative; } @@ -258,6 +278,7 @@ export const getDateString = ({ relativeCompact, relativeCompactMaxDays, relativeCompactMaxWeeks, + relativeCompactWeekRounding, t, tDateTimeParser, timestampTranslationKey, @@ -281,6 +302,7 @@ export const getDateString = ({ tDateTimeParser, timestamp: messageCreatedAt, translate: t, + weekRounding: asWeekRounding(relativeCompactWeekRounding), }); if (relative) return relative; } @@ -301,6 +323,7 @@ export const getDateString = ({ relativeCompact, relativeCompactMaxDays, relativeCompactMaxWeeks, + relativeCompactWeekRounding, }; for (const [key, value] of Object.entries(supplied)) { if (value !== undefined) overrides[key] = value; diff --git a/src/i18n/types.ts b/src/i18n/types.ts index a419b27586..180eaa9fb8 100644 --- a/src/i18n/types.ts +++ b/src/i18n/types.ts @@ -338,6 +338,18 @@ export type TimestampFormatterOptions = { relativeCompact?: boolean; relativeCompactMaxDays?: number; relativeCompactMaxWeeks?: number; + /** + * How a "weeks ago" label rounds, and what bounds the window. + * + * `floor` (the default) reports whole elapsed weeks and stops once that count passes + * `relativeCompactMaxWeeks` — 8 days is "1w ago", 27 days is "3w ago". + * + * `ceil` rounds up and bounds on *days* instead, stopping after `relativeCompactMaxWeeks * 7` — 8 + * days is "2w ago", and 22 days falls through to a date. It exists because that is what + * `stream-chat-react` rendered before its formatter moved here, and changing those labels is a + * visible UI change rather than a refactor. New call sites should prefer `floor`. + */ + relativeCompactWeekRounding?: 'ceil' | 'floor'; }; export type DurationFormatterOptions = { diff --git a/test/unit/i18n/Streami18n.test.ts b/test/unit/i18n/Streami18n.test.ts index d2240beab4..e7cc95009c 100644 --- a/test/unit/i18n/Streami18n.test.ts +++ b/test/unit/i18n/Streami18n.test.ts @@ -678,32 +678,79 @@ describe('Streami18n — a failed setLanguage rolls back', () => { }); /** - * A rejected `init()` must not be latched: both UI SDKs call `init()` without awaiting it, so a - * permanently rejected memo leaves the instance uninitialized for the process lifetime. + * A failed `init()` leaves the instance degraded but *safe*. + * + * Neither UI SDK awaits `init()`, so it must never reject. And `initialized` must stay false, because + * it means "i18next is usable" -- `registerTranslation` and `setLanguage` both branch on it, and a + * `true` there sends them into an instance whose own init rejected. */ -describe('Streami18n — init() is retryable after a genuine failure', () => { +describe('Streami18n — a failed init()', () => { beforeEach(() => { vi.useRealTimers(); }); - it('recovers when the cause is gone', async () => { - let shouldThrow = true; - const i18n = setup({ - logger: (message?: string) => { - if (shouldThrow) throw new Error('logger exploded'); - void message; - }, - // Unregistered, so `validateCurrentLanguage` logs -- and the logger throws. - language: 'de', + const failing = (logger = () => {}) => { + const i18n = setup({ logger }); + vi.spyOn(i18n.i18nInstance, 'init').mockRejectedValue(new Error('i18next exploded')); + return i18n; + }; + + it('resolves rather than rejecting, and reports the failure', async () => { + const logger = vi.fn(); + const i18n = failing(logger); + + await expect(i18n.init()).resolves.toBeDefined(); + expect(logger).toHaveBeenCalledWith( + expect.stringContaining('initialization failed: i18next exploded'), + ); + }); + + it('leaves `initialized` false', async () => { + const i18n = failing(); + const state = await i18n.init(); + + expect(state.initialized).toBe(false); + expect(i18n.initialized).toBe(false); + }); + + it('keeps rendering the inline English copy', async () => { + const i18n = failing(); + const { t } = await i18n.init(); + + expect(t('fixture.prose', 'Cancel')).toBe('Cancel'); + }); + + /** The bug this guards: `addResources` on a dead instance threw out of `registerTranslation`. */ + it('does not throw from registerTranslation or setLanguage', async () => { + const i18n = failing(); + await i18n.init(); + + expect(() => + i18n.registerTranslation('de', { 'fixture.prose': 'Abbrechen' } as never), + ).not.toThrow(); + await expect(i18n.setLanguage('de')).resolves.toBeUndefined(); + }); + + /** + * The one path that escapes, recorded rather than guarded. + * + * The logger is called from the `catch`, so a logger that throws rejects out of `init()`. Both UI + * SDKs call `init()` without awaiting it, so that surfaces as an unhandled rejection — worth knowing + * before supplying a logger that can throw. + */ + it('rejects when the logger itself throws', async () => { + const i18n = failing(() => { + throw new Error('logger exploded'); }); await expect(i18n.init()).rejects.toThrow('logger exploded'); expect(i18n.initialized).toBe(false); + }); +}); - shouldThrow = false; - const state = await i18n.init(); - - expect(state.initialized).toBe(true); +describe('Streami18n — init() memoization', () => { + beforeEach(() => { + vi.useRealTimers(); }); it('hands the same promise to concurrent callers on the happy path', async () => { @@ -715,3 +762,113 @@ describe('Streami18n — init() is retryable after a genuine failure', () => { expect(i18n.init()).toBe(first); }); }); + +/** + * Locale configuration has to reach the module that formats the dates. + * + * `ensureDayjsPlugins` was fixed to extend a supplied module, but locale registration still wrote to + * core's own dayjs — so a second physical copy got the plugins and none of the `calendar` wording, and + * `dayjsLocaleConfigForLanguage` was silently inert. + */ +describe('Streami18n — locale config on a supplied dayjs module', () => { + beforeEach(() => { + vi.useRealTimers(); + }); + + /** A stand-in for a second dayjs copy: its own `Ls` registry, and it records writes. */ + const makeDayjsCopy = () => { + const registered: Array<{ method: string; name: string }> = []; + const parser = ((input?: unknown) => ({ + calendar: () => 'calendar', + diff: () => 0, + format: () => String(input), + locale: () => parser(input), + startOf: () => ({ diff: () => 0 }), + valueOf: () => 0, + })) as unknown as DateTimeParserModule & Record; + + parser.extend = () => parser; + parser.Ls = { en: {} }; + parser.locale = (preset: { name: string }) => { + registered.push({ method: 'locale', name: preset.name }); + (parser.Ls as Record)[preset.name] = preset; + return parser; + }; + parser.updateLocale = (name: string) => { + registered.push({ method: 'updateLocale', name }); + return parser; + }; + + return { parser, registered }; + }; + + const calendar = { + lastDay: '[Gestern]', + lastWeek: 'dddd', + nextDay: '[Morgen]', + nextWeek: 'dddd [um] LT', + sameDay: '[Heute]', + sameElse: 'L', + }; + + it('registers a constructor-supplied locale config on that module', () => { + const { parser, registered } = makeDayjsCopy(); + + setup({ + DateTimeParser: parser, + dayjsLocaleConfigForLanguage: { calendar }, + language: 'de', + }); + + expect(registered).toEqual([{ method: 'locale', name: 'de' }]); + }); + + it('registers a registerTranslation locale config on that module', async () => { + const { parser, registered } = makeDayjsCopy(); + const i18n = setup({ DateTimeParser: parser }); + + i18n.registerTranslation('de', { 'fixture.prose': 'Abbrechen' } as never, { + calendar, + }); + // Locale configs are applied when the language becomes active, which needs an initialized + // instance -- `setLanguage` returns at its `initialized` guard otherwise. + await i18n.init(); + await i18n.setLanguage('de'); + + expect(registered.some(({ name }) => name === 'de')).toBe(true); + }); + + it('consults the supplied module when deciding whether a locale exists', () => { + const logger = vi.fn(); + const { parser } = makeDayjsCopy(); + + // `de` is absent from the supplied module's registry, so the missing-locale warning must fire -- + // it used to be suppressed unconditionally for any custom parser. + setup({ DateTimeParser: parser, language: 'de', logger }); + + expect(logger).toHaveBeenCalledWith( + expect.stringContaining("no dayjs locale is registered for 'de'"), + ); + }); + + it('reports rather than silently dropping a locale config for a non-dayjs parser', () => { + const logger = vi.fn(); + const momentish = ((input?: unknown) => ({ + diff: () => 0, + format: () => String(input), + startOf: () => ({ diff: () => 0 }), + valueOf: () => 0, + })) as unknown as DateTimeParserModule; + + setup({ + DateTimeParser: momentish, + dayjsLocaleConfigForLanguage: { calendar }, + language: 'de', + logger, + }); + + expect(logger).toHaveBeenCalledWith( + expect.stringContaining('DateTimeParser is not dayjs, so it cannot be applied'), + ); + }); +}); diff --git a/test/unit/i18n/getDateString.test.ts b/test/unit/i18n/getDateString.test.ts index 8c94f7a737..6ff4dd237c 100644 --- a/test/unit/i18n/getDateString.test.ts +++ b/test/unit/i18n/getDateString.test.ts @@ -377,3 +377,66 @@ describe('timestampFormatter — malformed calendarFormats', () => { expect(logger).not.toHaveBeenCalled(); }); }); + +/** + * Week-label boundaries, both roundings. + * + * `floor` is what this module has always done and what `stream-chat-react-native` shipped. `ceil` is + * what `stream-chat-react` shipped before its formatter moved here, and the difference is user-visible + * at 8, 15 and 22 days — which is why the mode is explicit rather than chosen. + */ +describe('relativeCompact — week rounding', () => { + const NOW = new Date('2026-04-30T12:00:00.000Z'); + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + }); + + const render = (daysAgo: number, weekRounding?: 'ceil' | 'floor') => + getDateString({ + messageCreatedAt: new Date(NOW.getTime() - daysAgo * 24 * 3600 * 1000), + relativeCompact: true, + relativeCompactWeekRounding: weekRounding, + t: createDefaultTranslatorFunction(), + tDateTimeParser: defaultDateTimeParser, + }); + + // maxDays 6 / maxWeeks 3 are the defaults for both. + it.each([ + [7, '1w ago', '1w ago'], + [8, '1w ago', '2w ago'], + [13, '1w ago', '2w ago'], + [14, '2w ago', '2w ago'], + [15, '2w ago', '3w ago'], + [21, '3w ago', '3w ago'], + ])('%i days ago — floor %s, ceil %s', (daysAgo, floorLabel, ceilLabel) => { + expect(render(daysAgo, 'floor')).toBe(floorLabel); + expect(render(daysAgo, 'ceil')).toBe(ceilLabel); + }); + + it('bounds the window on the week count under floor, and on days under ceil', () => { + // 22-27 days: three whole weeks elapsed, so `floor` still labels them... + expect(render(22, 'floor')).toBe('3w ago'); + expect(render(27, 'floor')).toBe('3w ago'); + // ...while `ceil` has already passed maxWeeks * 7 = 21 days and falls through to a date. + expect(render(22, 'ceil')).toMatch(/^\d{2}\/\d{2}\/\d{2}$/); + expect(render(27, 'ceil')).toMatch(/^\d{2}\/\d{2}\/\d{2}$/); + }); + + it('falls through to a date once both roundings are past the window', () => { + expect(render(28, 'floor')).toMatch(/^\d{2}\/\d{2}\/\d{2}$/); + expect(render(28, 'ceil')).toMatch(/^\d{2}\/\d{2}\/\d{2}$/); + }); + + it("defaults to floor, so an unset option keeps this module's long-standing behaviour", () => { + expect(render(8)).toBe('1w ago'); + expect(render(22)).toBe('3w ago'); + }); + + it('reads the option as text, the way an i18next expression supplies it', () => { + expect(render(8, 'ceil' as 'ceil')).toBe('2w ago'); + // Anything unrecognised degrades to the default rather than throwing inside the formatter. + expect(render(8, 'nonsense' as unknown as 'ceil')).toBe('1w ago'); + }); +}); From bb5be46d185ba54c23dd37c4213983eea5e6896c Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Thu, 20 Aug 2026 12:57:14 +0200 Subject: [PATCH 27/27] docs(i18n): correct the commandDisabled metadata.reason values The JSDoc named `'editing' | 'replying'`, but `getCommandDisabledReason` returns `'editing' | 'quoted_message'`. `stream-chat-react`'s translator already switches on the real values, so only the comment was wrong -- and it is the comment a UI SDK reads when deciding which branches its translator needs. --- src/notifications/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/notifications/types.ts b/src/notifications/types.ts index 87adf348a6..637898c0c2 100644 --- a/src/notifications/types.ts +++ b/src/notifications/types.ts @@ -42,7 +42,7 @@ export const CORE_NOTIFICATION_TYPE = { attachmentUploadBlocked: 'validation:attachment:upload:blocked', attachmentUploadFailed: 'api:attachment:upload:failed', attachmentUploadInProgress: 'validation:attachment:upload:in-progress', - /** Carries `metadata.reason` (`'editing' | 'replying'`), which the message depends on. */ + /** Carries `metadata.reason` (`'editing' | 'quoted_message'`), which the message depends on. */ commandDisabled: 'validation:command:disabled', commandNotReady: 'validation:command:not-ready', locationCreateFailed: 'api:location:create:failed',