Skip to content
Merged
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
2 changes: 1 addition & 1 deletion examples/tutorial/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"emoji-mart": "^5.6.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"stream-chat": "10.0.0-rc.7",
"stream-chat": "10.0.0-rc.9",
"stream-chat-react": "workspace:^"
},
"devDependencies": {
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
2 changes: 1 addition & 1 deletion examples/vite/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"modern-normalize": "^3.0.1",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"stream-chat": "10.0.0-rc.7",
"stream-chat": "10.0.0-rc.9",
"stream-chat-react": "workspace:^"
},
"devDependencies": {
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
Loading