perf(timeline): memoize message rows and cache the per-render call-aggregate scan and HTML body parse

This commit is contained in:
heaven 2026-05-29 02:36:13 +03:00
parent 067417050c
commit bfe2f89a28
4 changed files with 161 additions and 13 deletions

View file

@ -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 === '') <MessageEmptyContent />;
if (customBody) {
if (customBody === '') <MessageEmptyContent />;
return parse(sanitizeCustomHtml(customBody), htmlReactParserOptions);
return parsedCustomBody;
}
return (
<Linkify options={linkifyOpts}>
{highlightRegex
? highlightText(highlightRegex, scaleSystemEmoji(body))
: scaleSystemEmoji(body)}
</Linkify>
);
return <Linkify options={linkifyOpts}>{plainBody}</Linkify>;
}

View file

@ -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<string, CallAggregate> = (() => {
// 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<string, CallAggregate> = 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 (
<Message
key={mEvent.getId()}
renderSig={getMessageRenderSig(room, mEvent, editedEvent, showUrlPreview)}
data-message-item={item}
data-message-id={mEventId}
room={room}
@ -1637,6 +1645,12 @@ export function RoomTimeline({
return (
<Message
key={mEvent.getId()}
renderSig={getMessageRenderSig(
room,
mEvent,
getEditedEvent(mEventId, mEvent, timelineSet),
showUrlPreview
)}
data-message-item={item}
data-message-id={mEventId}
room={room}
@ -1776,6 +1790,7 @@ export function RoomTimeline({
return (
<Message
key={mEvent.getId()}
renderSig={getMessageRenderSig(room, mEvent, undefined, showUrlPreview)}
data-message-item={item}
data-message-id={mEventId}
room={room}

View file

@ -47,7 +47,13 @@ import { ImageViewer } from '../../components/image-viewer';
import { RenderMessageContent } from '../../components/RenderMessageContent';
import { RoomInput } from './RoomInput';
import { RoomInputPlaceholder } from './RoomInputPlaceholder';
import { EncryptedContent, Message, Reactions, useMessageInteractionHandlers } from './message';
import {
EncryptedContent,
Message,
Reactions,
getMessageRenderSig,
useMessageInteractionHandlers,
} from './message';
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { useRoomCreators } from '../../hooks/useRoomCreators';
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
@ -998,6 +1004,12 @@ export function ThreadDrawer({ room, rootId, parentRoomPath, variant }: ThreadDr
return (
<Message
key={eventId}
renderSig={getMessageRenderSig(
room,
mEvent,
getEditedEvent(eventId, mEvent, timelineSet),
showUrlPreview
)}
data-message-id={eventId}
room={room}
mEvent={mEvent}

View file

@ -23,7 +23,9 @@ import {
config,
} from 'folds';
import React, {
ComponentProps,
FormEventHandler,
memo,
MouseEventHandler,
ReactNode,
useCallback,
@ -713,8 +715,12 @@ export type MessageProps = {
// caller passes `true` alongside `layout='channel'`; kept as a prop so
// un-bubbled `ChannelLayout` consumers stay possible.
channelHeaderInBubble?: boolean;
// Parent-computed render snapshot (see getMessageRenderSig). Drives the
// React.memo comparator so in-place MatrixEvent mutations (edit/redact/
// decrypt/echo) and rename/urlPreview changes repaint the row.
renderSig?: string;
};
export const Message = as<'div', MessageProps>(
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<typeof MessageInner>,
next: ComponentProps<typeof MessageInner>
): 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<string, unknown>)['data-message-item'] ===
(next as Record<string, unknown>)['data-message-item'] &&
prev.renderSig === next.renderSig
);
}
export const Message = memo(MessageInner, areMessagePropsEqual);
export type EventProps = {
room: Room;
mEvent: MatrixEvent;