perf(P6A.T3): memoize MessageBubble + stabilize parent callbacks
This commit is contained in:
@@ -5,7 +5,7 @@ import {
|
||||
softDeleteMessage,
|
||||
} from '@chat-app/shared/chat';
|
||||
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 { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -60,8 +60,17 @@ interface Props {
|
||||
senderAvatarUrl?: string | null | undefined;
|
||||
conversationId: string;
|
||||
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;
|
||||
/** Delivery state for own messages: 'sent' / 'delivered' / 'read'. */
|
||||
deliveryState?: 'sent' | 'delivered' | 'read';
|
||||
@@ -83,7 +92,7 @@ interface Props {
|
||||
onTogglePin?: (messageId: string) => void;
|
||||
}
|
||||
|
||||
export function MessageBubble({
|
||||
function MessageBubbleInner({
|
||||
message,
|
||||
mine,
|
||||
groupedWithPrev,
|
||||
@@ -257,12 +266,12 @@ export function MessageBubble({
|
||||
async (emoji: string) => {
|
||||
setPickerOpen(false);
|
||||
try {
|
||||
await onToggleReaction(emoji);
|
||||
await onToggleReaction(message.id, emoji);
|
||||
} catch (err: unknown) {
|
||||
console.error('toggleReaction failed', err);
|
||||
}
|
||||
},
|
||||
[onToggleReaction],
|
||||
[onToggleReaction, message.id],
|
||||
);
|
||||
|
||||
const copyableText =
|
||||
@@ -527,7 +536,9 @@ export function MessageBubble({
|
||||
reactions={reactions}
|
||||
mine={mine}
|
||||
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
|
||||
key={r.emoji + ':' + r.count}
|
||||
type="button"
|
||||
onClick={() => void onToggleReaction(r.emoji)}
|
||||
onClick={() => void onToggleReaction(message.id, r.emoji)}
|
||||
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 ' +
|
||||
(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({
|
||||
question,
|
||||
options,
|
||||
|
||||
@@ -35,6 +35,7 @@ import { CallPreviewPanel } from '../components/CallPreviewPanel';
|
||||
import { MediaFilesDrawer } from '../components/MediaFilesDrawer';
|
||||
import { MentionAutocomplete } from '../components/MentionAutocomplete';
|
||||
import { MessageBubble, type QuotedRef } from '../components/MessageBubble';
|
||||
import type { AggregatedReaction } from '../lib/useMessageReactions';
|
||||
import { PinnedMessagesPanel } from '../components/PinnedMessagesPanel';
|
||||
import { PollComposerDialog } from '../components/PollComposerDialog';
|
||||
import { UserProfilePopover } from '../components/UserProfilePopover';
|
||||
@@ -78,6 +79,12 @@ import { useTypingChannel } from '../lib/useTypingChannel';
|
||||
|
||||
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
|
||||
// of ConversationPage when the route param (`id`) changes — switching
|
||||
// chats unmounts/remounts the page in our router setup. Session-only
|
||||
@@ -369,6 +376,20 @@ export function ConversationPage() {
|
||||
[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 el = scrollRef.current?.querySelector<HTMLElement>(
|
||||
'[data-message-id="' + CSS.escape(targetId) + '"]',
|
||||
@@ -388,6 +409,19 @@ export function ConversationPage() {
|
||||
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(
|
||||
() =>
|
||||
searchQuery.trim().length > 0 ||
|
||||
@@ -912,9 +946,9 @@ export function ConversationPage() {
|
||||
senderDisplayName={senderProfile?.displayName}
|
||||
senderAvatarUrl={senderProfile?.avatarUrl}
|
||||
conversationId={id ?? ''}
|
||||
reactions={reactionsByMessage.get(m.id) ?? []}
|
||||
onToggleReaction={(emoji) => toggleReaction(m.id, emoji)}
|
||||
onVotePoll={(emoji, optionEmojis) => votePoll(m.id, emoji, optionEmojis)}
|
||||
reactions={reactionsByMessage.get(m.id) ?? EMPTY_REACTIONS}
|
||||
onToggleReaction={toggleReaction}
|
||||
onVotePoll={votePoll}
|
||||
showSeen={m.id === lastSeenMessageId}
|
||||
{...(m.senderId === myId
|
||||
? {
|
||||
@@ -929,18 +963,11 @@ export function ConversationPage() {
|
||||
}),
|
||||
}
|
||||
: {})}
|
||||
quoted={buildQuoted(m.replyToId)}
|
||||
quoted={quotedByMessage.get(m.id) ?? null}
|
||||
onJumpToMessage={jumpToMessage}
|
||||
onReply={handleReply}
|
||||
onForward={handleForward}
|
||||
onAvatarClick={(uid, ev) => {
|
||||
ev.stopPropagation();
|
||||
setProfilePopover({
|
||||
userId: uid,
|
||||
x: ev.clientX,
|
||||
y: ev.clientY,
|
||||
});
|
||||
}}
|
||||
onAvatarClick={handleAvatarClick}
|
||||
highlighted={highlightedId === m.id}
|
||||
isPinned={pinnedIds.has(m.id)}
|
||||
onTogglePin={handleTogglePin}
|
||||
|
||||
Reference in New Issue
Block a user