Skip to content

refactor: architectural overhaul, memory leak fixes, and UI consistency#1163

Open
vivekyadav-3 wants to merge 4 commits intoRocketChat:developfrom
vivekyadav-3:develop
Open

refactor: architectural overhaul, memory leak fixes, and UI consistency#1163
vivekyadav-3 wants to merge 4 commits intoRocketChat:developfrom
vivekyadav-3:develop

Conversation

@vivekyadav-3
Copy link

@vivekyadav-3 vivekyadav-3 commented Feb 19, 2026

I've been spending more time diving into the
EmbeddedChat
package, and the deeper I go, the more opportunities I find to boost its stability and performance. I've updated this PR to include several critical refinements that I believe make the widget feel much more refined and production-ready.
What's new in this update?
Auth Stability: I've unified the onAuthChange triggers in
ChatBody.js
. By consolidating these into a single managed effect lifecycle, we avoid redundant calls and ensure that listeners are cleaned up properly.
Fixing Memory Leaks: I caught a bug in
ChatInput.js
where the typing indicator was creating "zombie" timeouts on every keystroke. I've implemented a robust cleanup logic that prevents these leaks and ensures the status clears correctly.
Performance Polish: I refactored the render loop in the
MessageAggregator
to remove unnecessary side-effects. This significantly reduces re-renders, making message grouping and scrolling feel a lot snappier.
UI Harmony: Updated the
ChatHeader
to use our dedicated
Avatar
component instead of a raw tag. It’s a small change, but it ensures our design system is consistent across the entire header.
Hardening MFA: Further refined the
useRCAuth
logic to handle TOTP/MFA states more gracefully, providing much better feedback for secure logins.

Copilot AI review requested due to automatic review settings February 19, 2026 17:31
@CLAassistant
Copy link

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This pull request aims to optimize authentication listeners and improve message rendering reliability in EmbeddedChat. The changes consolidate redundant auth listeners, refactor message deduplication logic, improve memoization for message toolbox permissions, and enhance login error handling.

Changes:

  • Consolidated multiple auth listeners in ChatBody.js into a single lifecycle to prevent listener accumulation
  • Refactored MessageAggregator.js to move side effects into useEffect and optimize message deduplication
  • Improved useMemo dependencies in MessageToolbox.js to ensure permission-based UI updates reflect role changes immediately
  • Enhanced TOTP/MFA flow error handling in useRCAuth.js with clearer state transitions and user feedback

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 9 comments.

File Description
packages/react/src/views/MessageAggregators/common/MessageAggregator.js Optimized theme destructuring, moved message render state logic to useEffect, and attempted to extract message deduplication into a reusable variable
packages/react/src/views/Message/MessageToolbox.js Wrapped permission checks in useMemo with complete dependencies, changed string quotes to double quotes throughout
packages/react/src/views/ChatBody/ChatBody.js Consolidated three separate auth listener useEffects into one, improved scroll-to-bottom logic with position awareness
packages/react/src/hooks/useRCAuth.js Restructured login error handling with explicit returns for each state, improved TOTP flow messages and state cleanup

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 1 to 376
@@ -8,16 +8,16 @@ import {
useComponentOverrides,
appendClassNames,
useTheme,
} from '@embeddedchat/ui-elements';
import RCContext from '../../context/RCInstance';
import { EmojiPicker } from '../EmojiPicker';
import { getMessageToolboxStyles } from './Message.styles';
import SurfaceMenu from '../SurfaceMenu/SurfaceMenu';
import { Markdown } from '../Markdown';
import Attachment from '../AttachmentHandler/Attachment';
} from "@embeddedchat/ui-elements";
import RCContext from "../../context/RCInstance";
import { EmojiPicker } from "../EmojiPicker";
import { getMessageToolboxStyles } from "./Message.styles";
import SurfaceMenu from "../SurfaceMenu/SurfaceMenu";
import { Markdown } from "../Markdown";
import Attachment from "../AttachmentHandler/Attachment";

