perf(desktop): stop typing from re-rendering the channel pane + timeline (#1364)

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Wes
2026-06-29 15:30:10 -06:00
committed by GitHub
parent 59be27ff3f
commit db1b617ab1
15 changed files with 443 additions and 188 deletions

View File

@@ -412,6 +412,7 @@ description. See [PR #803](https://github.com/block/buzz/pull/803).
4. **Worktrees: `cd` in the same command** — shell CWD doesn't persist between tool calls. Use `cd /path && cargo build` as one command.
5. **Desktop crate excluded from root workspace** — `cargo test` at repo root does NOT run desktop tests. Use `cargo test --manifest-path desktop/src-tauri/Cargo.toml` explicitly.
6. **Desktop Tauri fmt fails in worktrees and blocks commits** — the pre-commit hook runs `just desktop-tauri-fmt`, which fails in git worktrees because `cargo fmt` resolves workspace paths relative to the worktree root. Run `just desktop-tauri-fmt` from the main checkout to apply the fix, then re-stage and commit. CI is unaffected.
7. **React render perf: `React.memo` is all-or-nothing** — it only skips a re-render when *every* prop is reference-stable; one unstable prop (inline arrow/JSX, or a hook returning a fresh `{}`/`[]`/`Map` each render) defeats it. Two repeat offenders: (a) React Query results (`useMutation`/`useQuery`) are a **new object each render** — depend on the stable method (`mutation.mutateAsync`), not the object; (b) derived `Map`/array state that recomputes on a version bump — wrap in a content-equality ref cache (`shared/hooks/useStableReference.ts`). When chasing interaction lag, **measure with DevTools closed and no perf probes** (an open Web Inspector + per-keystroke `console.log` inflate the numbers), and isolate by removing one suspect at a time rather than guessing.
---

View File

@@ -528,6 +528,7 @@ export function ChannelScreen({
(message: TimelineMessage) => handleMarkMessageRead(message.id),
[handleMarkMessageRead],
);
const sendMessageMutateAsync = sendMessageMutation.mutateAsync;
const handleSendVideoReviewComment = React.useCallback(
async (
message: { id: string },
@@ -536,19 +537,32 @@ export function ChannelScreen({
mediaTags?: string[][],
parentEventId?: string,
) => {
await sendMessageMutation.mutateAsync({
await sendMessageMutateAsync({
content,
mediaTags,
mentionPubkeys,
parentEventId: parentEventId ?? message.id,
});
},
[sendMessageMutation],
[sendMessageMutateAsync],
);
const effectiveSendVideoReviewComment =
activeChannel && !activeChannel.archivedAt && activeChannel.isMember
? handleSendVideoReviewComment
: undefined;
const handleOpenAddBot = React.useCallback(() => setIsAddBotOpen(true), []);
const handleOpenMembersSidebar = React.useCallback(
() => setIsMembersSidebarOpen(true),
[],
);
const handleCloseChannelManagement = React.useCallback(
() => setChannelManagementOpen(false),
[setChannelManagementOpen],
);
const handleChannelManagementDeleted = React.useCallback(() => {
setChannelManagementOpen(false);
void goHome({ replace: true });
}, [setChannelManagementOpen, goHome]);
const {
agentSessionAgents,
channelAgentSessionAgents,
@@ -727,44 +741,77 @@ export function ChannelScreen({
enabled: !isSinglePanelView,
});
const channelHeader = (
<ChannelScreenHeader
activeChannel={activeChannel}
activeChannelEphemeralDisplay={activeChannelEphemeralDisplay}
activeChannelTitle={activeChannelTitle}
actionsVariant={shouldCompactHeaderActions ? "compact" : "inline"}
activeDmAvatarUrl={activeDmAvatarUrl}
activeDmHeaderParticipants={activeDmHeaderParticipants}
activeDmPresenceStatus={activeDmPresenceStatus}
chromeWrapperRef={channelHeaderChromeRef}
currentPubkey={currentPubkey}
isAddBotOpen={isAddBotOpen}
isJoining={joinChannelMutation.isPending}
onAddBotOpenChange={setIsAddBotOpen}
onJoinChannel={joinChannelMutation.mutateAsync}
onManageChannel={() => {
if (activeChannel?.channelType === "forum") {
openGlobalChannelManagement();
return;
}
const handleManageChannel = React.useCallback(() => {
if (activeChannel?.channelType === "forum") {
openGlobalChannelManagement();
return;
}
if (channelManagementOpen) {
setChannelManagementOpen(false);
return;
}
if (channelManagementOpen) {
setChannelManagementOpen(false);
return;
}
setOpenThreadHeadId(null);
setExpandedThreadReplyIds(new Set());
setThreadScrollTargetId(null);
setThreadReplyTargetId(null);
handleCloseAgentSession();
setProfilePanelPubkey(null);
setChannelManagementOpen(true);
}}
onToggleMembers={() => setIsMembersSidebarOpen((prev) => !prev)}
showHeaderContent={!isSinglePanelView}
transparentChrome={activeChannel?.channelType !== "forum"}
/>
setOpenThreadHeadId(null);
setExpandedThreadReplyIds(new Set());
setThreadScrollTargetId(null);
setThreadReplyTargetId(null);
handleCloseAgentSession();
setProfilePanelPubkey(null);
setChannelManagementOpen(true);
}, [
activeChannel?.channelType,
channelManagementOpen,
openGlobalChannelManagement,
setChannelManagementOpen,
setOpenThreadHeadId,
handleCloseAgentSession,
setProfilePanelPubkey,
]);
const handleToggleMembers = React.useCallback(
() => setIsMembersSidebarOpen((prev) => !prev),
[],
);
const channelHeader = React.useMemo(
() => (
<ChannelScreenHeader
activeChannel={activeChannel}
activeChannelEphemeralDisplay={activeChannelEphemeralDisplay}
activeChannelTitle={activeChannelTitle}
actionsVariant={shouldCompactHeaderActions ? "compact" : "inline"}
activeDmAvatarUrl={activeDmAvatarUrl}
activeDmHeaderParticipants={activeDmHeaderParticipants}
activeDmPresenceStatus={activeDmPresenceStatus}
chromeWrapperRef={channelHeaderChromeRef}
currentPubkey={currentPubkey}
isAddBotOpen={isAddBotOpen}
isJoining={joinChannelMutation.isPending}
onAddBotOpenChange={setIsAddBotOpen}
onJoinChannel={joinChannelMutation.mutateAsync}
onManageChannel={handleManageChannel}
onToggleMembers={handleToggleMembers}
showHeaderContent={!isSinglePanelView}
transparentChrome={activeChannel?.channelType !== "forum"}
/>
),
[
activeChannel,
activeChannelEphemeralDisplay,
activeChannelTitle,
shouldCompactHeaderActions,
activeDmAvatarUrl,
activeDmHeaderParticipants,
activeDmPresenceStatus,
channelHeaderChromeRef,
currentPubkey,
isAddBotOpen,
joinChannelMutation.isPending,
joinChannelMutation.mutateAsync,
handleManageChannel,
handleToggleMembers,
isSinglePanelView,
],
);
return (
@@ -807,9 +854,9 @@ export function ChannelScreen({
fetchOlder={fetchOlder}
header={channelHeader}
hasOlderMessages={hasOlderMessages}
onAddAgent={() => setIsAddBotOpen(true)}
onAddAgent={handleOpenAddBot}
onCreateChannel={openCreateChannel}
onOpenMembers={() => setIsMembersSidebarOpen(true)}
onOpenMembers={handleOpenMembersSidebar}
isFetchingOlder={isFetchingOlder}
editTarget={
editTargetMessage
@@ -834,10 +881,7 @@ export function ChannelScreen({
messages={timelineMessages}
onCancelEdit={handleCancelEdit}
onCancelThreadReply={handleCancelThreadReply}
onChannelManagementDeleted={() => {
setChannelManagementOpen(false);
void goHome({ replace: true });
}}
onChannelManagementDeleted={handleChannelManagementDeleted}
onFollowThread={
effectiveOpenThreadHeadId != null &&
!isNotifiedForEffectiveThread
@@ -851,9 +895,7 @@ export function ChannelScreen({
: undefined
}
onCloseAgentSession={handleCloseAgentSession}
onCloseChannelManagement={() =>
setChannelManagementOpen(false)
}
onCloseChannelManagement={handleCloseChannelManagement}
onCloseThread={handleCloseThread}
onDelete={
activeChannel?.archivedAt ? undefined : handleDelete

View File

@@ -50,12 +50,13 @@ export function useChannelProfilePanel({
setProfilePanelPubkey(null);
}, [setProfilePanelPubkey]);
const openDmMutateAsync = openDmMutation.mutateAsync;
const handleOpenDm = React.useCallback(
async (pubkeys: string[]) => {
const dm = await openDmMutation.mutateAsync({ pubkeys });
const dm = await openDmMutateAsync({ pubkeys });
await goChannel(dm.id);
},
[goChannel, openDmMutation],
[goChannel, openDmMutateAsync],
);
return {

View File

@@ -8,6 +8,10 @@ import {
} from "@/features/channels/lib/subtreeCreatedAt";
import { computeThreadReplyUnreadCounts } from "@/features/channels/lib/threadReplyUnreadCounts";
import { computeThreadBadgeCounts } from "@/features/channels/lib/threadBadgeCounts";
import {
useStableArrayShallow,
useStableMap,
} from "@/shared/hooks/useStableReference";
import {
buildThreadPanelDataFromIndex,
buildThreadPanelIndex,
@@ -174,7 +178,7 @@ export function useChannelUnreadState({
],
);
const openThreadHeadMessage = threadPanelData.threadHead;
const threadMessages = threadPanelData.visibleReplies;
const threadMessages = useStableArrayShallow(threadPanelData.visibleReplies);
const threadReplyTargetMessage = threadPanelData.replyTargetMessage;
// Oldest unread top-level message + count from the open-time frontier.
@@ -332,7 +336,7 @@ export function useChannelUnreadState({
// (LP4 Issue 2 by construction). readStateVersion is an intentional recompute
// trigger so the badge re-reads after any marker advances.
// biome-ignore lint/correctness/useExhaustiveDependencies: readStateVersion and forcedUnreadVersion are intentional recompute triggers
const threadUnreadCounts = React.useMemo(
const threadUnreadCountsRaw = React.useMemo(
() =>
computeThreadBadgeCounts(
timelineMessages,
@@ -353,6 +357,11 @@ export function useChannelUnreadState({
forcedUnreadVersion,
],
);
// Keep the same Map reference across recomputes when the entries are
// unchanged. readStateVersion bumps on read-state activity (frequent in busy
// channels) and recomputes a fresh Map even when no count changed; without
// this, every bump hands the timeline a new prop ref and reconciles all rows.
const threadUnreadCounts = useStableMap(threadUnreadCountsRaw);
// Per-message unread predicate for the mark-read/unread menu toggle. Reuses
// computeThreadUnreadMarker — the exact function the badge counts call

View File

@@ -24,5 +24,20 @@ export function useEphemeralChannelDisplay(channel: Channel | null) {
};
}, [deadlineKey]);
return channel ? getEphemeralChannelDisplay(channel, Date.now()) : null;
const display = channel
? getEphemeralChannelDisplay(channel, Date.now())
: null;
// Stabilise the reference across renders when the rendered content is
// unchanged: this object is recomputed from Date.now() every render, and it
// feeds memoised header/timeline subtrees — a fresh ref each render would
// defeat their React.memo on every background re-render.
const stableRef = React.useRef(display);
const prev = stableRef.current;
if (
prev?.detailLabel !== display?.detailLabel ||
prev?.tooltipLabel !== display?.tooltipLabel
) {
stableRef.current = display;
}
return stableRef.current;
}

View File

@@ -99,11 +99,11 @@ export function ForumComposer({
isAutocompleteOpen: isAutocompleteOpenRef,
onEditLink: (info) => onEditLinkRef.current?.(info),
onLinkSelectionChange: (info) => onLinkSelectionChangeRef.current?.(info),
onUpdate: ({ markdown, text }) => {
onUpdate: ({ cursor, text }) => {
const markdown = richText.getMarkdown();
setContent(markdown);
contentRef.current = markdown;
const { cursor } = richText.getPlainTextAndCursor();
mentions.updateMentionQuery(text, cursor);
channelLinks.updateChannelQuery(text, cursor);
},

View File

@@ -530,26 +530,46 @@ export function useMediaUpload() {
const isUploading = uploadingCount > 0;
return {
cancelUpload,
handleDragEnter,
handleDragLeave,
handleDragOver,
handleDrop,
handlePaperclip,
handlePaste,
isDragOver,
isUploading,
pendingImeta,
pendingImetaRef,
removeAttachment,
setPendingImeta,
setUploadState,
uploadFile,
uploadingCount,
uploadingPreviews,
uploadState,
};
return React.useMemo(
() => ({
cancelUpload,
handleDragEnter,
handleDragLeave,
handleDragOver,
handleDrop,
handlePaperclip,
handlePaste,
isDragOver,
isUploading,
pendingImeta,
pendingImetaRef,
removeAttachment,
setPendingImeta,
setUploadState,
uploadFile,
uploadingCount,
uploadingPreviews,
uploadState,
}),
[
cancelUpload,
handleDragEnter,
handleDragLeave,
handleDragOver,
handleDrop,
handlePaperclip,
handlePaste,
isDragOver,
isUploading,
pendingImeta,
removeAttachment,
setPendingImeta,
uploadFile,
uploadingCount,
uploadingPreviews,
uploadState,
],
);
}
export type MediaUploadController = ReturnType<typeof useMediaUpload>;

View File

@@ -53,7 +53,7 @@ export type AutocompleteEdit = {
export type RichTextEditorOptions = {
placeholder?: string;
onUpdate?: (info: { markdown: string; text: string }) => void;
onUpdate?: (info: { text: string; cursor: number }) => void;
editable?: boolean;
mentionNames?: string[];
agentMentionNames?: string[];
@@ -459,13 +459,15 @@ export function useRichTextEditor({
},
},
onUpdate: ({ editor: ed }) => {
const markdown = getMarkdownFromEditor(ed);
// Use the same plain-text projection that `getPlainTextAndCursor`
// uses, so autocomplete detection sees the *same* string the
// cursor offset is mapped against. `state.doc.textContent` would
// diverge by 1 per hard-break / block boundary.
const text = buildPlainTextProjection(ed.state.doc).text;
onUpdateRef.current?.({ markdown, text });
// Keep the hot typing path lightweight. Markdown serialization is
// still available through `getMarkdown()` for send/draft boundaries;
// per-keystroke consumers only need textarea-shaped plain text for
// autocomplete and empty/non-empty state.
const projection = buildPlainTextProjection(ed.state.doc);
onUpdateRef.current?.({
cursor: projection.mapPMToTextOffset(ed.state.selection.anchor),
text: projection.text,
});
},
},
[],

View File

@@ -0,0 +1,88 @@
import { CornerUpLeft, Pencil, X } from "lucide-react";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
const BANNER_CLASS =
"relative z-0 -mb-4 flex transform-gpu gap-2 rounded-t-2xl border border-b-0 border-border/60 bg-muted/55 px-4 pb-6 pt-2.5 text-sm leading-5 text-muted-foreground backdrop-blur-sm transition-colors";
/**
* The "Editing message" / "Replying to …" banner that sits above the composer
* input. Edit takes precedence over reply (matching the composer's own
* `editTarget ? … : replyTarget ? …` ordering). Rendered as a sibling so
* MessageComposer stays under the file-size guard; purely presentational.
*/
export function ComposerReplyEditBanner({
isEditing,
replyTarget,
onCancelEdit,
onCancelReply,
}: {
isEditing: boolean;
replyTarget?: { author: string; body: string; id: string } | null;
onCancelEdit?: () => void;
onCancelReply?: () => void;
}) {
if (isEditing) {
return (
<div
className={cn(BANNER_CLASS, "items-center")}
data-testid="edit-target"
>
<Pencil aria-hidden className="h-4 w-4 shrink-0" />
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-foreground">
Editing message
</p>
</div>
{onCancelEdit ? (
<Button
aria-label="Cancel edit"
className="-mr-1 h-7 w-7 shrink-0 px-0 text-muted-foreground hover:text-foreground"
onClick={onCancelEdit}
size="icon"
type="button"
variant="ghost"
>
<X className="h-4 w-4" />
</Button>
) : null}
</div>
);
}
if (replyTarget) {
return (
<div
className={cn(BANNER_CLASS, "items-start")}
data-testid="reply-target"
>
<CornerUpLeft aria-hidden className="mt-0.5 h-4 w-4 shrink-0" />
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-foreground">
Replying to {replyTarget.author}
</p>
{replyTarget.body ? (
<p className="truncate text-muted-foreground/80">
{replyTarget.body}
</p>
) : null}
</div>
{onCancelReply ? (
<Button
aria-label="Cancel reply"
className="-mr-1 h-7 w-7 shrink-0 px-0 text-muted-foreground hover:text-foreground"
onClick={onCancelReply}
size="icon"
type="button"
variant="ghost"
>
<X className="h-4 w-4" />
</Button>
) : null}
</div>
);
}
return null;
}

View File

@@ -1,7 +1,6 @@
import * as React from "react";
import { EditorContent } from "@tiptap/react";
import { CornerUpLeft, Pencil, X } from "lucide-react";
import { useChannelLinks } from "@/features/messages/lib/useChannelLinks";
import { useComposerAutofocus } from "@/features/messages/lib/useComposerAutofocus";
import type { ChannelSuggestion } from "@/features/messages/lib/useChannelLinks";
@@ -40,8 +39,8 @@ import { useTypingBroadcast } from "@/features/messages/useTypingBroadcast";
import { getBuzzCodeBlockClipboardText } from "@/shared/lib/codeBlockClipboard";
import { cn } from "@/shared/lib/cn";
import type { ChannelType } from "@/shared/api/types";
import { Button } from "@/shared/ui/button";
import { ChannelAutocomplete } from "./ChannelAutocomplete";
import { ComposerReplyEditBanner } from "./ComposerReplyEditBanner";
import { ComposerAttachments, DropZoneOverlay } from "./ComposerAttachments";
import { EmojiAutocomplete } from "./EmojiAutocomplete";
import {
@@ -51,6 +50,7 @@ import {
import { MessageComposerToolbar } from "./MessageComposerToolbar";
import { NonMemberMentionDialog } from "./NonMemberMentionDialog";
import { useMentionSendFlow } from "./useMentionSendFlow";
import { useComposerContentState } from "./useComposerContentState";
type MessageComposerProps = {
channelId?: string | null;
@@ -104,7 +104,7 @@ type MessageComposerProps = {
typingRootEventId?: string | null;
};
export function MessageComposer({
function MessageComposerImpl({
channelId = null,
channelName,
channelType = null,
@@ -127,10 +127,14 @@ export function MessageComposer({
typingParentEventId = null,
typingRootEventId = null,
}: MessageComposerProps) {
const [content, setContent] = React.useState("");
const contentRef = React.useRef(content);
contentRef.current = content;
const {
contentRef,
isContentEmpty,
setComposerContent,
setComposerContentFromText,
syncComposerContentFromEditor,
syncContentRefFromEditorRef,
} = useComposerContentState();
const [isEmojiPickerOpen, setIsEmojiPickerOpen] = React.useState(false);
const [isFormattingOpen, setIsFormattingOpen] = React.useState(false);
const [spoileredAttachmentUrls, setSpoileredAttachmentUrls] = React.useState<
@@ -238,14 +242,9 @@ export function MessageComposer({
isAutocompleteOpen: isAutocompleteOpenRef,
onEditLink: (info) => onEditLinkRef.current?.(info),
onLinkSelectionChange: (info) => onLinkSelectionChangeRef.current?.(info),
onUpdate: ({ markdown, text }) => {
setContent(markdown);
contentRef.current = markdown;
onUpdate: ({ cursor, text }) => {
setComposerContentFromText(text);
// Bridge to mention/channel/emoji detection hooks. `text` and the
// cursor are both in plain-text-offset space (treating hardBreak
// and inter-block boundaries as `\n`).
const { cursor } = richText.getPlainTextAndCursor();
mentions.updateMentionQuery(text, cursor);
channelLinks.updateChannelQuery(text, cursor);
emojiAutocomplete.updateEmojiQuery(text, cursor);
@@ -257,6 +256,11 @@ export function MessageComposer({
});
const linkEditor = useLinkEditor(richText);
syncContentRefFromEditorRef.current = () => {
const markdown = richText.getMarkdown();
contentRef.current = markdown;
return markdown;
};
onEditLinkRef.current = linkEditor.openFromClick;
onLinkSelectionChangeRef.current = linkEditor.showFromCursor;
useComposerSpoilerParticles(richText.editor, composerScrollRef);
@@ -272,7 +276,7 @@ export function MessageComposer({
mentions,
onSendRef,
richText,
setContent,
setContent: setComposerContent,
setIsEmojiPickerOpen,
setPendingImeta: media.setPendingImeta,
setSpoileredAttachmentUrls,
@@ -282,7 +286,7 @@ export function MessageComposer({
React.useEffect(() => {
const prevKey = previousDraftKeyRef.current;
if (prevKey) {
drafts.persistDraft(prevKey, contentRef.current);
drafts.persistDraft(prevKey, syncComposerContentFromEditor());
}
previousDraftKeyRef.current = effectiveDraftKey;
@@ -290,12 +294,10 @@ export function MessageComposer({
? drafts.loadDraft(effectiveDraftKey)
: undefined;
if (saved) {
setContent(saved.content);
contentRef.current = saved.content;
setComposerContent(saved.content);
richText.setContent(saved.content);
} else {
setContent("");
contentRef.current = "";
setComposerContent("");
richText.clearContent();
}
@@ -309,7 +311,7 @@ export function MessageComposer({
return () => {
if (effectiveDraftKey) {
drafts.persistDraft(effectiveDraftKey, contentRef.current);
drafts.persistDraft(effectiveDraftKey, syncComposerContentFromEditor());
}
};
}, [effectiveDraftKey]);
@@ -321,7 +323,7 @@ export function MessageComposer({
// in-flight work survives the edit-mode hijack and is restored on
// edit-cancel/exit.
preEditSnapshotRef.current = {
content: contentRef.current,
content: syncComposerContentFromEditor(),
pendingImeta: [...media.pendingImetaRef.current],
spoileredAttachmentUrls: new Set(spoileredAttachmentUrls),
};
@@ -332,8 +334,7 @@ export function MessageComposer({
editTarget.body,
editTarget.imetaMedia ?? [],
);
setContent(editableBody);
contentRef.current = editableBody;
setComposerContent(editableBody);
richText.setContent(editableBody);
// Seed the composer's pending-imeta state with the original event's
// attachments so they show up in `ComposerAttachments` and the user
@@ -360,8 +361,7 @@ export function MessageComposer({
spoileredAttachmentUrls: restoredSpoileredAttachmentUrls,
} = preEditSnapshotRef.current;
preEditSnapshotRef.current = null;
setContent(restoredContent);
contentRef.current = restoredContent;
setComposerContent(restoredContent);
restoredContent
? richText.setContent(restoredContent)
: richText.clearContent();
@@ -502,7 +502,7 @@ export function MessageComposer({
// ── Submit message ──────────────────────────────────────────────────
const submitMessage = React.useCallback(async () => {
const trimmed = contentRef.current.trim();
const trimmed = syncComposerContentFromEditor().trim();
// Edit mode
if (editTargetRef.current && onEditSaveRef.current) {
@@ -537,8 +537,7 @@ export function MessageComposer({
const savedContent = trimmed;
const savedImeta = [...currentPendingImeta];
const savedSpoileredAttachmentUrls = new Set(spoileredAttachmentUrls);
setContent("");
contentRef.current = "";
setComposerContent("");
richText.clearContent();
media.setPendingImeta([]);
setSpoileredAttachmentUrls(new Set());
@@ -550,8 +549,7 @@ export function MessageComposer({
try {
await onEditSaveRef.current(finalContent, outgoingTags);
} catch {
setContent(savedContent);
contentRef.current = savedContent;
setComposerContent(savedContent);
richText.setContent(savedContent);
media.setPendingImeta(savedImeta);
setSpoileredAttachmentUrls(savedSpoileredAttachmentUrls);
@@ -589,7 +587,9 @@ export function MessageComposer({
mentions.clearMentions,
richText.clearContent,
richText.setContent,
setComposerContent,
spoileredAttachmentUrls,
syncComposerContentFromEditor,
]);
submitMessageRef.current = submitMessage;
@@ -744,12 +744,12 @@ export function MessageComposer({
disabled ||
media.isUploading ||
mentionSendFlow.isPreparingMentionSend ||
(content.trim().length === 0 && media.pendingImeta.length === 0),
(isContentEmpty && media.pendingImeta.length === 0),
[
disabled,
media.isUploading,
mentionSendFlow.isPreparingMentionSend,
content,
isContentEmpty,
media.pendingImeta.length,
],
);
@@ -825,60 +825,12 @@ export function MessageComposer({
className="absolute inset-x-0 bottom-0 h-5 bg-background"
/>
<div className="relative flex w-full flex-col gap-0">
{editTarget ? (
<div
className="relative z-0 -mb-4 flex transform-gpu items-center gap-2 rounded-t-2xl border border-b-0 border-border/60 bg-muted/55 px-4 pb-6 pt-2.5 text-sm leading-5 text-muted-foreground backdrop-blur-sm transition-colors"
data-testid="edit-target"
>
<Pencil aria-hidden className="h-4 w-4 shrink-0" />
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-foreground">
Editing message
</p>
</div>
{onCancelEdit ? (
<Button
aria-label="Cancel edit"
className="-mr-1 h-7 w-7 shrink-0 px-0 text-muted-foreground hover:text-foreground"
onClick={onCancelEdit}
size="icon"
type="button"
variant="ghost"
>
<X className="h-4 w-4" />
</Button>
) : null}
</div>
) : replyTarget ? (
<div
className="relative z-0 -mb-4 flex transform-gpu items-start gap-2 rounded-t-2xl border border-b-0 border-border/60 bg-muted/55 px-4 pb-6 pt-2.5 text-sm leading-5 text-muted-foreground backdrop-blur-sm transition-colors"
data-testid="reply-target"
>
<CornerUpLeft aria-hidden className="mt-0.5 h-4 w-4 shrink-0" />
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-foreground">
Replying to {replyTarget.author}
</p>
{replyTarget.body ? (
<p className="truncate text-muted-foreground/80">
{replyTarget.body}
</p>
) : null}
</div>
{onCancelReply ? (
<Button
aria-label="Cancel reply"
className="-mr-1 h-7 w-7 shrink-0 px-0 text-muted-foreground hover:text-foreground"
onClick={onCancelReply}
size="icon"
type="button"
variant="ghost"
>
<X className="h-4 w-4" />
</Button>
) : null}
</div>
) : null}
<ComposerReplyEditBanner
isEditing={editTarget != null}
replyTarget={replyTarget}
onCancelEdit={onCancelEdit}
onCancelReply={onCancelReply}
/>
<form
className="relative z-10 isolate rounded-2xl border border-border/50 bg-background/80 px-3 pb-2 pt-3 shadow-none backdrop-blur-md supports-[backdrop-filter]:bg-background/70 dark:bg-background/70 dark:backdrop-blur-xl dark:supports-[backdrop-filter]:bg-background/55 sm:px-4"
data-testid="message-composer"
@@ -996,3 +948,5 @@ export function MessageComposer({
</>
);
}
export const MessageComposer = React.memo(MessageComposerImpl);

View File

@@ -0,0 +1,39 @@
import * as React from "react";
export function useComposerContentState() {
const contentRef = React.useRef("");
const [isContentEmpty, setIsContentEmpty] = React.useState(true);
const setComposerContentFromText = React.useCallback((nextText: string) => {
setIsContentEmpty((wasEmpty) => {
const isEmpty = nextText.trim().length === 0;
return wasEmpty === isEmpty ? wasEmpty : isEmpty;
});
}, []);
const setComposerContent = React.useCallback(
(nextContent: string) => {
contentRef.current = nextContent;
setComposerContentFromText(nextContent);
},
[setComposerContentFromText],
);
const syncContentRefFromEditorRef = React.useRef<() => string>(
() => contentRef.current,
);
const syncComposerContentFromEditor = React.useCallback(
() => syncContentRefFromEditorRef.current(),
[],
);
return {
contentRef,
isContentEmpty,
setComposerContent,
setComposerContentFromText,
syncComposerContentFromEditor,
syncContentRefFromEditorRef,
};
}

View File

@@ -62,7 +62,7 @@ type UseMentionSendFlowOptions = {
) => Promise<void>
>;
richText: Pick<UseRichTextEditorResult, "clearContent" | "setContent">;
setContent: React.Dispatch<React.SetStateAction<string>>;
setContent: (content: string) => void;
setIsEmojiPickerOpen: React.Dispatch<React.SetStateAction<boolean>>;
setPendingImeta: (pendingImeta: ImetaMedia[]) => void;
setSpoileredAttachmentUrls?: React.Dispatch<

View File

@@ -162,16 +162,29 @@ export function useChannelFind({
}
}, [channelId, reset]);
return {
activeIndex,
activeMatch,
close,
goToNext,
goToPrevious,
isOpen,
matchCount: matchedIds.length,
matchingMessageIds,
query,
setQuery,
};
return React.useMemo(
() => ({
activeIndex,
activeMatch,
close,
goToNext,
goToPrevious,
isOpen,
matchCount: matchedIds.length,
matchingMessageIds,
query,
setQuery,
}),
[
activeIndex,
activeMatch,
close,
goToNext,
goToPrevious,
isOpen,
matchedIds.length,
matchingMessageIds,
query,
],
);
}

View File

@@ -0,0 +1,54 @@
import * as React from "react";
/**
* Returns `next` but preserves the previous reference whenever the two Maps
* have identical entries (same size, same key→value pairs). Lets a value that
* is recomputed into a fresh Map on an unrelated invalidation (e.g. a version
* bump) keep a stable identity so downstream `React.memo` boundaries can bail.
*/
export function useStableMap<K, V>(next: Map<K, V>): Map<K, V> {
const ref = React.useRef(next);
const prev = ref.current;
if (prev !== next && mapsEqual(prev, next)) {
return prev;
}
ref.current = next;
return next;
}
function mapsEqual<K, V>(a: Map<K, V>, b: Map<K, V>): boolean {
if (a.size !== b.size) return false;
for (const [key, value] of a) {
if (!b.has(key) || !Object.is(b.get(key), value)) return false;
}
return true;
}
/**
* Returns `next` but preserves the previous reference when the two arrays are
* shallow-equal (same length, `Object.is` on each element). Same purpose as
* `useStableMap` for array-valued derived state. Preserves the caller's exact
* array type (mutable or readonly).
*/
export function useStableArrayShallow<T extends readonly unknown[]>(
next: T,
): T {
const ref = React.useRef(next);
const prev = ref.current;
if (prev !== next && arraysShallowEqual(prev, next)) {
return prev;
}
ref.current = next;
return next;
}
function arraysShallowEqual(
a: readonly unknown[],
b: readonly unknown[],
): boolean {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i += 1) {
if (!Object.is(a[i], b[i])) return false;
}
return true;
}

View File

@@ -199,6 +199,11 @@ test("preserves user scroll while older channel history loads", async ({
// Snapshot the oldest rendered index BEFORE firing the delayed page, so the
// poll's growth gate can require a genuinely NEW older index after it lands.
// Wait for a concrete rendered row first: under CI load the initial rows can
// lag, leaving the scan with nothing to match (null) and a spurious failure.
await expect
.poll(async () => await oldestRenderedIndex(), { timeout: 8_000 })
.not.toBeNull();
const oldestBeforeLanding = await oldestRenderedIndex();
expect(oldestBeforeLanding).not.toBeNull();
@@ -243,7 +248,7 @@ test("preserves user scroll while older channel history loads", async ({
return Math.abs(anchor.top - (anchorBeforeLanding?.top ?? 0));
},
{
timeout: 3_000,
timeout: 8_000,
},
)
.toBeLessThanOrEqual(2);
@@ -413,9 +418,12 @@ test("does not teleport upward when user abandons fetch by jumping to bottom", a
// messages land; we poll for that growth and then assert we are STILL
// at the bottom (no upward teleport to the abandoned anchor).
//
// Timeout: 6s. The wheel-up + smooth-abandon path can burn 2-3s of
// the 5s historyDelayMs window before this poll begins, so the
// prepend may not land for another 2-3s. 6s leaves margin.
// Timeout: 12s. The wheel-up + smooth-abandon path can burn 2-3s of the
// 5s historyDelayMs window before this poll begins, so the prepend may not
// land for another 2-3s. On a loaded CI runner that squeezes a tight 6s
// budget into a timeout (the prepend is a deterministic 5s mock timer, so
// the only failure mode is waiting too little); 12s leaves comfortable
// margin without weakening the assertion below.
await expect
.poll(
async () => {
@@ -424,7 +432,7 @@ test("does not teleport upward when user abandons fetch by jumping to bottom", a
? "resolved"
: "pending";
},
{ timeout: 6_000 },
{ timeout: 12_000 },
)
.toBe("resolved");
@@ -1756,6 +1764,15 @@ test("older-history prepend keeps the reading row fixed (no jump to oldest)", as
return Number.isFinite(min) ? min : null;
});
// Wait until at least one prepended "mock older" row has actually rendered
// before sampling the oldest index. On a loaded CI runner the prepended
// history can lag the scrollable-height gate above, leaving the scan with no
// "mock older N" rows yet (oldestBeforeLanding === null). Poll for a concrete
// older row so the baseline is deterministic.
await expect
.poll(async () => await oldestRenderedIndex(), { timeout: 8_000 })
.not.toBeNull();
const oldestBeforeLanding = await oldestRenderedIndex();
expect(oldestBeforeLanding).not.toBeNull();
@@ -1794,7 +1811,7 @@ test("older-history prepend keeps the reading row fixed (no jump to oldest)", as
}
return Math.abs(anchor.top - (anchorBeforeLanding?.top ?? 0));
},
{ timeout: 4_000 },
{ timeout: 8_000 },
)
.toBeLessThanOrEqual(2);