From 004cfdc68c910b400337c16f1fa5b46d5d34ca48 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Thu, 14 May 2026 18:00:46 +0200 Subject: [PATCH 1/3] fix: local channel sorting with predefined filters --- src/channel_manager.ts | 96 +++++++++-- src/client.ts | 72 ++++++-- src/types.ts | 1 + test/unit/channel_manager.test.ts | 277 ++++++++++++++++++++++++++++++ 4 files changed, 423 insertions(+), 23 deletions(-) diff --git a/src/channel_manager.ts b/src/channel_manager.ts index 7e0322922..0ac87605d 100644 --- a/src/channel_manager.ts +++ b/src/channel_manager.ts @@ -1,10 +1,11 @@ -import type { StreamChat } from './client'; +import type { QueryChannelsResponseWithChannels, StreamChat } from './client'; import type { ChannelFilters, ChannelOptions, ChannelSort, ChannelStateOptions, Event, + QueryChannelsAPIResponse, } from './types'; import type { ValueOrPatch } from './store'; import { isPatch, StateStore } from './store'; @@ -33,6 +34,8 @@ export type ChannelManagerPagination = { hasNext: boolean; isLoading: boolean; isLoadingNext: boolean; + mutationFilters?: ChannelFilters; + mutationSort?: ChannelSort; options: ChannelOptions; sort: ChannelSort; }; @@ -133,9 +136,14 @@ export type ChannelManagerOptions = { lockChannelOrder?: boolean; }; +export type QueryChannelsRequestOutput = Channel[] | QueryChannelsResponseWithChannels; + export type QueryChannelsRequestType = ( - ...params: Parameters -) => Promise; + filters: ChannelFilters, + sort?: ChannelSort, + options?: ChannelOptions, + stateOptions?: ChannelStateOptions, +) => Promise; export const DEFAULT_CHANNEL_MANAGER_OPTIONS = { abortInFlightQuery: false, @@ -152,6 +160,54 @@ export const DEFAULT_CHANNEL_MANAGER_PAGINATION_OPTIONS = { offset: 0, }; +const mapPredefinedFilterSortToChannelSort = ( + sort: NonNullable['sort'], +): ChannelSort => + (sort ?? []).map(({ direction = 1, field }) => ({ + [field]: direction, + })) as ChannelSort; + +const getMutationPaginationParams = ({ + queryChannelsResponse, + sort, +}: { + queryChannelsResponse?: Pick; + sort: ChannelSort; +}): Pick => { + const predefinedFilter = queryChannelsResponse?.predefined_filter; + + if (!predefinedFilter) { + return {}; + } + + return { + mutationFilters: predefinedFilter.filter as ChannelFilters, + mutationSort: + predefinedFilter.sort !== undefined + ? mapPredefinedFilterSortToChannelSort(predefinedFilter.sort) + : sort, + }; +}; + +const getMutationFiltersAndSort = ( + pagination: ChannelManagerPagination, +): Pick => ({ + filters: pagination.mutationFilters ?? pagination.filters, + sort: pagination.mutationSort ?? pagination.sort, +}); + +const omitMutationPaginationParams = (pagination: ChannelManagerPagination) => { + const paginationWithoutMutationParams = { ...pagination }; + delete paginationWithoutMutationParams.mutationFilters; + delete paginationWithoutMutationParams.mutationSort; + + return paginationWithoutMutationParams; +}; + +const isQueryChannelsResponseWithChannels = ( + response: QueryChannelsRequestOutput, +): response is QueryChannelsResponseWithChannels => !Array.isArray(response); + /** * A class that manages a list of channels and changes it based on configuration and WS events. The * list of channels is reactive as well as the pagination and it can be subscribed to for state updates. @@ -279,23 +335,34 @@ export class ChannelManager extends WithSubscriptions { ...options, }; try { - const channels = await this.queryChannelsRequest( + const queryChannelsResponse = await this.queryChannelsRequest( filters, sort, options, - stateOptions, + { ...stateOptions, withResponse: true }, ); + const channels = isQueryChannelsResponseWithChannels(queryChannelsResponse) + ? queryChannelsResponse.channels + : queryChannelsResponse; const newOffset = offset + (channels?.length ?? 0); const newOptions = { ...options, offset: newOffset }; const { pagination } = this.state.getLatestValue(); + const mutationPaginationParams = getMutationPaginationParams({ + queryChannelsResponse: isQueryChannelsResponseWithChannels(queryChannelsResponse) + ? queryChannelsResponse + : undefined, + sort, + }); + const paginationWithoutMutationParams = omitMutationPaginationParams(pagination); this.state.partialNext({ channels, pagination: { - ...pagination, + ...paginationWithoutMutationParams, hasNext: (channels?.length ?? 0) >= (limit ?? 1), isLoading: false, options: newOptions, + ...mutationPaginationParams, }, initialized: true, error: undefined, @@ -368,7 +435,7 @@ export class ChannelManager extends WithSubscriptions { this.state.next((currentState) => ({ ...currentState, pagination: { - ...currentState.pagination, + ...omitMutationPaginationParams(currentState.pagination), isLoading: true, isLoadingNext: false, filters, @@ -434,12 +501,15 @@ export class ChannelManager extends WithSubscriptions { this.state.partialNext({ pagination: { ...pagination, isLoading: false, isLoadingNext: true }, }); - const nextChannels = await this.queryChannelsRequest( + const queryChannelsResponse = await this.queryChannelsRequest( filters, sort, options, this.stateOptions, ); + const nextChannels = isQueryChannelsResponseWithChannels(queryChannelsResponse) + ? queryChannelsResponse.channels + : queryChannelsResponse; const { channels } = this.state.getLatestValue(); const newOffset = offset + (nextChannels?.length ?? 0); const newOptions = { ...options, offset: newOffset }; @@ -498,7 +568,7 @@ export class ChannelManager extends WithSubscriptions { return; } - const { sort } = pagination ?? {}; + const { sort } = getMutationFiltersAndSort(pagination); this.setChannels( promoteChannel({ @@ -535,7 +605,7 @@ export class ChannelManager extends WithSubscriptions { if (!channels) { return; } - const { filters, sort } = pagination ?? {}; + const { filters, sort } = getMutationFiltersAndSort(pagination); const channelType = event.channel_type; const channelId = event.channel_id; @@ -594,7 +664,7 @@ export class ChannelManager extends WithSubscriptions { }); const { channels, pagination } = this.state.getLatestValue(); - const { filters, sort } = pagination ?? {}; + const { filters, sort } = getMutationFiltersAndSort(pagination); const considerArchivedChannels = shouldConsiderArchivedChannels(filters); const isTargetChannelArchived = isChannelArchived(channel); @@ -631,7 +701,7 @@ export class ChannelManager extends WithSubscriptions { }); const { channels, pagination } = this.state.getLatestValue(); - const { sort, filters } = pagination ?? {}; + const { filters, sort } = getMutationFiltersAndSort(pagination); const considerArchivedChannels = shouldConsiderArchivedChannels(filters); const isTargetChannelArchived = isChannelArchived(channel); @@ -658,7 +728,7 @@ export class ChannelManager extends WithSubscriptions { private memberUpdatedHandler = (event: Event) => { const { pagination, channels } = this.state.getLatestValue(); - const { filters, sort } = pagination; + const { filters, sort } = getMutationFiltersAndSort(pagination); if ( !event.member?.user || event.member.user.id !== this.client.userID || diff --git a/src/client.ts b/src/client.ts index 6d77bb3df..42f59fbaa 100644 --- a/src/client.ts +++ b/src/client.ts @@ -281,6 +281,13 @@ function isString(x: unknown): x is string { type MessageComposerTearDownFunction = () => void; +export type QueryChannelsResponseWithChannels = Omit< + QueryChannelsAPIResponse, + 'channels' +> & { + channels: Channel[]; +}; + type MessageComposerSetupFunction = ({ composer, }: { @@ -1888,20 +1895,20 @@ export class StreamChat { } /** - * queryChannelsRequest - Queries channels and returns the raw response + * queryChannelsRequestWithResponse - Queries channels and returns the full API response * * @param {ChannelFilters} filterConditions object MongoDB style filters. Can be empty object when using predefined_filter in options. * @param {ChannelSort} [sort] Sort options, for instance {created_at: -1}. * When using multiple fields, make sure you use array of objects to guarantee field order, for instance [{last_updated: -1}, {created_at: 1}] * @param {ChannelOptions} [options] Options object. Can include predefined_filter, filter_values, and sort_values for using predefined filters. * - * @return {Promise>} search channels response + * @return {Promise} full search channels response */ - async queryChannelsRequest( + async queryChannelsRequestWithResponse( filterConditions: ChannelFilters, sort: ChannelSort = [], options: ChannelOptions = {}, - ) { + ): Promise { const defaultOptions: ChannelOptions = { state: true, watch: true, @@ -1934,9 +1941,28 @@ export class StreamChat { ...restOptions, }; - const data = await this.post( - this.baseURL + '/channels', - payload, + return await this.post(this.baseURL + '/channels', payload); + } + + /** + * queryChannelsRequest - Queries channels and returns the raw channel response list. + * + * @param {ChannelFilters} filterConditions object MongoDB style filters. Can be empty object when using predefined_filter in options. + * @param {ChannelSort} [sort] Sort options, for instance {created_at: -1}. + * When using multiple fields, make sure you use array of objects to guarantee field order, for instance [{last_updated: -1}, {created_at: 1}] + * @param {ChannelOptions} [options] Options object. Can include predefined_filter, filter_values, and sort_values for using predefined filters. + * + * @return {Promise>} search channels response + */ + async queryChannelsRequest( + filterConditions: ChannelFilters, + sort: ChannelSort = [], + options: ChannelOptions = {}, + ) { + const data = await this.queryChannelsRequestWithResponse( + filterConditions, + sort, + options, ); // FIXME: In the next major release, return the full QueryChannelsAPIResponse @@ -1958,13 +1984,30 @@ export class StreamChat { * * @return {Promise>} search channels response */ + async queryChannels( + filterConditions: ChannelFilters, + sort: ChannelSort, + options: ChannelOptions, + stateOptions: ChannelStateOptions & { withResponse: true }, + ): Promise; + async queryChannels( + filterConditions?: ChannelFilters, + sort?: ChannelSort, + options?: ChannelOptions, + stateOptions?: ChannelStateOptions, + ): Promise; async queryChannels( filterConditions: ChannelFilters, sort: ChannelSort = [], options: ChannelOptions = {}, stateOptions: ChannelStateOptions = {}, - ) { - const channels = await this.queryChannelsRequest(filterConditions, sort, options); + ): Promise { + const queryChannelsResponse = await this.queryChannelsRequestWithResponse( + filterConditions, + sort, + options, + ); + const channels = queryChannelsResponse.channels; this.dispatchEvent({ type: 'channels.queried', @@ -1980,7 +2023,16 @@ export class StreamChat { }); } - return this.hydrateActiveChannels(channels, stateOptions, options); + const hydratedChannels = this.hydrateActiveChannels(channels, stateOptions, options); + + if (stateOptions.withResponse) { + return { + ...queryChannelsResponse, + channels: hydratedChannels, + }; + } + + return hydratedChannels; } /** diff --git a/src/types.ts b/src/types.ts index 9dbeb49b7..9305456ee 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1111,6 +1111,7 @@ export type ChannelStateOptions = { offlineMode?: boolean; skipInitialization?: string[]; skipHydration?: boolean; + withResponse?: boolean; }; export type CreateChannelOptions = { diff --git a/test/unit/channel_manager.test.ts b/test/unit/channel_manager.test.ts index 8b06479c5..9e4fee29c 100644 --- a/test/unit/channel_manager.test.ts +++ b/test/unit/channel_manager.test.ts @@ -10,6 +10,7 @@ import { channelManagerEventToHandlerMapping, DEFAULT_CHANNEL_MANAGER_PAGINATION_OPTIONS, QueryChannelsRequestType, + QueryChannelsAPIResponse, } from '../../src'; import { generateChannel } from './test-utils/generateChannel'; @@ -1477,6 +1478,41 @@ describe('ChannelManager', () => { (typeof utils)['findLastPinnedChannelIndex'] >; let extractSortValueStub: MockInstance<(typeof utils)['extractSortValue']>; + const setChannelMembership = ( + channelId: string, + membership: Record, + ) => { + const channel = client.channel('messaging', channelId); + channel.state.membership = { + user: { id: client.userID }, + user_id: client.userID, + ...membership, + } as never; + + return channel; + }; + const queryChannelsWithPredefinedFilterResponse = async ({ + filter, + sort, + }: { + filter: Record; + sort?: NonNullable['sort']; + }) => { + vi.spyOn(client, 'post').mockResolvedValueOnce({ + duration: '0.01s', + channels: channelsResponse, + predefined_filter: { + name: 'messaging_channels', + filter, + sort, + }, + } satisfies QueryChannelsAPIResponse); + + await channelManager.queryChannels({}, [], { + predefined_filter: 'messaging_channels', + }); + setChannelsStub.mockClear(); + }; beforeEach(() => { setChannelsStub = vi.spyOn(channelManager, 'setChannels'); @@ -1499,6 +1535,247 @@ describe('ChannelManager', () => { sinon.reset(); }); + describe('predefined filter mutation metadata', () => { + it('does not promote an archived channel into a resolved non-archived list on message.new', async () => { + await queryChannelsWithPredefinedFilterResponse({ + filter: { archived: false }, + }); + setChannelMembership('channel2', { + archived_at: '2024-01-15T10:30:00Z', + }); + + client.dispatchEvent({ + type: 'message.new', + channel_type: 'messaging', + channel_id: 'channel2', + }); + + expect(setChannelsStub).toHaveBeenCalledTimes(0); + }); + + it('does not promote an unarchived channel into a resolved archived list on message.new', async () => { + await queryChannelsWithPredefinedFilterResponse({ + filter: { archived: true }, + }); + + client.dispatchEvent({ + type: 'message.new', + channel_type: 'messaging', + channel_id: 'channel2', + }); + + expect(setChannelsStub).toHaveBeenCalledTimes(0); + }); + + it('does not move a pinned channel when resolved predefined sort considers pinned_at', async () => { + await queryChannelsWithPredefinedFilterResponse({ + filter: {}, + sort: [{ field: 'pinned_at', direction: -1 }], + }); + setChannelMembership('channel2', { + pinned_at: '2024-01-15T10:30:00Z', + }); + + client.dispatchEvent({ + type: 'message.new', + channel_type: 'messaging', + channel_id: 'channel2', + }); + + expect(setChannelsStub).toHaveBeenCalledTimes(0); + }); + + it('does not promote an archived channel into a resolved non-archived list on notification.message_new', async () => { + const clock = sinon.useFakeTimers(); + await queryChannelsWithPredefinedFilterResponse({ + filter: { archived: false }, + }); + const channel = setChannelMembership('channel4', { + archived_at: '2024-01-15T10:30:00Z', + }); + getAndWatchChannelStub.mockResolvedValueOnce(channel); + + client.dispatchEvent({ + type: 'notification.message_new', + channel: { type: 'messaging', id: 'channel4' } as unknown as ChannelResponse, + }); + + await clock.runAllAsync(); + clock.restore(); + + expect(setChannelsStub).toHaveBeenCalledTimes(0); + }); + + it('does not add an archived channel into a resolved non-archived list on channel.visible', async () => { + const clock = sinon.useFakeTimers(); + await queryChannelsWithPredefinedFilterResponse({ + filter: { archived: false }, + }); + const channel = setChannelMembership('channel4', { + archived_at: '2024-01-15T10:30:00Z', + }); + getAndWatchChannelStub.mockResolvedValueOnce(channel); + + client.dispatchEvent({ + type: 'channel.visible', + channel_id: 'channel4', + channel_type: 'messaging', + }); + + await clock.runAllAsync(); + clock.restore(); + + expect(setChannelsStub).toHaveBeenCalledTimes(0); + }); + + it('uses resolved predefined filter metadata when handling member.updated archive changes', async () => { + await queryChannelsWithPredefinedFilterResponse({ + filter: { archived: false }, + }); + setChannelMembership('channel2', { + archived_at: '2024-01-15T10:30:00Z', + }); + + client.dispatchEvent({ + type: 'member.updated', + channel_id: 'channel2', + channel_type: 'messaging', + member: { user: { id: client.userID } }, + }); + + expect(setChannelsStub).toHaveBeenCalledOnce(); + expect( + (setChannelsStub.mock.calls[0][0] as Channel[]).map((c) => c.id), + ).to.deep.equal(['channel1', 'channel3']); + }); + + it('uses resolved predefined sort metadata when handling member.updated pin changes', async () => { + await queryChannelsWithPredefinedFilterResponse({ + filter: {}, + sort: [{ field: 'pinned_at', direction: -1 }], + }); + setChannelMembership('channel1', { + pinned_at: '2024-01-15T10:30:00Z', + }); + setChannelMembership('channel3', { + pinned_at: '2024-01-15T10:30:00Z', + }); + + client.dispatchEvent({ + type: 'member.updated', + channel_id: 'channel3', + channel_type: 'messaging', + member: { user: { id: client.userID } }, + }); + + expect(setChannelsStub).toHaveBeenCalledOnce(); + expect( + (setChannelsStub.mock.calls[0][0] as Channel[]).map((c) => c.id), + ).to.deep.equal(['channel3', 'channel1', 'channel2']); + }); + + it('keeps non-predefined query mutation behavior based on caller filters and sort', async () => { + vi.spyOn(client, 'post').mockResolvedValueOnce({ + duration: '0.01s', + channels: channelsResponse, + } satisfies QueryChannelsAPIResponse); + await channelManager.queryChannels({ archived: false }, [], { limit: 10 }); + setChannelsStub.mockClear(); + setChannelMembership('channel2', { + archived_at: '2024-01-15T10:30:00Z', + }); + + client.dispatchEvent({ + type: 'message.new', + channel_type: 'messaging', + channel_id: 'channel2', + }); + + expect(setChannelsStub).toHaveBeenCalledTimes(0); + }); + + it('preserves resolved predefined mutation metadata after loading the next page', async () => { + vi.spyOn(client, 'post') + .mockResolvedValueOnce({ + duration: '0.01s', + channels: channelsResponse, + predefined_filter: { + name: 'messaging_channels', + filter: { archived: false }, + sort: [{ field: 'pinned_at', direction: -1 }], + }, + } satisfies QueryChannelsAPIResponse) + .mockResolvedValueOnce({ + duration: '0.01s', + channels: [ + generateChannel({ channel: { id: 'channel4' } }), + generateChannel({ channel: { id: 'channel5' } }), + ], + predefined_filter: { + name: 'messaging_channels', + filter: { archived: false }, + sort: [{ field: 'pinned_at', direction: -1 }], + }, + } satisfies QueryChannelsAPIResponse); + + await channelManager.queryChannels({}, [], { + predefined_filter: 'messaging_channels', + limit: 2, + offset: 0, + }); + await channelManager.loadNext(); + setChannelsStub.mockClear(); + setChannelMembership('channel2', { + archived_at: '2024-01-15T10:30:00Z', + }); + + client.dispatchEvent({ + type: 'message.new', + channel_type: 'messaging', + channel_id: 'channel2', + }); + + expect(setChannelsStub).toHaveBeenCalledTimes(0); + }); + + it('clears resolved predefined mutation metadata when switching to a non-predefined query', async () => { + vi.spyOn(client, 'post') + .mockResolvedValueOnce({ + duration: '0.01s', + channels: channelsResponse, + predefined_filter: { + name: 'messaging_channels', + filter: { archived: false }, + sort: [{ field: 'pinned_at', direction: -1 }], + }, + } satisfies QueryChannelsAPIResponse) + .mockResolvedValueOnce({ + duration: '0.01s', + channels: channelsResponse, + } satisfies QueryChannelsAPIResponse); + + await channelManager.queryChannels({}, [], { + predefined_filter: 'messaging_channels', + }); + await channelManager.queryChannels({}, [], { limit: 10 }); + setChannelsStub.mockClear(); + setChannelMembership('channel2', { + archived_at: '2024-01-15T10:30:00Z', + }); + + client.dispatchEvent({ + type: 'message.new', + channel_type: 'messaging', + channel_id: 'channel2', + }); + + expect(setChannelsStub).toHaveBeenCalledOnce(); + expect( + (setChannelsStub.mock.calls[0][0] as Channel[]).map((c) => c.id), + ).to.deep.equal(['channel2', 'channel1', 'channel3']); + }); + }); + describe('channelDeletedHandler, channelHiddenHandler and notificationRemovedFromChannelHandler', () => { let channelToRemove: ChannelResponse; From ae5aa3aab68ddb8920130a3226b67385255603da Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Thu, 14 May 2026 18:09:17 +0200 Subject: [PATCH 2/3] fix: use better name for mutation filters/sort --- src/channel_manager.ts | 48 +++++++++++++++---------------- test/unit/channel_manager.test.ts | 8 +++--- 2 files changed, 28 insertions(+), 28 deletions(-) diff --git a/src/channel_manager.ts b/src/channel_manager.ts index 0ac87605d..3dc7dd3ad 100644 --- a/src/channel_manager.ts +++ b/src/channel_manager.ts @@ -34,9 +34,9 @@ export type ChannelManagerPagination = { hasNext: boolean; isLoading: boolean; isLoadingNext: boolean; - mutationFilters?: ChannelFilters; - mutationSort?: ChannelSort; options: ChannelOptions; + responseFilters?: ChannelFilters; + responseSort?: ChannelSort; sort: ChannelSort; }; @@ -167,13 +167,13 @@ const mapPredefinedFilterSortToChannelSort = ( [field]: direction, })) as ChannelSort; -const getMutationPaginationParams = ({ +const getResponsePaginationParams = ({ queryChannelsResponse, sort, }: { queryChannelsResponse?: Pick; sort: ChannelSort; -}): Pick => { +}): Pick => { const predefinedFilter = queryChannelsResponse?.predefined_filter; if (!predefinedFilter) { @@ -181,27 +181,27 @@ const getMutationPaginationParams = ({ } return { - mutationFilters: predefinedFilter.filter as ChannelFilters, - mutationSort: + responseFilters: predefinedFilter.filter as ChannelFilters, + responseSort: predefinedFilter.sort !== undefined ? mapPredefinedFilterSortToChannelSort(predefinedFilter.sort) : sort, }; }; -const getMutationFiltersAndSort = ( +const getResponseFiltersAndSort = ( pagination: ChannelManagerPagination, ): Pick => ({ - filters: pagination.mutationFilters ?? pagination.filters, - sort: pagination.mutationSort ?? pagination.sort, + filters: pagination.responseFilters ?? pagination.filters, + sort: pagination.responseSort ?? pagination.sort, }); -const omitMutationPaginationParams = (pagination: ChannelManagerPagination) => { - const paginationWithoutMutationParams = { ...pagination }; - delete paginationWithoutMutationParams.mutationFilters; - delete paginationWithoutMutationParams.mutationSort; +const omitResponsePaginationParams = (pagination: ChannelManagerPagination) => { + const paginationWithoutResponseParams = { ...pagination }; + delete paginationWithoutResponseParams.responseFilters; + delete paginationWithoutResponseParams.responseSort; - return paginationWithoutMutationParams; + return paginationWithoutResponseParams; }; const isQueryChannelsResponseWithChannels = ( @@ -347,22 +347,22 @@ export class ChannelManager extends WithSubscriptions { const newOffset = offset + (channels?.length ?? 0); const newOptions = { ...options, offset: newOffset }; const { pagination } = this.state.getLatestValue(); - const mutationPaginationParams = getMutationPaginationParams({ + const responsePaginationParams = getResponsePaginationParams({ queryChannelsResponse: isQueryChannelsResponseWithChannels(queryChannelsResponse) ? queryChannelsResponse : undefined, sort, }); - const paginationWithoutMutationParams = omitMutationPaginationParams(pagination); + const paginationWithoutResponseParams = omitResponsePaginationParams(pagination); this.state.partialNext({ channels, pagination: { - ...paginationWithoutMutationParams, + ...paginationWithoutResponseParams, hasNext: (channels?.length ?? 0) >= (limit ?? 1), isLoading: false, options: newOptions, - ...mutationPaginationParams, + ...responsePaginationParams, }, initialized: true, error: undefined, @@ -435,7 +435,7 @@ export class ChannelManager extends WithSubscriptions { this.state.next((currentState) => ({ ...currentState, pagination: { - ...omitMutationPaginationParams(currentState.pagination), + ...omitResponsePaginationParams(currentState.pagination), isLoading: true, isLoadingNext: false, filters, @@ -568,7 +568,7 @@ export class ChannelManager extends WithSubscriptions { return; } - const { sort } = getMutationFiltersAndSort(pagination); + const { sort } = getResponseFiltersAndSort(pagination); this.setChannels( promoteChannel({ @@ -605,7 +605,7 @@ export class ChannelManager extends WithSubscriptions { if (!channels) { return; } - const { filters, sort } = getMutationFiltersAndSort(pagination); + const { filters, sort } = getResponseFiltersAndSort(pagination); const channelType = event.channel_type; const channelId = event.channel_id; @@ -664,7 +664,7 @@ export class ChannelManager extends WithSubscriptions { }); const { channels, pagination } = this.state.getLatestValue(); - const { filters, sort } = getMutationFiltersAndSort(pagination); + const { filters, sort } = getResponseFiltersAndSort(pagination); const considerArchivedChannels = shouldConsiderArchivedChannels(filters); const isTargetChannelArchived = isChannelArchived(channel); @@ -701,7 +701,7 @@ export class ChannelManager extends WithSubscriptions { }); const { channels, pagination } = this.state.getLatestValue(); - const { filters, sort } = getMutationFiltersAndSort(pagination); + const { filters, sort } = getResponseFiltersAndSort(pagination); const considerArchivedChannels = shouldConsiderArchivedChannels(filters); const isTargetChannelArchived = isChannelArchived(channel); @@ -728,7 +728,7 @@ export class ChannelManager extends WithSubscriptions { private memberUpdatedHandler = (event: Event) => { const { pagination, channels } = this.state.getLatestValue(); - const { filters, sort } = getMutationFiltersAndSort(pagination); + const { filters, sort } = getResponseFiltersAndSort(pagination); if ( !event.member?.user || event.member.user.id !== this.client.userID || diff --git a/test/unit/channel_manager.test.ts b/test/unit/channel_manager.test.ts index 9e4fee29c..74b586322 100644 --- a/test/unit/channel_manager.test.ts +++ b/test/unit/channel_manager.test.ts @@ -1535,7 +1535,7 @@ describe('ChannelManager', () => { sinon.reset(); }); - describe('predefined filter mutation metadata', () => { + describe('predefined filter response metadata', () => { it('does not promote an archived channel into a resolved non-archived list on message.new', async () => { await queryChannelsWithPredefinedFilterResponse({ filter: { archived: false }, @@ -1674,7 +1674,7 @@ describe('ChannelManager', () => { ).to.deep.equal(['channel3', 'channel1', 'channel2']); }); - it('keeps non-predefined query mutation behavior based on caller filters and sort', async () => { + it('keeps non-predefined query behavior based on caller filters and sort', async () => { vi.spyOn(client, 'post').mockResolvedValueOnce({ duration: '0.01s', channels: channelsResponse, @@ -1694,7 +1694,7 @@ describe('ChannelManager', () => { expect(setChannelsStub).toHaveBeenCalledTimes(0); }); - it('preserves resolved predefined mutation metadata after loading the next page', async () => { + it('preserves resolved predefined response metadata after loading the next page', async () => { vi.spyOn(client, 'post') .mockResolvedValueOnce({ duration: '0.01s', @@ -1738,7 +1738,7 @@ describe('ChannelManager', () => { expect(setChannelsStub).toHaveBeenCalledTimes(0); }); - it('clears resolved predefined mutation metadata when switching to a non-predefined query', async () => { + it('clears resolved predefined response metadata when switching to a non-predefined query', async () => { vi.spyOn(client, 'post') .mockResolvedValueOnce({ duration: '0.01s', From bf50e8f6c77f2ad674f6c9c84ef81b79b6aadb5f Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Thu, 14 May 2026 18:27:16 +0200 Subject: [PATCH 3/3] chore: add jsdocs --- src/channel_manager.ts | 5 +++++ src/client.ts | 12 ++++++++++++ src/types.ts | 8 ++++++++ 3 files changed, 25 insertions(+) diff --git a/src/channel_manager.ts b/src/channel_manager.ts index 3dc7dd3ad..871599a20 100644 --- a/src/channel_manager.ts +++ b/src/channel_manager.ts @@ -358,6 +358,11 @@ export class ChannelManager extends WithSubscriptions { this.state.partialNext({ channels, pagination: { + // Drop response derived filter/sort from the previous query before applying + // the current response. Non predefined queries do not return this metadata, + // so keeping the old values would make later WS mutations use stale + // predefined filter semantics. Also the predefined_filter might change, producing + // a different combination as well so we always need to first clean up. ...paginationWithoutResponseParams, hasNext: (channels?.length ?? 0) >= (limit ?? 1), isLoading: false, diff --git a/src/client.ts b/src/client.ts index 42f59fbaa..eb8fa66ea 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1896,6 +1896,12 @@ export class StreamChat { /** * queryChannelsRequestWithResponse - Queries channels and returns the full API response + * including top-level metadata such as `predefined_filter`. + * + * This exists as a compatibility bridge, as changing `queryChannelsRequest()` to return + * `QueryChannelsAPIResponse` would be a breaking change because it currently returns + * only the channel list. In the next major release, the request/response APIs should + * be consolidated so callers can access the full response through the primary API. * * @param {ChannelFilters} filterConditions object MongoDB style filters. Can be empty object when using predefined_filter in options. * @param {ChannelSort} [sort] Sort options, for instance {created_at: -1}. @@ -1947,6 +1953,11 @@ export class StreamChat { /** * queryChannelsRequest - Queries channels and returns the raw channel response list. * + * This preserves the historical return shape for backwards compatibility. Use + * `queryChannelsRequestWithResponse()` when response level metadata such as + * `predefined_filter` is needed. In the next major release these APIs should be + * consolidated into a single full-response API. + * * @param {ChannelFilters} filterConditions object MongoDB style filters. Can be empty object when using predefined_filter in options. * @param {ChannelSort} [sort] Sort options, for instance {created_at: -1}. * When using multiple fields, make sure you use array of objects to guarantee field order, for instance [{last_updated: -1}, {created_at: 1}] @@ -1981,6 +1992,7 @@ export class StreamChat { * @param {ChannelStateOptions} [stateOptions] State options object. These options will only be used for state management and won't be sent in the request. * - stateOptions.skipInitialization - Skips the initialization of the state for the channels matching the ids in the list. * - stateOptions.skipHydration - Skips returning the channels as instances of the Channel class and rather returns the raw query response. + * - stateOptions.withResponse - Returns the full query response with hydrated channels. This is a compatibility bridge for internal callers that need response-level metadata while the default return value remains `Channel[]`. * * @return {Promise>} search channels response */ diff --git a/src/types.ts b/src/types.ts index 9305456ee..0f0adaaf5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1111,6 +1111,14 @@ export type ChannelStateOptions = { offlineMode?: boolean; skipInitialization?: string[]; skipHydration?: boolean; + /** + * Returns the full query response with hydrated channels from `queryChannels()`. + * + * This is a compatibility bridge for internal callers that need response level + * metadata such as `predefined_filter`. The default `queryChannels()` return value + * remains `Channel[]` to avoid a breaking change. This should be folded into a + * single full response API in the next major release. + */ withResponse?: boolean; };