diff --git a/apps/meteor/client/components/message/content/urlPreviews/OEmbedHtmlPreview.tsx b/apps/meteor/client/components/message/content/urlPreviews/OEmbedHtmlPreview.tsx index 483f553c01a2b..f2d24156858cf 100644 --- a/apps/meteor/client/components/message/content/urlPreviews/OEmbedHtmlPreview.tsx +++ b/apps/meteor/client/components/message/content/urlPreviews/OEmbedHtmlPreview.tsx @@ -1,5 +1,6 @@ import { Box } from '@rocket.chat/fuselage'; import DOMPurify from 'dompurify'; +import { useMemo } from 'react'; import OEmbedCollapsible from './OEmbedCollapsible'; import type { OEmbedPreviewMetadata } from './OEmbedPreviewMetadata'; @@ -10,10 +11,14 @@ const purifyOptions = { ALLOW_UNKNOWN_PROTOCOLS: true, }; -const OEmbedHtmlPreview = ({ html, ...props }: OEmbedPreviewMetadata) => ( - - {html && } - -); +const OEmbedHtmlPreview = ({ html, ...props }: OEmbedPreviewMetadata) => { + // Memoize the dangerouslySetInnerHTML object so its identity is stable across + // re-renders. React 19 re-applies innerHTML when this object is a new reference + // (even with an identical string), which reloads embedded iframes (e.g. YouTube) + // on every re-render — visible as a flicker on new messages / reactions. + const dangerous = useMemo(() => (html ? { __html: DOMPurify.sanitize(html, purifyOptions) } : undefined), [html]); + + return {dangerous && }; +}; export default OEmbedHtmlPreview; diff --git a/apps/meteor/client/contexts/EmojiPickerContext.ts b/apps/meteor/client/contexts/EmojiPickerContext.ts index b79239441f188..f8af547a25b36 100644 --- a/apps/meteor/client/contexts/EmojiPickerContext.ts +++ b/apps/meteor/client/contexts/EmojiPickerContext.ts @@ -1,4 +1,4 @@ -import { createContext, useContext } from 'react'; +import { createContext, useContext, useMemo } from 'react'; import type { EmojiPickerItem, CategoriesIndexes } from '../../app/emoji/client'; @@ -33,11 +33,13 @@ const useEmojiPickerContext = (): EmojiPickerContextValue => { return context; }; -export const useEmojiPicker = () => ({ - open: useEmojiPickerContext().open, - isOpen: useEmojiPickerContext().isOpen, - close: useEmojiPickerContext().close, -}); +export const useEmojiPicker = () => { + const { open, isOpen, close } = useEmojiPickerContext(); + // Stable identity: consumers (e.g. ChatAPI.emojiPicker) use this as a memo + // dependency, so returning a fresh object each render invalidates them and + // cascades re-renders across the whole message list. + return useMemo(() => ({ open, isOpen, close }), [open, isOpen, close]); +}; export const usePreviewEmoji = () => ({ emojiToPreview: useEmojiPickerContext().emojiToPreview, diff --git a/apps/meteor/client/providers/EmojiPickerProvider/EmojiPickerProvider.tsx b/apps/meteor/client/providers/EmojiPickerProvider/EmojiPickerProvider.tsx index 3fd2ca36844d6..a0cf63bbf683d 100644 --- a/apps/meteor/client/providers/EmojiPickerProvider/EmojiPickerProvider.tsx +++ b/apps/meteor/client/providers/EmojiPickerProvider/EmojiPickerProvider.tsx @@ -80,6 +80,8 @@ const EmojiPickerProvider = ({ children }: EmojiPickerProviderProps) => { return setEmojiPicker( setEmojiPicker(null)} onPickEmoji={(emoji) => callback(emoji)} />); }, []); + const close = useCallback(() => setEmojiPicker(null), []); + const handlePreview = useCallback( (emoji: string, name: string) => setEmojiToPreview((preview) => { @@ -96,7 +98,7 @@ const EmojiPickerProvider = ({ children }: EmojiPickerProviderProps) => { const contextValue = useMemo( (): ContextType => ({ isOpen: emojiPicker !== null, - close: () => setEmojiPicker(null), + close, open, emojiToPreview, handlePreview, @@ -117,6 +119,7 @@ const EmojiPickerProvider = ({ children }: EmojiPickerProviderProps) => { [ emojiPicker, open, + close, emojiToPreview, addRecentEmoji, emojiListByCategory, diff --git a/apps/meteor/client/views/room/MessageList/hooks/useKeepMountedMessages.ts b/apps/meteor/client/views/room/MessageList/hooks/useKeepMountedMessages.ts index 01d0875ea7f6f..b7267446b7c36 100644 --- a/apps/meteor/client/views/room/MessageList/hooks/useKeepMountedMessages.ts +++ b/apps/meteor/client/views/room/MessageList/hooks/useKeepMountedMessages.ts @@ -3,7 +3,12 @@ import type { IMessage } from '@rocket.chat/core-typings'; export const useKeepMountedMessages = (messages: IMessage[], canPreview: boolean = false): number[] => { const offset = canPreview ? 1 : 0; return messages.reduce((acc, message, index) => { - if (message.files?.length && message.files.length > 0) { + // Keep mounted anything with an embed that reloads when re-mounted: file + // attachments (audio/video players) and URL previews (e.g. YouTube iframes). + // Otherwise virtua recycles them on scroll-to-bottom (new message / reaction + // growing a message) and the iframe flickers. + const hasUrlPreview = message.urls?.some((url) => Object.keys(url.meta ?? {}).length > 0 || !!url.headers) ?? false; + if ((message.files?.length ?? 0) > 0 || hasUrlPreview) { acc.push(index + offset); } return acc;