perf(desktop): memoize composer and timeline to fix typing lag (#143)

This commit is contained in:
Wes
2026-03-20 16:32:16 -07:00
committed by GitHub
parent f68daef8dd
commit d68d0a6a33
9 changed files with 271 additions and 158 deletions
+26 -33
View File
@@ -3,6 +3,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { ChannelPane } from "@/app/ChannelPane";
import { useActiveChannelHeader } from "@/app/useActiveChannelHeader";
import { useChannelPaneHandlers } from "@/app/useChannelPaneHandlers";
import { AgentsView } from "@/features/agents/ui/AgentsView";
import { ForumView } from "@/features/forum/ui/ForumView";
import { ChatHeader } from "@/features/chat/ui/ChatHeader";
@@ -203,6 +204,26 @@ export function AppShell() {
[replyTargetId, timelineMessages],
);
const { handleCancelReply, handleReply, handleSend, handleToggleReaction } =
useChannelPaneHandlers({
replyTargetId,
sendMessageMutation,
setReplyTargetId,
toggleReactionMutation,
});
const handleTargetReached = React.useCallback((messageId: string) => {
setSearchAnchor((current) =>
current?.eventId === messageId ? null : current,
);
}, []);
const canReact = activeChannel !== null && activeChannel.archivedAt === null;
const effectiveToggleReaction = React.useMemo(
() => (canReact ? handleToggleReaction : undefined),
[canReact, handleToggleReaction],
);
const channelDescription = activeChannel
? [
activeChannel.archivedAt ? "Archived." : null,
@@ -664,39 +685,11 @@ export function AppShell() {
isSending={sendMessageMutation.isPending}
isTimelineLoading={isTimelineLoading}
messages={timelineMessages}
onCancelReply={() => {
setReplyTargetId(null);
}}
onReply={(message) => {
setReplyTargetId((current) =>
current === message.id ? null : message.id,
);
}}
onSend={async (content, mentionPubkeys, mediaTags) => {
await sendMessageMutation.mutateAsync({
content,
mentionPubkeys,
parentEventId: replyTargetId,
mediaTags,
});
setReplyTargetId(null);
}}
onTargetReached={(messageId) => {
setSearchAnchor((current) =>
current?.eventId === messageId ? null : current,
);
}}
onToggleReaction={
activeChannel && activeChannel.archivedAt === null
? async (message, emoji, remove) => {
await toggleReactionMutation.mutateAsync({
emoji,
eventId: message.id,
remove,
});
}
: undefined
}
onCancelReply={handleCancelReply}
onReply={handleReply}
onSend={handleSend}
onTargetReached={handleTargetReached}
onToggleReaction={effectiveToggleReaction}
profiles={messageProfiles}
replyTargetId={replyTargetId}
replyTargetMessage={replyTargetMessage}
+2 -2
View File
@@ -33,7 +33,7 @@ type ChannelPaneProps = {
typingPubkeys: string[];
};
export function ChannelPane({
export const ChannelPane = React.memo(function ChannelPane({
activeChannel,
currentPubkey,
isSending,
@@ -118,4 +118,4 @@ export function ChannelPane({
/>
</React.Fragment>
);
}
});
+82
View File
@@ -0,0 +1,82 @@
import * as React from "react";
import type { useSendMessageMutation } from "@/features/messages/hooks";
import type { useToggleReactionMutation } from "@/features/messages/hooks";
/**
* Stable callback references for ChannelPane so that keystroke-driven
* re-renders of AppShell don't cascade into the timeline and composer.
*
* Mutation objects from TanStack Query v5 are new references on every render
* (especially when `isPending` flips), so we stash `.mutateAsync` in a ref
* rather than listing the whole mutation as a dependency.
*/
export function useChannelPaneHandlers({
replyTargetId,
sendMessageMutation,
setReplyTargetId,
toggleReactionMutation,
}: {
replyTargetId: string | null;
sendMessageMutation: ReturnType<typeof useSendMessageMutation>;
setReplyTargetId: React.Dispatch<React.SetStateAction<string | null>>;
toggleReactionMutation: ReturnType<typeof useToggleReactionMutation>;
}) {
// Keep mutable values in refs so callbacks never need to list them as deps.
const replyTargetIdRef = React.useRef(replyTargetId);
replyTargetIdRef.current = replyTargetId;
const sendMutateRef = React.useRef(sendMessageMutation.mutateAsync);
sendMutateRef.current = sendMessageMutation.mutateAsync;
const toggleMutateRef = React.useRef(toggleReactionMutation.mutateAsync);
toggleMutateRef.current = toggleReactionMutation.mutateAsync;
const handleCancelReply = React.useCallback(() => {
setReplyTargetId(null);
}, [setReplyTargetId]);
const handleReply = React.useCallback(
(message: { id: string }) => {
setReplyTargetId((current) =>
current === message.id ? null : message.id,
);
},
[setReplyTargetId],
);
const handleSend = React.useCallback(
async (
content: string,
mentionPubkeys: string[],
mediaTags?: string[][],
) => {
await sendMutateRef.current({
content,
mentionPubkeys,
parentEventId: replyTargetIdRef.current,
mediaTags,
});
setReplyTargetId(null);
},
[setReplyTargetId],
);
const handleToggleReaction = React.useCallback(
async (message: { id: string }, emoji: string, remove: boolean) => {
await toggleMutateRef.current({
emoji,
eventId: message.id,
remove,
});
},
[],
);
return {
handleCancelReply,
handleReply,
handleSend,
handleToggleReaction,
};
}
@@ -9,7 +9,7 @@ type ChannelAutocompleteProps = {
onSelect: (suggestion: ChannelSuggestion) => void;
};
export function ChannelAutocomplete({
export const ChannelAutocomplete = React.memo(function ChannelAutocomplete({
suggestions,
selectedIndex,
onSelect,
@@ -58,4 +58,4 @@ export function ChannelAutocomplete({
</div>
</div>
);
}
});
@@ -1,3 +1,4 @@
import * as React from "react";
import Picker from "@emoji-mart/react";
import data from "@emoji-mart/data";
import { SmilePlus } from "lucide-react";
@@ -13,7 +14,7 @@ type ComposerEmojiPickerProps = {
open: boolean;
};
export function ComposerEmojiPicker({
export const ComposerEmojiPicker = React.memo(function ComposerEmojiPicker({
disabled = false,
onEmojiSelect,
onOpenChange,
@@ -55,4 +56,4 @@ export function ComposerEmojiPicker({
</PopoverContent>
</Popover>
);
}
});
@@ -14,7 +14,7 @@ type MentionAutocompleteProps = {
onSelect: (suggestion: MentionSuggestion) => void;
};
export function MentionAutocomplete({
export const MentionAutocomplete = React.memo(function MentionAutocomplete({
suggestions,
selectedIndex,
onSelect,
@@ -67,4 +67,4 @@ export function MentionAutocomplete({
</div>
</div>
);
}
});
@@ -64,10 +64,15 @@ export function MessageComposer({
replyTarget = null,
}: MessageComposerProps) {
const [content, setContent] = React.useState("");
const contentRef = React.useRef(content);
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
const pendingSelectionRef = React.useRef<number | null>(null);
const draftSelectionRef = React.useRef({ end: 0, start: 0 });
const [isEmojiPickerOpen, setIsEmojiPickerOpen] = React.useState(false);
const lineHeightRef = React.useRef<number | null>(null);
// Keep contentRef in sync — no extra re-render, just a ref assignment.
contentRef.current = content;
const mentions = useMentions(channelId);
const channelLinks = useChannelLinks();
@@ -78,9 +83,21 @@ export function MessageComposer({
}>({ status: "idle" });
const [pendingImeta, setPendingImeta] = React.useState<BlobDescriptor[]>([]);
// Stable refs for values read inside callbacks that should not cause
// callback identity changes when they update.
const pendingImetaRef = React.useRef(pendingImeta);
const disabledRef = React.useRef(disabled);
const isSendingRef = React.useRef(isSending);
const onSendRef = React.useRef(onSend);
pendingImetaRef.current = pendingImeta;
disabledRef.current = disabled;
isSendingRef.current = isSending;
onSendRef.current = onSend;
// biome-ignore lint/correctness/useExhaustiveDependencies: channelId is the sole trigger — reset all composer state on channel switch to prevent draft/upload/autocomplete leaks
React.useEffect(() => {
setContent("");
contentRef.current = "";
setPendingImeta([]);
setUploadState({ status: "idle" });
setIsEmojiPickerOpen(false);
@@ -88,14 +105,17 @@ export function MessageComposer({
channelLinks.clearChannels();
draftSelectionRef.current = { end: 0, start: 0 };
pendingSelectionRef.current = null;
lineHeightRef.current = null;
}, [channelId]);
const applyMentionInsert = React.useCallback(
(suggestion: MentionSuggestion) => {
const textarea = textareaRef.current;
const currentContent = contentRef.current;
const result = mentions.insertMention(
suggestion,
content,
textarea?.selectionEnd ?? content.length,
currentContent,
textarea?.selectionEnd ?? currentContent.length,
);
draftSelectionRef.current = {
end: result.nextCursor,
@@ -104,16 +124,17 @@ export function MessageComposer({
pendingSelectionRef.current = result.nextCursor;
setContent(result.nextContent);
},
[content, mentions.insertMention],
[mentions.insertMention],
);
const applyChannelInsert = React.useCallback(
(suggestion: ChannelSuggestion) => {
const textarea = textareaRef.current;
const currentContent = contentRef.current;
const result = channelLinks.insertChannel(
suggestion,
content,
textarea?.selectionEnd ?? content.length,
currentContent,
textarea?.selectionEnd ?? currentContent.length,
);
draftSelectionRef.current = {
end: result.nextCursor,
@@ -122,7 +143,7 @@ export function MessageComposer({
pendingSelectionRef.current = result.nextCursor;
setContent(result.nextContent);
},
[content, channelLinks.insertChannel],
[channelLinks.insertChannel],
);
const updateDraftSelection = React.useCallback(
@@ -141,11 +162,12 @@ export function MessageComposer({
const insertEmoji = React.useCallback(
(emoji: string) => {
const currentContent = contentRef.current;
const { end, start } = draftSelectionRef.current;
const nextStart = Math.min(start, content.length);
const nextEnd = Math.min(end, content.length);
const nextStart = Math.min(start, currentContent.length);
const nextEnd = Math.min(end, currentContent.length);
const nextCursor = nextStart + emoji.length;
const nextContent = `${content.slice(0, nextStart)}${emoji}${content.slice(nextEnd)}`;
const nextContent = `${currentContent.slice(0, nextStart)}${emoji}${currentContent.slice(nextEnd)}`;
draftSelectionRef.current = {
end: nextCursor,
@@ -156,7 +178,7 @@ export function MessageComposer({
setIsEmojiPickerOpen(false);
mentions.clearMentions();
},
[content, mentions.clearMentions],
[mentions.clearMentions],
);
const openMentionPicker = React.useCallback(() => {
@@ -165,23 +187,24 @@ export function MessageComposer({
return;
}
const cursorPosition = textarea.selectionStart ?? content.length;
const existingMention = detectMentionQuery(content, cursorPosition);
const currentContent = contentRef.current;
const cursorPosition = textarea.selectionStart ?? currentContent.length;
const existingMention = detectMentionQuery(currentContent, cursorPosition);
if (existingMention) {
mentions.updateMentionQuery(content, cursorPosition);
mentions.updateMentionQuery(currentContent, cursorPosition);
textarea.focus();
return;
}
const { end, start } = draftSelectionRef.current;
const nextStart = Math.min(start, content.length);
const nextEnd = Math.min(end, content.length);
const previousCharacter = content.slice(0, nextStart).slice(-1);
const nextStart = Math.min(start, currentContent.length);
const nextEnd = Math.min(end, currentContent.length);
const previousCharacter = currentContent.slice(0, nextStart).slice(-1);
const prefix =
nextStart > 0 && previousCharacter && !/\s/.test(previousCharacter)
? " @"
: "@";
const nextContent = `${content.slice(0, nextStart)}${prefix}${content.slice(nextEnd)}`;
const nextContent = `${currentContent.slice(0, nextStart)}${prefix}${currentContent.slice(nextEnd)}`;
const mentionIndex = nextStart + (prefix.startsWith(" ") ? 1 : 0);
const nextCursor = mentionIndex + 1;
@@ -193,7 +216,7 @@ export function MessageComposer({
setContent(nextContent);
setIsEmojiPickerOpen(false);
mentions.updateMentionQuery(nextContent, nextCursor);
}, [content, mentions.updateMentionQuery]);
}, [mentions.updateMentionQuery]);
const onUploaded = React.useCallback((descriptor: BlobDescriptor) => {
const markdown = `\n![image](${descriptor.url})\n`;
@@ -287,17 +310,22 @@ export function MessageComposer({
);
const submitMessage = React.useCallback(async () => {
const trimmed = content.trim();
const hasMedia = pendingImeta.length > 0;
if ((!trimmed && !hasMedia) || disabled || isSending) {
const trimmed = contentRef.current.trim();
const currentPendingImeta = pendingImetaRef.current;
const hasMedia = currentPendingImeta.length > 0;
if (
(!trimmed && !hasMedia) ||
disabledRef.current ||
isSendingRef.current
) {
return;
}
const pubkeys = mentions.extractMentionPubkeys(trimmed);
const mediaTags =
pendingImeta.length > 0
? pendingImeta.map((d) => [
currentPendingImeta.length > 0
? currentPendingImeta.map((d) => [
"imeta",
`url ${d.url}`,
`m ${d.type}`,
@@ -310,7 +338,7 @@ export function MessageComposer({
: undefined;
const savedContent = trimmed;
const savedImeta = [...pendingImeta];
const savedImeta = [...currentPendingImeta];
setContent("");
draftSelectionRef.current = { end: 0, start: 0 };
@@ -320,20 +348,15 @@ export function MessageComposer({
setIsEmojiPickerOpen(false);
try {
await onSend(trimmed, pubkeys, mediaTags);
await onSendRef.current(trimmed, pubkeys, mediaTags);
} catch {
setContent(savedContent);
setPendingImeta(savedImeta);
}
}, [
content,
disabled,
isSending,
onSend,
mentions.extractMentionPubkeys,
mentions.clearMentions,
channelLinks.clearChannels,
pendingImeta,
]);
const handleSubmit = React.useCallback(
@@ -420,8 +443,11 @@ export function MessageComposer({
return;
}
const lineHeight =
Number.parseFloat(window.getComputedStyle(textarea).lineHeight) || 24;
if (lineHeightRef.current === null) {
lineHeightRef.current =
Number.parseFloat(window.getComputedStyle(textarea).lineHeight) || 24;
}
const lineHeight = lineHeightRef.current;
const maxHeight = lineHeight * MAX_TEXTAREA_ROWS;
textarea.style.height = "auto";
@@ -451,6 +477,20 @@ export function MessageComposer({
const isUploading = uploadState.status === "uploading";
const sendDisabled = React.useMemo(
() =>
disabled || (content.trim().length === 0 && pendingImeta.length === 0),
[disabled, content, pendingImeta.length],
);
const handleCaptureSelection = React.useCallback(() => {
updateDraftSelection(textareaRef.current);
}, [updateDraftSelection]);
const handlePaperclipClick = React.useCallback(() => {
void handlePaperclip();
}, [handlePaperclip]);
return (
<footer className="border-t border-border/80 bg-background p-4">
<div className="mx-auto flex w-full max-w-4xl flex-col gap-3">
@@ -545,19 +585,12 @@ export function MessageComposer({
isEmojiPickerOpen={isEmojiPickerOpen}
isSending={isSending}
isUploading={isUploading}
onCaptureSelection={() => {
updateDraftSelection(textareaRef.current);
}}
onCaptureSelection={handleCaptureSelection}
onEmojiPickerOpenChange={setIsEmojiPickerOpen}
onEmojiSelect={insertEmoji}
onOpenMentionPicker={openMentionPicker}
onPaperclip={() => {
void handlePaperclip();
}}
sendDisabled={
disabled ||
(content.trim().length === 0 && pendingImeta.length === 0)
}
onPaperclip={handlePaperclipClick}
sendDisabled={sendDisabled}
/>
</form>
</div>
@@ -1,79 +1,82 @@
import * as React from "react";
import { AtSign, Paperclip, SendHorizontal } from "lucide-react";
import { Button } from "@/shared/ui/button";
import { ComposerEmojiPicker } from "./ComposerEmojiPicker";
export function MessageComposerToolbar({
composerDisabled,
isEmojiPickerOpen,
isSending,
isUploading,
onCaptureSelection,
onEmojiPickerOpenChange,
onEmojiSelect,
onOpenMentionPicker,
onPaperclip,
sendDisabled,
}: {
composerDisabled: boolean;
isEmojiPickerOpen: boolean;
isSending: boolean;
isUploading: boolean;
onCaptureSelection: () => void;
onEmojiPickerOpenChange: (open: boolean) => void;
onEmojiSelect: (emoji: string) => void;
onOpenMentionPicker: () => void;
onPaperclip: () => void;
sendDisabled: boolean;
}) {
return (
<div className="mt-4 flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Button
data-testid="message-insert-mention"
disabled={composerDisabled}
onClick={onOpenMentionPicker}
onMouseDown={onCaptureSelection}
size="icon"
title="Mention someone"
type="button"
variant="ghost"
>
<AtSign className="h-4 w-4" />
</Button>
<Button
disabled={composerDisabled || isUploading}
onClick={onPaperclip}
size="icon"
title="Attach image"
type="button"
variant="ghost"
>
{isUploading ? (
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
) : (
<Paperclip className="h-4 w-4" />
)}
</Button>
<ComposerEmojiPicker
disabled={composerDisabled}
onEmojiSelect={onEmojiSelect}
onOpenChange={onEmojiPickerOpenChange}
onTriggerMouseDown={onCaptureSelection}
open={isEmojiPickerOpen}
/>
</div>
export const MessageComposerToolbar = React.memo(
function MessageComposerToolbar({
composerDisabled,
isEmojiPickerOpen,
isSending,
isUploading,
onCaptureSelection,
onEmojiPickerOpenChange,
onEmojiSelect,
onOpenMentionPicker,
onPaperclip,
sendDisabled,
}: {
composerDisabled: boolean;
isEmojiPickerOpen: boolean;
isSending: boolean;
isUploading: boolean;
onCaptureSelection: () => void;
onEmojiPickerOpenChange: (open: boolean) => void;
onEmojiSelect: (emoji: string) => void;
onOpenMentionPicker: () => void;
onPaperclip: () => void;
sendDisabled: boolean;
}) {
return (
<div className="mt-4 flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Button
data-testid="message-insert-mention"
disabled={composerDisabled}
onClick={onOpenMentionPicker}
onMouseDown={onCaptureSelection}
size="icon"
title="Mention someone"
type="button"
variant="ghost"
>
<AtSign className="h-4 w-4" />
</Button>
<Button
disabled={composerDisabled || isUploading}
onClick={onPaperclip}
size="icon"
title="Attach image"
type="button"
variant="ghost"
>
{isUploading ? (
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
) : (
<Paperclip className="h-4 w-4" />
)}
</Button>
<ComposerEmojiPicker
disabled={composerDisabled}
onEmojiSelect={onEmojiSelect}
onOpenChange={onEmojiPickerOpenChange}
onTriggerMouseDown={onCaptureSelection}
open={isEmojiPickerOpen}
/>
</div>
<Button
className="gap-2"
data-testid="send-message"
disabled={sendDisabled || isSending}
title="Send (Enter)"
type="submit"
>
<SendHorizontal className="h-4 w-4" />
{isSending ? "Sending" : "Send"}
</Button>
</div>
);
}
<Button
className="gap-2"
data-testid="send-message"
disabled={sendDisabled || isSending}
title="Send (Enter)"
type="submit"
>
<SendHorizontal className="h-4 w-4" />
{isSending ? "Sending" : "Send"}
</Button>
</div>
);
},
);
@@ -1,3 +1,4 @@
import * as React from "react";
import { ArrowDown } from "lucide-react";
import type { TimelineMessage } from "@/features/messages/types";
@@ -29,7 +30,7 @@ type MessageTimelineProps = {
onTargetReached?: (messageId: string) => void;
};
export function MessageTimeline({
export const MessageTimeline = React.memo(function MessageTimeline({
channelId,
messages,
isLoading = false,
@@ -145,4 +146,4 @@ export function MessageTimeline({
) : null}
</div>
);
}
});