diff --git a/ai-docs/ai-migration-v14-v15.md b/ai-docs/ai-migration-v14-v15.md index 494735538..89d4c92f3 100644 --- a/ai-docs/ai-migration-v14-v15.md +++ b/ai-docs/ai-migration-v14-v15.md @@ -117,3 +117,120 @@ dictionary: [`i18n-v15-migration.md`](./i18n-v15-migration.md). - **Customize how the preview renders** → provide a `SummarizedMessagePreview` component override (via `ComponentProvider`, or the `` / `` component props). It receives `SummarizedMessagePreviewProps` (`{ latestMessage, messageDeliveryStatus, participantCount }`). - **Preview a specific message** rather than the channel's latest (e.g. a search result previewing the matched message) → pass the new `previewedMessage?: LocalMessage` prop to `ChannelListItem`. It defaults to the channel's reactive latest, `channel.messagePaginator.aggregateState.lastMessage`. - **Behavior note:** the preview now honors the channel's `skip_last_msg_update_for_system_msgs` config (a system message no longer becomes the previewed / last message), so the preview and the channel's sort position agree. + +## Message UI overrides consolidate on the `MessageUI` slot + +The deprecated `Message` component override is removed from `ComponentContext`, and so is every `Message` **prop** that took a message UI component. There is now exactly one way to override the message UI — the `MessageUI` slot — plus `VirtualMessage` for the virtualized list. + +| v14 | v15 | +| -------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | +| `ComponentContext.Message` (deprecated in v14) | `ComponentContext.MessageUI` | +| `` | `MessageUI` override | +| `` | `MessageUI` override | +| `` | `VirtualMessage` override (falls back to `MessageUI`) | +| `` | `MessageUI` override, scoped with `WithComponents` (see below) | +| `additionalMessageListProps` / `additionalVirtualizedMessageListProps` / `additionalParentMessageProps` carrying `Message` | `MessageUI` override | + +```tsx +// before + + + + +; + +// after + + + + +; +``` + +**`VirtualMessage` still wins inside the virtualized list**, and only there — `VirtualizedMessageList` applies it to its own subtree's `ComponentContext`, so it no longer needs to be drilled through a prop and no longer affects messages rendered outside the list: + +```tsx + + + {/* renders CustomVirtualMessage */} + {/* renders CustomMessage */} + + +``` + +**Overriding only a thread's messages** (was ``) means scoping the slot to the thread's subtree: + +```tsx + + + +``` + +> **This can fail silently in untyped code.** In TypeScript every leftover is a compile error: `Message={…}` on `Message` / `MessageList` / `VirtualizedMessageList` / `Thread` fails with `TS2322`, and a stale `Message:` key in a `WithComponents` `overrides` object or in `additionalMessageListProps` fails with `TS2561` — which reports `Did you mean to write 'MessageUI'?`, so the compiler names the fix. In plain JS or behind an `any`, both are ignored with no warning and the **default** message UI renders. Grep for `Message=` and `Message:` rather than relying on the build to find them. + +A custom message UI receives no props — read everything from `useMessageContext()`. + +## Context hooks: no `componentName` argument, and required contexts throw + +Two changes. The first is a compile break; the second only surfaces at runtime. + +### The optional `componentName` argument is removed + +```tsx +// v14 +const { t } = useTranslationContext('EmojiPicker'); +const { textareaRef } = useMessageComposerContext('EmojiPicker'); + +// v15 +const { t } = useTranslationContext(); +const { textareaRef } = useMessageComposerContext(); +``` + +Affects `useChatContext`, `useTranslationContext`, `useMessageContext`, `useComponentContext`, +`useMessageComposerContext`, `useMessageListContext` and `useMessageBounceContext`. TypeScript flags +every call site (`TS2554`). In plain JS the extra argument is simply ignored — harmless, but dead; +drop it. + +### Required contexts throw outside their provider + +In v14 these hooks logged a `console.warn` and returned `{}`, so a component rendered outside its +provider rendered blank and usually failed later with `Cannot read properties of undefined`. In v15 +the hook throws where the mistake is, naming itself and the provider it needs: + +| hook | must be rendered within | +| ---------------------------------- | --------------------------------------- | +| `useChatContext` | `` | +| `useMessageContext` | `MessageProvider` (a rendered message) | +| `useMessageComposerContext` | `MessageComposerContextProvider` | +| `useMessageListContext` | `MessageListContextProvider` | +| `useVirtualizedMessageListContext` | `VirtualizedMessageListContextProvider` | +| `useMessageBounceContext` | `MessageBounceProvider` | +| `usePollContext` | `PollProvider` | +| `useDialogManager` | `DialogManagerProvider` | +| `useSearchContext` | `SearchContextProvider` | +| `useSearchSourceResultsContext` | `SearchSourceResultsProvider` | +| `useContextMenuContext` | `ContextMenu` | +| `useChannelListItemContext` | `ChannelListItemUI` | +| `useGalleryContext` | `Gallery` | +| `useChatViewContext` | `ChatView` | + +**What to look for in the app:** any custom component that calls one of these hooks but is mounted +outside the provider — most often a custom message UI or attachment component rendered outside a +message, or a component mounted outside `` for a loading / empty state. In v14 these rendered +blank with a console warning; in v15 they throw. Move them inside the provider, or wrap them in it. + +These hooks are unchanged and still work outside their provider — no action needed: +`useTranslationContext` (renders the English copy), `useComponentContext` (empty override map), +`useChannelInstanceContext`, `useModalContext`, `useAriaLiveAnnouncer` and +`useMessageTranslationViewContext`. + +### Related type changes + +- `useChannelInstanceContext()` returns `Partial` — `channel` may be + `undefined`. Use `useChannel()` when a channel is required; it throws. +- `ChatViewContext` has no default value, so `useContext(ChatViewContext)` may be `undefined`. +- `MessageComposerContext` is typed `MessageComposerContextValue | undefined`. +- The gallery header renders the gallery item's own timestamp and no longer honors the + `ComponentContext.MessageTimestamp` override. diff --git a/examples/tutorial/src/4-custom-ui-components/App.tsx b/examples/tutorial/src/4-custom-ui-components/App.tsx index 2d4f726a2..e0338a1a3 100644 --- a/examples/tutorial/src/4-custom-ui-components/App.tsx +++ b/examples/tutorial/src/4-custom-ui-components/App.tsx @@ -164,7 +164,7 @@ const App = () => { diff --git a/src/components/Message/Message.tsx b/src/components/Message/Message.tsx index d34290649..d2062d7be 100644 --- a/src/components/Message/Message.tsx +++ b/src/components/Message/Message.tsx @@ -57,7 +57,6 @@ type MessageWithContextProps = Omit & const MessageWithContext = (props: MessageWithContextProps) => { const { - Message: propMessage, message, messageActions = Object.keys(MESSAGE_ACTIONS), onUserClick: propOnUserClick, @@ -68,11 +67,7 @@ const MessageWithContext = (props: MessageWithContextProps) => { const channel = useChannel(); const { isMessageAIGenerated } = useChatContext(); const channelConfig = useChannelConfig({ cid: channel.cid }); - const { - Message: contextMessage = DefaultMessageUI, - // TODO: remove this passthrough once we drop Message from the ComponentContext - MessageUI: contextMessageUI = contextMessage, - } = useComponentContext(); + const { MessageUI: MessageUIComponent = DefaultMessageUI } = useComponentContext(); const { getTranslationView, setTranslationView: setTranslationViewInContext } = useMessageTranslationViewContext(); @@ -83,7 +78,6 @@ const MessageWithContext = (props: MessageWithContextProps) => { ); const actionsEnabled = message.type === 'regular' && message.status === 'received'; - const MessageUIComponent = propMessage ?? contextMessageUI; const { onUserClick, onUserHover } = useUserHandler(message, { onUserClickHandler: propOnUserClick, @@ -241,7 +235,6 @@ export const Message = (props: MessageProps) => { lastOwnMessage={props.lastOwnMessage} lastReceivedId={props.lastReceivedId} message={message} - Message={props.Message} messageActions={props.messageActions} messageListRect={props.messageListRect} onMentionsClickMessage={onMentionsClick} diff --git a/src/components/Message/__tests__/Message.test.tsx b/src/components/Message/__tests__/Message.test.tsx index 342110350..4c5fbf8e0 100644 --- a/src/components/Message/__tests__/Message.test.tsx +++ b/src/components/Message/__tests__/Message.test.tsx @@ -160,7 +160,9 @@ async function renderComponent({ , + MessageUI: () => ( + + ), reactionOptions: defaultReactionOptions, ...components, }} @@ -877,7 +879,7 @@ describe(' component', () => { const UIMock = vi.fn(() =>
UI mock
); const { rerender } = await renderComponent({ - components: { Message: UIMock }, + components: { MessageUI: UIMock }, message, }); @@ -886,7 +888,7 @@ describe(' component', () => { UIMock.mockClear(); await renderComponent({ - components: { Message: UIMock }, + components: { MessageUI: UIMock }, message: updatedMessage, renderer: rerender, }); @@ -899,7 +901,7 @@ describe(' component', () => { const UIMock = vi.fn(() =>
UI mock
); const { rerender } = await renderComponent({ - components: { Message: UIMock }, + components: { MessageUI: UIMock }, message, }); @@ -907,7 +909,7 @@ describe(' component', () => { UIMock.mockClear(); await renderComponent({ - components: { Message: UIMock }, + components: { MessageUI: UIMock }, message, props: { readBy: [bob] }, renderer: rerender, @@ -921,7 +923,7 @@ describe(' component', () => { const UIMock = vi.fn(() =>
UI mock
); const { rerender } = await renderComponent({ - components: { Message: UIMock }, + components: { MessageUI: UIMock }, message, props: { groupStyles: ['bottom'] }, }); @@ -930,7 +932,7 @@ describe(' component', () => { UIMock.mockClear(); await renderComponent({ - components: { Message: UIMock }, + components: { MessageUI: UIMock }, message, props: { groupStyles: ['bottom', 'left'] as any }, renderer: rerender, @@ -944,7 +946,7 @@ describe(' component', () => { const UIMock = vi.fn(() =>
UI mock
); const { rerender } = await renderComponent({ - components: { Message: UIMock }, + components: { MessageUI: UIMock }, message, props: { lastReceivedId: 'last-received-id-1' }, }); @@ -953,7 +955,7 @@ describe(' component', () => { UIMock.mockClear(); await renderComponent({ - components: { Message: UIMock }, + components: { MessageUI: UIMock }, message, props: { lastReceivedId: 'last-received-id-2' }, renderer: rerender, @@ -967,7 +969,7 @@ describe(' component', () => { const UIMock = vi.fn(() =>
UI mock
); const { rerender } = await renderComponent({ - components: { Message: UIMock }, + components: { MessageUI: UIMock }, message, props: { messageListRect: fromPartial({ @@ -983,7 +985,7 @@ describe(' component', () => { UIMock.mockClear(); await renderComponent({ - components: { Message: UIMock }, + components: { MessageUI: UIMock }, message, props: { messageListRect: fromPartial({ diff --git a/src/components/Message/__tests__/MessageText.test.tsx b/src/components/Message/__tests__/MessageText.test.tsx index f41651be6..9399636c4 100644 --- a/src/components/Message/__tests__/MessageText.test.tsx +++ b/src/components/Message/__tests__/MessageText.test.tsx @@ -96,7 +96,7 @@ async function renderMessageText({ customProps = {} } = {}) { value={mockComponentContext({ Attachment, - Message: () => , + MessageUI: () => , reactionOptions: defaultReactionOptions, })} > diff --git a/src/components/Message/__tests__/MessageUI.test.tsx b/src/components/Message/__tests__/MessageUI.test.tsx index b36e36331..22f8872d7 100644 --- a/src/components/Message/__tests__/MessageUI.test.tsx +++ b/src/components/Message/__tests__/MessageUI.test.tsx @@ -170,7 +170,7 @@ describe('', () => { , + MessageUI: () => , reactionOptions: defaultReactionOptions, ...components, }} diff --git a/src/components/Message/__tests__/QuotedMessage.test.tsx b/src/components/Message/__tests__/QuotedMessage.test.tsx index df2254a1f..ccde81aa2 100644 --- a/src/components/Message/__tests__/QuotedMessage.test.tsx +++ b/src/components/Message/__tests__/QuotedMessage.test.tsx @@ -76,7 +76,7 @@ async function renderQuotedMessage({ > , + MessageUI: () => , ...componentContext, })} > diff --git a/src/components/Message/types.ts b/src/components/Message/types.ts index 80a046d9f..1ac688a7d 100644 --- a/src/components/Message/types.ts +++ b/src/components/Message/types.ts @@ -7,7 +7,6 @@ import type { MessageActionsArray } from './utils'; import type { GroupStyle } from '../MessageList/utils'; import type { MessageComposerProps } from '../MessageComposer'; import type { ReactionsComparator } from '../Reactions/types'; -import type { ComponentContextValue } from '../../context/ComponentContext'; import type { MessageContextValue } from '../../context/MessageContext'; import type { RenderTextFunction } from './renderText'; @@ -41,10 +40,6 @@ export type MessageProps = { // todo: could be moved to the Channel instance reactive state as lastReceivedMessage keeping the the receipt status as well (useful for channel preview) /** Latest message id on current channel */ lastReceivedId?: string | null; - /** UI component to display a Message in MessageList, overrides value in [ComponentContext](https://getstream.io/chat/docs/sdk/react/contexts/component_context/#message) - * @deprecated use `ComponentContext` (`WithComponents`) component override instead (`MessageUI` slot) - */ - Message?: ComponentContextValue['MessageUI']; /** Array of allowed message actions (ex: ['edit', 'delete', 'flag', 'mute', 'pin', 'quote', 'react', 'reply']). To disable all actions, provide an empty array. */ messageActions?: MessageActionsArray; /** DOMRect object for parent MessageList component */ diff --git a/src/components/Message/utils.tsx b/src/components/Message/utils.tsx index b9b665f16..830c6836f 100644 --- a/src/components/Message/utils.tsx +++ b/src/components/Message/utils.tsx @@ -208,11 +208,11 @@ export const areMessagePropsEqual = ( showDetailedReactions?: boolean; }, ) => { - const { message: prevMessage, Message: prevMessageUI } = prevProps; - const { message: nextMessage, Message: nextMessageUI } = nextProps; - - if (prevMessageUI !== nextMessageUI) return false; + const { message: prevMessage } = prevProps; + const { message: nextMessage } = nextProps; + // The message UI component itself is not compared here: it is resolved from + // `ComponentContext`, and context updates re-render consumers regardless of `React.memo`. if (nextProps.showDetailedReactions !== prevProps.showDetailedReactions) { return false; } diff --git a/src/components/MessageList/MessageList.tsx b/src/components/MessageList/MessageList.tsx index 0c65a42bb..e53e72bb6 100644 --- a/src/components/MessageList/MessageList.tsx +++ b/src/components/MessageList/MessageList.tsx @@ -211,7 +211,6 @@ const MessageListWithContext = (props: MessageListWithContextProps) => { closeReactionSelectorOnClick: props.closeReactionSelectorOnClick, disableQuotedMessages: props.disableQuotedMessages, formatDate: props.formatDate, - Message: props.Message, messageActions, messageListRect: wrapperRect, onMentionsClick: props.onMentionsClick, @@ -439,7 +438,6 @@ type PropsDrilledToMessage = | 'closeReactionSelectorOnClick' | 'disableQuotedMessages' | 'formatDate' - | 'Message' | 'messageActions' | 'onMentionsClick' | 'onMentionsHover' diff --git a/src/components/MessageList/VirtualizedMessageList.tsx b/src/components/MessageList/VirtualizedMessageList.tsx index 195715e03..48d407b10 100644 --- a/src/components/MessageList/VirtualizedMessageList.tsx +++ b/src/components/MessageList/VirtualizedMessageList.tsx @@ -33,8 +33,7 @@ import { NewMessageNotification as DefaultNewMessageNotification } from './NewMe import { MessageListMainPanel as DefaultMessageListMainPanel } from './MessageListMainPanel'; import type { GroupStyle, ProcessMessagesParams, RenderedMessage } from './utils'; import { getGroupStyles, getLastReceived, processMessages } from './utils'; -import type { MessageProps, MessageUIComponentProps } from '../Message'; -import { MessageUI } from '../Message'; +import type { MessageProps } from '../Message'; import { UnreadMessagesNotification as DefaultUnreadMessagesNotification } from './UnreadMessagesNotification'; import { calculateFirstItemIndex, @@ -53,7 +52,7 @@ import { import { DateSeparator as DefaultDateSeparator } from '../DateSeparator'; import { EventComponent as DefaultMessageSystem } from '../EventComponent'; -import { DialogManagerProvider, useChannel } from '../../context'; +import { DialogManagerProvider, useChannel, WithComponents } from '../../context'; import type { ChatContextValue } from '../../context/ChatContext'; import { useChatContext } from '../../context/ChatContext'; import type { ComponentContextValue } from '../../context/ComponentContext'; @@ -93,7 +92,6 @@ type VirtualizedMessageListPropsForContext = | 'customMessageRenderer' | 'head' // | 'loadingMore' - | 'Message' | 'returnAllReadData' | 'shouldGroupByUser'; @@ -227,7 +225,6 @@ const VirtualizedMessageListWithContext = ( // loadMore, // loadMoreNewer, maxTimeBetweenGroupedMessages, - Message: MessageUIComponentFromProps, messageActions, // messageLimit = DEFAULT_NEXT_CHANNEL_PAGE_SIZE, // messages, @@ -272,9 +269,15 @@ const VirtualizedMessageListWithContext = ( TypingIndicator, UnreadMessagesNotification = DefaultUnreadMessagesNotification, UnreadMessagesSeparator = DefaultUnreadMessagesSeparator, - VirtualMessage: MessageUIComponentFromContext = MessageUI, + VirtualMessage, } = useComponentContext(); - const MessageUIComponent = MessageUIComponentFromProps || MessageUIComponentFromContext; + // `VirtualMessage` overrides `MessageUI` for the messages this list renders, so it is + // applied to this subtree's `ComponentContext` rather than drilled down to each `Message`. + // Memoized so the provider does not hand every consumer below a new value each render. + const virtualMessageOverrides = useMemo( + () => ({ MessageUI: VirtualMessage }), + [VirtualMessage], + ); const { client, customClasses } = useChatContext(); const messagePaginator = useMessagePaginator(); @@ -531,7 +534,7 @@ const VirtualizedMessageListWithContext = ( ? `virtualized-message-list-dialog-manager-thread-${id}` : `virtualized-message-list-dialog-manager-${id}`; - return ( + const list = ( @@ -580,7 +583,6 @@ const VirtualizedMessageListWithContext = ( lastReadMessageId: channelUnreadUiState?.lastReadMessageId, lastReceivedMessageId, loadingMore: isLoading, - Message: MessageUIComponent, messageActions, messageGroupStyles, MessageSystem, @@ -648,6 +650,13 @@ const VirtualizedMessageListWithContext = ( ); + + // Only wrap when there is something to override, so the common case adds no provider. + return VirtualMessage ? ( + {list} + ) : ( + list + ); }; export type VirtualizedMessageListProps = Partial< @@ -699,8 +708,6 @@ export type VirtualizedMessageListProps = Partial< // loadMoreNewer?: () => Promise; /** Maximum time in milliseconds that should occur between messages to still consider them grouped together */ maxTimeBetweenGroupedMessages?: number; - /** Custom UI component to display a message, defaults to and accepts same props as [MessageSimple](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Message/MessageSimple.tsx) */ - Message?: React.ComponentType; // /** The limit to use when paginating messages */ // messageLimit?: number; /** Optional prop to override the messages available from the active message paginator. */ diff --git a/src/components/MessageList/VirtualizedMessageListComponents.tsx b/src/components/MessageList/VirtualizedMessageListComponents.tsx index 4c4eeb747..3f5233d03 100644 --- a/src/components/MessageList/VirtualizedMessageListComponents.tsx +++ b/src/components/MessageList/VirtualizedMessageListComponents.tsx @@ -118,7 +118,6 @@ export const messageRenderer = ( lastReadDate, lastReadMessageId, lastReceivedMessageId, - Message: MessageUIComponent, messageActions, messageGroupStyles, MessageSystem, @@ -185,7 +184,6 @@ export const messageRenderer = ( lastOwnMessage={lastOwnMessage} lastReceivedId={lastReceivedMessageId} message={message} - Message={MessageUIComponent} messageActions={messageActions} reactionDetailsSort={reactionDetailsSort} readBy={ownMessagesReadByOthers[message.id] || []} diff --git a/src/components/MessageList/__tests__/MessageList.test.tsx b/src/components/MessageList/__tests__/MessageList.test.tsx index c081862bd..13dc2e28d 100644 --- a/src/components/MessageList/__tests__/MessageList.test.tsx +++ b/src/components/MessageList/__tests__/MessageList.test.tsx @@ -1679,7 +1679,7 @@ describe('MessageList', () => { }); }); - describe('props forwarded to Message', () => { + describe('MessageUI override', () => { it.each([[true], [false]])( 'invokes handleMarkUnread from Message context (shouldFail: %s)', async (shouldFail) => { @@ -1687,7 +1687,7 @@ describe('MessageList', () => { if (shouldFail) markUnreadSpy.mockRejectedValueOnce(undefined!); const message = generateMessage(); - const Message = () => { + const MessageUI = () => { const { handleMarkUnread } = useMessageContext(); useEffect(() => { const event = fromPartial({ @@ -1702,7 +1702,8 @@ describe('MessageList', () => { renderComponent({ channelProps: { channel }, chatClient, - msgListProps: { Message, messages: [message] }, + components: { MessageUI }, + msgListProps: { messages: [message] }, }); }); diff --git a/src/components/MessageList/__tests__/VirtualizedMessageList.test.tsx b/src/components/MessageList/__tests__/VirtualizedMessageList.test.tsx index 7cbddcec1..55c5d39e8 100644 --- a/src/components/MessageList/__tests__/VirtualizedMessageList.test.tsx +++ b/src/components/MessageList/__tests__/VirtualizedMessageList.test.tsx @@ -17,6 +17,7 @@ import { VirtualizedMessageList } from '../VirtualizedMessageList'; import { Chat } from '../../Chat'; import { Channel } from '../../Channel'; +import { useComponentContext, WithComponents } from '../../../context'; vi.mock('react-virtuoso', async () => { const { Virtuoso } = await import('react-virtuoso'); @@ -99,6 +100,81 @@ describe('VirtualizedMessageList', () => { }); expect(result.container).toMatchSnapshot(); }); + + // The `VirtualMessage` override has to beat `MessageUI`, but only for messages rendered + // by this list. The list applies it by overriding `MessageUI` for its own subtree, so + // these assert on what `useComponentContext()` resolves to inside vs. outside the list. + // Virtuoso renders no items under jsdom (it measures document height), so `head` is the + // in-subtree render point available to us — it is the same provider subtree the items + // render into. + describe('VirtualMessage override', () => { + const CustomMessageUI = () =>
; + const CustomVirtualMessage = () =>
; + + const ResolvedMessageUIProbe = ({ label }: { label: string }) => { + const { MessageUI } = useComponentContext(); + return ( +
+ ); + }; + + const resolvedAt = (container: HTMLElement, label: string) => + container.querySelector(`[data-probe="${label}"]`)?.getAttribute('data-resolved') ?? + null; + + const renderWithOverrides = async ( + overrides: Parameters[0]['overrides'], + ) => { + const { channel, client } = await createChannel(); + vi.mocked(nanoid).mockReturnValue('mockedId'); + + let result: RenderResult; + await act(() => { + result = render( + + + + + } + /> + + + , + ); + }); + + return result!.container; + }; + + it('takes precedence over MessageUI for messages rendered by the list', async () => { + const container = await renderWithOverrides({ + MessageUI: CustomMessageUI, + VirtualMessage: CustomVirtualMessage, + }); + + expect(resolvedAt(container, 'inside')).toBe('CustomVirtualMessage'); + }); + + it('does not leak outside the list', async () => { + const container = await renderWithOverrides({ + MessageUI: CustomMessageUI, + VirtualMessage: CustomVirtualMessage, + }); + + expect(resolvedAt(container, 'outside')).toBe('CustomMessageUI'); + }); + + it('leaves the MessageUI override in place when unset', async () => { + const container = await renderWithOverrides({ MessageUI: CustomMessageUI }); + + expect(resolvedAt(container, 'inside')).toBe('CustomMessageUI'); + expect(resolvedAt(container, 'outside')).toBe('CustomMessageUI'); + }); + }); }); describe('usePrependedMessagesCount', () => { diff --git a/src/components/MessageList/__tests__/VirtualizedMessageListComponents.test.tsx b/src/components/MessageList/__tests__/VirtualizedMessageListComponents.test.tsx index e88ab196c..37c3cd9d7 100644 --- a/src/components/MessageList/__tests__/VirtualizedMessageListComponents.test.tsx +++ b/src/components/MessageList/__tests__/VirtualizedMessageListComponents.test.tsx @@ -321,7 +321,7 @@ describe('VirtualizedMessageComponents', () => { it('should forward message group styles', () => { const virtuosoRef = { current: {} }; let groupStylesMessageContext: GroupStyle[]; - const Message = () => { + const GroupStylesProbe = () => { const { groupStyles } = useMessageContext(); groupStylesMessageContext = groupStyles; return null; @@ -332,7 +332,6 @@ describe('VirtualizedMessageComponents', () => { <> {processedMessages.map((_, numItemsPrepended) => { const virtuosoContext = { - Message, messageGroupStyles, numItemsPrepended, ownMessagesDeliveredToOthers: {}, @@ -352,6 +351,7 @@ describe('VirtualizedMessageComponents', () => { ); })} , + { MessageUI: GroupStylesProbe }, ); expect(groupStylesMessageContext).toStrictEqual([ messageGroupStyles[processedMessages[0].id], @@ -466,7 +466,7 @@ describe('VirtualizedMessageComponents', () => { }), ); - const Message = () =>
; + const MessageUI = () =>
; const renderMarkUnread = async ({ virtuosoContext, @@ -491,7 +491,7 @@ describe('VirtualizedMessageComponents', () => { result = render( - + {messageRenderer(virtuosoIndex ?? PREPEND_OFFSET, undefined, ctx)} @@ -507,7 +507,6 @@ describe('VirtualizedMessageComponents', () => { lastReadDate: new Date(messages[0].created_at), lastReadMessageId: messages[0].id, lastReceivedMessageId: messages[1].id, - Message, messageGroupStyles: {}, numItemsPrepended: 1, ownMessagesDeliveredToOthers: {}, @@ -533,7 +532,6 @@ describe('VirtualizedMessageComponents', () => { lastReadDate: new Date(messages[1].created_at), lastReadMessageId: messages[1].id, lastReceivedMessageId: messages[1].id, - Message, messageGroupStyles: {}, numItemsPrepended: 1, ownMessagesDeliveredToOthers: {}, @@ -560,7 +558,6 @@ describe('VirtualizedMessageComponents', () => { firstUnreadMessageId: messages[1].id, lastReadMessageId: messages[0].id, lastReceivedMessageId: messages[1].id, - Message, messageGroupStyles: {}, numItemsPrepended: 1, ownMessagesDeliveredToOthers: {}, @@ -582,7 +579,6 @@ describe('VirtualizedMessageComponents', () => { virtuosoContext: { lastReadMessageId: messages[0].id, lastReceivedMessageId: messages[1].id, - Message, messageGroupStyles: {}, numItemsPrepended: 1, ownMessagesDeliveredToOthers: {}, @@ -604,7 +600,6 @@ describe('VirtualizedMessageComponents', () => { virtuosoContext: { lastReadMessageId: messages[0].id, lastReceivedMessageId: messages[1].id, - Message, messageGroupStyles: {}, numItemsPrepended: 0, ownMessagesDeliveredToOthers: {}, @@ -664,7 +659,6 @@ describe('VirtualizedMessageComponents', () => { <> {processedMessages.map((_, numItemsPrepended) => { const virtuosoContext = { - Message: MessageUI, messageGroupStyles, numItemsPrepended, ownMessagesDeliveredToOthers: {}, @@ -685,6 +679,7 @@ describe('VirtualizedMessageComponents', () => { ); })} , + { MessageUI }, ); const messageElements = container.querySelectorAll('.str-chat__message'); diff --git a/src/components/Thread/Thread.tsx b/src/components/Thread/Thread.tsx index b7c4b5f99..c35368c78 100644 --- a/src/components/Thread/Thread.tsx +++ b/src/components/Thread/Thread.tsx @@ -20,7 +20,7 @@ import { useThreadContext } from '../Threads'; import { useStateStore } from '../../store'; import { useThreadRequestHandlers } from './hooks/useThreadRequestHandlers'; -import type { MessageProps, MessageUIComponentProps } from '../Message/types'; +import type { MessageProps } from '../Message/types'; import type { MessageActionsArray } from '../Message/utils'; import type { DeleteMessageOptions, @@ -52,8 +52,6 @@ export type ThreadProps = { autoFocus?: boolean; /** Injects date separator components into `Thread`, defaults to `false`. To be passed to the underlying `MessageList` or `VirtualizedMessageList` components */ enableDateSeparator?: boolean; - /** Custom thread message UI component used to override the default `Message` value stored in `ComponentContext` */ - Message?: React.ComponentType; /** Array of allowed message actions (ex: ['edit', 'delete', 'flag', 'mute', 'pin', 'quote', 'react', 'reply']). To disable all actions, provide an empty array. */ messageActions?: MessageActionsArray; /** Custom action handler to override the default `client.deleteMessage(message.id)` function in thread flows */ @@ -137,18 +135,13 @@ const ThreadInner = (props: ThreadProps & { key: string }) => { doSendMessageRequest, doUpdateMessageRequest, enableDateSeparator = false, - Message: PropMessage, messageActions = Object.keys(MESSAGE_ACTIONS), virtualized, } = props; const threadInstance = useThreadContext(); const { client, customClasses } = useChatContext(); - const { - Message: ContextMessage, - ThreadHead = DefaultThreadHead, - ThreadHeader = DefaultThreadHeader, - VirtualMessage, - } = useComponentContext(); + const { ThreadHead = DefaultThreadHead, ThreadHeader = DefaultThreadHeader } = + useComponentContext(); const { isStateStale, parentMessage } = useStateStore(threadInstance?.state, selector) ?? {}; @@ -176,10 +169,8 @@ const ThreadInner = (props: ThreadProps & { key: string }) => { threadInstance?.deactivate(); }, [closeThreadPanel, threadInstance]); - const ThreadMessage = PropMessage || additionalMessageListProps?.Message; - const FallbackMessage = virtualized && VirtualMessage ? VirtualMessage : ContextMessage; - const MessageUIComponent = ThreadMessage || FallbackMessage; - + // The thread message UI comes from `ComponentContext` (`MessageUI`, or `VirtualMessage` + // which the virtualized list applies to its own subtree), so nothing is resolved here. const ThreadMessageList = virtualized ? VirtualizedMessageList : MessageList; useThreadRequestHandlers({ doDeleteMessageRequest, @@ -244,7 +235,6 @@ const ThreadInner = (props: ThreadProps & { key: string }) => { ); @@ -265,7 +255,6 @@ const ThreadInner = (props: ThreadProps & { key: string }) => { { const additionalMessageListProps = { loadingMore: false, }; - renderComponent({ - threadProps: { - additionalMessageListProps, - Message: MessageMock as ThreadProps['Message'], - }, - }); + renderComponent({ threadProps: { additionalMessageListProps } }); expect(MessageListMock).toHaveBeenCalledWith( expect.objectContaining({ @@ -151,7 +145,6 @@ describe('Thread', () => { head: expect.objectContaining({ type: expect.objectContaining({ name: 'ThreadHead' }), }), - Message: MessageMock, messageActions: expect.any(Array), ...additionalMessageListProps, }), @@ -164,11 +157,7 @@ describe('Thread', () => { loadingMore: false, }; renderComponent({ - threadProps: { - additionalMessageListProps, - enableDateSeparator: true, - Message: MessageMock as ThreadProps['Message'], - }, + threadProps: { additionalMessageListProps, enableDateSeparator: true }, }); expect(MessageListMock).toHaveBeenCalledWith( @@ -177,7 +166,6 @@ describe('Thread', () => { head: expect.objectContaining({ type: expect.objectContaining({ name: 'ThreadHead' }), }), - Message: MessageMock, messageActions: expect.any(Array), ...additionalMessageListProps, }), diff --git a/src/context/ComponentContext.tsx b/src/context/ComponentContext.tsx index ea58d9ee5..0b821a4dd 100644 --- a/src/context/ComponentContext.tsx +++ b/src/context/ComponentContext.tsx @@ -180,10 +180,6 @@ export type ComponentContextValue = { ProgressIndicator?: React.ComponentType; /** Custom UI component to display a message in the standard `MessageList`, defaults to and accepts the same props as: [MessageUI](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Message/MessageUI.tsx) */ MessageUI?: React.ComponentType; - /** Custom UI component to display a message in the standard `MessageList`, defaults to and accepts the same props as: [MessageUI](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Message/MessageUI.tsx) - * @deprecated use `MessageUI` instead - */ - Message?: React.ComponentType; /** Custom UI component for message actions popup, accepts no props, all the defaults are set within [MessageActions (unstable)](https://github.com/GetStream/stream-chat-react/blob/master/src/experimental/MessageActions/MessageActions.tsx) */ MessageActions?: React.ComponentType; /** Custom UI component to display the contents of a bounced message modal. Usually it allows to retry, edit, or delete the message. Defaults to and accepts the same props as: [MessageBouncePrompt](https://github.com/GetStream/stream-chat-react/blob/master/src/components/MessageBounce/MessageBouncePrompt.tsx) */ diff --git a/src/context/WithComponents.tsx b/src/context/WithComponents.tsx index 4f67a0da7..621d6fcb0 100644 --- a/src/context/WithComponents.tsx +++ b/src/context/WithComponents.tsx @@ -1,4 +1,4 @@ -import React, { useContext } from 'react'; +import React, { useContext, useMemo } from 'react'; import type { PropsWithChildren } from 'react'; import { ComponentContext } from './ComponentContext'; @@ -9,7 +9,14 @@ export function WithComponents({ overrides, }: PropsWithChildren<{ overrides: Partial }>) { const parentOverrides = useContext(ComponentContext); - const actualOverrides: ComponentContextValue = { ...parentOverrides, ...overrides }; + // Memoized because this provider sits on render paths hot enough for the identity of the + // merged value to matter: a new object on every render re-renders every + // `useComponentContext()` consumer below it, which would defeat the per-message + // memoization in `areMessagePropsEqual`. + const actualOverrides: ComponentContextValue = useMemo( + () => ({ ...parentOverrides, ...overrides }), + [parentOverrides, overrides], + ); return ( {children}