Skip to content

Commit 0100d98

Browse files
authored
refactor!: call Channel.watch(), which replaces ensureWatched() in stream-chat (#3346)
BREAKING CHANGE: channel.watch() resolves with the channel instance to use (the channel, or the instance that superseded it), not the server response: read channel.data, channel.state and channel.messagePaginator instead. - channel.watch() sends no query for a channel already watched and loaded unless it asks for options the current watch was not sent with, so it no longer refreshes one: refresh with channel.reload(), load other messages through channel.messagePaginator. - A channel.watch() while another with the same options is in flight joins it instead of sending a request.
1 parent 8d9732f commit 0100d98

61 files changed

Lines changed: 261 additions & 408 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎AGENTS.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ Each is a `StateStore`. Components read them with `useStateStore(store, selector
123123
└─ <MessageComposer>
124124
```
125125

126-
`Channel` takes a single `channel` prop (`src/components/Channel/Channel.tsx`). It activates the channel on mount, deactivates it on unmount, seeds the paginator's unread snapshot, and re-queries on `user.deleted` — nothing else. It does not query the channel for you: initialize it first (`channel.ensureWatched()` sends one watch for overlapping calls, and none for a channel already watched). There is no `Window` component in v15; compose layout yourself or through `ChatView` slots.
126+
`Channel` takes a single `channel` prop (`src/components/Channel/Channel.tsx`). It activates the channel on mount, deactivates it on unmount, seeds the paginator's unread snapshot, and re-queries on `user.deleted` — nothing else. It does not query the channel for you: initialize it first (`channel.watch()` sends one watch for overlapping calls, and none for a channel already watched and loaded). There is no `Window` component in v15; compose layout yourself or through `ChatView` slots.
127127

128128
Read the bound channel with `useChannel()` (`src/context/useChannel.ts`, thread's channel first, then the `Channel` subtree's) and the active message list with `useMessagePaginator()` (`src/hooks/useMessagePaginator.ts`, thread first, then channel). `useMessagePaginator()` is also exported from the package root, so integrator-facing docs and examples can use it too.
129129

@@ -282,7 +282,7 @@ const {
282282
// Manual setup when you need control over the API responses
283283
const client = await getTestClientWithUser({ id: 'test-user' });
284284
useMockedApis(client, [getOrCreateChannelApi(mockedChannelData)]);
285-
const channel = client.channelManager.ensure({ id: channelId, type: 'messaging' });
285+
const channel = client.channel('messaging', channelId);
286286
await channel.watch();
287287
```
288288

‎ai-docs/ai-migration-v14-v15.md‎

Lines changed: 18 additions & 18 deletions
Large diffs are not rendered by default.

‎ai-docs/instance-configuration.md‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ export const App = () => (
4545
```
4646

4747
**Not in an effect.** Some configuration is read once when an instance is constructed, and channels
48-
are constructed by `client.channelManager.ensure()` / `client.queryChannels()` — which an app typically calls before
48+
are constructed by `client.channel()` / `client.queryChannels()` — which an app typically calls before
4949
or during the same commit that mounts `<Chat>`. Registering from `useEffect` runs after that, so those
5050
values would arrive too late for instances that already exist.
5151

@@ -120,17 +120,17 @@ does differ, so the per-parent slices override the shared one field by field.
120120

121121
### `pageSize` is not the initial query's `messages.limit`
122122

123-
`channel.ensureWatched({ messages: { limit: 30 } })` sizes the **initial** channel query, which the code
123+
`channel.watch({ messages: { limit: 30 } })` sizes the **initial** channel query, which the code
124124
that opens the channel now sends (`<Channel>` no longer queries, and its `channelQueryOptions` prop is
125125
removed). The paginator's `pageSize` sizes **every subsequent page** — what a scroll-up fetches. They
126126
are different numbers and you usually want both:
127127

128128
```tsx
129129
chatClient.config.set({ channel: { messagePaginator: { pageSize: 50 } } });
130130

131-
const channel = await chatClient.channelManager
132-
.ensure({ id: 'general', type: 'messaging' })
133-
.ensureWatched({ messages: { limit: 30 } });
131+
const channel = await chatClient
132+
.channel('messaging', 'general')
133+
.watch({ messages: { limit: 30 } });
134134

135135
<Channel channel={channel}>…</Channel>;
136136
```

‎ai-docs/v15-breaking-changes.md‎

Lines changed: 12 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -842,7 +842,7 @@ A threads view is declared the same way (a `{ id: 'threads', slots: [...] }` lay
842842
- Without `ChatView`: hold the open channel in your own state and provide `WorkspaceNavigationProvider` (main entry) with an object built from `defaultWorkspaceNavigation`, overriding at least `openChannel`, `isChannelActive` and `openChannels` (and `openThread` / `openThreads` / `isThreadActive` if you render threads). Alternatively pass `onSelect` to `ChannelListItem` / search result items.
843843
- Reading "the active channel": inside the channel subtree use `useChannel()`; elsewhere `useWorkspaceNavigation().openChannels` (main entry) or `useSlotChannel({ slot })` (`slot-layout`).
844844
- Opening a channel programmatically: `useWorkspaceNavigation().openChannel(channel)`; to also list it without a re-query, `client.channelManager.ingestChannel(channel)` (stream-chat).
845-
- The `watchers` argument: query the channel yourself (`channel.ensureWatched({ watchers: { limit } })` or `channel.query(...)`, stream-chat) before opening it.
845+
- The `watchers` argument: query the channel yourself (`channel.watch({ watchers: { limit } })` or `channel.query(...)`, stream-chat) before opening it.
846846
- Evidence:
847847
- `release-v14:src/context/ChatContext.tsx:45-49` (`setActiveChannel`), `:54` (`channel?: Channel`); `release-v14:src/components/Chat/hooks/useChat.ts:116-131,137-144`
848848
- `master:src/context/ChatContext.tsx:27-46` (no `channel` / `setActiveChannel`); `master:src/components/Chat/hooks/useChat.ts:68-71`
@@ -1160,11 +1160,11 @@ import {
11601160
- `ChannelProps.initializeOnMount?: boolean` (default `true`: `Channel` called `channel.watch()`)
11611161
- `ChannelProps.channelQueryOptions?: ChannelQueryOptions`
11621162
- `getChannel({ client, channel?, id?, members?, options?, type? })`
1163-
- Replacement: initialize the channel before handing it to `Channel`. App code that already calls `await channel.watch(options)` on a channel created by id needs no change (`client.channel()` and `channel.watch()` keep their roles; `client.channel()` is shorthand for `client.channelManager.ensure({ type, id, data })`). A members-only channel (`client.channel(type, { members })`) must be rendered from the instance `ensureWatched()` resolves with: the created instance can be superseded by a stored instance for the server-assigned cid (`supersededBy`, `watchStatus: NotWatching`, no events). Code that relied on `Channel` to watch adds the call: `channel.watch(options)`, or `await channel.ensureWatched(options)` (stream-chat; sends nothing for an already-watched channel and joins an in-flight watch with the same options). Channels delivered by `ChannelList` arrive watched. v10 also changed the input shape (`members` as `{ user_id }` objects, custom fields under `custom`); that part is a type change only.
1163+
- Replacement: initialize the channel before handing it to `Channel`. App code that already calls `await channel.watch(options)` on a channel created by id needs no change (`client.channel()` and `channel.watch()` keep their roles; `client.channel()` is the public way to get a channel; it calls the internal `channelManager.ensure()`). A members-only channel (`client.channel(type, { members })`) must be rendered from the instance `watch()` resolves with: the created instance can be superseded by a stored instance for the server-assigned cid (`supersededBy`, `watchStatus: NotWatching`, no events). Code that relied on `Channel` to watch adds the call: `await channel.watch(options)` (stream-chat; sends nothing for a channel already watched and loaded and joins an in-flight watch with the same options). Channels delivered by `ChannelList` arrive watched. v10 also changed the input shape (`members` as `{ user_id }` objects, custom fields under `custom`); that part is a type change only.
11641164
- Evidence:
11651165
- `release-v14:src/components/Channel/Channel.tsx:107,141`; `release-v14:src/utils/getChannel.ts:34`
11661166
- `master:src/components/Channel/Channel.tsx:17-23` (prop doc: "Initialize it before passing it in; `Channel` does not query"); `master:src/utils/getChannel.ts` removed
1167-
- `node_modules/stream-chat/dist/types/channel.d.ts:747` (`ensureWatched(options?: ChannelGetOrCreateRequest): Promise<Channel>`); `node_modules/stream-chat/dist/types/ChannelManager.d.ts:157` (`ensure`)
1167+
- `node_modules/stream-chat/dist/types/channel.d.ts` (`watch(options?: ChannelGetOrCreateRequest, requestOptions?: StreamRequestOptions): Promise<Channel>`); `node_modules/stream-chat/dist/types/ChannelManager.d.ts:157` (`ensure`)
11681168
- commits: `088c795c1` (PR #3286); `a41eed56f feat!: replace StreamChat.activeChannels with entity item index for loaded channels` (PR #3310, removes `getChannel`)
11691169
- corpus: `docs/02-ui-components__05-channel-list__02-channel_list_context__2.tsx:11` TS2724 (`getChannel`)
11701170
- Docs impact:
@@ -1184,17 +1184,14 @@ const channel = client.channel('messaging', { members: [userId, otherUserId] });
11841184
After (v15):
11851185

11861186
```tsx
1187-
const channel = await client.channelManager
1188-
.ensure({
1189-
data: { members: [{ user_id: userId }, { user_id: otherUserId }] },
1190-
type: 'messaging',
1191-
})
1192-
.ensureWatched({ messages: { limit: 30 } });
1187+
const channel = await client
1188+
.channel('messaging', { members: [{ user_id: userId }, { user_id: otherUserId }] })
1189+
.watch({ messages: { limit: 30 } });
11931190

11941191
<Channel channel={channel}>{/* ... */}</Channel>;
11951192
```
11961193

1197-
`ensureWatched` takes stream-chat's `ChannelGetOrCreateRequest` (`messages`, `members`, `watchers`, `presence`, `state`, `watch`, `data`, ...), and `data.members` is `ChannelMemberRequest[]` (`{ user_id }` objects) in stream-chat v10 (LLC change; the React SDK builds DMs the same way, `SearchResultItem.tsx:222`).
1194+
`watch` takes stream-chat's `ChannelGetOrCreateRequest` (`messages`, `members`, `watchers`, `presence`, `state`, `watch`, `data`, ...), and `data.members` is `ChannelMemberRequest[]` (`{ user_id }` objects) in stream-chat v10 (LLC change; the React SDK builds DMs the same way, `SearchResultItem.tsx:222`).
11981195

11991196
---
12001197

@@ -1209,9 +1206,7 @@ const channel = await getChannel({ client, id: 'general', type: 'messaging' });
12091206
After (v15):
12101207

12111208
```ts
1212-
const channel = await client.channelManager
1213-
.ensure({ id: 'general', type: 'messaging' })
1214-
.ensureWatched();
1209+
const channel = await client.channel('messaging', 'general').watch();
12151210
```
12161211

12171212
### BC-021: `Channel` no longer writes `document.title`; `activeUnreadHandler` was removed
@@ -1933,15 +1928,15 @@ const CustomThreadHeader = () => {
19331928
- Status: confirmed
19341929
- Area: search
19351930
- User impact: silent.
1936-
- stream-chat v10 queries channel results (and the channels message search fetches) with `watch: false`. The SDK result items watch on open (`ensureWatched()`, or `jumpToMessage(id, { watchChannel: true })` for messages). A custom search result component that only opened the channel now opens an unwatched channel (no events, possibly empty).
1931+
- stream-chat v10 queries channel results (and the channels message search fetches) with `watch: false`. The SDK result items watch on open (`watch()`, or `jumpToMessage(id, { watchChannel: true })` for messages). A custom search result component that only opened the channel now opens an unwatched channel (no events, possibly empty).
19371932
- default result items call `openChannel` (workspace navigation: no-op outside `ChatView`, BC-013) and `client.channelManager.ingestChannel(channel)` instead of `setActiveChannel` + `ChannelListContext.setChannels`.
19381933
- selecting a message result jumps with `channel.messagePaginator.jumpToMessage(...)`; `Channel` no longer performs the jump (BC-027). The "jumped here" marker reads `messagePaginator.messageFocusSignal`, not `SearchController._internalState.focusedMessage`.
19391934
- Old API:
19401935
- default `ChannelSearchResultItem` / `MessageSearchResultItem` / `UserSearchResultItem` behavior
1941-
- Replacement: in a custom result component, call `channel.ensureWatched()` (or `jumpToMessage(id, { watchChannel: true })`) when opening; open via `useWorkspaceNavigation().openChannel(channel, { event })`. The default items gained an optional `onSelect` prop that replaces their selection handler.
1936+
- Replacement: in a custom result component, call `channel.watch()` (or `jumpToMessage(id, { watchChannel: true })`) when opening; open via `useWorkspaceNavigation().openChannel(channel, { event })`. The default items gained an optional `onSelect` prop that replaces their selection handler.
19421937
- Evidence:
19431938
- `release-v14:src/components/Search/SearchResults/SearchResultItem.tsx:23-30,45-74,99-114`
1944-
- `master:src/components/Search/SearchResults/SearchResultItem.tsx:66-105` (`ChannelSearchResultItem`: `openChannel`, `ensureWatched`, `ingestChannel`), `:117-180` (`MessageSearchResultItem`: `jumpToMessage(item.id, { watchChannel: true })`), `:199-243`
1939+
- `master:src/components/Search/SearchResults/SearchResultItem.tsx:66-105` (`ChannelSearchResultItem`: `openChannel`, `watch`, `ingestChannel`), `:117-180` (`MessageSearchResultItem`: `jumpToMessage(item.id, { watchChannel: true })`), `:199-243`
19451940
- commit: `a41eed56f` (PR #3310; "BREAKING CHANGE: channel search results ... are no longer watched; a custom search result component watches the channel when it is opened"); `088c795c1` (PR #3286)
19461941
- Docs impact:
19471942
- `02-ui-components/05-channel-list/05-channel_search.md`, `02-ui-components/05-channel-list/08-advanced-search.md`, `03-ui-cookbook/02-channel-list/02-channel_search.md`
@@ -2590,7 +2585,7 @@ export const Stop = ({ channel }: { channel: Channel }) => {
25902585
- Old API:
25912586
- `<ChannelActionProvider><ChannelStateProvider><Message … /></…></…>`, or `<Message>` with no channel context
25922587
- implicit `highlighted` from `useChannelStateContext().highlightedMessageId`
2593-
- Replacement: wrap it in `<ChannelInstanceProvider value={{ channel }}>` (any real `stream-chat` `Channel`, e.g. `client.channelManager.ensure({ id, type })`), or render it inside `<Channel channel={…}>` / `<Thread thread={…}>`. Pass `highlighted` explicitly when needed (`MessageProps.highlighted?: boolean`).
2588+
- Replacement: wrap it in `<ChannelInstanceProvider value={{ channel }}>` (any real `stream-chat` `Channel`, e.g. `client.channel(type, id)`), or render it inside `<Channel channel={…}>` / `<Thread thread={…}>`. Pass `highlighted` explicitly when needed (`MessageProps.highlighted?: boolean`).
25942589
- Evidence:
25952590
- `master:src/context/useChannel.ts` throws when neither a thread nor `ChannelInstanceContext` supplies a channel
25962591
- `master:src/components/Message/hooks/useOpenThreadHandler.ts:16` and `master:src/components/Message/hooks/useRetryHandler.ts:11` call `useChannel()` during every `Message` render

‎examples/ai-chatbot/implementation.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -344,7 +344,7 @@ examples/ai-chatbot/
344344

345345
### 18. **useSendAIMessage.ts**
346346

347-
- The send path shared by `MessageInputBar` and voice mode: creates a new conversation with `channel.ensureWatched()`, starts the AI agent when it is not watching yet, sends through `composer.send()` (with attachments), records the message against the rate limit and summarizes a new conversation in the background (the summary never delays or fails the send)
347+
- The send path shared by `MessageInputBar` and voice mode: creates a new conversation with `channel.watch()`, starts the AI agent when it is not watching yet, sends through `composer.send()` (with attachments), records the message against the rate limit and summarizes a new conversation in the background (the summary never delays or fails the send)
348348
- Also holds `availableModels`, `defaultPlatformModel` and `isWatchedByAI`
349349

350350
### 19. **VoiceMode**

‎examples/ai-chatbot/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
"next": "^16.2.4",
1919
"react": "^19.2.6",
2020
"react-dom": "^19.2.6",
21-
"stream-chat": "10.0.0-rc.24",
21+
"stream-chat": "10.0.0-rc.25",
2222
"stream-chat-react": "workspace:^"
2323
},
2424
"devDependencies": {

‎examples/ai-chatbot/src/components/AIChatApp/AIChatApp.tsx‎

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -92,18 +92,13 @@ const ChatContent = ({
9292
}, [channel?.id, searchParams, router]);
9393

9494
// Load initial channel from URL on mount. `Channel` no longer queries the channel it is given,
95-
// so it is watched here first. `channelManager.ensure()` returns the stored instance for the cid,
96-
// and `ensureWatched()` de-duplicates concurrent watches of it.
95+
// so it is watched here first. `client.channel()` returns the stored instance for the cid,
96+
// and `watch()` sends nothing for it once watched and loaded, and joins a watch in flight.
9797
useEffect(() => {
9898
if (initialChannelId && client && !channel) {
9999
const loadChannel = async () => {
100-
const targetChannel = client.channelManager.ensure({
101-
id: initialChannelId,
102-
type: 'messaging',
103-
});
104-
if (!targetChannel.initialized) {
105-
await targetChannel.ensureWatched();
106-
}
100+
const targetChannel = client.channel('messaging', initialChannelId);
101+
await targetChannel.watch();
107102
setActiveChannel(targetChannel);
108103
};
109104
loadChannel().catch((err) => {

‎examples/ai-chatbot/src/components/useSendAIMessage.ts‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,9 @@ export const useSendAIMessage = () => {
5656
composer.textComposer.setText(text);
5757

5858
// A new conversation exists only locally until now; `Channel` no longer creates it.
59-
// Both setup steps run before `send()`, so a failure here leaves the draft untouched.
60-
if (!channel.initialized) {
61-
await channel.ensureWatched();
62-
}
59+
// `watch()` sends nothing for a conversation already watched and loaded. Both setup steps
60+
// run before `send()`, so a failure here leaves the draft untouched.
61+
await channel.watch();
6362

6463
const [platform, model] = platformModel.split('|');
6564

‎examples/tutorial/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
"emoji-mart": "^5.6.0",
1717
"react": "^19.2.6",
1818
"react-dom": "^19.2.6",
19-
"stream-chat": "10.0.0-rc.24",
19+
"stream-chat": "10.0.0-rc.25",
2020
"stream-chat-react": "workspace:^"
2121
},
2222
"devDependencies": {

‎examples/tutorial/src/3-core-component-setup/App.tsx‎

Lines changed: 11 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -68,29 +68,23 @@ const App = () => {
6868
if (!client) return;
6969

7070
const initChannel = async () => {
71-
const channel = client.channelManager.ensure({
72-
data: {
73-
members: [userId],
74-
// custom channel fields live under `custom` since v10
75-
custom: {
76-
image: 'https://getstream.io/random_png/?name=react',
77-
name: 'Talk about React',
78-
},
71+
const channel = client.channel('messaging', 'custom_channel_id', {
72+
members: [userId],
73+
// custom channel fields live under `custom` since v10
74+
custom: {
75+
image: 'https://getstream.io/random_png/?name=react',
76+
name: 'Talk about React',
7977
},
80-
id: 'custom_channel_id',
81-
type: 'messaging',
8278
});
8379

8480
// `Channel` binds a channel to its subtree; it does not query one. Whoever supplies the
8581
// channel initializes it.
8682
//
87-
// `client.channelManager.ensure()` returns the stored instance for this cid, so a re-run of this effect
88-
// can hand back a channel that is already loaded -- query only when it is not. When a query
89-
// is needed, `channel.ensureWatched()` de-duplicates concurrent calls for the same channel, so two
90-
// overlapping runs still produce a single request.
91-
if (!channel.initialized) {
92-
await channel.ensureWatched();
93-
}
83+
// `client.channel()` returns the stored instance for this cid, so a re-run of
84+
// this effect can hand back a channel that is already loaded. `channel.watch()` sends nothing
85+
// for a channel already watched and loaded, and joins a watch in flight, so two overlapping
86+
// runs still produce a single request.
87+
await channel.watch();
9488

9589
setChannel(channel);
9690
};

0 commit comments

Comments
 (0)