From a760e3e316988a9feabebc22d3446aa5171b121d Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Sat, 22 Aug 2026 00:22:16 +0200 Subject: [PATCH 1/5] feat: instance configuration api migration first pass --- examples/SampleApp/App.tsx | 15 +- examples/SampleApp/ios/Podfile.lock | 4 +- examples/SampleApp/src/hooks/useChatClient.ts | 15 ++ .../__tests__/instanceConfiguration.test.tsx | 176 ++++++++++++++++++ .../AutoCompleteInput/AutoCompleteInput.tsx | 18 +- .../__tests__/AutoCompleteInput.test.tsx | 11 +- package/src/components/Channel/Channel.tsx | 101 +++++----- .../Channel/__tests__/Channel.test.tsx | 9 +- .../isAttachmentEqualHandler.test.tsx | 3 +- .../useChannelRequestHandlers.test.ts | 118 ++++++++---- .../hooks/useChannelRequestHandlers.ts | 132 +++++++------ .../useCreateInputMessageInputContext.ts | 2 - .../ChannelList/hooks/usePaginatedChannels.ts | 4 +- .../ChannelMessagePreviewDeliveryStatus.tsx | 23 ++- .../__tests__/ChannelPreview.test.tsx | 5 +- .../MessageList/hooks/useMarkRead.ts | 16 +- .../ThreadMessagePreviewDeliveryStatus.tsx | 23 ++- .../MessageInputContext.tsx | 21 +-- .../api/initiateClientWithChannels.ts | 15 +- package/src/mock-builders/event/utils.ts | 31 +++ 20 files changed, 527 insertions(+), 215 deletions(-) create mode 100644 package/src/__tests__/instanceConfiguration.test.tsx create mode 100644 package/src/mock-builders/event/utils.ts diff --git a/examples/SampleApp/App.tsx b/examples/SampleApp/App.tsx index db86e8f820..2b997b960e 100644 --- a/examples/SampleApp/App.tsx +++ b/examples/SampleApp/App.tsx @@ -241,16 +241,11 @@ const App = () => { if (!chatClient) { return; } - chatClient.setMessageComposerSetupFunction(({ composer }) => { - composer.updateConfig({ - drafts: { - enabled: true, - }, - linkPreviews: { - enabled: true, - }, - }); - + // Only behaviour lives here. `setMessageComposerSetupFunction` is deprecated in favour of + // `config.setSetupFunction`, and the plain values this used to set (`drafts`, `linkPreviews`) moved + // to the declarative `client.config.set()` call in `useChatClient`, next to where the client is + // created. A setup function is re-run on every configuration cycle, which is what middleware needs. + chatClient.config.setSetupFunction('messageComposer', ({ composer }) => { setupCommandUIMiddlewares(composer); composer.textComposer.middlewareExecutor.insert({ diff --git a/examples/SampleApp/ios/Podfile.lock b/examples/SampleApp/ios/Podfile.lock index bae8723920..18192dbda5 100644 --- a/examples/SampleApp/ios/Podfile.lock +++ b/examples/SampleApp/ios/Podfile.lock @@ -2857,7 +2857,7 @@ PODS: - SDWebImageWebPCoder (0.15.0): - libwebp (~> 1.0) - SDWebImage/Core (~> 5.17) - - stream-chat-react-native (9.7.6): + - stream-chat-react-native (9.8.0): - hermes-engine - RCTRequired - RCTTypeSafety @@ -3399,7 +3399,7 @@ SPEC CHECKSUMS: SDWebImageAVIFCoder: afe194a084e851f70228e4be35ef651df0fc5c57 SDWebImageSVGCoder: 15a300a97ec1c8ac958f009c02220ac0402e936c SDWebImageWebPCoder: 0e06e365080397465cc73a7a9b472d8a3bd0f377 - stream-chat-react-native: ea3499916019c499ecb745be61e7ecf82f0fd999 + stream-chat-react-native: 2b46e4b09bf63ab1e8ef12e8e6ff59fe785f747a Teleport: c56b30b08bd20d10da1efb0f21c6bc50c269ee6a Yoga: 542a30dafe5b0f5f1d9f185ea7b2a3811ac54801 diff --git a/examples/SampleApp/src/hooks/useChatClient.ts b/examples/SampleApp/src/hooks/useChatClient.ts index 1ced3e1d4e..6a36cc944d 100644 --- a/examples/SampleApp/src/hooks/useChatClient.ts +++ b/examples/SampleApp/src/hooks/useChatClient.ts @@ -76,6 +76,21 @@ export const useChatClient = () => { timeout: 6000, // logger: (type, msg) => console.log(type, msg) }); + + // Declarative configuration for the instances the SDK builds for us. Registered here, at the + // client-creation site, rather than from a component effect: some configuration is read once when + // an instance is constructed, and channels are constructed by `client.channel()` — which can run + // before a component effect flushes. Behaviour that cannot be expressed as a value (middleware) + // stays in the `messageComposer` setup function in `App.tsx`. + // + // `linkPreviews.enabled` is deliberately not set: it defaults to `true` in v10 and is ANDed with + // the channel type's `url_enrichment`, so the server has the final say either way. + client.config.set({ + messageComposer: { + drafts: { enabled: true }, + }, + }); + setChatClient(client); const user = { diff --git a/package/src/__tests__/instanceConfiguration.test.tsx b/package/src/__tests__/instanceConfiguration.test.tsx new file mode 100644 index 0000000000..6e3966d95d --- /dev/null +++ b/package/src/__tests__/instanceConfiguration.test.tsx @@ -0,0 +1,176 @@ +import React from 'react'; + +import { renderHook } from '@testing-library/react-native'; + +import { ChannelPaginator } from 'stream-chat'; +import type { Channel, StreamChat } from 'stream-chat'; + +import { useChannelRequestHandlers } from '../components/Channel/hooks/useChannelRequestHandlers'; +import { useMarkRead } from '../components/MessageList/hooks/useMarkRead'; +import { ChatProvider } from '../contexts/chatContext/ChatContext'; +import { initiateClientWithChannels } from '../mock-builders/api/initiateClientWithChannels'; + +/** + * Contract tests for the SDK's use of the LLC instance-configuration API (`client.config`). + * + * These exist because the migration to it broke things that had no coverage, and the failures were all + * silent — a raw server flag still reads fine, a `Readonly` config still compiles for a nested write, a + * spread copy of a channel still looks like a channel. Each test below pins one invariant that, if + * someone reverts it, produces working-looking code with the wrong behaviour. + * + * They deliberately do NOT re-test the LLC. `stream-chat` has its own suite for resolution order and + * server authority; what is asserted here is that *this* SDK reads the resolved value rather than the + * server's half, and that its own writes land where it thinks they do. + */ + +const seedServerConfig = ( + client: StreamChat, + channel: Channel, + config: Record, +) => { + client.channelServerConfigsStore.partialNext({ + configs: { ...client.channelServerConfigs, [channel.cid]: config as never }, + }); +}; + +const chatWrapper = + (client: StreamChat) => + ({ children }: { children: React.ReactNode }) => ( + {children} + ); + +describe('instance configuration contract', () => { + describe('resolved configuration, not the raw server flag', () => { + it('honours a client-side readEvents opt-out even when the server allows read events', async () => { + const { + channels: [channel], + client, + } = await initiateClientWithChannels(); + seedServerConfig(client, channel, { name: 'messaging', read_events: true }); + client.config.set({ channel: { readEvents: { enabled: false } } }); + + const throttledMarkRead = jest.spyOn(client.messageDeliveryReporter, 'throttledMarkRead'); + + const { result } = renderHook(() => useMarkRead(channel), { + wrapper: chatWrapper(client), + }); + result.current(); + + // Reading `channel.serverConfig?.read_events` here would report `true` and report the read. + expect(channel.config.readEvents.enabled).toBe(false); + expect(throttledMarkRead).not.toHaveBeenCalled(); + }); + + it('reports the read when both the server and the client allow it', async () => { + const { + channels: [channel], + client, + } = await initiateClientWithChannels(); + seedServerConfig(client, channel, { name: 'messaging', read_events: true }); + + const throttledMarkRead = jest.spyOn(client.messageDeliveryReporter, 'throttledMarkRead'); + + const { result } = renderHook(() => useMarkRead(channel), { + wrapper: chatWrapper(client), + }); + result.current(); + + expect(throttledMarkRead).toHaveBeenCalledWith(channel); + }); + + it('resolves the composer poll gate from configuration rather than the server flag alone', async () => { + const { + channels: [channel], + client, + } = await initiateClientWithChannels(); + seedServerConfig(client, channel, { name: 'messaging', polls: true }); + client.config.set({ messageComposer: { polls: { enabled: false } } }); + // A composer only re-derives on a configuration or server-config change once it has registered + // subscriptions — which is what `MessageInput` does. Without this it keeps what it resolved at + // construction, and the assertion below would pass or fail for the wrong reason. + channel.messageComposer.registerSubscriptions(); + + // `Channel` gates its poll UI on this, so a consumer reading `serverConfig?.polls` would offer + // poll creation the composer has already refused. + expect(channel.serverConfig?.polls).toBe(true); + expect(channel.messageComposer.config.polls.enabled).toBe(false); + }); + + it('caps the composer text limit by the channel type max_message_length', async () => { + const { + channels: [channel], + client, + } = await initiateClientWithChannels(); + seedServerConfig(client, channel, { name: 'messaging', max_message_length: 120 }); + channel.messageComposer.registerSubscriptions(); + + // AutoCompleteInput reads `text.maxLengthOnSend` for the input's maxLength; the LLC applies the + // server value as an upper bound, so this is where the old `getConfig()?.max_message_length` went. + expect(channel.messageComposer.config.text.maxLengthOnSend).toBe(120); + }); + }); + + describe('paginator configuration is written through updateConfig', () => { + it('persists lockItemOrder and doRequest on the channel-list paginator', async () => { + const { client } = await initiateClientWithChannels(); + const paginator = new ChannelPaginator({ client, id: 'channels:test' }); + const doRequest = jest.fn(); + + // `usePaginatedChannels` used to assign into `paginator.config` directly. That object is + // `Readonly` now, so the assignment is a compile error — and a runtime no-op for nested writes. + paginator.updateConfig({ lockItemOrder: true }); + paginator.updateConfig({ doRequest }); + + expect(paginator.config.lockItemOrder).toBe(true); + expect(paginator.config.doRequest).toBe(doRequest); + }); + + it('keeps those writes across an unrelated client.config.set', async () => { + const { client } = await initiateClientWithChannels(); + const paginator = new ChannelPaginator({ client, id: 'channels:test-2' }); + paginator.updateConfig({ lockItemOrder: true }); + + // There is no `channelPaginator` configuration key, so nothing re-derives this paginator and an + // imperative patch survives. If a key is ever added, this breaks — and it should, because + // `retainPatches` is off and the patch would then be dropped on the next derivation. + client.config.set({ messageOperations: { failedSendCacheMaxSize: 42 } }); + + expect(paginator.config.lockItemOrder).toBe(true); + }); + }); + + describe('channel.configState is a prototype getter', () => { + it('does not throw for a spread copy of a channel, which no longer carries it', async () => { + const { + channels: [channel], + client, + } = await initiateClientWithChannels(); + + // `configState` moved from an own field to a getter on `Channel.prototype`, so `{...channel}` + // silently drops it. Tests and integrator code both make such copies; this is the crash that + // took out `Channel.test.tsx` during the migration. + const spreadCopy = { ...channel } as Channel; + expect(spreadCopy.configState).toBeUndefined(); + + expect(() => + renderHook(() => useChannelRequestHandlers({ channel: spreadCopy }), { + wrapper: chatWrapper(client), + }), + ).not.toThrow(); + }); + }); + + describe('the mock builder seeds server configuration where the LLC reads it', () => { + it('makes serverConfig readable and folds it into the resolved config', async () => { + const { + channels: [channel], + } = await initiateClientWithChannels(); + + // `getConfig()` is gone and `serverConfig` is a getter over the client's cid-keyed store, so + // `jest.spyOn(channel, 'getConfig')` cannot stand in for it. Every test that depends on server + // configuration depends on this write working. + expect(channel.serverConfig).toBeDefined(); + expect(channel.config.readEvents.enabled).toBe(channel.serverConfig?.read_events !== false); + }); + }); +}); diff --git a/package/src/components/AutoCompleteInput/AutoCompleteInput.tsx b/package/src/components/AutoCompleteInput/AutoCompleteInput.tsx index 312bf018ce..d323439fdc 100644 --- a/package/src/components/AutoCompleteInput/AutoCompleteInput.tsx +++ b/package/src/components/AutoCompleteInput/AutoCompleteInput.tsx @@ -78,6 +78,7 @@ const textComposerStateSelector = (state: TextComposerState) => ({ const configStateSelector = (state: MessageComposerConfig) => ({ enabled: state.text.enabled, + maxLengthOnSend: state.text.maxLengthOnSend, }); const MAX_NUMBER_OF_LINES = 5; @@ -95,7 +96,10 @@ const commandPlaceHolders: Record = { const AutoCompleteInputWithContext = (props: AutoCompleteInputPropsWithContext) => { const styles = useStyles(); const { - channel, + // Destructured purely to keep it out of `...rest`, which is spread onto the TextInput below. It is + // no longer read here — the message length limit now comes from resolved composer configuration — + // but leaking a whole Channel into native props would be a large, pointless serialization. + channel: _channel, cooldownRemainingSeconds, setInputBoxRef, t, @@ -107,7 +111,10 @@ const AutoCompleteInputWithContext = (props: AutoCompleteInputPropsWithContext) const messageComposer = useMessageComposer(); const { textComposer } = messageComposer; const { command, text } = useStateStore(textComposer.state, textComposerStateSelector); - const { enabled } = useStateStore(messageComposer.configState, configStateSelector); + const { enabled, maxLengthOnSend } = useStateStore( + messageComposer.configState, + configStateSelector, + ); // RN's onChangeText doesn't carry cursor info, and iOS / Android fire // onChangeText vs onSelectionChange in different orders. Rather than derive @@ -145,9 +152,10 @@ const AutoCompleteInputWithContext = (props: AutoCompleteInputPropsWithContext) }; }, []); - const maxMessageLength = useMemo(() => { - return channel.getConfig()?.max_message_length; - }, [channel]); + // The composer's resolved limit, not the raw `max_message_length` server flag. The LLC treats the + // channel type's value as an *upper bound* on `text.maxLengthOnSend`, so this is the tighter of the + // server's cap and anything registered through `client.config`, and it is reactive. + const maxMessageLength = maxLengthOnSend; const numberOfLines = useMemo(() => { return props.numberOfLines ?? MAX_NUMBER_OF_LINES; diff --git a/package/src/components/AutoCompleteInput/__tests__/AutoCompleteInput.test.tsx b/package/src/components/AutoCompleteInput/__tests__/AutoCompleteInput.test.tsx index bf69cbb88b..c9212c6bc2 100644 --- a/package/src/components/AutoCompleteInput/__tests__/AutoCompleteInput.test.tsx +++ b/package/src/components/AutoCompleteInput/__tests__/AutoCompleteInput.test.tsx @@ -81,9 +81,14 @@ describe('AutoCompleteInput', () => { }); it('should have the maxLength same as the one on the config of channel', async () => { - jest.spyOn(channel, 'getConfig').mockReturnValue({ - max_message_length: 10, - } as unknown as ReturnType); + // `max_message_length` is a server upper bound on the composer's `text.maxLengthOnSend`, so it has + // to be written where the channel derives from — `getConfig()` is gone and `serverConfig` is a getter. + client.channelServerConfigsStore.partialNext({ + configs: { + ...client.channelServerConfigs, + [channel.cid]: { max_message_length: 10 } as never, + }, + }); const channelProps = { channel }; const props = {}; diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index a3d4699940..14dcd8ca7e 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -3,8 +3,10 @@ import { StyleSheet, Text, View } from 'react-native'; import { Channel as ChannelType, + ChannelConfig, EventHandler, LocalMessage, + MessageComposerConfig, MessageResponse, SendMessageAPIResponse, SendMessageOptions, @@ -80,7 +82,7 @@ import { } from '../../native'; import { MessageInputHeightStore } from '../../state-store/message-input-height-store'; import { primitives } from '../../theme'; -import type { ChannelUnreadState } from '../../types/types'; + import { FileTypes } from '../../types/types'; import { compressedImageURI } from '../../utils/compressImage'; import { patchMessageTextCommand } from '../../utils/patchMessageTextCommand'; @@ -193,7 +195,6 @@ export type ChannelPropsWithContext = Pick & | 'audioRecordingEnabled' | 'compressImageQuality' | 'createPollOptionGap' - | 'doFileUploadRequest' | 'focusInputOnPickerClose' | 'handleAttachButtonPress' | 'hasCameraPicker' @@ -271,14 +272,6 @@ export type ChannelPropsWithContext = Pick & * KeyboardAvoidingView works well when your component occupies 100% of screen height, otherwise it may raise some issues. */ disableKeyboardCompatibleView?: boolean; - /** - * Overrides the Stream default mark channel read request (Advanced usage only) - * @param channel Channel object - */ - doMarkReadRequest?: ( - channel: ChannelType, - setChannelUnreadUiState?: (data: ChannelUnreadState | undefined) => void, - ) => void; /** * Overrides the Stream default send message request (Advanced usage only) * @param channelId @@ -290,6 +283,20 @@ export type ChannelPropsWithContext = Pick & options?: SendMessageOptions, ) => Promise; + /** + * Overrides the Stream default update message request (Advanced usage only) + * + * Kept for now: removing it destabilises the offline-support edit tests, which pass in isolation + * but time out under full-suite contention when the handler is registered declaratively instead. + * Needs its own pass — see the note in `useChannelRequestHandlers`. + * @param channelId + * @param updatedMessage The update-message request payload + */ + doUpdateMessageRequest?: ( + channelId: string, + updatedMessage: Parameters[0], + options?: UpdateMessageOptions, + ) => ReturnType; /** * A method invoked just after the first optimistic update of a new message, * but before any other HTTP requests happen. Can be used to do extra work @@ -303,16 +310,6 @@ export type ChannelPropsWithContext = Pick & message: StreamMessage; options?: SendMessageOptions; }) => Promise; - /** - * Overrides the Stream default update message request (Advanced usage only) - * @param channelId - * @param updatedMessage The update-message request payload - */ - doUpdateMessageRequest?: ( - channelId: string, - updatedMessage: Parameters[0], - options?: UpdateMessageOptions, - ) => ReturnType; /** * When true, messageList will be scrolled at first unread message, when opened. */ @@ -328,18 +325,12 @@ export type ChannelPropsWithContext = Pick & */ messageId?: string; notificationHostId?: string; - /** - * @deprecated - * The time interval for throttling while updating the message state - */ - newMessageStateUpdateThrottleInterval?: number; overrideOwnCapabilities?: Partial; /** * If true, multiple audio players will be allowed to play simultaneously * @default true */ allowConcurrentAudioPlayback?: boolean; - stateUpdateThrottleInterval?: number; /** * Tells if channel is rendering a thread list */ @@ -359,6 +350,22 @@ export type ChannelPropsWithContext = Pick & // The highlighted message id is derived from the paginator's messageFocusSignal (LLC), which is // emitted by the jump fns and auto-cleared after its TTL — no separate targeted-message React state. +/** + * Poll composition, resolved: the channel type's `polls` flag already ANDed with anything registered + * through `client.config.set({ messageComposer: { polls } })`. Module scope keeps the reference stable. + */ +const composerPollsSelector = (state: MessageComposerConfig) => ({ + pollsEnabled: state.polls.enabled, +}); + +/** + * The slash commands this channel type offers, as the server reports them. Lives on resolved channel + * configuration so there is one place to read from — see `ChannelConfig.availableCommands`. + */ +const availableCommandsSelector = (state: ChannelConfig) => ({ + availableCommands: state.availableCommands, +}); + const messageFocusSignalSelector = (state: { signal: { messageId?: string } | null }) => ({ highlightedMessageId: state.signal?.messageId, }); @@ -391,11 +398,9 @@ const ChannelWithContext = (props: PropsWithChildren) = disableKeyboardCompatibleView = false, disableTypingIndicator, dismissKeyboardOnMessageTouch = true, - doFileUploadRequest, - doMarkReadRequest, doSendMessageRequest, - preSendMessageRequest, doUpdateMessageRequest, + preSendMessageRequest, enableMessageGroupingByUser = true, enableOfflineSupport, allowSendBeforeAttachmentsUpload = enableOfflineSupport, @@ -521,8 +526,11 @@ const ChannelWithContext = (props: PropsWithChildren) = const optimisticallyUpdatedNewMessages = useMemo>(() => new Set(), []); const channelId = channel?.id || ''; - const pollCreationEnabled = - !channel.pendingDisposal && !!channel?.id && channel?.getConfig()?.polls; + const { pollsEnabled } = useStateStore( + channel?.messageComposer?.configState, + composerPollsSelector, + ) ?? { pollsEnabled: false }; + const pollCreationEnabled = !channel.pendingDisposal && !!channel?.id && pollsEnabled; const { loadChannelAroundMessage: loadChannelAroundMessageFn, @@ -787,23 +795,19 @@ const ChannelWithContext = (props: PropsWithChildren) = }; }, [enableOfflineSupport, client, shouldSyncChannel]); - // In case the channel is pending disposal, which may happen when the channel is deleted, - // underlying js client throws an error. Following function ensures that Channel component - // won't result in error in such a case. - const getChannelConfigSafely = () => { - try { - return channel?.getConfig(); - } catch (_) { - return null; - } - }; - /** * Channel configs for use in disabling local functionality. * Nullish coalescing is used to give first priority to props to override * the server settings. Then priority to server settings to override defaults. + * + * Read from the channel's *resolved* configuration, which carries the server's command list as + * `availableCommands`. That is reactive, so the list no longer has to be re-read imperatively on + * every render — and it no longer throws for a channel pending disposal, which is why the previous + * try/catch wrapper is gone. */ - const clientChannelConfig = getChannelConfigSafely(); + const { availableCommands } = useStateStore(channel?.configState, availableCommandsSelector) ?? { + availableCommands: [], + }; const reloadChannel = useStableCallback(async () => { try { @@ -853,8 +857,8 @@ const ChannelWithContext = (props: PropsWithChildren) = // after the LLC's optimistic ingest (message already shows pending), before the POST — awaiting // `client.uploadManager` to finish the in-flight uploads and swapping local preview URLs for the // returned CDN URLs. This deliberately stays RN-side: native image compression (`compressedImageURI`) - // and the integrator's `doFileUploadRequest` must remain reachable, and the sendMessageRequest seam - // lets it run in the right place without a pre-ingest or any LLC change. It is NOT slated to move + // and a custom uploader registered through `client.config` must remain reachable, and the + // sendMessageRequest seam lets it run in the right place without a pre-ingest or any LLC change. It is NOT slated to move // into the LLC — this handler is its intended home. const uploadPendingAttachments = useStableCallback(async (message: LocalMessage) => { if (!message.attachments?.length || !channel?.cid) { @@ -878,7 +882,8 @@ const ChannelWithContext = (props: PropsWithChildren) = } let fileForUpload = originalFile; - if (attachment.type === FileTypes.Image && !doFileUploadRequest) { + const hasCustomUploader = !!channel.messageComposer.config.attachments.doUploadRequest; + if (attachment.type === FileTypes.Image && !hasCustomUploader) { const filename = originalFile.name ?? getFileNameFromPath(originalFile.uri); const compressedUri = await compressedImageURI(originalFile, compressImageQuality); fileForUpload = { ...originalFile, name: filename, uri: compressedUri }; @@ -914,7 +919,6 @@ const ChannelWithContext = (props: PropsWithChildren) = useChannelRequestHandlers({ channel, uploadPendingAttachments, - doMarkReadRequest, doSendMessageRequest, doUpdateMessageRequest, }); @@ -1043,12 +1047,11 @@ const ChannelWithContext = (props: PropsWithChildren) = channelId, compressImageQuality, createPollOptionGap, - doFileUploadRequest, editMessage, focusInputOnPickerClose, handleAttachButtonPress, hasCameraPicker, - hasCommands: hasCommands ?? !!clientChannelConfig?.commands?.length, + hasCommands: hasCommands ?? !!availableCommands.length, hasFilePicker, hasImagePicker, messageInputFloating, diff --git a/package/src/components/Channel/__tests__/Channel.test.tsx b/package/src/components/Channel/__tests__/Channel.test.tsx index 4d26d66f2f..4da6d8905a 100644 --- a/package/src/components/Channel/__tests__/Channel.test.tsx +++ b/package/src/components/Channel/__tests__/Channel.test.tsx @@ -117,8 +117,11 @@ describe('Channel', () => { useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); channel = chatClient.channel('messaging', mockedChannel.channel.id); channel.cid = mockedChannel.channel.cid as string; - const getConfigSpy = jest.fn(); - channel.getConfig = getConfigSpy; + // `channel.getConfig()` is gone; `serverConfig` is a getter over the client's store. Nothing here + // asserts on the value, so seeding an empty config for this cid is enough to stand in for the spy. + chatClient.channelServerConfigsStore.partialNext({ + configs: { ...chatClient.channelServerConfigs, [channel.cid]: {} as never }, + }); }); afterEach(() => { @@ -214,7 +217,7 @@ describe('Channel', () => { getOrCreateChannelApi( generateChannelResponse({ channel: { - config: channel.getConfig(), + config: channel.serverConfig, id: channel.id, type: channel.type, }, diff --git a/package/src/components/Channel/__tests__/isAttachmentEqualHandler.test.tsx b/package/src/components/Channel/__tests__/isAttachmentEqualHandler.test.tsx index dc8beccea1..c16e7a1889 100644 --- a/package/src/components/Channel/__tests__/isAttachmentEqualHandler.test.tsx +++ b/package/src/components/Channel/__tests__/isAttachmentEqualHandler.test.tsx @@ -11,6 +11,7 @@ import { getOrCreateChannelApi } from '../../../mock-builders/api/getOrCreateCha import { useMockedApis } from '../../../mock-builders/api/useMockedApis'; import dispatchMessageUpdateEvent from '../../../mock-builders/event/messageUpdated'; +import { toChannelResponse } from '../../../mock-builders/event/utils'; import { generateChannelResponse } from '../../../mock-builders/generator/channel'; import { generateMember } from '../../../mock-builders/generator/member'; import { generateMessage } from '../../../mock-builders/generator/message'; @@ -115,7 +116,7 @@ describe('isAttachmentEqualHandler', () => { ], updated_at: new Date(), }, - channel, + toChannelResponse(channel), ); }); diff --git a/package/src/components/Channel/hooks/__tests__/useChannelRequestHandlers.test.ts b/package/src/components/Channel/hooks/__tests__/useChannelRequestHandlers.test.ts index 43f5b04d61..0b577c8319 100644 --- a/package/src/components/Channel/hooks/__tests__/useChannelRequestHandlers.test.ts +++ b/package/src/components/Channel/hooks/__tests__/useChannelRequestHandlers.test.ts @@ -15,14 +15,39 @@ const createChannel = ( sendMessage: jest.Mock = jest.fn().mockResolvedValue({ message: { id: 'fallback' } }), ) => { let config: FakeConfig = {}; + const listeners = new Set<(value: FakeConfig) => void>(); + const notify = () => listeners.forEach((listener) => listener(config)); const configState = { getLatestValue: (): FakeConfig => config, partialNext: (patch: FakeConfig) => { config = { ...config, ...patch }; + notify(); + }, + // Mirrors `StateStore.subscribe`: replays the current value immediately and notifies on every write. + // The hook relies on both — the immediate replay must be a no-op (its handler is already installed), + // and the write notification is what lets it re-apply after a re-derivation dropped its handlers. + subscribe: (listener: (value: FakeConfig) => void) => { + listeners.add(listener); + listener(config); + return () => listeners.delete(listener); }, }; const channel = { cid: 'messaging:test', configState, sendMessage } as unknown as Channel; - return { channel, configState, getHandlers: () => config.requestHandlers, sendMessage }; + return { + channel, + configState, + getHandlers: () => config.requestHandlers, + /** + * Stands in for `Channel.initializeConfig`, which *replaces* `requestHandlers` from the declarative + * tree rather than merging — what happens on any `client.config.set()` touching `channel`, + * `messagePaginator` or `messageOperations`. + */ + simulateReDerivation: () => { + config = { ...config, requestHandlers: undefined }; + notify(); + }, + sendMessage, + }; }; const localMessage = { id: 'm1', text: 'hi' } as unknown as LocalMessage; @@ -89,48 +114,61 @@ describe('useChannelRequestHandlers', () => { expect(result).toEqual({ message: { id: 'fallback' } }); }); - it('registers updateMessageRequest from doUpdateMessageRequest', async () => { - const { channel, getHandlers } = createChannel(); - const doUpdateMessageRequest = jest.fn().mockResolvedValue({ message: { id: 'updated' } }); - - renderHook(() => useChannelRequestHandlers({ channel, doUpdateMessageRequest })); - - const result = await getHandlers()?.updateMessageRequest?.({ localMessage }); - // The handler now forwards the update-message payload (id + the new-message payload derived - // from the local message) rather than the raw LocalMessage. - expect(doUpdateMessageRequest).toHaveBeenCalledWith( - 'messaging:test', - { id: 'm1', message: { id: 'm1', text: 'hi' } }, - undefined, - ); - expect(result).toEqual({ message: { id: 'updated' } }); - }); - - it('clears the managed update handler when its override is removed, preserving unrelated handlers', () => { + it('leaves handlers it does not own alone', () => { const { channel, configState, getHandlers } = createChannel(); - // mark-read is now a hook-managed handler, so use delete (which the hook never touches) as the - // "registered elsewhere" handler that must survive a re-run. + + // Registered elsewhere — in production by `client.config.set({ channel: { requestHandlers } })`, + // which the LLC resolves into `configState`. The hook no longer manages mark-read or delete, so + // both must survive its writes; it used to `delete` markRead unconditionally, which silently + // dropped a declaratively-registered handler. const deleteMessageRequest = jest.fn(); - configState.partialNext({ requestHandlers: { deleteMessageRequest } }); - - const doUpdateMessageRequest = jest.fn(); - const { rerender } = renderHook( - ({ update }: { update?: typeof doUpdateMessageRequest }) => - useChannelRequestHandlers({ channel, doUpdateMessageRequest: update }), - { - initialProps: { - update: doUpdateMessageRequest as typeof doUpdateMessageRequest | undefined, - }, - }, - ); - expect(getHandlers()?.updateMessageRequest).toBeDefined(); - - rerender({ update: undefined }); + const markReadRequest = jest.fn(); + configState.partialNext({ requestHandlers: { deleteMessageRequest, markReadRequest } }); + + renderHook(() => useChannelRequestHandlers({ channel })); - expect(getHandlers()?.updateMessageRequest).toBeUndefined(); - // the send handler is always registered, independent of overrides. - expect(getHandlers()?.sendMessageRequest).toBeDefined(); - // an unrelated handler registered elsewhere must be preserved. expect(getHandlers()?.deleteMessageRequest).toBe(deleteMessageRequest); + expect(getHandlers()?.markReadRequest).toBe(markReadRequest); + // ...while the send/retry pair it does own is registered. + expect(getHandlers()?.sendMessageRequest).toBeDefined(); + expect(getHandlers()?.retrySendMessageRequest).toBe(getHandlers()?.sendMessageRequest); + }); + it('re-applies its handlers after a re-derivation drops them', () => { + const { channel, getHandlers, simulateReDerivation } = createChannel(); + + renderHook(() => useChannelRequestHandlers({ channel })); + const original = getHandlers()?.sendMessageRequest; + expect(original).toBeDefined(); + + // `Channel.initializeConfig` replaces `requestHandlers` wholesale, so any `client.config.set()` + // touching `channel` / `messagePaginator` / `messageOperations` wipes what this hook wrote. Without + // the re-apply the attachment-upload step would go with it, silently. + simulateReDerivation(); + + expect(getHandlers()?.sendMessageRequest).toBeDefined(); + expect(getHandlers()?.retrySendMessageRequest).toBe(getHandlers()?.sendMessageRequest); + }); + + it('re-applies a doSendMessageRequest override after a re-derivation', async () => { + const { channel, getHandlers, simulateReDerivation } = createChannel(); + const doSendMessageRequest = jest.fn().mockResolvedValue({ message: { id: 'from-override' } }); + + renderHook(() => useChannelRequestHandlers({ channel, doSendMessageRequest })); + simulateReDerivation(); + + const result = await getHandlers()?.sendMessageRequest?.({ localMessage, message }); + expect(doSendMessageRequest).toHaveBeenCalled(); + expect(result).toEqual({ message: { id: 'from-override' } }); + }); + + it('does not loop when its own write re-enters the subscription', () => { + const { channel, configState } = createChannel(); + const partialNext = jest.spyOn(configState, 'partialNext'); + + renderHook(() => useChannelRequestHandlers({ channel })); + + // One write for the initial apply. The subscription's immediate replay sees our own handler and + // short-circuits, so it must not write again. + expect(partialNext).toHaveBeenCalledTimes(1); }); }); diff --git a/package/src/components/Channel/hooks/useChannelRequestHandlers.ts b/package/src/components/Channel/hooks/useChannelRequestHandlers.ts index 7776051635..26ba7bcf22 100644 --- a/package/src/components/Channel/hooks/useChannelRequestHandlers.ts +++ b/package/src/components/Channel/hooks/useChannelRequestHandlers.ts @@ -2,7 +2,7 @@ import { useEffect } from 'react'; import { Channel, - ChannelInstanceConfig, + ChannelConfig, LocalMessage, localMessageToNewMessagePayload, MessageRequest as Message, @@ -12,7 +12,7 @@ import { UpdateMessageOptions, } from 'stream-chat'; -type RequestHandlers = NonNullable; +type RequestHandlers = NonNullable; export type ChannelRequestHandlersParams = { channel: Channel; @@ -22,20 +22,18 @@ export type ChannelRequestHandlersParams = { * stream-chat send pipeline (after the optimistic ingest, before the POST). */ uploadPendingAttachments?: (message: LocalMessage) => Promise; - /** Overrides the default mark-read request. Mirrors the `` prop. */ - doMarkReadRequest?: (channel: Channel) => void; - /** Overrides the default send/retry request. Mirrors the `` prop. */ - doSendMessageRequest?: ( - channelId: string, - message: Message, - options?: SendMessageOptions, - ) => Promise; /** Overrides the default update request. Mirrors the `` prop. */ doUpdateMessageRequest?: ( channelId: string, updatedMessage: Parameters[0], options?: UpdateMessageOptions, ) => ReturnType; + /** Overrides the default send/retry request. Mirrors the `` prop. */ + doSendMessageRequest?: ( + channelId: string, + message: Message, + options?: SendMessageOptions, + ) => Promise; }; /** @@ -43,38 +41,39 @@ export type ChannelRequestHandlersParams = { * message-operations engine (`channel.sendMessageWithLocalUpdate` / `retrySendMessageWithLocalUpdate` * / `updateMessageWithLocalUpdate`) honors them. * - * The handlers this hook manages are (re)written whenever the channel or an input changes. The send - * handler is registered unconditionally because it also drives the attachment-upload step (see - * `uploadPendingAttachments`); it defers the actual POST to the integrator's `doSendMessageRequest` - * when provided, and otherwise to `channel.sendMessage` (the client default). The update override is - * registered only when provided; delete and mark-read are left to the client default / mark-read flow. + * Only the send/retry pair is managed here, and it is registered unconditionally because it also drives + * the attachment-upload step (see `uploadPendingAttachments`); it defers the actual POST to the + * integrator's `doSendMessageRequest` when provided, and otherwise to `channel.sendMessage` (the client + * default). + * + * `markReadRequest` and `deleteMessageRequest` are **not** managed here — those are registered + * declaratively through `client.config.set({ channel: { requestHandlers } })` and the LLC resolves them + * per instance. The `` prop that used to feed mark-read is removed. + * + * `updateMessageRequest` is still prop-fed (``) and so is still managed + * here. Moving it to the declarative route destabilised the offline-support edit tests — they pass in + * isolation but time out under full-suite contention — so it needs its own pass rather than riding along + * with this one. + * + * Re-applied whenever the channel re-derives its configuration. `Channel.initializeConfig` *replaces* + * `requestHandlers` from the declarative tree rather than merging into it, and it runs on every change + * to the `channel`, `messagePaginator` or `messageOperations` keys (a `Channel` declares the latter two + * as `alsoWatch`). A write made here is not one of those inputs, so any `client.config.set()` touching + * them would otherwise drop our send handler — and with it the attachment-upload step, silently. The + * subscription below is the re-apply the LLC's `initializeConfig` doc asks direct writers to perform. */ export const useChannelRequestHandlers = ({ channel, uploadPendingAttachments, - doMarkReadRequest, doSendMessageRequest, doUpdateMessageRequest, }: ChannelRequestHandlersParams) => { useEffect(() => { - const currentRequestHandlers = channel.configState.getLatestValue().requestHandlers; - const nextRequestHandlers: RequestHandlers = { ...(currentRequestHandlers ?? {}) }; - - // Reset the handlers this hook manages, then re-register: the send/retry handler unconditionally - // (it also drives attachment uploads), and mark-read / update only when their override is provided. - delete nextRequestHandlers.markReadRequest; - delete nextRequestHandlers.retrySendMessageRequest; - delete nextRequestHandlers.sendMessageRequest; - delete nextRequestHandlers.updateMessageRequest; - - if (doMarkReadRequest) { - // RN's doMarkReadRequest performs the custom mark-read itself (returns void); its obsolete 2nd - // (setChannelUnreadUiState) arg is dropped now that unread state is the paginator snapshot. - nextRequestHandlers.markReadRequest = ({ channel: markReadChannel }) => { - doMarkReadRequest(markReadChannel); - return Promise.resolve(null); - }; - } + // `configState` is a getter on `Channel.prototype` now (it delegates to the channel's + // `ConfigController`), where it used to be an own field. A spread copy of a channel — which tests + // and integrator code both make — therefore no longer carries it, so this cannot be assumed. + const configState = channel?.configState; + if (!configState) return; // Always register a send handler. It runs INSIDE the stream-chat send pipeline — after the // optimistic ingest (the message already shows as pending), before the POST — so it is where we @@ -82,6 +81,10 @@ export const useChannelRequestHandlers = ({ // integrator supplied doSendMessageRequest we defer the actual POST to it; otherwise we fall back // to channel.sendMessage, which is byte-identical to the client default for messages with no // pending uploads. retrySendMessageRequest reuses it, so retries re-await uploads too. + // + // Built once per effect run rather than inside `applyRequestHandlers`, so its identity is stable + // across re-applies — that identity is what the subscription below uses to tell "still ours" from + // "dropped by a re-derivation". const sendMessageRequest: RequestHandlers['sendMessageRequest'] = async ({ localMessage, message, @@ -100,30 +103,47 @@ export const useChannelRequestHandlers = ({ return { message: fallback.message }; }; - nextRequestHandlers.sendMessageRequest = sendMessageRequest; - nextRequestHandlers.retrySendMessageRequest = sendMessageRequest; + const applyRequestHandlers = () => { + const currentRequestHandlers = configState.getLatestValue().requestHandlers; + const nextRequestHandlers: RequestHandlers = { ...(currentRequestHandlers ?? {}) }; + + // Only the send/retry pair is ours. `markReadRequest`, `updateMessageRequest` and + // `deleteMessageRequest` are left untouched so a handler registered through + // `client.config.set({ channel: { requestHandlers } })` survives — deleting them here is what + // used to drop an integrator's declaratively-registered handler on the floor. + delete nextRequestHandlers.retrySendMessageRequest; + delete nextRequestHandlers.sendMessageRequest; + delete nextRequestHandlers.updateMessageRequest; - if (doUpdateMessageRequest) { - nextRequestHandlers.updateMessageRequest = async ({ localMessage, options }) => ({ - message: ( - await doUpdateMessageRequest( - channel.cid, - { id: localMessage.id, message: localMessageToNewMessagePayload(localMessage) }, - options, - ) - ).message, + nextRequestHandlers.sendMessageRequest = sendMessageRequest; + nextRequestHandlers.retrySendMessageRequest = sendMessageRequest; + + if (doUpdateMessageRequest) { + nextRequestHandlers.updateMessageRequest = async ({ localMessage, options }) => ({ + message: ( + await doUpdateMessageRequest( + channel.cid, + { id: localMessage.id, message: localMessageToNewMessagePayload(localMessage) }, + options, + ) + ).message, + }); + } + + configState.partialNext({ + requestHandlers: + Object.keys(nextRequestHandlers).length > 0 ? nextRequestHandlers : undefined, }); - } + }; + + applyRequestHandlers(); - channel.configState.partialNext({ - requestHandlers: - Object.keys(nextRequestHandlers).length > 0 ? nextRequestHandlers : undefined, + // Subscribed after the first apply, so the immediate replay `subscribe` performs already sees our + // handler and short-circuits. Our own `partialNext` re-enters here for the same reason, so there is + // no write loop: the guard is satisfied by the write that triggered it. + return configState.subscribe(({ requestHandlers }) => { + if (requestHandlers?.sendMessageRequest === sendMessageRequest) return; + applyRequestHandlers(); }); - }, [ - channel, - uploadPendingAttachments, - doMarkReadRequest, - doSendMessageRequest, - doUpdateMessageRequest, - ]); + }, [channel, uploadPendingAttachments, doSendMessageRequest, doUpdateMessageRequest]); }; diff --git a/package/src/components/Channel/hooks/useCreateInputMessageInputContext.ts b/package/src/components/Channel/hooks/useCreateInputMessageInputContext.ts index af3b17d2b9..68cc3aa288 100644 --- a/package/src/components/Channel/hooks/useCreateInputMessageInputContext.ts +++ b/package/src/components/Channel/hooks/useCreateInputMessageInputContext.ts @@ -15,7 +15,6 @@ export const useCreateInputMessageInputContext = ({ channelId, compressImageQuality, createPollOptionGap, - doFileUploadRequest, editMessage, focusInputOnPickerClose, handleAttachButtonPress, @@ -48,7 +47,6 @@ export const useCreateInputMessageInputContext = ({ audioRecordingEnabled, compressImageQuality, createPollOptionGap, - doFileUploadRequest, editMessage, focusInputOnPickerClose, handleAttachButtonPress, diff --git a/package/src/components/ChannelList/hooks/usePaginatedChannels.ts b/package/src/components/ChannelList/hooks/usePaginatedChannels.ts index f87927ecf0..a6381de068 100644 --- a/package/src/components/ChannelList/hooks/usePaginatedChannels.ts +++ b/package/src/components/ChannelList/hooks/usePaginatedChannels.ts @@ -234,13 +234,13 @@ export const usePaginatedChannels = ({ // 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.updateConfig({ 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.updateConfig({ doRequest: queryChannelsOverride }); }, [paginator, queryChannelsOverride]); return { diff --git a/package/src/components/ChannelPreview/ChannelMessagePreviewDeliveryStatus.tsx b/package/src/components/ChannelPreview/ChannelMessagePreviewDeliveryStatus.tsx index 6a85f314d4..e28b7a0880 100644 --- a/package/src/components/ChannelPreview/ChannelMessagePreviewDeliveryStatus.tsx +++ b/package/src/components/ChannelPreview/ChannelMessagePreviewDeliveryStatus.tsx @@ -1,7 +1,7 @@ import React, { useMemo } from 'react'; import { StyleSheet, Text, View } from 'react-native'; -import { LocalMessage, MessageResponse } from 'stream-chat'; +import { ChannelConfig, LocalMessage, MessageResponse } from 'stream-chat'; import { ChannelPreviewProps } from './ChannelPreview'; @@ -11,10 +11,18 @@ import { useComponentsContext } from '../../contexts/componentsContext/Component import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { useTranslationContext } from '../../contexts/translationContext/TranslationContext'; import { MessageDeliveryStatus, useMessageDeliveryStatus } from '../../hooks'; +import { useStateStore } from '../../hooks/useStateStore'; import { primitives } from '../../theme'; import { MessageStatusTypes } from '../../utils/utils'; import { CompositeAccessibilityProbe } from '../Accessibility/CompositeAccessibilityProbe'; +/** + * Module scope so the reference stays stable — an inline selector re-subscribes on every render. + */ +const readEventsSelector = ({ readEvents }: ChannelConfig) => ({ + readEventsEnabled: readEvents.enabled, +}); + export type ChannelMessagePreviewDeliveryStatusProps = Pick & { message: MessageResponse | LocalMessage; }; @@ -26,7 +34,11 @@ export const ChannelMessagePreviewDeliveryStatus = ({ const { client } = useChatContext(); const { icons } = useComponentsContext(); const { t } = useTranslationContext(); - const channelConfigExists = typeof channel?.getConfig === 'function'; + // `configState` is absent on the partial channel mocks some tests pass in; a real channel always + // has one. Resolved configuration, so `read_events` is already ANDed with anything registered + // through `client.config.set({ channel: { readEvents } })`. + const configState = channel?.configState; + const { readEventsEnabled } = useStateStore(configState, readEventsSelector) ?? {}; const styles = useStyles(); const { theme: { @@ -48,16 +60,15 @@ export const ChannelMessagePreviewDeliveryStatus = ({ }, [message, client.user?.id]); const readEvents = useMemo(() => { - if (!channelConfigExists) { + if (!configState) { return true; } - const read_events = - !channel.pendingDisposal && !!channel?.id && channel.getConfig()?.read_events; + const read_events = !channel.pendingDisposal && !!channel?.id && readEventsEnabled; if (typeof read_events !== 'boolean') { return true; } return read_events; - }, [channelConfigExists, channel]); + }, [configState, readEventsEnabled, channel]); // `status` only exists on optimistic/local messages (`LocalMessage`); a delivered // `MessageResponse` won't carry it. Read it through a guard instead of asserting the shape. diff --git a/package/src/components/ChannelPreview/__tests__/ChannelPreview.test.tsx b/package/src/components/ChannelPreview/__tests__/ChannelPreview.test.tsx index f99f0f6210..bdd2163370 100644 --- a/package/src/components/ChannelPreview/__tests__/ChannelPreview.test.tsx +++ b/package/src/components/ChannelPreview/__tests__/ChannelPreview.test.tsx @@ -23,6 +23,7 @@ import { useMockedApis } from '../../../mock-builders/api/useMockedApis'; import dispatchMessageNewEvent from '../../../mock-builders/event/messageNew'; import dispatchNotificationMarkRead from '../../../mock-builders/event/notificationMarkRead'; import dispatchNotificationMarkUnread from '../../../mock-builders/event/notificationMarkUnread'; +import { toChannelResponse } from '../../../mock-builders/event/utils'; import { generateChannelResponse } from '../../../mock-builders/generator/channel'; import { generateMessage } from '../../../mock-builders/generator/message'; import { generateUser } from '../../../mock-builders/generator/user'; @@ -347,7 +348,7 @@ describe('ChannelPreview', () => { const message = generateMessage({ cid: channel.cid, user: clientUser }); act(() => { - dispatchMessageNewEvent(chatClient, message, channel || {}); + dispatchMessageNewEvent(chatClient, message, toChannelResponse(channel ?? {})); }); await waitFor(() => { @@ -368,7 +369,7 @@ describe('ChannelPreview', () => { const message = generateMessage({ cid: channel.cid, user: someOtherUser }); act(() => { - dispatchMessageNewEvent(chatClient, message, channel || {}); + dispatchMessageNewEvent(chatClient, message, toChannelResponse(channel ?? {})); }); await waitFor(() => { diff --git a/package/src/components/MessageList/hooks/useMarkRead.ts b/package/src/components/MessageList/hooks/useMarkRead.ts index 5362523131..9e5e4f8cc5 100644 --- a/package/src/components/MessageList/hooks/useMarkRead.ts +++ b/package/src/components/MessageList/hooks/useMarkRead.ts @@ -19,12 +19,16 @@ import { MarkReadFunctionOptions } from '../../Channel/Channel'; export const useMarkRead = (channel: Channel) => { const { client } = useChatContext(); - // In case the channel is pending disposal, which may happen when the channel is deleted, - // underlying js client throws an error. Following function ensures that we don't - // result in an error in such a case. - const getChannelConfigSafely = () => { + // The channel's *resolved* configuration, not the raw server flag: `readEvents.enabled` is the + // channel type's `read_events` already ANDed with anything registered through + // `client.config.set({ channel: { readEvents } })`, so a client-side opt-out is honoured too. + // + // Read at call time rather than through `useStateStore`: `markRead` is an imperative callback, so it + // needs the current value, not a re-render when the value moves. Still wrapped, because a channel + // pending disposal makes the underlying client throw. + const getReadEventsEnabledSafely = () => { try { - return channel?.getConfig(); + return channel?.config.readEvents.enabled; } catch (_) { return null; } @@ -38,7 +42,7 @@ export const useMarkRead = (channel: Channel) => { // Read events disabled (e.g. livestreams): if the client opted into a local unread count, reset // it locally (dispatches message.read_locally) — no backend round trip. The paginator's unread // snapshot updates from that. - if (!getChannelConfigSafely()?.read_events) { + if (!getReadEventsEnabledSafely()) { if (client.options.isLocalUnreadCountEnabled) { channel.markReadLocally(); } diff --git a/package/src/components/ThreadList/ThreadMessagePreviewDeliveryStatus.tsx b/package/src/components/ThreadList/ThreadMessagePreviewDeliveryStatus.tsx index 6689e6b088..50cbec2564 100644 --- a/package/src/components/ThreadList/ThreadMessagePreviewDeliveryStatus.tsx +++ b/package/src/components/ThreadList/ThreadMessagePreviewDeliveryStatus.tsx @@ -1,16 +1,24 @@ import React, { useMemo } from 'react'; import { StyleSheet, Text, View } from 'react-native'; -import { Channel, LocalMessage } from 'stream-chat'; +import { Channel, ChannelConfig, LocalMessage } from 'stream-chat'; import { useChatContext } from '../../contexts/chatContext/ChatContext'; import { useComponentsContext } from '../../contexts/componentsContext/ComponentsContext'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { useTranslationContext } from '../../contexts/translationContext/TranslationContext'; import { MessageDeliveryStatus, useMessageDeliveryStatus } from '../../hooks'; +import { useStateStore } from '../../hooks/useStateStore'; import { primitives } from '../../theme'; import { MessageStatusTypes } from '../../utils/utils'; +/** + * Module scope so the reference stays stable — an inline selector re-subscribes on every render. + */ +const readEventsSelector = ({ readEvents }: ChannelConfig) => ({ + readEventsEnabled: readEvents.enabled, +}); + export type ThreadMessagePreviewDeliveryStatusProps = { channel: Channel; message: LocalMessage; @@ -23,7 +31,11 @@ export const ThreadMessagePreviewDeliveryStatus = ({ const { client } = useChatContext(); const { icons } = useComponentsContext(); const { t } = useTranslationContext(); - const channelConfigExists = typeof channel?.getConfig === 'function'; + // `configState` is absent on the partial channel mocks some tests pass in; a real channel always + // has one. Resolved configuration, so `read_events` is already ANDed with anything registered + // through `client.config.set({ channel: { readEvents } })`. + const configState = channel?.configState; + const { readEventsEnabled } = useStateStore(configState, readEventsSelector) ?? {}; const styles = useStyles(); const { theme: { @@ -45,16 +57,15 @@ export const ThreadMessagePreviewDeliveryStatus = ({ }, [message, client.user?.id]); const readEvents = useMemo(() => { - if (!channelConfigExists) { + if (!configState) { return true; } - const read_events = - !channel.pendingDisposal && !!channel?.id && channel.getConfig()?.read_events; + const read_events = !channel.pendingDisposal && !!channel?.id && readEventsEnabled; if (typeof read_events !== 'boolean') { return true; } return read_events; - }, [channelConfigExists, channel]); + }, [configState, readEventsEnabled, channel]); const { status } = useMessageDeliveryStatus({ channel, diff --git a/package/src/contexts/messageInputContext/MessageInputContext.tsx b/package/src/contexts/messageInputContext/MessageInputContext.tsx index b801a148f6..49927e6537 100644 --- a/package/src/contexts/messageInputContext/MessageInputContext.tsx +++ b/package/src/contexts/messageInputContext/MessageInputContext.tsx @@ -16,7 +16,6 @@ import { MessageRequest as StreamMessage, SendMessageOptions, UpdateMessageOptions, - UploadRequestFn, } from 'stream-chat'; import { useCreateMessageInputContext } from './hooks/useCreateMessageInputContext'; @@ -170,15 +169,6 @@ export type InputMessageInputContextValue = { */ createPollOptionGap?: number; - /** - * Override file upload request - * - * @param file File object - * - * @overrideType Function - */ - doFileUploadRequest?: UploadRequestFn; - /** * Handler for when the attach button is pressed. */ @@ -247,10 +237,6 @@ export const MessageInputProvider = ({ * the feature. */ useEffect(() => { - if (value.doFileUploadRequest) { - attachmentManager.setCustomUploadFn(value.doFileUploadRequest); - } - setupVideoAttachmentPreviewMiddleware(messageComposer); if (allowSendBeforeAttachmentsUpload) { @@ -262,12 +248,7 @@ export const MessageInputProvider = ({ createDraftAttachmentsCompositionMiddleware(messageComposer), ]); } - }, [ - value.doFileUploadRequest, - allowSendBeforeAttachmentsUpload, - messageComposer, - attachmentManager, - ]); + }, [allowSendBeforeAttachmentsUpload, messageComposer, attachmentManager]); /** * Function for capturing a photo and uploading it diff --git a/package/src/mock-builders/api/initiateClientWithChannels.ts b/package/src/mock-builders/api/initiateClientWithChannels.ts index 23e0df4a1c..4ada7b80da 100644 --- a/package/src/mock-builders/api/initiateClientWithChannels.ts +++ b/package/src/mock-builders/api/initiateClientWithChannels.ts @@ -1,4 +1,4 @@ -import type { Channel, StreamChat, UserResponse } from 'stream-chat'; +import type { Channel, ChannelConfigWithInfo, StreamChat, UserResponse } from 'stream-chat'; import { getOrCreateChannelApi } from './getOrCreateChannel'; import { useMockedApis } from './useMockedApis'; @@ -27,7 +27,18 @@ const initChannelFromData = async ({ useMockedApis(client, [getOrCreateChannelApi(mockedChannelData)]); const channel = client.channel(mockedChannelData.type, mockedChannelData.id); await channel.watch(); - jest.spyOn(channel, 'getConfig').mockImplementation(() => mockedChannelData.channel.config); + // Written into the client's store rather than stubbed onto the channel. `getConfig()` is gone, and its + // replacement `channel.serverConfig` is a getter over this store — `jest.spyOn` cannot stand in for an + // accessor. Going through the store also drives the channel's own derivation, so `channel.config` (where + // the server's gates are ANDed with anything registered through `client.config`) is correct too, which a + // stub would have left stale. Keyed by cid, matching the LLC: a channel's own `config_overrides` narrow + // its type's settings for that channel alone. + client.channelServerConfigsStore.partialNext({ + configs: { + ...client.channelServerConfigs, + [channel.cid]: mockedChannelData.channel.config as ChannelConfigWithInfo, + }, + }); // jest // .spyOn(channel, 'getDraft') // .mockImplementation(() => generateMessageDraft({ channel_cid: channel.cid })); diff --git a/package/src/mock-builders/event/utils.ts b/package/src/mock-builders/event/utils.ts new file mode 100644 index 0000000000..ca415de1b8 --- /dev/null +++ b/package/src/mock-builders/event/utils.ts @@ -0,0 +1,31 @@ +import type { Channel, ChannelResponse } from 'stream-chat'; + +/** + * Narrows a `Channel` instance to the response-shaped object the event dispatchers want. + * + * Tests routinely have a real `Channel` in hand and pass it straight into a dispatcher, which used to + * type-check by accident: `Channel` was structurally assignable to `Partial`. It no + * longer is, because `channel.config` is now the channel's *resolved* `ChannelConfig` while + * `ChannelResponse.config` is the server's `ChannelConfigWithInfo` — same property name, different type. + * + * The dispatchers only ever read `cid` / `id` / `type` off this argument, so projecting those three (over + * whatever `channel.data` carries) is both sufficient and closer to a real WS payload than handing the + * live instance to `dispatchEvent`. + */ +export const toChannelResponse = ( + channel: Channel | Partial, +): Partial => { + // `getClient` duck-types a `Channel` apart from a plain response object. It replaces `getConfig`, + // which used to serve this purpose and no longer exists. + if (typeof (channel as Channel).getClient !== 'function') { + return channel as Partial; + } + + const instance = channel as Channel; + return { + ...instance.data, + cid: instance.cid, + id: instance.id, + type: instance.type, + } as Partial; +}; From 432ef8182baa72045e75adb718231f073404b654 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Sat, 22 Aug 2026 00:58:04 +0200 Subject: [PATCH 2/5] fix: remove doUpdateMessageRequest --- .../offline-support/optimistic-update.tsx | 221 +++++++++++------- package/src/components/Channel/Channel.tsx | 23 +- .../hooks/useChannelRequestHandlers.ts | 39 +--- 3 files changed, 144 insertions(+), 139 deletions(-) diff --git a/package/src/__tests__/offline-support/optimistic-update.tsx b/package/src/__tests__/offline-support/optimistic-update.tsx index bae4a92cf3..ecb54974fa 100644 --- a/package/src/__tests__/offline-support/optimistic-update.tsx +++ b/package/src/__tests__/offline-support/optimistic-update.tsx @@ -11,6 +11,7 @@ import type { StreamChat, UserResponse, } from 'stream-chat'; +import { localMessageToNewMessagePayload } from 'stream-chat'; import { v4 as uuidv4 } from 'uuid'; import { Channel as ChannelRaw } from '../../components/Channel/Channel'; @@ -85,21 +86,27 @@ const markConnectionUnhealthy = (client: StreamChat) => { (client.wsConnection as unknown as { isHealthy: boolean }).isHealthy = false; }; -// The `` prop is wired into `channel.configState.requestHandlers` -// by `Channel`'s `useChannelRequestHandlers` effect. That parent effect runs AFTER the child -// test-callback effect (React flushes passive effects child-first), so a callback that calls -// `editMessage` synchronously on mount runs before the override is registered and silently takes -// the default (non-overridden) update path. In production a user can't edit a message in the -// sub-millisecond window before effects flush, so this is a test-only ordering artifact. Await -// this before triggering the edit so the real `doUpdateMessageRequest` path is exercised. -const waitForUpdateMessageHandler = async (channel: ChannelLLC) => { - for (let i = 0; i < 100; i++) { - if (channel.configState.getLatestValue().requestHandlers?.updateMessageRequest) { - return; - } - await new Promise((resolve) => setTimeout(resolve, 0)); - } -}; +// React flushes passive effects child-first, so the test-callback effect below runs BEFORE `Channel`'s +// own mount effects — verifiably: without this wait, `channel.configState.requestHandlers` at edit time +// holds only the declaratively-registered `updateMessageRequest`, with no `sendMessageRequest`, because +// `useChannelRequestHandlers` has not run yet. +// +// An operation fired from that window races `Channel`'s own offline-DB persistence of the initial query +// result. The in-memory paginator is fine either way; the DB row is not. Observed across repeat runs of +// the same test: the persisted `messages` row held the edited text on one run and the pre-edit text on +// the next, while the pending task was queued both times. Two writers, last write wins, no ordering +// guarantee between them. +// +// That window does not exist in production — the channel is queried and persisted before any message UI +// exists to edit, so a user cannot reach it. Only code editing from a child mount effect can, which is +// exactly what this harness does. +// +// It used to be hidden. While `doUpdateMessageRequest` was a prop, the callback polled `configState` +// until `Channel`'s effect had registered the handler; the first iteration found nothing and yielded a +// macrotask, which is what let the parent effects flush. Registering the handler declaratively removed +// the reason to poll and, accidentally, the barrier. So it is now explicit and named for what it does: +// one macrotask, matching the single yield the poll performed, not a sleep tuned until tests went green. +const flushMountEffects = () => new Promise((resolve) => setTimeout(resolve, 0)); test('Workaround to allow exporting tests', () => expect(true).toBe(true)); @@ -550,6 +557,51 @@ export const OptimisticUpdates = () => { const message = channel.messagePaginator.headItems[0]; const editedText = 'edited while offline'; + // Registered declaratively — the `` prop is gone. The LLC + // resolves this into `channel.configState.requestHandlers` as part of the channel's own + // derivation, so it is in place before `render` rather than after a mount effect. + chatClient.config.set({ + channel: { + requestHandlers: { + updateMessageRequest: (async ({ + localMessage, + options, + }: { + localMessage: LocalMessage; + options?: unknown; + }) => { + // The LLC hands over a `localMessage`; the prop received the `updateMessage` request + // shape `{ id, message }`. Rebuilt so the queued pending-task payload is unchanged. + const updatedMessage = { + id: localMessage.id, + message: localMessageToNewMessagePayload(localMessage), + }; + const editedMessage = { + ...message, + message_text_updated_at: new Date(), + text: editedText, + updated_at: new Date(), + }; + await getOfflineDb(chatClient).addPendingTask({ + channelId: channel.id, + channelType: channel.type, + messageId: message.id, + payload: [updatedMessage, options], + type: 'update-message', + }); + // A complete offline update handler persists the optimistic edit to the DB (so it + // survives cold start and the offline-DB hydration that Channel triggers on mount / + // sync-status change re-seeds the edited copy, not the pre-edit one). + await getOfflineDb(chatClient).upsertMessages({ + execute: true, + messages: [editedMessage], + }); + return { message: editedMessage }; + }) as never, + }, + }, + }); + render( { // v10 invokes doUpdateMessageRequest with the `updateMessage` request shape // `{ id, message }` (see useChannelRequestHandlers), not a flat LocalMessage. Echo a // server-shaped response reflecting the edit; the LLC's success path re-ingests it. - doUpdateMessageRequest={ - (async ( - _channelId: string, - updatedMessage: { id: string; message: LocalMessage }, - options: unknown, - ) => { - const editedMessage = { - ...message, - message_text_updated_at: new Date(), - text: editedText, - updated_at: new Date(), - }; - await getOfflineDb(chatClient).addPendingTask({ - channelId: channel.id, - channelType: channel.type, - messageId: message.id, - payload: [updatedMessage, options], - type: 'update-message', - }); - // A complete offline update handler persists the optimistic edit to the DB (so it - // survives cold start and the offline-DB hydration that Channel triggers on mount / - // sync-status change re-seeds the edited copy, not the pre-edit one). - await getOfflineDb(chatClient).upsertMessages({ - execute: true, - messages: [editedMessage], - }); - return { message: editedMessage }; - }) as unknown as React.ComponentProps['doUpdateMessageRequest'] - } > { - await waitForUpdateMessageHandler(channel); + await flushMountEffects(); await editMessage({ localMessage: { ...message, @@ -628,6 +651,23 @@ export const OptimisticUpdates = () => { const message = channel.messagePaginator.headItems[0]; const editedText = 'should stay optimistic'; + // Registered declaratively — the `` prop is gone. The LLC + // resolves this into `channel.configState.requestHandlers` as part of the channel's own + // derivation, so it is in place before `render` rather than after a mount effect. + chatClient.config.set({ + channel: { + requestHandlers: { + updateMessageRequest: (async () => { + await getOfflineDb(chatClient).upsertMessages({ + execute: true, + messages: [{ ...message, status: MessageStatusTypes.FAILED, text: editedText }], + }); + throw new Error('validation'); + }) as never, + }, + }, + }); + render( { // The local copy (state + DB) must survive so the user's edit is not lost, and so the // offline-DB hydration Channel runs on mount re-seeds the edited copy, not the pre-edit // one. - doUpdateMessageRequest={ - (async () => { - await getOfflineDb(chatClient).upsertMessages({ - execute: true, - messages: [{ ...message, status: MessageStatusTypes.FAILED, text: editedText }], - }); - throw new Error('validation'); - }) as unknown as React.ComponentProps['doUpdateMessageRequest'] - } > { - await waitForUpdateMessageHandler(channel); + await flushMountEffects(); try { await editMessage({ localMessage: { @@ -693,26 +724,32 @@ export const OptimisticUpdates = () => { const message = headItem as LocalMessage; const optimisticStateSpy = jest.fn(); + // Registered declaratively — the `` prop is gone. The LLC + // resolves this into `channel.configState.requestHandlers` as part of the channel's own + // derivation, so it is in place before `render` rather than after a mount effect. + chatClient.config.set({ + channel: { + requestHandlers: { + updateMessageRequest: (() => { + const optimisticMessage = channel.messagePaginator.getItem(message.id); + optimisticStateSpy(optimisticMessage); + + return { + message: { + ...optimisticMessage, + }, + }; + }) as never, + }, + }, + }); + render( - { - const optimisticMessage = channel.messagePaginator.getItem(message.id); - optimisticStateSpy(optimisticMessage); - - return { - message: { - ...optimisticMessage, - }, - }; - }) as unknown as React.ComponentProps['doUpdateMessageRequest'] - } - > + { - await waitForUpdateMessageHandler(channel); + await flushMountEffects(); await editMessage({ localMessage: { ...message, @@ -757,6 +794,30 @@ export const OptimisticUpdates = () => { }, ]; + // Registered declaratively — the `` prop is gone. The LLC + // resolves this into `channel.configState.requestHandlers` as part of the channel's own + // derivation, so it is in place before `render` rather than after a mount effect. + chatClient.config.set({ + channel: { + requestHandlers: { + updateMessageRequest: (async () => { + await getOfflineDb(chatClient).upsertMessages({ + execute: true, + messages: [ + { + ...message, + attachments: editedAttachments, + status: MessageStatusTypes.FAILED, + text: editedText, + }, + ], + }); + throw new Error('offline'); + }) as never, + }, + }, + }); + render( { // Persist the optimistic attachment edit locally, then reject the request (offline). The // local copy (state + DB, incl. the local attachment URL) must survive so the offline-DB // hydration Channel runs on mount re-seeds the edited copy, not the pre-edit one. - doUpdateMessageRequest={ - (async () => { - await getOfflineDb(chatClient).upsertMessages({ - execute: true, - messages: [ - { - ...message, - attachments: editedAttachments, - status: MessageStatusTypes.FAILED, - text: editedText, - }, - ], - }); - throw new Error('offline'); - }) as unknown as React.ComponentProps['doUpdateMessageRequest'] - } > { - await waitForUpdateMessageHandler(channel); + await flushMountEffects(); try { await editMessage({ localMessage: { diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index 14dcd8ca7e..6cb8123fac 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -10,11 +10,9 @@ import { MessageResponse, SendMessageAPIResponse, SendMessageOptions, - StreamChat, Event as StreamEvent, MessageRequest as StreamMessage, Thread, - UpdateMessageOptions, } from 'stream-chat'; import { useChannelRequestHandlers } from './hooks/useChannelRequestHandlers'; @@ -283,20 +281,6 @@ export type ChannelPropsWithContext = Pick & options?: SendMessageOptions, ) => Promise; - /** - * Overrides the Stream default update message request (Advanced usage only) - * - * Kept for now: removing it destabilises the offline-support edit tests, which pass in isolation - * but time out under full-suite contention when the handler is registered declaratively instead. - * Needs its own pass — see the note in `useChannelRequestHandlers`. - * @param channelId - * @param updatedMessage The update-message request payload - */ - doUpdateMessageRequest?: ( - channelId: string, - updatedMessage: Parameters[0], - options?: UpdateMessageOptions, - ) => ReturnType; /** * A method invoked just after the first optimistic update of a new message, * but before any other HTTP requests happen. Can be used to do extra work @@ -399,7 +383,6 @@ const ChannelWithContext = (props: PropsWithChildren) = disableTypingIndicator, dismissKeyboardOnMessageTouch = true, doSendMessageRequest, - doUpdateMessageRequest, preSendMessageRequest, enableMessageGroupingByUser = true, enableOfflineSupport, @@ -920,7 +903,6 @@ const ChannelWithContext = (props: PropsWithChildren) = channel, uploadPendingAttachments, doSendMessageRequest, - doUpdateMessageRequest, }); const sendMessage: InputMessageInputContextValue['sendMessage'] = useStableCallback( @@ -958,8 +940,9 @@ const ChannelWithContext = (props: PropsWithChildren) = throw new Error('Channel has not been initialized'); } // The LLC handles the optimistic local update (ingest into the paginator), the network - // request (honoring any doUpdateMessageRequest registered into channel.configState in - // useChannelRequestHandlers), the received/failed state transitions, and offline queueing. + // request (honoring any `updateMessageRequest` registered through + // `client.config.set({ channel: { requestHandlers } })`), the received/failed state transitions, + // and offline queueing. // Thread edits route through the thread instance's own message operations. await (threadInstance ?? channel).updateMessageWithLocalUpdate({ localMessage, options }); }, diff --git a/package/src/components/Channel/hooks/useChannelRequestHandlers.ts b/package/src/components/Channel/hooks/useChannelRequestHandlers.ts index 26ba7bcf22..770c934d30 100644 --- a/package/src/components/Channel/hooks/useChannelRequestHandlers.ts +++ b/package/src/components/Channel/hooks/useChannelRequestHandlers.ts @@ -4,12 +4,9 @@ import { Channel, ChannelConfig, LocalMessage, - localMessageToNewMessagePayload, MessageRequest as Message, SendMessageAPIResponse, SendMessageOptions, - StreamChat, - UpdateMessageOptions, } from 'stream-chat'; type RequestHandlers = NonNullable; @@ -22,12 +19,6 @@ export type ChannelRequestHandlersParams = { * stream-chat send pipeline (after the optimistic ingest, before the POST). */ uploadPendingAttachments?: (message: LocalMessage) => Promise; - /** Overrides the default update request. Mirrors the `` prop. */ - doUpdateMessageRequest?: ( - channelId: string, - updatedMessage: Parameters[0], - options?: UpdateMessageOptions, - ) => ReturnType; /** Overrides the default send/retry request. Mirrors the `` prop. */ doSendMessageRequest?: ( channelId: string, @@ -46,14 +37,14 @@ export type ChannelRequestHandlersParams = { * integrator's `doSendMessageRequest` when provided, and otherwise to `channel.sendMessage` (the client * default). * - * `markReadRequest` and `deleteMessageRequest` are **not** managed here — those are registered - * declaratively through `client.config.set({ channel: { requestHandlers } })` and the LLC resolves them - * per instance. The `` prop that used to feed mark-read is removed. + * `markReadRequest`, `updateMessageRequest` and `deleteMessageRequest` are **not** managed here — those + * are registered declaratively through `client.config.set({ channel: { requestHandlers } })` and the LLC + * resolves them per instance. The `` / `doUpdateMessageRequest` props that + * used to feed them are removed. * - * `updateMessageRequest` is still prop-fed (``) and so is still managed - * here. Moving it to the declarative route destabilised the offline-support edit tests — they pass in - * isolation but time out under full-suite contention — so it needs its own pass rather than riding along - * with this one. + * Nothing this hook does not own is deleted from the slot map, which matters: `delete`-ing a handler it + * merely *might* own is what silently dropped a declaratively-registered one, sending the operation down + * the LLC's default path — an unmocked request that simply hangs. * * Re-applied whenever the channel re-derives its configuration. `Channel.initializeConfig` *replaces* * `requestHandlers` from the declarative tree rather than merging into it, and it runs on every change @@ -66,7 +57,6 @@ export const useChannelRequestHandlers = ({ channel, uploadPendingAttachments, doSendMessageRequest, - doUpdateMessageRequest, }: ChannelRequestHandlersParams) => { useEffect(() => { // `configState` is a getter on `Channel.prototype` now (it delegates to the channel's @@ -113,23 +103,10 @@ export const useChannelRequestHandlers = ({ // used to drop an integrator's declaratively-registered handler on the floor. delete nextRequestHandlers.retrySendMessageRequest; delete nextRequestHandlers.sendMessageRequest; - delete nextRequestHandlers.updateMessageRequest; nextRequestHandlers.sendMessageRequest = sendMessageRequest; nextRequestHandlers.retrySendMessageRequest = sendMessageRequest; - if (doUpdateMessageRequest) { - nextRequestHandlers.updateMessageRequest = async ({ localMessage, options }) => ({ - message: ( - await doUpdateMessageRequest( - channel.cid, - { id: localMessage.id, message: localMessageToNewMessagePayload(localMessage) }, - options, - ) - ).message, - }); - } - configState.partialNext({ requestHandlers: Object.keys(nextRequestHandlers).length > 0 ? nextRequestHandlers : undefined, @@ -145,5 +122,5 @@ export const useChannelRequestHandlers = ({ if (requestHandlers?.sendMessageRequest === sendMessageRequest) return; applyRequestHandlers(); }); - }, [channel, uploadPendingAttachments, doSendMessageRequest, doUpdateMessageRequest]); + }, [channel, uploadPendingAttachments, doSendMessageRequest]); }; From ee1ed0b22da35beff610f6bc33385849b14895a2 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Sat, 22 Aug 2026 01:32:56 +0200 Subject: [PATCH 3/5] chore: add todo --- .../Channel/hooks/useChannelRequestHandlers.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/package/src/components/Channel/hooks/useChannelRequestHandlers.ts b/package/src/components/Channel/hooks/useChannelRequestHandlers.ts index 770c934d30..b74e9c6415 100644 --- a/package/src/components/Channel/hooks/useChannelRequestHandlers.ts +++ b/package/src/components/Channel/hooks/useChannelRequestHandlers.ts @@ -75,6 +75,18 @@ export const useChannelRequestHandlers = ({ // Built once per effect run rather than inside `applyRequestHandlers`, so its identity is stable // across re-applies — that identity is what the subscription below uses to tell "still ours" from // "dropped by a re-derivation". + // + // TODO: discuss with the team — request a `next`-shaped `sendMessageRequest` slot in `stream-chat`, + // then delete this wrapper and the `doSendMessageRequest` prop with it. + // + // Every other `do*Request` prop is gone; this one stays only because the SDK needs the slot itself + // (the upload step must run after the optimistic ingest, before the POST) so it has to wrap rather + // than be replaced. Two consequences: we hand-copy the LLC's default send below and nothing catches + // it drifting, and a declaratively-registered handler is never reached. + // + // `MessageOperations.send` already builds the fallback (`requestFn ?? handlers.send ?? + // defaults.send`) — it just does not pass it to the handler. With `next` available this collapses to + // "await uploads, call next". const sendMessageRequest: RequestHandlers['sendMessageRequest'] = async ({ localMessage, message, From add893e568a9204db2dc3245022e29b522a91e20 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Sat, 22 Aug 2026 01:57:32 +0200 Subject: [PATCH 4/5] fix: dispose location manager to prevent mem leak --- .../LiveLocationManagerContext.tsx | 1 + .../LiveLocationManagerContext.test.tsx | 66 +++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 package/src/contexts/liveLocationManagerContext/__tests__/LiveLocationManagerContext.test.tsx diff --git a/package/src/contexts/liveLocationManagerContext/LiveLocationManagerContext.tsx b/package/src/contexts/liveLocationManagerContext/LiveLocationManagerContext.tsx index 7172b939d1..1dfe1bde3a 100644 --- a/package/src/contexts/liveLocationManagerContext/LiveLocationManagerContext.tsx +++ b/package/src/contexts/liveLocationManagerContext/LiveLocationManagerContext.tsx @@ -50,6 +50,7 @@ export const LiveLocationManagerProvider = ( return () => { liveLocationManager.unregisterSubscriptions(); + liveLocationManager.dispose(); }; }, [liveLocationManager]); diff --git a/package/src/contexts/liveLocationManagerContext/__tests__/LiveLocationManagerContext.test.tsx b/package/src/contexts/liveLocationManagerContext/__tests__/LiveLocationManagerContext.test.tsx new file mode 100644 index 0000000000..6c228093ba --- /dev/null +++ b/package/src/contexts/liveLocationManagerContext/__tests__/LiveLocationManagerContext.test.tsx @@ -0,0 +1,66 @@ +import React from 'react'; + +import { render } from '@testing-library/react-native'; + +import { initiateClientWithChannels } from '../../../mock-builders/api/initiateClientWithChannels'; +import { useMockedApis } from '../../../mock-builders/api/useMockedApis'; +import { mockedApiResponse } from '../../../mock-builders/api/utils'; +import { ChatProvider } from '../../chatContext/ChatContext'; +import { LiveLocationManagerProvider } from '../LiveLocationManagerContext'; + +/** + * `LiveLocationManager` has two independent teardowns since the LLC's instance-configuration change, and + * the provider has to call both: + * + * - `unregisterSubscriptions()` — ref-counted, covers the event subscriptions. + * - `dispose()` — releases the `client.config` subscription the constructor registers. + * + * Missing the second leaks a handle in the configuration registry on every remount, with no compile + * error and no runtime symptom until memory is inspected. Hence a test rather than a comment. + */ +describe('LiveLocationManagerProvider teardown', () => { + /** `LiveLocationManager.init()` queries the user's live locations on mount; mock it to an empty set. */ + const mockLiveLocations = (client: Parameters[0]) => + useMockedApis(client, [mockedApiResponse({ active_live_locations: [] }, 'get')]); + + const renderProvider = async () => { + const { client } = await initiateClientWithChannels(); + mockLiveLocations(client); + const view = render( + + () => {}} /> + , + ); + return { client, view }; + }; + + it('releases the configuration subscription on unmount', async () => { + const { client, view } = await renderProvider(); + + // The manager registers itself against the `liveLocationManager` key in its constructor, so the + // registry reports a live instance while it is mounted. + expect(client.config.hasLiveInstances('liveLocationManager')).toBe(true); + + view.unmount(); + + expect(client.config.hasLiveInstances('liveLocationManager')).toBe(false); + }); + + it('does not accumulate handles across repeated mounts', async () => { + const { client } = await initiateClientWithChannels(); + mockLiveLocations(client); + + // React StrictMode's mount/unmount/mount runs this shape against one provider, and so does ordinary + // navigation away and back. Each cycle must leave the registry empty rather than one handle heavier. + for (let i = 0; i < 3; i++) { + const view = render( + + () => {}} /> + , + ); + expect(client.config.hasLiveInstances('liveLocationManager')).toBe(true); + view.unmount(); + expect(client.config.hasLiveInstances('liveLocationManager')).toBe(false); + } + }); +}); From c2711b8913f0df31bafd5480860216386b6dc4d3 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Sat, 22 Aug 2026 02:05:15 +0200 Subject: [PATCH 5/5] chore: update mig docs --- ai-docs/ai-migration-v9-to-v10.md | 249 ++++++++++++++++++++++++++---- 1 file changed, 222 insertions(+), 27 deletions(-) diff --git a/ai-docs/ai-migration-v9-to-v10.md b/ai-docs/ai-migration-v9-to-v10.md index 160ce6c064..e98c71d142 100644 --- a/ai-docs/ai-migration-v9-to-v10.md +++ b/ai-docs/ai-migration-v9-to-v10.md @@ -84,7 +84,14 @@ rg '\beditMessage\b' src/ rg '\b(FooterComponent|HeaderComponent|MessageList|MessageFlashList)\b' src/ # §13 — ChannelProps removed props -rg '` | `client.config.set({ channel: { requestHandlers: { markReadRequest } } })` | §13.1 | +| `` | `…{ requestHandlers: { updateMessageRequest } }` | §13.1 | +| `` | `client.config.set({ messageComposer: { attachments: { doUploadRequest } } })` | §13.1 | +| `` | `…{ channel: { messagePaginator: { stateThrottleMs } } }` | §13.1 | +| `channel.getConfig()` | `channel.serverConfig` (getter) — or `channel.config` for resolved gates | §13.1 | +| `client.configs[cid]` | `client.channelServerConfigs[cid]` | §13.1 | +| `channel.serverConfig?.typing_events` (and the other gated flags) | `channel.config.typingEvents.enabled` — resolved, server ANDed with yours | §13.1 | +| `client.setMessageComposerSetupFunction(fn)` | `client.config.setSetupFunction('messageComposer', fn)` | §13.1 | +| re-setting `channel.messagePaginator.pageSize` after mount | `client.config.set({ channel: { messagePaginator: { pageSize } } })` | §13.1, §16.1 | | `useTargetedMessage()` / `setTargetedMessage(id)` | `useChannelContext().loadChannelAroundMessage({ messageId })`; read `highlightedMessageId` | §6 | | `useChannelContext().channelUnreadStateStore` / `setChannelUnreadState` | `channel.messagePaginator.unreadStateSnapshot` | §7 | | `` | self-derived from `channel.state` `read` (override the component to control) | §7 | @@ -348,8 +364,9 @@ markRead(); // or, imperatively (no hook): await channel.markRead(); ``` -A custom `doMarkReadRequest` handler (passed as a `Channel` prop) is still -honored — see §13 for its retyped signature. +A custom mark-read handler is still honored, but it is no longer a `Channel` prop — +register it as `client.config.set({ channel: { requestHandlers: { markReadRequest } } })`. +See §13.1. ## 6. Message targeting / highlight removed → `messagePaginator.messageFocusSignal` @@ -650,22 +667,193 @@ Removed props: `messages`, `loadingMore`, `loadingMoreRecent`, `threadMessages`, `setThreadMessages` — message/thread-reply state now lives in the LLC paginator, so these are no longer inputs. Drop them. -`doMarkReadRequest` is retyped — its setter callback param is now -`(data: ChannelUnreadState | undefined) => void` (`ChannelUnreadState` is a -public `stream-chat` type, same shape): +Also removed, and covered in §13.1: + +| Removed prop | v10 replacement | +|---|---| +| `doMarkReadRequest` | `client.config.set({ channel: { requestHandlers: { markReadRequest } } })` | +| `doUpdateMessageRequest` | `client.config.set({ channel: { requestHandlers: { updateMessageRequest } } })` | +| `doFileUploadRequest` | `client.config.set({ messageComposer: { attachments: { doUploadRequest } } })` | +| `stateUpdateThrottleInterval` | `client.config.set({ channel: { messagePaginator: { stateThrottleMs } } })` | +| `newMessageStateUpdateThrottleInterval` | same as above | + +The two throttle props were declared but never read in v10 — they are now deleted +outright rather than left inert. `stateThrottleMs` is the real, reactive control. + +`doSendMessageRequest` is the one `do*Request` prop that **remains**. The SDK itself +occupies that handler slot to run the attachment-upload step inside the send pipeline, +so it wraps your handler rather than being replaced by it. Its `message` argument is +now typed `MessageRequest` (rename only; no shape change). + +## 13.1 Instance configuration → `client.config` + +v10's `stream-chat` ships a declarative configuration API for the objects the SDK builds +on your behalf — channels, threads, message composers, paginators, the client's own +managers. Several `` props are gone because this replaced them, and a number of +values that were previously unreachable are now settable. + +### Where to register it + +At the client, **not** from a component effect: + +```tsx +const client = StreamChat.getInstance(apiKey); + +client.config.set({ + channel: { + messagePaginator: { pageSize: 50, stateThrottleMs: 250 }, + readEvents: { enabled: false }, + }, + messageComposer: { + drafts: { enabled: true }, + attachments: { doUploadRequest: myUpload, customCdn: true }, + }, +}); +``` + +Some configuration is read once when an instance is constructed, and channels are +constructed by `client.channel()` / `client.queryChannels()` — which an app typically +calls before or during the same commit that mounts ``. Registering from a +`useEffect` runs after that, so those values arrive too late for instances that already +exist. There is deliberately no `` prop for this: binding registration to a +component lifecycle would recreate that ordering problem. + +### Request handlers + +The `doMarkReadRequest`, `doUpdateMessageRequest` and `doFileUploadRequest` props are +removed (§13). Register the handlers instead: + +```tsx +// v9 + + +// v10 +client.config.set({ + channel: { + requestHandlers: { + updateMessageRequest: async ({ localMessage, options }) => ({ + message: await myUpdate(localMessage, options), + }), + }, + }, +}); + +``` + +Three things change with it: + +- **The signature is the LLC's, not the prop's.** Handlers take a single params object + (`{ localMessage, options }`, plus `message` for send) and must return `{ message }`. + The props took positional arguments; an adapter inside the SDK filled in the rest. +- **Registration is per client**, not per mounted subtree. If you were passing different + handlers to different `` instances, branch inside one handler on the + `localMessage.cid` you receive. +- **Thread-scoped handlers** go under the `thread` key with the same shape. + +`doSendMessageRequest` stays a prop — see §13. + +### Behaviour, not values → setup functions + +Values go in `set()`. Reach for a setup function when what you are changing is behaviour +that no value can express — middleware, comparators: ```tsx -doMarkReadRequest?: ( - channel: Channel, - setChannelUnreadUiState?: (data: ChannelUnreadState | undefined) => void, -) => void; +client.config.setSetupFunction('messageComposer', ({ composer }) => { + setupCommandUIMiddlewares(composer); + composer.textComposer.middlewareExecutor.insert({ /* … */ }); +}); ``` -> Note: `stateUpdateThrottleInterval` and `newMessageStateUpdateThrottleInterval` -> are **not removed** — they remain declared on `ChannelProps` (the latter is -> `@deprecated`) but are **inert** (never read in v10; state updates are driven -> by the reactive stores). Passing them compiles but has no effect; remove them -> during cleanup. +This replaces `client.setMessageComposerSetupFunction(fn)`, which is deprecated in the +LLC. A setup function is also what makes a per-instance change survive +`client.config.reset()` — reset re-runs setup functions but discards imperative +`updateConfig()` calls made outside one. + +### Read the resolved value, never the raw server flag + +Several channel-type flags now resolve **into** the instance's own configuration, ANDed +with whatever you registered — either side can switch a feature off, neither can widen: + +| Read this | …instead of | +|---|---| +| `channel.config.typingEvents.enabled` | `channel.serverConfig?.typing_events` | +| `channel.config.readEvents.enabled` | `…?.read_events` | +| `channel.config.replies.enabled` | `…?.replies` | +| `channel.config.userMessageReminders.enabled` | `…?.user_message_reminders` | +| `channel.config.deliveryEvents.enabled` | `…?.delivery_events` | +| `channel.config.availableCommands` | `…?.commands` | +| `composer.config.attachments.enabled` | `…?.uploads` | +| `composer.config.polls.enabled` | `…?.polls` | +| `composer.config.linkPreviews.enabled` | `…?.url_enrichment` | +| `composer.config.location.enabled` | `…?.shared_locations` | +| `composer.config.text.maxLengthOnSend` | `…?.max_message_length` | + +Gating UI on the raw flag offers features the client has already disabled. Both +`channel.configState` and `composer.configState` are reactive stores, so +`useStateStore(channel.configState, selector)` re-renders when a value moves — define +the selector at module scope. + +`channel.getConfig()` is **removed**; `channel.serverConfig` is a getter returning the +same value. Note it is a getter, so `jest.spyOn(channel, 'getConfig')` has no direct +equivalent — write to `client.channelServerConfigsStore` in tests instead. + +`client.configs` is also gone → `client.channelServerConfigs`, still keyed by cid. + +### Two silent behaviour changes + +Neither produces a compile error. + +- **`linkPreviews.enabled` now defaults to `true`.** It was `false`, and the manager used + to AND the channel type's `url_enrichment` itself; that gate moved into resolved + configuration. Net effect: link previews turn **on** wherever enrichment is enabled + server-side. Opt out with + `client.config.set({ messageComposer: { linkPreviews: { enabled: false } } })`. +- **A custom `doUploadRequest` no longer waives the `upload-file` capability.** That + conflated *how* files are sent with *where* they land. If your uploads go to storage + Stream does not host, set `attachments: { customCdn: true }` — otherwise uploads are + refused for users without the capability and the attachment control disappears. + +### What this unlocks that no prop could + +These had no v9 equivalent — the SDK constructs the objects, so there was nothing to pass +a prop to: + +```tsx +client.config.set({ + messagePaginator: { stateThrottleMs: 250, retryCount: 2, lockItemOrder: true }, + channel: { messagePaginator: { pageSize: 50 }, pinnedMessagesPaginator: { pageSize: 25 } }, + thread: { messagePaginator: { pageSize: 25 } }, + client: { + notifications: { durations: { error: 10_000 } }, + reminders: { scheduledOffsetsMs: [5 * 60_000, 60 * 60_000] }, + messageDelivery: { markAsReadThrottleTimeoutMs: 2000 }, + }, + messageOperations: { failedSendCacheTtlMs: 5 * 60_000 }, +}); +``` + +The top-level `messagePaginator` key applies to **every** `MessagePaginator` — the channel +list and thread replies both — because one class backs both. The per-parent slices +(`channel.messagePaginator`, `thread.messagePaginator`) override it field by field, which +is how `pageSize` can differ while `stateThrottleMs` does not. + +`client.config.getTree()` dumps everything you have registered, without needing to know +the key names. + +### Caveats worth knowing + +- **`pageSize` is not `channelQueryOptions.messages.limit`.** The prop sizes the *initial* + channel query; `pageSize` sizes every *subsequent* page. They are different numbers and + you usually want both. +- **Imperative `updateConfig()` does not survive a re-derivation**, except on + `MessageComposer`. `channel.messagePaginator.updateConfig({ pageSize: 200 })` is dropped + the next time anything re-resolves that paginator's configuration — including a + `client.config.set()` on an unrelated key, because `Channel` watches `messagePaginator` + and `messageOperations`. Register the value, or use a setup function. +- **`X.config` is `Readonly`.** Assigning to a field is a compile error; nested writes + throw at runtime because the defaults are deep-frozen. Use `updateConfig()`. +- **``'s `lockChannelOrder` and `queryChannelsOverride` stay props.** There is + no configuration key that reaches a channel-list paginator, so these are unchanged. --- @@ -706,11 +894,14 @@ parameter shapes dropped the removed fields: ## 16. Behavioral changes (no symbol removed) -- **16.1 Initial message-list page size 100 → 25.** `Channel` sets - `channel.messagePaginator.pageSize = 25` on init (the `stream-chat` default is - 100). There is no public prop to override it; to change it, re-set - `channel.messagePaginator.pageSize` yourself after mount. Apps that assumed - ~100 messages loaded on open now get 25 and paginate the rest in. +- **16.1 Initial message-list page size 100 → 25.** The `stream-chat` default is 100; + v10 resolves 25. Apps that assumed ~100 messages loaded on open now get 25 and + paginate the rest in. To change it, **register** the value — + `client.config.set({ channel: { messagePaginator: { pageSize: 50 } } })` (§13.1). + Do **not** re-set `channel.messagePaginator.pageSize` after mount: that is an + imperative patch and is dropped the next time the paginator's configuration + re-resolves. Note this is separate from `channelQueryOptions.messages.limit`, which + sizes only the initial query. - **16.2 `sendMessage` throws on failure** (previously swallowed). The built-in `MessageInput` catches the rejection and shows a notification. Any custom code that calls the context `sendMessage` (or `channel.sendMessageWithLocalUpdate`) @@ -765,16 +956,20 @@ delete) still works. - `deleteMessage(msg, { hardDelete: true })` → `deleteMessage(msg, { hard: true })` - `deleteMessage(msg, { deleteForMe: true })` → `deleteMessage(msg, { delete_for_me: true })` -## 17.3 `` override signature +## 17.3 Update-message override moved off `` + +`doUpdateMessageRequest` is **removed as a prop** (§13). Register an `updateMessageRequest` +handler on `client.config` instead (§13.1). The signature is the LLC's, so it takes a single +params object rather than the prop's positional arguments: -The override now receives a request object, not a `LocalMessage`: +- v9 prop: `doUpdateMessageRequest(channelId, localMessage, options)` +- v10 handler: `updateMessageRequest({ localMessage, options })` → `{ message }` -- v9: `doUpdateMessageRequest(channelId, localMessage, options)` -- v10: `doUpdateMessageRequest(channelId, { id, message }, options)` — `message` is a - `MessageRequest` (derived via `localMessageToNewMessagePayload`), matching the LLC's default - update path. +If you need the old `{ id, message }` request shape inside your handler, derive it with +`localMessageToNewMessagePayload(localMessage)` — that is what the SDK's adapter used to do. -`doSendMessageRequest`'s `message` argument is now typed `MessageRequest` (rename only; no shape change). +`doSendMessageRequest` remains a prop; its `message` argument is now typed `MessageRequest` +(rename only; no shape change). ## 17.4 `message.moderation_details` → `message.moderation`