From 5c2585287a11b47d0864641b86a792f32dccc2db Mon Sep 17 00:00:00 2001 From: martincupela Date: Wed, 16 Sep 2026 15:38:05 +0200 Subject: [PATCH 1/4] fix(MessageList): render the notification area above the message source key Raise a notification, switch channel, and the toast restarted its countdown and replayed its entry animation -- so it read as a notification about the channel just opened, and could follow the user indefinitely. `MessageList` keys its inner component on the message source, which rebuilds the subtree on a channel or thread switch. That is deliberate: it is what stops the previous source's scroll position carrying over. The notification area was rendered inside that subtree and got reset with it. `NotificationList` tracks the ids it has already started a countdown for in a ref, so a fresh instance asked `NotificationManager` for a new timeout and lost the original deadline; the entry animation restarted because a remounted element is a newly inserted one. A notification reports something the user just did. It does not belong to the message source, so it does not belong inside a boundary that resets when the source changes. `MessageListMainPanel` and `NotificationList` now render in the outer component, beside the keyed element rather than within it, so a switch replaces only the list. The notification keeps its component instance, its timer bookkeeping and its DOM node, and nothing has to move to preserve them. For `VirtualizedMessageList` this also puts the panel above `if (!processedMessages) return null`, which used to take the notification area down with the list. The rendered DOM is unchanged: the panel holds the same children in the same order, with the notification list last. Covered by a new `MessageList notification area` block in `MessageList.test.tsx`: the element is the same across a switch, it expires on its original schedule however many channels are opened, and it is still displayed for a notification raised afterwards. `channelSwitchReset` and `threadSwitchReset` used the panel element as their witness that the list had been rebuilt. The panel is now outside the key by design, so they assert on `.str-chat__message-list` instead -- inside the key, and the element that actually carries the scroll state the key exists to reset. Co-Authored-By: Claude Opus 5 --- .../__tests__/channelSwitchReset.test.tsx | 19 +- src/components/MessageList/MessageList.tsx | 159 +++++------ .../MessageList/VirtualizedMessageList.tsx | 253 +++++++++--------- .../__tests__/MessageList.test.tsx | 215 +++++++++++++++ .../__tests__/threadSwitchReset.test.tsx | 15 +- 5 files changed, 440 insertions(+), 221 deletions(-) diff --git a/src/components/Channel/__tests__/channelSwitchReset.test.tsx b/src/components/Channel/__tests__/channelSwitchReset.test.tsx index 2537164f2..8846bcfa4 100644 --- a/src/components/Channel/__tests__/channelSwitchReset.test.tsx +++ b/src/components/Channel/__tests__/channelSwitchReset.test.tsx @@ -15,7 +15,6 @@ import { Channel } from '../Channel'; import { CHANNEL_CONTAINER_ID } from '../constants'; import { Chat } from '../../Chat'; import { MessageList } from '../../MessageList'; -import { MESSAGE_LIST_MAIN_PANEL_CLASS } from '../../MessageList/MessageListMainPanel'; import { initClientWithChannels } from '../../../mock-builders'; import type { Channel as ChannelType, StreamChat } from 'stream-chat'; @@ -29,8 +28,10 @@ const renderChannel = (client: StreamChat, channel: ChannelType) => ( ); const channelContainer = () => document.getElementById(CHANNEL_CONTAINER_ID); -const messageListPanel = () => - document.querySelector(`.${MESSAGE_LIST_MAIN_PANEL_CLASS.split(' ').join('.')}`); +// The list element, not the panel: the panel is rendered above the message list's key so the +// notification area it holds survives a switch, which makes it a poor witness to the rebuild. This +// one is inside the key, and it is what carries the scroll state the key exists to reset. +const messageListElement = () => document.querySelector('.str-chat__message-list'); const setupTwo = () => initClientWithChannels({ @@ -65,11 +66,11 @@ describe('switching channels', () => { } = await setupTwo(); const { rerender } = render(renderChannel(client, channelA)); - const panelBefore = messageListPanel(); + const listBefore = messageListElement(); rerender(renderChannel(client, channelB)); - expect(messageListPanel()).not.toBe(panelBefore); + expect(messageListElement()).not.toBe(listBefore); }); it('keeps the message list intact when the same channel re-renders', async () => { @@ -79,11 +80,11 @@ describe('switching channels', () => { } = await setupTwo(); const { rerender } = render(renderChannel(client, channelA)); - const panelBefore = messageListPanel(); + const listBefore = messageListElement(); rerender(renderChannel(client, channelA)); - expect(messageListPanel()).toBe(panelBefore); + expect(messageListElement()).toBe(listBefore); }); it('rebuilds the message list for a replacement instance of the same channel', async () => { @@ -95,10 +96,10 @@ describe('switching channels', () => { const second = client.channel('messaging', 'channel-a'); const { rerender } = render(renderChannel(client, first)); - const panelBefore = messageListPanel(); + const listBefore = messageListElement(); rerender(renderChannel(client, second)); - await waitFor(() => expect(messageListPanel()).not.toBe(panelBefore)); + await waitFor(() => expect(messageListElement()).not.toBe(listBefore)); }); }); diff --git a/src/components/MessageList/MessageList.tsx b/src/components/MessageList/MessageList.tsx index 813912a9f..6e7c0621a 100644 --- a/src/components/MessageList/MessageList.tsx +++ b/src/components/MessageList/MessageList.tsx @@ -119,10 +119,8 @@ const MessageListWithContext = (props: MessageListWithContextProps) => { const { EmptyStateIndicator = DefaultEmptyStateIndicator, LoadingIndicator = DefaultLoadingIndicator, - MessageListMainPanel = DefaultMessageListMainPanel, MessageListWrapper = 'ul', NewMessageNotification = DefaultNewMessageNotification, - NotificationList = DefaultNotificationList, TypingIndicator = DefaultTypingIndicator, UnreadMessagesNotification = DefaultUnreadMessagesNotification, } = useComponentContext(); @@ -170,8 +168,6 @@ const MessageListWithContext = (props: MessageListWithContextProps) => { messageListIsThread: isThreadList, }); - const notificationTarget = useNotificationTarget(); - useIncomingMessageAnnouncements({ activeThreadId: thread?.id, channel, @@ -355,14 +351,11 @@ const MessageListWithContext = (props: MessageListWithContextProps) => { }} > - - - {!isThreadList && showUnreadMessagesNotification && ( - - )} - {/*todo: apply styles + + {!isThreadList && showUnreadMessagesNotification && ( + + )} + {/*todo: apply styles .str-chat__list { overflow-y: hidden; } @@ -371,71 +364,67 @@ const MessageListWithContext = (props: MessageListWithContextProps) => { height: 100%; } */} - -
- {showEmptyStateIndicator ? ( - - ) : ( - - {threadHead} - {isLoading && ( -
- {props.loadingMore && } -
- )} - - {elements} - - - -
- - )} - +
+ {showEmptyStateIndicator ? ( + + ) : ( + - {/* An empty list has nothing to jump to — see the matching gate in - VirtualizedMessageList. */} - {messages.length > 0 && ( - + {threadHead} + {isLoading && ( +
+ {props.loadingMore && } +
+ )} + + {elements} + + 0} - onClick={scrollToBottomFromNotification} + scrollToBottom={scrollToBottom} /> - )} -
- - - + +
+ + )} + + {/* An empty list has nothing to jump to — see the matching gate in + VirtualizedMessageList. */} + {messages.length > 0 && ( + 0} + onClick={scrollToBottomFromNotification} + /> + )} +
+ ); @@ -546,12 +535,24 @@ export type MessageListProps = Partial export const MessageList = (props: MessageListProps) => { const channel = useChannel(); const thread = useThreadContext(); + const notificationTarget = useNotificationTarget(); + const { + MessageListMainPanel = DefaultMessageListMainPanel, + NotificationList = DefaultNotificationList, + } = useComponentContext(); - // Scroll position and the rest of this list's local state belong to whatever it is showing -- a - // thread's replies or a channel's messages -- so a different one starts from scratch. `Channel` - // and `Thread` used to provide this reset by remounting their entire subtree; it belongs here, - // where the state actually lives. + // The panel and the notification area sit *above* the key on purpose. + // + // Scroll position and the rest of the list's local state belong to whatever it is showing -- a + // thread's replies or a channel's messages -- so a different one starts from scratch. A + // notification does not: it reports something the user just did, and its countdown and entry + // animation have to outlive the switch. Rendering it here keeps the element, its timer and the + // panel box it is positioned against whole, without anything having to move. + // See specs/notification-list-stable-host/spec.md. return ( - + + + + ); }; diff --git a/src/components/MessageList/VirtualizedMessageList.tsx b/src/components/MessageList/VirtualizedMessageList.tsx index bf9de0367..d06711fc4 100644 --- a/src/components/MessageList/VirtualizedMessageList.tsx +++ b/src/components/MessageList/VirtualizedMessageList.tsx @@ -265,10 +265,8 @@ const VirtualizedMessageListWithContext = ( const { DateSeparator = DefaultDateSeparator, GiphyPreviewMessage = DefaultGiphyPreviewMessage, - MessageListMainPanel = DefaultMessageListMainPanel, MessageSystem = DefaultMessageSystem, NewMessageNotification = DefaultNewMessageNotification, - NotificationList = DefaultNotificationList, TypingIndicator, UnreadMessagesNotification = DefaultUnreadMessagesNotification, UnreadMessagesSeparator = DefaultUnreadMessagesSeparator, @@ -417,8 +415,6 @@ const VirtualizedMessageListWithContext = ( messageListIsThread: isThreadList, }); - const notificationTarget = useNotificationTarget(); - useIncomingMessageAnnouncements({ activeThreadId: thread?.id, channel, @@ -545,114 +541,107 @@ const VirtualizedMessageListWithContext = ( const list = ( - - - {!isThreadList && showUnreadMessagesNotification && ( - - )} -
+ {!isThreadList && showUnreadMessagesNotification && ( + + )} +
+ + + atBottomStateChange={atBottomStateChange} + atBottomThreshold={100} + atTopStateChange={atTopStateChange} + atTopThreshold={100} + className='str-chat__message-list-scroll' + components={{ + EmptyPlaceholder, + Header, + Item, + ...virtuosoComponentsFromProps, + }} + computeItemKey={computeItemKey} + context={{ + additionalMessageComposerProps, + channel, + closeReactionSelectorOnClick, + customClasses, + customMessageRenderer, + DateSeparator, + firstUnreadMessageId: channelUnreadUiState?.firstUnreadMessageId, + focusedMessageId, + formatDate, + head: threadHead, + lastOwnMessage, + lastReadDate: channelUnreadUiState?.lastReadAt, + lastReadMessageId: channelUnreadUiState?.lastReadMessageId, + lastReceivedMessageId, + loadingMore: isLoading, + messageGroupStyles, + MessageSystem, + numItemsPrepended, + ownMessagesDeliveredToOthers, + ownMessagesReadByOthers, + processedMessages, + reactionDetailsSort, + renderText, + returnAllReadData, + shouldGroupByUser, + showAvatar, + sortReactions, + unreadMessageCount: channelUnreadUiState?.unreadCount, + UnreadMessagesSeparator, + virtuosoRef: virtuoso, + }} + firstItemIndex={calculateFirstItemIndex(numItemsPrepended)} + followOutput={followOutput} + increaseViewportBy={{ bottom: 200, top: 0 }} + initialTopMostItemIndex={calculateInitialTopMostItemIndex( + processedMessages, + focusedMessageId, + )} + itemContent={messageRenderer} + itemSize={fractionalItemSize} + itemsRendered={handleItemsRendered} + key={messageSetKey} + overscan={overscan} + ref={virtuoso} + style={{ overflowX: 'hidden' }} + totalCount={processedMessages.length} + {...overridingVirtuosoProps} + {...(scrollSeekPlaceHolder ? { scrollSeek: scrollSeekPlaceHolder } : {})} + {...(defaultItemHeight ? { defaultItemHeight } : {})} + /> + - - - atBottomStateChange={atBottomStateChange} - atBottomThreshold={100} - atTopStateChange={atTopStateChange} - atTopThreshold={100} - className='str-chat__message-list-scroll' - components={{ - EmptyPlaceholder, - Header, - Item, - ...virtuosoComponentsFromProps, - }} - computeItemKey={computeItemKey} - context={{ - additionalMessageComposerProps, - channel, - closeReactionSelectorOnClick, - customClasses, - customMessageRenderer, - DateSeparator, - firstUnreadMessageId: channelUnreadUiState?.firstUnreadMessageId, - focusedMessageId, - formatDate, - head: threadHead, - lastOwnMessage, - lastReadDate: channelUnreadUiState?.lastReadAt, - lastReadMessageId: channelUnreadUiState?.lastReadMessageId, - lastReceivedMessageId, - loadingMore: isLoading, - messageGroupStyles, - MessageSystem, - numItemsPrepended, - ownMessagesDeliveredToOthers, - ownMessagesReadByOthers, - processedMessages, - reactionDetailsSort, - renderText, - returnAllReadData, - shouldGroupByUser, - showAvatar, - sortReactions, - unreadMessageCount: channelUnreadUiState?.unreadCount, - UnreadMessagesSeparator, - virtuosoRef: virtuoso, - }} - firstItemIndex={calculateFirstItemIndex(numItemsPrepended)} - followOutput={followOutput} - increaseViewportBy={{ bottom: 200, top: 0 }} - initialTopMostItemIndex={calculateInitialTopMostItemIndex( - processedMessages, - focusedMessageId, - )} - itemContent={messageRenderer} - itemSize={fractionalItemSize} - itemsRendered={handleItemsRendered} - key={messageSetKey} - overscan={overscan} - ref={virtuoso} - style={{ overflowX: 'hidden' }} - totalCount={processedMessages.length} - {...overridingVirtuosoProps} - {...(scrollSeekPlaceHolder ? { scrollSeek: scrollSeekPlaceHolder } : {})} - {...(defaultItemHeight ? { defaultItemHeight } : {})} - /> - - {/* An empty list has nothing to jump to. Gate on the message count rather than on + /> + {/* An empty list has nothing to jump to. Gate on the message count rather than on scroll position alone: a list with no content can legitimately report "not at bottom", which would otherwise render a dead affordance. */} - {messages.length > 0 && ( - 0} - onClick={scrollToBottom} - /> - )} -
- - {TypingIndicator && ( - - )} - - + {messages.length > 0 && ( + 0} + onClick={scrollToBottom} + /> + )} +
+
+ {TypingIndicator && ( + + )} {giphyPreviewMessage && }
@@ -767,29 +756,41 @@ export type VirtualizedMessageListProps = Partial< export function VirtualizedMessageList(props: VirtualizedMessageListProps) { const channel = useChannel(); const thread = useThreadContext(); + const notificationTarget = useNotificationTarget(); + const { + MessageListMainPanel = DefaultMessageListMainPanel, + NotificationList = DefaultNotificationList, + } = useComponentContext(); const { read } = useStateStore(channel?.state, channelReadSelector) ?? {}; const messages = props.messages; // || contextMessages; + // See the note in `MessageList`: the panel and the notification area sit above the key, so a + // notification keeps its element, its countdown and its box across a switch. Here it also puts + // them above `VirtualizedMessageListWithContext`'s `if (!processedMessages) return null`, which + // would otherwise take the notification area down with the list. // todo: finalize the props shape for VirtualizedMessageList return ( - + + + + ); } diff --git a/src/components/MessageList/__tests__/MessageList.test.tsx b/src/components/MessageList/__tests__/MessageList.test.tsx index 32572959e..009ca7ee7 100644 --- a/src/components/MessageList/__tests__/MessageList.test.tsx +++ b/src/components/MessageList/__tests__/MessageList.test.tsx @@ -19,6 +19,8 @@ import { import { Chat } from '../../Chat'; import { MessageList } from '../MessageList'; import { Channel } from '../../Channel'; +import { CHANNEL_CONTAINER_ID } from '../../Channel/constants'; +import { MESSAGE_LIST_MAIN_PANEL_CLASS } from '../MessageListMainPanel'; import { ThreadProvider } from '../../Threads'; import { useChannel, useMessageContext, WithComponents } from '../../../context'; import { EmptyStateIndicator as EmptyStateIndicatorMock } from '../../EmptyStateIndicator'; @@ -1784,3 +1786,216 @@ describe('MessageList', () => { }); }); }); + +// The notification area is rendered here, above `MessageList`'s own +// `key={getMessageSourceKey(...)}`. A channel or thread switch rebuilds the list inside that key +// without touching the notification, which keeps its element, its countdown and the panel box it +// is positioned against. See specs/notification-list-stable-host/spec.md +describe('MessageList notification area', () => { + const NOTIFICATION_DURATION = 3000; + + const renderChannel = (client: StreamChat, channel: ChannelType) => ( + + + + + + ); + + const currentPanel = () => + document.querySelector(`.${MESSAGE_LIST_MAIN_PANEL_CLASS.split(' ').join('.')}`); + + const advanceBy = (ms: number) => + act(async () => { + vi.advanceTimersByTime(ms); + await Promise.resolve(); + }); + + // `NotificationList` starts a notification's countdown when the list intersects the viewport, and + // starts it immediately where there is no `IntersectionObserver` -- the path these tests take, + // since jsdom reports no intersections. An earlier describe in this file installs a stub on + // `window` without restoring it, so drop it here rather than inheriting one that never fires. + let inheritedIntersectionObserver: typeof IntersectionObserver | undefined; + + beforeEach(() => { + inheritedIntersectionObserver = window.IntersectionObserver; + // @ts-expect-error deliberately absent for these tests + delete window.IntersectionObserver; + }); + + afterEach(() => { + if (inheritedIntersectionObserver) { + window.IntersectionObserver = inheritedIntersectionObserver; + } + }); + + // https://github.com/GetStream/stream-chat-react/issues/3279 + describe('the notification area across a channel switch', () => { + const setup = async () => { + const { + channels: [channelA, channelB], + client, + } = await initClientWithChannels({ + channelsData: [ + { channel: { id: 'channel-a', type: 'messaging' } }, + { channel: { id: 'channel-b', type: 'messaging' } }, + ], + }); + + return { channelA, channelB, client }; + }; + + const raiseUploadBlocked = (client: StreamChat) => + act(() => { + client.notifications.addError({ + message: 'The attachment upload was blocked', + options: { type: 'validation:attachment:upload:blocked' }, + origin: { emitter: 'AttachmentManager' }, + }); + }); + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('keeps the very same element, so nothing is remounted or re-animated', async () => { + const { channelA, channelB, client } = await setup(); + + const { rerender } = render(renderChannel(client, channelA)); + raiseUploadBlocked(client); + const before = screen.getByTestId('notification-list'); + + rerender(renderChannel(client, channelB)); + + expect(screen.getByTestId('notification-list')).toBe(before); + }); + + it('moves into the newly mounted message list, keeping its positioning', async () => { + const { channelA, channelB, client } = await setup(); + + const { rerender } = render(renderChannel(client, channelA)); + raiseUploadBlocked(client); + + rerender(renderChannel(client, channelB)); + + // Whichever panel is now on screen holds the notification, which is what keeps + // `position: absolute` resolving against the message list's box. Deliberately does not assert + // that the panel element changed: whether the channel subtree remounts is not this mechanism's + // business, and it should keep working if that ever stops happening. + expect(currentPanel()).toContainElement(screen.getByTestId('notification-list')); + expect(screen.getAllByTestId('notification-list')).toHaveLength(1); + }); + + it('expires on its original schedule rather than starting over', async () => { + const { channelA, channelB, client } = await setup(); + + const { rerender } = render(renderChannel(client, channelA)); + raiseUploadBlocked(client); + + await advanceBy(NOTIFICATION_DURATION / 2); + rerender(renderChannel(client, channelB)); + expect(client.notifications.notifications).toHaveLength(1); + + await advanceBy(NOTIFICATION_DURATION / 2); + + expect(client.notifications.notifications).toHaveLength(0); + }); + + it('survives no longer than its duration however many channels the user opens', async () => { + const { channelA, channelB, client } = await setup(); + + const { rerender } = render(renderChannel(client, channelA)); + raiseUploadBlocked(client); + + for (let i = 0; i < 5; i++) { + await advanceBy(NOTIFICATION_DURATION / 3); + rerender(renderChannel(client, i % 2 === 0 ? channelB : channelA)); + } + + expect(client.notifications.notifications).toHaveLength(0); + }); + + it('still displays a notification raised after the switch', async () => { + const { channelA, channelB, client } = await setup(); + + const { rerender } = render(renderChannel(client, channelA)); + rerender(renderChannel(client, channelB)); + raiseUploadBlocked(client); + + expect(screen.getByTestId('notification-list')).toBeInTheDocument(); + expect(currentPanel()).toContainElement(screen.getByTestId('notification-list')); + }); + }); + + // The notification area belongs to the message list, so it exists exactly when one is mounted -- + // as it did before this change. These pin that boundary: a switch does not lose it, and a channel + // that never had a list does not gain one. + describe('the notification area and the message list that owns it', () => { + const setup = async () => { + const { + channels: [channelA, channelB], + client, + } = await initClientWithChannels({ + channelsData: [ + { channel: { id: 'channel-a', type: 'messaging' } }, + { channel: { id: 'channel-b', type: 'messaging' } }, + ], + }); + + return { channelA, channelB, client }; + }; + + const raise = (client: StreamChat) => + act(() => { + client.notifications.addError({ + message: 'The attachment upload was blocked', + options: { type: 'validation:attachment:upload:blocked' }, + origin: { emitter: 'AttachmentManager' }, + }); + }); + + const container = () => document.getElementById(CHANNEL_CONTAINER_ID); + + const renderWithList = ( + client: StreamChat, + channel: ChannelType, + withList: boolean, + ) => ( + + {withList ? :
} + + ); + + it('stays on screen when the channel being opened is still bootstrapping', async () => { + const { channelA, client } = await setup(); + // Never watched: `Channel` renders it without querying, so the switch commits immediately. + const bootstrapping = client.channel('messaging', 'never-watched'); + + const { rerender } = render(renderChannel(client, channelA)); + raise(client); + const node = screen.getByTestId('notification-list'); + + rerender(renderChannel(client, bootstrapping)); + + expect(bootstrapping.initialized).toBe(false); + expect(node.isConnected).toBe(true); + expect(container()).toContainElement(node); + }); + + it('displays nothing for a panel that has never been mounted, as before', async () => { + const { channelA, client } = await setup(); + + render(renderWithList(client, channelA, false)); + raise(client); + + // No message list has ever existed here, so there is no panel wrapper for the fallback to + // catch. Unchanged from before this mechanism: nothing rendered a notification either. + expect(screen.queryByTestId('notification-list')).not.toBeInTheDocument(); + expect(client.notifications.notifications).toHaveLength(1); + }); + }); +}); diff --git a/src/components/Thread/__tests__/threadSwitchReset.test.tsx b/src/components/Thread/__tests__/threadSwitchReset.test.tsx index 1da832995..cec303853 100644 --- a/src/components/Thread/__tests__/threadSwitchReset.test.tsx +++ b/src/components/Thread/__tests__/threadSwitchReset.test.tsx @@ -14,14 +14,15 @@ import { fromPartial } from '@total-typescript/shoehorn'; import { Channel } from '../../Channel'; import { Chat } from '../../Chat'; import { MessageList } from '../../MessageList'; -import { MESSAGE_LIST_MAIN_PANEL_CLASS } from '../../MessageList/MessageListMainPanel'; import { ThreadProvider } from '../../Threads'; import { initClientWithChannels } from '../../../mock-builders'; import type { Channel as ChannelType, StreamChat, Thread } from 'stream-chat'; -const messageListPanel = () => - document.querySelector(`.${MESSAGE_LIST_MAIN_PANEL_CLASS.split(' ').join('.')}`); +// The list element, not the panel: the panel is rendered above the message list's key so the +// notification area it holds survives a switch, which makes it a poor witness to the rebuild. This +// one is inside the key, and it is what carries the scroll state the key exists to reset. +const messageListElement = () => document.querySelector('.str-chat__message-list'); const renderThread = ({ channel, @@ -73,22 +74,22 @@ describe('switching threads within one channel', () => { const { channel, client, threadA, threadB } = await setup(); const { rerender } = render(renderThread({ channel, client, thread: threadA })); - const panelBefore = messageListPanel(); + const listBefore = messageListElement(); rerender(renderThread({ channel, client, thread: threadB })); - expect(messageListPanel()).not.toBe(panelBefore); + expect(messageListElement()).not.toBe(listBefore); }); it('keeps the message list intact when the same thread re-renders', async () => { const { channel, client, threadA } = await setup(); const { rerender } = render(renderThread({ channel, client, thread: threadA })); - const panelBefore = messageListPanel(); + const listBefore = messageListElement(); rerender(renderThread({ channel, client, thread: threadA })); - expect(messageListPanel()).toBe(panelBefore); + expect(messageListElement()).toBe(listBefore); }); it('does not rebuild the channel subtree around the thread', async () => { From 2071aa55a42b58ba7e400c860e4a5fd15451720f Mon Sep 17 00:00:00 2001 From: martincupela Date: Wed, 16 Sep 2026 15:48:19 +0200 Subject: [PATCH 2/4] fix(Notifications): show an upload failure where the upload was started An attachment rejected by the size limit while typing in a thread surfaced in the channel, not the thread. Notifications are routed to a panel by a `target:` tag or `origin.context.panel`, both of which the React SDK attaches when it publishes through `useNotificationApi`. `stream-chat` publishes these itself, so they carried no target at all, and `isNotificationForPanel` resolves an untargeted notification to `fallbackPanel ?? 'channel'`. The missing information only exists at the emit site: `AttachmentManager` belongs to a `MessageComposer`, and that composer is what says whether the upload was started in a thread or a channel. `stream-chat` now carries it in `origin.context` (as several composer-scoped emitters already did), so the panel is derived from its `contextType`. A panel remains the SDK's vocabulary -- the client names the composer, not the surface. Behaviour change worth noting: a composer-raised notification is no longer untargeted, so a `NotificationList` configured with a `fallbackPanel` no longer claims it. That is the point -- it belongs to one panel now -- but it is visible to anyone relying on the fallback to collect these. Inert until the `stream-chat` change ships: without the composer in the context the notification stays untargeted and falls back to the channel panel exactly as before. Co-Authored-By: Claude Opus 5 --- .../__tests__/notificationOrigin.test.ts | 54 +++++++++++++++++++ .../Notifications/notificationTarget.ts | 27 +++++++++- 2 files changed, 79 insertions(+), 2 deletions(-) diff --git a/src/components/Notifications/__tests__/notificationOrigin.test.ts b/src/components/Notifications/__tests__/notificationOrigin.test.ts index 7013ca943..246208ce4 100644 --- a/src/components/Notifications/__tests__/notificationOrigin.test.ts +++ b/src/components/Notifications/__tests__/notificationOrigin.test.ts @@ -45,6 +45,20 @@ const multiTaggedNotification = (tags: string[]) => tags, }) as Notification; +const composerNotification = (contextType?: unknown) => + ({ + createdAt: Date.now(), + id: 'n1', + message: 'test', + origin: { + // `stream-chat` puts the composer that raised an upload notification here; see + // `AttachmentManager` and the attachment upload middleware. + context: { composer: { contextType } }, + emitter: 'AttachmentManager', + }, + severity: 'error', + }) as Notification; + describe('notificationOrigin helpers', () => { it('recognizes supported panel values', () => { expect(isNotificationTargetPanel('channel')).toBe(true); @@ -117,3 +131,43 @@ describe('notificationOrigin helpers', () => { ); }); }); + +describe('the panel implied by the raising composer', () => { + it('routes a thread composer to the thread panel', () => { + expect(getNotificationTargetPanel(composerNotification('thread'))).toBe('thread'); + expect(getNotificationTargetPanels(composerNotification('thread'))).toEqual([ + 'thread', + ]); + }); + + it('routes a channel composer to the channel panel', () => { + expect(getNotificationTargetPanel(composerNotification('channel'))).toBe('channel'); + }); + + it('implies no panel for a composition context that has none', () => { + // `message` and `legacy_thread` are composition contexts without a surface of their own. + expect(getNotificationTargetPanel(composerNotification('message'))).toBeUndefined(); + expect(getNotificationTargetPanel(composerNotification(undefined))).toBeUndefined(); + expect(getNotificationTargetPanels(composerNotification('message'))).toEqual([]); + }); + + it('is outranked by an explicit target', () => { + const explicit = { + ...composerNotification('thread'), + tags: ['target:channel'], + } as Notification; + + expect(getNotificationTargetPanel(explicit)).toBe('channel'); + }); + + it('makes a composer-raised notification targeted, so it ignores a fallback panel', () => { + // Worth pinning: before the composer was carried, this notification was untargeted and any + // list claimed it through its `fallbackPanel`. It now belongs to one panel. + const raisedInThread = composerNotification('thread'); + + expect(isNotificationForPanel(raisedInThread, 'thread')).toBe(true); + expect( + isNotificationForPanel(raisedInThread, 'channel', { fallbackPanel: 'channel' }), + ).toBe(false); + }); +}); diff --git a/src/components/Notifications/notificationTarget.ts b/src/components/Notifications/notificationTarget.ts index 20b3cafea..6284109b1 100644 --- a/src/components/Notifications/notificationTarget.ts +++ b/src/components/Notifications/notificationTarget.ts @@ -20,6 +20,25 @@ export const isNotificationTargetPanel = ( typeof value === 'string' && (NOTIFICATION_TARGET_PANELS as readonly string[]).includes(value); +/** + * Panel implied by the composer that raised a notification. `stream-chat` cannot name a panel -- it + * has no notion of one -- but every composer-scoped emitter puts the composer in `origin.context`, + * and a thread composer's upload failure belongs in that thread rather than in the channel the + * thread hangs off. Only the two contexts that correspond to a panel are mapped; `message` and + * `legacy_thread` have none. + */ +const getPanelFromComposerContext = ( + notification: Notification, +): NotificationTargetPanel | undefined => { + const composer = notification.origin.context?.composer as + | { contextType?: unknown } + | undefined; + + if (composer?.contextType === 'thread') return 'thread'; + if (composer?.contextType === 'channel') return 'channel'; + return undefined; +}; + export const getNotificationTargetPanel = ( notification: Notification, ): NotificationTargetPanel | undefined => { @@ -29,7 +48,8 @@ export const getNotificationTargetPanel = ( if (isNotificationTargetPanel(candidate)) return candidate; } const panel = notification.origin.context?.panel; - return isNotificationTargetPanel(panel) ? panel : undefined; + if (isNotificationTargetPanel(panel)) return panel; + return getPanelFromComposerContext(notification); }; export const getNotificationTargetPanels = ( @@ -47,7 +67,10 @@ export const getNotificationTargetPanels = ( } const panel = notification.origin.context?.panel; - return isNotificationTargetPanel(panel) ? [panel] : []; + if (isNotificationTargetPanel(panel)) return [panel]; + + const composerPanel = getPanelFromComposerContext(notification); + return composerPanel ? [composerPanel] : []; }; export const getNotificationTargetTag = (panel: NotificationTargetPanel) => From 231b0380adf63e30c93adc01786cd3fd0335f603 Mon Sep 17 00:00:00 2001 From: martincupela Date: Wed, 16 Sep 2026 16:26:57 +0200 Subject: [PATCH 3/4] refactor(Notifications): let the manager own the start-once guarantee `NotificationList` kept a ref of the notification ids it had already started a countdown for, because `startTimeout` always restarts: call it twice and the notification silently gets another full lifetime. That record was load-bearing and lived in the wrong place -- a component that remounts starts with an empty one, calls again, and the notification outlives its duration while still looking like it is counting down. It is what made a channel switch able to keep a toast on screen indefinitely. `stream-chat` now offers `ensureTimeout`, which starts a countdown only when none is running, so the guarantee belongs to the thing that owns the deadline. `useNotificationApi` exposes it as `ensureNotificationTimeout` beside the existing `startNotificationTimeout`, which is unchanged and still restarts for callers that mean to. With that, the ref goes, along with the cleanup in `dismiss`, the effect pruning stale entries on every store change, and the delete in the exit handler. The list asks once per intersection and lets the manager decide. The remount that made this matter is already fixed -- the notification area no longer sits inside the message list's key -- so this removes the fallback rather than the bug. Requires a `stream-chat` release carrying `ensureTimeout`; the peer and dev ranges need bumping to it before this can build against the published package. Co-Authored-By: Claude Opus 5 --- .../Notifications/NotificationList.tsx | 34 ++++--------------- .../__tests__/NotificationList.test.tsx | 22 +++++++----- .../Notifications/hooks/useNotificationApi.ts | 14 ++++++++ 3 files changed, 33 insertions(+), 37 deletions(-) diff --git a/src/components/Notifications/NotificationList.tsx b/src/components/Notifications/NotificationList.tsx index 252526f2f..afa4b02fa 100644 --- a/src/components/Notifications/NotificationList.tsx +++ b/src/components/Notifications/NotificationList.tsx @@ -245,7 +245,7 @@ export const NotificationList = ({ const { Notification: NotificationComponent = DefaultNotification } = useComponentContext(); const { t } = useTranslationContext(); - const { removeNotification, startNotificationTimeout } = useNotificationApi(); + const { ensureNotificationTimeout, removeNotification } = useNotificationApi(); // Holds the timer that runs the exit animation. Set when we flip `transitionState` to // `'exit'`; when it fires, the previously displayed notification is removed and either the // next candidate is mounted (entering) or the list collapses to empty. Only one exit @@ -260,11 +260,6 @@ export const NotificationList = ({ const displayedAtRef = useRef(null); const listRef = useRef(null); const observedElementRef = useRef(null); - const startedTimeoutIdsRef = useRef | null>(null); - - if (!startedTimeoutIdsRef.current) { - startedTimeoutIdsRef.current = new Set(); - } const [displayedNotification, setDisplayedNotification] = useState( null, @@ -286,23 +281,11 @@ export const NotificationList = ({ const dismiss = useCallback( (id: string) => { - startedTimeoutIdsRef.current?.delete(id); removeNotification(id); }, [removeNotification], ); - // Drop any stale entries from the started-timeouts set whenever the store changes. - useEffect(() => { - const notificationIds = new Set(notifications.map(({ id }) => id)); - - startedTimeoutIdsRef.current?.forEach((id) => { - if (!notificationIds.has(id)) { - startedTimeoutIdsRef.current?.delete(id); - } - }); - }, [notifications]); - const clearReplacementTimeout = useCallback(() => { if (replacementTimeoutRef.current !== null) { window.clearTimeout(replacementTimeoutRef.current); @@ -412,7 +395,6 @@ export const NotificationList = ({ if (wasInStore) { // Remove the previously displayed notification from the store so it does not // re-appear, and so its NotificationManager auto-dismiss timer is cleared. - startedTimeoutIdsRef.current?.delete(previousId); removeNotification(previousId); } // Read the latest candidate at the moment the exit animation actually completes — @@ -483,15 +465,11 @@ export const NotificationList = ({ const element = observedElementRef.current; if (!element || !notification || transitionState === 'exit') return; + // `ensureTimeout` leaves a running countdown alone, so this can be called whenever the + // notification becomes visible without tracking which ones have been started already -- and + // without a second view of the same notification pushing its deadline out. const startTimeout = () => { - if ( - !startedTimeoutIdsRef.current || - startedTimeoutIdsRef.current.has(notification.id) - ) - return; - - startedTimeoutIdsRef.current.add(notification.id); - startNotificationTimeout(notification.id); + ensureNotificationTimeout(notification.id); }; if (typeof IntersectionObserver === 'undefined') { @@ -518,7 +496,7 @@ export const NotificationList = ({ return () => { observer.disconnect(); }; - }, [notification, startNotificationTimeout, transitionState]); + }, [ensureNotificationTimeout, notification, transitionState]); if (!notification) return null; diff --git a/src/components/Notifications/__tests__/NotificationList.test.tsx b/src/components/Notifications/__tests__/NotificationList.test.tsx index b50431822..3b7850e2e 100644 --- a/src/components/Notifications/__tests__/NotificationList.test.tsx +++ b/src/components/Notifications/__tests__/NotificationList.test.tsx @@ -57,7 +57,7 @@ const mockedUseNotificationApi = vi.mocked(useNotificationApi); const mockedUseNotifications = vi.mocked(useNotifications); const remove = vi.fn(); -const startTimeout = vi.fn(); +const ensureTimeout = vi.fn(); const EXIT_ANIMATION_MS = 340; const DEFAULT_MIN_DISPLAY_MS = 1000; @@ -123,8 +123,9 @@ describe('NotificationList', () => { mockedUseNotificationApi.mockReturnValue({ addNotification: vi.fn(), addSystemNotification: vi.fn(), + ensureNotificationTimeout: ensureTimeout, removeNotification: remove, - startNotificationTimeout: startTimeout, + startNotificationTimeout: vi.fn(), }); remove.mockImplementation((id: string) => { currentNotifications = currentNotifications.filter( @@ -149,18 +150,18 @@ describe('NotificationList', () => { afterEach(() => { vi.useRealTimers(); remove.mockReset(); - startTimeout.mockReset(); + ensureTimeout.mockReset(); mockedUseNotificationApi.mockReset(); mockedUseNotifications.mockReset(); delete window['IntersectionObserver']; }); - it('starts a timeout only when the displayed notification first intersects', () => { + it('asks for a timeout when the displayed notification intersects', () => { currentNotifications = [transientFixture()]; render(); - expect(startTimeout).not.toHaveBeenCalled(); + expect(ensureTimeout).not.toHaveBeenCalled(); expect(observerEntries).toHaveLength(1); expect(screen.getByTestId('notification-list')).toHaveClass( 'str-chat__notification-list--position-bottom', @@ -172,8 +173,11 @@ describe('NotificationList', () => { triggerLatestIntersection(); triggerLatestIntersection(); - expect(startTimeout).toHaveBeenCalledTimes(1); - expect(startTimeout).toHaveBeenCalledWith('n-1'); + // Asked once per intersection, deliberately: `ensureTimeout` leaves a countdown that is + // already running alone, so the list does not track which ids it has started. Keeping that + // record here is what let a remounted list restart a notification's countdown. + expect(ensureTimeout).toHaveBeenCalledTimes(2); + expect(ensureTimeout).toHaveBeenCalledWith('n-1'); }); it('starts timeouts immediately when IntersectionObserver is not available', () => { @@ -182,8 +186,8 @@ describe('NotificationList', () => { render(); - expect(startTimeout).toHaveBeenCalledTimes(1); - expect(startTimeout).toHaveBeenNthCalledWith(1, 'n-1'); + expect(ensureTimeout).toHaveBeenCalledTimes(1); + expect(ensureTimeout).toHaveBeenNthCalledWith(1, 'n-1'); }); it('shows untargeted notifications in the channel panel by default', () => { diff --git a/src/components/Notifications/hooks/useNotificationApi.ts b/src/components/Notifications/hooks/useNotificationApi.ts index bb4d01d70..dfc343483 100644 --- a/src/components/Notifications/hooks/useNotificationApi.ts +++ b/src/components/Notifications/hooks/useNotificationApi.ts @@ -88,10 +88,13 @@ export type AddNotification = (params: AddNotificationParams) => void; export type AddSystemNotification = (params: AddSystemNotificationParams) => string; export type RemoveNotification = (id: string) => void; export type StartNotificationTimeout = (id: string) => void; +/** Starts a notification's countdown unless one is already running. */ +export type EnsureNotificationTimeout = (id: string) => void; export type NotificationApi = { addNotification: AddNotification; addSystemNotification: AddSystemNotification; + ensureNotificationTimeout: EnsureNotificationTimeout; removeNotification: RemoveNotification; startNotificationTimeout: StartNotificationTimeout; }; @@ -247,9 +250,20 @@ export const useNotificationApi = (): NotificationApi => { [client], ); + // Starting is the manager's to make idempotent: a caller that cannot know whether the countdown + // is already running -- a component that remounts, a second view of the same notification -- + // would otherwise hand it another full lifetime and lose the original deadline. + const ensureNotificationTimeout: EnsureNotificationTimeout = useCallback( + (id) => { + client.notifications.ensureTimeout(id); + }, + [client], + ); + return { addNotification, addSystemNotification, + ensureNotificationTimeout, removeNotification, startNotificationTimeout, }; From 21d7fc234389cd3894f6a692060ce60e22d4628d Mon Sep 17 00:00:00 2001 From: martincupela Date: Wed, 16 Sep 2026 17:38:37 +0200 Subject: [PATCH 4/4] chore(deps): upgrade stream-chat to v10.0.0-rc.10 --- examples/tutorial/package.json | 2 +- examples/vite/package.json | 2 +- package.json | 2 +- yarn.lock | 14 +++++++------- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/examples/tutorial/package.json b/examples/tutorial/package.json index 1f3438a5b..7c3161e09 100644 --- a/examples/tutorial/package.json +++ b/examples/tutorial/package.json @@ -16,7 +16,7 @@ "emoji-mart": "^5.6.0", "react": "^19.2.6", "react-dom": "^19.2.6", - "stream-chat": "^10.0.0-rc.10", + "stream-chat": "10.0.0-rc.11", "stream-chat-react": "workspace:^" }, "devDependencies": { diff --git a/examples/vite/package.json b/examples/vite/package.json index 2b35d46c1..7b2d34d36 100644 --- a/examples/vite/package.json +++ b/examples/vite/package.json @@ -18,7 +18,7 @@ "modern-normalize": "^3.0.1", "react": "^19.2.6", "react-dom": "^19.2.6", - "stream-chat": "^10.0.0-rc.10", + "stream-chat": "10.0.0-rc.11", "stream-chat-react": "workspace:^" }, "devDependencies": { diff --git a/package.json b/package.json index 9eac7e695..d3526a2f7 100644 --- a/package.json +++ b/package.json @@ -201,7 +201,7 @@ "react-dom": "^19.2.6", "sass": "^1.100.0", "semantic-release": "^25.0.3", - "stream-chat": "^10.0.0-rc.10", + "stream-chat": "10.0.0-rc.11", "typescript": "^6.0.3", "typescript-eslint": "^8.59.4", "vite": "^8.1.3", diff --git a/yarn.lock b/yarn.lock index 64559363d..ddfc7253d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1861,7 +1861,7 @@ __metadata: emoji-mart: "npm:^5.6.0" react: "npm:^19.2.6" react-dom: "npm:^19.2.6" - stream-chat: "npm:^10.0.0-rc.10" + stream-chat: "npm:10.0.0-rc.11" stream-chat-react: "workspace:^" typescript: "npm:^6.0.3" vite: "npm:^8.1.3" @@ -1889,7 +1889,7 @@ __metadata: react: "npm:^19.2.6" react-dom: "npm:^19.2.6" sass: "npm:^1.100.0" - stream-chat: "npm:^10.0.0-rc.10" + stream-chat: "npm:10.0.0-rc.11" stream-chat-react: "workspace:^" typescript: "npm:^6.0.3" vite: "npm:^8.1.3" @@ -9551,7 +9551,7 @@ __metadata: remark-parse: "npm:^11.0.0" sass: "npm:^1.100.0" semantic-release: "npm:^25.0.3" - stream-chat: "npm:^10.0.0-rc.10" + stream-chat: "npm:10.0.0-rc.11" typescript: "npm:^6.0.3" typescript-eslint: "npm:^8.59.4" unified: "npm:^11.0.5" @@ -9595,9 +9595,9 @@ __metadata: languageName: unknown linkType: soft -"stream-chat@npm:^10.0.0-rc.10": - version: 10.0.0-rc.10 - resolution: "stream-chat@npm:10.0.0-rc.10" +"stream-chat@npm:10.0.0-rc.11": + version: 10.0.0-rc.11 + resolution: "stream-chat@npm:10.0.0-rc.11" dependencies: "@stream-io/logger": "npm:^2.0.0" "@stream-io/state-store": "npm:^1.1.6" @@ -9608,7 +9608,7 @@ __metadata: built: true husky: built: true - checksum: 10c0/00eb4de2b390f633a7b7e2613a6a2aa30543c6e0bcda96bbf10ac257cef07bd14b78e2c5677ad36c2b91d64d820baff63fa65782581f568755a9984f18843dc0 + checksum: 10c0/681c8dd92559843474ea26c12ea3e07b23dda254bfbc82295f1db326b210e2064d2911e8315bcce78b0606ef836c34ab890918aed0e831efb903dac8f25b1721 languageName: node linkType: hard