perf(room): memoize timeline/composer handlers and emoji-pack room lookups

- RoomTimeline: wrap jump-to-latest/unread + mark-as-read handlers in useCallback
  (the handlers passed to memoized message children were already memoized).
- RoomInput: wrap file/upload/emoji/sticker/location callbacks in useCallback so
  the editor and toolbar don't re-render needlessly.
- EmojiBoard: hoist repeated mx.getRoom() pack-label lookups into a useMemo'd map
  in the emoji and sticker sidebars (previously called per-render in map loops).

Behavior unchanged. (RoomTimeline/RoomInput already have ErrorBoundary wrappers
in RoomView, so no boundary added.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-24 08:22:00 -04:00
parent c0f9867218
commit b7e1f89c1d
3 changed files with 119 additions and 89 deletions
+22 -4
View File
@@ -178,6 +178,16 @@ function EmojiSidebar({ activeGroupAtom, packs, onScrollToGroup }: EmojiSidebarP
const labels = useEmojiGroupLabels(); const labels = useEmojiGroupLabels();
const icons = useEmojiGroupIcons(); const icons = useEmojiGroupIcons();
const packLabels = useMemo(() => {
const map = new Map<string, string | undefined>();
packs.forEach((pack) => {
let label = pack.meta.name;
if (!label) label = isUserId(pack.id) ? 'Personal Pack' : mx.getRoom(pack.id)?.name;
map.set(pack.id, label);
});
return map;
}, [mx, packs]);
const handleScrollToGroup = (groupId: string) => { const handleScrollToGroup = (groupId: string) => {
setActiveGroupId(groupId); setActiveGroupId(groupId);
onScrollToGroup(groupId); onScrollToGroup(groupId);
@@ -198,8 +208,7 @@ function EmojiSidebar({ activeGroupAtom, packs, onScrollToGroup }: EmojiSidebarP
<SidebarStack> <SidebarStack>
<SidebarDivider /> <SidebarDivider />
{packs.map((pack) => { {packs.map((pack) => {
let label = pack.meta.name; const label = packLabels.get(pack.id);
if (!label) label = isUserId(pack.id) ? 'Personal Pack' : mx.getRoom(pack.id)?.name;
const url = const url =
mxcUrlToHttp(mx, pack.getAvatarUrl(usage) ?? '', useAuthentication) ?? undefined; mxcUrlToHttp(mx, pack.getAvatarUrl(usage) ?? '', useAuthentication) ?? undefined;
@@ -252,6 +261,16 @@ function StickerSidebar({ activeGroupAtom, packs, onScrollToGroup }: StickerSide
const [activeGroupId, setActiveGroupId] = useAtom(activeGroupAtom); const [activeGroupId, setActiveGroupId] = useAtom(activeGroupAtom);
const usage = ImageUsage.Sticker; const usage = ImageUsage.Sticker;
const packLabels = useMemo(() => {
const map = new Map<string, string | undefined>();
packs.forEach((pack) => {
let label = pack.meta.name;
if (!label) label = isUserId(pack.id) ? 'Personal Pack' : mx.getRoom(pack.id)?.name;
map.set(pack.id, label);
});
return map;
}, [mx, packs]);
const handleScrollToGroup = (groupId: string) => { const handleScrollToGroup = (groupId: string) => {
setActiveGroupId(groupId); setActiveGroupId(groupId);
onScrollToGroup(groupId); onScrollToGroup(groupId);
@@ -261,8 +280,7 @@ function StickerSidebar({ activeGroupAtom, packs, onScrollToGroup }: StickerSide
<Sidebar> <Sidebar>
<SidebarStack> <SidebarStack>
{packs.map((pack) => { {packs.map((pack) => {
let label = pack.meta.name; const label = packLabels.get(pack.id);
if (!label) label = isUserId(pack.id) ? 'Personal Pack' : mx.getRoom(pack.id)?.name;
const url = const url =
mxcUrlToHttp(mx, pack.getAvatarUrl(usage) ?? '', useAuthentication) ?? undefined; mxcUrlToHttp(mx, pack.getAvatarUrl(usage) ?? '', useAuthentication) ?? undefined;
+91 -79
View File
@@ -221,7 +221,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const showSchedule = composerToolbarButtons?.showSchedule ?? true; const showSchedule = composerToolbarButtons?.showSchedule ?? true;
const [locating, setLocating] = React.useState(false); const [locating, setLocating] = React.useState(false);
const [locationError, setLocationError] = React.useState<string | null>(null); const [locationError, setLocationError] = React.useState<string | null>(null);
const handleShareLocation = () => { const handleShareLocation = useCallback(() => {
if (!navigator.geolocation) { if (!navigator.geolocation) {
setLocationError('Geolocation not supported.'); setLocationError('Geolocation not supported.');
setTimeout(() => setLocationError(null), 4000); setTimeout(() => setLocationError(null), 4000);
@@ -252,7 +252,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
}, },
{ timeout: 10000 }, { timeout: 10000 },
); );
}; }, [mx, roomId]);
const handleVoiceSend = useCallback( const handleVoiceSend = useCallback(
async (blob: Blob, mimeType: string, durationMs: number, waveform: number[]) => { async (blob: Blob, mimeType: string, durationMs: number, waveform: number[]) => {
@@ -405,71 +405,77 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
[setSelectedFiles, selectedFiles], [setSelectedFiles, selectedFiles],
); );
const handleCancelUpload = (uploads: Upload[]) => { const handleCancelUpload = useCallback(
uploads.forEach((upload) => { (uploads: Upload[]) => {
if (upload.status === UploadStatus.Loading) { uploads.forEach((upload) => {
mx.cancelUpload(upload.promise); if (upload.status === UploadStatus.Loading) {
} mx.cancelUpload(upload.promise);
});
handleRemoveUpload(uploads.map((upload) => upload.file));
};
const handleSendUpload = async (uploads: UploadSuccess[]) => {
const contentsPromises = uploads.map(async (upload) => {
const fileItem = selectedFiles.find((f) => f.file === upload.file);
if (!fileItem) throw new Error('Broken upload');
// Resolve the MXC URL to use — may be overridden if compression is enabled
let mxc = upload.mxc;
if (fileItem.metadata.compressImage && isCompressible(fileItem.originalFile)) {
// Use the cached compression result if available, otherwise compute it now
let compressionResult = fileItem.metadata.compressionResult;
if (compressionResult === undefined) {
compressionResult = await compressImage(fileItem.originalFile);
} }
});
handleRemoveUpload(uploads.map((upload) => upload.file));
},
[mx, handleRemoveUpload],
);
if (compressionResult) { const handleSendUpload = useCallback(
const originalFile = fileItem.originalFile as File; async (uploads: UploadSuccess[]) => {
const compressedFile = new File([compressionResult.blob], originalFile.name, { const contentsPromises = uploads.map(async (upload) => {
type: 'image/jpeg', const fileItem = selectedFiles.find((f) => f.file === upload.file);
}); if (!fileItem) throw new Error('Broken upload');
const uploadRes = await mx.uploadContent(compressedFile, {
name: originalFile.name, // Resolve the MXC URL to use — may be overridden if compression is enabled
type: 'image/jpeg', let mxc = upload.mxc;
});
const compressedMxc = (uploadRes as { content_uri: string }).content_uri; if (fileItem.metadata.compressImage && isCompressible(fileItem.originalFile)) {
if (compressedMxc) { // Use the cached compression result if available, otherwise compute it now
// Delete the pre-uploaded original so only one copy lives on the server. let compressionResult = fileItem.metadata.compressionResult;
tryDeleteMxcContent(mx, upload.mxc); if (compressionResult === undefined) {
mxc = compressedMxc; compressionResult = await compressImage(fileItem.originalFile);
// Build a synthetic fileItem that refers to the compressed file so }
// getImageMsgContent picks up the correct dimensions and type.
const compressedItem = { if (compressionResult) {
...fileItem, const originalFile = fileItem.originalFile as File;
file: compressedFile, const compressedFile = new File([compressionResult.blob], originalFile.name, {
originalFile: compressedFile, type: 'image/jpeg',
}; });
return getImageMsgContent(mx, compressedItem, mxc); const uploadRes = await mx.uploadContent(compressedFile, {
name: originalFile.name,
type: 'image/jpeg',
});
const compressedMxc = (uploadRes as { content_uri: string }).content_uri;
if (compressedMxc) {
// Delete the pre-uploaded original so only one copy lives on the server.
tryDeleteMxcContent(mx, upload.mxc);
mxc = compressedMxc;
// Build a synthetic fileItem that refers to the compressed file so
// getImageMsgContent picks up the correct dimensions and type.
const compressedItem = {
...fileItem,
file: compressedFile,
originalFile: compressedFile,
};
return getImageMsgContent(mx, compressedItem, mxc);
}
} }
} }
}
if (fileItem.file.type.startsWith('image')) { if (fileItem.file.type.startsWith('image')) {
return getImageMsgContent(mx, fileItem, mxc); return getImageMsgContent(mx, fileItem, mxc);
} }
if (fileItem.file.type.startsWith('video')) { if (fileItem.file.type.startsWith('video')) {
return getVideoMsgContent(mx, fileItem, mxc); return getVideoMsgContent(mx, fileItem, mxc);
} }
if (fileItem.file.type.startsWith('audio')) { if (fileItem.file.type.startsWith('audio')) {
return getAudioMsgContent(fileItem, mxc); return getAudioMsgContent(fileItem, mxc);
} }
return getFileMsgContent(fileItem, mxc); return getFileMsgContent(fileItem, mxc);
}); });
handleCancelUpload(uploads); handleCancelUpload(uploads);
const contents = fulfilledPromiseSettledResult(await Promise.allSettled(contentsPromises)); const contents = fulfilledPromiseSettledResult(await Promise.allSettled(contentsPromises));
contents.forEach((content) => mx.sendMessage(roomId, content as any)); contents.forEach((content) => mx.sendMessage(roomId, content as any));
}; },
[mx, roomId, selectedFiles, handleCancelUpload],
);
const submit = useCallback(() => { const submit = useCallback(() => {
uploadBoardHandlers.current?.handleSend(); uploadBoardHandlers.current?.handleSend();
@@ -675,10 +681,13 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
ReactEditor.focus(editor); ReactEditor.focus(editor);
}, [editor]); }, [editor]);
const handleEmoticonSelect = (key: string, shortcode: string) => { const handleEmoticonSelect = useCallback(
editor.insertNode(createEmoticonElement(key, shortcode)); (key: string, shortcode: string) => {
moveCursor(editor); editor.insertNode(createEmoticonElement(key, shortcode));
}; moveCursor(editor);
},
[editor],
);
const handleGifSelect = useCallback( const handleGifSelect = useCallback(
async (gifUrl: string, w: number, h: number) => { async (gifUrl: string, w: number, h: number) => {
@@ -736,21 +745,24 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
[mx, roomId, alive], [mx, roomId, alive],
); );
const handleStickerSelect = async (mxc: string, shortcode: string, label: string) => { const handleStickerSelect = useCallback(
const stickerUrl = mxcUrlToHttp(mx, mxc, useAuthentication); async (mxc: string, shortcode: string, label: string) => {
if (!stickerUrl) return; const stickerUrl = mxcUrlToHttp(mx, mxc, useAuthentication);
if (!stickerUrl) return;
const info = await getImageInfo( const info = await getImageInfo(
await loadImageElement(stickerUrl), await loadImageElement(stickerUrl),
await getImageUrlBlob(stickerUrl), await getImageUrlBlob(stickerUrl),
); );
mx.sendEvent(roomId, EventType.Sticker, { mx.sendEvent(roomId, EventType.Sticker, {
body: label, body: label,
url: mxc, url: mxc,
info, info,
}); });
}; },
[mx, roomId, useAuthentication],
);
if (room.getType() === 'm.server_notice') { if (room.getType() === 'm.server_notice') {
return ( return (
+6 -6
View File
@@ -906,25 +906,25 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
} }
}, [scrollToElement, editId]); }, [scrollToElement, editId]);
const handleJumpToLatest = () => { const handleJumpToLatest = useCallback(() => {
if (eventId) { if (eventId) {
navigateRoom(room.roomId, undefined, { replace: true }); navigateRoom(room.roomId, undefined, { replace: true });
} }
setTimeline(getInitialTimeline(room)); setTimeline(getInitialTimeline(room));
scrollToBottomRef.current.count += 1; scrollToBottomRef.current.count += 1;
scrollToBottomRef.current.smooth = false; scrollToBottomRef.current.smooth = false;
}; }, [eventId, navigateRoom, room]);
const handleJumpToUnread = () => { const handleJumpToUnread = useCallback(() => {
if (unreadInfo?.readUptoEventId) { if (unreadInfo?.readUptoEventId) {
setTimeline(getEmptyTimeline()); setTimeline(getEmptyTimeline());
loadEventTimeline(unreadInfo.readUptoEventId); loadEventTimeline(unreadInfo.readUptoEventId);
} }
}; }, [unreadInfo, loadEventTimeline]);
const handleMarkAsRead = () => { const handleMarkAsRead = useCallback(() => {
markAsRead(mx, room.roomId, hideActivity); markAsRead(mx, room.roomId, hideActivity);
}; }, [mx, room, hideActivity]);
const handleOpenReply: MouseEventHandler = useCallback( const handleOpenReply: MouseEventHandler = useCallback(
async (evt) => { async (evt) => {