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 parse, { HTMLReactParserOptions } from 'html-react-parser';
|
||||||
import Linkify from 'linkify-react';
|
import Linkify from 'linkify-react';
|
||||||
import { Opts } from 'linkifyjs';
|
import { Opts } from 'linkifyjs';
|
||||||
|
|
@ -21,16 +21,30 @@ export function RenderBody({
|
||||||
htmlReactParserOptions,
|
htmlReactParserOptions,
|
||||||
linkifyOpts,
|
linkifyOpts,
|
||||||
}: RenderBodyProps) {
|
}: 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 (body === '') <MessageEmptyContent />;
|
||||||
if (customBody) {
|
if (customBody) {
|
||||||
if (customBody === '') <MessageEmptyContent />;
|
if (customBody === '') <MessageEmptyContent />;
|
||||||
return parse(sanitizeCustomHtml(customBody), htmlReactParserOptions);
|
return parsedCustomBody;
|
||||||
}
|
}
|
||||||
return (
|
return <Linkify options={linkifyOpts}>{plainBody}</Linkify>;
|
||||||
<Linkify options={linkifyOpts}>
|
|
||||||
{highlightRegex
|
|
||||||
? highlightText(highlightRegex, scaleSystemEmoji(body))
|
|
||||||
: scaleSystemEmoji(body)}
|
|
||||||
</Linkify>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,7 @@ import {
|
||||||
SyslineMessage,
|
SyslineMessage,
|
||||||
EncryptedContent,
|
EncryptedContent,
|
||||||
useMessageInteractionHandlers,
|
useMessageInteractionHandlers,
|
||||||
|
getMessageRenderSig,
|
||||||
} from './message';
|
} from './message';
|
||||||
import { ThreadSummaryCard } from './ThreadSummaryCard';
|
import { ThreadSummaryCard } from './ThreadSummaryCard';
|
||||||
import { useMemberEventParser } from '../../hooks/useMemberEventParser';
|
import { useMemberEventParser } from '../../hooks/useMemberEventParser';
|
||||||
|
|
@ -1274,7 +1275,13 @@ export function RoomTimeline({
|
||||||
// created_ts + expires, we treat the stale join as gone. Linear in
|
// created_ts + expires, we treat the stale join as gone. Linear in
|
||||||
// #call-member events (rare); matches the inline `streamRenderableItemHasBefore`
|
// #call-member events (rare); matches the inline `streamRenderableItemHasBefore`
|
||||||
// IIFE pattern just below.
|
// 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 = {
|
type CallScan = {
|
||||||
slotId: string;
|
slotId: string;
|
||||||
anchorEventId: string;
|
anchorEventId: string;
|
||||||
|
|
@ -1489,7 +1496,7 @@ export function RoomTimeline({
|
||||||
}
|
}
|
||||||
/* eslint-enable no-restricted-syntax, no-continue */
|
/* eslint-enable no-restricted-syntax, no-continue */
|
||||||
return anchors;
|
return anchors;
|
||||||
})();
|
}, [timeline]);
|
||||||
|
|
||||||
const renderMatrixEvent = useMatrixEventRenderer<
|
const renderMatrixEvent = useMatrixEventRenderer<
|
||||||
[string, MatrixEvent, number, EventTimelineSet, boolean, boolean, boolean]
|
[string, MatrixEvent, number, EventTimelineSet, boolean, boolean, boolean]
|
||||||
|
|
@ -1528,6 +1535,7 @@ export function RoomTimeline({
|
||||||
return (
|
return (
|
||||||
<Message
|
<Message
|
||||||
key={mEvent.getId()}
|
key={mEvent.getId()}
|
||||||
|
renderSig={getMessageRenderSig(room, mEvent, editedEvent, showUrlPreview)}
|
||||||
data-message-item={item}
|
data-message-item={item}
|
||||||
data-message-id={mEventId}
|
data-message-id={mEventId}
|
||||||
room={room}
|
room={room}
|
||||||
|
|
@ -1637,6 +1645,12 @@ export function RoomTimeline({
|
||||||
return (
|
return (
|
||||||
<Message
|
<Message
|
||||||
key={mEvent.getId()}
|
key={mEvent.getId()}
|
||||||
|
renderSig={getMessageRenderSig(
|
||||||
|
room,
|
||||||
|
mEvent,
|
||||||
|
getEditedEvent(mEventId, mEvent, timelineSet),
|
||||||
|
showUrlPreview
|
||||||
|
)}
|
||||||
data-message-item={item}
|
data-message-item={item}
|
||||||
data-message-id={mEventId}
|
data-message-id={mEventId}
|
||||||
room={room}
|
room={room}
|
||||||
|
|
@ -1776,6 +1790,7 @@ export function RoomTimeline({
|
||||||
return (
|
return (
|
||||||
<Message
|
<Message
|
||||||
key={mEvent.getId()}
|
key={mEvent.getId()}
|
||||||
|
renderSig={getMessageRenderSig(room, mEvent, undefined, showUrlPreview)}
|
||||||
data-message-item={item}
|
data-message-item={item}
|
||||||
data-message-id={mEventId}
|
data-message-id={mEventId}
|
||||||
room={room}
|
room={room}
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,13 @@ import { ImageViewer } from '../../components/image-viewer';
|
||||||
import { RenderMessageContent } from '../../components/RenderMessageContent';
|
import { RenderMessageContent } from '../../components/RenderMessageContent';
|
||||||
import { RoomInput } from './RoomInput';
|
import { RoomInput } from './RoomInput';
|
||||||
import { RoomInputPlaceholder } from './RoomInputPlaceholder';
|
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 { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
||||||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||||
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
|
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
|
||||||
|
|
@ -998,6 +1004,12 @@ export function ThreadDrawer({ room, rootId, parentRoomPath, variant }: ThreadDr
|
||||||
return (
|
return (
|
||||||
<Message
|
<Message
|
||||||
key={eventId}
|
key={eventId}
|
||||||
|
renderSig={getMessageRenderSig(
|
||||||
|
room,
|
||||||
|
mEvent,
|
||||||
|
getEditedEvent(eventId, mEvent, timelineSet),
|
||||||
|
showUrlPreview
|
||||||
|
)}
|
||||||
data-message-id={eventId}
|
data-message-id={eventId}
|
||||||
room={room}
|
room={room}
|
||||||
mEvent={mEvent}
|
mEvent={mEvent}
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,9 @@ import {
|
||||||
config,
|
config,
|
||||||
} from 'folds';
|
} from 'folds';
|
||||||
import React, {
|
import React, {
|
||||||
|
ComponentProps,
|
||||||
FormEventHandler,
|
FormEventHandler,
|
||||||
|
memo,
|
||||||
MouseEventHandler,
|
MouseEventHandler,
|
||||||
ReactNode,
|
ReactNode,
|
||||||
useCallback,
|
useCallback,
|
||||||
|
|
@ -713,8 +715,12 @@ export type MessageProps = {
|
||||||
// caller passes `true` alongside `layout='channel'`; kept as a prop so
|
// caller passes `true` alongside `layout='channel'`; kept as a prop so
|
||||||
// un-bubbled `ChannelLayout` consumers stay possible.
|
// un-bubbled `ChannelLayout` consumers stay possible.
|
||||||
channelHeaderInBubble?: boolean;
|
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,
|
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 = {
|
export type EventProps = {
|
||||||
room: Room;
|
room: Room;
|
||||||
mEvent: MatrixEvent;
|
mEvent: MatrixEvent;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue