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;