From cfdaf2812f6b5448ba04cb73a861f24322a82499 Mon Sep 17 00:00:00 2001 From: martincupela Date: Thu, 5 Nov 2026 20:50:07 +0100 Subject: [PATCH 1/3] fix(ChannelListHeader)!: list headers take StartContent / EndContent props ChannelListHeader and ThreadListHeader take StartContent and EndContent props, rendered whenever they are set. Apps pass them by overriding the header through the ChannelListHeader key or the new ThreadListHeader key, which ThreadList now reads. ThreadListHeader is exported. BREAKING CHANGE: ComponentContext.HeaderEndContent is removed. It filled the end of both list headers, and only while a channel (channel list) or a thread (thread list) was open. Pass EndContent to each header instead; content that should show only next to an open channel or thread returns null otherwise. See BC-099 in ai-docs/v15-breaking-changes.md and "List headers: HeaderEndContent becomes an EndContent prop" in ai-docs/ai-migration-v14-v15.md. --- ai-docs/ai-migration-v14-v15.md | 90 ++++++++++++++++--- ai-docs/v15-breaking-changes.md | 40 +++++++-- .../layout/collapsible-sidebar/views.tsx | 16 +++- examples/vite/src/App.tsx | 4 +- .../SwitchableChannelNavigation.tsx | 3 +- examples/vite/src/Sidebar/HeaderContent.tsx | 26 +++++- .../ChannelList/ChannelListHeader.tsx | 25 +++--- .../__tests__/ChannelListHeader.test.tsx | 66 ++++++-------- .../Threads/ThreadList/ThreadList.tsx | 3 +- .../Threads/ThreadList/ThreadListHeader.tsx | 22 ++--- .../ThreadList/__tests__/ThreadList.test.tsx | 11 +++ .../__tests__/ThreadListHeader.test.tsx | 66 ++++++-------- src/components/Threads/ThreadList/index.ts | 1 + src/context/ComponentContext.tsx | 15 ++-- 14 files changed, 262 insertions(+), 126 deletions(-) diff --git a/ai-docs/ai-migration-v14-v15.md b/ai-docs/ai-migration-v14-v15.md index 87150e7a5a..43cbcade77 100644 --- a/ai-docs/ai-migration-v14-v15.md +++ b/ai-docs/ai-migration-v14-v15.md @@ -145,7 +145,7 @@ code '\b(HeaderStartContent|HeaderEndContent)\b|<(Channel|Thread)Header\b' # §9 channel state and actions code '\b(ChannelStateContext|ChannelStateContextValue|ChannelStateProvider|useChannelStateContext|ChannelActionContext|ChannelActionContextValue|ChannelActionProvider|useChannelActionContext|ChannelNotifications|MarkReadWrapperOptions|RetrySendMessage|TypingContext|TypingContextValue|TypingProvider|useTypingContext|ChannelUnreadUiState|channelUnreadUiState|setChannelUnreadUiState|channelConfig|WatcherState)\b|getConfig\(|\.(read|typing|members|watcher|ownCapabilities|mutedUsers)Store\b|\.disconnected\b|\.(hasNext|hasPrev)\b' # §10 component overrides, context hook arguments, message actions -code '\bMessage=\{|\b(Message|ThreadHeader|HeaderStartContent|ChannelListUI|LoadingErrorIndicator):|use[A-Za-z]+Context\([^)]|\b(messageActions|MESSAGE_ACTIONS|OPTIONAL_MESSAGE_ACTIONS|getMessageActions|MessageActionsArray|Capabilities|actionsEnabled|canEdit|messageActionSet|disableBaseMessageActionSetFilter)\b' +code '\bMessage=\{|\b(Message|ThreadHeader|HeaderStartContent|HeaderEndContent|ChannelListUI|LoadingErrorIndicator):|use[A-Za-z]+Context\([^)]|\b(messageActions|MESSAGE_ACTIONS|OPTIONAL_MESSAGE_ACTIONS|getMessageActions|MessageActionsArray|Capabilities|actionsEnabled|canEdit|messageActionSet|disableBaseMessageActionSetFilter)\b' # §11 message lists code '\bmessages=\{|\b(loadMore|loadMoreNewer|hasMore|hasMoreNewer|loadingMoreNewer|jumpToLatestMessage|messageLimit|highlightedMessageId|threadList|disableDateSeparator|renderMessages|RenderMessagesOptions|getIsFirstUnreadMessage|additionalVirtuosoProps|headerPosition|insertIntro|DateSeparatorMessage|useEnrichedMessages|useNewMessageNotification|useScrollLocationLogic|areMessagePropsEqual|useMessageListScrollManager|NewMessageNotification|ScrollToLatestMessageButton|InfiniteScrollPaginator|loadNextDebounceMs|loadMoreDebounceMs|internalInfiniteScrollProps|noGroupByUser|shouldGroupByUser|groupStyles|defaultItemHeight|overscan|scrollSeekPlaceHolder)\b|\bhead=\{' # §12 messages @@ -221,6 +221,7 @@ Old → new for the most common changes. Ctrl-F the v14 name; the section has th | `Thread` `additionalMessageListProps` and the other `additional*Props` | Props on the children you render | [7](#thread-takes-a-thread-prop-and-renders-its-children) | | `ThreadHeaderProps.closeThread` / `.thread` | `useCloseThread()` / `useThreadContext()` | [7](#threadheader-lost-closethread-and-thread) | | `HeaderStartContent` override | `StartContent` prop on `ChannelHeader` / `ThreadHeader` | [8](#8-channel-and-thread-headers) | +| `HeaderEndContent` override | `EndContent` prop on `ChannelListHeader` / `ThreadListHeader`, through their override keys | [8](#list-headers-headerendcontent-becomes-an-endcontent-prop) | | `useChannelStateContext()` | `useChannel()`, then `useStateStore(channel.messagePaginator.state, selector)` or `useStateStore(channel.state, selector)` | [9](#channelstatecontext-and-channelactioncontext-are-removed) | | `loadMore()` / `loadMoreNewer()` / `hasMore` / `hasMoreNewer` | `messagePaginator.toTail()` / `.toHead()` / `.hasMoreTail` / `.hasMoreHead` | [9](#channelstatecontext-and-channelactioncontext-are-removed) | | `jumpToMessage(id)` / `jumpToLatestMessage()` / `jumpToFirstUnreadMessage()` | `messagePaginator.jumpToMessage(id)` / `.jumpToTheLatestMessage()` / `.jumpToTheFirstUnreadMessage()` | [9](#channelstatecontext-and-channelactioncontext-are-removed) | @@ -297,7 +298,7 @@ These compile cleanly, or are ignored in plain JS, and change behavior at runtim | Translations or overrides stop applying; non-English UI renders English | Keys renamed, non-English dictionaries removed | Rename keys, register dictionaries ([section 18](#18-i18n)) | `registerTranslation`, `Translations`, `language:` | | CSS on `.str-chat__main-panel`, `.str-chat__container`, `.str-chat__channel-list-inner` or `.str-chat__thread--virtualized` stops applying | Those wrappers and classes are gone | Grep for them, combinators included, and retarget ([section 19](#19-css-and-markup)) | The section 19 `styles` pattern | | Plain JS: `useCreateChatClient({ options })` stops applying `baseURL` and other settings | `options` was renamed to `config` | [Section 2](#usecreatechatclient-options--config) | `useCreateChatClient` | -| Plain JS: a removed prop or override key (`Message`, `HeaderStartContent`, `do*Request`, `overrideSubmitHandler`, ...) does nothing | Ignored without a warning | Grep for each removed name listed in this guide | The [leftover-identifier grep](#verification) | +| Plain JS: a removed prop or override key (`Message`, `HeaderStartContent`, `HeaderEndContent`, `do*Request`, `overrideSubmitHandler`, ...) does nothing | Ignored without a warning | Grep for each removed name listed in this guide | The [leftover-identifier grep](#verification) | | Plain JS: a mention click handler, a custom `renderText` or a custom mention renderer gets `undefined` where it read the mentioned users, and users passed to `mentionsMarkdownPlugin` render without `data-user-id` | The `mentioned_users` / `mentionedUsers` arguments and `node.mentionedUser` are removed, arguments after them moved up, and the plugin takes mention entities only | Read the metadata from `message` or `options.messageMentionEntities` ([section 12](#rendertext-takes-text-options)); pass `getRenderTextMentionEntities(message)` to `mentionsMarkdownPlugin` ([section 12](#mentionsmarkdownplugin-takes-mention-entities-only)) | `mentioned_users`, `mentionedUsers`, `mentionedUser`, `mentionsMarkdownPlugin` | | Without `onMentionsClick`, a message with mentions is no longer a separate focus stop. Plain JS: a custom message UI that calls `onMentionsClickMessage(event)` throws | `useMessageContext()` sets `onMentionsClickMessage` / `onMentionsHoverMessage` only when `onMentionsClick` / `onMentionsHover` is passed | Pass `onMentionsClick` to `MessageList` or `Message` (`VirtualizedMessageList` has none: render `` from its `customMessageRenderer`, and from a `ThreadHead` override for the thread's parent message), and call the handlers with `?.` ([section 12](#onmentionsclickmessage-and-onmentionshovermessage-are-undefined-without-a-handler)) | `onMentionsClickMessage`, `onMentionsHoverMessage` | | A supplied composer keeps its content after `MessageComposer` unmounts | A supplied composer is never cleared on unmount | Call `composer.clear()` yourself ([section 13](#preventclearingonunmount-is-removed)) | `MessageComposerControllerProvider` | @@ -768,7 +769,6 @@ In v14, `ChatContext.setActiveChannel` selected a channel and `ChannelActionCont - a message's reply action and reply-count button, "also sent in channel": `openThread({ channel, message })` - `ThreadHeader`'s close button, `useCloseThread()`: `closeThread(threadId)` - `ChannelHeader` / `ThreadHeader` back and close buttons: `canGoBack`, `goBack`, `isChannelDismissable`, `isThreadDismissable` -- `ChannelListHeader` shows `HeaderEndContent` only while `openChannels` is non-empty The channel-detail "send direct message" member action and the pinned-message entry (from `stream-chat-react/channel-detail`) call `openChannel(channel)` too. @@ -1612,7 +1612,7 @@ v14's `` rendered `` aro - The buttons act on the panel they render in. A layout that renders its own panels declares them with `WorkspacePanelProvider`. Without a workspace navigation (outside `ChatView`, with no provider), nothing can be dismissed, so they render nothing. - Passing a component replaces the default button. Render `WorkspaceNavigationBackButton` / `WorkspaceNavigationCloseButton` inside yours to keep it. - `ChannelHeader` also takes `Title`, which replaces only the title and receives `{ channel, displayTitle }` (`ChannelHeaderTitleProps`; the default is `ChannelHeaderTitle`). Render the title as a button there instead of rebuilding the header. `Subtitle` likewise replaces only the subtitle (default `ChannelHeaderSubtitle`). A header you build yourself keeps the default subtitle by rendering `ChannelHeaderSubtitle` (Online/Offline for a direct message, member and online counts otherwise, the typing indicator while someone types), or reads the status text with `useChannelHeaderOnlineStatus()`. -- `ComponentContext.HeaderEndContent` is unchanged and still feeds `ChannelListHeader` / `ThreadListHeader`, but `ChannelListHeader` shows it only while a channel is open in the workspace. +- The channel-list and thread-list headers take `StartContent` / `EndContent` props too: [below](#list-headers-headerendcontent-becomes-an-endcontent-prop). ```tsx // v14 @@ -1644,6 +1644,68 @@ export const ChannelPanel = ({ channel }: { channel: StreamChannel }) => ( ); ``` +### List headers: `HeaderEndContent` becomes an `EndContent` prop + +`ComponentContext.HeaderEndContent` is removed (`TS2353` / `TS2561`; in JS the key is ignored and the content disappears). In v14 one component filled the end of both list headers, and showed only while a channel was open (channel list) or a thread was open (thread list). + +`ChannelListHeader` and `ThreadListHeader` now take the content as props, like `ChannelHeader` and `ThreadHeader` do. Neither prop has a default, and both render whenever they are set: + +| Prop | Shows | +| -------------- | ---------------------- | +| `StartContent` | Before the title (new) | +| `EndContent` | After the title | + +The SDK renders both list headers itself, so pass the props through the header's override key: register a component that renders the default header with your content. The keys are `ChannelListHeader` (read by `ChannelNavigation`) and `ThreadListHeader` (new, read by `ThreadList`). Both headers are exported. + +To keep the v14 behavior of showing the content only next to an open channel or thread (a sidebar toggle, for example), make the content return `null` otherwise: read `openChannels` / `openThreads` from `useWorkspaceNavigation()`. + +```tsx +// v14 +{/* … */} +``` + +```tsx +import { + ChannelListHeader, + ThreadListHeader, + useWorkspaceNavigation, + WithComponents, +} from 'stream-chat-react'; + +// `SidebarToggle` stands for a control of your own. +const SidebarToggle = () => ; + +// Shown only while a channel is open, as in v14. +const SidebarToggleWhileChannelOpen = () => { + const { openChannels } = useWorkspaceNavigation(); + return openChannels.length > 0 ? : null; +}; + +// Shown only while a thread is open, as in v14. +const SidebarToggleWhileThreadOpen = () => { + const { openThreads } = useWorkspaceNavigation(); + return openThreads.length > 0 ? : null; +}; + +const ChannelListHeaderWithToggle = () => ( + +); + +const ThreadListHeaderWithToggle = () => ( + +); + +// Declared once, so WithComponents gets the same object on every render. +const overrides = { + ChannelListHeader: ChannelListHeaderWithToggle, + ThreadListHeader: ThreadListHeaderWithToggle, +}; + +export const App = ({ children }: { children: React.ReactNode }) => ( + {children} +); +``` + --- ## 9. Reading channel state @@ -1868,15 +1930,16 @@ These are `stream-chat` changes, documented in its own migration notes. They are ### Removed `ComponentContext` slots -A stale key in `WithComponents overrides` is a compile error (`TS2353` / `TS2561`). In plain JS or behind `any` it is ignored with no warning, and the default UI renders (or nothing, for `HeaderStartContent`). +A stale key in `WithComponents overrides` is a compile error (`TS2353` / `TS2561`). In plain JS or behind `any` it is ignored with no warning, and the default UI renders (or nothing, for `HeaderStartContent` and `HeaderEndContent`). -| Removed slot | v15 | -| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `Message` | `MessageUI` ([below](#message-ui-overrides-consolidate-on-the-messageui-slot)) | -| `ThreadHeader` | Render `` (or your own) as a child of `` ([section 7](#threadheader-lost-closethread-and-thread)) | -| `HeaderStartContent` | `StartContent` on `ChannelHeader` / `ThreadHeader` ([section 8](#8-channel-and-thread-headers)) | -| `ChannelListUI` | The list slots ([section 6](#channellistui-is-removed-use-the-list-slots)) | -| `LoadingErrorIndicator` | Render the exported `LoadingErrorIndicator` yourself, around `Channel` ([section 5](#channel-requires-channel-and-renders-no-placeholder-loading-or-error-state)) | +| Removed slot | v15 | +| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `Message` | `MessageUI` ([below](#message-ui-overrides-consolidate-on-the-messageui-slot)) | +| `ThreadHeader` | Render `` (or your own) as a child of `` ([section 7](#threadheader-lost-closethread-and-thread)) | +| `HeaderStartContent` | `StartContent` on `ChannelHeader` / `ThreadHeader` ([section 8](#8-channel-and-thread-headers)) | +| `HeaderEndContent` | `EndContent` on `ChannelListHeader` / `ThreadListHeader`, through the `ChannelListHeader` / `ThreadListHeader` keys ([section 8](#list-headers-headerendcontent-becomes-an-endcontent-prop)) | +| `ChannelListUI` | The list slots ([section 6](#channellistui-is-removed-use-the-list-slots)) | +| `LoadingErrorIndicator` | Render the exported `LoadingErrorIndicator` yourself, around `Channel` ([section 5](#channel-requires-channel-and-renders-no-placeholder-loading-or-error-state)) | TypeScript suggests `ThreadHead` for a stale `ThreadHeader` key. Do not follow it: `ThreadHead` is the thread's parent-message slot ([section 7](#threadheader-lost-closethread-and-thread)). @@ -3394,7 +3457,7 @@ Run after every section is applied. 2. **Leftover identifiers.** Every v14 name below was removed or renamed. In a plain-JS app this grep is the only thing that finds them. It must print nothing outside comments, changelogs and app identifiers that only share the name (record those in the report). Uses the `code` / `styles` helpers from [Detection](#detection). ```bash - code '\b(WindowProps|ChannelStateContext|ChannelStateContextValue|ChannelStateProvider|useChannelStateContext|ChannelActionContext|ChannelActionContextValue|ChannelActionProvider|useChannelActionContext|ChannelNotifications|MarkReadWrapperOptions|RetrySendMessage|TypingContext|TypingContextValue|TypingProvider|useTypingContext|ChannelUnreadUiState|channelUnreadUiState|setChannelUnreadUiState|updateChannelUiUnreadState|useThreadsViewContext|ThreadsViewContextValue|ThreadAdapter|activeChatView|setActiveChatView|setActiveChannel|setActiveChannelOnMount|customActiveChannel|channelsQueryState|latestMessageDatesByChannels|useChannelEditMessageHandler|useChannelMentionsHandler|ChannelListUI|ChannelListUIProps|usePaginatedChannels|useConnectionRecoveredListener|CustomQueryChannelParams|CustomQueryChannelsFn|EffectiveQueryParams|moveChannelUpwards|findLastPinnedChannelIndex|extractSortValue|isChannelPinned|isChannelArchived|shouldConsiderPinnedChannels|shouldConsiderArchivedChannels|allowNewMessagesFromUnfilteredChannels|lockChannelOrder|showChannelSearch|sendChannelsToList|renderChannels|channelRenderFilterFn|customQueryChannels|recoveryThrottleIntervalMs|onMessageNewHandler|channelUpdateCount|useLegacyThreadContext|additionalMessageComposerProps|additionalMessageListProps|additionalVirtualizedMessageListProps|additionalParentMessageProps|enableDateSeparator|disableDateSeparator|HeaderStartContent|MESSAGE_ACTIONS|OPTIONAL_MESSAGE_ACTIONS|getMessageActions|MessageActionsArray|actionsEnabled|messageIsUnread|initializeOnMount|channelQueryOptions|markReadOnMount|activeUnreadHandler|skipMessageDataMemoization|allowConcurrentAudioPlayback|allowConcurrentPlayback|doSendMessageRequest|doUpdateMessageRequest|doDeleteMessageRequest|doMarkReadRequest|overrideSubmitHandler|preventClearingOnUnmount|loadMoreNewer|loadMoreThread|loadingMoreNewer|jumpToLatestMessage|jumpToFirstUnreadMessage|highlightedMessageId|threadSuppressAutoscroll|loadingMoreForJumpToChannelMessage|messageLimit|hasPendingUploadState|recoverStateOnReconnect|wsUrlParams|isLocalUnreadCountEnabled|StreamChatOptions|isLanguageSupported|SupportedTranslations|DateFormatterOptions|getTranslators|registerSetLanguageCallback|getAvailableLanguages|geti18Instance|deTranslations|enTranslations|esTranslations|frTranslations|hiTranslations|itTranslations|jaTranslations|koTranslations|nlTranslations|ptTranslations|ruTranslations|trTranslations|ChannelSort|UserSort|ReactionSort|WatcherState|unBlockUser|ReminderResponse|CommandResponse|DefaultCommandData|CustomCommandData|chatContainer|useUserRole|disableQuotedMessages|MentionedUserEventHandler|mentionedUser|noGroupByUser|shouldGroupByUser|defaultItemHeight|scrollSeekPlaceHolder|endOfGroup|firstOfGroup|groupedByUser)\b| { - Replacement: - `Message` -> the `MessageUI` slot. `VirtualMessage` still overrides inside `VirtualizedMessageList` only. The `Message` _props_ on the lists and `Thread` belong to the message area's entry. - `ThreadHeader` -> render `` (or your own header) as a child of ``. `Thread` renders children and no header of its own. - - `HeaderStartContent` -> the `StartContent` prop on `ChannelHeader` / `ThreadHeader`, which defaults to `WorkspaceNavigationBackButton`. `EndContent` defaults to `WorkspaceNavigationCloseButton`. `HeaderEndContent` stays on `ComponentContext` for the list headers. + - `HeaderStartContent` -> the `StartContent` prop on `ChannelHeader` / `ThreadHeader`, which defaults to `WorkspaceNavigationBackButton`. `EndContent` defaults to `WorkspaceNavigationCloseButton`. `HeaderEndContent` -> the `EndContent` prop on `ChannelListHeader` / `ThreadListHeader` (BC-099). - `ChannelListUI` -> no wrapper slot. `ChannelList` renders an `InfiniteScrollPaginator` and reads the new slots `EmptyListIndicator`, `EndReachedIndicator`, `FirstPageLoadingIndicator`, `ListItem` and `LoadingNextPageIndicator` from `ComponentContext`. `ThreadList` reads `FirstPageLoadingIndicator` too, and both read the new `ListLoadErrorIndicator`; only `ChannelList` reads the others today. - Evidence: - `release-v14:src/context/ComponentContext.tsx:129` `ChannelListUI?`, `:179` `Message?` (`@deprecated use MessageUI`), `:288` `ThreadHeader?`, `:305` `HeaderStartContent?` - - `master:src/context/ComponentContext.tsx` none of the four; `:187` `MessageUI?`, `:294` `ThreadHead?`, `:309` `HeaderEndContent?`, `:317` `VirtualMessage?`, `:88-102` the new list slots + - `master:src/context/ComponentContext.tsx` none of the four; `:187` `MessageUI?`, `:294` `ThreadHead?`, `:317` `VirtualMessage?`, `:88-102` the new list slots - `master:src/components/ChannelHeader/ChannelHeader.tsx:58,64,77,79` and `master:src/components/Thread/ThreadHeader.tsx:85,91,97,99` `StartContent` / `EndContent` props with the workspace-navigation defaults - `master:src/components/Thread/Thread.tsx:41-56` JSDoc: `…` - `master:src/components/ChannelList/ChannelList.tsx:48-54` reads `EmptyListIndicator`, `EndReachedIndicator`, `FirstPageLoadingIndicator`, `ListItem`, `LoadingNextPageIndicator` from `useComponentContext()` @@ -875,7 +876,7 @@ A threads view is declared the same way (a `{ id: 'threads', slots: [...] }` lay - Area: navigation - User impact: - compile error reading `setActiveChannel` / `channel` from `useChatContext()` (and from the return of `useChat()`); `undefined` in JS. - - silent behavior change: without `ChatView` (or your own `WorkspaceNavigationProvider`), clicking a `ChannelList` row, a channel/message/user search result, a `ThreadList` row, a message's reply-count button, "also sent in channel", or the channel-detail "send direct message" / pinned-message entry does nothing, and no list row renders as active (`ChannelListItem` derives `active` from `isChannelActive(cid)`, `ChannelListHeader` only shows `HeaderEndContent` while `openChannels` is non-empty). + - silent behavior change: without `ChatView` (or your own `WorkspaceNavigationProvider`), clicking a `ChannelList` row, a channel/message/user search result, a `ThreadList` row, a message's reply-count button, "also sent in channel", or the channel-detail "send direct message" / pinned-message entry does nothing, and no list row renders as active (`ChannelListItem` derives `active` from `isChannelActive(cid)`). - with no navigation provided at all, each operation (`openChannel`, `openThread`, `closeChannel`, `closeThread`, `goBack`) logs a one-time console warning that nothing happened; `defaultWorkspaceNavigation` itself stays silent, so a provider built by spreading it does not warn. - `ChannelListItem` / `ChannelListItemUI` no longer accept `setActiveChannel` / `watchers` (BC-033). - Old API: @@ -1813,10 +1814,10 @@ Guide check: `ai-migration-v14-v15.md` "`ChannelListItem` `getLatestMessagePrevi - User impact: compile error on a `HeaderStartContent` override (`TS2561`); in JS it is ignored and the custom content (often a sidebar toggle) disappears. Visible change: by default `ChannelHeader` / `ThreadHeader` render `WorkspaceNavigationBackButton` at the start and `WorkspaceNavigationCloseButton` at the end; both render nothing outside a workspace navigation that reports the panel as stacked / dismissable. - Old API: - `ComponentContextValue.HeaderStartContent` -- Replacement: `` and the same on `ThreadHeader`. Passing a component replaces the default button; render `WorkspaceNavigationBackButton` / `WorkspaceNavigationCloseButton` (new exports) inside yours to keep it. `ComponentContext.HeaderEndContent` is unchanged and still feeds `ChannelListHeader` / `ThreadListHeader` (but `ChannelListHeader` shows it only while a channel is open in the workspace). +- Replacement: `` and the same on `ThreadHeader`. Passing a component replaces the default button; render `WorkspaceNavigationBackButton` / `WorkspaceNavigationCloseButton` (new exports) inside yours to keep it. `ComponentContext.HeaderEndContent` was removed too; the list headers take `StartContent` / `EndContent` props (BC-099). - Evidence: - `release-v14:src/context/ComponentContext.tsx:305`; `release-v14:src/components/ChannelHeader/ChannelHeader.tsx:52,61-63` - - `master:src/components/ChannelHeader/ChannelHeader.tsx:50-69,74-110`; `master:src/components/Thread/ThreadHeader.tsx:79-100`; `master:src/components/ChannelList/ChannelListHeader.tsx` (`hasActiveChannel = useWorkspaceNavigation().openChannels.length > 0`) + - `master:src/components/ChannelHeader/ChannelHeader.tsx:50-69,74-110`; `master:src/components/Thread/ThreadHeader.tsx:79-100` - commit: `66557d63b feat!: port the tutorial alignment (#3251) to v15, with panel-aware workspace navigation` (PR #3301) - Docs impact: - `02-ui-components/04-channel/02-channel_header.md`, `02-ui-components/04-channel/05-component_context.md`, `02-ui-components/03-chat/01-chat.md`, `03-ui-cookbook/12-collapsible-sidebar.md`, `06-release-guides/01-upgrade-to-v14.md` @@ -2062,6 +2063,31 @@ const CustomThreadHeader = () => { - `03-ui-cookbook/07-notifications/01-system-notifications.md`, `04-guides/13-notifications.md` - Example needed: no + + +### BC-099: `ComponentContext.HeaderEndContent` was removed; `ChannelListHeader` and `ThreadListHeader` take `StartContent` / `EndContent` props, rendered whenever set + +- Status: confirmed +- Area: channel list header / thread list header / ComponentContext +- User impact: + - compile error on a `HeaderEndContent` override (`TS2353` / `TS2561`); in JS the key is ignored and the content disappears from both list headers. + - behavior change: v14 rendered `HeaderEndContent` in `ChannelListHeader` only while a channel was active, and in `ThreadListHeader` only while a thread was active. The `EndContent` prop renders whenever it is set, so content that only makes sense next to an open channel or thread (a sidebar toggle) has to return `null` itself. + - one component no longer fills both headers: pass it to each header that should show it. +- Old API: + - `ComponentContextValue.HeaderEndContent`, rendered by `ChannelListHeader` (gated on the active channel) and `ThreadListHeader` (gated on the active thread) +- Replacement: + - `ChannelListHeader` and `ThreadListHeader` take `StartContent` (before the title) and `EndContent` (after the title), as `ChannelHeader` / `ThreadHeader` do. Neither has a default. Types: `ChannelListHeaderProps`, `ThreadListHeaderProps`. + - the SDK renders both headers, so the props go through the header's override key: register a component that renders the default header with them. `ChannelListHeader` is read by `ChannelNavigation`; `ThreadListHeader` is a new key, read by `ThreadList`. `ThreadListHeader` is now exported. + - to keep the v14 gate, read `useWorkspaceNavigation().openChannels` / `.openThreads` in the content component and return `null` while it is empty. +- Evidence: + - `release-v14:src/components/ChannelList/ChannelListHeader.tsx:15` `{channel && HeaderEndContent && }`; `release-v14:src/components/Threads/ThreadList/ThreadListHeader.tsx:12` `{activeThread && HeaderEndContent && }`; `release-v14:src/context/ComponentContext.tsx:303` + - `master:src/components/ChannelList/ChannelListHeader.tsx` and `master:src/components/Threads/ThreadList/ThreadListHeader.tsx` (`StartContent` / `EndContent` props); `master:src/components/Threads/ThreadList/ThreadList.tsx` reads `ThreadListHeader` from `useComponentContext()`; `master:src/context/ComponentContext.tsx` `ThreadListHeader?` + - ticket: REACT-1449 + - examples: `examples/vite/src/ChatLayout/SwitchableChannelNavigation.tsx` passes `EndContent={SidebarToggleWhileChannelOpen}` to `ChannelListHeader`; `examples/vite/src/App.tsx` registers `ThreadListHeaderWithSidebarToggle` (`examples/vite/src/Sidebar/HeaderContent.tsx`) as `ThreadListHeader` +- Docs impact: + - `03-ui/03-components/01-channel-list-ui.md`, `03-ui/03-components/08-thread-list-ui.md`, `03-ui/03-components/12-overriding-components.md`, `06-component-reference/02-channel/04-componentcontext.md`, `06-component-reference/07-thread-list/01-threadlist.md`, `05-ui-cookbooks/10-layout/04-collapsible-sidebar.md`, `05-ui-cookbooks/03-search/03-mentions-inbox.md`, `08-release-guides/01-upgrade-to-v15.md` +- Example needed: yes (the guide section has one) + --- ## Messages and message lists diff --git a/examples/cookbook/src/recipes/layout/collapsible-sidebar/views.tsx b/examples/cookbook/src/recipes/layout/collapsible-sidebar/views.tsx index 7a796b6919..0f32984ab5 100644 --- a/examples/cookbook/src/recipes/layout/collapsible-sidebar/views.tsx +++ b/examples/cookbook/src/recipes/layout/collapsible-sidebar/views.tsx @@ -1,10 +1,12 @@ import type { PropsWithChildren } from 'react'; import { ChannelHeader, + ChannelListHeader, ChannelNavigation, MessageComposer, MessageList, ThreadHeader, + useWorkspaceNavigation, WorkspaceNavigationBackButton, } from 'stream-chat-react'; import { ChannelSlot, ThreadSlot } from 'stream-chat-react/slot-layout'; @@ -18,8 +20,20 @@ export const layouts: LayoutDescriptor[] = [ { id: 'channels', slots: ['channel', 'thread'] }, ]; +// The toggle, only while a channel is open beside the list. With nothing open, collapsing the +// sidebar would leave an empty screen. +const SidebarToggleWhileChannelOpen = () => { + const { openChannels } = useWorkspaceNavigation(); + return openChannels.length > 0 ? : null; +}; + +// The default channel list header, with the toggle at its end. +const ChannelListHeaderWithToggle = () => ( + +); + // Declared once, so WithComponents gets the same object on every render. -export const overrides = { HeaderEndContent: SidebarToggle }; +export const overrides = { ChannelListHeader: ChannelListHeaderWithToggle }; // The toggle, then the back button the header shows by default. const ChannelHeaderStart = () => ( diff --git a/examples/vite/src/App.tsx b/examples/vite/src/App.tsx index db9fc3f98a..bef64b8ba2 100644 --- a/examples/vite/src/App.tsx +++ b/examples/vite/src/App.tsx @@ -94,7 +94,7 @@ import { } from './CustomMessageUi'; import { ConfigurableMessageActions } from './CustomMessageActions'; import { InlineEditableMessage } from './InlineEditMessage'; -import { SidebarToggle } from './Sidebar/SidebarToggle.tsx'; +import { ThreadListHeaderWithSidebarToggle } from './Sidebar/HeaderContent.tsx'; import { CommandModeAttachmentSelector } from './CommandModeAttachmentSelector.tsx'; import { ConfigurableComposerPreviews, @@ -694,7 +694,7 @@ const App = () => { MessageReactions: CustomMessageReactions, reactionOptions: newReactionOptions, Search: CustomChannelSearch, - HeaderEndContent: SidebarToggle, + ThreadListHeader: ThreadListHeaderWithSidebarToggle, MessageActions: ConfigurableMessageActions, AttachmentSelector: CommandModeAttachmentSelector, MessageBubbleContent: ConfigurableMessageBubbleContent, diff --git a/examples/vite/src/ChatLayout/SwitchableChannelNavigation.tsx b/examples/vite/src/ChatLayout/SwitchableChannelNavigation.tsx index cf0f8dcebc..e0eefa4255 100644 --- a/examples/vite/src/ChatLayout/SwitchableChannelNavigation.tsx +++ b/examples/vite/src/ChatLayout/SwitchableChannelNavigation.tsx @@ -25,6 +25,7 @@ import { useStateStore, } from 'stream-chat-react'; +import { SidebarToggleWhileChannelOpen } from '../Sidebar/HeaderContent.tsx'; import { ScrollToActiveChannelButton } from './ScrollToActiveChannelButton'; // Human-readable labels for each channel list, keyed by the paginator id set up in App.tsx. @@ -221,7 +222,7 @@ export const SwitchableChannelNavigation = () => { return (
- + {!isActive && activePaginator && ( // Expose the active paginator through ChannelListContext (as the SDK's ChannelLists diff --git a/examples/vite/src/Sidebar/HeaderContent.tsx b/examples/vite/src/Sidebar/HeaderContent.tsx index 607a0916c1..30e977db93 100644 --- a/examples/vite/src/Sidebar/HeaderContent.tsx +++ b/examples/vite/src/Sidebar/HeaderContent.tsx @@ -1,4 +1,8 @@ -import { WorkspaceNavigationBackButton } from 'stream-chat-react'; +import { + ThreadListHeader, + useWorkspaceNavigation, + WorkspaceNavigationBackButton, +} from 'stream-chat-react'; import { SidebarToggle } from './SidebarToggle.tsx'; @@ -10,3 +14,23 @@ export const HeaderStartWithSidebarToggle = () => ( ); + +/** The sidebar toggle while a channel is open beside the channel list. With nothing open, + * collapsing the sidebar would leave an empty screen. */ +export const SidebarToggleWhileChannelOpen = () => { + const { openChannels } = useWorkspaceNavigation(); + return openChannels.length > 0 ? : null; +}; + +/** The sidebar toggle while a thread is open beside the thread list. With nothing open, + * collapsing the sidebar would leave an empty screen. */ +export const SidebarToggleWhileThreadOpen = () => { + const { openThreads } = useWorkspaceNavigation(); + return openThreads.length > 0 ? : null; +}; + +/** The thread list header, registered as the `ThreadListHeader` override, with the sidebar toggle + * at its end. */ +export const ThreadListHeaderWithSidebarToggle = () => ( + +); diff --git a/src/components/ChannelList/ChannelListHeader.tsx b/src/components/ChannelList/ChannelListHeader.tsx index 269beea370..3d80a33475 100644 --- a/src/components/ChannelList/ChannelListHeader.tsx +++ b/src/components/ChannelList/ChannelListHeader.tsx @@ -1,21 +1,26 @@ import React from 'react'; -import { - useComponentContext, - useTranslationContext, - useWorkspaceNavigation, -} from '../../context'; +import { useTranslationContext } from '../../context'; -export const ChannelListHeader = () => { +export type ChannelListHeaderProps = { + /** Rendered at the end of the header, after the title. No default. */ + EndContent?: React.ComponentType; + /** Rendered at the start of the header, before the title. No default. */ + StartContent?: React.ComponentType; +}; + +/** Header of the channel list: the "Chats" title, with optional start and end content. */ +export const ChannelListHeader = ({ + EndContent, + StartContent, +}: ChannelListHeaderProps) => { const { t } = useTranslationContext(); - const { HeaderEndContent } = useComponentContext(); - // `HeaderEndContent` renders only while a channel is open in the workspace. - const hasActiveChannel = useWorkspaceNavigation().openChannels.length > 0; return (
+ {StartContent && }
{t('channelList.header.chats.text', 'Chats')}
- {hasActiveChannel && HeaderEndContent && } + {EndContent && }
); }; diff --git a/src/components/ChannelList/__tests__/ChannelListHeader.test.tsx b/src/components/ChannelList/__tests__/ChannelListHeader.test.tsx index 7cbd57394d..1d60ef201d 100644 --- a/src/components/ChannelList/__tests__/ChannelListHeader.test.tsx +++ b/src/components/ChannelList/__tests__/ChannelListHeader.test.tsx @@ -1,58 +1,50 @@ import React from 'react'; import { cleanup, render, screen } from '@testing-library/react'; import { fromPartial } from '@total-typescript/shoehorn'; -import { WithComponents, WorkspaceNavigationProvider } from '../../../context'; +import { WorkspaceNavigationProvider } from '../../../context'; import { TranslationProvider } from '../../../context/TranslationContext'; import { mockTranslationContextValue } from '../../../mock-builders'; +import type { ChannelListHeaderProps } from '../ChannelListHeader'; import { ChannelListHeader } from '../ChannelListHeader'; import { mockT } from '../../../mock-builders/translator'; const t = vi.fn(mockT); -const HeaderEndContent = () =>
; +const EndContent = () =>
; +const StartContent = () =>
; afterEach(cleanup); -// The header reads `openChannels` from the workspace navigation; drive it through the real -// provider. -const renderHeader = ({ - openChannels, - withHeaderEndContent, -}: { - openChannels: unknown[]; - withHeaderEndContent: boolean; -}) => { - const inner = ( - - - - ); - return render( - - {withHeaderEndContent ? ( - {inner} - ) : ( - inner - )} +// Nothing is open in the workspace navigation: the start and end content must not depend on it. +const renderHeader = (props: ChannelListHeaderProps = {}) => + render( + + + + , ); -}; describe('ChannelListHeader', () => { - it('should not render HeaderEndContent when not provided via ComponentContext', () => { - renderHeader({ openChannels: [{}], withHeaderEndContent: false }); - - expect(screen.queryByTestId('sidebar-toggle')).not.toBeInTheDocument(); - }); - - it('should render HeaderEndContent when a channel is active', () => { - renderHeader({ openChannels: [{}], withHeaderEndContent: true }); - - expect(screen.getByTestId('sidebar-toggle')).toBeInTheDocument(); + it('should render only the title by default', () => { + const { container } = renderHeader(); + + expect(screen.queryByTestId('header-start')).not.toBeInTheDocument(); + expect(screen.queryByTestId('header-end')).not.toBeInTheDocument(); + expect( + container.querySelector('.str-chat__channel-list__header')?.children, + ).toHaveLength(1); }); - it('should not render HeaderEndContent when no channel is active', () => { - renderHeader({ openChannels: [], withHeaderEndContent: true }); + it('should render StartContent before the title and EndContent after it, without an open channel', () => { + const { container } = renderHeader({ EndContent, StartContent }); - expect(screen.queryByTestId('sidebar-toggle')).not.toBeInTheDocument(); + const children = Array.from( + container.querySelector('.str-chat__channel-list__header')?.children ?? [], + ); + expect( + children.map((child) => child.getAttribute('data-testid') ?? child.className), + ).toEqual(['header-start', 'str-chat__channel-list__header__title', 'header-end']); }); }); diff --git a/src/components/Threads/ThreadList/ThreadList.tsx b/src/components/Threads/ThreadList/ThreadList.tsx index 3bbdac1794..46204a3197 100644 --- a/src/components/Threads/ThreadList/ThreadList.tsx +++ b/src/components/Threads/ThreadList/ThreadList.tsx @@ -24,7 +24,7 @@ import { useTranslationContext, } from '../../../context'; import { useStateStore } from '../../../store'; -import { ThreadListHeader } from './ThreadListHeader'; +import { ThreadListHeader as DefaultThreadListHeader } from './ThreadListHeader'; const noThreads: Thread[] = []; @@ -80,6 +80,7 @@ export const ThreadList = ({ virtuosoProps }: ThreadListProps) => { ListLoadErrorIndicator = DefaultListLoadErrorIndicator, NotificationList: NotificationListFromContext = NotificationList, ThreadListEmptyPlaceholder = DefaultThreadListEmptyPlaceholder, + ThreadListHeader = DefaultThreadListHeader, ThreadListItem = DefaultThreadListItem, ThreadListLoadingIndicator = DefaultThreadListLoadingIndicator, ThreadListUnseenThreadsBanner = DefaultThreadListUnseenThreadsBanner, diff --git a/src/components/Threads/ThreadList/ThreadListHeader.tsx b/src/components/Threads/ThreadList/ThreadListHeader.tsx index cd845a545a..57f08358cc 100644 --- a/src/components/Threads/ThreadList/ThreadListHeader.tsx +++ b/src/components/Threads/ThreadList/ThreadListHeader.tsx @@ -1,21 +1,23 @@ import React from 'react'; -import { - useComponentContext, - useTranslationContext, - useWorkspaceNavigation, -} from '../../../context'; +import { useTranslationContext } from '../../../context'; -export const ThreadListHeader = () => { +export type ThreadListHeaderProps = { + /** Rendered at the end of the header, after the title. No default. */ + EndContent?: React.ComponentType; + /** Rendered at the start of the header, before the title. No default. */ + StartContent?: React.ComponentType; +}; + +/** Header of the thread list: the "Threads" title, with optional start and end content. */ +export const ThreadListHeader = ({ EndContent, StartContent }: ThreadListHeaderProps) => { const { t } = useTranslationContext(); - const { HeaderEndContent } = useComponentContext(); - // A thread is "active" when one is open in the workspace. - const hasActiveThread = useWorkspaceNavigation().openThreads.length > 0; return (
+ {StartContent && }
{t('common.threads.text', 'Threads')}
- {hasActiveThread && HeaderEndContent && } + {EndContent && }
); }; diff --git a/src/components/Threads/ThreadList/__tests__/ThreadList.test.tsx b/src/components/Threads/ThreadList/__tests__/ThreadList.test.tsx index ed022c57f8..482f245ad8 100644 --- a/src/components/Threads/ThreadList/__tests__/ThreadList.test.tsx +++ b/src/components/Threads/ThreadList/__tests__/ThreadList.test.tsx @@ -251,6 +251,17 @@ describe('ThreadList', () => { expect(screen.queryByTestId('loading-channels')).not.toBeInTheDocument(); }); + it('renders the ThreadListHeader override instead of the default header', () => { + mockUseComponentContext.mockReturnValue({ + ThreadListHeader: () =>
, + }); + + render(); + + expect(screen.getByTestId('custom-thread-list-header')).toBeInTheDocument(); + expect(screen.queryByTestId('thread-list-header')).not.toBeInTheDocument(); + }); + it('renders the overrides of the first-load skeleton and the load error', () => { const lastQueryError = new Error('query failed'); const ListLoadErrorIndicator = vi.fn(() =>
); diff --git a/src/components/Threads/ThreadList/__tests__/ThreadListHeader.test.tsx b/src/components/Threads/ThreadList/__tests__/ThreadListHeader.test.tsx index 8e0c48ef01..0dc3a5ea16 100644 --- a/src/components/Threads/ThreadList/__tests__/ThreadListHeader.test.tsx +++ b/src/components/Threads/ThreadList/__tests__/ThreadListHeader.test.tsx @@ -1,58 +1,50 @@ import React from 'react'; import { cleanup, render, screen } from '@testing-library/react'; import { fromPartial } from '@total-typescript/shoehorn'; -import { WithComponents, WorkspaceNavigationProvider } from '../../../../context'; +import { WorkspaceNavigationProvider } from '../../../../context'; import { TranslationProvider } from '../../../../context/TranslationContext'; import { mockTranslationContextValue } from '../../../../mock-builders'; +import type { ThreadListHeaderProps } from '../ThreadListHeader'; import { ThreadListHeader } from '../ThreadListHeader'; import { mockT } from '../../../../mock-builders/translator'; const t = vi.fn(mockT); -const HeaderEndContent = () =>
; +const EndContent = () =>
; +const StartContent = () =>
; afterEach(cleanup); -// The header derives "a thread is active" from the workspace navigation's `openThreads`, so the -// tests drive it through the real provider. -const renderHeader = ({ - openThreads, - withHeaderEndContent, -}: { - openThreads: unknown[]; - withHeaderEndContent: boolean; -}) => { - const inner = ( - - - - ); - return render( - - {withHeaderEndContent ? ( - {inner} - ) : ( - inner - )} +// Nothing is open in the workspace navigation: the start and end content must not depend on it. +const renderHeader = (props: ThreadListHeaderProps = {}) => + render( + + + + , ); -}; describe('ThreadListHeader', () => { - it('should not render HeaderEndContent when not provided via ComponentContext', () => { - renderHeader({ openThreads: [{}], withHeaderEndContent: false }); - - expect(screen.queryByTestId('sidebar-toggle')).not.toBeInTheDocument(); - }); - - it('should render HeaderEndContent when a thread is active', () => { - renderHeader({ openThreads: [{}], withHeaderEndContent: true }); - - expect(screen.getByTestId('sidebar-toggle')).toBeInTheDocument(); + it('should render only the title by default', () => { + const { container } = renderHeader(); + + expect(screen.queryByTestId('header-start')).not.toBeInTheDocument(); + expect(screen.queryByTestId('header-end')).not.toBeInTheDocument(); + expect( + container.querySelector('.str-chat__thread-list__header')?.children, + ).toHaveLength(1); }); - it('should not render HeaderEndContent when no thread is active', () => { - renderHeader({ openThreads: [], withHeaderEndContent: true }); + it('should render StartContent before the title and EndContent after it, without an open thread', () => { + const { container } = renderHeader({ EndContent, StartContent }); - expect(screen.queryByTestId('sidebar-toggle')).not.toBeInTheDocument(); + const children = Array.from( + container.querySelector('.str-chat__thread-list__header')?.children ?? [], + ); + expect( + children.map((child) => child.getAttribute('data-testid') ?? child.className), + ).toEqual(['header-start', 'str-chat__thread-list__header__title', 'header-end']); }); }); diff --git a/src/components/Threads/ThreadList/index.ts b/src/components/Threads/ThreadList/index.ts index 71a4c7df45..9c4a467e17 100644 --- a/src/components/Threads/ThreadList/index.ts +++ b/src/components/Threads/ThreadList/index.ts @@ -1,4 +1,5 @@ export * from './ThreadList'; +export * from './ThreadListHeader'; export * from './ThreadListItem'; export * from './ThreadListItemUI'; export * from './utils.a11y'; diff --git a/src/context/ComponentContext.tsx b/src/context/ComponentContext.tsx index 875f72ddb1..5466083115 100644 --- a/src/context/ComponentContext.tsx +++ b/src/context/ComponentContext.tsx @@ -139,7 +139,10 @@ export type ComponentContextValue = { CalloutDialog?: React.ComponentType; /** Shown instead of an image that fails to load. */ ImagePlaceholder?: React.ComponentType; - /** Header of the `ChannelList`. */ + /** + * Header of the `ChannelList`, rendered without props. To add start or end content, register a + * component that renders `ChannelListHeader` with `StartContent` / `EndContent`. + */ ChannelListHeader?: React.ComponentType; /** Action buttons within `ChannelListItemUI`. */ ChannelListItemActionButtons?: React.ComponentType; @@ -333,6 +336,11 @@ export type ComponentContextValue = { /** The thread's parent message, rendered above the replies in a thread's message list. */ ThreadHead?: React.ComponentType; ThreadListEmptyPlaceholder?: React.ComponentType; + /** + * Header of the `ThreadList`, rendered without props. To add start or end content, register a + * component that renders `ThreadListHeader` with `StartContent` / `EndContent`. + */ + ThreadListHeader?: React.ComponentType; ThreadListItem?: React.ComponentType; ThreadListItemUI?: React.ComponentType; ThreadListLoadingIndicator?: React.ComponentType; @@ -350,11 +358,6 @@ export type ComponentContextValue = { * `UnreadMessagesSeparator`. */ UnreadMessagesNotification?: React.ComponentType; - /** - * Rendered at the end of `ChannelListHeader` while a channel is open, and of `ThreadListHeader` - * while a thread is open. No default. - */ - HeaderEndContent?: React.ComponentType; /** Separates read messages from unread ones. */ UnreadMessagesSeparator?: React.ComponentType; /** Uploaded vs total byte size during an attachment upload, in the composer previews. */ From 986b129cf19bbe0e1d9c2f895d24f4b283eec0f1 Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 6 Nov 2026 11:07:28 +0100 Subject: [PATCH 2/3] docs(ai-docs): note when removed override keys type-check and what the header gate needs A removed key in an overrides object declared as a variable that also holds a valid key passes TypeScript and is ignored at runtime. The openChannels / openThreads gate for list-header content needs ChatView or a WorkspaceNavigationProvider; without one the lists stay empty. --- ai-docs/ai-migration-v14-v15.md | 98 ++++++++++++++++----------------- 1 file changed, 49 insertions(+), 49 deletions(-) diff --git a/ai-docs/ai-migration-v14-v15.md b/ai-docs/ai-migration-v14-v15.md index fccc15ae18..47c6ae538a 100644 --- a/ai-docs/ai-migration-v14-v15.md +++ b/ai-docs/ai-migration-v14-v15.md @@ -279,52 +279,52 @@ Old → new for the most common changes. Ctrl-F the v14 name; the section has th These compile cleanly, or are ignored in plain JS, and change behavior at runtime. Check each one explicitly; the absence of an error does not mean the app is migrated. **Detect** lists `rg` patterns (run them with the `code` / `styles` helpers from [Detection](#detection)) or what to review; a row with no hit and nothing to review does not apply. -| Symptom | Cause | Fix | Detect | -| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | -| Clicking a channel-list row, a search result, a thread-list row or a message's reply button does nothing; no row shows active | Selection and thread opening go through workspace navigation, whose default outside `ChatView` is a no-op (it logs a one-time console warning per operation) | Use `ChatView`, or provide `WorkspaceNavigationProvider` ([section 4](#4-selecting-channels-and-opening-threads-workspace-navigation)) | `` in the thread ([section 7](#thread-takes-a-thread-prop-and-renders-its-children)) | ``, the channel list or thread list fills the view, the channel or thread beside it is not visible and the page scrolls horizontally | `--str-chat__channel-list-width` and `--str-chat__thread-list-width` default to `100%`, and the lists do not shrink | Set both widths on the `ChatView` root ([section 19](#panel-layout)) | `views=` with `ChannelNavigation` / `ChannelList` / `ThreadList`, and no `--str-chat__channel-list-width` (`styles`) | -| A channel name or image renders empty | `name` / `image` are custom data in `stream-chat` v10: `channel.data.custom.name` | Read `custom` ([section 17](#17-other-stream-chat-v10-data-shapes)) | `.data?.name`, `.data.name`, `.data?.image` | -| A held channel throws "pending disposal" after logout or a user switch | `disconnectUser()` disposes the user's channels | Drop held channels on disconnect ([section 4](#without-chatview)) | `disconnectUser`, `useState` (a preview, a pinned-message card) throws | `Message` needs a channel in context | Wrap it in `ChannelInstanceProvider` ([section 12](#a-standalone-message-needs-a-channel-in-context)) | `` misses messages sent while the client was offline, or a channel reloads twice after a reconnect | The client reloads only activated channels after a reconnect, and v14's `Channel` no longer reloads on `connection.recovered` | Call `channel.activate()` in a custom surface; remove app-side reloads on `connection.recovered` ([section 5](#channel-lifecycle-activation-no-remount-no-reload-no-search-jump)) | `connection.recovered`, `.reload(`, custom channel surfaces without `` in the thread ([section 7](#thread-takes-a-thread-prop-and-renders-its-children)) | ``, the channel list or thread list fills the view, the channel or thread beside it is not visible and the page scrolls horizontally | `--str-chat__channel-list-width` and `--str-chat__thread-list-width` default to `100%`, and the lists do not shrink | Set both widths on the `ChatView` root ([section 19](#panel-layout)) | `views=` with `ChannelNavigation` / `ChannelList` / `ThreadList`, and no `--str-chat__channel-list-width` (`styles`) | +| A channel name or image renders empty | `name` / `image` are custom data in `stream-chat` v10: `channel.data.custom.name` | Read `custom` ([section 17](#17-other-stream-chat-v10-data-shapes)) | `.data?.name`, `.data.name`, `.data?.image` | +| A held channel throws "pending disposal" after logout or a user switch | `disconnectUser()` disposes the user's channels | Drop held channels on disconnect ([section 4](#without-chatview)) | `disconnectUser`, `useState` (a preview, a pinned-message card) throws | `Message` needs a channel in context | Wrap it in `ChannelInstanceProvider` ([section 12](#a-standalone-message-needs-a-channel-in-context)) | `` misses messages sent while the client was offline, or a channel reloads twice after a reconnect | The client reloads only activated channels after a reconnect, and v14's `Channel` no longer reloads on `connection.recovered` | Call `channel.activate()` in a custom surface; remove app-side reloads on `connection.recovered` ([section 5](#channel-lifecycle-activation-no-remount-no-reload-no-search-jump)) | `connection.recovered`, `.reload(`, custom channel surfaces without ` ( ### List headers: `HeaderEndContent` becomes an `EndContent` prop -`ComponentContext.HeaderEndContent` is removed (`TS2353` / `TS2561`; in JS the key is ignored and the content disappears). In v14 one component filled the end of both list headers, and showed only while a channel was open (channel list) or a thread was open (thread list). +`ComponentContext.HeaderEndContent` is removed. Written inline in `overrides`, the key is a compile error (`TS2353` / `TS2561`). In JS, or in an `overrides` object declared as a variable that also holds a valid key, it is ignored without an error and the content disappears. In v14 one component filled the end of both list headers, and showed only while a channel was open (channel list) or a thread was open (thread list). `ChannelListHeader` and `ThreadListHeader` now take the content as props, like `ChannelHeader` and `ThreadHeader` do. Neither prop has a default, and both render whenever they are set: @@ -1687,7 +1687,7 @@ Where you pass the props depends on who renders the header: Both headers are exported. -To keep the v14 behavior of showing the content only next to an open channel or thread (a sidebar toggle, for example), make the content return `null` otherwise: read `openChannels` / `openThreads` from `useWorkspaceNavigation()`. +To keep the v14 behavior of showing the content only next to an open channel or thread (a sidebar toggle, for example), make the content return `null` otherwise: read `openChannels` / `openThreads` from `useWorkspaceNavigation()`. `ChatView` (or your own `WorkspaceNavigationProvider`, [section 4](#4-selecting-channels-and-opening-threads-workspace-navigation)) fills these lists; without one they stay empty, so gate on your own selection state instead. ```tsx // v14 @@ -1960,7 +1960,7 @@ These are `stream-chat` changes, documented in its own migration notes. They are ### Removed `ComponentContext` slots -A stale key in `WithComponents overrides` is a compile error (`TS2353` / `TS2561`). In plain JS or behind `any` it is ignored with no warning, and the default UI renders (or nothing, for `HeaderStartContent` and `HeaderEndContent`). +A stale key written inline in `WithComponents overrides` is a compile error (`TS2353` / `TS2561`). In plain JS, behind `any`, or in an `overrides` object declared as a variable that also holds a valid key (TypeScript checks unknown keys only on object literals), it is ignored with no warning, and the default UI renders (or nothing, for `HeaderStartContent` and `HeaderEndContent`). | Removed slot | v15 | | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | From 059c928361670b1b0fe35e42da68e3bbacf830d3 Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 6 Nov 2026 11:15:59 +0100 Subject: [PATCH 3/3] docs(ai-docs): note the variable-overrides case and the gate's navigation in BC-099 --- ai-docs/v15-breaking-changes.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ai-docs/v15-breaking-changes.md b/ai-docs/v15-breaking-changes.md index f3bcdd44dd..5277c21ffc 100644 --- a/ai-docs/v15-breaking-changes.md +++ b/ai-docs/v15-breaking-changes.md @@ -2255,7 +2255,7 @@ const CustomThreadHeader = () => { - Status: confirmed - Area: channel list header / thread list header / ComponentContext - User impact: - - compile error on a `HeaderEndContent` override (`TS2353` / `TS2561`); in JS the key is ignored and the content disappears from both list headers. + - compile error on a `HeaderEndContent` key written inline in `overrides` (`TS2353` / `TS2561`). In JS, or in an `overrides` object declared as a variable that also holds a valid key, the key is ignored without an error and the content disappears from both list headers. - behavior change: v14 rendered `HeaderEndContent` in `ChannelListHeader` only while a channel was active, and in `ThreadListHeader` only while a thread was active. The `EndContent` prop renders whenever it is set, so content that only makes sense next to an open channel or thread (a sidebar toggle) has to return `null` itself. - one component no longer fills both headers: pass it to each header that should show it. - Old API: @@ -2264,7 +2264,7 @@ const CustomThreadHeader = () => { - `ChannelListHeader` and `ThreadListHeader` take `StartContent` (before the title) and `EndContent` (after the title), as `ChannelHeader` / `ThreadHeader` do. Neither has a default. Types: `ChannelListHeaderProps`, `ThreadListHeaderProps`. - where the SDK renders the header, the props go through the header's override key: register a component that renders the default header with them. `ChannelListHeader` is read by `ChannelNavigation`; `ThreadListHeader` is a new key, read by `ThreadList`. `ThreadListHeader` is now exported. - where the app renders the header itself (for example `ChannelListHeader` above a standalone `ChannelList`), it passes the props directly; the override key does not reach that header. - - to keep the v14 gate, read `useWorkspaceNavigation().openChannels` / `.openThreads` in the content component and return `null` while it is empty. + - to keep the v14 gate, read `useWorkspaceNavigation().openChannels` / `.openThreads` in the content component and return `null` while it is empty. `ChatView` or an app's own `WorkspaceNavigationProvider` fills these lists; without one they stay empty, so an app without either gates on its own selection state. - Evidence: - `release-v14:src/components/ChannelList/ChannelListHeader.tsx:15` `{channel && HeaderEndContent && }`; `release-v14:src/components/Threads/ThreadList/ThreadListHeader.tsx:12` `{activeThread && HeaderEndContent && }`; `release-v14:src/context/ComponentContext.tsx:303` - `master:src/components/ChannelList/ChannelListHeader.tsx` and `master:src/components/Threads/ThreadList/ThreadListHeader.tsx` (`StartContent` / `EndContent` props); `master:src/components/Threads/ThreadList/ThreadList.tsx` reads `ThreadListHeader` from `useComponentContext()`; `master:src/context/ComponentContext.tsx` `ThreadListHeader?`