Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions ai-docs/ai-migration-v14-v15.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,102 @@ To ingest an ad-hoc channel (e.g. navigating to a DM or search result) into the

`Channel` no longer reflects the channel-list query state. Its loading / error / empty rendering is driven by the channel's own `watch()` bootstrap (`LoadingIndicator` while watching, `LoadingErrorIndicator` on watch failure, `EmptyPlaceholder` when no channel is provided). The channel-list query state is the `ChannelList`'s concern, not `Channel`'s.

## Dates on response types are unix-nanosecond numbers

`stream-chat` now types every **server-sent** date as the unix-nanosecond `number` the API puts on the
wire — `created_at`, `updated_at`, `last_read`, and every sibling on a response or event. It is not a
`Date` and not an ISO string, and the React types that carry those values through changed with it.

Two failure modes, neither of which is a type error:

- **Every `Date`-based path is out of range.** `Date` tops out near 8.64e15 ms while a current
timestamp is ~1.79e18, and a date library reads a bare number as **milliseconds** — so both land on
an invalid instance rather than on a plausible wrong date. `.toISOString()` throws
`RangeError: Invalid time value`, usually mid-render; `dayjs(created_at).format()` instead returns
the literal string `Invalid Date` and renders it on screen.
- **A unit mix-up between two `number`s is the silent one.** Comparing a wire timestamp against
`Date.now()`, or adding a millisecond duration to one, produces a plausible-looking number and no
complaint at all — see `headerPosition` below for a case with no type change to warn you.

### The public React types that changed

| Type | v14 | v15 |
| ----------------------------------------------------- | ----------------------------- | ------------------------------- |
| `ChatContextValue.latestMessageDatesByChannels` | `Record<ChannelConfId, Date>` | `Record<ChannelConfId, number>` |
| `ProcessMessagesParams.lastRead` (`processMessages`) | `Date \| null` | `number \| null` |
| `VirtualizedMessageList` render props: `lastReadDate` | `Date \| null` | `number \| null` |

`DateSeparatorMessage` (a member of the exported `RenderedMessage` union) changed shape rather than
type: it **lost its `type: MessageLabel` field**, and `unread` is now optional. The `type` field was
never actually populated — every construction site cast the object into place without it — so reading
it was already `undefined` at runtime; it now fails to compile. `unread` is set only by the unread
separator; the plain day divider omits it. Narrow with `isDateSeparatorMessage` rather than checking
either field.

Comparisons get simpler, not harder — compare and sort the raw numbers and drop the `Date` round-trip:

```ts
// v14
if (latestMessageDatesByChannels[cid].getTime() < new Date(message.created_at).getTime()) { … }

// v15
if (latestMessageDatesByChannels[cid] < message.created_at) { … }
```

### Presentational props still take `Date`

The conversion boundary is where core data enters the component tree, so components that exist to
_render_ a date are unchanged — `DateSeparator`'s `date: Date` and `formatDate?: (date: Date) => string`,
for instance. Convert at that boundary with the guarded helper `stream-chat` exports:

`convertTimestampToDate` returns `Date | undefined` — `undefined` for an absent or non-finite value.
**Handle that `undefined`; do not cast it away.** A prop typed `date: Date` will accept it through a
cast and then fail somewhere further along: `isDateSeparatorMessage` (`src/components/MessageList/utils.ts`)
gates on `isDate(message.date)`, so the list stops recognising the object as a separator and renders it
as an ordinary message — an empty row where the day divider belonged, with no error and no type error.

```ts
import { convertTimestampToDate } from 'stream-chat';

const createdAt = convertTimestampToDate(message.created_at);

// Render nothing when there is no usable timestamp.
{createdAt ? <DateSeparator date={createdAt} /> : null}
```

```ts
// WRONG — the cast launders `undefined` into a required `Date`.
<DateSeparator date={convertTimestampToDate(message.created_at) as Date} />
// WRONG — invents "now", labelling a months-old message "Today".
<DateSeparator date={convertTimestampToDate(message.created_at) ?? new Date()} />
```

`nsToDate` / `dateToNs` / `nsToMs` / `msToNs` / `nowNs` are exported alongside it for values known to be
present. Note that **outgoing request** date fields are still `Date` (filter bounds like
`created_at_before`, plus `remind_at` and `message_timestamp`) — `JSON.stringify` emits RFC3339 for a
`Date`, which is what the request spec declares. Use `nsToDate` when handing a server-sent timestamp
back to the API.

### `MessageList`'s `headerPosition` prop changed unit, not type

