perf(timeline): memoize message rows and cache the per-render call-aggregate scan and HTML body parse
This commit is contained in:
parent
067417050c
commit
bfe2f89a28
4 changed files with 161 additions and 13 deletions
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue