From 6f4b5b992464270dda23133337e9f4007dfb5f7a Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Tue, 14 Apr 2026 13:59:46 +0200 Subject: [PATCH 01/11] fix: message host customizability --- .../SampleApp/src/screens/ChannelScreen.tsx | 11 +++ package/src/components/Message/Message.tsx | 84 +++++++++++++---- .../MessageItemView/__tests__/Message.test.js | 81 ++++++++++++++-- .../Message/MessageOverlayWrapper.tsx | 94 +++++++++++++++++++ .../useShouldUseOverlayStyles.test.tsx | 6 ++ .../Message/hooks/useCreateMessageContext.ts | 11 +++ .../hooks/useShouldUseOverlayStyles.ts | 11 ++- package/src/components/index.ts | 1 + .../messageContext/MessageContext.tsx | 31 ++++++ 9 files changed, 301 insertions(+), 29 deletions(-) create mode 100644 package/src/components/Message/MessageOverlayWrapper.tsx diff --git a/examples/SampleApp/src/screens/ChannelScreen.tsx b/examples/SampleApp/src/screens/ChannelScreen.tsx index b9329d4c34..d35b692699 100644 --- a/examples/SampleApp/src/screens/ChannelScreen.tsx +++ b/examples/SampleApp/src/screens/ChannelScreen.tsx @@ -5,8 +5,10 @@ import { AlsoSentToChannelHeaderPressPayload, Channel, MessageComposer, + MessageContent as DefaultMessageContent, MessageList, MessageFlashList, + MessageOverlayWrapper, ThreadContextValue, useAttachmentPickerContext, useChannelPreviewDisplayName, @@ -50,6 +52,14 @@ export type ChannelHeaderProps = { channel: StreamChatChannel; }; +const OverlayTargetedMessageContent = ( + props: React.ComponentProps, +) => ( + + + +); + const ChannelHeader: React.FC = ({ channel }) => { const { closePicker } = useAttachmentPickerContext(); const membersStatus = useChannelMembersStatus(channel); @@ -275,6 +285,7 @@ export const ChannelScreen: React.FC = ({ navigation, route initialScrollToFirstUnreadMessage keyboardVerticalOffset={0} messageActions={messageActions} + // MessageContent={OverlayTargetedMessageContent} MessageLocation={MessageLocation} messageId={messageId} NetworkDownIndicator={() => null} diff --git a/package/src/components/Message/Message.tsx b/package/src/components/Message/Message.tsx index 6ee7445381..bddca5513f 100644 --- a/package/src/components/Message/Message.tsx +++ b/package/src/components/Message/Message.tsx @@ -19,6 +19,7 @@ import { useMessageActions } from './hooks/useMessageActions'; import { useMessageDeliveredData } from './hooks/useMessageDeliveryData'; import { useMessageReadData } from './hooks/useMessageReadData'; import { useProcessReactions } from './hooks/useProcessReactions'; +import { MessageOverlayWrapper } from './MessageOverlayWrapper'; import { measureInWindow } from './utils/measureInWindow'; import { messageActions as defaultMessageActions } from './utils/messageActions'; @@ -335,11 +336,67 @@ const MessageWithContext = (props: MessagePropsWithContext) => { const rectRef = useRef(undefined); const bubbleRect = useRef(undefined); const contextMenuAnchorRef = useRef(null); + const messageOverlayTargetsRef = useRef< + Array<{ id: string; isDefault: boolean; view: View | null }> + >([]); + const [activeMessageOverlayTargetId, setActiveMessageOverlayTargetId] = useState< + string | undefined + >(undefined); + const [hasCustomMessageOverlayTarget, setHasCustomMessageOverlayTarget] = useState(false); + const syncMessageOverlayTargetsState = useStableCallback(() => { + const registeredTargets = messageOverlayTargetsRef.current; + const customTargets = registeredTargets.filter((target) => !target.isDefault && target.view); + const defaultTargets = registeredTargets.filter((target) => target.isDefault && target.view); + const activeTarget = + customTargets[customTargets.length - 1] ?? defaultTargets[defaultTargets.length - 1]; + + setActiveMessageOverlayTargetId(activeTarget?.id); + setHasCustomMessageOverlayTarget(customTargets.length > 0); + }); + const registerMessageOverlayTarget = useStableCallback( + ({ id, isDefault, view }: { id: string; isDefault: boolean; view: View | null }) => { + const existingTargetIndex = messageOverlayTargetsRef.current.findIndex( + (target) => target.id === id, + ); + + if (existingTargetIndex === -1) { + messageOverlayTargetsRef.current = [ + ...messageOverlayTargetsRef.current, + { id, isDefault, view }, + ]; + } else { + messageOverlayTargetsRef.current = messageOverlayTargetsRef.current.map((target, index) => + index === existingTargetIndex ? { id, isDefault, view } : target, + ); + } + + syncMessageOverlayTargetsState(); + }, + ); + const unregisterMessageOverlayTarget = useStableCallback((id: string) => { + messageOverlayTargetsRef.current = messageOverlayTargetsRef.current.filter( + (target) => target.id !== id, + ); + syncMessageOverlayTargetsState(); + }); const showMessageOverlay = useStableCallback(async () => { dismissKeyboard(); try { - const layout = await measureInWindow(messageWrapperRef, insets); + const customTargets = messageOverlayTargetsRef.current.filter( + (target) => !target.isDefault && target.view, + ); + const defaultTargets = messageOverlayTargetsRef.current.filter( + (target) => target.isDefault && target.view, + ); + const activeTarget = + customTargets[customTargets.length - 1] ?? defaultTargets[defaultTargets.length - 1]; + + if (!activeTarget?.view) { + throw new Error('No message overlay target is registered for this message.'); + } + + const layout = await measureInWindow({ current: activeTarget.view }, insets); const bubbleLayout = await measureInWindow(contextMenuAnchorRef, insets).catch(() => layout); rectRef.current = layout; @@ -667,8 +724,6 @@ const MessageWithContext = (props: MessagePropsWithContext) => { unpinMessage: handleTogglePinMessage, }; - const messageWrapperRef = useRef(null); - const onLongPress = () => { setNativeScrollability(false); if (hasAttachmentActions || isBlockedMessage(message) || !enableLongPress) { @@ -704,9 +759,11 @@ const MessageWithContext = (props: MessagePropsWithContext) => { handleReaction, handleToggleReaction, hasReactions, + activeMessageOverlayTargetId, images: attachments.images, isMessageAIGenerated, isMyMessage, + hasCustomMessageOverlayTarget, lastGroupMessage: groupStyles?.[0] === 'single' || groupStyles?.[0] === 'bottom', members, message: overlayActive ? frozenMessage.current : message, @@ -783,6 +840,8 @@ const MessageWithContext = (props: MessagePropsWithContext) => { onThreadSelect, otherAttachments: attachments.other, preventPress: overlayActive ? true : preventPress, + registerMessageOverlayTarget, + unregisterMessageOverlayTarget, reactions, readBy, setQuotedMessage, @@ -830,14 +889,6 @@ const MessageWithContext = (props: MessagePropsWithContext) => { return ( - {overlayActive && rect ? ( - - ) : null} {/*TODO: V9: Find a way to separate these in a dedicated file*/} {overlayActive && rect && overlayItemsAnchorRect ? ( @@ -863,14 +914,9 @@ const MessageWithContext = (props: MessagePropsWithContext) => { ) : null} - - - - - + + + {showMessageReactions ? ( { + const shouldUseOverlayStyles = useShouldUseOverlayStyles(); + + return {`${label}:${shouldUseOverlayStyles ? 'overlay' : 'normal'}`}; +}; + +const OverlayTrigger = () => { + const { onLongPress } = useMessageContext(); + + return ( + onLongPress({ emitter: 'message' })} + testID='custom-overlay-trigger' + > + Open overlay + + ); +}; + +const CustomMessageItemView = () => ( + + + + + + + +); describe('Message', () => { let channel; @@ -37,16 +71,25 @@ describe('Message', () => { useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); channel = chatClient.channel('messaging', mockedChannel.id); - renderMessage = (options) => + renderMessage = (options, channelProps) => render( - - - - - - + + + + + + + + , ); @@ -88,4 +131,28 @@ describe('Message', () => { expect(onLongPressMessage).toHaveBeenCalledTimes(1); }); }); + + it('teleports a custom overlay target without applying overlay styles to siblings', async () => { + const message = generateMessage({ user }); + const { getByTestId, getByText } = renderMessage( + { message }, + { + MessageItemView: CustomMessageItemView, + }, + ); + + await waitFor(() => { + expect(getByTestId('custom-message-item-view')).toBeTruthy(); + expect(getByText('outside:normal')).toBeTruthy(); + expect(getByText('inside:normal')).toBeTruthy(); + }); + + fireEvent(getByTestId('custom-overlay-trigger'), 'longPress'); + + await waitFor(() => { + expect(getByText('outside:normal')).toBeTruthy(); + expect(getByText('inside:overlay')).toBeTruthy(); + expect(getByTestId('custom-overlay-target-placeholder')).toBeTruthy(); + }); + }); }); diff --git a/package/src/components/Message/MessageOverlayWrapper.tsx b/package/src/components/Message/MessageOverlayWrapper.tsx new file mode 100644 index 0000000000..38e79fadbe --- /dev/null +++ b/package/src/components/Message/MessageOverlayWrapper.tsx @@ -0,0 +1,94 @@ +import React, { PropsWithChildren, useEffect, useRef } from 'react'; +import { LayoutChangeEvent, View } from 'react-native'; + +import { Portal } from 'react-native-teleport'; + +import { + MessageOverlayTargetProvider, + useMessageContext, +} from '../../contexts/messageContext/MessageContext'; +import { useStableCallback } from '../../hooks'; +import { useIsOverlayActive } from '../../state-store'; +import { generateRandomId } from '../../utils/utils'; + +export type MessageOverlayWrapperProps = PropsWithChildren<{ + /** + * Marks this wrapper as the default whole-message overlay target. + * Integrators should not set this manually. + */ + isDefault?: boolean; + /** + * Optional test id attached to the wrapped target container. + */ + testID?: string; +}>; + +export const MessageOverlayWrapper = ({ + children, + isDefault = false, + testID, +}: MessageOverlayWrapperProps) => { + const { + activeMessageOverlayTargetId, + messageOverlayId, + registerMessageOverlayTarget, + unregisterMessageOverlayTarget, + } = useMessageContext(); + const { active: overlayActive } = useIsOverlayActive(messageOverlayId); + const placeholderLayoutRef = useRef({ h: 0, w: 0 }); + const registrationIdRef = useRef(`message-overlay-target-${generateRandomId()}`); + const registrationId = registrationIdRef.current; + const isActiveTarget = activeMessageOverlayTargetId === registrationId; + + const handleTargetRef = useStableCallback((view: View | null) => { + registerMessageOverlayTarget({ + id: registrationId, + isDefault, + view, + }); + }); + + const handleLayout = useStableCallback((event: LayoutChangeEvent) => { + const { + nativeEvent: { + layout: { height, width }, + }, + } = event; + + placeholderLayoutRef.current = { + h: height, + w: width, + }; + }); + + useEffect( + () => () => { + unregisterMessageOverlayTarget(registrationId); + }, + [registrationId, unregisterMessageOverlayTarget], + ); + + const placeholderLayout = placeholderLayoutRef.current; + + return ( + <> + + + + {children} + + + + {overlayActive && isActiveTarget ? ( + 0 ? placeholderLayout.w : '100%', + }} + testID={testID ? `${testID}-placeholder` : 'message-overlay-wrapper-placeholder'} + /> + ) : null} + + ); +}; diff --git a/package/src/components/Message/hooks/__tests__/useShouldUseOverlayStyles.test.tsx b/package/src/components/Message/hooks/__tests__/useShouldUseOverlayStyles.test.tsx index 7ae7133225..3a97393d43 100644 --- a/package/src/components/Message/hooks/__tests__/useShouldUseOverlayStyles.test.tsx +++ b/package/src/components/Message/hooks/__tests__/useShouldUseOverlayStyles.test.tsx @@ -15,6 +15,7 @@ const createMessageContextValue = (overrides: Partial): Mes ({ actionsEnabled: false, alignment: 'left', + activeMessageOverlayTargetId: undefined, channel: {} as MessageContextValue['channel'], deliveredToCount: 0, dismissOverlay: jest.fn(), @@ -22,6 +23,8 @@ const createMessageContextValue = (overrides: Partial): Mes groupStyles: [], handleAction: jest.fn(), handleToggleReaction: jest.fn(), + hasAttachmentActions: false, + hasCustomMessageOverlayTarget: false, hasReactions: false, images: [], isMessageAIGenerated: jest.fn(), @@ -29,6 +32,7 @@ const createMessageContextValue = (overrides: Partial): Mes lastGroupMessage: false, members: {}, message: generateMessage({ id: 'shared-message-id' }), + contextMenuAnchorRef: { current: null }, messageContentOrder: [], messageHasOnlySingleAttachment: false, messageOverlayId: 'message-overlay-default', @@ -38,6 +42,7 @@ const createMessageContextValue = (overrides: Partial): Mes onPress: jest.fn(), onPressIn: null, otherAttachments: [], + registerMessageOverlayTarget: jest.fn(), reactions: [], readBy: false, setQuotedMessage: jest.fn(), @@ -46,6 +51,7 @@ const createMessageContextValue = (overrides: Partial): Mes showReactionsOverlay: jest.fn(), showMessageStatus: false, threadList: false, + unregisterMessageOverlayTarget: jest.fn(), videos: [], ...overrides, }) as MessageContextValue; diff --git a/package/src/components/Message/hooks/useCreateMessageContext.ts b/package/src/components/Message/hooks/useCreateMessageContext.ts index 55e176e95d..55ab909e08 100644 --- a/package/src/components/Message/hooks/useCreateMessageContext.ts +++ b/package/src/components/Message/hooks/useCreateMessageContext.ts @@ -33,9 +33,11 @@ export const useCreateMessageContext = ({ images, isMessageAIGenerated, isMyMessage, + activeMessageOverlayTargetId, lastGroupMessage, members, message, + hasCustomMessageOverlayTarget, messageOverlayId, messageContentOrder, myMessageTheme, @@ -47,6 +49,8 @@ export const useCreateMessageContext = ({ onThreadSelect, otherAttachments, preventPress, + registerMessageOverlayTarget, + unregisterMessageOverlayTarget, reactions, readBy, showAvatar, @@ -88,6 +92,8 @@ export const useCreateMessageContext = ({ images, isMessageAIGenerated, isMyMessage, + activeMessageOverlayTargetId, + hasCustomMessageOverlayTarget, lastGroupMessage, members, message, @@ -102,6 +108,8 @@ export const useCreateMessageContext = ({ onThreadSelect, otherAttachments, preventPress, + registerMessageOverlayTarget, + unregisterMessageOverlayTarget, reactions, readBy, setQuotedMessage, @@ -120,6 +128,8 @@ export const useCreateMessageContext = ({ stableGroupStyles, hasAttachmentActions, hasReactions, + activeMessageOverlayTargetId, + hasCustomMessageOverlayTarget, messageHasOnlySingleAttachment, lastGroupMessage, membersValue, @@ -134,6 +144,7 @@ export const useCreateMessageContext = ({ showMessageStatus, threadList, preventPress, + unregisterMessageOverlayTarget, ], ); diff --git a/package/src/components/Message/hooks/useShouldUseOverlayStyles.ts b/package/src/components/Message/hooks/useShouldUseOverlayStyles.ts index 9baa0f2046..0390f35e5f 100644 --- a/package/src/components/Message/hooks/useShouldUseOverlayStyles.ts +++ b/package/src/components/Message/hooks/useShouldUseOverlayStyles.ts @@ -1,9 +1,14 @@ -import { useMessageContext } from '../../../contexts'; +import { useMessageContext, useMessageOverlayTargetContext } from '../../../contexts'; import { useIsOverlayActive } from '../../../state-store'; export const useShouldUseOverlayStyles = () => { - const { messageOverlayId } = useMessageContext(); + const { hasCustomMessageOverlayTarget, messageOverlayId } = useMessageContext(); + const isWithinMessageOverlayTarget = useMessageOverlayTargetContext(); const { active, closing } = useIsOverlayActive(messageOverlayId); - return active && !closing; + if (!active || closing) { + return false; + } + + return hasCustomMessageOverlayTarget ? isWithinMessageOverlayTarget : true; }; diff --git a/package/src/components/index.ts b/package/src/components/index.ts index 1cf41c4af6..60ab729169 100644 --- a/package/src/components/index.ts +++ b/package/src/components/index.ts @@ -87,6 +87,7 @@ export * from './Message/hooks/useStreamingMessage'; export * from './Message/hooks/useMessageDeliveryData'; export * from './Message/hooks/useMessageReadData'; export * from './Message/Message'; +export * from './Message/MessageOverlayWrapper'; export * from './Message/MessageItemView/MessageAuthor'; export * from './Message/MessageItemView/MessageBounce'; export * from './Message/MessageItemView/MessageBlocked'; diff --git a/package/src/contexts/messageContext/MessageContext.tsx b/package/src/contexts/messageContext/MessageContext.tsx index 934344e328..ebd2545483 100644 --- a/package/src/contexts/messageContext/MessageContext.tsx +++ b/package/src/contexts/messageContext/MessageContext.tsx @@ -64,6 +64,14 @@ export type MessageContextValue = { * Custom message renderers can attach this to a different subview if needed. */ contextMenuAnchorRef: React.RefObject; + /** + * Whether the current message renderer registered a custom subtree to teleport into the overlay. + */ + hasCustomMessageOverlayTarget: boolean; + /** + * Registration id for the currently active overlay target subtree. + */ + activeMessageOverlayTargetId?: string; /** * Stable UI-instance identifier for the rendered message. * Used for overlay state so two rendered instances of the same message do not collide. @@ -100,6 +108,16 @@ export type MessageContextValue = { onPressIn: ((payload: PressableHandlerPayload) => void) | null; /** The images attached to a message */ otherAttachments: Attachment[]; + /** + * Registers the subtree that should be measured and portaled into the message overlay. + * Custom message renderers typically interact with this via `MessageOverlayWrapper`. + */ + registerMessageOverlayTarget: (params: { + id: string; + isDefault: boolean; + view: View | null; + }) => void; + unregisterMessageOverlayTarget: (id: string) => void; reactions: ReactionSummary[]; /** Read count of the message */ readBy: number | boolean; @@ -164,3 +182,16 @@ export const useMessageContext = () => { return contextValue; }; + +const MessageOverlayTargetContext = React.createContext(false); + +export const MessageOverlayTargetProvider = ({ + children, + value, +}: PropsWithChildren<{ value: boolean }>) => ( + + {children} + +); + +export const useMessageOverlayTargetContext = () => useContext(MessageOverlayTargetContext); From da327c8c274514e522e91b07a81141f600e226e0 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Tue, 14 Apr 2026 15:53:07 +0200 Subject: [PATCH 02/11] chore: continue building poc --- .../SampleApp/src/screens/ChannelScreen.tsx | 5 +- package/src/components/Channel/Channel.tsx | 3 + .../Channel/hooks/useCreateMessagesContext.ts | 3 + package/src/components/Message/Message.tsx | 224 ++++++++---------- .../MessageItemView/__tests__/Message.test.js | 3 +- .../Message/MessageOverlayWrapper.tsx | 65 +++-- .../useShouldUseOverlayStyles.test.tsx | 16 +- .../Message/hooks/useCreateMessageContext.ts | 6 - .../hooks/useShouldUseOverlayStyles.ts | 14 +- .../Message/messageOverlayConstants.ts | 1 + .../messageContext/MessageContext.tsx | 36 ++- .../messagesContext/MessagesContext.tsx | 5 + 12 files changed, 197 insertions(+), 184 deletions(-) create mode 100644 package/src/components/Message/messageOverlayConstants.ts diff --git a/examples/SampleApp/src/screens/ChannelScreen.tsx b/examples/SampleApp/src/screens/ChannelScreen.tsx index d35b692699..72594b2904 100644 --- a/examples/SampleApp/src/screens/ChannelScreen.tsx +++ b/examples/SampleApp/src/screens/ChannelScreen.tsx @@ -55,7 +55,7 @@ export type ChannelHeaderProps = { const OverlayTargetedMessageContent = ( props: React.ComponentProps, ) => ( - + ); @@ -285,9 +285,10 @@ export const ChannelScreen: React.FC = ({ navigation, route initialScrollToFirstUnreadMessage keyboardVerticalOffset={0} messageActions={messageActions} - // MessageContent={OverlayTargetedMessageContent} + MessageContent={OverlayTargetedMessageContent} MessageLocation={MessageLocation} messageId={messageId} + messageOverlayTargetId='message-content' NetworkDownIndicator={() => null} onAlsoSentToChannelHeaderPress={onAlsoSentToChannelHeaderPress} thread={selectedThread} diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index 3a8542f53d..fcb66dd6dc 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -372,6 +372,7 @@ export type ChannelPropsWithContext = Pick & | 'MessageContentBottomView' | 'MessageContentLeadingView' | 'messageContentOrder' + | 'messageOverlayTargetId' | 'MessageContentTrailingView' | 'MessageContentTopView' | 'MessageDeleted' @@ -704,6 +705,7 @@ const ChannelWithContext = (props: PropsWithChildren) = 'text', 'location', ], + messageOverlayTargetId, MessageContentTrailingView, MessageContentTopView, MessageDeleted = MessageDeletedDefault, @@ -2032,6 +2034,7 @@ const ChannelWithContext = (props: PropsWithChildren) = MessageContentBottomView, MessageContentLeadingView, messageContentOrder, + messageOverlayTargetId, MessageContentTrailingView, MessageContentTopView, MessageDeleted, diff --git a/package/src/components/Channel/hooks/useCreateMessagesContext.ts b/package/src/components/Channel/hooks/useCreateMessagesContext.ts index 2f21318b6d..40b92efb04 100644 --- a/package/src/components/Channel/hooks/useCreateMessagesContext.ts +++ b/package/src/components/Channel/hooks/useCreateMessagesContext.ts @@ -60,6 +60,7 @@ export const useCreateMessagesContext = ({ MessageContentBottomView, MessageContentLeadingView, messageContentOrder, + messageOverlayTargetId, MessageContentTrailingView, MessageContentTopView, MessageDeleted, @@ -189,6 +190,7 @@ export const useCreateMessagesContext = ({ MessageContentBottomView, MessageContentLeadingView, messageContentOrder, + messageOverlayTargetId, MessageContentTrailingView, MessageContentTopView, MessageDeleted, @@ -259,6 +261,7 @@ export const useCreateMessagesContext = ({ initialScrollToFirstUnreadMessage, markdownRulesLength, messageContentOrderValue, + messageOverlayTargetId, supportedReactionsLength, myMessageTheme, targetedMessage, diff --git a/package/src/components/Message/Message.tsx b/package/src/components/Message/Message.tsx index bddca5513f..10f599c73f 100644 --- a/package/src/components/Message/Message.tsx +++ b/package/src/components/Message/Message.tsx @@ -19,6 +19,7 @@ import { useMessageActions } from './hooks/useMessageActions'; import { useMessageDeliveredData } from './hooks/useMessageDeliveryData'; import { useMessageReadData } from './hooks/useMessageReadData'; import { useProcessReactions } from './hooks/useProcessReactions'; +import { DEFAULT_MESSAGE_OVERLAY_TARGET_ID } from './messageOverlayConstants'; import { MessageOverlayWrapper } from './MessageOverlayWrapper'; import { measureInWindow } from './utils/measureInWindow'; import { messageActions as defaultMessageActions } from './utils/messageActions'; @@ -36,7 +37,11 @@ import { MessageComposerAPIContextValue, useMessageComposerAPIContext, } from '../../contexts/messageComposerContext/MessageComposerAPIContext'; -import { MessageContextValue, MessageProvider } from '../../contexts/messageContext/MessageContext'; +import { + MessageContextValue, + MessageOverlayRuntimeProvider, + MessageProvider, +} from '../../contexts/messageContext/MessageContext'; import { useMessageListItemContext } from '../../contexts/messageListItemContext/MessageListItemContext'; import { MessagesContextValue, @@ -208,6 +213,7 @@ export type MessagePropsWithContext = Pick< | 'isAttachmentEqual' | 'MessageMenu' | 'messageActions' + | 'messageOverlayTargetId' | 'messageContentOrder' | 'MessageBounce' | 'MessageBlocked' @@ -290,6 +296,7 @@ const MessageWithContext = (props: MessagePropsWithContext) => { members, message, messageActions: messageActionsProp = defaultMessageActions, + messageOverlayTargetId = DEFAULT_MESSAGE_OVERLAY_TARGET_ID, MessageBlocked, MessageBounce, messageContentOrder: messageContentOrderProp, @@ -336,67 +343,28 @@ const MessageWithContext = (props: MessagePropsWithContext) => { const rectRef = useRef(undefined); const bubbleRect = useRef(undefined); const contextMenuAnchorRef = useRef(null); - const messageOverlayTargetsRef = useRef< - Array<{ id: string; isDefault: boolean; view: View | null }> - >([]); - const [activeMessageOverlayTargetId, setActiveMessageOverlayTargetId] = useState< - string | undefined - >(undefined); - const [hasCustomMessageOverlayTarget, setHasCustomMessageOverlayTarget] = useState(false); - const syncMessageOverlayTargetsState = useStableCallback(() => { - const registeredTargets = messageOverlayTargetsRef.current; - const customTargets = registeredTargets.filter((target) => !target.isDefault && target.view); - const defaultTargets = registeredTargets.filter((target) => target.isDefault && target.view); - const activeTarget = - customTargets[customTargets.length - 1] ?? defaultTargets[defaultTargets.length - 1]; - - setActiveMessageOverlayTargetId(activeTarget?.id); - setHasCustomMessageOverlayTarget(customTargets.length > 0); - }); + const messageOverlayTargetsRef = useRef>({}); const registerMessageOverlayTarget = useStableCallback( - ({ id, isDefault, view }: { id: string; isDefault: boolean; view: View | null }) => { - const existingTargetIndex = messageOverlayTargetsRef.current.findIndex( - (target) => target.id === id, - ); - - if (existingTargetIndex === -1) { - messageOverlayTargetsRef.current = [ - ...messageOverlayTargetsRef.current, - { id, isDefault, view }, - ]; - } else { - messageOverlayTargetsRef.current = messageOverlayTargetsRef.current.map((target, index) => - index === existingTargetIndex ? { id, isDefault, view } : target, - ); - } - - syncMessageOverlayTargetsState(); + ({ id, view }: { id: string; view: View | null }) => { + messageOverlayTargetsRef.current[id] = view; }, ); const unregisterMessageOverlayTarget = useStableCallback((id: string) => { - messageOverlayTargetsRef.current = messageOverlayTargetsRef.current.filter( - (target) => target.id !== id, - ); - syncMessageOverlayTargetsState(); + delete messageOverlayTargetsRef.current[id]; }); const showMessageOverlay = useStableCallback(async () => { dismissKeyboard(); try { - const customTargets = messageOverlayTargetsRef.current.filter( - (target) => !target.isDefault && target.view, - ); - const defaultTargets = messageOverlayTargetsRef.current.filter( - (target) => target.isDefault && target.view, - ); - const activeTarget = - customTargets[customTargets.length - 1] ?? defaultTargets[defaultTargets.length - 1]; - - if (!activeTarget?.view) { - throw new Error('No message overlay target is registered for this message.'); + const activeTargetView = messageOverlayTargetsRef.current[messageOverlayTargetId]; + + if (!activeTargetView) { + throw new Error( + `No message overlay target is registered for target id "${messageOverlayTargetId}".`, + ); } - const layout = await measureInWindow({ current: activeTarget.view }, insets); + const layout = await measureInWindow({ current: activeTargetView }, insets); const bubbleLayout = await measureInWindow(contextMenuAnchorRef, insets).catch(() => layout); rectRef.current = layout; @@ -759,11 +727,9 @@ const MessageWithContext = (props: MessagePropsWithContext) => { handleReaction, handleToggleReaction, hasReactions, - activeMessageOverlayTargetId, images: attachments.images, isMessageAIGenerated, isMyMessage, - hasCustomMessageOverlayTarget, lastGroupMessage: groupStyles?.[0] === 'single' || groupStyles?.[0] === 'bottom', members, message: overlayActive ? frozenMessage.current : message, @@ -852,6 +818,13 @@ const MessageWithContext = (props: MessagePropsWithContext) => { threadList, videos: attachments.videos, }); + const messageOverlayRuntimeContext = useMemo( + () => ({ + messageOverlayTargetId, + overlayActive, + }), + [messageOverlayTargetId, overlayActive], + ); const prevActive = useRef(overlayActive); @@ -888,78 +861,80 @@ const MessageWithContext = (props: MessagePropsWithContext) => { return ( - - {/*TODO: V9: Find a way to separate these in a dedicated file*/} - - {overlayActive && rect && overlayItemsAnchorRect ? ( - { - const { width: w, height: h } = e.nativeEvent.layout; - - setOverlayTopH({ - h, - w, - x: - overlayItemAlignment === 'right' - ? overlayItemsAnchorRect.x + overlayItemsAnchorRect.w - w - : overlayItemsAnchorRect.x, - y: rect.y - h, - }); - }} + + + {/*TODO: V9: Find a way to separate these in a dedicated file*/} + + {overlayActive && rect && overlayItemsAnchorRect ? ( + { + const { width: w, height: h } = e.nativeEvent.layout; + + setOverlayTopH({ + h, + w, + x: + overlayItemAlignment === 'right' + ? overlayItemsAnchorRect.x + overlayItemsAnchorRect.w - w + : overlayItemsAnchorRect.x, + y: rect.y - h, + }); + }} + > + + + ) : null} + + + + + {showMessageReactions ? ( + setShowMessageReactions(false)} + visible={showMessageReactions} + height={424} > - - + ) : null} - - - - - {showMessageReactions ? ( - setShowMessageReactions(false)} - visible={showMessageReactions} - height={424} - > - - - ) : null} - - {overlayActive && rect && overlayItemsAnchorRect ? ( - { - const { width: w, height: h } = e.nativeEvent.layout; - setOverlayBottomH({ - h, - w, - x: - overlayItemAlignment === 'right' - ? overlayItemsAnchorRect.x + overlayItemsAnchorRect.w - w - : overlayItemsAnchorRect.x, - y: rect.y + rect.h, - }); - }} - > - - + + {overlayActive && rect && overlayItemsAnchorRect ? ( + { + const { width: w, height: h } = e.nativeEvent.layout; + setOverlayBottomH({ + h, + w, + x: + overlayItemAlignment === 'right' + ? overlayItemsAnchorRect.x + overlayItemsAnchorRect.w - w + : overlayItemsAnchorRect.x, + y: rect.y + rect.h, + }); + }} + > + + + ) : null} + + {isBounceDialogOpen ? ( + ) : null} - - {isBounceDialogOpen ? ( - - ) : null} - + + ); }; @@ -972,6 +947,7 @@ const areEqual = (prevProps: MessagePropsWithContext, nextProps: MessagePropsWit groupStyles: prevGroupStyles, isAttachmentEqual, isTargetedMessage: prevIsTargetedMessage, + messageOverlayTargetId: prevMessageOverlayTargetId, members: prevMembers, message: prevMessage, messagesContext: prevMessagesContext, @@ -985,6 +961,7 @@ const areEqual = (prevProps: MessagePropsWithContext, nextProps: MessagePropsWit goToMessage: nextGoToMessage, groupStyles: nextGroupStyles, isTargetedMessage: nextIsTargetedMessage, + messageOverlayTargetId: nextMessageOverlayTargetId, members: nextMembers, message: nextMessage, messagesContext: nextMessagesContext, @@ -1025,6 +1002,11 @@ const areEqual = (prevProps: MessagePropsWithContext, nextProps: MessagePropsWit return false; } + const messageOverlayTargetIdEqual = prevMessageOverlayTargetId === nextMessageOverlayTargetId; + if (!messageOverlayTargetIdEqual) { + return false; + } + const messageEqual = checkMessageEquality(prevMessage, nextMessage); if (!messageEqual) { diff --git a/package/src/components/Message/MessageItemView/__tests__/Message.test.js b/package/src/components/Message/MessageItemView/__tests__/Message.test.js index 84bf42d8c0..b9e6c2beb6 100644 --- a/package/src/components/Message/MessageItemView/__tests__/Message.test.js +++ b/package/src/components/Message/MessageItemView/__tests__/Message.test.js @@ -45,7 +45,7 @@ const OverlayTrigger = () => { const CustomMessageItemView = () => ( - + @@ -138,6 +138,7 @@ describe('Message', () => { { message }, { MessageItemView: CustomMessageItemView, + messageOverlayTargetId: 'custom-overlay-target', }, ); diff --git a/package/src/components/Message/MessageOverlayWrapper.tsx b/package/src/components/Message/MessageOverlayWrapper.tsx index 38e79fadbe..f8cc850bf3 100644 --- a/package/src/components/Message/MessageOverlayWrapper.tsx +++ b/package/src/components/Message/MessageOverlayWrapper.tsx @@ -1,4 +1,4 @@ -import React, { PropsWithChildren, useEffect, useRef } from 'react'; +import React, { PropsWithChildren, useCallback, useEffect, useRef } from 'react'; import { LayoutChangeEvent, View } from 'react-native'; import { Portal } from 'react-native-teleport'; @@ -6,17 +6,16 @@ import { Portal } from 'react-native-teleport'; import { MessageOverlayTargetProvider, useMessageContext, + useMessageOverlayRuntimeContext, } from '../../contexts/messageContext/MessageContext'; import { useStableCallback } from '../../hooks'; -import { useIsOverlayActive } from '../../state-store'; -import { generateRandomId } from '../../utils/utils'; export type MessageOverlayWrapperProps = PropsWithChildren<{ /** - * Marks this wrapper as the default whole-message overlay target. - * Integrators should not set this manually. + * Stable identifier for this overlay target. Must match `messageOverlayTargetId` + * when this subtree should be teleported into the overlay. */ - isDefault?: boolean; + targetId: string; /** * Optional test id attached to the wrapped target container. */ @@ -25,28 +24,23 @@ export type MessageOverlayWrapperProps = PropsWithChildren<{ export const MessageOverlayWrapper = ({ children, - isDefault = false, + targetId, testID, }: MessageOverlayWrapperProps) => { - const { - activeMessageOverlayTargetId, - messageOverlayId, - registerMessageOverlayTarget, - unregisterMessageOverlayTarget, - } = useMessageContext(); - const { active: overlayActive } = useIsOverlayActive(messageOverlayId); + const { registerMessageOverlayTarget, unregisterMessageOverlayTarget } = useMessageContext(); + const { messageOverlayTargetId, overlayActive } = useMessageOverlayRuntimeContext(); const placeholderLayoutRef = useRef({ h: 0, w: 0 }); - const registrationIdRef = useRef(`message-overlay-target-${generateRandomId()}`); - const registrationId = registrationIdRef.current; - const isActiveTarget = activeMessageOverlayTargetId === registrationId; + const isActiveTarget = messageOverlayTargetId === targetId; - const handleTargetRef = useStableCallback((view: View | null) => { - registerMessageOverlayTarget({ - id: registrationId, - isDefault, - view, - }); - }); + const handleTargetRef = useCallback( + (view: View | null) => { + registerMessageOverlayTarget({ + id: targetId, + view, + }); + }, + [registerMessageOverlayTarget, targetId], + ); const handleLayout = useStableCallback((event: LayoutChangeEvent) => { const { @@ -63,23 +57,26 @@ export const MessageOverlayWrapper = ({ useEffect( () => () => { - unregisterMessageOverlayTarget(registrationId); + unregisterMessageOverlayTarget(targetId); }, - [registrationId, unregisterMessageOverlayTarget], + [targetId, unregisterMessageOverlayTarget], ); const placeholderLayout = placeholderLayoutRef.current; + const target = ( + + {children} + + ); + + if (!isActiveTarget) { + return children; + } return ( <> - - - - {children} - - - - {overlayActive && isActiveTarget ? ( + {target} + {overlayActive ? ( ): Mes ({ actionsEnabled: false, alignment: 'left', - activeMessageOverlayTargetId: undefined, channel: {} as MessageContextValue['channel'], deliveredToCount: 0, dismissOverlay: jest.fn(), @@ -24,7 +25,6 @@ const createMessageContextValue = (overrides: Partial): Mes handleAction: jest.fn(), handleToggleReaction: jest.fn(), hasAttachmentActions: false, - hasCustomMessageOverlayTarget: false, hasReactions: false, images: [], isMessageAIGenerated: jest.fn(), @@ -56,9 +56,17 @@ const createMessageContextValue = (overrides: Partial): Mes ...overrides, }) as MessageContextValue; -const createWrapper = (value: MessageContextValue) => { +const createWrapper = ( + value: MessageContextValue, + runtimeValue = { + messageOverlayTargetId: DEFAULT_MESSAGE_OVERLAY_TARGET_ID, + overlayActive: false, + }, +) => { const Wrapper = ({ children }: PropsWithChildren) => ( - {children} + + {children} + ); return Wrapper; diff --git a/package/src/components/Message/hooks/useCreateMessageContext.ts b/package/src/components/Message/hooks/useCreateMessageContext.ts index 55ab909e08..3c2141699e 100644 --- a/package/src/components/Message/hooks/useCreateMessageContext.ts +++ b/package/src/components/Message/hooks/useCreateMessageContext.ts @@ -33,11 +33,9 @@ export const useCreateMessageContext = ({ images, isMessageAIGenerated, isMyMessage, - activeMessageOverlayTargetId, lastGroupMessage, members, message, - hasCustomMessageOverlayTarget, messageOverlayId, messageContentOrder, myMessageTheme, @@ -92,8 +90,6 @@ export const useCreateMessageContext = ({ images, isMessageAIGenerated, isMyMessage, - activeMessageOverlayTargetId, - hasCustomMessageOverlayTarget, lastGroupMessage, members, message, @@ -128,8 +124,6 @@ export const useCreateMessageContext = ({ stableGroupStyles, hasAttachmentActions, hasReactions, - activeMessageOverlayTargetId, - hasCustomMessageOverlayTarget, messageHasOnlySingleAttachment, lastGroupMessage, membersValue, diff --git a/package/src/components/Message/hooks/useShouldUseOverlayStyles.ts b/package/src/components/Message/hooks/useShouldUseOverlayStyles.ts index 0390f35e5f..e030113325 100644 --- a/package/src/components/Message/hooks/useShouldUseOverlayStyles.ts +++ b/package/src/components/Message/hooks/useShouldUseOverlayStyles.ts @@ -1,8 +1,14 @@ -import { useMessageContext, useMessageOverlayTargetContext } from '../../../contexts'; +import { + useMessageContext, + useMessageOverlayRuntimeContext, + useMessageOverlayTargetContext, +} from '../../../contexts'; import { useIsOverlayActive } from '../../../state-store'; +import { DEFAULT_MESSAGE_OVERLAY_TARGET_ID } from '../messageOverlayConstants'; export const useShouldUseOverlayStyles = () => { - const { hasCustomMessageOverlayTarget, messageOverlayId } = useMessageContext(); + const { messageOverlayId } = useMessageContext(); + const { messageOverlayTargetId } = useMessageOverlayRuntimeContext(); const isWithinMessageOverlayTarget = useMessageOverlayTargetContext(); const { active, closing } = useIsOverlayActive(messageOverlayId); @@ -10,5 +16,7 @@ export const useShouldUseOverlayStyles = () => { return false; } - return hasCustomMessageOverlayTarget ? isWithinMessageOverlayTarget : true; + return messageOverlayTargetId === DEFAULT_MESSAGE_OVERLAY_TARGET_ID + ? true + : isWithinMessageOverlayTarget; }; diff --git a/package/src/components/Message/messageOverlayConstants.ts b/package/src/components/Message/messageOverlayConstants.ts new file mode 100644 index 0000000000..3080d4cd12 --- /dev/null +++ b/package/src/components/Message/messageOverlayConstants.ts @@ -0,0 +1 @@ +export const DEFAULT_MESSAGE_OVERLAY_TARGET_ID = '@stream-io/message-root'; diff --git a/package/src/contexts/messageContext/MessageContext.tsx b/package/src/contexts/messageContext/MessageContext.tsx index ebd2545483..b5dd8822d3 100644 --- a/package/src/contexts/messageContext/MessageContext.tsx +++ b/package/src/contexts/messageContext/MessageContext.tsx @@ -9,6 +9,7 @@ import type { MessagePressableHandlerPayload, PressableHandlerPayload, } from '../../components/Message/Message'; +import { DEFAULT_MESSAGE_OVERLAY_TARGET_ID } from '../../components/Message/messageOverlayConstants'; import type { GroupType } from '../../components/MessageList/hooks/useMessageList'; import type { ChannelContextValue } from '../../contexts/channelContext/ChannelContext'; import type { MessageContentType } from '../../contexts/messagesContext/MessagesContext'; @@ -64,14 +65,6 @@ export type MessageContextValue = { * Custom message renderers can attach this to a different subview if needed. */ contextMenuAnchorRef: React.RefObject; - /** - * Whether the current message renderer registered a custom subtree to teleport into the overlay. - */ - hasCustomMessageOverlayTarget: boolean; - /** - * Registration id for the currently active overlay target subtree. - */ - activeMessageOverlayTargetId?: string; /** * Stable UI-instance identifier for the rendered message. * Used for overlay state so two rendered instances of the same message do not collide. @@ -112,11 +105,7 @@ export type MessageContextValue = { * Registers the subtree that should be measured and portaled into the message overlay. * Custom message renderers typically interact with this via `MessageOverlayWrapper`. */ - registerMessageOverlayTarget: (params: { - id: string; - isDefault: boolean; - view: View | null; - }) => void; + registerMessageOverlayTarget: (params: { id: string; view: View | null }) => void; unregisterMessageOverlayTarget: (id: string) => void; reactions: ReactionSummary[]; /** Read count of the message */ @@ -183,6 +172,27 @@ export const useMessageContext = () => { return contextValue; }; +type MessageOverlayRuntimeContextValue = { + messageOverlayTargetId: string; + overlayActive: boolean; +}; + +const MessageOverlayRuntimeContext = React.createContext({ + messageOverlayTargetId: DEFAULT_MESSAGE_OVERLAY_TARGET_ID, + overlayActive: false, +}); + +export const MessageOverlayRuntimeProvider = ({ + children, + value, +}: PropsWithChildren<{ value: MessageOverlayRuntimeContextValue }>) => ( + + {children} + +); + +export const useMessageOverlayRuntimeContext = () => useContext(MessageOverlayRuntimeContext); + const MessageOverlayTargetContext = React.createContext(false); export const MessageOverlayTargetProvider = ({ diff --git a/package/src/contexts/messagesContext/MessagesContext.tsx b/package/src/contexts/messagesContext/MessagesContext.tsx index 5b1b3c9572..d06957c248 100644 --- a/package/src/contexts/messagesContext/MessagesContext.tsx +++ b/package/src/contexts/messagesContext/MessagesContext.tsx @@ -259,6 +259,11 @@ export type MessagesContextValue = Pick Date: Tue, 14 Apr 2026 16:10:03 +0200 Subject: [PATCH 03/11] chore: add handover doc as well --- MESSAGE_OVERLAY_CUSTOMIZATION_NOTES.md | 565 +++++++++++++++++++++++++ 1 file changed, 565 insertions(+) create mode 100644 MESSAGE_OVERLAY_CUSTOMIZATION_NOTES.md diff --git a/MESSAGE_OVERLAY_CUSTOMIZATION_NOTES.md b/MESSAGE_OVERLAY_CUSTOMIZATION_NOTES.md new file mode 100644 index 0000000000..975d7976dd --- /dev/null +++ b/MESSAGE_OVERLAY_CUSTOMIZATION_NOTES.md @@ -0,0 +1,565 @@ +# Message Overlay Customization Notes + +Status: design note / implementation history / future reference + +Audience: SDK maintainers working on `Message`, message overlay, context menu customization, and performance-sensitive message rendering paths. + +Scope: this note documents the customization problem we tried to solve, the approaches we explored, the performance constraints that matter in this codepath, what we implemented experimentally, why we stopped, and what a future production-ready solution should look like. + +This document is intentionally detailed. `Message` is a hot path. Small architectural mistakes here get multiplied by every visible message row, every rerender, every scroll, and every overlay open/close cycle. + +## Why This Came Up + +We wanted to revisit message overlay customizability for a new major version. + +The main integrator-facing goals were: + +1. Top and bottom overlay items should be configurable or overridable. +2. The actual content portaled into the overlay should be overridable. + In plain terms: integrators should be able to choose which part of the message is teleported and measured. +3. Ideally, the whole overlay block should be overridable. + In practice, this is much harder because the current animation and measurement model is tightly coupled to the existing structure. + +The driving product requirement was not “nice abstraction for maintainers”. It was: + +- integrators should be able to satisfy custom layout requirements without forking core message logic +- customization should not degrade behavior +- customization should not tank performance + +The performance requirement is non-negotiable because `Message` is one of the hottest paths in the SDK. + +## Baseline Mental Model + +Before this work, the message overlay effectively assumed a mostly fixed structure: + +- `Message` owned overlay opening and measurement +- the default rendered message subtree was the thing measured and teleported +- top and bottom overlay items were rendered from `Message` +- deep subtree override of “what exactly moves into the overlay” was not a first-class concept + +That is simple and efficient, but restrictive. + +The moment we say “integrators can choose a smaller subtree, such as only the bubble content”, we introduce a new architectural problem: + +- how does `Message` know which mounted native view should be measured and portaled? + +That question is where all the complexity came from. + +## The Core Constraint + +There are two very different ways to decide overlay target ownership: + +1. Discover it after mount. + Wrappers register themselves, parent figures out which one wins. + +2. Declare it before render. + Parent already knows which target id should win, wrappers simply declare whether they match. + +The first model is flexible but reactive. +The second model is stricter but much better for performance and predictability. + +Most of the work below is really about moving from model 1 to model 2. + +## Requirements and Non-Requirements + +### Hard requirements + +- Custom message renderers must be able to choose the teleported subtree. +- The measured layout must correspond to the teleported subtree. +- Overlay styles must only apply to the teleported subtree when customization is active. +- The default whole-message behavior must remain available. +- The solution must avoid unnecessary subscriptions, context churn, and mount-time rerenders. + +### Nice-to-haves + +- Integrators should not need to replace the entire `Message` component just to customize the teleported subtree. +- The API should be understandable from user code without having to know overlay internals. +- The solution should degrade predictably when misconfigured. + +### Non-goals for the first serious version + +- Full arbitrary override of the entire top/message/bottom animated block. +- Preserving every possible “multiple competing wrappers register themselves and the system figures it out” behavior. +- Allowing extremely flexible internals if they materially harm the hot path. + +## What We Tried + +## Phase 1: Wrapper Registration Model + +The first serious idea was: + +- introduce `MessageOverlayWrapper` +- allow integrators to wrap a custom subtree +- every wrapper registers itself with `Message` +- `Message` chooses the winning target +- the winning target is measured and portaled + +The default path wrapped the whole `MessageItemView`. +Custom integrator paths could wrap a smaller subtree, such as `MessageContent`. + +This solved the raw customization problem: + +- it allowed “portal only the bubble” +- it allowed future extension to other subtrees +- it kept measurement and teleport coupled + +But it introduced a series of costs. + +### Why the registration model is heavier than it looks + +For each message instance: + +- wrapper renders once before registration +- wrapper ref mounts +- wrapper registers itself into parent state +- parent computes the active target +- message rerenders so wrappers know whether they are active + +That means even the default case pays a mount-time “discovery pass”. + +This is not just a cosmetic rerender. It means target identity is not known during the initial render, so every optimization that depends on `isActiveTarget` is delayed by one render. + +### Performance concerns in this model + +The first version carried several kinds of overhead: + +- registration itself +- React state updates in `Message` to store active target metadata +- broad `MessageContext` invalidation because target-selection state lived there +- an overlay store subscription inside `MessageOverlayWrapper` +- extra portal wrappers even for inactive candidates + +The cumulative effect was small per row, but dangerous in aggregate because messages are many and frequent. + +## Phase 2: Narrow Runtime Context Split + +The next optimization was to remove the most obvious duplicate subscription path. + +At that point, overlay state was effectively being consumed in multiple places: + +- `Message.tsx` needed it for overlay orchestration +- `MessageOverlayWrapper` needed it to decide whether to portal and render a placeholder +- `useShouldUseOverlayStyles()` needed it to know whether overlay styles should apply inside the teleported subtree + +The first refinement was: + +- stop putting active target runtime data into the broad `MessageContext` +- move runtime bits used by the wrapper into a narrow dedicated context +- remove the wrapper’s direct overlay-store subscription + +This helped, because: + +- fewer broad message context invalidations +- one fewer `useIsOverlayActive(messageOverlayId)` subscription per wrapper + +But it did not solve the deeper issue: + +- target identity was still discovered after mount + +So the mount-time second render still existed. + +## Phase 3: “Why Are Inactive Wrappers Paying for Portal Machinery?” + +A key observation during review was: + +- if a wrapper is not the active target, why is it still rendering through the full portal branch? + +This question was valid. + +The original wrapper shape effectively kept a portal-capable structure in place and toggled teleport via `hostName`. +That preserved subtree shape, but it meant inactive wrappers still paid for some inert structure. + +The natural follow-up proposal was: + +- inactive wrapper: render normal wrapped content only +- active wrapper: render portal branch and placeholder + +This is the right instinct, but in the discovery-based model it is not fully safe because: + +- on first render the wrapper does not yet know whether it is active +- it only learns that after ref registration and parent state update +- that means switching from plain branch to portal branch can happen after mount + +That is exactly the kind of structural flip that can remount or reparent the subtree in ways we do not want to rely on in a hot path. + +Important detail: + +- the ref being set after commit does not prevent remounts +- a ready ref does not mean “React will preserve this tree if the parent element type changes” + +So the question exposed the real problem correctly: + +- the issue was not just “we have too many portals” +- the issue was “the winner is discovered too late” + +## Phase 4: Declarative Target ID Model + +The next design step was the first one that actually addressed the root cause. + +The idea: + +- `Message` should know the desired overlay target id before render +- `MessageOverlayWrapper` should declare its own stable `targetId` +- `isActiveTarget` should become a synchronous string comparison +- registration should exist only to store mounted refs, not to drive React state + +This changes the model from: + +- wrappers register +- parent discovers winner +- parent stores winner in state +- wrappers rerender + +to: + +- parent already knows requested target id +- wrappers know their own target ids +- wrappers can decide active vs inactive in one pass +- registration only fills a ref map for measurement + +This is the first architecture that meaningfully supports the “inactive wrappers should not pay portal costs” goal. + +## The Experimental Declarative Shape + +The experimental refactor introduced the following ideas: + +### 1. Stable target ids + +- default whole-message target gets a stable constant id +- custom targets must provide a stable id via `MessageOverlayWrapper` + +Example shape: + +```tsx + + + +``` + +Custom integrator shape: + +```tsx +const CustomMessageContent = (props) => ( + + + +); +``` + +### 2. Parent-declared selected target + +`Message` receives a selected target id up front. + +Default: + +```ts +messageOverlayTargetId = DEFAULT_MESSAGE_OVERLAY_TARGET_ID +``` + +Custom: + +```tsx + +``` + +### 3. Registration becomes imperative bookkeeping only + +Wrappers still register their mounted native views, but registration no longer sets React state. + +Instead, `Message` stores: + +```ts +const messageOverlayTargetsRef = useRef>({}); +``` + +Registration becomes: + +```ts +messageOverlayTargetsRef.current[targetId] = view; +``` + +Unregistration becomes: + +```ts +delete messageOverlayTargetsRef.current[targetId]; +``` + +No active target state. +No post-mount selection rerender. + +### 4. Wrapper can branch immediately + +Because both values are known on the first render: + +- `requestedTargetId` +- `wrapper.targetId` + +the wrapper can immediately decide: + +- inactive branch: normal local `View` +- active branch: portal-capable branch + +This is the key performance win in the design. + +### 5. Overlay open measures the requested target directly + +On overlay open: + +```ts +const activeTargetView = messageOverlayTargetsRef.current[messageOverlayTargetId]; +``` + +The selected target is measured directly. + +There is no “find last registered custom target else fallback to default” logic in React state anymore. + +## Why This Direction Is Better + +Conceptually, the declarative id model is much stronger than the registration-discovery model. + +It gives us: + +- single-pass target selection +- no mount-time “discover winner” rerender +- no stateful active-target bookkeeping in `Message` +- no need for inactive wrappers to carry portal machinery +- a much clearer mental model for integrators + +For performance-sensitive code, these are meaningful wins. + +## Why We Stopped Anyway + +Even though the direction is strong, it is still too invasive to rush before release. + +This is no longer a small internal refactor. It changes the public customization contract. + +Specifically, the declarative model raises real design questions: + +### 1. What should happen when the selected target id does not register? + +There are two plausible answers: + +- hard fail or warning +- silent fallback to default target + +Hard fail is cleaner and more predictable. +Fallback is friendlier, but it muddies behavior and can hide configuration bugs. + +For a hot path, silent fallback is risky because it creates “works but not really” states. + +### 2. Where should the selected target id live? + +Possible owners: + +- `Message` +- `Channel` +- `MessagesContext` +- some combination of the above + +We experimented with plumbing it through `Channel` / `MessagesContext` because that gives integrators a practical way to override only `MessageContent`. + +That is likely the right UX, but it should be designed intentionally, not patched in casually. + +### 3. Do we still want multiple candidates? + +The old model allowed: + +- default wrapper +- one or more custom wrappers +- parent picks one + +The declarative model strongly encourages: + +- exactly one declared target id +- any non-matching wrappers are inactive by definition + +This is better for performance, but it is also more opinionated. + +### 4. How much freedom are we willing to give up to protect performance? + +This is the central architectural question. + +A message overlay system can be: + +- very flexible +- very predictable +- very cheap + +but getting all three at once is difficult. + +For `Message`, the bias should probably be toward: + +- predictable +- cheap + +with only the minimum flexibility needed for real integrator use cases. + +## Important Performance Lessons + +These are the main performance takeaways from the work so far. + +### 1. Do not discover active target identity through React state if it can be declared synchronously + +If target identity is known before render, keep it out of React state. + +Stateful discovery forces: + +- extra renders +- context churn +- more complicated active/inactive branching + +### 2. Be very careful what lives in `MessageContext` + +`MessageContext` is consumed widely across message internals. + +Putting rapidly changing overlay-target metadata there causes: + +- broad rerenders +- hidden coupling between overlay internals and unrelated message UI + +Any overlay-runtime data that does not need to invalidate the whole message subtree should live in a narrower context. + +### 3. Avoid duplicate subscriptions in wrapper-level code + +If `Message` already knows overlay state and wrapper code can receive it cheaply, do not add another direct store subscription per wrapper unless it is strictly necessary. + +### 4. Inactive candidates should be as cheap as possible + +If we support multiple possible overlay targets, only the selected one should pay for teleport-specific machinery. + +At minimum, inactive wrappers should avoid: + +- portal-specific behavior +- placeholder rendering +- unnecessary style gating complexity + +### 5. Ref registration is cheaper than stateful registration, but not free + +Even with the declarative model, wrappers still register refs. +That is acceptable. + +What is not acceptable is allowing registration to drive render-time selection state. + +### 6. Shape changes after mount are dangerous + +Switching a subtree from: + +- plain local branch + +to + +- portal branch + +after mount can cause remount or reparenting behavior that is hard to reason about. + +If we want inactive wrappers to bypass portal machinery, we should do it only when active/inactive status is known before the first render. + +## Integrator Experience We Were Optimizing For + +The real-world use case we kept in mind was: + +- an integrator likes the SDK +- their PM or designer asks for “only the message bubble should lift into the overlay” +- they do not want to replace the whole `Message` +- they want a small override with predictable behavior + +The ideal integrator story would look like this: + +```tsx +const CustomMessageContent = (props) => ( + + + +); + + +``` + +That is simple enough to document and reason about. + +The moment the integrator wants to portal an arbitrary combination of message parts from multiple places, the complexity rises sharply. That is where we should be careful not to overspec the public API too early. + +## What Exists in the Experimental Branch + +At the time this note was written, the local experimental work had explored all of the following: + +- wrapper-based overlay target customization +- narrow overlay runtime context split +- declarative target ids +- `Channel`/`MessagesContext` plumbing for `messageOverlayTargetId` +- sample app experiment where only `MessageContent` is the overlay target + +This work proved the concept, but it should not be treated as final design merely because it works locally. + +## Why This Should Not Ship Hastily + +Before shipping a major-version API around this, we should be able to answer all of these confidently: + +- What is the exact public API? +- What is the missing-target behavior? +- What is the migration path from default whole-message behavior? +- What is the fallback strategy, if any? +- What are the expected rerender characteristics in idle state? +- What are the expected rerender characteristics on overlay open/close? +- How does this behave with custom `MessageItemView`, custom `MessageContent`, and future whole-block overrides? +- How do we document the contract so integrators do not accidentally build slow or broken setups? + +If any of those are still vague, the work is not ready. + +## Recommended Future Plan + +When revisiting this, treat it as a design task first and an implementation task second. + +Recommended order: + +1. Decide the public API. + Choose whether the selected overlay target is configured on `Channel`, `Message`, or both. + +2. Decide the contract. + Define whether one target id is selected globally per message instance, and whether multiple candidate wrappers are supported. + +3. Decide failure behavior. + Explicitly choose hard error vs warning vs fallback when the selected target does not register. + +4. Decide styling semantics. + Define exactly which subtree receives overlay-specific styles when a non-default target is selected. + +5. Write migration examples. + Especially for the common “portal only `MessageContent`” case. + +6. Profile before and after. + Measure real message list behavior rather than relying on feel alone. + +7. Only then finalize implementation. + +## Concrete Guardrails for the Future Implementation + +Any future production-ready implementation should satisfy these guardrails: + +- No mount-time target-selection rerender in the default case. +- No broad `MessageContext` invalidation from overlay-target identity changes. +- No redundant overlay store subscription in wrapper-level code unless clearly justified. +- Inactive overlay targets must not pay for portal-specific runtime behavior. +- Measurement target identity must be explicit and predictable. +- Customization must not require overriding the entire `Message` for the common bubble-only case. +- The API contract must be documented with real examples, not just types. + +## Short Conclusion + +The concept is good. + +The main insight is: + +- overlay target identity should be declarative, not discovered after mount + +That is the version with the best chance of being both customizable and performant. + +However, this is not small enough to rush before release. + +The work should be revisited later with a proper design pass, explicit API decisions, and profiling, because `Message` is too hot a path for a half-settled abstraction. From 7b3e8e97483acca69487eb0d3ff50681a40e863d Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Thu, 16 Apr 2026 15:44:36 +0200 Subject: [PATCH 04/11] fix: lint issues --- .../SampleApp/src/screens/ChannelScreen.tsx | 79 ++++++++++--------- package/src/components/Message/Message.tsx | 5 +- .../MessageItemView/__tests__/Message.test.js | 24 ++++-- 3 files changed, 60 insertions(+), 48 deletions(-) diff --git a/examples/SampleApp/src/screens/ChannelScreen.tsx b/examples/SampleApp/src/screens/ChannelScreen.tsx index b0cabd9386..73e0ca966a 100644 --- a/examples/SampleApp/src/screens/ChannelScreen.tsx +++ b/examples/SampleApp/src/screens/ChannelScreen.tsx @@ -19,6 +19,7 @@ import { MessageActionsParams, ChannelAvatar, PortalWhileClosingView, + WithComponents, } from 'stream-chat-react-native'; import { Pressable, StyleSheet, View } from 'react-native'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; @@ -273,44 +274,46 @@ export const ChannelScreen: React.FC = ({ navigation, route return ( - - - - - {messageListImplementation === 'flashlist' ? ( - - ) : ( - - )} - - - {modalVisible && ( - - )} - + + + + + + {messageListImplementation === 'flashlist' ? ( + + ) : ( + + )} + + + {modalVisible && ( + + )} + + ); }; diff --git a/package/src/components/Message/Message.tsx b/package/src/components/Message/Message.tsx index 9fce4f4367..0d1e6d93fb 100644 --- a/package/src/components/Message/Message.tsx +++ b/package/src/components/Message/Message.tsx @@ -886,10 +886,7 @@ const MessageWithContext = (props: MessagePropsWithContext) => { visible={showMessageReactions} height={424} > - + ) : null} diff --git a/package/src/components/Message/MessageItemView/__tests__/Message.test.js b/package/src/components/Message/MessageItemView/__tests__/Message.test.js index b9e6c2beb6..9a87d7f7b8 100644 --- a/package/src/components/Message/MessageItemView/__tests__/Message.test.js +++ b/package/src/components/Message/MessageItemView/__tests__/Message.test.js @@ -5,6 +5,7 @@ import { SafeAreaProvider } from 'react-native-safe-area-context'; import { cleanup, fireEvent, render, waitFor } from '@testing-library/react-native'; +import { WithComponents } from '../../../../contexts/componentsContext/ComponentsContext'; import { useMessageContext } from '../../../../contexts/messageContext/MessageContext'; import { MessageListItemProvider } from '../../../../contexts/messageListItemContext/MessageListItemContext'; import { OverlayProvider } from '../../../../contexts/overlayContext/OverlayProvider'; @@ -71,7 +72,7 @@ describe('Message', () => { useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); channel = chatClient.channel('messaging', mockedChannel.id); - renderMessage = (options, channelProps) => + renderMessage = (options, channelProps, componentOverrides) => render( @@ -84,10 +85,19 @@ describe('Message', () => { }} > - - - - + {componentOverrides ? ( + + + + + + + ) : ( + + + + + )} @@ -137,9 +147,11 @@ describe('Message', () => { const { getByTestId, getByText } = renderMessage( { message }, { - MessageItemView: CustomMessageItemView, messageOverlayTargetId: 'custom-overlay-target', }, + { + MessageItemView: CustomMessageItemView, + }, ); await waitFor(() => { From 8e7ed643e74a05ba143237ab865b5491d336341d Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Fri, 17 Apr 2026 11:12:09 +0200 Subject: [PATCH 05/11] chore: perf testing --- .../SampleApp/src/screens/ChannelScreen.tsx | 6 +- package/src/components/Message/Message.tsx | 152 ++++++++++-------- 2 files changed, 87 insertions(+), 71 deletions(-) diff --git a/examples/SampleApp/src/screens/ChannelScreen.tsx b/examples/SampleApp/src/screens/ChannelScreen.tsx index 73e0ca966a..32c0152653 100644 --- a/examples/SampleApp/src/screens/ChannelScreen.tsx +++ b/examples/SampleApp/src/screens/ChannelScreen.tsx @@ -274,7 +274,7 @@ export const ChannelScreen: React.FC = ({ navigation, route return ( - + {/**/} = ({ navigation, route keyboardVerticalOffset={0} messageActions={messageActions} messageId={messageId} - messageOverlayTargetId='message-content' + // messageOverlayTargetId='message-content' onAlsoSentToChannelHeaderPress={onAlsoSentToChannelHeaderPress} thread={selectedThread} maximumMessageLimit={messageListPruning} @@ -313,7 +313,7 @@ export const ChannelScreen: React.FC = ({ navigation, route /> )} - + {/**/} ); }; diff --git a/package/src/components/Message/Message.tsx b/package/src/components/Message/Message.tsx index 0d1e6d93fb..c9e3249637 100644 --- a/package/src/components/Message/Message.tsx +++ b/package/src/components/Message/Message.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useRef, useState } from 'react'; +import React, { Profiler, useEffect, useMemo, useRef, useState } from 'react'; import { GestureResponderEvent, I18nManager, @@ -848,76 +848,92 @@ const MessageWithContext = (props: MessagePropsWithContext) => { } return ( - - - - {/*TODO: V9: Find a way to separate these in a dedicated file*/} - - {overlayActive && rect && overlayItemsAnchorRect ? ( - { - const { width: w, height: h } = e.nativeEvent.layout; - - setOverlayTopH({ - h, - w, - x: - overlayItemAlignment === 'right' - ? overlayItemsAnchorRect.x + overlayItemsAnchorRect.w - w - : overlayItemsAnchorRect.x, - y: rect.y - h, - }); - }} + { + console.log('[MessageProfiler]', { + actualDuration, + baseDuration, + commitTime, + id, + messageId: message.id, + overlayActive, + phase, + startTime, + }); + }} + > + + + + {/*TODO: V9: Find a way to separate these in a dedicated file*/} + + {overlayActive && rect && overlayItemsAnchorRect ? ( + { + const { width: w, height: h } = e.nativeEvent.layout; + + setOverlayTopH({ + h, + w, + x: + overlayItemAlignment === 'right' + ? overlayItemsAnchorRect.x + overlayItemsAnchorRect.w - w + : overlayItemsAnchorRect.x, + y: rect.y - h, + }); + }} + > + + + ) : null} + + + + + {showMessageReactions ? ( + setShowMessageReactions(false)} + visible={showMessageReactions} + height={424} > - - + + ) : null} - - - - - {showMessageReactions ? ( - setShowMessageReactions(false)} - visible={showMessageReactions} - height={424} - > - - - ) : null} - - {overlayActive && rect && overlayItemsAnchorRect ? ( - { - const { width: w, height: h } = e.nativeEvent.layout; - setOverlayBottomH({ - h, - w, - x: - overlayItemAlignment === 'right' - ? overlayItemsAnchorRect.x + overlayItemsAnchorRect.w - w - : overlayItemsAnchorRect.x, - y: rect.y + rect.h, - }); - }} - > - - + + {overlayActive && rect && overlayItemsAnchorRect ? ( + { + const { width: w, height: h } = e.nativeEvent.layout; + setOverlayBottomH({ + h, + w, + x: + overlayItemAlignment === 'right' + ? overlayItemsAnchorRect.x + overlayItemsAnchorRect.w - w + : overlayItemsAnchorRect.x, + y: rect.y + rect.h, + }); + }} + > + + + ) : null} + + {isBounceDialogOpen ? ( + ) : null} - - {isBounceDialogOpen ? ( - - ) : null} - - - + + + + ); }; From 3462555cd51723f46ba22527a828d4f11f3f45b1 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Fri, 17 Apr 2026 14:49:20 +0200 Subject: [PATCH 06/11] refactor: single placeholder source --- package/src/components/Message/Message.tsx | 1 + .../Message/MessageOverlayWrapper.tsx | 42 +++++++------------ .../useShouldUseOverlayStyles.test.tsx | 1 + .../messageContext/MessageContext.tsx | 3 ++ 4 files changed, 19 insertions(+), 28 deletions(-) diff --git a/package/src/components/Message/Message.tsx b/package/src/components/Message/Message.tsx index c9e3249637..9b3f52c5ab 100644 --- a/package/src/components/Message/Message.tsx +++ b/package/src/components/Message/Message.tsx @@ -808,6 +808,7 @@ const MessageWithContext = (props: MessagePropsWithContext) => { }); const messageOverlayRuntimeContext = useMemo( () => ({ + overlayTargetRectRef: rectRef, messageOverlayTargetId, overlayActive, }), diff --git a/package/src/components/Message/MessageOverlayWrapper.tsx b/package/src/components/Message/MessageOverlayWrapper.tsx index f8cc850bf3..1769116454 100644 --- a/package/src/components/Message/MessageOverlayWrapper.tsx +++ b/package/src/components/Message/MessageOverlayWrapper.tsx @@ -1,5 +1,5 @@ -import React, { PropsWithChildren, useCallback, useEffect, useRef } from 'react'; -import { LayoutChangeEvent, View } from 'react-native'; +import React, { PropsWithChildren, useCallback, useEffect } from 'react'; +import { View } from 'react-native'; import { Portal } from 'react-native-teleport'; @@ -8,7 +8,6 @@ import { useMessageContext, useMessageOverlayRuntimeContext, } from '../../contexts/messageContext/MessageContext'; -import { useStableCallback } from '../../hooks'; export type MessageOverlayWrapperProps = PropsWithChildren<{ /** @@ -28,9 +27,10 @@ export const MessageOverlayWrapper = ({ testID, }: MessageOverlayWrapperProps) => { const { registerMessageOverlayTarget, unregisterMessageOverlayTarget } = useMessageContext(); - const { messageOverlayTargetId, overlayActive } = useMessageOverlayRuntimeContext(); - const placeholderLayoutRef = useRef({ h: 0, w: 0 }); + const { messageOverlayTargetId, overlayActive, overlayTargetRectRef } = + useMessageOverlayRuntimeContext(); const isActiveTarget = messageOverlayTargetId === targetId; + const placeholderLayout = overlayTargetRectRef.current; const handleTargetRef = useCallback( (view: View | null) => { @@ -42,19 +42,6 @@ export const MessageOverlayWrapper = ({ [registerMessageOverlayTarget, targetId], ); - const handleLayout = useStableCallback((event: LayoutChangeEvent) => { - const { - nativeEvent: { - layout: { height, width }, - }, - } = event; - - placeholderLayoutRef.current = { - h: height, - w: width, - }; - }); - useEffect( () => () => { unregisterMessageOverlayTarget(targetId); @@ -62,26 +49,25 @@ export const MessageOverlayWrapper = ({ [targetId, unregisterMessageOverlayTarget], ); - const placeholderLayout = placeholderLayoutRef.current; - const target = ( - - {children} - - ); - if (!isActiveTarget) { return children; } return ( <> - {target} + + + + {children} + + + {overlayActive ? ( 0 ? placeholderLayout.w : '100%', + height: placeholderLayout?.h ?? 0, + width: placeholderLayout?.w && placeholderLayout.w > 0 ? placeholderLayout.w : '100%', }} testID={testID ? `${testID}-placeholder` : 'message-overlay-wrapper-placeholder'} /> diff --git a/package/src/components/Message/hooks/__tests__/useShouldUseOverlayStyles.test.tsx b/package/src/components/Message/hooks/__tests__/useShouldUseOverlayStyles.test.tsx index 59ce4045ac..a8173e45f9 100644 --- a/package/src/components/Message/hooks/__tests__/useShouldUseOverlayStyles.test.tsx +++ b/package/src/components/Message/hooks/__tests__/useShouldUseOverlayStyles.test.tsx @@ -59,6 +59,7 @@ const createMessageContextValue = (overrides: Partial): Mes const createWrapper = ( value: MessageContextValue, runtimeValue = { + overlayTargetRectRef: { current: undefined }, messageOverlayTargetId: DEFAULT_MESSAGE_OVERLAY_TARGET_ID, overlayActive: false, }, diff --git a/package/src/contexts/messageContext/MessageContext.tsx b/package/src/contexts/messageContext/MessageContext.tsx index b5dd8822d3..22a4e09c3f 100644 --- a/package/src/contexts/messageContext/MessageContext.tsx +++ b/package/src/contexts/messageContext/MessageContext.tsx @@ -13,6 +13,7 @@ import { DEFAULT_MESSAGE_OVERLAY_TARGET_ID } from '../../components/Message/mess import type { GroupType } from '../../components/MessageList/hooks/useMessageList'; import type { ChannelContextValue } from '../../contexts/channelContext/ChannelContext'; import type { MessageContentType } from '../../contexts/messagesContext/MessagesContext'; +import type { Rect } from '../../state-store/message-overlay-store'; import type { DeepPartial } from '../../contexts/themeContext/ThemeContext'; import type { Theme } from '../../contexts/themeContext/utils/theme'; @@ -173,11 +174,13 @@ export const useMessageContext = () => { }; type MessageOverlayRuntimeContextValue = { + overlayTargetRectRef: { current: Rect }; messageOverlayTargetId: string; overlayActive: boolean; }; const MessageOverlayRuntimeContext = React.createContext({ + overlayTargetRectRef: { current: undefined }, messageOverlayTargetId: DEFAULT_MESSAGE_OVERLAY_TARGET_ID, overlayActive: false, }); From 71fbcd2976595ce9e122dc25a3f3cea8bab95d05 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Fri, 17 Apr 2026 14:53:58 +0200 Subject: [PATCH 07/11] chore: remove profiler --- package/src/components/Message/Message.tsx | 152 +++++++++------------ 1 file changed, 68 insertions(+), 84 deletions(-) diff --git a/package/src/components/Message/Message.tsx b/package/src/components/Message/Message.tsx index 9b3f52c5ab..316979be3f 100644 --- a/package/src/components/Message/Message.tsx +++ b/package/src/components/Message/Message.tsx @@ -1,4 +1,4 @@ -import React, { Profiler, useEffect, useMemo, useRef, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { GestureResponderEvent, I18nManager, @@ -849,92 +849,76 @@ const MessageWithContext = (props: MessagePropsWithContext) => { } return ( - { - console.log('[MessageProfiler]', { - actualDuration, - baseDuration, - commitTime, - id, - messageId: message.id, - overlayActive, - phase, - startTime, - }); - }} - > - - - - {/*TODO: V9: Find a way to separate these in a dedicated file*/} - - {overlayActive && rect && overlayItemsAnchorRect ? ( - { - const { width: w, height: h } = e.nativeEvent.layout; - - setOverlayTopH({ - h, - w, - x: - overlayItemAlignment === 'right' - ? overlayItemsAnchorRect.x + overlayItemsAnchorRect.w - w - : overlayItemsAnchorRect.x, - y: rect.y - h, - }); - }} - > - - - ) : null} - - - - - {showMessageReactions ? ( - setShowMessageReactions(false)} - visible={showMessageReactions} - height={424} + + + + {/*TODO: V9: Find a way to separate these in a dedicated file*/} + + {overlayActive && rect && overlayItemsAnchorRect ? ( + { + const { width: w, height: h } = e.nativeEvent.layout; + + setOverlayTopH({ + h, + w, + x: + overlayItemAlignment === 'right' + ? overlayItemsAnchorRect.x + overlayItemsAnchorRect.w - w + : overlayItemsAnchorRect.x, + y: rect.y - h, + }); + }} > - - + + ) : null} - - {overlayActive && rect && overlayItemsAnchorRect ? ( - { - const { width: w, height: h } = e.nativeEvent.layout; - setOverlayBottomH({ - h, - w, - x: - overlayItemAlignment === 'right' - ? overlayItemsAnchorRect.x + overlayItemsAnchorRect.w - w - : overlayItemsAnchorRect.x, - y: rect.y + rect.h, - }); - }} - > - - - ) : null} - - {isBounceDialogOpen ? ( - + + + + + {showMessageReactions ? ( + setShowMessageReactions(false)} + visible={showMessageReactions} + height={424} + > + + + ) : null} + + {overlayActive && rect && overlayItemsAnchorRect ? ( + { + const { width: w, height: h } = e.nativeEvent.layout; + setOverlayBottomH({ + h, + w, + x: + overlayItemAlignment === 'right' + ? overlayItemsAnchorRect.x + overlayItemsAnchorRect.w - w + : overlayItemsAnchorRect.x, + y: rect.y + rect.h, + }); + }} + > + + ) : null} - - - - + + {isBounceDialogOpen ? ( + + ) : null} + + + ); }; From 2fcfb5938c4b06063fafe18295c9a1838df64812 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Fri, 17 Apr 2026 15:11:18 +0200 Subject: [PATCH 08/11] fix: lint and tests --- .../SampleApp/src/screens/ChannelScreen.tsx | 88 ++++++++----------- .../__snapshots__/Thread.test.js.snap | 16 +++- .../messageContext/MessageContext.tsx | 2 +- 3 files changed, 50 insertions(+), 56 deletions(-) diff --git a/examples/SampleApp/src/screens/ChannelScreen.tsx b/examples/SampleApp/src/screens/ChannelScreen.tsx index 32c0152653..f423215596 100644 --- a/examples/SampleApp/src/screens/ChannelScreen.tsx +++ b/examples/SampleApp/src/screens/ChannelScreen.tsx @@ -5,10 +5,8 @@ import { AlsoSentToChannelHeaderPressPayload, Channel, MessageComposer, - MessageContent as DefaultMessageContent, MessageList, MessageFlashList, - MessageOverlayWrapper, ThreadContextValue, useAttachmentPickerContext, useChannelPreviewDisplayName, @@ -19,7 +17,6 @@ import { MessageActionsParams, ChannelAvatar, PortalWhileClosingView, - WithComponents, } from 'stream-chat-react-native'; import { Pressable, StyleSheet, View } from 'react-native'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; @@ -51,14 +48,6 @@ export type ChannelHeaderProps = { channel: StreamChatChannel; }; -const OverlayTargetedMessageContent = ( - props: React.ComponentProps, -) => ( - - - -); - const ChannelHeader: React.FC = ({ channel }) => { const { closePicker } = useAttachmentPickerContext(); const membersStatus = useChannelMembersStatus(channel); @@ -274,46 +263,43 @@ export const ChannelScreen: React.FC = ({ navigation, route return ( - {/**/} - - - - - {messageListImplementation === 'flashlist' ? ( - - ) : ( - - )} - - - {modalVisible && ( - - )} - - {/**/} + + + + + {messageListImplementation === 'flashlist' ? ( + + ) : ( + + )} + + + {modalVisible && ( + + )} + ); }; diff --git a/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap b/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap index 0987d6c577..049ff2af71 100644 --- a/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap +++ b/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap @@ -368,7 +368,9 @@ exports[`Thread should match thread snapshot 1`] = ` > - + - + - + - + Date: Fri, 17 Apr 2026 15:14:30 +0200 Subject: [PATCH 09/11] chore: bump react-native-teleport --- examples/SampleApp/ios/Podfile.lock | 10 +++++----- examples/SampleApp/package.json | 2 +- examples/SampleApp/yarn.lock | 8 ++++---- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/examples/SampleApp/ios/Podfile.lock b/examples/SampleApp/ios/Podfile.lock index beaeacb61d..9deda9f191 100644 --- a/examples/SampleApp/ios/Podfile.lock +++ b/examples/SampleApp/ios/Podfile.lock @@ -3327,7 +3327,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - Teleport (0.5.4): + - Teleport (1.1.2): - boost - DoubleConversion - fast_float @@ -3354,9 +3354,9 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - SocketRocket - - Teleport/common (= 0.5.4) + - Teleport/common (= 1.1.2) - Yoga - - Teleport/common (0.5.4): + - Teleport/common (1.1.2): - boost - DoubleConversion - fast_float @@ -3827,7 +3827,7 @@ SPEC CHECKSUMS: RNGestureHandler: 6bc8f2f56c8a68f3380cd159f3a1ae06defcfabb RNNotifee: 5e3b271e8ea7456a36eec994085543c9adca9168 RNReactNativeHapticFeedback: 5f1542065f0b24c9252bd8cf3e83bc9c548182e4 - RNReanimated: 0e779d4d219b01331bf5ad620d30c5b993d18856 + RNReanimated: a1e0ce339c1d8f9164b7499920d8787d6a7f7a23 RNScreens: e902eba58a27d3ad399a495d578e8aba3ea0f490 RNShare: c0f25f3d0ec275239c35cadbc98c94053118bee7 RNSVG: b1cb00d54dbc3066a3e98732e5418c8361335124 @@ -3836,7 +3836,7 @@ SPEC CHECKSUMS: SDWebImageWebPCoder: 908b83b6adda48effe7667cd2b7f78c897e5111d SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 stream-chat-react-native: d15df89b47c1a08bc7db90c316d34b8ac4e13900 - Teleport: c5c5d9ac843d3024fd5776a7fcba22d37080f86b + Teleport: ed828b19e62ca8b9ec101d991bf0594b1c1c8812 Yoga: ff16d80456ce825ffc9400eeccc645a0dfcccdf5 PODFILE CHECKSUM: 4f662370295f8f9cee909f1a4c59a614999a209d diff --git a/examples/SampleApp/package.json b/examples/SampleApp/package.json index 5cacc802f0..228b0e2334 100644 --- a/examples/SampleApp/package.json +++ b/examples/SampleApp/package.json @@ -66,7 +66,7 @@ "react-native-screens": "^4.24.0", "react-native-share": "^12.0.11", "react-native-svg": "^15.15.4", - "react-native-teleport": "^0.5.4", + "react-native-teleport": "^1.1.2", "react-native-video": "^6.16.1", "react-native-worklets": "^0.8.1", "stream-chat-react-native": "link:../../package/native-package", diff --git a/examples/SampleApp/yarn.lock b/examples/SampleApp/yarn.lock index 70af5c4a61..5793eccca9 100644 --- a/examples/SampleApp/yarn.lock +++ b/examples/SampleApp/yarn.lock @@ -7674,10 +7674,10 @@ react-native-svg@^15.15.4: css-tree "^1.1.3" warn-once "0.1.1" -react-native-teleport@^0.5.4: - version "0.5.4" - resolved "https://registry.yarnpkg.com/react-native-teleport/-/react-native-teleport-0.5.4.tgz#6af16e3984ee0fc002e5d6c6dae0ad40f22699c2" - integrity sha512-kiNbB27lJHAO7DdG7LlPi6DaFnYusVQV9rqp0q5WoRpnqKNckIvzXULox6QpZBstaTF/jkO+xmlkU+pnQqKSAQ== +react-native-teleport@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/react-native-teleport/-/react-native-teleport-1.1.2.tgz#23deea2a34f6b1bb378e0305d44deeb93d51d490" + integrity sha512-64dcEkxlVKzxIts2FAVhzI2tDExcD23T13c2yDC/E+1dA1vP9UlDwPYUEkHvnoTOFtMDGrKLH03RJahIWfQC1g== react-native-url-polyfill@^2.0.0: version "2.0.0" From 582eba7b9182a81ccc50853e3bbf7fd8a3940c66 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Fri, 17 Apr 2026 15:16:42 +0200 Subject: [PATCH 10/11] chore: jsdoc --- package/src/components/Message/MessageOverlayWrapper.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/package/src/components/Message/MessageOverlayWrapper.tsx b/package/src/components/Message/MessageOverlayWrapper.tsx index 1769116454..a27f2eb7af 100644 --- a/package/src/components/Message/MessageOverlayWrapper.tsx +++ b/package/src/components/Message/MessageOverlayWrapper.tsx @@ -21,6 +21,10 @@ export type MessageOverlayWrapperProps = PropsWithChildren<{ testID?: string; }>; +/** + * Wraps the primary message overlay target so the active message can be teleported + * into the overlay host while a placeholder preserves its original layout space. + */ export const MessageOverlayWrapper = ({ children, targetId, From a3feeed079b08b324cc88649e6222593093bb11d Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Fri, 17 Apr 2026 15:47:13 +0200 Subject: [PATCH 11/11] chore: remove redundant file --- MESSAGE_OVERLAY_CUSTOMIZATION_NOTES.md | 565 ------------------------- 1 file changed, 565 deletions(-) delete mode 100644 MESSAGE_OVERLAY_CUSTOMIZATION_NOTES.md diff --git a/MESSAGE_OVERLAY_CUSTOMIZATION_NOTES.md b/MESSAGE_OVERLAY_CUSTOMIZATION_NOTES.md deleted file mode 100644 index 975d7976dd..0000000000 --- a/MESSAGE_OVERLAY_CUSTOMIZATION_NOTES.md +++ /dev/null @@ -1,565 +0,0 @@ -# Message Overlay Customization Notes - -Status: design note / implementation history / future reference - -Audience: SDK maintainers working on `Message`, message overlay, context menu customization, and performance-sensitive message rendering paths. - -Scope: this note documents the customization problem we tried to solve, the approaches we explored, the performance constraints that matter in this codepath, what we implemented experimentally, why we stopped, and what a future production-ready solution should look like. - -This document is intentionally detailed. `Message` is a hot path. Small architectural mistakes here get multiplied by every visible message row, every rerender, every scroll, and every overlay open/close cycle. - -## Why This Came Up - -We wanted to revisit message overlay customizability for a new major version. - -The main integrator-facing goals were: - -1. Top and bottom overlay items should be configurable or overridable. -2. The actual content portaled into the overlay should be overridable. - In plain terms: integrators should be able to choose which part of the message is teleported and measured. -3. Ideally, the whole overlay block should be overridable. - In practice, this is much harder because the current animation and measurement model is tightly coupled to the existing structure. - -The driving product requirement was not “nice abstraction for maintainers”. It was: - -- integrators should be able to satisfy custom layout requirements without forking core message logic -- customization should not degrade behavior -- customization should not tank performance - -The performance requirement is non-negotiable because `Message` is one of the hottest paths in the SDK. - -## Baseline Mental Model - -Before this work, the message overlay effectively assumed a mostly fixed structure: - -- `Message` owned overlay opening and measurement -- the default rendered message subtree was the thing measured and teleported -- top and bottom overlay items were rendered from `Message` -- deep subtree override of “what exactly moves into the overlay” was not a first-class concept - -That is simple and efficient, but restrictive. - -The moment we say “integrators can choose a smaller subtree, such as only the bubble content”, we introduce a new architectural problem: - -- how does `Message` know which mounted native view should be measured and portaled? - -That question is where all the complexity came from. - -## The Core Constraint - -There are two very different ways to decide overlay target ownership: - -1. Discover it after mount. - Wrappers register themselves, parent figures out which one wins. - -2. Declare it before render. - Parent already knows which target id should win, wrappers simply declare whether they match. - -The first model is flexible but reactive. -The second model is stricter but much better for performance and predictability. - -Most of the work below is really about moving from model 1 to model 2. - -## Requirements and Non-Requirements - -### Hard requirements - -- Custom message renderers must be able to choose the teleported subtree. -- The measured layout must correspond to the teleported subtree. -- Overlay styles must only apply to the teleported subtree when customization is active. -- The default whole-message behavior must remain available. -- The solution must avoid unnecessary subscriptions, context churn, and mount-time rerenders. - -### Nice-to-haves - -- Integrators should not need to replace the entire `Message` component just to customize the teleported subtree. -- The API should be understandable from user code without having to know overlay internals. -- The solution should degrade predictably when misconfigured. - -### Non-goals for the first serious version - -- Full arbitrary override of the entire top/message/bottom animated block. -- Preserving every possible “multiple competing wrappers register themselves and the system figures it out” behavior. -- Allowing extremely flexible internals if they materially harm the hot path. - -## What We Tried - -## Phase 1: Wrapper Registration Model - -The first serious idea was: - -- introduce `MessageOverlayWrapper` -- allow integrators to wrap a custom subtree -- every wrapper registers itself with `Message` -- `Message` chooses the winning target -- the winning target is measured and portaled - -The default path wrapped the whole `MessageItemView`. -Custom integrator paths could wrap a smaller subtree, such as `MessageContent`. - -This solved the raw customization problem: - -- it allowed “portal only the bubble” -- it allowed future extension to other subtrees -- it kept measurement and teleport coupled - -But it introduced a series of costs. - -### Why the registration model is heavier than it looks - -For each message instance: - -- wrapper renders once before registration -- wrapper ref mounts -- wrapper registers itself into parent state -- parent computes the active target -- message rerenders so wrappers know whether they are active - -That means even the default case pays a mount-time “discovery pass”. - -This is not just a cosmetic rerender. It means target identity is not known during the initial render, so every optimization that depends on `isActiveTarget` is delayed by one render. - -### Performance concerns in this model - -The first version carried several kinds of overhead: - -- registration itself -- React state updates in `Message` to store active target metadata -- broad `MessageContext` invalidation because target-selection state lived there -- an overlay store subscription inside `MessageOverlayWrapper` -- extra portal wrappers even for inactive candidates - -The cumulative effect was small per row, but dangerous in aggregate because messages are many and frequent. - -## Phase 2: Narrow Runtime Context Split - -The next optimization was to remove the most obvious duplicate subscription path. - -At that point, overlay state was effectively being consumed in multiple places: - -- `Message.tsx` needed it for overlay orchestration -- `MessageOverlayWrapper` needed it to decide whether to portal and render a placeholder -- `useShouldUseOverlayStyles()` needed it to know whether overlay styles should apply inside the teleported subtree - -The first refinement was: - -- stop putting active target runtime data into the broad `MessageContext` -- move runtime bits used by the wrapper into a narrow dedicated context -- remove the wrapper’s direct overlay-store subscription - -This helped, because: - -- fewer broad message context invalidations -- one fewer `useIsOverlayActive(messageOverlayId)` subscription per wrapper - -But it did not solve the deeper issue: - -- target identity was still discovered after mount - -So the mount-time second render still existed. - -## Phase 3: “Why Are Inactive Wrappers Paying for Portal Machinery?” - -A key observation during review was: - -- if a wrapper is not the active target, why is it still rendering through the full portal branch? - -This question was valid. - -The original wrapper shape effectively kept a portal-capable structure in place and toggled teleport via `hostName`. -That preserved subtree shape, but it meant inactive wrappers still paid for some inert structure. - -The natural follow-up proposal was: - -- inactive wrapper: render normal wrapped content only -- active wrapper: render portal branch and placeholder - -This is the right instinct, but in the discovery-based model it is not fully safe because: - -- on first render the wrapper does not yet know whether it is active -- it only learns that after ref registration and parent state update -- that means switching from plain branch to portal branch can happen after mount - -That is exactly the kind of structural flip that can remount or reparent the subtree in ways we do not want to rely on in a hot path. - -Important detail: - -- the ref being set after commit does not prevent remounts -- a ready ref does not mean “React will preserve this tree if the parent element type changes” - -So the question exposed the real problem correctly: - -- the issue was not just “we have too many portals” -- the issue was “the winner is discovered too late” - -## Phase 4: Declarative Target ID Model - -The next design step was the first one that actually addressed the root cause. - -The idea: - -- `Message` should know the desired overlay target id before render -- `MessageOverlayWrapper` should declare its own stable `targetId` -- `isActiveTarget` should become a synchronous string comparison -- registration should exist only to store mounted refs, not to drive React state - -This changes the model from: - -- wrappers register -- parent discovers winner -- parent stores winner in state -- wrappers rerender - -to: - -- parent already knows requested target id -- wrappers know their own target ids -- wrappers can decide active vs inactive in one pass -- registration only fills a ref map for measurement - -This is the first architecture that meaningfully supports the “inactive wrappers should not pay portal costs” goal. - -## The Experimental Declarative Shape - -The experimental refactor introduced the following ideas: - -### 1. Stable target ids - -- default whole-message target gets a stable constant id -- custom targets must provide a stable id via `MessageOverlayWrapper` - -Example shape: - -```tsx - - - -``` - -Custom integrator shape: - -```tsx -const CustomMessageContent = (props) => ( - - - -); -``` - -### 2. Parent-declared selected target - -`Message` receives a selected target id up front. - -Default: - -```ts -messageOverlayTargetId = DEFAULT_MESSAGE_OVERLAY_TARGET_ID -``` - -Custom: - -```tsx - -``` - -### 3. Registration becomes imperative bookkeeping only - -Wrappers still register their mounted native views, but registration no longer sets React state. - -Instead, `Message` stores: - -```ts -const messageOverlayTargetsRef = useRef>({}); -``` - -Registration becomes: - -```ts -messageOverlayTargetsRef.current[targetId] = view; -``` - -Unregistration becomes: - -```ts -delete messageOverlayTargetsRef.current[targetId]; -``` - -No active target state. -No post-mount selection rerender. - -### 4. Wrapper can branch immediately - -Because both values are known on the first render: - -- `requestedTargetId` -- `wrapper.targetId` - -the wrapper can immediately decide: - -- inactive branch: normal local `View` -- active branch: portal-capable branch - -This is the key performance win in the design. - -### 5. Overlay open measures the requested target directly - -On overlay open: - -```ts -const activeTargetView = messageOverlayTargetsRef.current[messageOverlayTargetId]; -``` - -The selected target is measured directly. - -There is no “find last registered custom target else fallback to default” logic in React state anymore. - -## Why This Direction Is Better - -Conceptually, the declarative id model is much stronger than the registration-discovery model. - -It gives us: - -- single-pass target selection -- no mount-time “discover winner” rerender -- no stateful active-target bookkeeping in `Message` -- no need for inactive wrappers to carry portal machinery -- a much clearer mental model for integrators - -For performance-sensitive code, these are meaningful wins. - -## Why We Stopped Anyway - -Even though the direction is strong, it is still too invasive to rush before release. - -This is no longer a small internal refactor. It changes the public customization contract. - -Specifically, the declarative model raises real design questions: - -### 1. What should happen when the selected target id does not register? - -There are two plausible answers: - -- hard fail or warning -- silent fallback to default target - -Hard fail is cleaner and more predictable. -Fallback is friendlier, but it muddies behavior and can hide configuration bugs. - -For a hot path, silent fallback is risky because it creates “works but not really” states. - -### 2. Where should the selected target id live? - -Possible owners: - -- `Message` -- `Channel` -- `MessagesContext` -- some combination of the above - -We experimented with plumbing it through `Channel` / `MessagesContext` because that gives integrators a practical way to override only `MessageContent`. - -That is likely the right UX, but it should be designed intentionally, not patched in casually. - -### 3. Do we still want multiple candidates? - -The old model allowed: - -- default wrapper -- one or more custom wrappers -- parent picks one - -The declarative model strongly encourages: - -- exactly one declared target id -- any non-matching wrappers are inactive by definition - -This is better for performance, but it is also more opinionated. - -### 4. How much freedom are we willing to give up to protect performance? - -This is the central architectural question. - -A message overlay system can be: - -- very flexible -- very predictable -- very cheap - -but getting all three at once is difficult. - -For `Message`, the bias should probably be toward: - -- predictable -- cheap - -with only the minimum flexibility needed for real integrator use cases. - -## Important Performance Lessons - -These are the main performance takeaways from the work so far. - -### 1. Do not discover active target identity through React state if it can be declared synchronously - -If target identity is known before render, keep it out of React state. - -Stateful discovery forces: - -- extra renders -- context churn -- more complicated active/inactive branching - -### 2. Be very careful what lives in `MessageContext` - -`MessageContext` is consumed widely across message internals. - -Putting rapidly changing overlay-target metadata there causes: - -- broad rerenders -- hidden coupling between overlay internals and unrelated message UI - -Any overlay-runtime data that does not need to invalidate the whole message subtree should live in a narrower context. - -### 3. Avoid duplicate subscriptions in wrapper-level code - -If `Message` already knows overlay state and wrapper code can receive it cheaply, do not add another direct store subscription per wrapper unless it is strictly necessary. - -### 4. Inactive candidates should be as cheap as possible - -If we support multiple possible overlay targets, only the selected one should pay for teleport-specific machinery. - -At minimum, inactive wrappers should avoid: - -- portal-specific behavior -- placeholder rendering -- unnecessary style gating complexity - -### 5. Ref registration is cheaper than stateful registration, but not free - -Even with the declarative model, wrappers still register refs. -That is acceptable. - -What is not acceptable is allowing registration to drive render-time selection state. - -### 6. Shape changes after mount are dangerous - -Switching a subtree from: - -- plain local branch - -to - -- portal branch - -after mount can cause remount or reparenting behavior that is hard to reason about. - -If we want inactive wrappers to bypass portal machinery, we should do it only when active/inactive status is known before the first render. - -## Integrator Experience We Were Optimizing For - -The real-world use case we kept in mind was: - -- an integrator likes the SDK -- their PM or designer asks for “only the message bubble should lift into the overlay” -- they do not want to replace the whole `Message` -- they want a small override with predictable behavior - -The ideal integrator story would look like this: - -```tsx -const CustomMessageContent = (props) => ( - - - -); - - -``` - -That is simple enough to document and reason about. - -The moment the integrator wants to portal an arbitrary combination of message parts from multiple places, the complexity rises sharply. That is where we should be careful not to overspec the public API too early. - -## What Exists in the Experimental Branch - -At the time this note was written, the local experimental work had explored all of the following: - -- wrapper-based overlay target customization -- narrow overlay runtime context split -- declarative target ids -- `Channel`/`MessagesContext` plumbing for `messageOverlayTargetId` -- sample app experiment where only `MessageContent` is the overlay target - -This work proved the concept, but it should not be treated as final design merely because it works locally. - -## Why This Should Not Ship Hastily - -Before shipping a major-version API around this, we should be able to answer all of these confidently: - -- What is the exact public API? -- What is the missing-target behavior? -- What is the migration path from default whole-message behavior? -- What is the fallback strategy, if any? -- What are the expected rerender characteristics in idle state? -- What are the expected rerender characteristics on overlay open/close? -- How does this behave with custom `MessageItemView`, custom `MessageContent`, and future whole-block overrides? -- How do we document the contract so integrators do not accidentally build slow or broken setups? - -If any of those are still vague, the work is not ready. - -## Recommended Future Plan - -When revisiting this, treat it as a design task first and an implementation task second. - -Recommended order: - -1. Decide the public API. - Choose whether the selected overlay target is configured on `Channel`, `Message`, or both. - -2. Decide the contract. - Define whether one target id is selected globally per message instance, and whether multiple candidate wrappers are supported. - -3. Decide failure behavior. - Explicitly choose hard error vs warning vs fallback when the selected target does not register. - -4. Decide styling semantics. - Define exactly which subtree receives overlay-specific styles when a non-default target is selected. - -5. Write migration examples. - Especially for the common “portal only `MessageContent`” case. - -6. Profile before and after. - Measure real message list behavior rather than relying on feel alone. - -7. Only then finalize implementation. - -## Concrete Guardrails for the Future Implementation - -Any future production-ready implementation should satisfy these guardrails: - -- No mount-time target-selection rerender in the default case. -- No broad `MessageContext` invalidation from overlay-target identity changes. -- No redundant overlay store subscription in wrapper-level code unless clearly justified. -- Inactive overlay targets must not pay for portal-specific runtime behavior. -- Measurement target identity must be explicit and predictable. -- Customization must not require overriding the entire `Message` for the common bubble-only case. -- The API contract must be documented with real examples, not just types. - -## Short Conclusion - -The concept is good. - -The main insight is: - -- overlay target identity should be declarative, not discovered after mount - -That is the version with the best chance of being both customizable and performant. - -However, this is not small enough to rush before release. - -The work should be revisited later with a proper design pass, explicit API decisions, and profiling, because `Message` is too hot a path for a half-settled abstraction.