`headerPosition` is compared against `message.created_at`, so it is now **unix nanoseconds** — it was
epoch milliseconds while `created_at` was a `Date`. The type is still `number`, so nothing warns.

### Peer-dependency gate before release

The SDK imports `convertTimestampToDate` / `nsToDate` / `nsToMs` from `stream-chat`, which only exist
from the version that ships `utils/time`. Until that is published, `package.json` pins
`stream-chat` exactly and the workspace resolves it through a local `portal:` — so a green local build
says nothing about whether a consumer can resolve these imports. Before publishing, widen the peer
range to the version that exports them and verify from a clean install with no `portal:` override.

### Test fixtures have to model the wire

A fixture that hands the SDK a `Date` cannot catch either failure mode above, and will diverge from
runtime behavior. The SDK's own suite normalizes through
`mock-builders/generator/time.ts` (`convertDateToTimestamp`), which accepts a `Date`, an ISO string or a
raw wire number so tests stay readable while the value on the wire stays a number.

## i18n: English-only bundle, namespaced translation keys

Two breaking changes, both of which fail **silently** — no error, no compile break unless the app
Expand Down
7 changes: 7 additions & 0 deletions ai-docs/i18n-v15-migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,13 @@ find . -maxdepth 4 -name dayjs -type d -path '*node_modules*' # expect exactly

## Date and time

