From b7e1f89c1d201d18e3a7ae75403eec423200f6f2 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Wed, 24 Jun 2026 08:22:00 -0400 Subject: [PATCH] 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 --- src/app/components/emoji-board/EmojiBoard.tsx | 26 ++- src/app/features/room/RoomInput.tsx | 170 ++++++++++-------- src/app/features/room/RoomTimeline.tsx | 12 +- 3 files changed, 119 insertions(+), 89 deletions(-) diff --git a/src/app/components/emoji-board/EmojiBoard.tsx b/src/app/components/emoji-board/EmojiBoard.tsx index 33d2aca94..a1550d79e 100644 --- a/src/app/components/emoji-board/EmojiBoard.tsx +++ b/src/app/components/emoji-board/EmojiBoard.tsx @@ -178,6 +178,16 @@ function EmojiSidebar({ activeGroupAtom, packs, onScrollToGroup }: EmojiSidebarP const labels = useEmojiGroupLabels(); const icons = useEmojiGroupIcons(); + const packLabels = useMemo(() => { + const map = new Map(); + 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) => { setActiveGroupId(groupId); onScrollToGroup(groupId); @@ -198,8 +208,7 @@ function EmojiSidebar({ activeGroupAtom, packs, onScrollToGroup }: EmojiSidebarP {packs.map((pack) => { - let label = pack.meta.name; - if (!label) label = isUserId(pack.id) ? 'Personal Pack' : mx.getRoom(pack.id)?.name; + const label = packLabels.get(pack.id); const url = mxcUrlToHttp(mx, pack.getAvatarUrl(usage) ?? '', useAuthentication) ?? undefined; @@ -252,6 +261,16 @@ function StickerSidebar({ activeGroupAtom, packs, onScrollToGroup }: StickerSide const [activeGroupId, setActiveGroupId] = useAtom(activeGroupAtom); const usage = ImageUsage.Sticker; + const packLabels = useMemo(() => { + const map = new Map(); + 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) => { setActiveGroupId(groupId); onScrollToGroup(groupId); @@ -261,8 +280,7 @@ function StickerSidebar({ activeGroupAtom, packs, onScrollToGroup }: StickerSide {packs.map((pack) => { - let label = pack.meta.name; - if (!label) label = isUserId(pack.id) ? 'Personal Pack' : mx.getRoom(pack.id)?.name; + const label = packLabels.get(pack.id); const url = mxcUrlToHttp(mx, pack.getAvatarUrl(usage) ?? '', useAuthentication) ?? undefined; diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index cc062ea8b..9e0a0bc32 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -221,7 +221,7 @@ export const RoomInput = forwardRef( const showSchedule = composerToolbarButtons?.showSchedule ?? true; const [locating, setLocating] = React.useState(false); const [locationError, setLocationError] = React.useState(null); - const handleShareLocation = () => { + const handleShareLocation = useCallback(() => { if (!navigator.geolocation) { setLocationError('Geolocation not supported.'); setTimeout(() => setLocationError(null), 4000); @@ -252,7 +252,7 @@ export const RoomInput = forwardRef( }, { timeout: 10000 }, ); - }; + }, [mx, roomId]); const handleVoiceSend = useCallback( async (blob: Blob, mimeType: string, durationMs: number, waveform: number[]) => { @@ -405,71 +405,77 @@ export const RoomInput = forwardRef( [setSelectedFiles, selectedFiles], ); - const handleCancelUpload = (uploads: Upload[]) => { - uploads.forEach((upload) => { - 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); + const handleCancelUpload = useCallback( + (uploads: Upload[]) => { + uploads.forEach((upload) => { + if (upload.status === UploadStatus.Loading) { + mx.cancelUpload(upload.promise); } + }); + handleRemoveUpload(uploads.map((upload) => upload.file)); + }, + [mx, handleRemoveUpload], + ); - if (compressionResult) { - const originalFile = fileItem.originalFile as File; - const compressedFile = new File([compressionResult.blob], originalFile.name, { - type: 'image/jpeg', - }); - 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); + const handleSendUpload = useCallback( + 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); + } + + if (compressionResult) { + const originalFile = fileItem.originalFile as File; + const compressedFile = new File([compressionResult.blob], originalFile.name, { + type: 'image/jpeg', + }); + 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')) { - return getImageMsgContent(mx, fileItem, mxc); - } - if (fileItem.file.type.startsWith('video')) { - return getVideoMsgContent(mx, fileItem, mxc); - } - if (fileItem.file.type.startsWith('audio')) { - return getAudioMsgContent(fileItem, mxc); - } - return getFileMsgContent(fileItem, mxc); - }); - handleCancelUpload(uploads); - const contents = fulfilledPromiseSettledResult(await Promise.allSettled(contentsPromises)); - contents.forEach((content) => mx.sendMessage(roomId, content as any)); - }; + if (fileItem.file.type.startsWith('image')) { + return getImageMsgContent(mx, fileItem, mxc); + } + if (fileItem.file.type.startsWith('video')) { + return getVideoMsgContent(mx, fileItem, mxc); + } + if (fileItem.file.type.startsWith('audio')) { + return getAudioMsgContent(fileItem, mxc); + } + return getFileMsgContent(fileItem, mxc); + }); + handleCancelUpload(uploads); + const contents = fulfilledPromiseSettledResult(await Promise.allSettled(contentsPromises)); + contents.forEach((content) => mx.sendMessage(roomId, content as any)); + }, + [mx, roomId, selectedFiles, handleCancelUpload], + ); const submit = useCallback(() => { uploadBoardHandlers.current?.handleSend(); @@ -675,10 +681,13 @@ export const RoomInput = forwardRef( ReactEditor.focus(editor); }, [editor]); - const handleEmoticonSelect = (key: string, shortcode: string) => { - editor.insertNode(createEmoticonElement(key, shortcode)); - moveCursor(editor); - }; + const handleEmoticonSelect = useCallback( + (key: string, shortcode: string) => { + editor.insertNode(createEmoticonElement(key, shortcode)); + moveCursor(editor); + }, + [editor], + ); const handleGifSelect = useCallback( async (gifUrl: string, w: number, h: number) => { @@ -736,21 +745,24 @@ export const RoomInput = forwardRef( [mx, roomId, alive], ); - const handleStickerSelect = async (mxc: string, shortcode: string, label: string) => { - const stickerUrl = mxcUrlToHttp(mx, mxc, useAuthentication); - if (!stickerUrl) return; + const handleStickerSelect = useCallback( + async (mxc: string, shortcode: string, label: string) => { + const stickerUrl = mxcUrlToHttp(mx, mxc, useAuthentication); + if (!stickerUrl) return; - const info = await getImageInfo( - await loadImageElement(stickerUrl), - await getImageUrlBlob(stickerUrl), - ); + const info = await getImageInfo( + await loadImageElement(stickerUrl), + await getImageUrlBlob(stickerUrl), + ); - mx.sendEvent(roomId, EventType.Sticker, { - body: label, - url: mxc, - info, - }); - }; + mx.sendEvent(roomId, EventType.Sticker, { + body: label, + url: mxc, + info, + }); + }, + [mx, roomId, useAuthentication], + ); if (room.getType() === 'm.server_notice') { return ( diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 49d5d5d32..7ec8462db 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -906,25 +906,25 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli } }, [scrollToElement, editId]); - const handleJumpToLatest = () => { + const handleJumpToLatest = useCallback(() => { if (eventId) { navigateRoom(room.roomId, undefined, { replace: true }); } setTimeline(getInitialTimeline(room)); scrollToBottomRef.current.count += 1; scrollToBottomRef.current.smooth = false; - }; + }, [eventId, navigateRoom, room]); - const handleJumpToUnread = () => { + const handleJumpToUnread = useCallback(() => { if (unreadInfo?.readUptoEventId) { setTimeline(getEmptyTimeline()); loadEventTimeline(unreadInfo.readUptoEventId); } - }; + }, [unreadInfo, loadEventTimeline]); - const handleMarkAsRead = () => { + const handleMarkAsRead = useCallback(() => { markAsRead(mx, room.roomId, hideActivity); - }; + }, [mx, room, hideActivity]); const handleOpenReply: MouseEventHandler = useCallback( async (evt) => {