diff --git a/src/app/components/message/RenderBody.tsx b/src/app/components/message/RenderBody.tsx
index 6db9ee48..647de498 100644
--- a/src/app/components/message/RenderBody.tsx
+++ b/src/app/components/message/RenderBody.tsx
@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useMemo } from 'react';
import parse, { HTMLReactParserOptions } from 'html-react-parser';
import Linkify from 'linkify-react';
import { Opts } from 'linkifyjs';
@@ -21,16 +21,30 @@ export function RenderBody({
htmlReactParserOptions,
linkifyOpts,
}: RenderBodyProps) {
+ // `sanitizeCustomHtml` (a full sanitize-html DOM pass) + `parse` are the
+ // hottest per-render cost on the timeline — RoomTimeline re-renders every
+ // visible Message on each live event. Both `customBody` and the memoized
+ // `htmlReactParserOptions` (RoomTimeline.tsx:654) are stable across those
+ // renders, so caching the parsed tree skips the work for unchanged rows.
+ const parsedCustomBody = useMemo(
+ () => (customBody ? parse(sanitizeCustomHtml(customBody), htmlReactParserOptions) : null),
+ [customBody, htmlReactParserOptions]
+ );
+ // Plaintext path: scaleSystemEmoji walks the string for unicode emoji and
+ // highlightText re-splits on the search regex — both pure functions of
+ // (body, highlightRegex), so memoize to match the custom-body fast path.
+ const plainBody = useMemo(
+ () =>
+ highlightRegex
+ ? highlightText(highlightRegex, scaleSystemEmoji(body))
+ : scaleSystemEmoji(body),
+ [body, highlightRegex]
+ );
+
if (body === '') ;
if (customBody) {
if (customBody === '') ;
- return parse(sanitizeCustomHtml(customBody), htmlReactParserOptions);
+ return parsedCustomBody;
}
- return (
-
- {highlightRegex
- ? highlightText(highlightRegex, scaleSystemEmoji(body))
- : scaleSystemEmoji(body)}
-
- );
+ return {plainBody};
}
diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx
index 6c8bc53f..bb5b1d23 100644
--- a/src/app/features/room/RoomTimeline.tsx
+++ b/src/app/features/room/RoomTimeline.tsx
@@ -83,6 +83,7 @@ import {
SyslineMessage,
EncryptedContent,
useMessageInteractionHandlers,
+ getMessageRenderSig,
} from './message';
import { ThreadSummaryCard } from './ThreadSummaryCard';
import { useMemberEventParser } from '../../hooks/useMemberEventParser';
@@ -1274,7 +1275,13 @@ export function RoomTimeline({
// created_ts + expires, we treat the stale join as gone. Linear in
// #call-member events (rare); matches the inline `streamRenderableItemHasBefore`
// IIFE pattern just below.
- const callAnchors: Map = (() => {
+ // This rebuilds the call-membership state machine by scanning EVERY event in
+ // EVERY linked timeline (grows unbounded with scrollback). Previously it ran
+ // as a per-render IIFE, so every fab/atBottom/focus/unread/pulse state change
+ // re-scanned the whole room. Memoized on the `timeline` state object — the
+ // only reactive input — so it recomputes only when timeline content changes.
+ // Date.now() (used below for «ongoing») reads fresh on each actual recompute.
+ const callAnchors: Map = useMemo(() => {
type CallScan = {
slotId: string;
anchorEventId: string;
@@ -1489,7 +1496,7 @@ export function RoomTimeline({
}
/* eslint-enable no-restricted-syntax, no-continue */
return anchors;
- })();
+ }, [timeline]);
const renderMatrixEvent = useMatrixEventRenderer<
[string, MatrixEvent, number, EventTimelineSet, boolean, boolean, boolean]
@@ -1528,6 +1535,7 @@ export function RoomTimeline({
return (
(
+const MessageInner = as<'div', MessageProps>(
(
{
className,
@@ -1246,6 +1252,107 @@ export const Message = as<'div', MessageProps>(
}
);
+// Parent-computed render snapshot for a timeline message row. This MUST be
+// called during the PARENT's render and threaded in as the `renderSig` prop;
+// it must NOT be recomputed from `mEvent` inside the memo comparator.
+//
+// matrix-js-sdk mutates the `MatrixEvent` IN PLACE on edit / redaction /
+// decryption / local-echo (makeReplaced / makeRedacted / setClearData /
+// handleRemoteEcho), and the timeline reuses one MatrixEvent instance per id,
+// so `prevProps.mEvent === nextProps.mEvent`. A signature read off that live
+// object inside the comparator would return the SAME (already-mutated) value
+// for both prev and next and could never detect the change -- the classic
+// stale-row trap element-web documents on `EventTile` (`isRedacted` is a prop
+// there for exactly this reason). Capturing the snapshot here freezes it into
+// the prop value, so React's retained prevProps holds the OLD snapshot and the
+// comparator can diff old-vs-new. It also folds in the sender display name and
+// the urlPreview setting, so a live rename / preview-toggle repaints the row.
+export function getMessageRenderSig(
+ room: Room,
+ mEvent: MatrixEvent,
+ editedEvent: MatrixEvent | undefined,
+ urlPreview: boolean | undefined
+): string {
+ const senderId = mEvent.getSender() ?? '';
+ const senderDisplayName =
+ getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId;
+ const content = mEvent.getContent();
+ return [
+ mEvent.getId() ?? '',
+ mEvent.getType(),
+ typeof content.msgtype === 'string' ? content.msgtype : '',
+ mEvent.isRedacted() ? '1' : '0',
+ editedEvent?.getId() ?? '',
+ mEvent.status ?? '',
+ senderDisplayName,
+ urlPreview ? '1' : '0',
+ ].join('\u0001');
+}
+
+// Custom equality for the timeline row. RoomTimeline re-renders on every live
+// event / receipt / scroll-state change and rebuilds all ~80 visible rows; this
+// lets React skip rows whose visible output is unchanged. SAFE because the
+// dynamic children self-subscribe to their own data (Reactions→useRelations,
+// Reply→useRoomEvent, ThreadSummaryCard→ThreadEvent) and the delivery dot
+// self-subscribes (useDotColor→RoomEvent.Receipt) — so we only need to compare
+// the row's own scalar/identity props, the PRESENCE of the dynamic node slots,
+// and the parent-computed renderSig snapshot. The comparator errs toward re-rendering:
+// any prop whose identity is unstable simply defeats the memo (no perf win) but
+// never causes staleness. Returns true to SKIP the re-render.
+//
+// The mutable-event vectors (edit / redaction / decryption / local-echo)
+// AND the children-only inputs (sender display name, urlPreview) are all
+// folded into the parent-computed `renderSig` prop (see getMessageRenderSig),
+// so they repaint correctly. `mediaAutoLoad` is mount-time (autoPlay /
+// initial fetch), so it needs no signature term.
+function areMessagePropsEqual(
+ prev: ComponentProps,
+ next: ComponentProps
+): boolean {
+ return (
+ prev.room === next.room &&
+ prev.mEvent === next.mEvent &&
+ prev.relations === next.relations &&
+ prev.collapse === next.collapse &&
+ prev.highlight === next.highlight &&
+ prev.edit === next.edit &&
+ prev.canDelete === next.canDelete &&
+ prev.canSendReaction === next.canSendReaction &&
+ prev.canPinEvent === next.canPinEvent &&
+ prev.imagePackRooms === next.imagePackRooms &&
+ prev.hideReadReceipts === next.hideReadReceipts &&
+ prev.showDeveloperTools === next.showDeveloperTools &&
+ prev.memberPowerTag === next.memberPowerTag &&
+ prev.accessibleTagColors === next.accessibleTagColors &&
+ prev.legacyUsernameColor === next.legacyUsernameColor &&
+ prev.streamRailStart === next.streamRailStart &&
+ prev.streamRailEnd === next.streamRailEnd &&
+ prev.hideThreadReplyAffordance === next.hideThreadReplyAffordance &&
+ prev.hideMainReplyAffordance === next.hideMainReplyAffordance &&
+ prev.msgType === next.msgType &&
+ prev.layout === next.layout &&
+ prev.channelHeaderInBubble === next.channelHeaderInBubble &&
+ prev.className === next.className &&
+ prev.onUserClick === next.onUserClick &&
+ prev.onUsernameClick === next.onUsernameClick &&
+ prev.onReplyClick === next.onReplyClick &&
+ prev.onReactionToggle === next.onReactionToggle &&
+ prev.onEditId === next.onEditId &&
+ Boolean(prev.reply) === Boolean(next.reply) &&
+ Boolean(prev.reactions) === Boolean(next.reactions) &&
+ Boolean(prev.threadSummary) === Boolean(next.threadSummary) &&
+ // `data-message-item` is the row's ABSOLUTE virtual-paginator index, queried
+ // by scrollToItem(). It shifts for existing rows when older history
+ // paginates in at the top, so a skipped render must not leave it stale —
+ // live appends at the bottom don't move it, so the memo still fires there.
+ (prev as Record)['data-message-item'] ===
+ (next as Record)['data-message-item'] &&
+ prev.renderSig === next.renderSig
+ );
+}
+
+export const Message = memo(MessageInner, areMessagePropsEqual);
+
export type EventProps = {
room: Room;
mEvent: MatrixEvent;