From 521a2557969299cc89ffa0157d1a43b3bd2c52df Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 23 Oct 2026 17:34:45 +0200 Subject: [PATCH 1/9] refactor: call Channel.watch(), which replaces ensureWatched() in stream-chat stream-chat renames ensureWatched() to watch(): it sends nothing for a channel already watched and loaded and resolves with the instance to use. The channel and user search results and the channel detail's "send direct message" action call it. A message search result loads its channel with the jump alone (jumpToMessage with watchChannel), one request instead of a watch followed by the jump. Refs REACT-1341. Co-Authored-By: Claude Opus 5.5 --- .../ScrollToLatestMessageButton.test.tsx | 2 +- .../Search/SearchResults/SearchResultItem.tsx | 35 ++++++------- .../__tests__/SearchResultItem.test.tsx | 49 ++++++++++--------- .../ChannelMemberActions.defaults.tsx | 2 +- .../SendDirectMessageAction.test.tsx | 8 ++- 5 files changed, 48 insertions(+), 48 deletions(-) diff --git a/src/components/MessageList/__tests__/ScrollToLatestMessageButton.test.tsx b/src/components/MessageList/__tests__/ScrollToLatestMessageButton.test.tsx index b270dd772..cc6a78e63 100644 --- a/src/components/MessageList/__tests__/ScrollToLatestMessageButton.test.tsx +++ b/src/components/MessageList/__tests__/ScrollToLatestMessageButton.test.tsx @@ -100,7 +100,7 @@ const requeryWithOwnUnreadCount = async (unreadCount: number) => { ), ]); await act(async () => { - await channel.watch(); + await channel.reload(); }); }; diff --git a/src/components/Search/SearchResults/SearchResultItem.tsx b/src/components/Search/SearchResults/SearchResultItem.tsx index 048c22450..2305af72f 100644 --- a/src/components/Search/SearchResults/SearchResultItem.tsx +++ b/src/components/Search/SearchResults/SearchResultItem.tsx @@ -87,7 +87,7 @@ export const ChannelSearchResultItem = ({ // honor ⌘/ctrl-click. openChannel(item, { event }); // Neither channel search nor `Channel` watches, so watch here to receive the channel events. - item.ensureWatched().catch((error) => + item.watch().catch((error) => reportLoadFailed({ channel: item, client, @@ -155,22 +155,19 @@ export const MessageSearchResultItem = ({ if (!current || current.pendingDisposal) return; openChannel(current, { event }); channelManager.ingestChannel(current); - void (async () => { - // Load a never-loaded channel first, through the watch shared by anything opening it: a - // first page landing after the jump would move the list off the message. - if (!current.initialized) await current.ensureWatched(); - // `watchChannel` also watches a stored but unwatched channel (e.g. one a thread created), - // so it receives its events. - await current.messagePaginator.jumpToMessage(item.id, { watchChannel: true }); - })().catch((error) => - reportLoadFailed({ - channel: current, - client, - emitter: 'MessageSearchResultItem', - error, - t, - }), - ); + // One request loads the page around the message and the channel state, and watches a + // channel not watched yet (e.g. one a thread created), so it receives its events. + current.messagePaginator + .jumpToMessage(item.id, { watchChannel: true }) + .catch((error) => + reportLoadFailed({ + channel: current, + client, + emitter: 'MessageSearchResultItem', + error, + t, + }), + ); }, [cid, item, openChannel, channelManager, client, onSelect, t], ); @@ -219,9 +216,9 @@ export const UserSearchResultItem = ({ item, onSelect }: UserSearchResultItemPro }); // The event is forwarded so an `openChannel` override can honor ⌘/ctrl-click. openChannel(newChannel, { event }); - newChannel.ensureWatched().then( + newChannel.watch().then( // Listed only once the watch gives a new DM its ID, since lists refuse a channel without - // one. `ensureWatched()` resolves with the stored instance if another took that ID + // one. `watch()` resolves with the stored instance if another took that ID // meanwhile. (channel) => channelManager.ingestChannel(channel), (error) => diff --git a/src/components/Search/__tests__/SearchResultItem.test.tsx b/src/components/Search/__tests__/SearchResultItem.test.tsx index 8b198e1d3..6a761c735 100644 --- a/src/components/Search/__tests__/SearchResultItem.test.tsx +++ b/src/components/Search/__tests__/SearchResultItem.test.tsx @@ -182,7 +182,7 @@ describe('SearchResultItem Components', () => { ); }); - it('does not watch an opened channel that is already watched', async () => { + it('sends no watch query for an opened channel already watched and loaded', async () => { const channelSearchData = generateChannel(); const { client } = await renderComponent({ channelSearchData, @@ -190,12 +190,16 @@ describe('SearchResultItem Components', () => { }); const channel = client.channelManager.get(channelSearchData.channel.cid); if (!channel) throw new Error('the result channel is not stored'); + // watched, with its state and messages loaded: `watch()` has nothing to send channel.watchStatus = ChannelWatchStatus.Watching; - const watch = vi.spyOn(channel, 'watch'); + channel.initialized = true; + channel.messagePaginator.placeExternalQueryPage([], 25); + const getOrCreate = vi.spyOn(channel, 'getOrCreate'); fireEvent.click(screen.getByTestId(CHANNEL_PREVIEW_BUTTON_TEST_ID)); - expect(watch).not.toHaveBeenCalled(); + await act(() => Promise.resolve()); + expect(getOrCreate).not.toHaveBeenCalled(); }); it('runs a custom onSelect instead of the default open', async () => { @@ -257,7 +261,7 @@ describe('SearchResultItem Components', () => { expect(mockIngestChannel).toHaveBeenCalledTimes(1); }); - it('loads a channel that was never loaded before jumping to the message', async () => { + it('loads a channel that was never loaded with the jump to the message alone', async () => { const message = generateMessage(); const messageResponseData = { id: message.id, @@ -271,21 +275,24 @@ describe('SearchResultItem Components', () => { const channel = client.channelManager.ensure({ id, type }); channel.state.partialNext({ initialized: false }); const calls: string[] = []; - vi.spyOn(channel, 'ensureWatched').mockImplementation(() => { - calls.push('ensureWatched'); + vi.spyOn(channel, 'watch').mockImplementation(() => { + calls.push('watch'); return Promise.resolve(channel); }); - vi.spyOn(channel.messagePaginator, 'jumpToMessage').mockImplementation(() => { - calls.push('jumpToMessage'); - return Promise.resolve(true); - }); + const jumpToMessage = vi + .spyOn(channel.messagePaginator, 'jumpToMessage') + .mockImplementation(() => { + calls.push('jumpToMessage'); + return Promise.resolve(true); + }); await act(async () => { fireEvent.click(screen.getByTestId(CHANNEL_PREVIEW_BUTTON_TEST_ID)); await Promise.resolve(); }); - expect(calls).toEqual(['ensureWatched', 'jumpToMessage']); + expect(calls).toEqual(['jumpToMessage']); + expect(jumpToMessage).toHaveBeenCalledWith(message.id, { watchChannel: true }); }); it('renders its stored channel without a connected user', async () => { @@ -434,11 +441,11 @@ describe('SearchResultItem Components', () => { it('adds the direct-message channel to the lists only once its watch resolves', async () => { await renderComponent({ SearchResultItemComponent, userData: user }); let resolveWatch: () => void = () => undefined; - const ensureWatched = vi - .spyOn(Channel.prototype, 'ensureWatched') - .mockImplementation(function (this: Channel) { - return new Promise((resolve) => (resolveWatch = () => resolve(this))); - }); + const watch = vi.spyOn(Channel.prototype, 'watch').mockImplementation(function ( + this: Channel, + ) { + return new Promise((resolve) => (resolveWatch = () => resolve(this))); + }); await act(() => { fireEvent.click(screen.getByRole('option')); @@ -451,8 +458,8 @@ describe('SearchResultItem Components', () => { await Promise.resolve(); }); - expect(mockIngestChannel).toHaveBeenCalledWith(ensureWatched.mock.contexts[0]); - ensureWatched.mockRestore(); + expect(mockIngestChannel).toHaveBeenCalledWith(watch.mock.contexts[0]); + watch.mockRestore(); }); it('adds the stored instance when the direct message was superseded during its watch', async () => { @@ -461,16 +468,14 @@ describe('SearchResultItem Components', () => { userData: user, }); const stored = client.channelManager.ensure({ id: 'stored-dm', type: 'messaging' }); - const ensureWatched = vi - .spyOn(Channel.prototype, 'ensureWatched') - .mockResolvedValue(stored); + const watch = vi.spyOn(Channel.prototype, 'watch').mockResolvedValue(stored); await act(() => { fireEvent.click(screen.getByRole('option')); }); await vi.waitFor(() => expect(mockIngestChannel).toHaveBeenCalledWith(stored)); - ensureWatched.mockRestore(); + watch.mockRestore(); }); it('runs a custom onSelect instead of the default DM open', async () => { diff --git a/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberActions.defaults.tsx b/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberActions.defaults.tsx index c0aeb7f4a..163e13178 100644 --- a/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberActions.defaults.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberActions.defaults.tsx @@ -240,7 +240,7 @@ const SendDirectMessageAction = () => { }); // The stored instance, which differs from `directMessageChannel` if another instance took the // DM's `cid` while the watch was in flight. - const dm = await directMessageChannel.ensureWatched(); + const dm = await directMessageChannel.watch(); // Open the DM in the workspace (a `ChatView` slot, or whatever a custom // `WorkspaceNavigationProvider` does), then ingest it into the channel list(s) that should // own it so it appears without a re-query. diff --git a/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/__tests__/SendDirectMessageAction.test.tsx b/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/__tests__/SendDirectMessageAction.test.tsx index 39f033041..a7d2b2a2d 100644 --- a/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/__tests__/SendDirectMessageAction.test.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/__tests__/SendDirectMessageAction.test.tsx @@ -72,8 +72,7 @@ describe('SendDirectMessage action', () => { it('opens and lists the direct message once it is watched', async () => { const created = fromPartial({ cid: 'messaging:dm' }); - (created as { ensureWatched: () => Promise }).ensureWatched = () => - Promise.resolve(created); + (created as { watch: () => Promise }).watch = () => Promise.resolve(created); renderAction(created); await act(async () => { @@ -88,9 +87,8 @@ describe('SendDirectMessage action', () => { it('opens and lists the stored instance when the direct message was superseded during its watch', async () => { const stored = fromPartial({ cid: 'messaging:dm' }); const created = fromPartial({ cid: 'messaging:dm', supersededBy: stored }); - // as stream-chat does: `ensureWatched()` resolves with the instance that superseded it - (created as { ensureWatched: () => Promise }).ensureWatched = () => - Promise.resolve(stored); + // as stream-chat does: `watch()` resolves with the instance that superseded it + (created as { watch: () => Promise }).watch = () => Promise.resolve(stored); renderAction(created); await act(async () => { From 0e3e559fddb1bd48b38ba62b0596ce4e35c1287c Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 23 Oct 2026 17:34:50 +0200 Subject: [PATCH 2/9] chore(examples): call channel.watch() without an initialized guard watch() itself sends nothing for a channel already watched and loaded, and initialized does not mean watched or loaded, so the tutorial, ai-chatbot and vite examples call it directly. The vite deep link loads its channel with the jump alone (jumpToMessage with watchChannel). Co-Authored-By: Claude Opus 5.5 --- .../src/components/AIChatApp/AIChatApp.tsx | 6 ++---- .../MessageInputBar/MessageInputBar.tsx | 7 +++---- .../src/3-core-component-setup/App.tsx | 12 +++++------- .../src/6-custom-ui-components/App.tsx | 8 +++----- examples/tutorial/src/7-emoji-picker/App.tsx | 8 +++----- .../optional-custom-attachment-type/App.tsx | 8 +++----- .../tutorial/src/optional-livestream/App.tsx | 9 ++++----- examples/vite/src/App.tsx | 12 +++++------- .../vite/src/ChatLayout/WorkspaceUrlSync.tsx | 18 +++++++----------- 9 files changed, 35 insertions(+), 53 deletions(-) diff --git a/examples/ai-chatbot/src/components/AIChatApp/AIChatApp.tsx b/examples/ai-chatbot/src/components/AIChatApp/AIChatApp.tsx index 056d54043..bb95eb937 100644 --- a/examples/ai-chatbot/src/components/AIChatApp/AIChatApp.tsx +++ b/examples/ai-chatbot/src/components/AIChatApp/AIChatApp.tsx @@ -93,7 +93,7 @@ const ChatContent = ({ // Load initial channel from URL on mount. `Channel` no longer queries the channel it is given, // so it is watched here first. `channelManager.ensure()` returns the stored instance for the cid, - // and `ensureWatched()` de-duplicates concurrent watches of it. + // and `watch()` sends nothing for it once watched and loaded, and joins a watch in flight. useEffect(() => { if (initialChannelId && client && !channel) { const loadChannel = async () => { @@ -101,9 +101,7 @@ const ChatContent = ({ id: initialChannelId, type: 'messaging', }); - if (!targetChannel.initialized) { - await targetChannel.ensureWatched(); - } + await targetChannel.watch(); setActiveChannel(targetChannel); }; loadChannel().catch((err) => { diff --git a/examples/ai-chatbot/src/components/MessageInputBar/MessageInputBar.tsx b/examples/ai-chatbot/src/components/MessageInputBar/MessageInputBar.tsx index 2ffefc413..69992dd79 100644 --- a/examples/ai-chatbot/src/components/MessageInputBar/MessageInputBar.tsx +++ b/examples/ai-chatbot/src/components/MessageInputBar/MessageInputBar.tsx @@ -130,10 +130,9 @@ export const MessageInputBar = () => { composer.textComposer.setText(message as string); // A new conversation exists only locally until now; `Channel` no longer creates it. - // Both setup steps run before `send()`, so a failure here leaves the draft untouched. - if (!channel.initialized) { - await channel.ensureWatched(); - } + // `watch()` sends nothing for a conversation already watched and loaded. Both setup steps + // run before `send()`, so a failure here leaves the draft untouched. + await channel.watch(); const [platform, model] = (platformModel as string).split('|'); diff --git a/examples/tutorial/src/3-core-component-setup/App.tsx b/examples/tutorial/src/3-core-component-setup/App.tsx index 8fd5bd5f3..fe78d6ea4 100644 --- a/examples/tutorial/src/3-core-component-setup/App.tsx +++ b/examples/tutorial/src/3-core-component-setup/App.tsx @@ -84,13 +84,11 @@ const App = () => { // `Channel` binds a channel to its subtree; it does not query one. Whoever supplies the // channel initializes it. // - // `client.channelManager.ensure()` returns the stored instance for this cid, so a re-run of this effect - // can hand back a channel that is already loaded -- query only when it is not. When a query - // is needed, `channel.ensureWatched()` de-duplicates concurrent calls for the same channel, so two - // overlapping runs still produce a single request. - if (!channel.initialized) { - await channel.ensureWatched(); - } + // `client.channelManager.ensure()` returns the stored instance for this cid, so a re-run of + // this effect can hand back a channel that is already loaded. `channel.watch()` sends nothing + // for a channel already watched and loaded, and joins a watch in flight, so two overlapping + // runs still produce a single request. + await channel.watch(); setChannel(channel); }; diff --git a/examples/tutorial/src/6-custom-ui-components/App.tsx b/examples/tutorial/src/6-custom-ui-components/App.tsx index b914fd9db..cc970f7ce 100644 --- a/examples/tutorial/src/6-custom-ui-components/App.tsx +++ b/examples/tutorial/src/6-custom-ui-components/App.tsx @@ -228,11 +228,9 @@ const App = () => { }); // `Channel` binds a channel to its subtree; it does not query one, so initializing is the - // caller's job. The cached instance may already be loaded, so query only when it is not -- - // and when a query is needed, `channel.ensureWatched()` de-duplicates calls that overlap in time. - if (!channel.initialized) { - await channel.ensureWatched(); - } + // caller's job. `channel.watch()` sends nothing for a channel already watched and loaded, and + // joins a watch in flight, so a re-run of this effect still sends one request at most. + await channel.watch(); setIsReady(true); }; diff --git a/examples/tutorial/src/7-emoji-picker/App.tsx b/examples/tutorial/src/7-emoji-picker/App.tsx index c4bee8cc0..6928ecdfc 100644 --- a/examples/tutorial/src/7-emoji-picker/App.tsx +++ b/examples/tutorial/src/7-emoji-picker/App.tsx @@ -152,11 +152,9 @@ const App = () => { }); // `Channel` binds a channel to its subtree; it does not query one, so initializing is the - // caller's job. The cached instance may already be loaded, so query only when it is not -- - // and when a query is needed, `channel.ensureWatched()` de-duplicates calls that overlap in time. - if (!channel.initialized) { - await channel.ensureWatched(); - } + // caller's job. `channel.watch()` sends nothing for a channel already watched and loaded, and + // joins a watch in flight, so a re-run of this effect still sends one request at most. + await channel.watch(); setIsReady(true); }; diff --git a/examples/tutorial/src/optional-custom-attachment-type/App.tsx b/examples/tutorial/src/optional-custom-attachment-type/App.tsx index 4afd40469..a45aa6186 100644 --- a/examples/tutorial/src/optional-custom-attachment-type/App.tsx +++ b/examples/tutorial/src/optional-custom-attachment-type/App.tsx @@ -194,11 +194,9 @@ const App = () => { }); // `Channel` binds a channel to its subtree; it does not query one, so initializing is the - // caller's job. The cached instance may already be loaded, so query only when it is not -- - // and when a query is needed, `channel.ensureWatched()` de-duplicates calls that overlap in time. - if (!channel.initialized) { - await channel.ensureWatched(); - } + // caller's job. `channel.watch()` sends nothing for a channel already watched and loaded, and + // joins a watch in flight, so a re-run of this effect still sends one request at most. + await channel.watch(); // messages are no longer kept on channel.state — the paginator owns the list const hasProductMessage = (channel.messagePaginator.items ?? []).some((message) => diff --git a/examples/tutorial/src/optional-livestream/App.tsx b/examples/tutorial/src/optional-livestream/App.tsx index 2073f6e3c..3560fe2e3 100644 --- a/examples/tutorial/src/optional-livestream/App.tsx +++ b/examples/tutorial/src/optional-livestream/App.tsx @@ -54,11 +54,10 @@ const App = () => { }); // `Channel` binds a channel to its subtree; it does not query one, so initializing is the - // caller's job. The cached instance may already be loaded, so query only when it is not -- - // and when a query is needed, `channel.ensureWatched()` de-duplicates calls that overlap in time. - if (!spaceChannel.initialized) { - await spaceChannel.ensureWatched(); - } + // caller's job. `spaceChannel.watch()` sends nothing for a channel already watched and + // loaded, and joins a watch in flight, so a re-run of this effect still sends one request at + // most. + await spaceChannel.watch(); setChannel(spaceChannel); }; diff --git a/examples/vite/src/App.tsx b/examples/vite/src/App.tsx index b8b4cdaab..7f13cdbdf 100644 --- a/examples/vite/src/App.tsx +++ b/examples/vite/src/App.tsx @@ -388,9 +388,10 @@ const App = () => { }, [chatClient]); // `?focus=:` (repeatable) — open each named channel at the message it names. - // `jumpToMessage` loads the window and leaves a focus signal on the channel's paginator; the - // signal's countdown only starts once a message list has actually rendered it, so running this - // before the layout has mounted is fine — the highlight is still there when the list appears. + // `jumpToMessage` loads the page around the message and the channel state in one request, which + // also watches the channel (`watchChannel`), and leaves a focus signal on the channel's + // paginator. The signal's countdown only starts once a message list has rendered it, so running + // this before the layout has mounted is fine: the highlight is still there when the list appears. useEffect(() => { if (!chatClient) return; @@ -404,10 +405,7 @@ const App = () => { type: cid.slice(0, separatorIndex), }); - void (async () => { - if (!channel.initialized) await channel.ensureWatched(); - await channel.messagePaginator.jumpToMessage(messageId); - })(); + void channel.messagePaginator.jumpToMessage(messageId, { watchChannel: true }); }); }, [chatClient]); diff --git a/examples/vite/src/ChatLayout/WorkspaceUrlSync.tsx b/examples/vite/src/ChatLayout/WorkspaceUrlSync.tsx index bef1f07f3..28c1d8212 100644 --- a/examples/vite/src/ChatLayout/WorkspaceUrlSync.tsx +++ b/examples/vite/src/ChatLayout/WorkspaceUrlSync.tsx @@ -229,14 +229,10 @@ const resolveBinding = async ( // `membership`, mute status etc. aren't loaded yet. Ownership resolution (`ingestChannel` // → `matchesFilter`) reads exactly that state, so ingesting now would only match the // empty-filter fallback list (`channels:opened`), never `channels:default`. Watch first so - // the channel is classified into its real owning list. Already-initialized channels - // (paginator-first / warm Back-Forward) skip this — and this is the same single watch - // `` would otherwise issue, just moved earlier, so it stays a single `/query`. - // Through `ensureWatched()`, so a `?focus=` entry naming this same channel shares this one - // watch instead of racing a second one. - if (!channel.initialized) { - await channel.ensureWatched().catch(() => undefined); - } + // the channel is classified into its real owning list. `watch()` sends nothing for a channel + // already watched and loaded (paginator-first / warm Back-Forward), and a `?focus=` entry + // naming this same channel joins this watch instead of racing a second one. + await channel.watch().catch(() => undefined); return { binding: { key: channel.cid, kind: 'channel', source: channel }, channel }; } case 'thread': { @@ -262,9 +258,9 @@ const resolveBinding = async ( const channel = resolveChannel(client, parentMessage.cid); if (!channel) return undefined; - // Same watch the bound `` would issue (see the channel case) — moved earlier so the - // thread's channel config, members and read state are loaded when the panel renders. - if (!channel.initialized) await channel.watch().catch(() => undefined); + // The same watch as in the channel case, so the thread's channel config, members and read + // state are loaded when the panel renders. + await channel.watch().catch(() => undefined); // Through `ensure()`, so the thread is registered with the `ThreadManager` and receives // events. It starts stale only when the parent has replies, and `` loads it then. From 49b22ee27c76f5a547e4f8452c564fb0f09ac879 Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 23 Oct 2026 17:34:54 +0200 Subject: [PATCH 3/9] docs: describe channel.watch() instead of the removed ensureWatched() Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- ai-docs/ai-migration-v14-v15.md | 26 +++++++++++++------------- ai-docs/instance-configuration.md | 4 ++-- ai-docs/v15-breaking-changes.md | 18 +++++++++--------- 4 files changed, 25 insertions(+), 25 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 38eac2f27..5b19b5ca2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -123,7 +123,7 @@ Each is a `StateStore`. Components read them with `useStateStore(store, selector └─ ``` -`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. +`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. 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. diff --git a/ai-docs/ai-migration-v14-v15.md b/ai-docs/ai-migration-v14-v15.md index 8f59825d0..3c2f21c95 100644 --- a/ai-docs/ai-migration-v14-v15.md +++ b/ai-docs/ai-migration-v14-v15.md @@ -9,7 +9,7 @@ Every breaking change confirmed in [`ai-docs/v15-breaking-changes.md`](https://g Never rely on training knowledge. The v14 surface is heavily represented in pretraining data and will mislead you. Before writing any code, verify against the user's installed packages: 1. `node_modules/stream-chat-react/dist/types/index.d.ts`: the public React type surface (props, override keys, hook signatures). Entry points other than the root have their own folders: `dist/types/plugins/SlotLayout/` for `stream-chat-react/slot-layout`, and so on. -2. `node_modules/stream-chat/dist/types/index.d.ts`: the core client types. `Channel` (`channel.d.ts`: `messagePaginator`, `messageOperations`, `configState`, `ensureWatched`), `ChannelManager.d.ts`, `thread.d.ts`, `pagination/paginators/`, `messageOperations/types.d.ts`. +2. `node_modules/stream-chat/dist/types/index.d.ts`: the core client types. `Channel` (`channel.d.ts`: `messagePaginator`, `messageOperations`, `configState`, `watch`), `ChannelManager.d.ts`, `thread.d.ts`, `pagination/paginators/`, `messageOperations/types.d.ts`. 3. `node_modules/stream-chat-react/package.json`: the `exports` map and peer dependencies. Rule: before claiming a symbol, prop or method exists, or writing a replacement snippet, grep the installed `.d.ts` and read the current signature. Do not reconstruct it from memory. @@ -59,15 +59,15 @@ These compile cleanly, or are ignored in plain JS, and change behavior at runtim | A thread renders below the channel instead of beside it, or a panel collapses to a narrow column | The channel no longer contains a flex row, and `ChatView` slots are `flex: 1 1 0` siblings | Make your container a row; size panels by slot ([section 19](#panel-layout)) | | A channel name or image renders empty | `name` / `image` are custom data in `stream-chat` v10: `channel.data.custom.name` | Read `custom` ([section 17](#17-other-stream-chat-v10-data-shapes)) | | A held channel throws "pending disposal" after logout or a user switch | `disconnectUser()` disposes the user's channels | Drop held channels on disconnect ([section 4](#without-chatview)) | -| A channel renders empty, with no error, and gets no new messages | `Channel` no longer watches the channel | Watch it first: keep `channel.watch()`, or `ensureWatched()` ([section 5](#channel-no-longer-watches-the-channel)) | -| A direct message the app just created renders, but new messages never arrive | The created members-only instance was superseded by the store's instance for the same cid | Render the instance `ensureWatched()` resolves with ([section 5](#channel-no-longer-watches-the-channel)) | +| A channel renders empty, with no error, and gets no new messages | `Channel` no longer watches the channel | Watch it first: `channel.watch()` ([section 5](#channel-no-longer-watches-the-channel)) | +| A direct message the app just created renders, but new messages never arrive | The created members-only instance was superseded by the store's instance for the same cid | Render the instance `watch()` resolves with ([section 5](#channel-no-longer-watches-the-channel)) | | After a failed send, the composer is empty instead of holding the text and attachments | The composer is released before the request and nothing is put back | Nothing to do; the failed message is retried from the list ([section 13](#a-failed-send-or-edit-no-longer-restores-the-composer)) | | A component that rendered blank outside its provider now throws | Required context hooks throw instead of warning and returning `{}` | Render it inside the provider ([section 10](#required-context-hooks-throw-outside-their-provider)) | | A standalone `` (a preview, a pinned-message card) throws | `Message` needs a channel in context | Wrap it in `ChannelInstanceProvider` ([section 12](#a-standalone-message-needs-a-channel-in-context)) | | The channel is not marked read | Mark-read moved from `Channel` mount to the message list | Render a message list, or mark read yourself ([section 5](#markreadonmount-is-removed-the-list-marks-read)) | | The tab title no longer shows `(3) Title` | `Channel` no longer writes `document.title` | Write it in app code ([section 5](#channel-no-longer-writes-documenttitle)) | | Mount effects in a channel's children stop re-running when the channel changes | `Channel` no longer remounts its subtree per channel | Depend on the `channel` instance ([section 5](#channel-lifecycle-activation-no-remount-no-reload-no-search-jump)) | -| Search results open unwatched, empty channels in a custom result component | Search results are no longer watched | `ensureWatched()` on open ([section 4](#search-results)) | +| Search results open unwatched, empty channels in a custom result component | Search results are no longer watched | `watch()` on open ([section 4](#search-results)) | | Translations or overrides stop applying; non-English UI renders English | Keys renamed, non-English dictionaries removed | Rename keys, register dictionaries ([section 18](#18-i18n)) | | CSS on `.str-chat__main-panel`, `.str-chat__container`, `.str-chat__channel-list-inner` or `.str-chat__thread--virtualized` stops applying | Those wrappers and classes are gone | Retarget ([section 19](#19-css-and-markup)) | | Plain JS: `useCreateChatClient({ options })` stops applying `baseURL` and other settings | `options` was renamed to `config` | [Section 2](#usecreatechatclient-options--config) | @@ -652,11 +652,11 @@ export const App = ({ client, userId }: { client: StreamChat; userId: string }) - `useWorkspaceNavigation().openChannel(channel)`. - To also add it to the matching channel lists without a re-query: `client.channelManager.ingestChannel(channel)`. -- v14's `setActiveChannel(channel, watchers)` `watchers` argument: watch the channel yourself before opening it with the same options, `await channel.watch({ watchers: { limit: 10 } })` (or `ensureWatched(...)`, which skips the request when the channel is already watched). +- v14's `setActiveChannel(channel, watchers)` `watchers` argument: watch the channel yourself before opening it with the same options, `await channel.watch({ watchers: { limit: 10 } })` (it sends nothing when the channel is already watched and loaded). ### Search results -- `stream-chat` v10 queries channel search results with `watch: false`. The SDK's result items watch on open (`ensureWatched()`, or `jumpToMessage(id, { watchChannel: true })` for a message result). **A custom search result component that only opens the channel now opens an unwatched channel**: no events, possibly empty. Call `channel.ensureWatched()` when opening. +- `stream-chat` v10 queries channel search results with `watch: false`. The SDK's result items watch on open (`watch()`, or `jumpToMessage(id, { watchChannel: true })` for a message result). **A custom search result component that only opens the channel now opens an unwatched channel**: no events, possibly empty. Call `channel.watch()` when opening. - Default result items call `openChannel` and `client.channelManager.ingestChannel(channel)` instead of `setActiveChannel` and `ChannelListContext.setChannels`. - Selecting a message result jumps with `channel.messagePaginator.jumpToMessage(...)`. The "jumped here" marker reads `messagePaginator.messageFocusSignal`, not `SearchController._internalState.focusedMessage`. @@ -717,12 +717,12 @@ What to change depends on whether the app already watched the channel, and on wh - **Channels from `ChannelList` or any `queryChannels` call arrive watched.** Nothing changes for them. - **The app already watches a channel it created by id** (`client.channel(type, id)` + `await channel.watch()`): **nothing changes.** `client.channel(...)` and `channel.watch(options)` still exist with the same roles; `client.channel()` is shorthand for `client.channelManager.ensure({ type, id, data })`. Do not rewrite these calls. The only edit at such call sites is the `stream-chat` v10 input shape ([below](#channel-input-shape)). -- **Members-only channels** (`client.channel(type, { members })`, no id, as most "start a direct message" flows create): keep `client.channel()`, but **render the instance `ensureWatched()` resolves with**, not the one you created. The created instance is provisional until the server assigns the id, and the client may already hold a channel with that id (event routing can store it while the request is in flight). The store then keeps its own instance and marks yours superseded (`state.supersededBy` set, `watchStatus: NotWatching`), and yours receives no events. `ensureWatched()` resolves with the surviving instance; `watch()` resolves with the response, so code that keeps the created instance renders a channel that silently stops updating. `ChatView` swaps superseded channels in its slots, so this bites when the app holds the channel itself. -- **The app relied on `Channel` to watch** (rendered an instance it never watched, possibly with `channelQueryOptions`): watch it before rendering. `await channel.watch(options)` works. `await channel.ensureWatched(options)` is the better fit where the call can repeat, such as an effect: it sends nothing for a channel already watched and joins an in-flight watch with the same options, so an effect that runs twice still sends one query. `channel.watch()` always sends its request. `ensureWatched()` resolves with the channel. +- **Members-only channels** (`client.channel(type, { members })`, no id, as most "start a direct message" flows create): keep `client.channel()`, but **render the instance `watch()` resolves with**, not the one you created. The created instance is provisional until the server assigns the id, and the client may already hold a channel with that id (event routing can store it while the request is in flight). The store then keeps its own instance and marks yours superseded (`state.supersededBy` set, `watchStatus: NotWatching`), and yours receives no events. `watch()` resolves with the surviving instance, so code that keeps the created instance renders a channel that silently stops updating. `ChatView` swaps superseded channels in its slots, so this bites when the app holds the channel itself. +- **The app relied on `Channel` to watch** (rendered an instance it never watched, possibly with `channelQueryOptions`): watch it before rendering: `await channel.watch(options)`. It fits where the call can repeat, such as an effect: it sends nothing for a channel already watched and loaded and joins an in-flight watch with the same options, so an effect that runs twice still sends one query. It resolves with the channel; refresh a watched channel with `channel.reload()`. - **`channelQueryOptions`** → pass them to that watch call (`ChannelGetOrCreateRequest`: `messages`, `members`, `watchers`, `presence`, `state`, `watch`, `data`, ...). -- **`getChannel({ client, id, type })`** → `await client.channel(type, id).ensureWatched()`. +- **`getChannel({ client, id, type })`** → `await client.channel(type, id).watch()`. -A members-only channel the app already watched: keep `client.channel()`, take the instance from `ensureWatched()`, and fix the member shape: +A members-only channel the app already watched: keep `client.channel()`, take the instance from `watch()`, and fix the member shape: ```tsx // v14 @@ -735,7 +735,7 @@ setActiveChannel(channel); // v15 const channel = await client .channel('messaging', { members: [{ user_id: userId }, { user_id: otherUserId }] }) - .ensureWatched(); // the surviving instance; the created one can be superseded + .watch(); // the surviving instance; the created one can be superseded openChannel(channel); // useWorkspaceNavigation(), section 4 ``` @@ -755,7 +755,7 @@ const channel = client.channel('messaging', 'general'); ```tsx // v15, before rendering const channel = client.channel('messaging', 'general'); -await channel.ensureWatched({ messages: { limit: 30 } }); // or channel.watch(...) +await channel.watch({ messages: { limit: 30 } }); {/* ... */}; ``` @@ -767,7 +767,7 @@ const channel = await getChannel({ client, id: 'general', type: 'messaging' }); ```ts // v15 -const channel = await client.channel('messaging', 'general').ensureWatched(); +const channel = await client.channel('messaging', 'general').watch(); ``` diff --git a/ai-docs/instance-configuration.md b/ai-docs/instance-configuration.md index 4a3f41175..109f6694b 100644 --- a/ai-docs/instance-configuration.md +++ b/ai-docs/instance-configuration.md @@ -120,7 +120,7 @@ does differ, so the per-parent slices override the shared one field by field. ### `pageSize` is not the initial query's `messages.limit` -`channel.ensureWatched({ messages: { limit: 30 } })` sizes the **initial** channel query, which the code +`channel.watch({ messages: { limit: 30 } })` sizes the **initial** channel query, which the code that opens the channel now sends (`` no longer queries, and its `channelQueryOptions` prop is removed). The paginator's `pageSize` sizes **every subsequent page** — what a scroll-up fetches. They are different numbers and you usually want both: @@ -130,7 +130,7 @@ chatClient.config.set({ channel: { messagePaginator: { pageSize: 50 } } }); const channel = await chatClient.channelManager .ensure({ id: 'general', type: 'messaging' }) - .ensureWatched({ messages: { limit: 30 } }); + .watch({ messages: { limit: 30 } }); …; ``` diff --git a/ai-docs/v15-breaking-changes.md b/ai-docs/v15-breaking-changes.md index 3304fedfb..d71f41024 100644 --- a/ai-docs/v15-breaking-changes.md +++ b/ai-docs/v15-breaking-changes.md @@ -842,7 +842,7 @@ A threads view is declared the same way (a `{ id: 'threads', slots: [...] }` lay - 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. - Reading "the active channel": inside the channel subtree use `useChannel()`; elsewhere `useWorkspaceNavigation().openChannels` (main entry) or `useSlotChannel({ slot })` (`slot-layout`). - Opening a channel programmatically: `useWorkspaceNavigation().openChannel(channel)`; to also list it without a re-query, `client.channelManager.ingestChannel(channel)` (stream-chat). - - The `watchers` argument: query the channel yourself (`channel.ensureWatched({ watchers: { limit } })` or `channel.query(...)`, stream-chat) before opening it. + - The `watchers` argument: query the channel yourself (`channel.watch({ watchers: { limit } })` or `channel.query(...)`, stream-chat) before opening it. - Evidence: - `release-v14:src/context/ChatContext.tsx:45-49` (`setActiveChannel`), `:54` (`channel?: Channel`); `release-v14:src/components/Chat/hooks/useChat.ts:116-131,137-144` - `master:src/context/ChatContext.tsx:27-46` (no `channel` / `setActiveChannel`); `master:src/components/Chat/hooks/useChat.ts:68-71` @@ -1160,11 +1160,11 @@ import { - `ChannelProps.initializeOnMount?: boolean` (default `true`: `Channel` called `channel.watch()`) - `ChannelProps.channelQueryOptions?: ChannelQueryOptions` - `getChannel({ client, channel?, id?, members?, options?, type? })` -- 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. +- 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 `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. - Evidence: - `release-v14:src/components/Channel/Channel.tsx:107,141`; `release-v14:src/utils/getChannel.ts:34` - `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 - - `node_modules/stream-chat/dist/types/channel.d.ts:747` (`ensureWatched(options?: ChannelGetOrCreateRequest): Promise`); `node_modules/stream-chat/dist/types/ChannelManager.d.ts:157` (`ensure`) + - `node_modules/stream-chat/dist/types/channel.d.ts` (`watch(options?: ChannelGetOrCreateRequest, requestOptions?: StreamRequestOptions): Promise`); `node_modules/stream-chat/dist/types/ChannelManager.d.ts:157` (`ensure`) - commits: `088c795c1` (PR #3286); `a41eed56f feat!: replace StreamChat.activeChannels with entity item index for loaded channels` (PR #3310, removes `getChannel`) - corpus: `docs/02-ui-components__05-channel-list__02-channel_list_context__2.tsx:11` TS2724 (`getChannel`) - Docs impact: @@ -1189,12 +1189,12 @@ const channel = await client.channelManager data: { members: [{ user_id: userId }, { user_id: otherUserId }] }, type: 'messaging', }) - .ensureWatched({ messages: { limit: 30 } }); + .watch({ messages: { limit: 30 } }); {/* ... */}; ``` -`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`). +`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`). --- @@ -1211,7 +1211,7 @@ After (v15): ```ts const channel = await client.channelManager .ensure({ id: 'general', type: 'messaging' }) - .ensureWatched(); + .watch(); ``` ### BC-021: `Channel` no longer writes `document.title`; `activeUnreadHandler` was removed @@ -1933,15 +1933,15 @@ const CustomThreadHeader = () => { - Status: confirmed - Area: search - User impact: silent. - - 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). + - 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). - default result items call `openChannel` (workspace navigation: no-op outside `ChatView`, BC-013) and `client.channelManager.ingestChannel(channel)` instead of `setActiveChannel` + `ChannelListContext.setChannels`. - 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`. - Old API: - default `ChannelSearchResultItem` / `MessageSearchResultItem` / `UserSearchResultItem` behavior -- 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. +- 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. - Evidence: - `release-v14:src/components/Search/SearchResults/SearchResultItem.tsx:23-30,45-74,99-114` - - `master:src/components/Search/SearchResults/SearchResultItem.tsx:66-105` (`ChannelSearchResultItem`: `openChannel`, `ensureWatched`, `ingestChannel`), `:117-180` (`MessageSearchResultItem`: `jumpToMessage(item.id, { watchChannel: true })`), `:199-243` + - `master:src/components/Search/SearchResults/SearchResultItem.tsx:66-105` (`ChannelSearchResultItem`: `openChannel`, `watch`, `ingestChannel`), `:117-180` (`MessageSearchResultItem`: `jumpToMessage(item.id, { watchChannel: true })`), `:199-243` - 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) - Docs impact: - `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` From aca83595f775d46f8a3a02ab302d6e7a0603312e Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 23 Oct 2026 17:50:12 +0200 Subject: [PATCH 4/9] refactor: get channels through client.channel() stream-chat marks ChannelManager.ensure() internal; client.channel() is the public way to get a channel and calls it. The search result DM, the "send direct message" action, the mock builders and the tests use client.channel(). Co-Authored-By: Claude Opus 5.5 --- .../Attachment/__tests__/Card.test.tsx | 5 +---- .../Channel/__tests__/Channel.test.tsx | 10 ++-------- .../__tests__/ChannelHeader.test.tsx | 16 +++------------ .../__tests__/ChannelLists.test.tsx | 4 +--- .../__tests__/useChannelMemberStates.test.ts | 4 ++-- .../__tests__/ChannelListItemUI.test.tsx | 2 +- .../ChannelListItem/__tests__/utils.test.ts | 2 +- .../__tests__/useChannelPreviewInfo.test.tsx | 5 +---- .../__tests__/useIsChannelMuted.test.tsx | 20 ++++--------------- .../useMessageDeliveryStatus.test.tsx | 5 +---- src/components/Chat/__tests__/Chat.test.tsx | 2 +- .../Message/__tests__/Message.test.tsx | 2 +- .../hooks/__tests__/useDeleteHandler.test.tsx | 5 +---- .../__tests__/useReactionHandler.test.tsx | 5 +---- .../useMessageComposerController.test.tsx | 5 +---- .../__tests__/MessageList.test.tsx | 15 +++----------- .../__tests__/VirtualizedMessageList.test.tsx | 15 +++----------- .../__tests__/messageSourceKey.test.ts | 2 +- .../Search/SearchResults/SearchResultItem.tsx | 7 ++----- .../__tests__/SearchResultItem.test.tsx | 9 +++------ .../__tests__/TypingIndicator.test.tsx | 2 +- src/mock-builders/utils.ts | 13 +++++------- .../ChannelMemberActions.defaults.tsx | 7 ++----- .../SendDirectMessageAction.test.tsx | 2 +- .../AvatarWithChannelDetail.test.tsx | 2 +- .../SlotLayout/__tests__/ChatView.test.tsx | 2 +- .../__tests__/DisposedChannelRelease.test.tsx | 4 ++-- .../__tests__/SupersededChannelSwap.test.tsx | 18 ++++++++--------- 28 files changed, 55 insertions(+), 135 deletions(-) diff --git a/src/components/Attachment/__tests__/Card.test.tsx b/src/components/Attachment/__tests__/Card.test.tsx index 61222fe73..3e2059c0f 100644 --- a/src/components/Attachment/__tests__/Card.test.tsx +++ b/src/components/Attachment/__tests__/Card.test.tsx @@ -61,10 +61,7 @@ describe('Card', () => { beforeAll(async () => { chatClient = await getTestClientWithUser({ id: user.id }); useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); - channel = chatClient.channelManager.ensure({ - id: mockedChannel['id'], - type: 'messaging', - }); + channel = chatClient.channel('messaging', mockedChannel['id']); channel.query(); }); diff --git a/src/components/Channel/__tests__/Channel.test.tsx b/src/components/Channel/__tests__/Channel.test.tsx index aa4213842..60e43a9a7 100644 --- a/src/components/Channel/__tests__/Channel.test.tsx +++ b/src/components/Channel/__tests__/Channel.test.tsx @@ -122,10 +122,7 @@ const initClient = async ({ const chatClient = await getTestClientWithUser(user); // eslint-disable-next-line react-hooks/rules-of-hooks useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); - const channel = chatClient.channelManager.ensure({ - id: mockedChannel.channel.id, - type: 'messaging', - }); + const channel = chatClient.channel('messaging', mockedChannel.channel.id); // `Channel` does not query: the caller initializes the channel. await channel.watch(); @@ -213,10 +210,7 @@ describe('Channel', () => { // `Channel` binds and subscribes to the channel but does not fetch: an unqueried channel stays // unqueried and its children render what an empty channel renders. const { chatClient } = await setup(); - const unqueried = chatClient.channelManager.ensure({ - id: 'never-queried', - type: 'messaging', - }); + const unqueried = chatClient.channel('messaging', 'never-queried'); const watchSpy = vi.spyOn(unqueried, 'watch'); await renderComponent({ channel: unqueried, chatClient }); diff --git a/src/components/ChannelHeader/__tests__/ChannelHeader.test.tsx b/src/components/ChannelHeader/__tests__/ChannelHeader.test.tsx index 0d44a7db7..c36c1ece7 100644 --- a/src/components/ChannelHeader/__tests__/ChannelHeader.test.tsx +++ b/src/components/ChannelHeader/__tests__/ChannelHeader.test.tsx @@ -86,11 +86,7 @@ async function renderComponent({ testChannel1 = generateChannel({ ...defaultChannelState, channel: channelData }); /* eslint-disable-next-line react-hooks/rules-of-hooks */ useMockedApis(client, [getOrCreateChannelApi(testChannel1)]); - const channel = client.channelManager.ensure({ - data: channelData, - id: testChannel1.channel.id, - type: channelType, - }); + const channel = client.channel(channelType, testChannel1.channel.id, channelData); await channel.query(); if (typing) channel.state.typing = typing; @@ -225,10 +221,7 @@ describe('ChannelHeader', () => { client = await getTestClientWithUser(user1); testChannel1 = generateChannel({ ...defaultChannelState }); useMockedApis(client, [getOrCreateChannelApi(testChannel1)]); - const channel = client.channelManager.ensure({ - id: testChannel1.channel.id, - type: 'messaging', - }); + const channel = client.channel('messaging', testChannel1.channel.id); await channel.query(); renderComponentBase({ @@ -252,10 +245,7 @@ describe('ChannelHeader', () => { testChannel1 = generateChannel({ ...defaultChannelState }); /* eslint-disable-next-line react-hooks/rules-of-hooks */ useMockedApis(client, [getOrCreateChannelApi(testChannel1)]); - const channel = client.channelManager.ensure({ - id: testChannel1.channel.id, - type: 'messaging', - }); + const channel = client.channel('messaging', testChannel1.channel.id); await channel.query(); render( diff --git a/src/components/ChannelList/__tests__/ChannelLists.test.tsx b/src/components/ChannelList/__tests__/ChannelLists.test.tsx index 608b7bed5..7367d6814 100644 --- a/src/components/ChannelList/__tests__/ChannelLists.test.tsx +++ b/src/components/ChannelList/__tests__/ChannelLists.test.tsx @@ -38,9 +38,7 @@ const seededPaginator = (client: StreamChat, id: string) => { const paginator = new ChannelPaginator({ client, id }); paginator.setItems({ isLastPage: true, - valueOrFactory: [ - client.channelManager.ensure({ id: id.replace(':', '-'), type: 'messaging' }), - ], + valueOrFactory: [client.channel('messaging', id.replace(':', '-'))], }); return paginator; }; diff --git a/src/components/ChannelList/hooks/__tests__/useChannelMemberStates.test.ts b/src/components/ChannelList/hooks/__tests__/useChannelMemberStates.test.ts index ab9c3dba8..c102e061c 100644 --- a/src/components/ChannelList/hooks/__tests__/useChannelMemberStates.test.ts +++ b/src/components/ChannelList/hooks/__tests__/useChannelMemberStates.test.ts @@ -13,7 +13,7 @@ const member = (id: string) => ({ describe('channel member state hooks', () => { it('useChannelMembersState follows members applied without an event, such as a query response', async () => { const client = await getTestClientWithUser({ id: 'ann' }); - const channel = client.channelManager.ensure({ id: 'dm', type: 'messaging' }); + const channel = client.channel('messaging', 'dm'); const { result } = renderHook(() => useChannelMembersState(channel)); expect(result.current).toEqual({}); @@ -29,7 +29,7 @@ describe('channel member state hooks', () => { it('useChannelMembershipState follows the membership applied without an event', async () => { const client = await getTestClientWithUser({ id: 'ann' }); - const channel = client.channelManager.ensure({ id: 'dm', type: 'messaging' }); + const channel = client.channel('messaging', 'dm'); const { result } = renderHook(() => useChannelMembershipState(channel)); act(() => { diff --git a/src/components/ChannelListItem/__tests__/ChannelListItemUI.test.tsx b/src/components/ChannelListItem/__tests__/ChannelListItemUI.test.tsx index dbc048b18..a30145665 100644 --- a/src/components/ChannelListItem/__tests__/ChannelListItemUI.test.tsx +++ b/src/components/ChannelListItem/__tests__/ChannelListItemUI.test.tsx @@ -87,7 +87,7 @@ describe('ChannelPreviewMessenger', () => { // eslint-disable-next-line react-hooks/rules-of-hooks useMockedApis(chatClient, [getOrCreateChannelApi(c)]); - channel = chatClient.channelManager.ensure({ type: 'messaging' }); + channel = chatClient.channel('messaging'); await channel.watch(); }; diff --git a/src/components/ChannelListItem/__tests__/utils.test.ts b/src/components/ChannelListItem/__tests__/utils.test.ts index 9535b6d51..749309c21 100644 --- a/src/components/ChannelListItem/__tests__/utils.test.ts +++ b/src/components/ChannelListItem/__tests__/utils.test.ts @@ -36,7 +36,7 @@ describe('ChannelPreview utils', () => { // eslint-disable-next-line react-hooks/rules-of-hooks useMockedApis(chatClient, [getOrCreateChannelApi(c)]); - const channel = chatClient.channelManager.ensure({ type: 'messaging' }); + const channel = chatClient.channel('messaging'); await channel.watch(); diff --git a/src/components/ChannelListItem/hooks/__tests__/useChannelPreviewInfo.test.tsx b/src/components/ChannelListItem/hooks/__tests__/useChannelPreviewInfo.test.tsx index bf4e2a100..00ba39f72 100644 --- a/src/components/ChannelListItem/hooks/__tests__/useChannelPreviewInfo.test.tsx +++ b/src/components/ChannelListItem/hooks/__tests__/useChannelPreviewInfo.test.tsx @@ -32,10 +32,7 @@ const getClientAndChannel = async (channelOverrides = {}) => { // eslint-disable-next-line react-hooks/rules-of-hooks useMockedApis(client, [getOrCreateChannelApi(mockedChannel)]); - const channel = client.channelManager.ensure({ - id: mockedChannel.channel.id, - type: 'messaging', - }); + const channel = client.channel('messaging', mockedChannel.channel.id); await channel.watch(); return { channel, client }; diff --git a/src/components/ChannelListItem/hooks/__tests__/useIsChannelMuted.test.tsx b/src/components/ChannelListItem/hooks/__tests__/useIsChannelMuted.test.tsx index e6a7e70a8..b156db675 100644 --- a/src/components/ChannelListItem/hooks/__tests__/useIsChannelMuted.test.tsx +++ b/src/components/ChannelListItem/hooks/__tests__/useIsChannelMuted.test.tsx @@ -32,10 +32,7 @@ describe('useIsChannelMuted', () => { // A channel that was never watched is not initialized, and the imperative // `channel.muteStatus()` throws `_checkInitialized` on it. The reactive `muteStatus` slice is // safe to read. - const channel = client.channelManager.ensure({ - id: 'never-watched-channel', - type: 'messaging', - }); + const channel = client.channel('messaging', 'never-watched-channel'); expect(channel.initialized).toBe(false); @@ -50,10 +47,7 @@ describe('useIsChannelMuted', () => { const client = await getTestClientWithUser(clientUser); const mockedChannel = generateChannel(); useMockedApis(client, [getOrCreateChannelApi(mockedChannel)]); - const channel = client.channelManager.ensure({ - id: mockedChannel.channel.id, - type: 'messaging', - }); + const channel = client.channel('messaging', mockedChannel.channel.id); await channel.watch(); const { result } = renderHook(() => useIsChannelMuted(channel), { @@ -67,10 +61,7 @@ describe('useIsChannelMuted', () => { const client = await getTestClientWithUser(clientUser); const mockedChannel = generateChannel(); useMockedApis(client, [getOrCreateChannelApi(mockedChannel)]); - const channel = client.channelManager.ensure({ - id: mockedChannel.channel.id, - type: 'messaging', - }); + const channel = client.channel('messaging', mockedChannel.channel.id); await channel.watch(); expect(channel.initialized).toBe(true); @@ -90,10 +81,7 @@ describe('useIsChannelMuted', () => { const client = await getTestClientWithUser(clientUser); const mockedChannel = generateChannel(); useMockedApis(client, [getOrCreateChannelApi(mockedChannel)]); - const channel = client.channelManager.ensure({ - id: mockedChannel.channel.id, - type: 'messaging', - }); + const channel = client.channel('messaging', mockedChannel.channel.id); await channel.watch(); const { result } = renderHook(() => useIsChannelMuted(channel), { diff --git a/src/components/ChannelListItem/hooks/__tests__/useMessageDeliveryStatus.test.tsx b/src/components/ChannelListItem/hooks/__tests__/useMessageDeliveryStatus.test.tsx index b8d71d3d3..8b26585cc 100644 --- a/src/components/ChannelListItem/hooks/__tests__/useMessageDeliveryStatus.test.tsx +++ b/src/components/ChannelListItem/hooks/__tests__/useMessageDeliveryStatus.test.tsx @@ -41,10 +41,7 @@ const getClientAndChannel = async (channelData = {}, user = ownUser) => { // eslint-disable-next-line react-hooks/rules-of-hooks useMockedApis(client, [getOrCreateChannelApi(mockedChannel)]); - const channel = client.channelManager.ensure({ - id: mockedChannel.channel.id, - type: 'messaging', - }); + const channel = client.channel('messaging', mockedChannel.channel.id); await channel.watch(); return { diff --git a/src/components/Chat/__tests__/Chat.test.tsx b/src/components/Chat/__tests__/Chat.test.tsx index baf7ffcff..eecd6d6e1 100644 --- a/src/components/Chat/__tests__/Chat.test.tsx +++ b/src/components/Chat/__tests__/Chat.test.tsx @@ -346,7 +346,7 @@ describe('Chat', () => { // The search results and the threads view replace the channel list, and the lists must keep // receiving events meanwhile. const client = await getTestClientWithUser({ id: 'user_x' }); - const channel = client.channelManager.ensure({ id: 'routed', type: 'messaging' }); + const channel = client.channel('messaging', 'routed'); const ingestChannel = vi.spyOn(client.channelManager, 'ingestChannel'); const channelUpdated = () => client.dispatchEvent({ diff --git a/src/components/Message/__tests__/Message.test.tsx b/src/components/Message/__tests__/Message.test.tsx index b41f4cae9..c9f3f0e47 100644 --- a/src/components/Message/__tests__/Message.test.tsx +++ b/src/components/Message/__tests__/Message.test.tsx @@ -111,7 +111,7 @@ async function renderComponent({ }); // eslint-disable-next-line react-hooks/rules-of-hooks useMockedApis(client, [getOrCreateChannelApi(channelData)]); - channel = client.channelManager.ensure({ id: channelData.channel.id, type }); + channel = client.channel(type, channelData.channel.id); await channel.watch(); client.channelServerConfigsStore.partialNext({ configs: { ...client.channelServerConfigs, [channel.cid]: config as never }, diff --git a/src/components/Message/hooks/__tests__/useDeleteHandler.test.tsx b/src/components/Message/hooks/__tests__/useDeleteHandler.test.tsx index 805181078..049c596b1 100644 --- a/src/components/Message/hooks/__tests__/useDeleteHandler.test.tsx +++ b/src/components/Message/hooks/__tests__/useDeleteHandler.test.tsx @@ -47,10 +47,7 @@ describe('useDeleteHandler custom hook', () => { client = await getTestClientWithUser(generateUser()); const channelData = generateChannel(); useMockedApis(client, [getOrCreateChannelApi(channelData)]); - channel = client.channelManager.ensure({ - id: channelData.channel.id, - type: 'messaging', - }); + channel = client.channel('messaging', channelData.channel.id); }); afterEach(() => { diff --git a/src/components/Message/hooks/__tests__/useReactionHandler.test.tsx b/src/components/Message/hooks/__tests__/useReactionHandler.test.tsx index 3590dd924..119329158 100644 --- a/src/components/Message/hooks/__tests__/useReactionHandler.test.tsx +++ b/src/components/Message/hooks/__tests__/useReactionHandler.test.tsx @@ -55,10 +55,7 @@ describe('useReactionHandler custom hook', () => { client = await getTestClientWithUser(alice); const channelData = generateChannel(); useMockedApis(client, [getOrCreateChannelApi(channelData)]); - channel = client.channelManager.ensure({ - id: channelData.channel.id, - type: 'messaging', - }); + channel = client.channel('messaging', channelData.channel.id); }); afterEach(() => { diff --git a/src/components/MessageComposer/hooks/__tests__/useMessageComposerController.test.tsx b/src/components/MessageComposer/hooks/__tests__/useMessageComposerController.test.tsx index a691849e8..ef894b95c 100644 --- a/src/components/MessageComposer/hooks/__tests__/useMessageComposerController.test.tsx +++ b/src/components/MessageComposer/hooks/__tests__/useMessageComposerController.test.tsx @@ -65,10 +65,7 @@ describe('useMessageComposerController', () => { client = await getTestClientWithUser({ id: 'test-user' }); const mockedChannelData = generateChannel(); useMockedApis(client, [getOrCreateChannelApi(mockedChannelData)]); - channel = client.channelManager.ensure({ - id: mockedChannelData.channel.id, - type: 'messaging', - }); + channel = client.channel('messaging', mockedChannelData.channel.id); await channel.watch(); }); diff --git a/src/components/MessageList/__tests__/MessageList.test.tsx b/src/components/MessageList/__tests__/MessageList.test.tsx index 1e38903be..efb799ff5 100644 --- a/src/components/MessageList/__tests__/MessageList.test.tsx +++ b/src/components/MessageList/__tests__/MessageList.test.tsx @@ -209,10 +209,7 @@ describe('MessageList', () => { beforeEach(async () => { chatClient = await getTestClientWithUser({ id: 'vishal' }); useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannelData)]); - channel = chatClient.channelManager.ensure({ - id: mockedChannelData['id'], - type: 'messaging', - }); + channel = chatClient.channel('messaging', mockedChannelData['id']); await channel.watch(); markReadMock = vi @@ -2089,10 +2086,7 @@ describe('MessageList notification area', () => { it('stays on screen when the channel being opened is still bootstrapping', async () => { const { channelA, client } = await setup(); // Never watched: `Channel` renders it without querying, so the switch commits immediately. - const bootstrapping = client.channelManager.ensure({ - id: 'never-watched', - type: 'messaging', - }); + const bootstrapping = client.channel('messaging', 'never-watched'); const { rerender } = render(renderChannel(client, channelA)); raise(client); @@ -2162,10 +2156,7 @@ describe('MessageList NewMessageNotification', () => { beforeEach(async () => { chatClient = await getTestClientWithUser({ id: 'vishal' }); useMockedApis(chatClient, [getOrCreateChannelApi(windowChannelData)]); - channel = chatClient.channelManager.ensure({ - id: windowChannelData.channel.id, - type: 'messaging', - }); + channel = chatClient.channel('messaging', windowChannelData.channel.id); await channel.watch(); vi.spyOn(channel, 'markRead').mockResolvedValue(fromPartial(markReadApi(channel))); }); diff --git a/src/components/MessageList/__tests__/VirtualizedMessageList.test.tsx b/src/components/MessageList/__tests__/VirtualizedMessageList.test.tsx index 3bad81eab..7303a816f 100644 --- a/src/components/MessageList/__tests__/VirtualizedMessageList.test.tsx +++ b/src/components/MessageList/__tests__/VirtualizedMessageList.test.tsx @@ -114,10 +114,7 @@ async function createChannel(empty = false) { }); const client = await getTestClientWithUser({ id: 'id' }); useMockedApis(client, [getOrCreateChannelApi(mockedChannel)]); // eslint-disable-line react-hooks/rules-of-hooks - const channel = client.channelManager.ensure({ - id: mockedChannel.channel.id, - type: 'messaging', - }); + const channel = client.channel('messaging', mockedChannel.channel.id); await channel.watch(); return { channel, client }; @@ -251,10 +248,7 @@ describe('VirtualizedMessageList', () => { }); const client = await getTestClientWithUser({ id: 'id' }); useMockedApis(client, [getOrCreateChannelApi(mockedChannel)]); // eslint-disable-line react-hooks/rules-of-hooks - const channel = client.channelManager.ensure({ - id: mockedChannel.channel.id, - type: 'messaging', - }); + const channel = client.channel('messaging', mockedChannel.channel.id); await channel.watch(); // With newer pages still to load, scrollToBottom jumps to the latest message set instead of // scrolling, so pin it to the branch that targets a Virtuoso index. @@ -396,10 +390,7 @@ describe('VirtualizedMessageList', () => { }); const client = await getTestClientWithUser({ id: 'id' }); useMockedApis(client, [getOrCreateChannelApi(mockedChannel)]); // eslint-disable-line react-hooks/rules-of-hooks - const channel = client.channelManager.ensure({ - id: mockedChannel.channel.id, - type: 'messaging', - }); + const channel = client.channel('messaging', mockedChannel.channel.id); await channel.watch(); await act(() => { diff --git a/src/components/MessageList/__tests__/messageSourceKey.test.ts b/src/components/MessageList/__tests__/messageSourceKey.test.ts index 7204ef6aa..5e9237f94 100644 --- a/src/components/MessageList/__tests__/messageSourceKey.test.ts +++ b/src/components/MessageList/__tests__/messageSourceKey.test.ts @@ -17,7 +17,7 @@ describe('getMessageSourceKey', () => { }); // A replacement instance for the same cid is a different object, and must key differently. client.channelManager.removeChannel(first.cid); - const second = client.channelManager.ensure({ id: 'channel-a', type: 'messaging' }); + const second = client.channel('messaging', 'channel-a'); expect(getMessageSourceKey({ channel: first })).toBe( getMessageSourceKey({ channel: first }), diff --git a/src/components/Search/SearchResults/SearchResultItem.tsx b/src/components/Search/SearchResults/SearchResultItem.tsx index 2305af72f..0aadb27cb 100644 --- a/src/components/Search/SearchResults/SearchResultItem.tsx +++ b/src/components/Search/SearchResults/SearchResultItem.tsx @@ -208,11 +208,8 @@ export const UserSearchResultItem = ({ item, onSelect }: UserSearchResultItemPro onSelect(event); return; } - const newChannel = channelManager.ensure({ - data: { - members: [{ user_id: client.userId as string }, { user_id: item.id }], - }, - type: directMessagingChannelType, + const newChannel = client.channel(directMessagingChannelType, { + members: [{ user_id: client.userId as string }, { user_id: item.id }], }); // The event is forwarded so an `openChannel` override can honor ⌘/ctrl-click. openChannel(newChannel, { event }); diff --git a/src/components/Search/__tests__/SearchResultItem.test.tsx b/src/components/Search/__tests__/SearchResultItem.test.tsx index 6a761c735..acfc34826 100644 --- a/src/components/Search/__tests__/SearchResultItem.test.tsx +++ b/src/components/Search/__tests__/SearchResultItem.test.tsx @@ -245,10 +245,7 @@ describe('SearchResultItem Components', () => { }); const { id, type } = messageResponseData.channel; const jumpToMessage = vi - .spyOn( - client.channelManager.ensure({ id, type }).messagePaginator, - 'jumpToMessage', - ) + .spyOn(client.channel(type, id).messagePaginator, 'jumpToMessage') .mockResolvedValue(true); await act(() => { @@ -272,7 +269,7 @@ describe('SearchResultItem Components', () => { SearchResultItemComponent, }); const { id, type } = messageResponseData.channel; - const channel = client.channelManager.ensure({ id, type }); + const channel = client.channel(type, id); channel.state.partialNext({ initialized: false }); const calls: string[] = []; vi.spyOn(channel, 'watch').mockImplementation(() => { @@ -467,7 +464,7 @@ describe('SearchResultItem Components', () => { SearchResultItemComponent, userData: user, }); - const stored = client.channelManager.ensure({ id: 'stored-dm', type: 'messaging' }); + const stored = client.channel('messaging', 'stored-dm'); const watch = vi.spyOn(Channel.prototype, 'watch').mockResolvedValue(stored); await act(() => { diff --git a/src/components/TypingIndicator/__tests__/TypingIndicator.test.tsx b/src/components/TypingIndicator/__tests__/TypingIndicator.test.tsx index 968f1f2b8..9af1934bf 100644 --- a/src/components/TypingIndicator/__tests__/TypingIndicator.test.tsx +++ b/src/components/TypingIndicator/__tests__/TypingIndicator.test.tsx @@ -221,7 +221,7 @@ describe('TypingIndicator', () => { channel: { config: fromPartial({ typing_events: true }) }, }); useMockedApis(client, [getOrCreateChannelApi(ch)]); - channel = client.channelManager.ensure({ id: ch.id, type: 'messaging' }); + channel = client.channel('messaging', ch.id); await channel.watch(); }); diff --git a/src/mock-builders/utils.ts b/src/mock-builders/utils.ts index b2bc70ea7..754172521 100644 --- a/src/mock-builders/utils.ts +++ b/src/mock-builders/utils.ts @@ -48,10 +48,7 @@ export async function createClientWithChannel({ const client = existingClient || (await getTestClientWithUser({ id: users[0].id })); useMockedApis(client, [getOrCreateChannelApi(mockedChannel)]); // eslint-disable-line react-hooks/rules-of-hooks - const channel = client.channelManager.ensure({ - id: mockedChannel['id'], - type: 'messaging', - }); + const channel = client.channel('messaging', mockedChannel['id']); await channel.watch(); return { channel, client, users }; @@ -73,10 +70,10 @@ export const initChannelFromData = async ({ // eslint-disable-next-line react-hooks/rules-of-hooks useMockedApis(client, [getOrCreateChannelApi(mockedChannelData)]); - const channel = client.channelManager.ensure({ - id: mockedChannelData.channel.id, - type: mockedChannelData.channel.type, - }); + const channel = client.channel( + mockedChannelData.channel.type, + mockedChannelData.channel.id, + ); await channel.watch(); // Written to the client's store, which `channel.serverConfig` reads and `channel.config` is // derived from; stubbing an accessor would leave `channel.config` stale. Keyed by cid: a diff --git a/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberActions.defaults.tsx b/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberActions.defaults.tsx index 163e13178..c6f9a1275 100644 --- a/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberActions.defaults.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberActions.defaults.tsx @@ -232,11 +232,8 @@ const SendDirectMessageAction = () => { setIsSending(true); try { - const directMessageChannel = client.channelManager.ensure({ - data: { - members: [client.userID, targetUserId].map((user_id) => ({ user_id })), - }, - type: channel.type, + const directMessageChannel = client.channel(channel.type, { + members: [client.userID, targetUserId].map((user_id) => ({ user_id })), }); // The stored instance, which differs from `directMessageChannel` if another instance took the // DM's `cid` while the watch was in flight. diff --git a/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/__tests__/SendDirectMessageAction.test.tsx b/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/__tests__/SendDirectMessageAction.test.tsx index a7d2b2a2d..9bfe4e58b 100644 --- a/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/__tests__/SendDirectMessageAction.test.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/__tests__/SendDirectMessageAction.test.tsx @@ -38,7 +38,7 @@ describe('SendDirectMessage action', () => { const renderAction = (created: Channel) => { vi.mocked(useChatContext).mockReturnValue({ channelManager: { ingestChannel }, - client: { channelManager: { ensure: () => created }, userID: 'user-me' }, + client: { channel: () => created, userID: 'user-me' }, } as unknown as ReturnType); render( ({ type: 'messaging' })}> diff --git a/src/plugins/ChannelDetail/__tests__/AvatarWithChannelDetail.test.tsx b/src/plugins/ChannelDetail/__tests__/AvatarWithChannelDetail.test.tsx index d83e2e3f1..ec3cf0b4e 100644 --- a/src/plugins/ChannelDetail/__tests__/AvatarWithChannelDetail.test.tsx +++ b/src/plugins/ChannelDetail/__tests__/AvatarWithChannelDetail.test.tsx @@ -19,7 +19,7 @@ describe('AvatarWithChannelDetail', () => { beforeEach(async () => { client = await getTestClientWithUser({ id: 'alice' }); // Created locally (e.g. a "new conversation" flow), so the server does not have it yet. - channel = client.channelManager.ensure({ id: 'local', type: 'messaging' }); + channel = client.channel('messaging', 'local'); }); const renderAvatar = () => diff --git a/src/plugins/SlotLayout/__tests__/ChatView.test.tsx b/src/plugins/SlotLayout/__tests__/ChatView.test.tsx index 4097c047b..9fc9bcc2a 100644 --- a/src/plugins/SlotLayout/__tests__/ChatView.test.tsx +++ b/src/plugins/SlotLayout/__tests__/ChatView.test.tsx @@ -212,7 +212,7 @@ describe('ChatView built-in workspace layout empty state', () => { initialState: { activeView, availableSlots: ['slot1', 'slot2'] }, }); if (bindChannel) { - const channel = client.channelManager.ensure({ id: 'general', type: 'messaging' }); + const channel = client.channel('messaging', 'general'); layoutController.bind( 'slot1', createChatViewSlotBinding({ key: channel.cid, kind: 'channel', source: channel }), diff --git a/src/plugins/SlotLayout/__tests__/DisposedChannelRelease.test.tsx b/src/plugins/SlotLayout/__tests__/DisposedChannelRelease.test.tsx index 57803f192..10d4e19b8 100644 --- a/src/plugins/SlotLayout/__tests__/DisposedChannelRelease.test.tsx +++ b/src/plugins/SlotLayout/__tests__/DisposedChannelRelease.test.tsx @@ -24,7 +24,7 @@ describe('DisposedChannelRelease', () => { beforeEach(async () => { client = await getTestClientWithUser({ id: 'ann' }); - channel = client.channelManager.ensure({ id: 'general', type: 'messaging' }); + channel = client.channel('messaging', 'general'); layoutController = new LayoutController({ initialState: { availableSlots: ['slot1'] }, }); @@ -90,7 +90,7 @@ describe('DisposedChannelRelease', () => { }); it('leaves a slot that shows another channel', () => { - const other = client.channelManager.ensure({ id: 'other', type: 'messaging' }); + const other = client.channel('messaging', 'other'); renderInChatView(); act(() => { diff --git a/src/plugins/SlotLayout/__tests__/SupersededChannelSwap.test.tsx b/src/plugins/SlotLayout/__tests__/SupersededChannelSwap.test.tsx index 5b6d9d7c3..bbfd86d7a 100644 --- a/src/plugins/SlotLayout/__tests__/SupersededChannelSwap.test.tsx +++ b/src/plugins/SlotLayout/__tests__/SupersededChannelSwap.test.tsx @@ -15,8 +15,8 @@ describe('SupersededChannelSwap', () => { beforeEach(async () => { client = await getTestClientWithUser({ id: 'ann' }); - previous = client.channelManager.ensure({ id: 'previous', type: 'messaging' }); - successor = client.channelManager.ensure({ id: 'successor', type: 'messaging' }); + previous = client.channel('messaging', 'previous'); + successor = client.channel('messaging', 'successor'); layoutController = new LayoutController({ initialState: { availableSlots: ['slot1'] }, }); @@ -43,9 +43,8 @@ describe('SupersededChannelSwap', () => { ); it('binds the slot again under the real cid once a channel created from members gets it', () => { - const created = client.channelManager.ensure({ - data: { members: [{ user_id: 'ann' }, { user_id: 'bob' }] }, - type: 'messaging', + const created = client.channel('messaging', { + members: [{ user_id: 'ann' }, { user_id: 'bob' }], }); const temporaryCid = created.cid; layoutController.bind( @@ -109,7 +108,7 @@ describe('SupersededChannelSwap', () => { }); it('keeps a binding the slot got after its channel was superseded', () => { - const other = client.channelManager.ensure({ id: 'other', type: 'messaging' }); + const other = client.channel('messaging', 'other'); renderSwap(); // The slot is bound to another channel before the swap's effect runs. @@ -125,11 +124,10 @@ describe('SupersededChannelSwap', () => { }); it('keeps a binding the slot got before its channel took its real cid', () => { - const created = client.channelManager.ensure({ - data: { members: [{ user_id: 'ann' }, { user_id: 'bob' }] }, - type: 'messaging', + const created = client.channel('messaging', { + members: [{ user_id: 'ann' }, { user_id: 'bob' }], }); - const other = client.channelManager.ensure({ id: 'other', type: 'messaging' }); + const other = client.channel('messaging', 'other'); layoutController.bind( 'slot1', createChatViewSlotBinding({ key: created.cid, kind: 'channel', source: created }), From f072d265fc0efbf1c3bc3087539bc1050ea39afd Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 23 Oct 2026 17:50:17 +0200 Subject: [PATCH 5/9] chore(examples): get channels through client.channel() Co-Authored-By: Claude Opus 5.5 --- .../src/components/AIChatApp/AIChatApp.tsx | 7 ++--- .../src/3-core-component-setup/App.tsx | 18 +++++------ .../src/6-custom-ui-components/App.tsx | 16 ++++------ examples/tutorial/src/7-emoji-picker/App.tsx | 16 ++++------ .../optional-custom-attachment-type/App.tsx | 14 ++++----- .../tutorial/src/optional-livestream/App.tsx | 14 ++++----- examples/vite/src/App.tsx | 8 ++--- .../serverSideClient.ts | 2 +- .../serverSideMethods.ts | 2 +- .../tabs/Reactions/ReactionsTab.tsx | 2 +- .../vite/src/ChatLayout/WorkspaceUrlSync.tsx | 6 ++-- examples/vite/src/Debug/scenarioChannel.ts | 2 +- .../src/NewConversation/createConversation.ts | 30 +++++++------------ .../src/SingleChannel/SingleChannelApp.tsx | 7 ++--- 14 files changed, 55 insertions(+), 89 deletions(-) diff --git a/examples/ai-chatbot/src/components/AIChatApp/AIChatApp.tsx b/examples/ai-chatbot/src/components/AIChatApp/AIChatApp.tsx index bb95eb937..fa940d737 100644 --- a/examples/ai-chatbot/src/components/AIChatApp/AIChatApp.tsx +++ b/examples/ai-chatbot/src/components/AIChatApp/AIChatApp.tsx @@ -92,15 +92,12 @@ const ChatContent = ({ }, [channel?.id, searchParams, router]); // Load initial channel from URL on mount. `Channel` no longer queries the channel it is given, - // so it is watched here first. `channelManager.ensure()` returns the stored instance for the cid, + // so it is watched here first. `client.channel()` returns the stored instance for the cid, // and `watch()` sends nothing for it once watched and loaded, and joins a watch in flight. useEffect(() => { if (initialChannelId && client && !channel) { const loadChannel = async () => { - const targetChannel = client.channelManager.ensure({ - id: initialChannelId, - type: 'messaging', - }); + const targetChannel = client.channel('messaging', initialChannelId); await targetChannel.watch(); setActiveChannel(targetChannel); }; diff --git a/examples/tutorial/src/3-core-component-setup/App.tsx b/examples/tutorial/src/3-core-component-setup/App.tsx index fe78d6ea4..6238a0e09 100644 --- a/examples/tutorial/src/3-core-component-setup/App.tsx +++ b/examples/tutorial/src/3-core-component-setup/App.tsx @@ -68,23 +68,19 @@ const App = () => { if (!client) return; const initChannel = async () => { - const channel = client.channelManager.ensure({ - data: { - members: [userId], - // custom channel fields live under `custom` since v10 - custom: { - image: 'https://getstream.io/random_png/?name=react', - name: 'Talk about React', - }, + const channel = client.channel('messaging', 'custom_channel_id', { + members: [userId], + // custom channel fields live under `custom` since v10 + custom: { + image: 'https://getstream.io/random_png/?name=react', + name: 'Talk about React', }, - id: 'custom_channel_id', - type: 'messaging', }); // `Channel` binds a channel to its subtree; it does not query one. Whoever supplies the // channel initializes it. // - // `client.channelManager.ensure()` returns the stored instance for this cid, so a re-run of + // `client.channel()` returns the stored instance for this cid, so a re-run of // this effect can hand back a channel that is already loaded. `channel.watch()` sends nothing // for a channel already watched and loaded, and joins a watch in flight, so two overlapping // runs still produce a single request. diff --git a/examples/tutorial/src/6-custom-ui-components/App.tsx b/examples/tutorial/src/6-custom-ui-components/App.tsx index cc970f7ce..200c4ca39 100644 --- a/examples/tutorial/src/6-custom-ui-components/App.tsx +++ b/examples/tutorial/src/6-custom-ui-components/App.tsx @@ -214,17 +214,13 @@ const App = () => { if (!client) return; const initChannel = async () => { - const channel = client.channelManager.ensure({ - data: { - members: [userId], - // custom channel fields live under `custom` since v10 - custom: { - image: 'https://getstream.io/random_png/?name=react-v14', - name: 'Talk about React', - }, + const channel = client.channel('messaging', 'react-tutorial', { + members: [userId], + // custom channel fields live under `custom` since v10 + custom: { + image: 'https://getstream.io/random_png/?name=react-v14', + name: 'Talk about React', }, - id: 'react-tutorial', - type: 'messaging', }); // `Channel` binds a channel to its subtree; it does not query one, so initializing is the diff --git a/examples/tutorial/src/7-emoji-picker/App.tsx b/examples/tutorial/src/7-emoji-picker/App.tsx index 6928ecdfc..34af08963 100644 --- a/examples/tutorial/src/7-emoji-picker/App.tsx +++ b/examples/tutorial/src/7-emoji-picker/App.tsx @@ -138,17 +138,13 @@ const App = () => { if (!client) return; const initChannel = async () => { - const channel = client.channelManager.ensure({ - data: { - members: [userId], - // custom channel fields live under `custom` since v10 - custom: { - image: 'https://getstream.io/random_png/?name=react-v14', - name: 'Talk about React', - }, + const channel = client.channel('messaging', 'react-tutorial', { + members: [userId], + // custom channel fields live under `custom` since v10 + custom: { + image: 'https://getstream.io/random_png/?name=react-v14', + name: 'Talk about React', }, - id: 'react-tutorial', - type: 'messaging', }); // `Channel` binds a channel to its subtree; it does not query one, so initializing is the diff --git a/examples/tutorial/src/optional-custom-attachment-type/App.tsx b/examples/tutorial/src/optional-custom-attachment-type/App.tsx index a45aa6186..e6ec0dea3 100644 --- a/examples/tutorial/src/optional-custom-attachment-type/App.tsx +++ b/examples/tutorial/src/optional-custom-attachment-type/App.tsx @@ -181,16 +181,12 @@ const App = () => { if (!client) return; const initChannel = async () => { - const channel = client.channelManager.ensure({ - data: { - members: [userId], - custom: { - image: 'https://getstream.io/random_png/?name=products', - name: 'Product recommendations', - }, + const channel = client.channel('messaging', 'react-tutorial-products', { + members: [userId], + custom: { + image: 'https://getstream.io/random_png/?name=products', + name: 'Product recommendations', }, - id: 'react-tutorial-products', - type: 'messaging', }); // `Channel` binds a channel to its subtree; it does not query one, so initializing is the diff --git a/examples/tutorial/src/optional-livestream/App.tsx b/examples/tutorial/src/optional-livestream/App.tsx index 3560fe2e3..3d3fcf5b2 100644 --- a/examples/tutorial/src/optional-livestream/App.tsx +++ b/examples/tutorial/src/optional-livestream/App.tsx @@ -41,16 +41,12 @@ const App = () => { if (!chatClient) return; const initChannel = async () => { - const spaceChannel = chatClient.channelManager.ensure({ - data: { - // custom channel fields live under `custom` since v10 - custom: { - image: 'https://goo.gl/Zefkbx', - name: 'SpaceX launch discussion', - }, + const spaceChannel = chatClient.channel('livestream', 'spacex', { + // custom channel fields live under `custom` since v10 + custom: { + image: 'https://goo.gl/Zefkbx', + name: 'SpaceX launch discussion', }, - id: 'spacex', - type: 'livestream', }); // `Channel` binds a channel to its subtree; it does not query one, so initializing is the diff --git a/examples/vite/src/App.tsx b/examples/vite/src/App.tsx index 7f13cdbdf..9f5db4a60 100644 --- a/examples/vite/src/App.tsx +++ b/examples/vite/src/App.tsx @@ -400,10 +400,10 @@ const App = () => { targets.forEach(({ cid, messageId }) => { const separatorIndex = cid.indexOf(':'); - const channel = chatClient.channelManager.ensure({ - id: cid.slice(separatorIndex + 1), - type: cid.slice(0, separatorIndex), - }); + const channel = chatClient.channel( + cid.slice(0, separatorIndex), + cid.slice(separatorIndex + 1), + ); void channel.messagePaginator.jumpToMessage(messageId, { watchChannel: true }); }); diff --git a/examples/vite/src/AppSettings/ActionsMenu/ServerSideClientPromptDialog/serverSideClient.ts b/examples/vite/src/AppSettings/ActionsMenu/ServerSideClientPromptDialog/serverSideClient.ts index bab97b096..e034af481 100644 --- a/examples/vite/src/AppSettings/ActionsMenu/ServerSideClientPromptDialog/serverSideClient.ts +++ b/examples/vite/src/AppSettings/ActionsMenu/ServerSideClientPromptDialog/serverSideClient.ts @@ -16,7 +16,7 @@ import { StreamChat } from 'stream-chat'; * header. * * 2. The client constructor takes `(key, options)` only — there is no secret parameter — and - * `client.channelManager.ensure()` throws without a connected user. So callers must not build `Channel` + * `client.channel()` throws without a connected user. So callers must not build `Channel` * objects; they issue requests through `client.api.sendRequest` instead (see serverSideMethods). * * `crypto.subtle` requires a secure context — fine on localhost and https. diff --git a/examples/vite/src/AppSettings/ActionsMenu/ServerSideClientPromptDialog/serverSideMethods.ts b/examples/vite/src/AppSettings/ActionsMenu/ServerSideClientPromptDialog/serverSideMethods.ts index 54a51aabd..260aa9147 100644 --- a/examples/vite/src/AppSettings/ActionsMenu/ServerSideClientPromptDialog/serverSideMethods.ts +++ b/examples/vite/src/AppSettings/ActionsMenu/ServerSideClientPromptDialog/serverSideMethods.ts @@ -7,7 +7,7 @@ import type { StreamChat } from 'stream-chat'; * member's channel-specific data, which a browser client cannot do. * * Everything goes through `client.api.sendRequest` rather than the generated helpers, for two - * reasons: `client.channelManager.ensure()` throws without a connected user, and the generated + * reasons: `client.channel()` throws without a connected user, and the generated * `updateMemberPartial` sends no `user_id`, so it can only ever write the caller's own * membership. `sendRequest` is the same primitive the generated APIs use internally and it * accepts query params, which is where `user_id` belongs. diff --git a/examples/vite/src/AppSettings/tabs/Reactions/ReactionsTab.tsx b/examples/vite/src/AppSettings/tabs/Reactions/ReactionsTab.tsx index da9ff76d6..dede5bda9 100644 --- a/examples/vite/src/AppSettings/tabs/Reactions/ReactionsTab.tsx +++ b/examples/vite/src/AppSettings/tabs/Reactions/ReactionsTab.tsx @@ -27,7 +27,7 @@ export const ReactionsTab = ({ close }: ReactionsTabProps) => { // `useUserRole` -> `useChannelCapabilities`, which subscribes to it as a StateStore. A plain // object shaped like channel state crashes there, which is what this preview used to pass. const previewChannel = useMemo( - () => client.channelManager.ensure({ id: 'reactions-preview', type: 'messaging' }), + () => client.channel('messaging', 'reactions-preview'), [client], ); diff --git a/examples/vite/src/ChatLayout/WorkspaceUrlSync.tsx b/examples/vite/src/ChatLayout/WorkspaceUrlSync.tsx index 28c1d8212..d51fab10b 100644 --- a/examples/vite/src/ChatLayout/WorkspaceUrlSync.tsx +++ b/examples/vite/src/ChatLayout/WorkspaceUrlSync.tsx @@ -185,7 +185,7 @@ const writeWorkspaceToUrl = (encoded: string, mode: 'push' | 'replace') => { // ---- resolve: token → live binding (async) ------------------------------------------------------ -// `client.channelManager.ensure()` returns the stored instance for that cid (the same one the +// `client.channel()` returns the stored instance for that cid (the same one the // channel-list query watches), or builds one when none is stored. When the caller has already // waited for the list to settle (see `waitForChannelList`), that instance is `initialized`, so the // bound `` skips its own watch — no duplicate `/query`. A channel absent from every loaded @@ -197,7 +197,7 @@ const resolveChannel = (client: StreamChat, cid: string): Channel | undefined => const colon = cid.indexOf(':'); const type = cid.slice(0, colon); const id = cid.slice(colon + 1); - return type && id ? client.channelManager.ensure({ id, type }) : undefined; + return type && id ? client.channel(type, id) : undefined; }; /** @@ -373,7 +373,7 @@ export const WorkspaceUrlSync = () => { // Resolve a parsed workspace by entity id and apply it to the controller in ONE atomic write. // - // Resolution is paginator-first (see `resolveBinding`): channels come from `client.channelManager.ensure()` + // Resolution is paginator-first (see `resolveBinding`): channels come from `client.channel()` // and threads from `client.threads.get()`, both populated by the list paginators. Entities // already paginated are reused with NO network round-trip — so navigating Back/Forward between // already-visited workspaces (which keeps those paginators warm, unlike a reload) fetches nothing. diff --git a/examples/vite/src/Debug/scenarioChannel.ts b/examples/vite/src/Debug/scenarioChannel.ts index 56d2f7d3f..435c93f3d 100644 --- a/examples/vite/src/Debug/scenarioChannel.ts +++ b/examples/vite/src/Debug/scenarioChannel.ts @@ -27,7 +27,7 @@ const NAME = 'Pagination scenarios (local)'; * server does not have it. */ export const ensureScenarioChannel = async (client: StreamChat) => { - const channel = client.channelManager.ensure({ id: ID, type: TYPE }); + const channel = client.channel(TYPE, ID); channel.data = { ...channel.data, custom: { ...channel.data?.custom, name: NAME } }; // You are its member, as in the channels you open from the list; the fake backend's answers // carry the channel's members on. diff --git a/examples/vite/src/NewConversation/createConversation.ts b/examples/vite/src/NewConversation/createConversation.ts index ebea70bcf..b603c106d 100644 --- a/examples/vite/src/NewConversation/createConversation.ts +++ b/examples/vite/src/NewConversation/createConversation.ts @@ -82,7 +82,8 @@ export const openOneToOne = async ( * Dev switch: `false` skips the lookup, so an existing 1:1 that isn't loaded yet is opened as a * local channel. Once that 1:1 is loaded meanwhile (by scrolling the channel list to it, or an * event), the server answers the local channel's first query with its cid, and the loaded - * instance supersedes the local one. A 1:1 already loaded is returned by `ensure` itself. + * instance supersedes the local one. A 1:1 already loaded is returned by `client.channel()` + * itself. */ lookUpExisting = true, }: { lookUpExisting?: boolean } = {}, @@ -105,14 +106,11 @@ export const openOneToOne = async ( const existing = candidates.find((channel) => channel.id?.startsWith('!members-')); if (existing) return { channel: existing, existed: true }; - const channel = client.channelManager.ensure({ - data: { - custom: withoutEmpty(details), - members: memberIds.map((user_id) => ({ user_id })), - }, - type: CHANNEL_TYPE, + const channel = client.channel(CHANNEL_TYPE, { + custom: withoutEmpty(details), + members: memberIds.map((user_id) => ({ user_id })), }); - // `ensure` returns a loaded 1:1 with these members rather than a new local channel + // `client.channel()` returns a loaded 1:1 with these members rather than a new local channel if (!channel.isProvisional) return { channel, existed: true }; createOnFirstSend(channel, client, [other]); return { channel, existed: false }; @@ -129,17 +127,11 @@ export const createGroup = async ( others: UserResponse[], details: ConversationDetails, ): Promise => { - const channel = client.channelManager.ensure({ - data: { - custom: withoutEmpty(details), - members: [client.userId as string, ...others.map(({ id }) => id)].map( - (user_id) => ({ - user_id, - }), - ), - }, - id: crypto.randomUUID(), - type: CHANNEL_TYPE, + const channel = client.channel(CHANNEL_TYPE, crypto.randomUUID(), { + custom: withoutEmpty(details), + members: [client.userId as string, ...others.map(({ id }) => id)].map((user_id) => ({ + user_id, + })), }); await channel.watch(); return { channel, existed: false }; diff --git a/examples/vite/src/SingleChannel/SingleChannelApp.tsx b/examples/vite/src/SingleChannel/SingleChannelApp.tsx index 1c576fa1b..86d947b6b 100644 --- a/examples/vite/src/SingleChannel/SingleChannelApp.tsx +++ b/examples/vite/src/SingleChannel/SingleChannelApp.tsx @@ -48,7 +48,7 @@ export const resolveSingleChannel = ({ separatorIndex === -1 ? ['messaging', channelKey] : [channelKey.slice(0, separatorIndex), channelKey.slice(separatorIndex + 1)]; - return client.channelManager.ensure({ id, type }); + return client.channel(type, id); } const loadedChannel = channelManager?.paginators.flatMap( @@ -56,10 +56,7 @@ export const resolveSingleChannel = ({ )[0]; if (loadedChannel) return loadedChannel; - return client.channelManager.ensure({ - id: SINGLE_CHANNEL_DEFAULT_ID, - type: 'messaging', - }); + return client.channel('messaging', SINGLE_CHANNEL_DEFAULT_ID); }; const channelDisplayName = (channel: StreamChannel) => From e092e2bb2b20355308ce4601cb7efe881a9f6b07 Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 23 Oct 2026 17:50:21 +0200 Subject: [PATCH 6/9] chore(examples): get channels through client.channel() in the docs screenshot scripts Co-Authored-By: Claude Opus 5.5 --- .../screenshot-attachment-actions.ts | 4 ++-- .../vite/docs-playwright/screenshot-misc.ts | 4 ++-- .../docs-playwright/screenshot-reactions.ts | 4 ++-- .../screenshot-system-message.ts | 4 ++-- .../docs-playwright/screenshot-variants.ts | 4 ++-- .../screenshot-voice-recording.ts | 4 ++-- .../seed-channel-and-screenshot.mjs | 20 +++++-------------- 7 files changed, 17 insertions(+), 27 deletions(-) diff --git a/examples/vite/docs-playwright/screenshot-attachment-actions.ts b/examples/vite/docs-playwright/screenshot-attachment-actions.ts index 0a7821a64..223210918 100644 --- a/examples/vite/docs-playwright/screenshot-attachment-actions.ts +++ b/examples/vite/docs-playwright/screenshot-attachment-actions.ts @@ -45,10 +45,10 @@ async function initSession( if (createChannel) { await page.evaluate(`(async () => { var client = window.client; - var ch = client.channelManager.ensure({ type: 'messaging', id: ${JSON.stringify(CHANNEL_ID)}, data: { + var ch = client.channel('messaging', ${JSON.stringify(CHANNEL_ID)}, { name: 'Attachment Actions Demo', members: [${JSON.stringify(USER_A)}, ${JSON.stringify(USER_B)}], - } }); + }); await ch.create(); })()`); console.log(` 📦 Channel "${CHANNEL_ID}" created`); diff --git a/examples/vite/docs-playwright/screenshot-misc.ts b/examples/vite/docs-playwright/screenshot-misc.ts index 9167e969b..d64eb75f0 100644 --- a/examples/vite/docs-playwright/screenshot-misc.ts +++ b/examples/vite/docs-playwright/screenshot-misc.ts @@ -46,10 +46,10 @@ async function createChannel( await page.evaluate(`(async () => { var client = window.client; - var ch = client.channelManager.ensure({ type: 'messaging', id: ${JSON.stringify(channelId)}, data: { + var ch = client.channel('messaging', ${JSON.stringify(channelId)}, { name: 'Demo', members: [${JSON.stringify(USER_A)}, ${JSON.stringify(USER_B)}], - } }); + }); await ch.create(); })()`); diff --git a/examples/vite/docs-playwright/screenshot-reactions.ts b/examples/vite/docs-playwright/screenshot-reactions.ts index e77359a4f..de3119ac2 100644 --- a/examples/vite/docs-playwright/screenshot-reactions.ts +++ b/examples/vite/docs-playwright/screenshot-reactions.ts @@ -46,10 +46,10 @@ async function initSession( if (createChannel) { await page.evaluate(`(async () => { var client = window.client; - var ch = client.channelManager.ensure({ type: 'messaging', id: ${JSON.stringify(CHANNEL_ID)}, data: { + var ch = client.channel('messaging', ${JSON.stringify(CHANNEL_ID)}, { name: 'Reactions Demo', members: [${JSON.stringify(USER_A)}, ${JSON.stringify(USER_B)}], - } }); + }); await ch.create(); })()`); console.log(` 📦 Channel "${CHANNEL_ID}" created`); diff --git a/examples/vite/docs-playwright/screenshot-system-message.ts b/examples/vite/docs-playwright/screenshot-system-message.ts index acfb52c67..bf62dae5e 100644 --- a/examples/vite/docs-playwright/screenshot-system-message.ts +++ b/examples/vite/docs-playwright/screenshot-system-message.ts @@ -41,10 +41,10 @@ async function initSession( if (createChannel) { await page.evaluate(`(async () => { var client = window.client; - var ch = client.channelManager.ensure({ type: 'messaging', id: ${JSON.stringify(CHANNEL_ID)}, data: { + var ch = client.channel('messaging', ${JSON.stringify(CHANNEL_ID)}, { name: 'System Message Demo', members: [${JSON.stringify(USER_A)}, ${JSON.stringify(USER_B)}], - } }); + }); await ch.create(); })()`); console.log(` 📦 Channel "${CHANNEL_ID}" created`); diff --git a/examples/vite/docs-playwright/screenshot-variants.ts b/examples/vite/docs-playwright/screenshot-variants.ts index 56c186714..cfd6098fa 100644 --- a/examples/vite/docs-playwright/screenshot-variants.ts +++ b/examples/vite/docs-playwright/screenshot-variants.ts @@ -44,10 +44,10 @@ async function initSession( if (createChannel) { await page.evaluate(`(async () => { var client = window.client; - var ch = client.channelManager.ensure({ type: 'messaging', id: ${JSON.stringify(CHANNEL_ID)}, data: { + var ch = client.channel('messaging', ${JSON.stringify(CHANNEL_ID)}, { name: 'Cookbook Screenshots', members: [${JSON.stringify(USER_A)}, ${JSON.stringify(USER_B)}], - } }); + }); await ch.create(); })()`); console.log(` 📦 Channel "${CHANNEL_ID}" created`); diff --git a/examples/vite/docs-playwright/screenshot-voice-recording.ts b/examples/vite/docs-playwright/screenshot-voice-recording.ts index 1b7b88b98..be35fa536 100644 --- a/examples/vite/docs-playwright/screenshot-voice-recording.ts +++ b/examples/vite/docs-playwright/screenshot-voice-recording.ts @@ -48,10 +48,10 @@ async function initSession( if (createChannel) { await page.evaluate(`(async () => { var client = window.client; - var ch = client.channelManager.ensure({ type: 'messaging', id: ${JSON.stringify(CHANNEL_ID)}, data: { + var ch = client.channel('messaging', ${JSON.stringify(CHANNEL_ID)}, { name: 'Voice Recording Demo', members: [${JSON.stringify(USER_A)}, ${JSON.stringify(USER_B)}], - } }); + }); await ch.create(); })()`); console.log(` 📦 Channel "${CHANNEL_ID}" created`); diff --git a/examples/vite/docs-playwright/seed-channel-and-screenshot.mjs b/examples/vite/docs-playwright/seed-channel-and-screenshot.mjs index 98a21581f..37d652c3c 100644 --- a/examples/vite/docs-playwright/seed-channel-and-screenshot.mjs +++ b/examples/vite/docs-playwright/seed-channel-and-screenshot.mjs @@ -636,13 +636,9 @@ async function run() { const client = findClient(); if (!client) return { ok: false, reason: 'no client' }; try { - const ch = client.channelManager.ensure({ - type: 'messaging', - id: channelId, - data: { - members: [userA, userB], - name: 'Design redesign — v14', - }, + const ch = client.channel('messaging', channelId, { + members: [userA, userB], + name: 'Design redesign — v14', }); await ch.watch(); const existingCount = (ch.state.messages || []).length; @@ -761,19 +757,13 @@ async function run() { const results = []; for (const [channelId, data] of Object.entries(channelImages)) { try { - const ch = client.channelManager.ensure({ - type: 'public', - id: channelId, - }); + const ch = client.channel('public', channelId); await ch.updatePartial({ set: { image: data.image } }); results.push(channelId + ': ok'); } catch (e) { // Try messaging type too try { - const ch = client.channelManager.ensure({ - type: 'messaging', - id: channelId, - }); + const ch = client.channel('messaging', channelId); await ch.updatePartial({ set: { image: data.image } }); results.push(channelId + ': ok (messaging)'); } catch (e2) { From 6977d53a6ace9288b281fbe838465abbc7008d48 Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 23 Oct 2026 17:50:25 +0200 Subject: [PATCH 7/9] docs: recommend client.channel() over the internal channelManager.ensure() Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- ai-docs/ai-migration-v14-v15.md | 10 +++++----- ai-docs/instance-configuration.md | 6 +++--- ai-docs/v15-breaking-changes.md | 15 +++++---------- 4 files changed, 14 insertions(+), 19 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5b19b5ca2..d1f4c8f46 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -282,7 +282,7 @@ const { // Manual setup when you need control over the API responses const client = await getTestClientWithUser({ id: 'test-user' }); useMockedApis(client, [getOrCreateChannelApi(mockedChannelData)]); -const channel = client.channelManager.ensure({ id: channelId, type: 'messaging' }); +const channel = client.channel('messaging', channelId); await channel.watch(); ``` diff --git a/ai-docs/ai-migration-v14-v15.md b/ai-docs/ai-migration-v14-v15.md index 3c2f21c95..79aadb587 100644 --- a/ai-docs/ai-migration-v14-v15.md +++ b/ai-docs/ai-migration-v14-v15.md @@ -716,7 +716,7 @@ export const Main = ({ activeChannel }: { activeChannel?: StreamChannel }) => What to change depends on whether the app already watched the channel, and on whether the channel has an id: - **Channels from `ChannelList` or any `queryChannels` call arrive watched.** Nothing changes for them. -- **The app already watches a channel it created by id** (`client.channel(type, id)` + `await channel.watch()`): **nothing changes.** `client.channel(...)` and `channel.watch(options)` still exist with the same roles; `client.channel()` is shorthand for `client.channelManager.ensure({ type, id, data })`. Do not rewrite these calls. The only edit at such call sites is the `stream-chat` v10 input shape ([below](#channel-input-shape)). +- **The app already watches a channel it created by id** (`client.channel(type, id)` + `await channel.watch()`): **nothing changes.** `client.channel(...)` and `channel.watch(options)` still exist with the same roles; `client.channel()` is the public way to get a channel (it calls the internal `channelManager.ensure()`). Do not rewrite these calls. The only edit at such call sites is the `stream-chat` v10 input shape ([below](#channel-input-shape)). - **Members-only channels** (`client.channel(type, { members })`, no id, as most "start a direct message" flows create): keep `client.channel()`, but **render the instance `watch()` resolves with**, not the one you created. The created instance is provisional until the server assigns the id, and the client may already hold a channel with that id (event routing can store it while the request is in flight). The store then keeps its own instance and marks yours superseded (`state.supersededBy` set, `watchStatus: NotWatching`), and yours receives no events. `watch()` resolves with the surviving instance, so code that keeps the created instance renders a channel that silently stops updating. `ChatView` swaps superseded channels in its slots, so this bites when the app holds the channel itself. - **The app relied on `Channel` to watch** (rendered an instance it never watched, possibly with `channelQueryOptions`): watch it before rendering: `await channel.watch(options)`. It fits where the call can repeat, such as an effect: it sends nothing for a channel already watched and loaded and joins an in-flight watch with the same options, so an effect that runs twice still sends one query. It resolves with the channel; refresh a watched channel with `channel.reload()`. - **`channelQueryOptions`** → pass them to that watch call (`ChannelGetOrCreateRequest`: `messages`, `members`, `watchers`, `presence`, `state`, `watch`, `data`, ...). @@ -772,7 +772,7 @@ const channel = await client.channel('messaging', 'general').watch(); -**Channel input shape (`stream-chat` v10).** The third argument of `client.channel()` (and `data` of `channelManager.ensure()`) is `ChannelInput`. `members` is `ChannelMemberRequest[]`, `{ user_id }` objects instead of an id list, and custom fields such as `name` go under `custom` (`{ custom: { name } }`). Plain ids now fail to compile (`TS2769`), though the API still accepts them at runtime (checked with `stream-chat@10.0.0-rc.22`), so this is a type fix, not a behavior change. +**Channel input shape (`stream-chat` v10).** The third argument of `client.channel()` is `ChannelInput`. `members` is `ChannelMemberRequest[]`, `{ user_id }` objects instead of an id list, and custom fields such as `name` go under `custom` (`{ custom: { name } }`). Plain ids now fail to compile (`TS2769`), though the API still accepts them at runtime (checked with `stream-chat@10.0.0-rc.22`), so this is a type fix, not a behavior change. [`examples/vite`](https://github.com/GetStream/stream-chat-react/tree/master/examples/vite) and the tutorial steps show the full flow. @@ -1840,7 +1840,7 @@ rather than a count of `message.new` events received while scrolled up. `Message` resolves its channel with `useChannel()`, which throws when there is no `Channel`, `Thread` or `ChannelInstanceProvider` ancestor. In v14 the missing contexts only warned. Wrapping a preview `` in `ChannelStateProvider` / `ChannelActionProvider` no longer compiles. -- Wrap it in ``, with any real `stream-chat` `Channel` (for example `client.channelManager.ensure({ id, type })`), or render it inside `` / ``. [`examples/vite/src/AppSettings/tabs/Reactions/ReactionsTab.tsx`](https://github.com/GetStream/stream-chat-react/blob/master/examples/vite/src/AppSettings/tabs/Reactions/ReactionsTab.tsx) is the reference. +- Wrap it in ``, with any real `stream-chat` `Channel` (for example `client.channel(type, id)`), or render it inside `` / ``. [`examples/vite/src/AppSettings/tabs/Reactions/ReactionsTab.tsx`](https://github.com/GetStream/stream-chat-react/blob/master/examples/vite/src/AppSettings/tabs/Reactions/ReactionsTab.tsx) is the reference. - `highlighted` is no longer derived from `ChannelStateContext.highlightedMessageId`. Pass `highlighted` yourself (`MessageProps.highlighted?: boolean`). ```tsx @@ -1856,7 +1856,7 @@ export const MessagePreview = ({ }) => ( @@ -2440,7 +2440,7 @@ A panel your layout renders without `` (for example a `ThreadSlot` placed ## 20. Tests -**Mock real stores.** `channel.state` is a `StateStore` now, so a plain-object mock crashes the `useStateStore` hooks with `getLatestValue is not a function`. Build a real channel (`client.channelManager.ensure(...)`) and seed state on it. Build a `Thread` mock from a real `client.threads` instance too. +**Mock real stores.** `channel.state` is a `StateStore` now, so a plain-object mock crashes the `useStateStore` hooks with `getLatestValue is not a function`. Build a real channel (`client.channel(...)`) and seed state on it. Build a `Thread` mock from a real `client.threads` instance too. | v14 mock | v15 | | --------------------------------- | ------------------------------------------------------------------------- | diff --git a/ai-docs/instance-configuration.md b/ai-docs/instance-configuration.md index 109f6694b..5864eca51 100644 --- a/ai-docs/instance-configuration.md +++ b/ai-docs/instance-configuration.md @@ -45,7 +45,7 @@ export const App = () => ( ``` **Not in an effect.** Some configuration is read once when an instance is constructed, and channels -are constructed by `client.channelManager.ensure()` / `client.queryChannels()` — which an app typically calls before +are constructed by `client.channel()` / `client.queryChannels()` — which an app typically calls before or during the same commit that mounts ``. Registering from `useEffect` runs after that, so those values would arrive too late for instances that already exist. @@ -128,8 +128,8 @@ are different numbers and you usually want both: ```tsx chatClient.config.set({ channel: { messagePaginator: { pageSize: 50 } } }); -const channel = await chatClient.channelManager - .ensure({ id: 'general', type: 'messaging' }) +const channel = await chatClient + .channel('messaging', 'general') .watch({ messages: { limit: 30 } }); …; diff --git a/ai-docs/v15-breaking-changes.md b/ai-docs/v15-breaking-changes.md index d71f41024..daa58bbee 100644 --- a/ai-docs/v15-breaking-changes.md +++ b/ai-docs/v15-breaking-changes.md @@ -1160,7 +1160,7 @@ import { - `ChannelProps.initializeOnMount?: boolean` (default `true`: `Channel` called `channel.watch()`) - `ChannelProps.channelQueryOptions?: ChannelQueryOptions` - `getChannel({ client, channel?, id?, members?, options?, type? })` -- 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 `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. +- 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. - Evidence: - `release-v14:src/components/Channel/Channel.tsx:107,141`; `release-v14:src/utils/getChannel.ts:34` - `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 @@ -1184,11 +1184,8 @@ const channel = client.channel('messaging', { members: [userId, otherUserId] }); After (v15): ```tsx -const channel = await client.channelManager - .ensure({ - data: { members: [{ user_id: userId }, { user_id: otherUserId }] }, - type: 'messaging', - }) +const channel = await client + .channel('messaging', { members: [{ user_id: userId }, { user_id: otherUserId }] }) .watch({ messages: { limit: 30 } }); {/* ... */}; @@ -1209,9 +1206,7 @@ const channel = await getChannel({ client, id: 'general', type: 'messaging' }); After (v15): ```ts -const channel = await client.channelManager - .ensure({ id: 'general', type: 'messaging' }) - .watch(); +const channel = await client.channel('messaging', 'general').watch(); ``` ### BC-021: `Channel` no longer writes `document.title`; `activeUnreadHandler` was removed @@ -2590,7 +2585,7 @@ export const Stop = ({ channel }: { channel: Channel }) => { - Old API: - ``, or `` with no channel context - implicit `highlighted` from `useChannelStateContext().highlightedMessageId` -- Replacement: wrap it in `` (any real `stream-chat` `Channel`, e.g. `client.channelManager.ensure({ id, type })`), or render it inside `` / ``. Pass `highlighted` explicitly when needed (`MessageProps.highlighted?: boolean`). +- Replacement: wrap it in `` (any real `stream-chat` `Channel`, e.g. `client.channel(type, id)`), or render it inside `` / ``. Pass `highlighted` explicitly when needed (`MessageProps.highlighted?: boolean`). - Evidence: - `master:src/context/useChannel.ts` throws when neither a thread nor `ChannelInstanceContext` supplies a channel - `master:src/components/Message/hooks/useOpenThreadHandler.ts:16` and `master:src/components/Message/hooks/useRetryHandler.ts:11` call `useChannel()` during every `Message` render From c48fefb1ce9ec98fbef227779c990bb9817722b5 Mon Sep 17 00:00:00 2001 From: martincupela Date: Tue, 27 Oct 2026 11:36:58 +0100 Subject: [PATCH 8/9] chore(deps): upgrade stream-chat to v10.0.0-rc.25 --- examples/ai-chatbot/package.json | 2 +- examples/tutorial/package.json | 2 +- examples/vite/package.json | 2 +- package.json | 2 +- yarn.lock | 16 ++++++++-------- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/examples/ai-chatbot/package.json b/examples/ai-chatbot/package.json index 1db27d174..631e937da 100644 --- a/examples/ai-chatbot/package.json +++ b/examples/ai-chatbot/package.json @@ -18,7 +18,7 @@ "next": "^16.2.4", "react": "^19.2.6", "react-dom": "^19.2.6", - "stream-chat": "10.0.0-rc.24", + "stream-chat": "10.0.0-rc.25", "stream-chat-react": "workspace:^" }, "devDependencies": { diff --git a/examples/tutorial/package.json b/examples/tutorial/package.json index 97e3c0e86..c38a10c80 100644 --- a/examples/tutorial/package.json +++ b/examples/tutorial/package.json @@ -16,7 +16,7 @@ "emoji-mart": "^5.6.0", "react": "^19.2.6", "react-dom": "^19.2.6", - "stream-chat": "10.0.0-rc.24", + "stream-chat": "10.0.0-rc.25", "stream-chat-react": "workspace:^" }, "devDependencies": { diff --git a/examples/vite/package.json b/examples/vite/package.json index 632b20895..5e15c9a37 100644 --- a/examples/vite/package.json +++ b/examples/vite/package.json @@ -18,7 +18,7 @@ "modern-normalize": "^3.0.1", "react": "^19.2.6", "react-dom": "^19.2.6", - "stream-chat": "10.0.0-rc.24", + "stream-chat": "10.0.0-rc.25", "stream-chat-react": "workspace:^" }, "devDependencies": { diff --git a/package.json b/package.json index 27dc9d30b..73fa65f42 100644 --- a/package.json +++ b/package.json @@ -210,7 +210,7 @@ "react-dom": "^19.2.6", "sass": "^1.100.0", "semantic-release": "^25.0.3", - "stream-chat": "10.0.0-rc.24", + "stream-chat": "10.0.0-rc.25", "typescript": "^6.0.3", "typescript-eslint": "^8.59.4", "vite": "^8.1.3", diff --git a/yarn.lock b/yarn.lock index b00d4dba2..e16f4fee6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2186,7 +2186,7 @@ __metadata: react: "npm:^19.2.6" react-dom: "npm:^19.2.6" sass: "npm:^1.100.0" - stream-chat: "npm:10.0.0-rc.24" + stream-chat: "npm:10.0.0-rc.25" stream-chat-react: "workspace:^" typescript: "npm:^6.0.3" languageName: unknown @@ -2203,7 +2203,7 @@ __metadata: emoji-mart: "npm:^5.6.0" react: "npm:^19.2.6" react-dom: "npm:^19.2.6" - stream-chat: "npm:10.0.0-rc.24" + stream-chat: "npm:10.0.0-rc.25" stream-chat-react: "workspace:^" typescript: "npm:^6.0.3" vite: "npm:^8.1.3" @@ -2231,7 +2231,7 @@ __metadata: react: "npm:^19.2.6" react-dom: "npm:^19.2.6" sass: "npm:^1.100.0" - stream-chat: "npm:10.0.0-rc.24" + stream-chat: "npm:10.0.0-rc.25" stream-chat-react: "workspace:^" typescript: "npm:^6.0.3" vite: "npm:^8.1.3" @@ -10257,7 +10257,7 @@ __metadata: remark-parse: "npm:^11.0.0" sass: "npm:^1.100.0" semantic-release: "npm:^25.0.3" - stream-chat: "npm:10.0.0-rc.24" + stream-chat: "npm:10.0.0-rc.25" typescript: "npm:^6.0.3" typescript-eslint: "npm:^8.59.4" unified: "npm:^11.0.5" @@ -10299,9 +10299,9 @@ __metadata: languageName: unknown linkType: soft -"stream-chat@npm:10.0.0-rc.24": - version: 10.0.0-rc.24 - resolution: "stream-chat@npm:10.0.0-rc.24" +"stream-chat@npm:10.0.0-rc.25": + version: 10.0.0-rc.25 + resolution: "stream-chat@npm:10.0.0-rc.25" dependencies: "@stream-io/logger": "npm:^2.0.0" "@stream-io/state-store": "npm:^1.1.6" @@ -10312,7 +10312,7 @@ __metadata: built: true husky: built: true - checksum: 10c0/111dd28f50b4a331d1faccdc34460d49fe11987f9230984d6c9de0fab0fbbb7972a9afc4c1dfb7296aff46ca5f345a74fefa19ba68fa78df7470f6f0bfbd46a4 + checksum: 10c0/cb8a316988d73c00347c9a98047246ac05cbedcba28560afaa6358b6e022b84350a51bb80a73bf3376ff68a91fffe2d203414831b342200cde831972ea027d11 languageName: node linkType: hard From fb4f21de9cf1caa18b50e54528b797760640da0d Mon Sep 17 00:00:00 2001 From: martincupela Date: Tue, 27 Oct 2026 11:42:19 +0100 Subject: [PATCH 9/9] chore(deps): require stream-chat v10.0.0-rc.25 as the peer dependency rc.25 is the first release whose Channel.watch() resolves with the channel, which the direct message and search flows use. With rc.24, watch() resolves with the server response. Co-Authored-By: Claude Opus 5.5 --- package.json | 2 +- yarn.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 73fa65f42..15e9cc119 100644 --- a/package.json +++ b/package.json @@ -144,7 +144,7 @@ "modern-normalize": "^3.0.1", "react": "^19.0.0 || ^18.0.0 || ^17.0.0", "react-dom": "^19.0.0 || ^18.0.0 || ^17.0.0", - "stream-chat": "^10.0.0-rc.24" + "stream-chat": "^10.0.0-rc.25" }, "peerDependenciesMeta": { "@breezystack/lamejs": { diff --git a/yarn.lock b/yarn.lock index e16f4fee6..9b9a40cdb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10275,7 +10275,7 @@ __metadata: modern-normalize: ^3.0.1 react: ^19.0.0 || ^18.0.0 || ^17.0.0 react-dom: ^19.0.0 || ^18.0.0 || ^17.0.0 - stream-chat: ^10.0.0-rc.24 + stream-chat: ^10.0.0-rc.25 dependenciesMeta: "@parcel/watcher": built: true