From 6db7b5fe78be84ba05cb58ac73a3cb9b9d3298ca Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Thu, 6 Aug 2026 19:25:22 +0200 Subject: [PATCH 1/7] feat: channel paginator initial migration --- .../components/ChannelList/ChannelList.tsx | 271 +++++++--------- .../__tests__/ChannelListView.test.tsx | 7 +- .../ChannelList/hooks/usePaginatedChannels.ts | 297 ++++++++++-------- package/src/components/Chat/Chat.tsx | 16 +- .../Chat/hooks/useCreateChatContext.ts | 12 +- .../channelsContext/ChannelsContext.tsx | 3 +- .../src/contexts/chatContext/ChatContext.tsx | 17 +- package/src/mock-builders/mock.ts | 5 +- 8 files changed, 324 insertions(+), 304 deletions(-) diff --git a/package/src/components/ChannelList/ChannelList.tsx b/package/src/components/ChannelList/ChannelList.tsx index bda49e9f89..5da129ea14 100644 --- a/package/src/components/ChannelList/ChannelList.tsx +++ b/package/src/components/ChannelList/ChannelList.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { StyleSheet, View } from 'react-native'; import type { FlatList } from 'react-native-gesture-handler'; @@ -6,16 +6,20 @@ import type { FlatList } from 'react-native-gesture-handler'; import { Channel, ChannelFilters, + ChannelManager, + ChannelManagerEventHandlerContext, ChannelOptions, ChannelSort, - Event, - QueryChannelsRequestType, + EventHandlerPipelineHandler, + EventType, } from 'stream-chat'; import { ChannelListView } from './ChannelListView'; -import { useChannelUpdated } from './hooks/listeners/useChannelUpdated'; import { useCreateChannelsContext } from './hooks/useCreateChannelsContext'; -import { usePaginatedChannels } from './hooks/usePaginatedChannels'; +import { + ChannelListQueryChannelsOverride, + usePaginatedChannels, +} from './hooks/usePaginatedChannels'; import { ChannelsContextValue, @@ -25,10 +29,24 @@ import { useChatContext } from '../../contexts/chatContext/ChatContext'; import { useComponentsContext } from '../../contexts/componentsContext/ComponentsContext'; import { SwipeRegistryProvider } from '../../contexts/swipeableContext/SwipeRegistryContext'; import { useLazyRef } from '../../hooks/useLazyRef'; -import type { ChannelListEventListenerOptions } from '../../types/types'; import { generateRandomId } from '../../utils/utils'; import { NotificationTargetProvider } from '../Notifications/NotificationTargetContext'; +/** + * A `ChannelList` event handler. It is registered on the shared `ChannelManager`'s + * `EventHandlerPipeline` for its event type and REPLACES the SDK's default handler for that event + * (matching the previous "override" semantics). It receives the routed `event` plus a `ctx` exposing + * the `channelManager` — from which the relevant `ChannelPaginator`(s) can be read/mutated + * (`ingestItem`, `removeItem`, `setItems`, `boost`, …). Returning `{ action: 'stop' }` cancels the rest + * of the pipeline for that event. + * + * NOTE (breaking change vs v9): these handlers previously received `(setChannels, event, options?)`. + * The `setChannels` dispatcher no longer exists — list mutation now goes through the paginator obtained + * from `ctx.channelManager`. + */ +export type ChannelListEventHandler = + EventHandlerPipelineHandler; + export type ChannelListProps = Partial< Pick< ChannelsContextValue, @@ -59,140 +77,69 @@ export type ChannelListProps = Partial< */ lockChannelOrder?: boolean; /** - * Function that overrides default behavior when a user gets added to a channel - * - * @param setChannels Setter for internal state property - `channels`. It's created from useState() hook. - * @param event An [Event Object](https://getstream.io/chat/docs/event_object) corresponding to `notification.added_to_channel` event - * @param filters Channel filters - * @param sort Channel sort options + * Overrides the default handler for the `notification.added_to_channel` event on the shared + * `ChannelManager`. See {@link ChannelListEventHandler}. * * @overrideType Function * */ - onAddedToChannel?: ( - setChannels: React.Dispatch>, - event: Event, - options?: ChannelListEventListenerOptions, - ) => void; + onAddedToChannel?: ChannelListEventHandler; /** - * Function that overrides default behavior when a channel gets deleted. In absence of this prop, the channel will be removed from the list. - * - * @param setChannels Setter for internal state property - `channels`. It's created from useState() hook. - * @param event An [Event object](https://getstream.io/chat/docs/event_object) corresponding to `channel.deleted` event + * Overrides the default handler for the `channel.deleted` event. In its absence the channel is + * removed from the list. See {@link ChannelListEventHandler}. * * @overrideType Function * */ - onChannelDeleted?: ( - setChannels: React.Dispatch>, - event: Event, - ) => void; + onChannelDeleted?: ChannelListEventHandler; /** - * Function that overrides default behavior when a channel gets hidden. In absence of this prop, the channel will be removed from the list. - * - * @param setChannels Setter for internal state property - `channels`. It's created from useState() hook. - * @param event An [Event object](https://getstream.io/chat/docs/event_object) corresponding to `channel.hidden` event + * Overrides the default handler for the `channel.hidden` event. See {@link ChannelListEventHandler}. * * @overrideType Function * */ - onChannelHidden?: ( - setChannels: React.Dispatch>, - event: Event, - ) => void; + onChannelHidden?: ChannelListEventHandler; /** - * Function that overrides default behavior when a channel member.updated event is triggered - * @param lockChannelOrder If set to true, channels won't dynamically sort by most recent message, defaults to false - * @param setChannels Setter for internal state property - `channels`. It's created from useState() hook. - * @param event An [Event object](https://getstream.io/chat/docs/event_object) corresponding to `member.updated` event - * @param filters Channel filters - * @param sort Channel sort options + * Overrides the default handler for the `member.updated` event. See {@link ChannelListEventHandler}. + * * @overrideType Function */ - onChannelMemberUpdated?: ( - lockChannelOrder: boolean, - setChannels: React.Dispatch>, - event: Event, - options?: ChannelListEventListenerOptions, - ) => void; + onChannelMemberUpdated?: ChannelListEventHandler; /** - * Function to customize behavior when a channel gets truncated - * - * @param setChannels Setter for internal state property - `channels`. It's created from useState() hook. - * @param event [Event object](https://getstream.io/chat/docs/event_object) corresponding to `channel.truncated` event + * Overrides the default handler for the `channel.truncated` event. See {@link ChannelListEventHandler}. * * @overrideType Function * */ - onChannelTruncated?: ( - setChannels: React.Dispatch>, - event: Event, - ) => void; + onChannelTruncated?: ChannelListEventHandler; /** - * Function that overrides default behavior when a channel gets updated - * - * @param setChannels Setter for internal state property - `channels`. It's created from useState() hook. - * @param event An [Event object](https://getstream.io/chat/docs/event_object) corresponding to `channel.updated` event + * Overrides the default handler for the `channel.updated` event. See {@link ChannelListEventHandler}. * * @overrideType Function * */ - onChannelUpdated?: ( - setChannels: React.Dispatch>, - event: Event, - ) => void; + onChannelUpdated?: ChannelListEventHandler; /** - * Function that overrides default behavior when a channel gets visible. In absence of this prop, the channel will be added to the list. - * - * @param setChannels Setter for internal state property - `channels`. It's created from useState() hook. - * @param event An [Event object](https://getstream.io/chat/docs/event_object) corresponding to `channel.visible` event + * Overrides the default handler for the `channel.visible` event. See {@link ChannelListEventHandler}. * * @overrideType Function * */ - onChannelVisible?: ( - setChannels: React.Dispatch>, - event: Event, - ) => void; + onChannelVisible?: ChannelListEventHandler; /** - * Override the default listener/handler for event `message.new` - * This event is received on channel, when a new message is added on a channel. + * Overrides the default handler for the `message.new` event. See {@link ChannelListEventHandler}. * - * @param lockChannelOrder If set to true, channels won't dynamically sort by most recent message, defaults to false - * @param setChannels Setter for internal state property - `channels`. It's created from useState() hook. - * @param event An [Event object](https://getstream.io/chat/docs/event_object) corresponding to `message.new` event - * @param considerArchivedChannels If set to true, archived channels will be considered while updating the list of channels - * @param filters Channel filters - * @param sort Channel sort options * @overrideType Function * */ - onNewMessage?: ( - lockChannelOrder: boolean, - setChannels: React.Dispatch>, - event: Event, - options?: ChannelListEventListenerOptions, - ) => void; + onNewMessage?: ChannelListEventHandler; /** - * Override the default listener/handler for event `notification.message_new` - * This event is received on channel, which is not being watched. + * Overrides the default handler for the `notification.message_new` event (received for a channel that + * is not being watched). See {@link ChannelListEventHandler}. * - * @param setChannels Setter for internal state property - `channels`. It's created from useState() hook. - * @param event An [Event object](https://getstream.io/chat/docs/event_object) corresponding to `notification.message_new` event - * @param filters Channel filters * @overrideType Function * */ - onNewMessageNotification?: ( - setChannels: React.Dispatch>, - event: Event, - options?: ChannelListEventListenerOptions, - ) => void; - + onNewMessageNotification?: ChannelListEventHandler; /** - * Function that overrides default behavior when a user gets removed from a channel - * - * @param setChannels Setter for internal state property - `channels`. It's created from useState() hook. - * @param event An [Event object](https://getstream.io/chat/docs/event_object) corresponding to `notification.removed_from_channel` event + * Overrides the default handler for the `notification.removed_from_channel` event. + * See {@link ChannelListEventHandler}. * * @overrideType Function * */ - onRemovedFromChannel?: ( - setChannels: React.Dispatch>, - event: Event, - ) => void; + onRemovedFromChannel?: ChannelListEventHandler; /** * Object containing channel query options * @see See [Channel query documentation](https://getstream.io/chat/docs/query_channels) for a list of available option fields @@ -205,13 +152,14 @@ export type ChannelListProps = Partial< sort?: ChannelSort; /** - * A function that overrides the default ChannelManager queryChannels method, which is StreamChat.queryChannels. - * It is particularly useful whenever we want to pass specific cids that we want to query but also want to - * paginate over them (which is not possible through normal filters). It comes with with several rules/assumptions: - * - StreamChat.queryChannels has to be called inside of queryChannelsOverride (as it updates important client state) - * - The return type has to be Channel[] (which is the return type of StreamChat.queryChannels) + * A custom request implementation for this list's `ChannelPaginator` (its `doRequest`). Use it to + * query a specific set of channels while still paginating over them. Call `client.queryChannels(...)` + * inside so client state stays in sync, and return `{ items }`. + * + * NOTE (breaking change vs v9): this replaces the previous `queryChannelsOverride` typed as the + * removed `QueryChannelsRequestType` (which returned `Channel[]`). */ - queryChannelsOverride?: QueryChannelsRequestType; + queryChannelsOverride?: ChannelListQueryChannelsOverride; notificationHostId?: string; }; @@ -219,6 +167,9 @@ const DEFAULT_FILTERS = {}; const DEFAULT_OPTIONS = {}; const DEFAULT_SORT: ChannelSort = []; +/** The event types whose default handlers a `ChannelList` prop can override, mapped to the prop. */ +const OVERRIDE_HANDLER_ID_PREFIX = 'stream-chat-react-native:channel-list'; + /** * This component fetches a list of channels, allowing you to select the channel you want to open. * The ChannelList renders a ChannelListView which provides the UI for the underlying React Native FlatList. @@ -257,75 +208,71 @@ export const ChannelList = (props: ChannelListProps) => { swipeActionsEnabled = true, } = props; - const [forceUpdate, setForceUpdate] = useState(0); + const [forceUpdate] = useState(0); const fallbackNotificationHostIdRef = useLazyRef(() => `channel-list:${generateRandomId()}`); const notificationHostId = notificationHostIdProp ?? fallbackNotificationHostIdRef.current; - const { client, enableOfflineSupport } = useChatContext(); + const { channelManager, enableOfflineSupport } = useChatContext(); const { NotificationList } = useComponentsContext(); - const channelManager = useMemo(() => client.createChannelManager({}), [client]); /** - * This hook sets the event handler overrides in the channelManager internally - * whenever they change. We do this to avoid recreating the channelManager instance - * every time these change, as we want to keep it as static as possible. - * This protects us from something like defining the overrides as inline functions - * causing the manager instance to be recreated over and over again. + * Register this list's event-handler overrides on the shared `ChannelManager`. Each provided prop + * replaces the SDK default handler for that event type; on unmount / prop change we restore the + * default. Handlers are manager-global: with multiple mounted ``s the last-registered + * override for a given event wins (single-list is the common case). */ useEffect(() => { - channelManager.setEventHandlerOverrides({ - channelDeletedHandler: onChannelDeleted, - channelHiddenHandler: onChannelHidden, - channelTruncatedHandler: onChannelTruncated, - channelVisibleHandler: onChannelVisible, - memberUpdatedHandler: onChannelMemberUpdated - ? (setChannels, event) => - onChannelMemberUpdated(lockChannelOrder, setChannels, event, { filters, sort }) - : undefined, - newMessageHandler: onNewMessage - ? (setChannels, event) => - onNewMessage(lockChannelOrder, setChannels, event, { filters, sort }) - : undefined, - notificationAddedToChannelHandler: onAddedToChannel - ? (setChannels, event) => onAddedToChannel(setChannels, event, { filters, sort }) - : undefined, - notificationNewMessageHandler: onNewMessageNotification - ? (setChannels, event) => onNewMessageNotification(setChannels, event, { filters, sort }) - : undefined, - notificationRemovedFromChannelHandler: onRemovedFromChannel, - }); + const overrides: Array<[EventType, ChannelListEventHandler | undefined]> = [ + ['channel.deleted', onChannelDeleted], + ['channel.hidden', onChannelHidden], + ['channel.truncated', onChannelTruncated], + ['channel.updated', onChannelUpdated], + ['channel.visible', onChannelVisible], + ['member.updated', onChannelMemberUpdated], + ['message.new', onNewMessage], + ['notification.added_to_channel', onAddedToChannel], + ['notification.message_new', onNewMessageNotification], + ['notification.removed_from_channel', onRemovedFromChannel], + ]; + + const overriddenEventTypes = overrides + .filter(([, handle]) => typeof handle === 'function') + .map(([eventType, handle]) => { + channelManager.setEventHandlers({ + eventType, + handlers: [{ handle: handle!, id: `${OVERRIDE_HANDLER_ID_PREFIX}:${eventType}` }], + }); + return eventType; + }); + + if (overriddenEventTypes.length === 0) { + return; + } + + const defaultHandlers = ChannelManager.getDefaultHandlers(); + return () => { + overriddenEventTypes.forEach((eventType) => { + channelManager.setEventHandlers({ + eventType, + handlers: defaultHandlers[eventType] ?? [], + }); + }); + }; }, [ channelManager, - filters, - lockChannelOrder, onAddedToChannel, onChannelDeleted, onChannelHidden, onChannelMemberUpdated, onChannelTruncated, + onChannelUpdated, onChannelVisible, onNewMessage, onNewMessageNotification, onRemovedFromChannel, - sort, ]); - useEffect(() => { - if (queryChannelsOverride) { - channelManager.setQueryChannelsRequest(queryChannelsOverride); - } - }, [channelManager, queryChannelsOverride]); - - useEffect(() => { - channelManager.setOptions({ abortInFlightQuery: false, lockChannelOrder }); - }, [channelManager, lockChannelOrder]); - - useEffect(() => { - channelManager.registerSubscriptions(); - - return () => { - channelManager.unregisterSubscriptions(); - }; - }, [channelManager]); + // Ref-counted on the shared manager: subscriptions live only while at least one ChannelList is mounted. + useEffect(() => channelManager.registerSubscriptions(), [channelManager]); const { channelListInitialized, @@ -342,20 +289,16 @@ export const ChannelList = (props: ChannelListProps) => { channelManager, enableOfflineSupport, filters, + lockChannelOrder, options, - setForceUpdate, + queryChannelsOverride, sort, }); - useChannelUpdated({ - onChannelUpdated, - setChannels: channelManager.setChannels, - }); - const channelsContext = useCreateChannelsContext({ additionalFlatListProps, channelListInitialized, - channels: channelRenderFilterFn ? channelRenderFilterFn(channels ?? []) : channels, + channels: channelRenderFilterFn ? channelRenderFilterFn(channels ?? []) : (channels ?? null), error, forceUpdate, hasNextPage, diff --git a/package/src/components/ChannelList/__tests__/ChannelListView.test.tsx b/package/src/components/ChannelList/__tests__/ChannelListView.test.tsx index 0ff7415b48..7245548859 100644 --- a/package/src/components/ChannelList/__tests__/ChannelListView.test.tsx +++ b/package/src/components/ChannelList/__tests__/ChannelListView.test.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { cleanup, render, waitFor } from '@testing-library/react-native'; -import type { Channel, QueryChannelsRequestType, StreamChat, UserResponse } from 'stream-chat'; +import type { Channel, StreamChat, UserResponse } from 'stream-chat'; import type { ChannelsContextValue } from '../../../contexts/channelsContext/ChannelsContext'; import { ChannelsProvider } from '../../../contexts/channelsContext/ChannelsContext'; @@ -13,13 +13,14 @@ import { getTestClientWithUser } from '../../../mock-builders/mock'; import { Chat } from '../../Chat/Chat'; import { ChannelList } from '../ChannelList'; import { ChannelListView } from '../ChannelListView'; +import type { ChannelListQueryChannelsOverride } from '../hooks/usePaginatedChannels'; let chatClient: StreamChat; let defaultChannels: Channel[]; let queryChannelsResponse: Channel[]; -const queryChannelsOverride: QueryChannelsRequestType = () => - Promise.resolve(queryChannelsResponse); +const queryChannelsOverride: ChannelListQueryChannelsOverride = () => + Promise.resolve({ items: queryChannelsResponse }); /** * Renders the full ChannelList (which now always uses ChannelListView internally). diff --git a/package/src/components/ChannelList/hooks/usePaginatedChannels.ts b/package/src/components/ChannelList/hooks/usePaginatedChannels.ts index be2f533585..1e48ae36cf 100644 --- a/package/src/components/ChannelList/hooks/usePaginatedChannels.ts +++ b/package/src/components/ChannelList/hooks/usePaginatedChannels.ts @@ -1,210 +1,247 @@ -import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { + Channel, ChannelFilters, ChannelManager, - ChannelManagerState, ChannelOptions, + ChannelPaginator, + ChannelPaginatorState, + ChannelQueryShape, ChannelSort, + PaginatorOptions, } from 'stream-chat'; import { useActiveChannelsRefContext } from '../../../contexts/activeChannelsRefContext/ActiveChannelsRefContext'; import { useChatContext } from '../../../contexts/chatContext/ChatContext'; import { useStateStore } from '../../../hooks'; -import { useIsMountedRef } from '../../../hooks/useIsMountedRef'; +import { useLazyRef } from '../../../hooks/useLazyRef'; +import { useStableCallback } from '../../../hooks/useStableCallback'; +import { generateRandomId } from '../../../utils/utils'; + +/** + * Custom `queryChannels` implementation for a `ChannelList`. Mapped straight onto the paginator's + * `doRequest`: it receives the request the paginator would have sent and must return the resolved + * channels (call `client.queryChannels(...)` inside so client state stays in sync). It supersedes the + * legacy `queryChannelsOverride` (which was typed as the now-removed `QueryChannelsRequestType`). + */ +export type ChannelListQueryChannelsOverride = PaginatorOptions< + Channel, + ChannelQueryShape +>['doRequest']; type Parameters = { channelManager: ChannelManager; enableOfflineSupport: boolean; filters: ChannelFilters; options: ChannelOptions; - setForceUpdate: React.Dispatch>; sort: ChannelSort; + lockChannelOrder?: boolean; + queryChannelsOverride?: ChannelListQueryChannelsOverride; }; const RETRY_INTERVAL_IN_MS = 5000; -type QueryType = 'queryLocalDB' | 'reload' | 'refresh' | 'loadChannels' | 'backgroundRefresh'; +type QueryType = 'reload' | 'refresh' | 'loadChannels' | 'backgroundRefresh'; -export type QueryChannels = (queryType?: QueryType, retryCount?: number) => Promise; - -const selector = (nextValue: ChannelManagerState) => +const selector = (nextValue: ChannelPaginatorState) => ({ - channelListInitialized: nextValue.initialized, - channels: nextValue.channels, - error: nextValue.error, - pagination: nextValue.pagination, + channels: nextValue.items, + hasNextPage: nextValue.hasMoreTail, + isLoading: nextValue.isLoading, + lastQueryError: nextValue.lastQueryError, }) as const; export const usePaginatedChannels = ({ channelManager, enableOfflineSupport, filters = {}, + lockChannelOrder = false, options = {}, + queryChannelsOverride, sort = [], }: Parameters) => { - const [staticChannelsActive, setStaticChannelsActive] = useState(false); - const [activeQueryType, setActiveQueryType] = useState('queryLocalDB'); + const [activeQueryType, setActiveQueryType] = useState(null); const activeChannels = useActiveChannelsRefContext(); - const isMountedRef = useIsMountedRef(); const { client } = useChatContext(); - const { channelListInitialized, channels, pagination, error } = - useStateStore(channelManager?.state, selector) ?? {}; - const hasNextPage = pagination?.hasNext; - - const filtersRef = useRef(null); - const optionsRef = useRef(null); - const sortRef = useRef(null); - const activeRequestId = useRef(0); - const isQueryingRef = useRef(false); - const lastRefresh = useRef(Date.now()); - const queryChannels: QueryChannels = async ( - queryType: QueryType = 'loadChannels', - ): Promise => { - if (!client || !isMountedRef.current) { - return; + /** + * One `ChannelPaginator` per `` instance, contributed to the shared `ChannelManager`. + * The id is stable for the component's lifetime so the manager routes events to it and we can remove + * it on unmount. Filters/sort/options are updated in place via setters when props change (the setters + * do NOT reset the paginator, so the list is not blanked on a re-query — matching the legacy behavior). + */ + const paginatorIdRef = useLazyRef(() => `channels:${generateRandomId()}`); + const paginator = useMemo(() => { + const existing = channelManager.getPaginatorById(paginatorIdRef.current); + if (existing) { + return existing as ChannelPaginator; } + const { limit, offset: _offset, ...requestOptions } = options; + return new ChannelPaginator({ + channelStateOptions: { + skipInitialization: enableOfflineSupport ? undefined : activeChannels.current, + }, + client, + filters, + id: paginatorIdRef.current, + paginatorOptions: { + doRequest: queryChannelsOverride, + lockItemOrder: lockChannelOrder, + ...(typeof limit === 'number' ? { pageSize: limit } : {}), + }, + requestOptions, + sort, + }); + // Only (re)create when the manager or client identity changes. Prop changes are applied via + // setters below; recreating would blank the list. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [channelManager, client]); - const hasUpdatedData = - queryType === 'loadChannels' || - queryType === 'refresh' || - queryType === 'backgroundRefresh' || - JSON.stringify(filtersRef.current) !== JSON.stringify(filters) || - JSON.stringify(optionsRef.current) !== JSON.stringify(options) || - JSON.stringify(sortRef.current) !== JSON.stringify(sort); - - const isQueryStale = () => !isMountedRef || activeRequestId.current !== currentRequestId; - - /** - * We don't need to make another call to query channels if we don't - * have new data for the query to include - * */ - if (!hasUpdatedData) { - if (activeQueryType === null) { - return; - } - } + const { channels, hasNextPage, isLoading, lastQueryError } = + useStateStore(paginator.state, selector) ?? {}; - filtersRef.current = filters; - optionsRef.current = options; - sortRef.current = sort; - isQueryingRef.current = true; - activeRequestId.current++; - const currentRequestId = activeRequestId.current; - setActiveQueryType(queryType); - - const newOptions = { - offset: 0, - ...options, + const channelListInitialized = channels !== undefined; + const error = lastQueryError; + + const isMountedRef = useRef(true); + const lastRefresh = useRef(Date.now()); + + useEffect(() => { + isMountedRef.current = true; + return () => { + isMountedRef.current = false; }; + }, []); - try { - if (isQueryStale() || !isMountedRef.current) { - return; - } - /** - * We skipInitialization here for handling race condition between ChannelList, Channel (and Thread) - * when they all (may) update the channel state at the same time (when connection state recovers) - * TODO: if we move the channel state to a single context and share it between ChannelList, Channel and Thread we can remove this - */ - if (queryType === 'loadChannels') { - await channelManager.loadNext(); - } else { - await channelManager.queryChannels( - { ...newOptions, filter_conditions: filters, sort }, - { - skipInitialization: enableOfflineSupport ? undefined : activeChannels.current, - }, - ); - } + /** + * Insert the paginator into the shared manager on mount and remove it on unmount. `ChannelManager` + * has no `removePaginator`, so removal is done through its public `StateStore` plus `dispose()` to + * unlink the paginator from the shared item store (otherwise it lingers and keeps handling events). + */ + useEffect(() => { + channelManager.insertPaginator({ paginator }); - setStaticChannelsActive(false); - isQueryingRef.current = false; - } catch (err: unknown) { - isQueryingRef.current = false; + return () => { + channelManager.state.partialNext({ + paginators: channelManager.paginators.filter((p) => p !== paginator), + }); + paginator.dispose(); + }; + }, [channelManager, paginator]); - if (isQueryStale()) { + const queryChannels = useStableCallback( + async (queryType: QueryType = 'loadChannels'): Promise => { + if (!client || !isMountedRef.current) { return; } - console.warn(err); - } + // Keep `skipInitialization` current for the online query (avoids clobbering the state of already + // active channels on reconnect). Only relevant when offline support is disabled. + paginator.channelStateOptions = { + skipInitialization: enableOfflineSupport ? undefined : activeChannels.current, + }; + + setActiveQueryType(queryType); + + try { + if (queryType === 'loadChannels') { + // Next page — append toward the tail, keeping the current list. + await paginator.toTail(); + } else if (queryType === 'backgroundRefresh') { + // Reconnect refresh — refresh without blanking the visible list. + await paginator.toTail({ keepPreviousItems: true, reset: 'yes' }); + } else if (queryType === 'refresh') { + // Pull-to-refresh — keep the list visible; the RefreshControl spinner conveys progress. + await paginator.toTail({ keepPreviousItems: true, reset: 'yes' }); + } else { + // Reload (initial load / filters-sort-options change) — fresh first page. + await paginator.reload(); + } + } catch (err: unknown) { + console.warn(err); + } - setActiveQueryType(null); - }; + if (isMountedRef.current) { + setActiveQueryType(null); + } + }, + ); + + const refreshList = useStableCallback( + async ({ isBackground = false }: { isBackground?: boolean } = {}) => { + const now = Date.now(); + // Only allow pull-to-refresh 5 seconds after the last successful refresh. + if (now - lastRefresh.current < RETRY_INTERVAL_IN_MS && error === undefined) { + return; + } - const refreshList = async ({ isBackground = false }: { isBackground?: boolean } = {}) => { - const now = Date.now(); - // Only allow pull-to-refresh 5 seconds after last successful refresh. - if (now - lastRefresh.current < RETRY_INTERVAL_IN_MS && error === undefined) { - return; - } + lastRefresh.current = Date.now(); + await queryChannels(isBackground ? 'backgroundRefresh' : 'refresh'); + }, + ); - lastRefresh.current = Date.now(); - await queryChannels(isBackground ? 'backgroundRefresh' : 'refresh'); - }; + const reloadList = useStableCallback(() => queryChannels('reload')); - const reloadList = async () => { - await queryChannels('reload'); - }; + const loadNextPage = useStableCallback(() => queryChannels('loadChannels')); /** - * Equality check using stringified filters/options/sort ensure that we don't make un-necessary queryChannels api calls - * for the scenario: - * - * - * - * Here we have passed filters as inline object, which means on every re-render of - * parent component, ChannelList will receive new object reference (even though value is same), which - * in return will trigger useEffect. To avoid this, we can add a value check. + * Equality check using stringified filters/options/sort ensures we don't run unnecessary queries + * when a parent re-render passes new object references with the same value. */ const filterStr = useMemo(() => JSON.stringify(filters), [filters]); const optionsStr = useMemo(() => JSON.stringify(options), [options]); const sortStr = useMemo(() => JSON.stringify(sort), [sort]); useEffect(() => { + // Sync the paginator config with the current props (setters don't reset state → no blank flash), + // then reload with the new query shape. + paginator.staticFilters = filters; + paginator.sort = sort; + const { limit, offset: _offset, ...requestOptions } = options; + paginator.options = requestOptions; + if (typeof limit === 'number') { + paginator.pageSize = limit; + } + + reloadList(); + const listener: ReturnType = client.on( 'connection.changed', async (event) => { if (event.online) { - // Reconnection refreshes should stay silent, but still share the same debounce - // path as pull-to-refresh. + // Reconnection refreshes stay silent but share the pull-to-refresh debounce path. await refreshList({ isBackground: true }); } }, ); - reloadList(); return () => listener?.unsubscribe?.(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [filterStr, optionsStr, sortStr, channelManager]); + }, [filterStr, optionsStr, sortStr, paginator]); + + // Propagate runtime `lockChannelOrder` changes without a re-query (matches the legacy `setOptions` + // effect). Only affects how subsequent event-driven ingests reorder the list. + useEffect(() => { + paginator.config.lockItemOrder = lockChannelOrder; + }, [paginator, lockChannelOrder]); + + // Propagate a runtime `queryChannelsOverride` swap (matches the legacy `setQueryChannelsRequest` + // effect). The next query picks it up; no immediate reload needed. + useEffect(() => { + paginator.config.doRequest = queryChannelsOverride; + }, [paginator, queryChannelsOverride]); return { channelListInitialized, channels, error, hasNextPage, - loadingChannels: - activeQueryType === 'queryLocalDB' - ? true - : // Although channels.length === 0 should come as a given when we have !channelListInitialized, - // due to the way offline storage works currently we have to do this additional - // check to make sure channels were not populated before the reactive list becomes - // ready. I do not like providing a way to set the ready state, as it should be managed - // in the LLC entirely. Once we move offline support to the LLC, we can remove this check - // too as it'll be redundant. - pagination?.isLoading || (!channelListInitialized && channels.length === 0 && !error), - loadingNextPage: pagination?.isLoadingNext, - loadNextPage: channelManager.loadNext, + loadingChannels: channels === undefined && !error, + loadingNextPage: activeQueryType === 'loadChannels' && !!isLoading, + loadNextPage, refreshing: activeQueryType === 'refresh', refreshList: () => refreshList(), reloadList, - staticChannelsActive, }; }; diff --git a/package/src/components/Chat/Chat.tsx b/package/src/components/Chat/Chat.tsx index e8e647dfd2..c074ddc0ae 100644 --- a/package/src/components/Chat/Chat.tsx +++ b/package/src/components/Chat/Chat.tsx @@ -32,7 +32,7 @@ import { version } from '../../version.json'; init(); export type ChatProps = Pick & - Partial> & { + Partial> & { /** * When false, ws connection won't be disconnection upon backgrounding the app. * To receive push notifications, its necessary that user doesn't have active @@ -145,6 +145,7 @@ const selector = (nextValue: OfflineDBState) => const ChatWithContext = (props: PropsWithChildren) => { const { + channelManager: customChannelManager, children, client, closeConnectionOnBackground = true, @@ -169,6 +170,18 @@ const ChatWithContext = (props: PropsWithChildren) => { [client.user?.language, translators], ); + /** + * The shared channel-list orchestrator. Created once per client (or supplied by the integrator via + * the `channelManager` prop). It holds no paginators here — each `` contributes and + * removes its own `ChannelPaginator`, and registers/unregisters the manager's WS subscriptions while + * it is mounted. Kept as static as possible so the paginators/subscriptions are not torn down on + * unrelated Chat re-renders. + */ + const channelManager = useMemo( + () => customChannelManager ?? client.createChannelManager({}), + [client, customChannelManager], + ); + /** * Setup connection event listeners */ @@ -271,6 +284,7 @@ const ChatWithContext = (props: PropsWithChildren) => { const chatContext = useCreateChatContext({ appSettings, channel, + channelManager, client, connectionRecovering, enableOfflineSupport, diff --git a/package/src/components/Chat/hooks/useCreateChatContext.ts b/package/src/components/Chat/hooks/useCreateChatContext.ts index 1a74a10e58..120b8d84a9 100644 --- a/package/src/components/Chat/hooks/useCreateChatContext.ts +++ b/package/src/components/Chat/hooks/useCreateChatContext.ts @@ -5,6 +5,7 @@ import type { ChatContextValue } from '../../../contexts/chatContext/ChatContext export const useCreateChatContext = ({ appSettings, channel, + channelManager, client, connectionRecovering, enableOfflineSupport, @@ -25,6 +26,7 @@ export const useCreateChatContext = ({ () => ({ appSettings, channel, + channelManager, client, connectionRecovering, enableOfflineSupport, @@ -34,7 +36,15 @@ export const useCreateChatContext = ({ setActiveChannel, }), // eslint-disable-next-line react-hooks/exhaustive-deps - [appSettings, channelId, clientValues, connectionRecovering, isOnline, mutedUsersLength], + [ + appSettings, + channelId, + channelManager, + clientValues, + connectionRecovering, + isOnline, + mutedUsersLength, + ], ); return chatContext; diff --git a/package/src/contexts/channelsContext/ChannelsContext.tsx b/package/src/contexts/channelsContext/ChannelsContext.tsx index c6aa806d22..851da09a99 100644 --- a/package/src/contexts/channelsContext/ChannelsContext.tsx +++ b/package/src/contexts/channelsContext/ChannelsContext.tsx @@ -5,7 +5,6 @@ import type { FlatList } from 'react-native-gesture-handler'; import type { Channel } from 'stream-chat'; -import type { QueryChannels } from '../../components/ChannelList/hooks/usePaginatedChannels'; import type { GetChannelActionItems } from '../../hooks/actions/useChannelActionItems'; import { DEFAULT_BASE_CONTEXT_VALUE } from '../utils/defaultBaseContextValue'; @@ -63,7 +62,7 @@ export type ChannelsContextValue = { /** * Loads the next page of `channels`, which is present as a required prop */ - loadNextPage: QueryChannels; + loadNextPage: () => Promise; /** * Max number to display within notification badge. Default: 255 and it cannot be higher than that for now due to backend limitations */ diff --git a/package/src/contexts/chatContext/ChatContext.tsx b/package/src/contexts/chatContext/ChatContext.tsx index 291c9fad61..0af2d31a60 100644 --- a/package/src/contexts/chatContext/ChatContext.tsx +++ b/package/src/contexts/chatContext/ChatContext.tsx @@ -1,6 +1,12 @@ import React, { PropsWithChildren, useContext } from 'react'; -import type { Channel, GetApplicationResponse, StreamChat, UserMuteResponse } from 'stream-chat'; +import type { + Channel, + ChannelManager, + GetApplicationResponse, + StreamChat, + UserMuteResponse, +} from 'stream-chat'; import { MessageContextValue } from '../messageContext/MessageContext'; import { DEFAULT_BASE_CONTEXT_VALUE } from '../utils/defaultBaseContextValue'; @@ -12,6 +18,15 @@ export type ChatContextValue = { * Object of application settings returned from Stream. * */ appSettings: GetApplicationResponse | null; + /** + * The shared `ChannelManager` instance that orchestrates the channel-list paginators and keeps + * them in sync with WS events. It is created by `` (or supplied via the `channelManager` + * prop) and consumed by ``. Exposed here so it can be inspected/driven directly for + * advanced / multi-list use cases. + * + * @overrideType ChannelManager + */ + channelManager: ChannelManager; /** * The StreamChat client object * diff --git a/package/src/mock-builders/mock.ts b/package/src/mock-builders/mock.ts index d5d661ac8f..8f8cf32df0 100644 --- a/package/src/mock-builders/mock.ts +++ b/package/src/mock-builders/mock.ts @@ -40,10 +40,11 @@ function mockClient(client: StreamChat, options: MockClientOptions = {}): Stream const { disableAppSettings = true } = options; const c = client as MockableStreamChat; - type WithPrivates = { _setToken: () => void; _setupConnection: () => void }; + type WithPrivates = { _setToken: () => void; openConnection: () => void }; const withPrivates = c as unknown as WithPrivates; jest.spyOn(withPrivates, '_setToken').mockImplementation(); - jest.spyOn(withPrivates, '_setupConnection').mockImplementation(); + // v10 renamed the private `_setupConnection` to the public `openConnection`. + jest.spyOn(withPrivates, 'openConnection').mockImplementation(); c.tokenManager = { getToken: jest.fn(() => token), tokenReady: jest.fn(() => true), From 670c9f8a23c48238b653f2815539927989d6f09b Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Fri, 7 Aug 2026 09:58:09 +0200 Subject: [PATCH 2/7] fix: refresh api --- .../ChannelList/hooks/usePaginatedChannels.ts | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/package/src/components/ChannelList/hooks/usePaginatedChannels.ts b/package/src/components/ChannelList/hooks/usePaginatedChannels.ts index 1e48ae36cf..fd80b42d89 100644 --- a/package/src/components/ChannelList/hooks/usePaginatedChannels.ts +++ b/package/src/components/ChannelList/hooks/usePaginatedChannels.ts @@ -148,15 +148,14 @@ export const usePaginatedChannels = ({ if (queryType === 'loadChannels') { // Next page — append toward the tail, keeping the current list. await paginator.toTail(); - } else if (queryType === 'backgroundRefresh') { - // Reconnect refresh — refresh without blanking the visible list. - await paginator.toTail({ keepPreviousItems: true, reset: 'yes' }); - } else if (queryType === 'refresh') { - // Pull-to-refresh — keep the list visible; the RefreshControl spinner conveys progress. - await paginator.toTail({ keepPreviousItems: true, reset: 'yes' }); - } else { - // Reload (initial load / filters-sort-options change) — fresh first page. + } else if (queryType === 'reload') { + // Initial load / filters-sort-options change — fresh first page (blanks to the skeleton). await paginator.reload(); + } else { + // Pull-to-refresh / reconnect — first-page reset that REPLACES the list, but keeps the current + // channels visible during the fetch (no skeleton flash). `reset: 'yes'` re-establishes the + // window from page 1; `keepPreviousItems` keeps the list visible until the fresh page swaps in. + await paginator.toTail({ keepPreviousItems: true, reset: 'yes' }); } } catch (err: unknown) { console.warn(err); From e9344119306345b930c79afa37e1a3f15f3d1f81 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Sat, 8 Aug 2026 00:04:59 +0200 Subject: [PATCH 3/7] fix: race conditions during reconnection --- .../ChannelList/hooks/usePaginatedChannels.ts | 33 ++++++++++++++----- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/package/src/components/ChannelList/hooks/usePaginatedChannels.ts b/package/src/components/ChannelList/hooks/usePaginatedChannels.ts index fd80b42d89..2531276647 100644 --- a/package/src/components/ChannelList/hooks/usePaginatedChannels.ts +++ b/package/src/components/ChannelList/hooks/usePaginatedChannels.ts @@ -136,10 +136,16 @@ export const usePaginatedChannels = ({ return; } - // Keep `skipInitialization` current for the online query (avoids clobbering the state of already - // active channels on reconnect). Only relevant when offline support is disabled. + // Do NOT skip state initialization on the (re)query. `activeChannels.current` is + // `Object.keys(channelsState)` — every channel ever MOUNTED, and it is never cleared on + // navigate-back — so passing it as `skipInitialization` made `hydrateActiveChannels` skip + // `seedFirstPageSync`/`_initializeState` for every previously-opened channel on each reconnect. + // Those channels' `messagePaginator.aggregateState` then never re-seeds on the fresh socket, so + // their list-row preview (last message / unread, sourced from that aggregate) freezes while the + // list still reorders. Re-initializing matches the offline-enabled path; the client still guards a + // scrolled-up open channel from being clobbered via the `isActiveIntervalAtHead` check. paginator.channelStateOptions = { - skipInitialization: enableOfflineSupport ? undefined : activeChannels.current, + skipInitialization: undefined, }; setActiveQueryType(queryType); @@ -168,10 +174,19 @@ export const usePaginatedChannels = ({ ); const refreshList = useStableCallback( - async ({ isBackground = false }: { isBackground?: boolean } = {}) => { + async ({ + force = false, + isBackground = false, + }: { force?: boolean; isBackground?: boolean } = {}) => { const now = Date.now(); - // Only allow pull-to-refresh 5 seconds after the last successful refresh. - if (now - lastRefresh.current < RETRY_INTERVAL_IN_MS && error === undefined) { + // Only allow pull-to-refresh 5 seconds after the last successful refresh. A reconnect (`force`) + // must bypass this throttle: it is the sole trigger that re-establishes channel watches after the + // socket reopens (the JS client's own recovery is disabled via `recoverStateOnReconnect = false`), + // so debouncing it leaves the channels un-watched — the list still reorders on member-level + // `notification.message_new`, but per-channel state (last message / unread) stays frozen until the + // next reconnect > 5s later or an app reload. This bites both a reconnect < 5s after launch + // (`lastRefresh` is seeded to mount time) and two reconnects < 5s apart. + if (!force && now - lastRefresh.current < RETRY_INTERVAL_IN_MS && error === undefined) { return; } @@ -209,8 +224,10 @@ export const usePaginatedChannels = ({ 'connection.changed', async (event) => { if (event.online) { - // Reconnection refreshes stay silent but share the pull-to-refresh debounce path. - await refreshList({ isBackground: true }); + // Reconnection refreshes stay silent (`isBackground`) but must NOT be throttled by the + // pull-to-refresh debounce (`force`) — this is the query that re-watches the channels on the + // fresh socket. See the `force` note in `refreshList`. + await refreshList({ force: true, isBackground: true }); } }, ); From c52345c53a9601d71b896f7fd70434cb47cc869c Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Sat, 8 Aug 2026 01:22:04 +0200 Subject: [PATCH 4/7] feat: migrate to new api --- .../ChannelList/hooks/usePaginatedChannels.ts | 13 +++++++------ package/src/components/Chat/Chat.tsx | 5 ++++- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/package/src/components/ChannelList/hooks/usePaginatedChannels.ts b/package/src/components/ChannelList/hooks/usePaginatedChannels.ts index 2531276647..f270d8ee5d 100644 --- a/package/src/components/ChannelList/hooks/usePaginatedChannels.ts +++ b/package/src/components/ChannelList/hooks/usePaginatedChannels.ts @@ -115,17 +115,18 @@ export const usePaginatedChannels = ({ }, []); /** - * Insert the paginator into the shared manager on mount and remove it on unmount. `ChannelManager` - * has no `removePaginator`, so removal is done through its public `StateStore` plus `dispose()` to - * unlink the paginator from the shared item store (otherwise it lingers and keeps handling events). + * Insert the paginator into the shared manager on mount and remove it on unmount. `removePaginator` + * detaches it from the manager (restores its own query filtering and cancels any scheduled query); + * `dispose()` then releases the paginator's own throttles and index so it stops handling events. */ useEffect(() => { channelManager.insertPaginator({ paginator }); return () => { - channelManager.state.partialNext({ - paginators: channelManager.paginators.filter((p) => p !== paginator), - }); + // TODO: Figure out if we really want to dispose of paginators. Why would we want to create a new + // paginator each time this mounts and then dispose it ? Perhaps a better way is to keep the + // state stable and perhaps only dispose on user disconnect or something like that. + channelManager.removePaginator(paginator); paginator.dispose(); }; }, [channelManager, paginator]); diff --git a/package/src/components/Chat/Chat.tsx b/package/src/components/Chat/Chat.tsx index c074ddc0ae..7d513c6d68 100644 --- a/package/src/components/Chat/Chat.tsx +++ b/package/src/components/Chat/Chat.tsx @@ -177,8 +177,11 @@ const ChatWithContext = (props: PropsWithChildren) => { * it is mounted. Kept as static as possible so the paginators/subscriptions are not torn down on * unrelated Chat re-renders. */ + // TODO: This will do for now for the purposes of going ahead, but let's think of a better way to + // attach the manager to our client and not just propagate it through context. It is not + // necessary at all. const channelManager = useMemo( - () => customChannelManager ?? client.createChannelManager({}), + () => customChannelManager ?? client.channelManager, [client, customChannelManager], ); From 8828de4a1da08fd4d9f8b126678e2b0685326f0f Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Sat, 8 Aug 2026 03:51:41 +0200 Subject: [PATCH 5/7] feat: remove event handler overrides and cleanup channellist --- .../components/ChannelList/ChannelList.tsx | 160 +------------- .../__tests__/ChannelList.test.tsx | 207 ------------------ .../src/components/ChannelList/hooks/index.ts | 1 - .../__tests__/useChannelUpdated.test.tsx | 87 -------- .../hooks/listeners/useChannelUpdated.ts | 49 ----- 5 files changed, 1 insertion(+), 503 deletions(-) delete mode 100644 package/src/components/ChannelList/hooks/listeners/__tests__/useChannelUpdated.test.tsx delete mode 100644 package/src/components/ChannelList/hooks/listeners/useChannelUpdated.ts diff --git a/package/src/components/ChannelList/ChannelList.tsx b/package/src/components/ChannelList/ChannelList.tsx index 5da129ea14..b27a837688 100644 --- a/package/src/components/ChannelList/ChannelList.tsx +++ b/package/src/components/ChannelList/ChannelList.tsx @@ -3,16 +3,7 @@ import React, { useEffect, useState } from 'react'; import { StyleSheet, View } from 'react-native'; import type { FlatList } from 'react-native-gesture-handler'; -import { - Channel, - ChannelFilters, - ChannelManager, - ChannelManagerEventHandlerContext, - ChannelOptions, - ChannelSort, - EventHandlerPipelineHandler, - EventType, -} from 'stream-chat'; +import { Channel, ChannelFilters, ChannelOptions, ChannelSort } from 'stream-chat'; import { ChannelListView } from './ChannelListView'; import { useCreateChannelsContext } from './hooks/useCreateChannelsContext'; @@ -32,21 +23,6 @@ import { useLazyRef } from '../../hooks/useLazyRef'; import { generateRandomId } from '../../utils/utils'; import { NotificationTargetProvider } from '../Notifications/NotificationTargetContext'; -/** - * A `ChannelList` event handler. It is registered on the shared `ChannelManager`'s - * `EventHandlerPipeline` for its event type and REPLACES the SDK's default handler for that event - * (matching the previous "override" semantics). It receives the routed `event` plus a `ctx` exposing - * the `channelManager` — from which the relevant `ChannelPaginator`(s) can be read/mutated - * (`ingestItem`, `removeItem`, `setItems`, `boost`, …). Returning `{ action: 'stop' }` cancels the rest - * of the pipeline for that event. - * - * NOTE (breaking change vs v9): these handlers previously received `(setChannels, event, options?)`. - * The `setChannels` dispatcher no longer exists — list mutation now goes through the paginator obtained - * from `ctx.channelManager`. - */ -export type ChannelListEventHandler = - EventHandlerPipelineHandler; - export type ChannelListProps = Partial< Pick< ChannelsContextValue, @@ -76,70 +52,6 @@ export type ChannelListProps = Partial< * If set to true, channels won't dynamically sort by most recent message, defaults to false */ lockChannelOrder?: boolean; - /** - * Overrides the default handler for the `notification.added_to_channel` event on the shared - * `ChannelManager`. See {@link ChannelListEventHandler}. - * - * @overrideType Function - * */ - onAddedToChannel?: ChannelListEventHandler; - /** - * Overrides the default handler for the `channel.deleted` event. In its absence the channel is - * removed from the list. See {@link ChannelListEventHandler}. - * - * @overrideType Function - * */ - onChannelDeleted?: ChannelListEventHandler; - /** - * Overrides the default handler for the `channel.hidden` event. See {@link ChannelListEventHandler}. - * - * @overrideType Function - * */ - onChannelHidden?: ChannelListEventHandler; - /** - * Overrides the default handler for the `member.updated` event. See {@link ChannelListEventHandler}. - * - * @overrideType Function - */ - onChannelMemberUpdated?: ChannelListEventHandler; - /** - * Overrides the default handler for the `channel.truncated` event. See {@link ChannelListEventHandler}. - * - * @overrideType Function - * */ - onChannelTruncated?: ChannelListEventHandler; - /** - * Overrides the default handler for the `channel.updated` event. See {@link ChannelListEventHandler}. - * - * @overrideType Function - * */ - onChannelUpdated?: ChannelListEventHandler; - /** - * Overrides the default handler for the `channel.visible` event. See {@link ChannelListEventHandler}. - * - * @overrideType Function - * */ - onChannelVisible?: ChannelListEventHandler; - /** - * Overrides the default handler for the `message.new` event. See {@link ChannelListEventHandler}. - * - * @overrideType Function - * */ - onNewMessage?: ChannelListEventHandler; - /** - * Overrides the default handler for the `notification.message_new` event (received for a channel that - * is not being watched). See {@link ChannelListEventHandler}. - * - * @overrideType Function - * */ - onNewMessageNotification?: ChannelListEventHandler; - /** - * Overrides the default handler for the `notification.removed_from_channel` event. - * See {@link ChannelListEventHandler}. - * - * @overrideType Function - * */ - onRemovedFromChannel?: ChannelListEventHandler; /** * Object containing channel query options * @see See [Channel query documentation](https://getstream.io/chat/docs/query_channels) for a list of available option fields @@ -167,9 +79,6 @@ const DEFAULT_FILTERS = {}; const DEFAULT_OPTIONS = {}; const DEFAULT_SORT: ChannelSort = []; -/** The event types whose default handlers a `ChannelList` prop can override, mapped to the prop. */ -const OVERRIDE_HANDLER_ID_PREFIX = 'stream-chat-react-native:channel-list'; - /** * This component fetches a list of channels, allowing you to select the channel you want to open. * The ChannelList renders a ChannelListView which provides the UI for the underlying React Native FlatList. @@ -186,16 +95,6 @@ export const ChannelList = (props: ChannelListProps) => { lockChannelOrder = false, maxUnreadCount = 255, numberOfSkeletons = 8, - onAddedToChannel, - onChannelDeleted, - onChannelHidden, - onChannelMemberUpdated, - onChannelTruncated, - onChannelUpdated, - onChannelVisible, - onNewMessage, - onNewMessageNotification, - onRemovedFromChannel, onSelect, options = DEFAULT_OPTIONS, getChannelActionItems, @@ -214,63 +113,6 @@ export const ChannelList = (props: ChannelListProps) => { const { channelManager, enableOfflineSupport } = useChatContext(); const { NotificationList } = useComponentsContext(); - /** - * Register this list's event-handler overrides on the shared `ChannelManager`. Each provided prop - * replaces the SDK default handler for that event type; on unmount / prop change we restore the - * default. Handlers are manager-global: with multiple mounted ``s the last-registered - * override for a given event wins (single-list is the common case). - */ - useEffect(() => { - const overrides: Array<[EventType, ChannelListEventHandler | undefined]> = [ - ['channel.deleted', onChannelDeleted], - ['channel.hidden', onChannelHidden], - ['channel.truncated', onChannelTruncated], - ['channel.updated', onChannelUpdated], - ['channel.visible', onChannelVisible], - ['member.updated', onChannelMemberUpdated], - ['message.new', onNewMessage], - ['notification.added_to_channel', onAddedToChannel], - ['notification.message_new', onNewMessageNotification], - ['notification.removed_from_channel', onRemovedFromChannel], - ]; - - const overriddenEventTypes = overrides - .filter(([, handle]) => typeof handle === 'function') - .map(([eventType, handle]) => { - channelManager.setEventHandlers({ - eventType, - handlers: [{ handle: handle!, id: `${OVERRIDE_HANDLER_ID_PREFIX}:${eventType}` }], - }); - return eventType; - }); - - if (overriddenEventTypes.length === 0) { - return; - } - - const defaultHandlers = ChannelManager.getDefaultHandlers(); - return () => { - overriddenEventTypes.forEach((eventType) => { - channelManager.setEventHandlers({ - eventType, - handlers: defaultHandlers[eventType] ?? [], - }); - }); - }; - }, [ - channelManager, - onAddedToChannel, - onChannelDeleted, - onChannelHidden, - onChannelMemberUpdated, - onChannelTruncated, - onChannelUpdated, - onChannelVisible, - onNewMessage, - onNewMessageNotification, - onRemovedFromChannel, - ]); - // Ref-counted on the shared manager: subscriptions live only while at least one ChannelList is mounted. useEffect(() => channelManager.registerSubscriptions(), [channelManager]); diff --git a/package/src/components/ChannelList/__tests__/ChannelList.test.tsx b/package/src/components/ChannelList/__tests__/ChannelList.test.tsx index 54afa94178..1a5ac91e0a 100644 --- a/package/src/components/ChannelList/__tests__/ChannelList.test.tsx +++ b/package/src/components/ChannelList/__tests__/ChannelList.test.tsx @@ -23,7 +23,6 @@ import { queryChannelsApi } from '../../../mock-builders/api/queryChannels'; import { useMockedApis } from '../../../mock-builders/api/useMockedApis'; import dispatchChannelDeletedEvent from '../../../mock-builders/event/channelDeleted'; import dispatchChannelHiddenEvent from '../../../mock-builders/event/channelHidden'; -import dispatchChannelTruncatedEvent from '../../../mock-builders/event/channelTruncated'; import dispatchChannelUpdatedEvent from '../../../mock-builders/event/channelUpdated'; import dispatchConnectionChangedEvent from '../../../mock-builders/event/connectionChanged'; import dispatchConnectionRecoveredEvent from '../../../mock-builders/event/connectionRecovered'; @@ -561,31 +560,6 @@ describe('ChannelList', () => { expect(within(items[2]).getByText(newMessage.text as string)).toBeTruthy(); }); }); - it('should call the `onNewMessage` function prop, if provided', async () => { - const onNewMessage = jest.fn(); - render( - - - - - , - ); - - await waitFor(() => { - expect(screen.getByTestId('channel-list-view')).toBeTruthy(); - }); - - act(() => - dispatchMessageNewEvent( - chatClient, - testChannel2.channel as unknown as Parameters[1], - ), - ); - - await waitFor(() => { - expect(onNewMessage).toHaveBeenCalledTimes(1); - }); - }); }); describe('notification.message_new', () => { @@ -618,53 +592,6 @@ describe('ChannelList', () => { expect(within(items[0]).getByTestId(testChannel3.channel.id)).toBeTruthy(); }); }); - - it('should call the `onNewMessage` function prop, if provided', async () => { - const onNewMessage = jest.fn(); - render( - - - - - , - ); - - await waitFor(() => { - expect(screen.getByTestId('channel-list-view')).toBeTruthy(); - }); - - act(() => - dispatchMessageNewEvent( - chatClient, - testChannel2.channel as unknown as Parameters[1], - ), - ); - - await waitFor(() => { - expect(onNewMessage).toHaveBeenCalledTimes(1); - }); - }); - - it('should call the `onNewMessageNotification` function prop, if provided', async () => { - const onNewMessageNotification = jest.fn(); - render( - - - - - , - ); - - await waitFor(() => { - expect(screen.getByTestId('channel-list-view')).toBeTruthy(); - }); - - act(() => dispatchNotificationMessageNewEvent(chatClient, testChannel2.channel)); - - await waitFor(() => { - expect(onNewMessageNotification).toHaveBeenCalledTimes(1); - }); - }); }); describe('notification.added_to_channel', () => { @@ -700,27 +627,6 @@ describe('ChannelList', () => { expect(within(items[0]).getByTestId(testChannel3.channel.id)).toBeTruthy(); }); }); - - it('should call the `onAddedToChannel` function prop, if provided', async () => { - const onAddedToChannel = jest.fn(); - render( - - - - - , - ); - - await waitFor(() => { - expect(screen.getByTestId('channel-list-view')).toBeTruthy(); - }); - - act(() => dispatchNotificationAddedToChannelEvent(chatClient, testChannel3.channel)); - - await waitFor(() => { - expect(onAddedToChannel).toHaveBeenCalledTimes(1); - }); - }); }); describe('notification.removed_from_channel', () => { @@ -753,27 +659,6 @@ describe('ChannelList', () => { expect(newItems).toHaveLength(2); }); }); - - it('should call the `onRemovedFromChannel` function prop, if provided', async () => { - const onRemovedFromChannel = jest.fn(); - render( - - - - - , - ); - - await waitFor(() => { - expect(screen.getByTestId('channel-list-view')).toBeTruthy(); - }); - - act(() => dispatchNotificationRemovedFromChannel(chatClient, testChannel3.channel)); - - await waitFor(() => { - expect(onRemovedFromChannel).toHaveBeenCalledTimes(1); - }); - }); }); describe('channel.updated', () => { @@ -805,32 +690,6 @@ describe('ChannelList', () => { expect(screen.getByText('updated')).toBeTruthy(); }); }); - - it('should call the `onChannelUpdated` function prop, if provided', async () => { - const onChannelUpdated = jest.fn(); - render( - - - - - , - ); - - await waitFor(() => { - expect(screen.getByTestId('channel-list-view')).toBeTruthy(); - }); - - act(() => - dispatchChannelUpdatedEvent(chatClient, { - ...testChannel2.channel, - custom: { name: 'updated' }, - }), - ); - - await waitFor(() => { - expect(onChannelUpdated).toHaveBeenCalledTimes(1); - }); - }); }); describe('channel.deleted', () => { @@ -863,27 +722,6 @@ describe('ChannelList', () => { expect(newItems).toHaveLength(1); }); }); - - it('should call the `onChannelDeleted` function prop, if provided', async () => { - const onChannelDeleted = jest.fn(); - render( - - - - - , - ); - - await waitFor(() => { - expect(screen.getByTestId('channel-list-view')).toBeTruthy(); - }); - - act(() => dispatchChannelDeletedEvent(chatClient, testChannel2.channel)); - - await waitFor(() => { - expect(onChannelDeleted).toHaveBeenCalledTimes(1); - }); - }); }); describe('channel.hidden', () => { @@ -916,27 +754,6 @@ describe('ChannelList', () => { expect(newItems).toHaveLength(1); }); }); - - it('should call the `onChannelHidden` function prop, if provided', async () => { - const onChannelHidden = jest.fn(); - render( - - - - - , - ); - - await waitFor(() => { - expect(screen.getByTestId('channel-list-view')).toBeTruthy(); - }); - - act(() => dispatchChannelHiddenEvent(chatClient, testChannel2.channel)); - - await waitFor(() => { - expect(onChannelHidden).toHaveBeenCalledTimes(1); - }); - }); }); describe('connection.recovered', () => { @@ -1004,29 +821,5 @@ describe('ChannelList', () => { dateNowSpy.mockRestore(); }); }); - - describe('channel.truncated', () => { - it('should call the `onChannelTruncated` function prop, if provided', async () => { - useMockedApis(chatClient, [queryChannelsApi([testChannel1])]); - const onChannelTruncated = jest.fn(); - render( - - - - - , - ); - - await waitFor(() => { - expect(screen.getByTestId('channel-list-view')).toBeTruthy(); - }); - - act(() => dispatchChannelTruncatedEvent(chatClient, testChannel1.channel)); - - await waitFor(() => { - expect(onChannelTruncated).toHaveBeenCalledTimes(1); - }); - }); - }); }); }); diff --git a/package/src/components/ChannelList/hooks/index.ts b/package/src/components/ChannelList/hooks/index.ts index 47e0e5001b..5ada89b872 100644 --- a/package/src/components/ChannelList/hooks/index.ts +++ b/package/src/components/ChannelList/hooks/index.ts @@ -1,4 +1,3 @@ -export * from './listeners/useChannelUpdated'; export * from './useChannelMembersState'; export * from './useChannelOnlineMemberCount'; export * from './useMutedChannels'; diff --git a/package/src/components/ChannelList/hooks/listeners/__tests__/useChannelUpdated.test.tsx b/package/src/components/ChannelList/hooks/listeners/__tests__/useChannelUpdated.test.tsx deleted file mode 100644 index ce02470d6d..0000000000 --- a/package/src/components/ChannelList/hooks/listeners/__tests__/useChannelUpdated.test.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import React, { useState } from 'react'; -import { Image, Text } from 'react-native'; - -import { act, render, waitFor } from '@testing-library/react-native'; -import type { Channel, ChannelResponse, Event, StreamChat } from 'stream-chat'; - -import type { ChatContextValue } from '../../../../../contexts/chatContext/ChatContext'; -import { ChatContext, useChannelUpdated } from '../../../../../index'; - -describe('useChannelUpdated', () => { - it("defaults to the channels own_capabilities if the event doesn't include it", async () => { - let eventHanler: (event: Event) => void; - const mockChannel = { - cid: 'channeltype:123abc', - data: { - own_capabilities: { - send_messages: true, - }, - }, - } as unknown as Channel; - - const mockEvent = { - channel: { - cid: mockChannel.cid, - } as ChannelResponse, - type: 'channel.updated', - } as unknown as Event; - - const mockClient = { - off: jest.fn(), - on: jest.fn().mockImplementation((_eventName: string, handler: (event: Event) => void) => { - eventHanler = handler; - }), - } as unknown as StreamChat; - - const TestComponent = () => { - const [channels, setChannels] = useState([mockChannel]); - - useChannelUpdated({ setChannels }); - - if ( - channels && - channels[0].data?.own_capabilities && - Object.keys( - channels[0].data?.own_capabilities as unknown as { [key: string]: boolean }, - ).includes('send_messages') - ) { - return Send messages enabled; - } - - return Send messages NOT enabled; - }; - - const { getByText } = await waitFor(() => - render( - null, - } as unknown as ChatContextValue - } - > - - , - ), - ); - - await waitFor(() => { - expect(getByText('Send messages enabled')).toBeTruthy(); - }); - - act(() => { - eventHanler(mockEvent); - }); - - await waitFor(() => { - expect(getByText('Send messages enabled')).toBeTruthy(); - }); - }); -}); diff --git a/package/src/components/ChannelList/hooks/listeners/useChannelUpdated.ts b/package/src/components/ChannelList/hooks/listeners/useChannelUpdated.ts deleted file mode 100644 index e5d5922a55..0000000000 --- a/package/src/components/ChannelList/hooks/listeners/useChannelUpdated.ts +++ /dev/null @@ -1,49 +0,0 @@ -import React, { useEffect } from 'react'; - -import type { Channel, Event, EventPayload } from 'stream-chat'; - -import { useChatContext } from '../../../../contexts/chatContext/ChatContext'; - -type Parameters = { - setChannels: React.Dispatch>; - onChannelUpdated?: ( - setChannels: React.Dispatch>, - event: Event, - ) => void; -}; - -export const useChannelUpdated = ({ onChannelUpdated, setChannels }: Parameters) => { - const { client } = useChatContext(); - - useEffect(() => { - const handleEvent = (event: EventPayload<'channel.updated'>) => { - if (typeof onChannelUpdated === 'function') { - onChannelUpdated(setChannels, event); - } else { - setChannels((channels) => { - if (!channels) { - return channels; - } - - const index = channels.findIndex( - (channel) => channel.cid === (event.cid || event.channel?.cid), - ); - if (index >= 0 && event.channel) { - channels[index].data = { - ...event.channel, - hidden: event.channel?.hidden ?? channels[index].data?.hidden, - own_capabilities: - event.channel?.own_capabilities ?? channels[index].data?.own_capabilities, - }; - } - - return [...channels]; - }); - } - }; - - const listener = client?.on('channel.updated', handleEvent); - return () => listener?.unsubscribe(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); -}; From b323ea59bf42e01e38d9d8313459cf6287cff8a4 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Mon, 10 Aug 2026 12:29:05 +0200 Subject: [PATCH 6/7] feat: cleanup of moot contexts and state --- package/src/components/Channel/Channel.tsx | 3 - .../Channel/__tests__/Channel.test.tsx | 15 +- .../components/ChannelList/ChannelList.tsx | 5 +- .../ChannelList/hooks/usePaginatedChannels.ts | 10 +- package/src/components/Chat/Chat.tsx | 24 +-- .../Chat/hooks/useCreateChatContext.ts | 12 +- .../__tests__/MessageContent.test.tsx | 149 ++++++++---------- .../__tests__/MessageItemView.test.tsx | 23 ++- .../__tests__/MessageStatus.test.tsx | 25 ++- .../__tests__/ReactionListBottom.test.tsx | 14 +- .../__tests__/ReactionListTop.test.tsx | 14 +- .../Thread/__tests__/Thread.test.tsx | 37 ++--- package/src/contexts/__tests__/index.test.tsx | 5 - .../ActiveChannelsRefContext.tsx | 15 -- .../ChannelsStateContext.tsx | 109 ------------- .../channelsStateContext/useChannelState.ts | 22 --- .../src/contexts/chatContext/ChatContext.tsx | 17 +- 17 files changed, 128 insertions(+), 371 deletions(-) delete mode 100644 package/src/contexts/activeChannelsRefContext/ActiveChannelsRefContext.tsx delete mode 100644 package/src/contexts/channelsStateContext/ChannelsStateContext.tsx delete mode 100644 package/src/contexts/channelsStateContext/useChannelState.ts diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index 61959ba6fb..dbea392701 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -41,7 +41,6 @@ import { } from '../../contexts/audioPlayerContext/AudioPlayerContext'; import { ChannelContextValue, ChannelProvider } from '../../contexts/channelContext/ChannelContext'; -import { useChannelState } from '../../contexts/channelsStateContext/useChannelState'; import { ChatContextValue, useChatContext } from '../../contexts/chatContext/ChatContext'; import { useComponentsContext } from '../../contexts/componentsContext/ComponentsContext'; import { MessageComposerProvider } from '../../contexts/messageComposerContext/MessageComposerContext'; @@ -1212,8 +1211,6 @@ export const Channel = (props: PropsWithChildren) => { const shouldSyncChannel = threadMessage?.id ? !!props.threadList : true; - useChannelState(props.channel); - const channelWithContext = ( = ChannelContext as React.Context, ) => render( - - - )}> - {props.children} - - - - , + + )}> + {props.children} + + + , ); describe('Channel', () => { diff --git a/package/src/components/ChannelList/ChannelList.tsx b/package/src/components/ChannelList/ChannelList.tsx index b27a837688..4899e99be7 100644 --- a/package/src/components/ChannelList/ChannelList.tsx +++ b/package/src/components/ChannelList/ChannelList.tsx @@ -110,7 +110,8 @@ export const ChannelList = (props: ChannelListProps) => { const [forceUpdate] = useState(0); const fallbackNotificationHostIdRef = useLazyRef(() => `channel-list:${generateRandomId()}`); const notificationHostId = notificationHostIdProp ?? fallbackNotificationHostIdRef.current; - const { channelManager, enableOfflineSupport } = useChatContext(); + const { client } = useChatContext(); + const channelManager = client.channelManager; const { NotificationList } = useComponentsContext(); // Ref-counted on the shared manager: subscriptions live only while at least one ChannelList is mounted. @@ -128,8 +129,6 @@ export const ChannelList = (props: ChannelListProps) => { refreshList, reloadList, } = usePaginatedChannels({ - channelManager, - enableOfflineSupport, filters, lockChannelOrder, options, diff --git a/package/src/components/ChannelList/hooks/usePaginatedChannels.ts b/package/src/components/ChannelList/hooks/usePaginatedChannels.ts index f270d8ee5d..9f9b470dcc 100644 --- a/package/src/components/ChannelList/hooks/usePaginatedChannels.ts +++ b/package/src/components/ChannelList/hooks/usePaginatedChannels.ts @@ -3,7 +3,6 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { Channel, ChannelFilters, - ChannelManager, ChannelOptions, ChannelPaginator, ChannelPaginatorState, @@ -12,7 +11,6 @@ import { PaginatorOptions, } from 'stream-chat'; -import { useActiveChannelsRefContext } from '../../../contexts/activeChannelsRefContext/ActiveChannelsRefContext'; import { useChatContext } from '../../../contexts/chatContext/ChatContext'; import { useStateStore } from '../../../hooks'; import { useLazyRef } from '../../../hooks/useLazyRef'; @@ -31,8 +29,6 @@ export type ChannelListQueryChannelsOverride = PaginatorOptions< >['doRequest']; type Parameters = { - channelManager: ChannelManager; - enableOfflineSupport: boolean; filters: ChannelFilters; options: ChannelOptions; sort: ChannelSort; @@ -53,8 +49,6 @@ const selector = (nextValue: ChannelPaginatorState) => }) as const; export const usePaginatedChannels = ({ - channelManager, - enableOfflineSupport, filters = {}, lockChannelOrder = false, options = {}, @@ -62,8 +56,8 @@ export const usePaginatedChannels = ({ sort = [], }: Parameters) => { const [activeQueryType, setActiveQueryType] = useState(null); - const activeChannels = useActiveChannelsRefContext(); const { client } = useChatContext(); + const channelManager = client.channelManager; /** * One `ChannelPaginator` per `` instance, contributed to the shared `ChannelManager`. @@ -80,7 +74,7 @@ export const usePaginatedChannels = ({ const { limit, offset: _offset, ...requestOptions } = options; return new ChannelPaginator({ channelStateOptions: { - skipInitialization: enableOfflineSupport ? undefined : activeChannels.current, + skipInitialization: undefined, }, client, filters, diff --git a/package/src/components/Chat/Chat.tsx b/package/src/components/Chat/Chat.tsx index 7d513c6d68..803d6395f3 100644 --- a/package/src/components/Chat/Chat.tsx +++ b/package/src/components/Chat/Chat.tsx @@ -8,7 +8,6 @@ import { useAppSettings } from './hooks/useAppSettings'; import { useCreateChatContext } from './hooks/useCreateChatContext'; import { useIsOnline } from './hooks/useIsOnline'; -import { ChannelsStateProvider } from '../../contexts/channelsStateContext/ChannelsStateContext'; import { ChatContextValue, ChatProvider } from '../../contexts/chatContext/ChatContext'; import { useComponentsContext } from '../../contexts/componentsContext/ComponentsContext'; import { useDebugContext } from '../../contexts/debugContext/DebugContext'; @@ -32,7 +31,7 @@ import { version } from '../../version.json'; init(); export type ChatProps = Pick & - Partial> & { + Partial> & { /** * When false, ws connection won't be disconnection upon backgrounding the app. * To receive push notifications, its necessary that user doesn't have active @@ -145,7 +144,6 @@ const selector = (nextValue: OfflineDBState) => const ChatWithContext = (props: PropsWithChildren) => { const { - channelManager: customChannelManager, children, client, closeConnectionOnBackground = true, @@ -170,21 +168,6 @@ const ChatWithContext = (props: PropsWithChildren) => { [client.user?.language, translators], ); - /** - * The shared channel-list orchestrator. Created once per client (or supplied by the integrator via - * the `channelManager` prop). It holds no paginators here — each `` contributes and - * removes its own `ChannelPaginator`, and registers/unregisters the manager's WS subscriptions while - * it is mounted. Kept as static as possible so the paginators/subscriptions are not torn down on - * unrelated Chat re-renders. - */ - // TODO: This will do for now for the purposes of going ahead, but let's think of a better way to - // attach the manager to our client and not just propagate it through context. It is not - // necessary at all. - const channelManager = useMemo( - () => customChannelManager ?? client.channelManager, - [client, customChannelManager], - ); - /** * Setup connection event listeners */ @@ -287,7 +270,6 @@ const ChatWithContext = (props: PropsWithChildren) => { const chatContext = useCreateChatContext({ appSettings, channel, - channelManager, client, connectionRecovering, enableOfflineSupport, @@ -305,9 +287,7 @@ const ChatWithContext = (props: PropsWithChildren) => { return ( - - {children} - + {children} ); diff --git a/package/src/components/Chat/hooks/useCreateChatContext.ts b/package/src/components/Chat/hooks/useCreateChatContext.ts index 120b8d84a9..1a74a10e58 100644 --- a/package/src/components/Chat/hooks/useCreateChatContext.ts +++ b/package/src/components/Chat/hooks/useCreateChatContext.ts @@ -5,7 +5,6 @@ import type { ChatContextValue } from '../../../contexts/chatContext/ChatContext export const useCreateChatContext = ({ appSettings, channel, - channelManager, client, connectionRecovering, enableOfflineSupport, @@ -26,7 +25,6 @@ export const useCreateChatContext = ({ () => ({ appSettings, channel, - channelManager, client, connectionRecovering, enableOfflineSupport, @@ -36,15 +34,7 @@ export const useCreateChatContext = ({ setActiveChannel, }), // eslint-disable-next-line react-hooks/exhaustive-deps - [ - appSettings, - channelId, - channelManager, - clientValues, - connectionRecovering, - isOnline, - mutedUsersLength, - ], + [appSettings, channelId, clientValues, connectionRecovering, isOnline, mutedUsersLength], ); return chatContext; diff --git a/package/src/components/Message/MessageItemView/__tests__/MessageContent.test.tsx b/package/src/components/Message/MessageItemView/__tests__/MessageContent.test.tsx index 4bc4e1c150..2e5fffc676 100644 --- a/package/src/components/Message/MessageItemView/__tests__/MessageContent.test.tsx +++ b/package/src/components/Message/MessageItemView/__tests__/MessageContent.test.tsx @@ -4,7 +4,6 @@ import { StyleSheet, View } from 'react-native'; import { cleanup, render, screen, waitFor } from '@testing-library/react-native'; import type { Channel as ChannelType, StreamChat } from 'stream-chat'; -import { ChannelsStateProvider } from '../../../../contexts/channelsStateContext/ChannelsStateContext'; import { WithComponents } from '../../../../contexts/componentsContext/ComponentsContext'; import { getOrCreateChannelApi } from '../../../../mock-builders/api/getOrCreateChannel'; @@ -124,15 +123,13 @@ describe('MessageContent', () => { ); render( - - - - - - - - - , + + + + + + + , ); await waitFor(() => { @@ -150,15 +147,13 @@ describe('MessageContent', () => { ); render( - - - - - - - - - , + + + + + + + , ); await waitFor(() => { @@ -172,20 +167,18 @@ describe('MessageContent', () => { const message = generateMessage({ user }); render( - - - , - MessageContentTopView: () => , - }} - > - - - - - - , + + , + MessageContentTopView: () => , + }} + > + + + + + , ); await waitFor(() => { @@ -200,20 +193,18 @@ describe('MessageContent', () => { const message = generateMessage({ user }); render( - - - , - MessageContentTrailingView: () => , - }} - > - - - - - - , + + , + MessageContentTrailingView: () => , + }} + > + + + + + , ); await waitFor(() => { @@ -229,20 +220,18 @@ describe('MessageContent', () => { const rightAlignedMessage = generateMessage({ user }); const { rerender } = render( - - - , - MessageContentTrailingView: () => , - }} - > - - - - - - , + + , + MessageContentTrailingView: () => , + }} + > + + + + + , ); await waitFor(() => { @@ -254,20 +243,18 @@ describe('MessageContent', () => { expect(contentRowStyle?.flexDirection).toBe('row'); rerender( - - - , - MessageContentTrailingView: () => , - }} - > - - - - - - , + + , + MessageContentTrailingView: () => , + }} + > + + + + + , ); await waitFor(() => { @@ -572,13 +559,11 @@ describe('MessageContent', () => { }); render( - - - - - - - , + + + + + , ); await waitFor(() => { diff --git a/package/src/components/Message/MessageItemView/__tests__/MessageItemView.test.tsx b/package/src/components/Message/MessageItemView/__tests__/MessageItemView.test.tsx index 175d993b0c..2f0d535b0d 100644 --- a/package/src/components/Message/MessageItemView/__tests__/MessageItemView.test.tsx +++ b/package/src/components/Message/MessageItemView/__tests__/MessageItemView.test.tsx @@ -6,7 +6,6 @@ import { GestureDetector } from 'react-native-gesture-handler'; import { cleanup, render, screen, waitFor } from '@testing-library/react-native'; import type { Channel as ChannelType, StreamChat } from 'stream-chat'; -import { ChannelsStateProvider } from '../../../../contexts/channelsStateContext/ChannelsStateContext'; import type { ComponentOverrides } from '../../../../contexts/componentsContext/ComponentsContext'; import { WithComponents } from '../../../../contexts/componentsContext/ComponentsContext'; import { useMessageContext } from '../../../../contexts/messageContext/MessageContext'; @@ -52,21 +51,19 @@ describe('MessageItemView', () => { renderMessage = (options, channelProps, componentOverrides) => render( - - - {componentOverrides ? ( - - - - - - ) : ( + + {componentOverrides ? ( + - )} - - , + + ) : ( + + + + )} + , ); }); diff --git a/package/src/components/Message/MessageItemView/__tests__/MessageStatus.test.tsx b/package/src/components/Message/MessageItemView/__tests__/MessageStatus.test.tsx index acf705c17f..e8da181eb3 100644 --- a/package/src/components/Message/MessageItemView/__tests__/MessageStatus.test.tsx +++ b/package/src/components/Message/MessageItemView/__tests__/MessageStatus.test.tsx @@ -4,7 +4,6 @@ import { cleanup, render, waitFor } from '@testing-library/react-native'; import type { Channel as ChannelType, StreamChat } from 'stream-chat'; import { Channel } from '../../..'; -import { ChannelsStateProvider } from '../../../../contexts/channelsStateContext/ChannelsStateContext'; import { OverlayProvider } from '../../../../contexts/overlayContext/OverlayProvider'; import { getOrCreateChannelApi } from '../../../../mock-builders/api/getOrCreateChannel'; import { useMockedApis } from '../../../../mock-builders/api/useMockedApis'; @@ -56,13 +55,11 @@ describe('MessageStatus', () => { ) => render( - - - - - - - + + + + + , ); @@ -90,13 +87,11 @@ describe('MessageStatus', () => { const staticMessage = generateMessage({ user: staticUser }); rerender( - - - - - - - , + + + + + , ); await waitFor(() => { diff --git a/package/src/components/Message/MessageItemView/__tests__/ReactionListBottom.test.tsx b/package/src/components/Message/MessageItemView/__tests__/ReactionListBottom.test.tsx index 12fbf25cdd..c703125bc1 100644 --- a/package/src/components/Message/MessageItemView/__tests__/ReactionListBottom.test.tsx +++ b/package/src/components/Message/MessageItemView/__tests__/ReactionListBottom.test.tsx @@ -3,8 +3,6 @@ import React from 'react'; import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react-native'; import type { Channel as ChannelType, StreamChat } from 'stream-chat'; -import { ChannelsStateProvider } from '../../../../contexts/channelsStateContext/ChannelsStateContext'; - import { getOrCreateChannelApi } from '../../../../mock-builders/api/getOrCreateChannel'; import { useMockedApis } from '../../../../mock-builders/api/useMockedApis'; import { generateChannelResponse } from '../../../../mock-builders/generator/channel'; @@ -42,13 +40,11 @@ describe('ReactionListBottom', () => { renderMessage = (options, channelProps) => render( - - - - - - - , + + + + + , ); }); diff --git a/package/src/components/Message/MessageItemView/__tests__/ReactionListTop.test.tsx b/package/src/components/Message/MessageItemView/__tests__/ReactionListTop.test.tsx index 344e2489e7..e2ef72474b 100644 --- a/package/src/components/Message/MessageItemView/__tests__/ReactionListTop.test.tsx +++ b/package/src/components/Message/MessageItemView/__tests__/ReactionListTop.test.tsx @@ -3,8 +3,6 @@ import React from 'react'; import { cleanup, render, screen, waitFor } from '@testing-library/react-native'; import type { Channel as ChannelType, StreamChat } from 'stream-chat'; -import { ChannelsStateProvider } from '../../../../contexts/channelsStateContext/ChannelsStateContext'; - import { getOrCreateChannelApi } from '../../../../mock-builders/api/getOrCreateChannel'; import { useMockedApis } from '../../../../mock-builders/api/useMockedApis'; import { generateChannelResponse } from '../../../../mock-builders/generator/channel'; @@ -42,13 +40,11 @@ describe('ReactionListTop', () => { renderMessage = (options, channelProps) => render( - - - - - - - , + + + + + , ); }); diff --git a/package/src/components/Thread/__tests__/Thread.test.tsx b/package/src/components/Thread/__tests__/Thread.test.tsx index 4d7a13d4ec..c4df50e3ec 100644 --- a/package/src/components/Thread/__tests__/Thread.test.tsx +++ b/package/src/components/Thread/__tests__/Thread.test.tsx @@ -12,7 +12,6 @@ import { Thread as ThreadClass } from 'stream-chat'; import { v5 as uuidv5 } from 'uuid'; import { AttachmentPickerProvider } from '../../../contexts/attachmentPickerContext/AttachmentPickerContext'; -import { ChannelsStateProvider } from '../../../contexts/channelsStateContext/ChannelsStateContext'; import { ImageGalleryProvider } from '../../../contexts/imageGalleryContext/ImageGalleryContext'; import { OverlayProvider } from '../../../contexts/overlayContext/OverlayProvider'; import { getOrCreateChannelApi } from '../../../mock-builders/api/getOrCreateChannel'; @@ -154,26 +153,24 @@ describe('Thread', () => { ); const { getByText, toJSON } = render( - - - ['value'] - } + + ['value'] + } + > + ['value']} > - ['value']} - > - - - - - - - , + + + + + + , ); await waitFor(() => { diff --git a/package/src/contexts/__tests__/index.test.tsx b/package/src/contexts/__tests__/index.test.tsx index 09d14f3b2c..5c8ed5574c 100644 --- a/package/src/contexts/__tests__/index.test.tsx +++ b/package/src/contexts/__tests__/index.test.tsx @@ -16,7 +16,6 @@ import { useTheme, useThreadContext, } from '../'; -import { useChannelsStateContext } from '../channelsStateContext/ChannelsStateContext'; jest.mock('../utils/isTestEnvironment', () => ({ isTestEnvironment: jest.fn(() => false) })); jest.spyOn(console, 'error').mockImplementation(); @@ -31,10 +30,6 @@ describe('contexts hooks in a component throws an error with message when not wr useOverlayContext, 'The useOverlayContext hook was called outside the OverlayContext Provider. Make sure you have configured OverlayProvider component correctly - https://getstream.io/chat/docs/sdk/reactnative/basics/hello_stream_chat/#overlay-provider', ], - [ - useChannelsStateContext, - 'The useChannelsStateContext hook was called outside the ChannelStateContext Provider. Make sure you have configured OverlayProvider component correctly - https://getstream.io/chat/docs/sdk/reactnative/basics/hello_stream_chat/#overlay-provider', - ], [ useOwnCapabilitiesContext, 'The useOwnCapabilitiesContext hook was called outside the Channel Component. Make sure you have configured Channel component correctly - https://getstream.io/chat/docs/sdk/reactnative/basics/hello_stream_chat/#channel', diff --git a/package/src/contexts/activeChannelsRefContext/ActiveChannelsRefContext.tsx b/package/src/contexts/activeChannelsRefContext/ActiveChannelsRefContext.tsx deleted file mode 100644 index a2de73dc96..0000000000 --- a/package/src/contexts/activeChannelsRefContext/ActiveChannelsRefContext.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import React, { PropsWithChildren, useContext } from 'react'; - -type ActiveChannels = React.MutableRefObject; - -const ActiveChannelsContext = React.createContext({ current: [] } as ActiveChannels); - -export const ActiveChannelsProvider = ({ - children, - value, -}: PropsWithChildren<{ - value: ActiveChannels; -}>) => {children}; - -export const useActiveChannelsRefContext = () => - useContext(ActiveChannelsContext) as unknown as ActiveChannels; diff --git a/package/src/contexts/channelsStateContext/ChannelsStateContext.tsx b/package/src/contexts/channelsStateContext/ChannelsStateContext.tsx deleted file mode 100644 index 772bb7549d..0000000000 --- a/package/src/contexts/channelsStateContext/ChannelsStateContext.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import React, { - ReactNode, - useCallback, - useContext, - useEffect, - useMemo, - useReducer, - useRef, -} from 'react'; - -import { ActiveChannelsProvider } from '../activeChannelsRefContext/ActiveChannelsRefContext'; - -import { DEFAULT_BASE_CONTEXT_VALUE } from '../utils/defaultBaseContextValue'; - -import { isTestEnvironment } from '../utils/isTestEnvironment'; - -// Per-channel state is no longer stored here — message/thread state lives in the LLC paginators. -// This context now only tracks which channels are mounted ("active"), which the ChannelList uses -// to skip re-initializing their state on a reconnect query (see useChannelState / usePaginatedChannels). -export type ChannelState = { - active: boolean; -}; - -type ChannelsState = { - [cid: string]: ChannelState; -}; - -export type Keys = keyof ChannelState; - -export type Payload = { - cid: string; - key: Key; - value: ChannelState[Key]; -}; - -type SetStateAction = { - payload: Payload; - type: 'SET_STATE'; -}; - -type Action = SetStateAction; - -export type ChannelsStateContextValue = { - setState: (value: Payload) => void; - state: ChannelsState; -}; - -type Reducer = (state: ChannelsState, action: Action) => ChannelsState; - -function reducer(state: ChannelsState, action: Action) { - switch (action.type) { - case 'SET_STATE': - return { - ...state, - [action.payload.cid]: { - ...(state[action.payload.cid] || {}), - [action.payload.key]: action.payload.value, - }, - }; - - default: - throw new Error(); - } -} - -const ChannelsStateContext = React.createContext( - DEFAULT_BASE_CONTEXT_VALUE as ChannelsStateContextValue, -); - -export const ChannelsStateProvider = ({ children }: { children: ReactNode }) => { - const [state, dispatch] = useReducer(reducer as unknown as Reducer, {}); - - const setState = useCallback((payload: Payload) => { - dispatch({ payload, type: 'SET_STATE' }); - }, []); - - const value = useMemo( - () => ({ - setState, - state, - }), - // eslint-disable-next-line react-hooks/exhaustive-deps - [state], - ); - - const activeChannelsRef = useRef(Object.keys(state)); - - useEffect(() => { - activeChannelsRef.current = Object.keys(state); - }, [state]); - - return ( - - {children} - - ); -}; - -export const useChannelsStateContext = () => { - const contextValue = useContext(ChannelsStateContext) as unknown as ChannelsStateContextValue; - - if (contextValue === DEFAULT_BASE_CONTEXT_VALUE && !isTestEnvironment()) { - throw new Error( - 'The useChannelsStateContext hook was called outside the ChannelStateContext Provider. Make sure you have configured OverlayProvider component correctly - https://getstream.io/chat/docs/sdk/reactnative/basics/hello_stream_chat/#overlay-provider', - ); - } - - return contextValue; -}; diff --git a/package/src/contexts/channelsStateContext/useChannelState.ts b/package/src/contexts/channelsStateContext/useChannelState.ts deleted file mode 100644 index 21f8c953ca..0000000000 --- a/package/src/contexts/channelsStateContext/useChannelState.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { useEffect } from 'react'; - -import type { Channel as ChannelType } from 'stream-chat'; - -import { useChannelsStateContext } from './ChannelsStateContext'; - -/** - * Registers the channel as "active" in the ChannelsStateContext while it is mounted, so the - * ChannelList's `queryChannels` call can skip re-initializing (and thereby clearing) the state - * of channels that are currently open — preventing a reconnect race between ChannelList and - * Channel/Thread. Message and thread-reply state itself now lives in the LLC paginators - * (`channel.messagePaginator` / `thread.messagePaginator`), not in this context. - */ -export function useChannelState(channel: ChannelType | undefined): void { - const cid = channel?.id || 'id'; // in case channel is not initialized, use generic id string for indexing - const { setState } = useChannelsStateContext(); - - useEffect(() => { - setState({ cid, key: 'active', value: true }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [cid]); -} diff --git a/package/src/contexts/chatContext/ChatContext.tsx b/package/src/contexts/chatContext/ChatContext.tsx index 0af2d31a60..291c9fad61 100644 --- a/package/src/contexts/chatContext/ChatContext.tsx +++ b/package/src/contexts/chatContext/ChatContext.tsx @@ -1,12 +1,6 @@ import React, { PropsWithChildren, useContext } from 'react'; -import type { - Channel, - ChannelManager, - GetApplicationResponse, - StreamChat, - UserMuteResponse, -} from 'stream-chat'; +import type { Channel, GetApplicationResponse, StreamChat, UserMuteResponse } from 'stream-chat'; import { MessageContextValue } from '../messageContext/MessageContext'; import { DEFAULT_BASE_CONTEXT_VALUE } from '../utils/defaultBaseContextValue'; @@ -18,15 +12,6 @@ export type ChatContextValue = { * Object of application settings returned from Stream. * */ appSettings: GetApplicationResponse | null; - /** - * The shared `ChannelManager` instance that orchestrates the channel-list paginators and keeps - * them in sync with WS events. It is created by `` (or supplied via the `channelManager` - * prop) and consumed by ``. Exposed here so it can be inspected/driven directly for - * advanced / multi-list use cases. - * - * @overrideType ChannelManager - */ - channelManager: ChannelManager; /** * The StreamChat client object * From 520da3b352ee5f9a8b0b9aad0c3698423b150a17 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Mon, 10 Aug 2026 13:23:32 +0200 Subject: [PATCH 7/7] fix: update migration guide --- ai-docs/ai-migration-v9-to-v10.md | 126 +++++++++++++++++++++++++++++- 1 file changed, 125 insertions(+), 1 deletion(-) diff --git a/ai-docs/ai-migration-v9-to-v10.md b/ai-docs/ai-migration-v9-to-v10.md index 0308b07c40..85d53d9067 100644 --- a/ai-docs/ai-migration-v9-to-v10.md +++ b/ai-docs/ai-migration-v9-to-v10.md @@ -86,6 +86,11 @@ rg '\b(useMutedUsers|useCreateChannelContext|useCreateMessagesContext|useCreateT # §16 — behavioral (no symbol; review if you rely on send/mark-read/page-size behavior) rg '\b(sendMessage|SendMessageDisallowedIndicator)\b' src/ + +# §18 — ChannelList event-override props + channelManager (orchestrator) +rg '\b(onAddedToChannel|onRemovedFromChannel|onChannelDeleted|onChannelHidden|onChannelVisible|onChannelUpdated|onChannelTruncated|onChannelMemberUpdated|onNewMessage|onNewMessageNotification|ChannelListEventHandler|useChannelUpdated|queryChannelsOverride)\b' src/ +rg 'useChatContext\(\)' -A6 src/ | rg '\bchannelManager\b' +rg '` event callbacks | `client.channelManager.addEventHandler(...)` / `client.on(...)`; membership+order via `filters`/`sort` | §18 | +| `queryChannelsOverride` returning `Channel[]` | now the paginator `doRequest` → `return { items }` | §18.1 | +| `useChannelUpdated()` | removed — `channel.updated` is handled by the orchestrator | §18.2 | +| `loadNextPage(filters, sort, options)` | `loadNextPage()` (no args) | §18.3 | +| `` / `useChatContext().channelManager` | `client.channelManager` | §18.4 | --- @@ -759,7 +769,116 @@ Highlights that hit integrator code: --- -## 18. Verify +# Part J — `ChannelList` & `ChannelManager` (orchestrator) + +`` now runs on `stream-chat` v10's client-owned orchestrator: `client.channelManager` +(one instance per client) plus one `ChannelPaginator` per list. The list is a **deterministic +projection of its `filters` + `sort`** — every query (initial load, pagination, pull-to-refresh, +reconnect) re-asserts them, so the list can no longer silently drift from, or be forced to +contradict, its own query. Most of the removed API below existed to make the list disagree with its +query; that is intentionally gone (a footgun removed). What you actually need is served by `filters`, +`sort`, and — for transient surfacing — the paginator's `boost` primitive. + +## 18. `` per-event override props removed + +Removed props (and the exported `ChannelListEventHandler` type): +`onAddedToChannel`, `onRemovedFromChannel`, `onChannelDeleted`, `onChannelHidden`, +`onChannelVisible`, `onChannelUpdated`, `onChannelTruncated`, `onChannelMemberUpdated`, +`onNewMessage`, `onNewMessageNotification`. + +In v9 these callbacks let you imperatively rewrite the list on each WS event. In v10 that is +redundant: the list is a projection of `filters` + `sort` that re-asserts on every query, so a +membership/order change you made by hand was either wiped by the next refresh/reconnect or is already +expressible declaratively. Map what each override did to its v10 equivalent: + +| The override did… | v10 | +|---|---| +| decide membership (keep/drop a channel) | `filters` — the query is the source of truth | +| decide order | `sort` (+ `lockChannelOrder` to freeze order across events) | +| filter what renders without changing the query | `channelRenderFilterFn` (unchanged prop) | +| briefly float a channel to the top | `paginator.boost(cid, { ttlMs })` (survives queries) | +| genuinely global event handling (new event type, SDK-bug hotfix, side effect) | `client.channelManager.addEventHandler(...)` or `client.on(...)` | + +Global handler (replaces the old per-list callbacks): + +```tsx +const unsubscribe = client.channelManager.addEventHandler({ + eventType: 'message.new', + id: 'my-app:on-new-message', + handle: ({ event, ctx: { channelManager } }) => { + // side effects, or drive the list via channelManager / its paginators + }, +}); +// call unsubscribe() on cleanup +``` + +> Why they're gone as per-list props: the manager is a single client-owned instance shared by every +> list, so per-list handlers pooled into it (last-writer-wins) and unmounting one `` +> restored the default — clobbering a still-mounted sibling. Global handlers belong on +> `client.channelManager`; per-list shaping stays in each list's `filters` / `sort`. + +## 18.1 `queryChannelsOverride` retyped + +Still a prop, but it is now the paginator's `doRequest` rather than a `Channel[]`-returning function. +It receives the query params the paginator would have sent and must return `{ items }` — call +`client.queryChannels(...)` inside so client state stays in sync: + +```tsx +// v9: queryChannelsOverride = (filters, sort, options) => Promise +// v10: +queryChannelsOverride={async (queryParams) => { + const items = await client.queryChannels(queryParams); + return { items }; +}} +``` + +## 18.2 `useChannelUpdated` removed + +The public `useChannelUpdated` hook is gone (it patched a `useState`-backed channel array that no +longer exists). `channel.updated` is handled by the orchestrator; there is nothing to wire — delete +the usage. + +## 18.3 `ChannelsContextValue.loadNextPage` retyped + +`loadNextPage` is now `() => Promise` (it dropped its optional query-type arguments): + +```tsx +// Before: loadNextPage(filters, sort, options) +// After: +loadNextPage(); +``` + +## 18.4 `` prop + `ChatContext.channelManager` removed + +`ChannelManager` is a singleton per client, so `` no longer accepts a `channelManager` prop and +`useChatContext()` no longer returns one. Read `client.channelManager` directly: + +```tsx +// Before +const { channelManager } = useChatContext(); +// After +const { client } = useChatContext(); +const channelManager = client.channelManager; +``` + +Configure the shared manager through its own API (`client.channelManager.setEventHandlers(...)`, +`setOwnershipResolver(...)`) instead of the removed prop. + +## 18.5 Behavioral: channel-list ordering & watch defaults + +- **No default "float to top" on events.** The manager no longer boosts a channel on any event — + order is driven purely by `sort`. A new/edited message, an added-to-channel, or an unhidden channel + now relocates by its sort key (e.g. `last_message_at`) instead of jumping to the very top. This also + means a new message no longer overrides a pinned-first `sort` (pinned channels stay pinned). To keep + the old jump-to-top, boost it yourself: `paginator.boost(channel.cid)`. +- **Watch-on-notification narrowed.** On `notification.*` events (e.g. added to a channel) v10 watches + only channels it does not already know; v9 re-watched unconditionally. Deliberate change — the SDK's + own reconnect/query flow re-establishes watches, and blanket auto-watch risks the watch limit. To + watch a specific channel, call `channel.watch()`. + +--- + +## 19. Verify - Typecheck the customer app; removed symbols surface as "Property does not exist" / "Cannot find name" errors — fix each per the section it maps to. @@ -770,3 +889,8 @@ Highlights that hit integrator code: tap) and jump-to-first-unread; the unread separator, scroll-to-bottom button, and unread notification; and any custom overrides of `Message`, the list loading indicators, the thread footer, or the typing indicator. +- Exercise the **channel list**: initial load + skeleton; scroll pagination; + new-message reorder; add/remove-from-channel, hide/unhide, delete, truncate, + `channel.updated`; pull-to-refresh and reconnect (the list re-queries, no + blank); and confirm a pinned-first `sort` keeps pinned channels on top when + other channels receive messages.