perf(P6A.T3): memoize MessageBubble + stabilize parent callbacks

This commit is contained in:
byGalax
2026-05-16 23:30:50 +02:00
parent 53b3b5e1fc
commit ddd696a790
2 changed files with 66 additions and 20 deletions
+27 -8
View File
@@ -5,7 +5,7 @@ import {
softDeleteMessage, softDeleteMessage,
} from '@chat-app/shared/chat'; } from '@chat-app/shared/chat';
import { extractErrorCode } from '@chat-app/shared/i18n'; import { extractErrorCode } from '@chat-app/shared/i18n';
import { useCallback, useEffect, useRef, useState } from 'react'; import { memo, useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -60,8 +60,17 @@ interface Props {
senderAvatarUrl?: string | null | undefined; senderAvatarUrl?: string | null | undefined;
conversationId: string; conversationId: string;
reactions: AggregatedReaction[]; reactions: AggregatedReaction[];
onToggleReaction: (emoji: string) => Promise<void>; /**
onVotePoll?: (emoji: string, optionEmojis: string[]) => Promise<void>; * Toggle a reaction on this message. Receives the message id so the parent
* can pass a stable handler reference across every row (lets `React.memo`
* actually skip re-renders triggered by composer keystrokes / typing pings).
*/
onToggleReaction: (messageId: string, emoji: string) => Promise<void>;
/**
* Cast/clear an exclusive poll vote. Receives the message id for the same
* reason as `onToggleReaction`.
*/
onVotePoll?: (messageId: string, emoji: string, optionEmojis: string[]) => Promise<void>;
showSeen?: boolean; showSeen?: boolean;
/** Delivery state for own messages: 'sent' / 'delivered' / 'read'. */ /** Delivery state for own messages: 'sent' / 'delivered' / 'read'. */
deliveryState?: 'sent' | 'delivered' | 'read'; deliveryState?: 'sent' | 'delivered' | 'read';
@@ -83,7 +92,7 @@ interface Props {
onTogglePin?: (messageId: string) => void; onTogglePin?: (messageId: string) => void;
} }
export function MessageBubble({ function MessageBubbleInner({
message, message,
mine, mine,
groupedWithPrev, groupedWithPrev,
@@ -257,12 +266,12 @@ export function MessageBubble({
async (emoji: string) => { async (emoji: string) => {
setPickerOpen(false); setPickerOpen(false);
try { try {
await onToggleReaction(emoji); await onToggleReaction(message.id, emoji);
} catch (err: unknown) { } catch (err: unknown) {
console.error('toggleReaction failed', err); console.error('toggleReaction failed', err);
} }
}, },
[onToggleReaction], [onToggleReaction, message.id],
); );
const copyableText = const copyableText =
@@ -527,7 +536,9 @@ export function MessageBubble({
reactions={reactions} reactions={reactions}
mine={mine} mine={mine}
onVote={(emoji) => onVote={(emoji) =>
onVotePoll ? onVotePoll(emoji, pollOptionEmojis) : onToggleReaction(emoji) onVotePoll
? onVotePoll(message.id, emoji, pollOptionEmojis)
: onToggleReaction(message.id, emoji)
} }
/> />
) : ( ) : (
@@ -593,7 +604,7 @@ export function MessageBubble({
<button <button
key={r.emoji + ':' + r.count} key={r.emoji + ':' + r.count}
type="button" type="button"
onClick={() => void onToggleReaction(r.emoji)} onClick={() => void onToggleReaction(message.id, r.emoji)}
className={ className={
'inline-flex origin-bottom animate-reaction-pop cursor-pointer items-center gap-1 rounded-full border px-2 py-0.5 text-xs transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' + 'inline-flex origin-bottom animate-reaction-pop cursor-pointer items-center gap-1 rounded-full border px-2 py-0.5 text-xs transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
(r.mine (r.mine
@@ -743,6 +754,14 @@ export function MessageBubble({
); );
} }
/**
* Memoized export. Skips re-rendering when none of its props' shallow
* references change — i.e. when the parent re-renders due to composer
* keystrokes, typing-indicator updates, presence pings, etc. Relies on
* the parent passing stable callback refs (see `ConversationPage`).
*/
export const MessageBubble = memo(MessageBubbleInner);
function PollCard({ function PollCard({
question, question,
options, options,
+39 -12
View File
@@ -35,6 +35,7 @@ import { CallPreviewPanel } from '../components/CallPreviewPanel';
import { MediaFilesDrawer } from '../components/MediaFilesDrawer'; import { MediaFilesDrawer } from '../components/MediaFilesDrawer';
import { MentionAutocomplete } from '../components/MentionAutocomplete'; import { MentionAutocomplete } from '../components/MentionAutocomplete';
import { MessageBubble, type QuotedRef } from '../components/MessageBubble'; import { MessageBubble, type QuotedRef } from '../components/MessageBubble';
import type { AggregatedReaction } from '../lib/useMessageReactions';
import { PinnedMessagesPanel } from '../components/PinnedMessagesPanel'; import { PinnedMessagesPanel } from '../components/PinnedMessagesPanel';
import { PollComposerDialog } from '../components/PollComposerDialog'; import { PollComposerDialog } from '../components/PollComposerDialog';
import { UserProfilePopover } from '../components/UserProfilePopover'; import { UserProfilePopover } from '../components/UserProfilePopover';
@@ -78,6 +79,12 @@ import { useTypingChannel } from '../lib/useTypingChannel';
const STICK_THRESHOLD = 80; const STICK_THRESHOLD = 80;
// Stable empty-reactions sentinel. We pass this when a message has no
// reactions instead of `[]` literal — a fresh array per render would defeat
// `React.memo` on `MessageBubble` since the `reactions` prop reference would
// change on every parent render.
const EMPTY_REACTIONS: AggregatedReaction[] = [];
// Per-conversation scroll memory. Module-scoped so it survives re-mounts // Per-conversation scroll memory. Module-scoped so it survives re-mounts
// of ConversationPage when the route param (`id`) changes — switching // of ConversationPage when the route param (`id`) changes — switching
// chats unmounts/remounts the page in our router setup. Session-only // chats unmounts/remounts the page in our router setup. Session-only
@@ -369,6 +376,20 @@ export function ConversationPage() {
[messageById, senderNameFor, t], [messageById, senderNameFor, t],
); );
// Pre-compute quoted refs per message into a stable map. Calling
// `buildQuoted(m.replyToId)` inline inside the `.map` returned a fresh
// object on every parent render, defeating `React.memo` on MessageBubble.
// With the map memoized on the same deps as `buildQuoted`, each bubble
// gets a stable `quoted` reference until the underlying data actually
// changes (new messages, sender renames, language switch).
const quotedByMessage = useMemo(() => {
const out = new Map<string, QuotedRef | null>();
for (const m of messages) {
out.set(m.id, buildQuoted(m.replyToId));
}
return out;
}, [messages, buildQuoted]);
const jumpToMessage = useCallback((targetId: string) => { const jumpToMessage = useCallback((targetId: string) => {
const el = scrollRef.current?.querySelector<HTMLElement>( const el = scrollRef.current?.querySelector<HTMLElement>(
'[data-message-id="' + CSS.escape(targetId) + '"]', '[data-message-id="' + CSS.escape(targetId) + '"]',
@@ -388,6 +409,19 @@ export function ConversationPage() {
setForwardTarget(m); setForwardTarget(m);
}, []); }, []);
// Stable handler for MessageBubble's `onAvatarClick`. Previously this was
// an inline arrow in the `.map`, which gave every row a fresh callback ref
// and defeated `React.memo` on the bubble (every parent re-render — every
// keystroke in the composer — re-rendered all 200 bubbles).
const handleAvatarClick = useCallback((uid: string, ev: React.MouseEvent) => {
ev.stopPropagation();
setProfilePopover({
userId: uid,
x: ev.clientX,
y: ev.clientY,
});
}, []);
const searchActive = useMemo( const searchActive = useMemo(
() => () =>
searchQuery.trim().length > 0 || searchQuery.trim().length > 0 ||
@@ -912,9 +946,9 @@ export function ConversationPage() {
senderDisplayName={senderProfile?.displayName} senderDisplayName={senderProfile?.displayName}
senderAvatarUrl={senderProfile?.avatarUrl} senderAvatarUrl={senderProfile?.avatarUrl}
conversationId={id ?? ''} conversationId={id ?? ''}
reactions={reactionsByMessage.get(m.id) ?? []} reactions={reactionsByMessage.get(m.id) ?? EMPTY_REACTIONS}
onToggleReaction={(emoji) => toggleReaction(m.id, emoji)} onToggleReaction={toggleReaction}
onVotePoll={(emoji, optionEmojis) => votePoll(m.id, emoji, optionEmojis)} onVotePoll={votePoll}
showSeen={m.id === lastSeenMessageId} showSeen={m.id === lastSeenMessageId}
{...(m.senderId === myId {...(m.senderId === myId
? { ? {
@@ -929,18 +963,11 @@ export function ConversationPage() {
}), }),
} }
: {})} : {})}
quoted={buildQuoted(m.replyToId)} quoted={quotedByMessage.get(m.id) ?? null}
onJumpToMessage={jumpToMessage} onJumpToMessage={jumpToMessage}
onReply={handleReply} onReply={handleReply}
onForward={handleForward} onForward={handleForward}
onAvatarClick={(uid, ev) => { onAvatarClick={handleAvatarClick}
ev.stopPropagation();
setProfilePopover({
userId: uid,
x: ev.clientX,
y: ev.clientY,
});
}}
highlighted={highlightedId === m.id} highlighted={highlightedId === m.id}
isPinned={pinnedIds.has(m.id)} isPinned={pinnedIds.has(m.id)}
onTogglePin={handleTogglePin} onTogglePin={handleTogglePin}