> **Before anything on this page:** every timestamp you hand a formatter is now a unix-**nanosecond**
> number, and the `t('timestamp.X', { timestamp })` path is **not type-checked** — i18next's
> interpolation bag is untyped, so a raw wire number compiles and renders the literal text
> `Invalid Date`. `getDateString`'s `messageCreatedAt` _is_ typed (`string | Date`). If a timestamp is
> rendering wrong or blank, check the conversion first; see
> [Dates on response types are unix-nanosecond numbers](./ai-migration-v14-v15.md#dates-on-response-types-are-unix-nanosecond-numbers).

Only the `en` dayjs locale is bundled, and the per-language `calendar` formats the SDK used to ship
are gone. For any other language, import the locale and supply the calendar config:

Expand Down
3 changes: 2 additions & 1 deletion examples/vite/docs-playwright/screenshot-misc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,8 @@ async function captureCustomNotification(browser: any) {
cid: ch.cid,
channel_id: ch.id,
channel_type: ch.type,
message: { ...msg, text: msg.text, message_text_updated_at: new Date().toISOString() },
// Unix nanoseconds, as the wire carries it; no imports are available in page.evaluate.
message: { ...msg, text: msg.text, message_text_updated_at: Date.now() * 1e6 },
});
}
})()`);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ async function main() {
const injectSystemMessage = `(async () => {
var ch = window.channel;
var client = window.client;
var now = new Date().toISOString();
var now = Date.now() * 1e6; // server-sent dates are unix nanoseconds
var msg = {
id: 'system-msg-' + Date.now(),
text: '/mute @${USER_B}',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { nowNs } from 'stream-chat';
import type {
Channel,
ChannelMemberResponse,
Expand Down Expand Up @@ -97,9 +98,7 @@ const buildReactionState = ({
typeof reaction.score === 'number' && Number.isFinite(reaction.score)
? reaction.score
: 1;
const reactionTimestamp = reaction.created_at
? new Date(reaction.created_at)
: new Date();
const reactionTimestamp = reaction.created_at ?? nowNs();

return {
latest_reactions: [reaction],
Expand Down Expand Up @@ -160,7 +159,7 @@ const buildFreshContext = (
simulationState: SimulationState,
): WebSocketEventTemplateContext => {
const sequence = simulationState.nextSequence;
const createdAt = new Date().toISOString();
const createdAt = nowNs();
const channelMembers = getChannelMembersForCid(
templateContext.cid,
simulationState,
Expand Down Expand Up @@ -389,12 +388,12 @@ export const buildFreshWebSocketEventPayload = ({
created_at: freshContext.createdAt,
message: {
...baseMessage,
created_at: new Date(freshContext.createdAt),
created_at: freshContext.createdAt,
html: `<p>${text}</p>\n`,
id: messageId,
member,
text,
updated_at: new Date(freshContext.createdAt),
updated_at: freshContext.createdAt,
user,
},
message_id: messageId,
Expand All @@ -412,14 +411,14 @@ export const buildFreshWebSocketEventPayload = ({
const reactionScore = eventType === 'reaction.updated' ? 2 : 1;
const reaction = {
...baseReaction,
// `dispatchEvent` receives an already-parsed `Event`, so timestamps are `Date`s here
// (only the raw wire format uses ISO strings).
created_at: new Date(freshContext.createdAt),
// Server-sent dates are unix-nanosecond numbers everywhere now — on the raw wire frame and
// on the parsed `Event` that `dispatchEvent` receives alike.
created_at: freshContext.createdAt,
// v10 requires `custom` on reaction responses.
custom: {},
message_id: messageId,
type: reactionType,
updated_at: new Date(freshContext.createdAt),
updated_at: freshContext.createdAt,
user,
user_id: user.id,
score: reactionScore,
Expand All @@ -437,7 +436,7 @@ export const buildFreshWebSocketEventPayload = ({
...baseMessage,
id: messageId,
member,
updated_at: new Date(freshContext.createdAt),
updated_at: freshContext.createdAt,
user,
...buildReactionState({ reaction }),
},
Expand Down Expand Up @@ -472,7 +471,7 @@ export const buildFreshWebSocketEventPayload = ({
...baseMessage,
id: messageId,
member,
updated_at: new Date(freshContext.createdAt),
updated_at: freshContext.createdAt,
user,
},
user,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { msToNs, nowNs } from 'stream-chat';
import type {
Channel,
ChannelMemberResponse,
Expand Down Expand Up @@ -101,8 +102,10 @@ export type WebSocketEventTemplateContext = {
channelName: string;
channelType: string;
cid: string;
createdAt: string;
lastReadAt: string;
/** Unix nanoseconds, the unit every server-sent date uses on the wire. */
createdAt: number;
/** Unix nanoseconds, the unit every server-sent date uses on the wire. */
lastReadAt: number;
memberCount: number;
messageId: string;
otherMember: ChannelMemberResponse;
Expand All @@ -120,7 +123,7 @@ type BuildChannelSeedContext = Omit<WebSocketEventTemplateContext, 'channel'> &
channel: Partial<DebugChannelResponse>;
};

const createFallbackUser = (id: string, createdAt: Date): DebugUserResponse => ({
const createFallbackUser = (id: string, createdAt: number): DebugUserResponse => ({
banned: false,
blocked_user_ids: [],
created_at: createdAt,
Expand All @@ -141,9 +144,9 @@ const getUserId = (user: DebugUserResponse) =>
typeof user.id === 'string' ? user.id : 'debug-user';

const createMember = (user: DebugUserResponse): ChannelMemberResponse => {
// `user.created_at` is typed as `Date` in v10, but this builder also receives raw event/JSON
// payloads where it may still be a string — normalize either form to a `Date`.
const createdAt = user.created_at ? new Date(user.created_at) : new Date();
// Every date on a response type is already a unix-nanosecond number, so there is nothing to
// normalize — only a fallback for the builders that hand over a user with no timestamps.
const createdAt = user.created_at ?? nowNs();

return {
banned: false,
Expand Down Expand Up @@ -178,9 +181,9 @@ const buildChannel = (
context: BuildChannelSeedContext,
overrides: JsonObject = {},
): DebugChannelResponse => {
// `context.createdAt` stays an ISO string (event payloads carry strings), but the
// `ChannelResponse`/config timestamps below are typed as `Date` in v10.
const createdAt = new Date(context.createdAt);
// Wire timestamps all the way through: the event payload and the `ChannelResponse`/config
// fields below all carry the same unix-nanosecond number.
const createdAt = context.createdAt;

return {
cid: context.cid,
Expand Down Expand Up @@ -228,6 +231,8 @@ const buildChannel = (
delivery_events: true,
mark_messages_pending: false,
max_message_length: 5000,
// Required on `ChannelConfigWithInfo`; the date error above used to mask its absence.
message_retention: 'infinite',
mutes: true,
name: context.channelType,
polls: true,
Expand Down Expand Up @@ -425,7 +430,7 @@ const buildReactionState = ({
latestReactions: JsonObject[];
reactionType: string;
score: number;
timestamp: string;
timestamp: number;
}): JsonObject => ({
latest_reactions: latestReactions,
reaction_counts: {
Expand Down Expand Up @@ -564,7 +569,7 @@ const buildPollWithAnswerComment = (
context: WebSocketEventTemplateContext,
answerText: string,
) => {
const answerCreatedAt = new Date(Date.now() - 60_000).toISOString();
const answerCreatedAt = nowNs() - msToNs(60_000);
const pollVote = buildPollAnswerVote(context, answerText, {
created_at: answerCreatedAt,
updated_at: context.createdAt,
Expand Down Expand Up @@ -768,14 +773,13 @@ export const createWebSocketEventTemplateContext = ({
channel?: Channel;
client: StreamChat;
}): WebSocketEventTemplateContext => {
// Kept as an ISO string on the context (event payloads carry string timestamps), with the `Date`
// form on hand for the response-shaped builders that v10 types as `Date`.
const createdAtDate = new Date();
const createdAt = createdAtDate.toISOString();
// One unit for the whole context: unix nanoseconds, which is what event payloads and the
// response-shaped builders both carry now that the SDK does no date decoding.
const createdAt = nowNs();
const actorUser =
client.user && typeof client.user === 'object'
? ({ ...client.user } as DebugUserResponse)
: createFallbackUser('debug-user', createdAtDate);
: createFallbackUser('debug-user', createdAt);
const actorId = typeof actorUser.id === 'string' ? actorUser.id : 'debug-user';

const members = channel ? Object.values(channel.state.members) : [];
Expand All @@ -792,7 +796,7 @@ export const createWebSocketEventTemplateContext = ({
const otherUser =
otherMemberFromChannel?.user && typeof otherMemberFromChannel.user === 'object'
? ({ ...otherMemberFromChannel.user } as DebugUserResponse)
: createFallbackUser('debug-other-user', createdAtDate);
: createFallbackUser('debug-other-user', createdAt);
const otherMember = otherMemberFromChannel
? ({ ...otherMemberFromChannel } as ChannelMemberResponse)
: createMember(otherUser);
Expand Down Expand Up @@ -1011,7 +1015,7 @@ export const websocketEventTemplateDefinitions = {
buildBaseEvent(context, 'message.delivered', {
channel_custom: { name: context.channelName },
channel_member_count: context.memberCount,
last_delivered_at: context.createdAt.replace(/\.\d+Z$/, 'Z'),
last_delivered_at: context.createdAt,
last_delivered_message_id: context.messageId,
user: context.otherUser,
}),
Expand Down Expand Up @@ -1227,7 +1231,7 @@ export const websocketEventTemplateDefinitions = {
},
'poll.vote_changed': {
buildDefault: (context) => {
const originalCreatedAt = new Date(Date.now() - 60_000).toISOString();
const originalCreatedAt = nowNs() - msToNs(60_000);
const answerText = 'Some new comment X';
const pollVote = buildPollAnswerVote(context, answerText, {
created_at: originalCreatedAt,
Expand Down Expand Up @@ -1397,21 +1401,15 @@ export const websocketEventTemplateDefinitions = {
'typing.start': {
buildDefault: (context) =>
buildBaseEvent(context, 'typing.start', {
channel_last_message_at:
typeof context.channel.last_message_at === 'string'
? context.channel.last_message_at
: context.createdAt,
channel_last_message_at: context.channel.last_message_at ?? context.createdAt,
user: context.actor,
}),
description: 'Start typing in the active channel.',
},
'typing.stop': {
buildDefault: (context) =>
buildBaseEvent(context, 'typing.stop', {
channel_last_message_at:
typeof context.channel.last_message_at === 'string'
? context.channel.last_message_at
: context.createdAt,
channel_last_message_at: context.channel.last_message_at ?? context.createdAt,
user: context.actor,
}),
description: 'Stop typing in the active channel.',
Expand All @@ -1422,7 +1420,7 @@ export const websocketEventTemplateDefinitions = {
channel_custom: { name: context.channelName },
channel_member_count: context.memberCount,
created_by: context.actor,
expiration: new Date(Date.now() + 60 * 60_000).toISOString(),
expiration: nowNs() + msToNs(60 * 60_000),
reason: 'because',
user: context.otherUser,
}),
Expand Down Expand Up @@ -1714,15 +1712,15 @@ const websocketEventPresetDefinitions = {
created_at: context.createdAt,
message_id: context.messageId,
reminder: buildReminderPayload(context, {
remind_at: new Date(Date.now() + 2 * 60_000).toISOString(),
remind_at: nowNs() + msToNs(2 * 60_000),
}),
type: 'reminder.created',
user_id: context.actorId,
}),
},
'reminder.deleted.timed': {
buildDefault: (context: WebSocketEventTemplateContext) => {
const remindAt = new Date(Date.now() + 2 * 60_000).toISOString();
const remindAt = nowNs() + msToNs(2 * 60_000);

return {
cid: context.cid,
Expand Down
Loading
Loading