export const MessageToolbox = ({
className = '',
className = "",
message,
variantStyles = {},
style = {},
@@ -42,16 +42,16 @@ export const MessageToolbox = ({
isEditing = false,
optionConfig = {
surfaceItems: [
'reaction',
'reply',
'quote',
'star',
'copy',
'link',
'pin',
'edit',
'delete',
'report',
"reaction",
"reply",
"quote",
"star",
"copy",
"link",
"pin",
"edit",
"delete",
"report",
],

menuItems: [],
@@ -60,9 +60,9 @@ export const MessageToolbox = ({
...props
}) => {
const { styleOverrides, classNames, configOverrides } = useComponentOverrides(
'MessageToolbox',
"MessageToolbox",
className,
style
style,
);
const { RCInstance } = useContext(RCContext);
const instanceHost = RCInstance.getHost();
@@ -81,121 +81,148 @@ export const MessageToolbox = ({
setShowDeleteModal(false);
};

const isAllowedToPin = userRoles.some((role) => pinRoles.has(role));
const {
isAllowedToPin,
isAllowedToReport,
isAllowedToEditMessage,
isAllowedToDeleteMessage,
isAllowedToDeleteOwnMessage,
isAllowedToForceDeleteMessage,
isVisibleForMessageType,
canDeleteMessage,
} = useMemo(() => {
const isOwner = message.u._id === authenticatedUserId;
const allowedToPin = userRoles.some((role) => pinRoles.has(role));
const allowedToReport = !isOwner;
const allowedToEdit =
userRoles.some((role) => editMessageRoles.has(role)) || isOwner;
const allowedToDelete = userRoles.some((role) =>
deleteMessageRoles.has(role),
);
const allowedToDeleteOwn = userRoles.some((role) =>
deleteOwnMessageRoles.has(role),
);
const allowedToForceDelete = userRoles.some((role) =>
forceDeleteMessageRoles.has(role),
);

const isAllowedToReport = message.u._id !== authenticatedUserId;
const visibleForMessageType =
message.files?.[0]?.type !== "audio/mpeg" &&
message.files?.[0]?.type !== "video/mp4";

const isAllowedToEditMessage = userRoles.some((role) =>
editMessageRoles.has(role)
)
? true
: message.u._id === authenticatedUserId;
const canDelete = allowedToForceDelete
? true
: allowedToDelete
? true
: allowedToDeleteOwn
? isOwner
: false;

const isAllowedToDeleteMessage = userRoles.some((role) =>
deleteMessageRoles.has(role)
);
const isAllowedToDeleteOwnMessage = userRoles.some((role) =>
deleteOwnMessageRoles.has(role)
);
const isAllowedToForceDeleteMessage = userRoles.some((role) =>
forceDeleteMessageRoles.has(role)
);

const isVisibleForMessageType =
message.files?.[0].type !== 'audio/mpeg' &&
message.files?.[0].type !== 'video/mp4';

const canDeleteMessage = isAllowedToForceDeleteMessage
? true
: isAllowedToDeleteMessage
? true
: isAllowedToDeleteOwnMessage
? message.u._id === authenticatedUserId
: false;
return {
isAllowedToPin: allowedToPin,
isAllowedToReport: allowedToReport,
isAllowedToEditMessage: allowedToEdit,
isAllowedToDeleteMessage: allowedToDelete,
isAllowedToDeleteOwnMessage: allowedToDeleteOwn,
isAllowedToForceDeleteMessage: allowedToForceDelete,
isVisibleForMessageType: visibleForMessageType,
canDeleteMessage: canDelete,
};
}, [
authenticatedUserId,
userRoles,
pinRoles,
deleteMessageRoles,
deleteOwnMessageRoles,
forceDeleteMessageRoles,
editMessageRoles,
message.u._id,
message.files,
]);

const options = useMemo(
() => ({
reply: {
label: 'Reply in thread',
id: 'reply',
label: "Reply in thread",
id: "reply",
onClick: handleOpenThread(message),
iconName: 'thread',
iconName: "thread",
visible: !isThreadMessage,
},
quote: {
label: 'Quote',
id: 'quote',
label: "Quote",
id: "quote",
onClick: () => handleQuoteMessage(message),
iconName: 'quote',
iconName: "quote",
visible: true,
},
star: {
label:
message.starred &&
message.starred.find((u) => u._id === authenticatedUserId)
? 'Unstar'
: 'Star',
id: 'star',
? "Unstar"
: "Star",
id: "star",
onClick: () => handleStarMessage(message),
iconName:
message.starred &&
message.starred.find((u) => u._id === authenticatedUserId)
? 'star-filled'
: 'star',
? "star-filled"
: "star",
visible: true,
},
reaction: {
label: 'Add reaction',
id: 'reaction',
label: "Add reaction",
id: "reaction",
onClick: () => setEmojiOpen(true),
iconName: 'emoji',
iconName: "emoji",
visible: true,
},
pin: {
label: message.pinned ? 'Unpin' : 'Pin',
id: 'pin',
label: message.pinned ? "Unpin" : "Pin",
id: "pin",
onClick: () => handlePinMessage(message),
iconName: message.pinned ? 'pin-filled' : 'pin',
iconName: message.pinned ? "pin-filled" : "pin",
visible: isAllowedToPin,
},
edit: {
label: 'Edit',
id: 'edit',
label: "Edit",
id: "edit",
onClick: () => handleEditMessage(message),
iconName: 'edit',
iconName: "edit",
visible: isAllowedToEditMessage,
color: isEditing ? 'secondary' : 'default',
color: isEditing ? "secondary" : "default",
ghost: !isEditing,
},
copy: {
label: 'Copy message',
id: 'copy',
label: "Copy message",
id: "copy",
onClick: () => handleCopyMessage(message),
iconName: 'copy',
iconName: "copy",
visible: true,
},
link: {
label: 'Copy link',
id: 'link',
label: "Copy link",
id: "link",
onClick: () => handleCopyMessageLink(message),
iconName: 'link',
iconName: "link",
visible: true,
},
delete: {
label: 'Delete',
id: 'delete',
label: "Delete",
id: "delete",
onClick: () => setShowDeleteModal(true),
iconName: 'trash',
iconName: "trash",
visible: canDeleteMessage,
type: 'destructive',
type: "destructive",
},
report: {
label: 'Report',
id: 'report',
label: "Report",
id: "report",
onClick: () => handlerReportMessage(message),
iconName: 'report',
iconName: "report",
visible: isAllowedToReport,
type: 'destructive',
type: "destructive",
},
}),
[
@@ -210,8 +237,12 @@ export const MessageToolbox = ({
handleEditMessage,
handlerReportMessage,
handleCopyMessage,
handleCopyMessageLink,
isAllowedToPin,
]
isAllowedToReport,
isAllowedToEditMessage,
canDeleteMessage,
],
);

const menuOptions = menuItems
@@ -248,7 +279,7 @@ export const MessageToolbox = ({
<Box css={variantStyles.toolboxContainer || styles.toolboxContainer}>
<Box
css={styles.toolbox}
className={appendClassNames('ec-message-toolbox', classNames)}
className={appendClassNames("ec-message-toolbox", classNames)}
style={styleOverrides}
{...props}
>
@@ -259,9 +290,9 @@ export const MessageToolbox = ({
<Menu
size="small"
options={menuOptions}
tooltip={{ isToolTip: true, position: 'top', text: 'More' }}
tooltip={{ isToolTip: true, position: "top", text: "More" }}
useWrapper={false}
style={{ top: 'auto', bottom: `calc(100% + 2px)` }}
style={{ top: "auto", bottom: `calc(100% + 2px)` }}
/>
)}

@@ -288,45 +319,45 @@ export const MessageToolbox = ({
<Icon
name="trash"
size="1.25rem"
style={{ marginRight: '0.5rem' }}
/>{' '}
style={{ marginRight: "0.5rem" }}
/>{" "}
Delete this message?
</Modal.Title>
<Modal.Close onClick={handleOnClose} />
</Modal.Header>
<Modal.Content
style={{
overflow: 'scroll',
whiteSpace: 'wrap',
padding: '1rem',
maxHeight: '50vh',
overflow: "scroll",
whiteSpace: "wrap",
padding: "1rem",
maxHeight: "50vh",
}}
>
{message.file ? (
message.file.type.startsWith('image/') ? (
message.file.type.startsWith("image/") ? (
<div>
<img
src={`${instanceHost}/file-upload/${message.file._id}/${message.file.name}`}
alt={message.file.name}
style={{ maxWidth: '100px', maxHeight: '100px' }}
style={{ maxWidth: "100px", maxHeight: "100px" }}
/>
<div>{`${message.file.name} (${(
message.file.size / 1024
).toFixed(2)} kB)`}</div>
</div>
) : message.file.type.startsWith('video/') ? (
) : message.file.type.startsWith("video/") ? (
<video
controls
style={{ maxWidth: '100%', maxHeight: '200px' }}
style={{ maxWidth: "100%", maxHeight: "200px" }}
>
<source
src={`${instanceHost}/file-upload/${message.file._id}/${message.file.name}`}
type={message.file.type}
/>
Your browser does not support the video tag.
</video>
) : message.file.type.startsWith('audio/') ? (
<audio controls style={{ maxWidth: '100%' }}>
) : message.file.type.startsWith("audio/") ? (
<audio controls style={{ maxWidth: "100%" }}>
<source
src={`${instanceHost}/file-upload/${message.file._id}/${message.file.name}`}
type={message.file.type}
@@ -342,7 +373,7 @@ export const MessageToolbox = ({
{message.attachments &&
message.attachments.length > 0 &&
message.msg &&
message.msg[0] === '[' &&
message.msg[0] === "[" &&
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Quote style violation: This file changed from single quotes to double quotes for all string literals, which violates the project's Prettier configuration. The .prettierrc file explicitly specifies "singleQuote": true. All string literals should use single quotes to match the project's code style. Run Prettier on this file to fix the formatting.

Copilot uses AI. Check for mistakes.
}
}
}, [messages]);
}, [messages, messageListRef]);
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Incorrect dependency in useEffect: messageListRef should not be included in the dependency array. Ref objects have stable references across renders and don't trigger re-renders when their .current value changes. Including refs in dependency arrays is incorrect and can cause unexpected behavior. Remove messageListRef from this dependency array.

Copilot uses AI. Check for mistakes.
return () => {
currentRef.removeEventListener('scroll', handleScroll);
};
}, [handleScroll, messageListRef]);
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Incorrect dependency in useEffect: messageListRef should not be included in the dependency array. Ref objects have stable references across renders and should not be dependencies. This can cause the event listener to be unnecessarily removed and re-added. Remove messageListRef from this dependency array.

Copilot uses AI. Check for mistakes.
anonymousMode,
getMessagesAndRoles,
fetchAndSetPermissions,
permissionsRef,
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Incorrect dependency in useEffect: permissionsRef should not be included in the dependency array. Ref objects like permissionsRef have stable references across renders and don't trigger re-renders when their .current value changes. Including refs in dependency arrays is unnecessary and can violate React's rules of hooks. Remove permissionsRef from the dependency array.

Suggested change
permissionsRef,

Copilot uses AI. Check for mistakes.
Comment on lines +315 to +321
const { scrollTop, scrollHeight, clientHeight } = messageListRef.current;
const isAtBottom = scrollHeight - scrollTop - clientHeight < 100;
const isInitialLoad = messages.length > 0 && scrollTop === 0;

if (isAtBottom || isInitialLoad) {
messageListRef.current.scrollTop = scrollHeight;
}
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The scroll behavior logic has a potential issue. The condition isInitialLoad = messages.length > 0 && scrollTop === 0 may incorrectly trigger scrolling to bottom when the user has scrolled to the top to load older messages. This could disrupt the user's reading experience by unexpectedly jumping to the bottom when old messages are loaded. Consider checking if this is truly an initial load rather than just being at scroll position 0.

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing useEffect import from React. The code uses useEffect on line 130 but it's not included in the import statement. This will cause a runtime error.

Copilot uses AI. Check for mistakes.
Comment on lines 130 to 133
useEffect(() => {
const hasRendered = uniqueMessageList.some((msg) => shouldRender(msg));
setMessageRendered(hasRendered);
}, [uniqueMessageList, shouldRender]);
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing definition of uniqueMessageList. The code references uniqueMessageList on lines 131, 133, and 174, but this variable is never defined. Based on the original code, this should be a memoized deduplication of messageList by message _id. Add the missing useMemo definition to compute the unique message list.

Copilot uses AI. Check for mistakes.
Comment on lines +91 to +141
isVisibleForMessageType,
canDeleteMessage,
} = useMemo(() => {
const isOwner = message.u._id === authenticatedUserId;
const allowedToPin = userRoles.some((role) => pinRoles.has(role));
const allowedToReport = !isOwner;
const allowedToEdit =
userRoles.some((role) => editMessageRoles.has(role)) || isOwner;
const allowedToDelete = userRoles.some((role) =>
deleteMessageRoles.has(role),
);
const allowedToDeleteOwn = userRoles.some((role) =>
deleteOwnMessageRoles.has(role),
);
const allowedToForceDelete = userRoles.some((role) =>
forceDeleteMessageRoles.has(role),
);

const isAllowedToReport = message.u._id !== authenticatedUserId;
const visibleForMessageType =
message.files?.[0]?.type !== "audio/mpeg" &&
message.files?.[0]?.type !== "video/mp4";

const isAllowedToEditMessage = userRoles.some((role) =>
editMessageRoles.has(role)
)
? true
: message.u._id === authenticatedUserId;
const canDelete = allowedToForceDelete
? true
: allowedToDelete
? true
: allowedToDeleteOwn
? isOwner
: false;

const isAllowedToDeleteMessage = userRoles.some((role) =>
deleteMessageRoles.has(role)
);
const isAllowedToDeleteOwnMessage = userRoles.some((role) =>
deleteOwnMessageRoles.has(role)
);
const isAllowedToForceDeleteMessage = userRoles.some((role) =>
forceDeleteMessageRoles.has(role)
);

const isVisibleForMessageType =
message.files?.[0].type !== 'audio/mpeg' &&
message.files?.[0].type !== 'video/mp4';

const canDeleteMessage = isAllowedToForceDeleteMessage
? true
: isAllowedToDeleteMessage
? true
: isAllowedToDeleteOwnMessage
? message.u._id === authenticatedUserId
: false;
return {
isAllowedToPin: allowedToPin,
isAllowedToReport: allowedToReport,
isAllowedToEditMessage: allowedToEdit,
isAllowedToDeleteMessage: allowedToDelete,
isAllowedToDeleteOwnMessage: allowedToDeleteOwn,
isAllowedToForceDeleteMessage: allowedToForceDelete,
isVisibleForMessageType: visibleForMessageType,
canDeleteMessage: canDelete,
};
}, [
authenticatedUserId,
userRoles,
pinRoles,
deleteMessageRoles,
deleteOwnMessageRoles,
forceDeleteMessageRoles,
editMessageRoles,
message.u._id,
message.files,
]);
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dead code: isVisibleForMessageType is computed in the useMemo but is never actually used anywhere in the component. This adds unnecessary computation overhead. Either remove this unused variable or use it where intended (possibly to control visibility of certain toolbox options).

Copilot uses AI. Check for mistakes.
// Catch-all for other response errors
if (res.error) {
throw new Error(res.reason || res.error);
}
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The catch-all error handling at line 78-81 may inadvertently catch successful login responses that don't match the expected format. If res.status !== 'success' and !res.me and !res.error, the function will not return anything, potentially leaving the UI in an inconsistent state. Consider adding an explicit return statement for this case or logging a warning when an unexpected response format is received.

Suggested change
}
}
// Handle unexpected response formats to avoid returning undefined
console.warn('Unexpected login response format:', res);
return {
status: 'error',
error: 'Unexpected response format received from authentication server.',
};

Copilot uses AI. Check for mistakes.
@vivekyadav-3 vivekyadav-3 changed the title refactor: optimize auth listeners and improve reliability in Embedded… refactor: architectural overhaul, memory leak fixes, and UI consistency Feb 19, 2026
- Prevented crash on unknown slash commands in CommandsList\n- Improved command list visibility logic in useShowCommands\n- Fixed session state leaks by resetting user store on logout in EmbeddedChat\n- Removed hardcoded default emoji preview in EmojiPicker
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

Comments