perf(P6A.T3): memoize MessageBubble + stabilize parent callbacks
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user