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
6 changes: 4 additions & 2 deletions .claude/skills/perf-gauntlet/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,10 @@ are opened/closed by `devmark.sh` (`PERFMARK S|E <label> <device ms> <thread tic
device clock. Commit ids and name ids restart per app process: everything is keyed by pid.

Bundle-time changes (`gauntlet-transformer.js`, Metro config), identical on both sides:
- SampleApp `ChannelListScreen` `message_limit: 25` -> `100` (so an opened channel has 100 loaded messages without
a network round trip; the bundle FAILS if the pattern disappears: update `MESSAGE_LIMIT_FROM`).
- SampleApp channel list query `message_limit: 25` -> `100` (so an opened channel has 100 loaded messages without
a network round trip). The query is in `ChannelListScreen` on older trees and in `src/hooks/useChatClient.ts` once
the app owns the list's paginator; whichever file defines it is patched, and the bundle FAILS if that file lost
the pattern: update `MESSAGE_LIMIT_FROM`.
- static tokens for the bench users replace `src/ChatUsers` (`runtime/generated/`, gitignored, 7-day validity).
- `keep_fnames`/`keep_classnames`: without it the minifier mangles component names and attribution is wrong.
- Diagnostic builds only (`GAUNTLET_DIAG=1`): hook counts per mount, context-value changes per commit, and which
Expand Down
103 changes: 83 additions & 20 deletions ai-docs/ai-migration-v9-to-v10.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ rg '\b(sendMessage|SendMessageDisallowedIndicator)\b' src/
rg '\b(onAddedToChannel|onRemovedFromChannel|onChannelDeleted|onChannelHidden|onChannelVisible|onChannelUpdated|onChannelTruncated|onChannelMemberUpdated|onNewMessage|onNewMessageNotification|ChannelListEventHandler|useChannelUpdated|queryChannelsOverride)\b' src/
rg 'useChatContext\(\)' -A6 src/ | rg '\bchannelManager\b'
rg '<Chat\b' -A10 src/ | rg '\bchannelManager\b'
rg '<ChannelList\b' -A15 src/ | rg '\b(filters|sort|options|lockChannelOrder|queryChannelsOverride)\b'

# §L — connection recovery moved into the client
rg '\b(recoverState|recoverStateOnReconnect|preventThreadCleanup)\b' src/
Expand Down Expand Up @@ -186,8 +187,9 @@ means changed. Details in the linked section.
| initial message-list page size ~100 | 25 — override via `channel.messagePaginator.pageSize` | §16.1 |
| `sendMessage` errors swallowed | `sendMessage` throws — handle the rejection | §16.2 |
| `stream-chat` v9 | `stream-chat` `^10.x` required | §17 |
| `<ChannelList onNewMessage / onAddedToChannel / …>` event callbacks | `client.channelManager.addEventHandler(...)` / `client.on(...)`; membership+order via `filters`/`sort` | §18 |
| `queryChannelsOverride` returning `Channel[]` | now the paginator `doRequest` → `return { items }` | §18.1 |
| `<ChannelList onNewMessage / onAddedToChannel / …>` event callbacks | `client.channelManager.addEventHandler(...)` / `client.on(...)`; membership+order via the paginator's `filters`/`sort` | §18 |
| `<ChannelList filters sort options lockChannelOrder>` | `<ChannelList paginator>` with an app-owned `ChannelPaginator` registered on `client.channelManager` | §18.6 |
| `queryChannelsOverride` returning `Channel[]` | removed - the paginator's `paginatorOptions.doRequest` → `return { items }` | §18.1 |
| `useChannelUpdated()` | removed — `channel.updated` is handled by the orchestrator | §18.2 |
| `loadNextPage(filters, sort, options)` | `loadNextPage()` (no args) | §18.3 |
| `<Chat channelManager={…}>` / `useChatContext().channelManager` | `client.channelManager` | §18.4 |
Expand Down Expand Up @@ -1042,8 +1044,8 @@ the key names.
and `messageOperations`. Register the value, or use a setup function.
- **`X.config` is `Readonly`.** Assigning to a field is a compile error; nested writes
throw at runtime because the defaults are deep-frozen. Use `updateConfig()`.
- **`<ChannelList>`'s `lockChannelOrder` and `queryChannelsOverride` stay props.** There is
no configuration key that reaches a channel-list paginator, so these are unchanged.
- **No configuration key reaches a channel-list paginator.** `lockItemOrder` and `doRequest` live on
the app-owned `ChannelPaginator` (§18.6): set them in its constructor or with `updateConfig()`.

---

Expand Down Expand Up @@ -1402,8 +1404,8 @@ already writes it from `enableOfflineSupport`. To set it yourself, use
# Part J — `ChannelList` & `ChannelManager` (orchestrator)

`<ChannelList>` now runs on `stream-chat` v10's client-owned orchestrator: `client.channelManager`
(one instance per client) plus one `ChannelPaginator` per list. The list is a **deterministic
projection of its `filters` + `sort`** — every query (initial load, pagination, pull-to-refresh,
(one instance per client) plus one `ChannelPaginator` per list, which the app creates and registers
(§18.6). The list is a **deterministic projection of its paginator's `filters` + `sort`** — every query (initial load, pagination, pull-to-refresh,
reconnect) re-asserts them, so the list can no longer silently drift from, or be forced to
contradict, its own query. Most of the removed API below existed to make the list disagree with its
query; that is intentionally gone (a footgun removed). What you actually need is served by `filters`,
Expand All @@ -1424,7 +1426,7 @@ expressible declaratively. Map what each override did to its v10 equivalent:
| The override did… | v10 |
|---|---|
| decide membership (keep/drop a channel) | `filters` — the query is the source of truth |
| decide order | `sort` (+ `lockChannelOrder` to freeze order across events) |
| decide order | `sort` (+ `paginatorOptions.lockItemOrder` to freeze order across events) |
| filter what renders without changing the query | `channelRenderFilterFn` (unchanged prop) |
| briefly float a channel to the top | `paginator.boost(cid, { ttlMs })` (survives queries) |
| genuinely global event handling (new event type, SDK-bug hotfix, side effect) | `client.channelManager.addEventHandler(...)` or `client.on(...)` |
Expand All @@ -1447,21 +1449,29 @@ const unsubscribe = client.channelManager.addEventHandler({
> restored the default — clobbering a still-mounted sibling. Global handlers belong on
> `client.channelManager`; per-list shaping stays in each list's `filters` / `sort`.

## 18.1 `queryChannelsOverride` retyped
## 18.1 `queryChannelsOverride` removed → `paginatorOptions.doRequest`

Still a prop, but it is now the paginator's `doRequest` rather than a `Channel[]`-returning function.
It receives the query params the paginator would have sent and must return `{ items }` — call
`client.queryChannels(...)` inside so client state stays in sync:
The prop is gone with the other query props (§18.6). Its replacement is the paginator's `doRequest`,
which receives the query params the paginator would have sent and must return `{ items }`, not a
`Channel[]`. Call `client.queryChannels(...)` inside so client state stays in sync:

```tsx
// v9: queryChannelsOverride = (filters, sort, options) => Promise<Channel[]>
// v9: <ChannelList queryChannelsOverride={(filters, sort, options) => Promise<Channel[]>} />
// v10:
queryChannelsOverride={async (queryParams) => {
const items = await client.queryChannels(queryParams);
return { items };
}}
const paginator = new ChannelPaginator({
client,
id: 'channels:main',
paginatorOptions: {
doRequest: async (queryParams) => {
const items = await client.queryChannels(queryParams);
return { items };
},
},
});
```

To swap it at runtime, call `paginator.updateConfig({ doRequest })`.

## 18.2 `useChannelUpdated` removed

The public `useChannelUpdated` hook is gone (it patched a `useState`-backed channel array that no
Expand Down Expand Up @@ -1509,6 +1519,59 @@ Configure the shared manager through its own API (`client.channelManager.setEven
client's watch count can never exceed what it already had, and blanket auto-watch never risks the
watch limit. To watch a specific channel yourself, call `channel.watch()`.

## 18.6 `<ChannelList>` takes an app-owned `paginator` (breaking)

`<ChannelList>` no longer creates its own `ChannelPaginator`. The app creates one, registers it on
`client.channelManager` and passes it as the required `paginator` prop. The props that configured the
SDK-created paginator are removed:

| Removed prop | On the app's `ChannelPaginator` |
|---|---|
| `filters` | `new ChannelPaginator({ filters })`, later `paginator.staticFilters = …` |
| `sort` | `new ChannelPaginator({ sort })`, later `paginator.sort = …` |
| `options` | `requestOptions` (`state`, `watch`, `presence`, `message_limit`, `predefined_filter`, `filter_values`, …); `options.limit` → `paginatorOptions.pageSize` |
| `lockChannelOrder` | `paginatorOptions.lockItemOrder`, later `paginator.updateConfig({ lockItemOrder })` |
| `queryChannelsOverride` | `paginatorOptions.doRequest` (§18.1) |

```tsx
// v9
<ChannelList filters={filters} sort={sort} options={options} onSelect={onSelect} />

// v10, next to `connectUser`: one paginator per connected user
const paginator = new ChannelPaginator({
client,
filters,
id: 'channel-list',
requestOptions: options,
sort,
});
client.channelManager.insertPaginator({ paginator });
await client.connectUser(user, token);

<ChannelList onSelect={onSelect} paginator={paginator} />;

// on logout
client.channelManager.removePaginator(paginator);
paginator.dispose();
await client.disconnectUser();
```

Behaviour:

- **Registration is the app's job.** A paginator that is not inserted into `client.channelManager`
loads, but receives no events, so the list does not update live.
- **The list loads the paginator's first page only when it has none** - on first mount, after
`client.disconnectUser()` resets it, or when a different paginator is passed. A list that mounts
again with the same paginator shows its channels without a query.
- **The list never removes or disposes the paginator.** Create it with the user, not in the list's
screen, so the list keeps its channels when the screen unmounts (for example while search results
replace it). Remove and dispose it on logout: `client.disconnectUser()` empties the registered
lists but keeps them registered.
- **Changing the query is a paginator change.** Either set `staticFilters` / `sort` / `options` on it
and call `paginator.reload()`, or create a new paginator with a new `id` and pass that instead. A
prop change no longer re-queries by itself.
- **Use a unique `id` per list.** The manager keys its lists by it.

---

# Part K — Unified channel state (`channel.state`)
Expand Down Expand Up @@ -2155,19 +2218,19 @@ The `SyncStatus` name family deliberately stays: `onSyncStatusChange`,
`ChannelPaginator` treated `sort` with `??`, which does not catch `[]`. An empty array is now read as
"unspecified" and resolves to `DEFAULT_BACKEND_SORT` — `[{ last_message_at: -1 }, { updated_at: -1 }]`.

`<ChannelList>` ships `DEFAULT_SORT = []`, so **this is the default integrator experience**, offline
support or not. Before, the comparator was built from zero sort fields: every comparison tied, the cid
A `ChannelPaginator` created without `sort` gets `[]`, so **this is the default integrator experience**,
offline support or not. Before, the comparator was built from zero sort fields: every comparison tied, the cid
tiebreaker decided, and the list held server order until the first live ingest re-sorted the whole
window alphabetically by cid. Now such a list is ordered by recency and reorders live.

Visible delta for a list with no `sort` prop:
Visible delta for a list whose paginator has no `sort`:

- a new message **moves its channel to the top**, where previously nothing moved
- the alphabetical-by-cid collapse on the first ingest is gone
- `paginator.sort` returns `DEFAULT_BACKEND_SORT` rather than the `[]` you passed, so an assertion of
`toStrictEqual([])` on it fails

**→** nothing to do if recency is what you wanted. Pass an explicit `sort` to `<ChannelList>` to pin a
**→** nothing to do if recency is what you wanted. Pass an explicit `sort` to the `ChannelPaginator` to pin a
different order — including a `pinned_at`-first sort if you want pinned channels to stay on top, which
the backend default does not do. Note that "sort by nothing" is not expressible and never was: the
server applies its own default whenever a request carries no sort, so honouring `[]` client-side could
Expand Down
37 changes: 34 additions & 3 deletions examples/ExpoChatGPTSample/contexts/AppContext.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,24 @@
import type { PropsWithChildren } from 'react';
import React, { useMemo, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';

import type { Channel, StreamChat } from 'stream-chat';
import { ChannelPaginator } from 'stream-chat';
import type { Channel, SortParamRequest, StreamChat } from 'stream-chat';

import { chatUserId } from '../chatConfig';

const channelListFilters = { members: { $in: [chatUserId] } };
const channelListSort: SortParamRequest[] = [{ direction: -1, field: 'last_updated' }];

export type AppContextValue = {
channel: Channel | undefined;
channelListPaginator: ChannelPaginator | undefined;
setChannel: (channel: Channel) => void;
};

export const AppContext = React.createContext<AppContextValue>({
setChannel: () => {},
channel: undefined,
channelListPaginator: undefined,
});

const ALPHABET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz-';
Expand All @@ -34,7 +40,32 @@ export const createChannel = (client: StreamChat) =>
export const AppProvider = ({ client, children }: PropsWithChildren<{ client: StreamChat }>) => {
const [channel, setChannel] = useState<Channel>(() => createChannel(client));

const contextValue = useMemo(() => ({ channel, setChannel }), [channel]);
// The app owns the channel list's paginator. It lives as long as the client, so the list keeps its
// channels when the drawer unmounts. `disconnectUser` empties the registered lists but keeps them
// registered, so it is removed when the client goes away.
const channelListPaginator = useMemo(
() =>
new ChannelPaginator({
client,
filters: channelListFilters,
id: 'channel-list',
sort: channelListSort,
}),
[client],
);

useEffect(() => {
client.channelManager.insertPaginator({ paginator: channelListPaginator });
return () => {
client.channelManager.removePaginator(channelListPaginator);
channelListPaginator.dispose();
};
}, [channelListPaginator, client]);

const contextValue = useMemo(
() => ({ channel, channelListPaginator, setChannel }),
[channel, channelListPaginator],
);

return <AppContext.Provider value={contextValue}>{children}</AppContext.Provider>;
};
Expand Down
18 changes: 4 additions & 14 deletions examples/ExpoChatGPTSample/screens/MenuDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@ import { Pressable, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';

import type { DrawerContentComponentProps } from 'expo-router/drawer';
import type { SortParamRequest } from 'stream-chat';

import type { Channel as ChannelClass } from 'stream-chat';
import {
ChannelList,
Expand All @@ -17,18 +15,8 @@ import {
WithComponents,
} from 'stream-chat-expo';

import { chatUserId } from '../chatConfig';

import { createChannel, useAppContext } from '../contexts/AppContext';

const filters = {
members: {
$in: [chatUserId],
},
};

const sort: SortParamRequest[] = [{ direction: -1, field: 'last_updated' }];

const ChannelPreview = (props: ChannelPreviewProps) => {
const channel = props.channel;
const { onSelect } = useChannelsContext();
Expand All @@ -53,7 +41,7 @@ const ChannelPreview = (props: ChannelPreviewProps) => {

export const MenuDrawer = ({ navigation }: DrawerContentComponentProps) => {
const { client } = useChatContext();
const { setChannel } = useAppContext();
const { channelListPaginator, setChannel } = useAppContext();

const onSelect = useStableCallback((channel: ChannelClass) => {
setChannel(channel);
Expand All @@ -74,7 +62,9 @@ export const MenuDrawer = ({ navigation }: DrawerContentComponentProps) => {
</Pressable>
</View>
<WithComponents overrides={{ ChannelPreview }}>
<ChannelList filters={filters} sort={sort} onSelect={onSelect} />
{channelListPaginator ? (
<ChannelList onSelect={onSelect} paginator={channelListPaginator} />
) : null}
</WithComponents>
</SafeAreaView>
);
Expand Down
37 changes: 11 additions & 26 deletions examples/ExpoMessaging/app/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React from 'react';

import { useCallback, useContext, useMemo } from 'react';
import { useCallback, useContext } from 'react';
import { Alert, Image, Pressable, StyleSheet, View } from 'react-native';

import { Stack, useRouter } from 'expo-router';
Expand All @@ -11,12 +11,6 @@ import { AppContext } from '../context/AppContext';
import { useUserContext } from '@/context/UserContext';
import { getInitialsOfName } from '@/utils/getInitialsOfName';

const baseOptions = {
predefined_filter: 'basic_channel_list_filter',
state: true,
watch: true,
};

const LogoutButton = () => {
const { logOut, user } = useUserContext();

Expand Down Expand Up @@ -50,31 +44,22 @@ const LogoutButton = () => {
const renderLogoutButton = () => <LogoutButton />;

export default function ChannelListScreen() {
const { user } = useUserContext();
const userId = user?.id || '';
const options = useMemo(
() => ({
...baseOptions,
filter_values: {
user_id: userId,
},
}),
[userId],
);
const router = useRouter();
const { setChannel } = useContext(AppContext);
const { channelListPaginator, setChannel } = useContext(AppContext);

return (
<View style={styles.container}>
<Stack.Screen options={{ title: 'Channel List Screen', headerLeft: renderLogoutButton }} />

<ChannelList
onSelect={(channel) => {
setChannel(channel);
router.push(`/channel/${channel.cid}`);
}}
options={options}
/>
{channelListPaginator ? (
<ChannelList
onSelect={(channel) => {
setChannel(channel);
router.push(`/channel/${channel.cid}`);
}}
paginator={channelListPaginator}
/>
) : null}
</View>
);
}
Expand Down
Loading
Loading