perf: remove reply backlink subscription churn

Replaced per-reply useReplies subscriptions in backlink rendering with a precomputed directRepliesByParentCid map from thread-level filteredReplies. Also optimized quote link rendering in comment-content with scoped store subscription, reduced edit menu state churn, and improved DOM lookups with memoization to keep all backlink rendering data-local and eliminate repeated backend-state-driven reference churn during board scroll.
This commit is contained in:
plebeius
2026-02-12 20:00:32 +08:00
parent 7821f9cd82
commit 3d2dbbf75c
6 changed files with 286 additions and 121 deletions
@@ -1,4 +1,4 @@
import { Fragment, useMemo, useState } from 'react';
import { Fragment, useCallback, useMemo, useState } from 'react';
import { useLocation, useParams } from 'react-router-dom';
import { Trans, useTranslation } from 'react-i18next';
import { Comment, useComment } from '@plebbit/plebbit-react-hooks';
@@ -25,6 +25,41 @@ const QuotedCidLink = ({ cid, postCid }: { cid: string; postCid: string }) => {
return <ReplyQuotePreview isQuotelinkReply={true} quotelinkReply={quotedComment} isOP={isOP} />;
};
const useScopedCidToNumber = (cids: string[]) => {
const sortedUniqueCids = useMemo(() => {
const uniqueCids = new Set<string>();
for (const cid of cids) {
if (cid) {
uniqueCids.add(cid);
}
}
return [...uniqueCids].sort();
}, [cids]);
// Subscribe only to CIDs this comment needs so unrelated thread updates do not rerender content.
const cidNumbersSignature = usePostNumberStore(
useCallback((state) => sortedUniqueCids.map((cid) => `${cid}:${state.cidToNumber[cid] ?? ''}`).join('|'), [sortedUniqueCids]),
);
return useMemo(() => {
if (sortedUniqueCids.length === 0) {
return {} as Record<string, number>;
}
const { cidToNumber } = usePostNumberStore.getState();
const nextCidToNumber: Record<string, number> = {};
for (const cid of sortedUniqueCids) {
const number = cidToNumber[cid];
if (typeof number === 'number') {
nextCidToNumber[cid] = number;
}
}
return nextCidToNumber;
}, [sortedUniqueCids, cidNumbersSignature]);
};
const CommentContent = ({ comment: post }: { comment: Comment }) => {
const { t } = useTranslation();
const params = useParams();
@@ -59,7 +94,15 @@ const CommentContent = ({ comment: post }: { comment: Comment }) => {
return new Set([...matches].map((m) => parseInt(m[1], 10)));
}, [content]);
const cidToNumber = usePostNumberStore((state) => state.cidToNumber);
const relevantQuotedCids = useMemo(() => {
const cids = quotedCids ? [...quotedCids] : [];
if (parentCid) {
cids.push(parentCid);
}
return cids;
}, [quotedCids, parentCid]);
const cidToNumber = useScopedCidToNumber(relevantQuotedCids);
const filteredQuotedCids = useMemo(() => {
if (!quotedCids?.length) return [];
return quotedCids.filter((cid: string) => {
+30 -29
View File
@@ -1,4 +1,4 @@
import { useEffect, useState, useMemo, useCallback } from 'react';
import { useCallback, useMemo, useRef, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { autoUpdate, FloatingFocusManager, offset, shift, useClick, useDismiss, useFloating, useId, useInteractions, useRole } from '@floating-ui/react';
import {
@@ -33,30 +33,22 @@ const EditMenu = ({ post }: { post: Comment }) => {
const { t } = useTranslation();
const isMobile = useIsMobile();
const { author, cid, content, deleted, locked, parentCid, pinned, postCid, reason, removed, spoiler, subplebbitAddress } = post || {};
const authorDisplayName = post?.author?.displayName;
const modBanExpiresAt = post?.commentModeration?.author?.banExpiresAt;
const purged = post?.commentModeration?.purged ?? false;
const [isEditMenuOpen, setIsEditMenuOpen] = useState(false);
const [isContentEditorOpen, setIsContentEditorOpen] = useState(false);
const account = useAccount();
const [signer, setSigner] = useState<any>(account?.signer);
const { isCommentAuthorMod, isAccountMod, isAccountCommentAuthor } = useAuthorPrivileges({
commentAuthorAddress: author?.address,
subplebbitAddress,
postCid,
});
const checkSigner = useCallback(() => {
if (isAccountCommentAuthor) {
setSigner(account?.signer);
} else {
setSigner(null);
}
}, [isAccountCommentAuthor, account?.signer]);
useEffect(() => {
checkSigner();
}, [checkSigner]);
const signer = isAccountCommentAuthor ? account?.signer : null;
const latestPostRef = useRef(post);
latestPostRef.current = post;
const onChallenge = useCallback((...args: any) => addChallenge([...args, latestPostRef.current]), []);
const defaultPublishEditOptions = useMemo(() => {
return {
@@ -75,17 +67,17 @@ const EditMenu = ({ post }: { post: Comment }) => {
purged: purged ?? false,
spoiler: spoiler ?? false,
reason,
author: post?.commentModeration?.author?.banExpiresAt ? { banExpiresAt: post.commentModeration.author.banExpiresAt } : undefined,
author: modBanExpiresAt ? { banExpiresAt: modBanExpiresAt } : undefined,
}
: undefined,
onChallenge: (...args: any) => addChallenge([...args, post]),
onChallenge,
onChallengeVerification: alertChallengeVerificationFailed,
onError: (error: Error) => {
console.warn(error);
alert('Comment edit failed. ' + error.message);
},
};
}, [isAccountMod, isAccountCommentAuthor, cid, content, deleted, locked, pinned, reason, removed, purged, spoiler, subplebbitAddress, post]);
}, [isAccountMod, isAccountCommentAuthor, cid, content, deleted, locked, pinned, reason, removed, purged, spoiler, subplebbitAddress, modBanExpiresAt, onChallenge]);
const [publishCommentEditOptions, setPublishCommentEditOptions] = useState<PublishCommentEditOptions>(defaultPublishEditOptions);
@@ -94,19 +86,19 @@ const EditMenu = ({ post }: { post: Comment }) => {
commentCid: cid,
subplebbitAddress,
signer,
author: signer?.address === author?.address ? { address: signer?.address, displayName: post?.author?.displayName } : account?.author,
author: signer?.address === author?.address ? { address: signer?.address, displayName: authorDisplayName } : account?.author,
content: publishCommentEditOptions.content,
deleted: publishCommentEditOptions.deleted,
reason: publishCommentEditOptions.reason,
spoiler: publishCommentEditOptions.spoiler,
onChallenge: (...args: any) => addChallenge([...args, post]),
onChallenge,
onChallengeVerification: alertChallengeVerificationFailed,
onError: (error: Error) => {
console.warn(error);
alert('Comment edit failed. ' + error.message);
},
}),
[publishCommentEditOptions, cid, subplebbitAddress, signer, post, account?.author, author?.address],
[publishCommentEditOptions, cid, subplebbitAddress, signer, account?.author, author?.address, authorDisplayName, onChallenge],
);
const modEditOptions = useMemo<PublishCommentModerationOptions>(
@@ -123,27 +115,31 @@ const EditMenu = ({ post }: { post: Comment }) => {
author: publishCommentEditOptions.commentModeration?.author,
},
author: account?.author,
onChallenge: (...args: any) => addChallenge([...args, post]),
onChallenge,
onChallengeVerification: alertChallengeVerificationFailed,
onError: (error: Error) => {
console.warn(error);
alert('Comment moderation failed. ' + error.message);
},
}),
[publishCommentEditOptions, cid, subplebbitAddress, post, account?.author, parentCid],
[publishCommentEditOptions, cid, subplebbitAddress, account?.author, parentCid, onChallenge],
);
const { publishCommentEdit: publishAuthorEdit } = usePublishCommentEdit(authorEditOptions);
const { publishCommentModeration } = usePublishCommentModeration(modEditOptions);
useEffect(() => {
setPublishCommentEditOptions(defaultPublishEditOptions);
}, [defaultPublishEditOptions]);
const [banDuration, setBanDuration] = useState(() =>
publishCommentEditOptions.commentModeration?.author?.banExpiresAt ? timestampToDays(publishCommentEditOptions.commentModeration.author.banExpiresAt) : 1,
defaultPublishEditOptions.commentModeration?.author?.banExpiresAt ? timestampToDays(defaultPublishEditOptions.commentModeration.author.banExpiresAt) : 1,
);
const resetMenuState = () => {
setPublishCommentEditOptions(defaultPublishEditOptions);
setBanDuration(
defaultPublishEditOptions.commentModeration?.author?.banExpiresAt ? timestampToDays(defaultPublishEditOptions.commentModeration.author.banExpiresAt) : 1,
);
setIsContentEditorOpen(false);
};
const onCheckbox = (e: React.ChangeEvent<HTMLInputElement>) => {
const { id, checked } = e.target;
@@ -258,7 +254,12 @@ const EditMenu = ({ post }: { post: Comment }) => {
type='checkbox'
onChange={() => {
if (cid && (isAccountCommentAuthor || isAccountMod)) {
setIsEditMenuOpen(!isEditMenuOpen);
if (!isEditMenuOpen) {
resetMenuState();
setIsEditMenuOpen(true);
} else {
setIsEditMenuOpen(false);
}
} else {
setIsEditMenuOpen(false);
alert(parentCid ? t('cannot_edit_reply') : t('cannot_edit_thread'));
+30 -23
View File
@@ -247,14 +247,18 @@ const renderAnchorLink = (children: React.ReactNode, href: string, threadPostCid
};
const Markdown = ({ content, title, postCid }: MarkdownProps) => {
const remarkPlugins: any[] = [[supersub]];
const remarkPlugins = useMemo(() => {
const plugins: any[] = [[supersub]];
if (content && content.length <= MAX_LENGTH_FOR_GFM) {
remarkPlugins.push([remarkGfm, { singleTilde: false }]);
plugins.push([remarkGfm, { singleTilde: false }]);
}
remarkPlugins.push([blockquoteToGreentext]);
remarkPlugins.push([spoilerTransform]);
plugins.push([blockquoteToGreentext]);
plugins.push([spoilerTransform]);
return plugins;
}, [content]);
const customSchema = useMemo(
() => ({
@@ -270,25 +274,18 @@ const Markdown = ({ content, title, postCid }: MarkdownProps) => {
[],
);
const isInCatalogView = isCatalogView(useLocation().pathname, useParams());
const location = useLocation();
const params = useParams();
const isInCatalogView = isCatalogView(location.pathname, params);
const rehypePlugins = useMemo(() => [[rehypeRaw as any], [rehypeSanitize, customSchema]] as any[], [customSchema]);
// Preprocess content to convert plain text 5chan patterns to markdown links
const processedContent = preprocess5chanPatterns(content || '');
const processedContent = useMemo(() => preprocess5chanPatterns(content || ''), [content]);
return (
<span className={styles.markdown}>
{isInCatalogView && title && (
<span>
<b>{title}</b>
{content ? ': ' : ''}
</span>
)}
<ReactMarkdown
children={processedContent}
remarkPlugins={remarkPlugins}
rehypePlugins={[[rehypeRaw as any], [rehypeSanitize, customSchema]]}
components={
{
const components = useMemo(
() =>
({
p: ({ children }) => <p className={isInCatalogView ? styles.inline : ''}>{children}</p>,
h1: ({ children }) => <p className={styles.header}>{children}</p>,
h2: ({ children }) => <p className={styles.header}>{children}</p>,
@@ -321,9 +318,19 @@ const Markdown = ({ content, title, postCid }: MarkdownProps) => {
return renderAnchorLink(children, href || '', postCid);
},
} as ExtendedComponents
}
/>
}) as ExtendedComponents,
[isInCatalogView, postCid],
);
return (
<span className={styles.markdown}>
{isInCatalogView && title && (
<span>
<b>{title}</b>
{content ? ': ' : ''}
</span>
)}
<ReactMarkdown children={processedContent} remarkPlugins={remarkPlugins} rehypePlugins={rehypePlugins} components={components} />
</span>
);
};
+105 -27
View File
@@ -78,11 +78,11 @@ const PostInfo = ({
onApprove,
onReject,
quotedByMap,
}: PostProps) => {
directRepliesByParentCid,
}: PostProps & { directRepliesByParentCid?: Map<string, Comment[]> }) => {
const { t } = useTranslation();
const { author, cid, deleted, locked, pinned, parentCid, postCid, reason, removed, state, subplebbitAddress, timestamp } = post || {};
const title = post?.title?.trim();
const { replies } = useReplies({ comment: post, accountComments: { newerThan: Infinity } });
const { address, shortAddress } = author || {};
const displayName = author?.displayName?.trim();
const authorRole = roles?.[address]?.role?.replace('moderator', 'mod');
@@ -205,12 +205,18 @@ const PostInfo = ({
const userIDBackgroundColor = hashStringToColor(userID);
const userIDTextColor = getTextColorForBackground(userIDBackgroundColor);
const handleUserAddressClick = useAuthorAddressClick();
const numberOfPostsByAuthor = document.querySelectorAll(`[data-author-address="${shortAddress}"][data-post-cid="${postCid}"]`).length;
const pseudonymityMode = useSubplebbitField(subplebbitAddress, (sub) => sub?.features?.pseudonymityMode);
const showUserID = pseudonymityMode !== 'per-reply';
const handleUserAddressClick = useAuthorAddressClick();
const numberOfPostsByAuthor = useMemo(() => {
if (!showUserID || deleted || removed || !shortAddress || !postCid || typeof document === 'undefined') {
return 0;
}
return document.querySelectorAll(`[data-author-address="${shortAddress}"][data-post-cid="${postCid}"]`).length;
}, [showUserID, deleted, removed, shortAddress, postCid]);
const { hidden } = useHide(post);
const { openReplyModal } = useReplyModalStore();
@@ -421,18 +427,35 @@ const PostInfo = ({
)}
</span>
{!(removed || deleted) && !isModQueue && <PostMenuDesktop postMenu={postMenuProps} />}
{cid &&
parentCid &&
replies &&
replies.map(
{cid && parentCid && <ReplyBacklinks post={post} quotedByMap={quotedByMap} directRepliesByParentCid={directRepliesByParentCid} />}
{cid && !parentCid && <OpBacklinks cid={cid} quotedByMap={quotedByMap} />}
</span>
</div>
);
};
const ReplyBacklinks = ({
post,
quotedByMap,
directRepliesByParentCid,
}: {
post: Comment;
quotedByMap?: Map<string, Comment[]>;
directRepliesByParentCid?: Map<string, Comment[]>;
}) => {
const { cid, parentCid } = post || {};
if (!cid || !parentCid) {
return null;
}
const directReplies = directRepliesByParentCid?.get(cid) || [];
return (
<>
{directReplies.map(
(reply: Comment, index: number) =>
reply?.parentCid === cid &&
reply?.cid &&
!(reply?.deleted || reply?.removed) && <ReplyQuotePreview key={index} isBacklinkReply={true} backlinkReply={reply} />,
reply?.parentCid === cid && reply?.cid && !(reply?.deleted || reply?.removed) && <ReplyQuotePreview key={index} isBacklinkReply={true} backlinkReply={reply} />,
)}
{cid &&
parentCid &&
quotedByMap
{quotedByMap
?.get(cid)
?.map(
(reply: Comment, index: number) =>
@@ -440,18 +463,20 @@ const PostInfo = ({
reply?.cid &&
!(reply?.deleted || reply?.removed) && <ReplyQuotePreview key={`qb-${index}`} isBacklinkReply={true} backlinkReply={reply} />,
)}
{cid &&
!parentCid &&
quotedByMap
</>
);
};
const OpBacklinks = ({ cid, quotedByMap }: { cid: string; quotedByMap?: Map<string, Comment[]> }) => (
<>
{quotedByMap
?.get(cid)
?.map(
(reply: Comment) =>
reply?.cid && !(reply?.deleted || reply?.removed) && <ReplyQuotePreview key={`op-bl-${reply.cid}`} isBacklinkReply={true} backlinkReply={reply} />,
)}
</span>
</div>
);
};
</>
);
interface PostMediaProps {
commentMediaInfo: CommentMediaInfo | undefined;
@@ -550,7 +575,14 @@ const PostMedia = ({
);
};
const Reply = ({ postReplyCount, reply, roles, threadNumber, quotedByMap }: PostProps) => {
const Reply = ({
postReplyCount,
reply,
roles,
threadNumber,
quotedByMap,
directRepliesByParentCid,
}: PostProps & { directRepliesByParentCid?: Map<string, Comment[]> }) => {
let post = reply;
// handle pending mod or author edit
const { editedComment } = useEditedComment({ comment: reply });
@@ -578,7 +610,15 @@ const Reply = ({ postReplyCount, reply, roles, threadNumber, quotedByMap }: Post
<div className={styles.replyDesktop}>
<div className={styles.sideArrows}>{'>>'}</div>
<div className={`${styles.reply} ${isRouteLinkToReply && styles.highlight}`} data-cid={cid} data-author-address={author?.shortAddress} data-post-cid={postCid}>
<PostInfo post={post} postReplyCount={postReplyCount} roles={roles} isHidden={hidden} threadNumber={threadNumber} quotedByMap={quotedByMap} />
<PostInfo
post={post}
postReplyCount={postReplyCount}
roles={roles}
isHidden={hidden}
threadNumber={threadNumber}
quotedByMap={quotedByMap}
directRepliesByParentCid={directRepliesByParentCid}
/>
{link && !hidden && !(deleted || removed) && isValidURL(link) && (
<PostMedia
commentMediaInfo={commentMediaInfo}
@@ -668,6 +708,22 @@ const PostDesktop = ({
// Filter out deleted replies with no children for both virtuoso and non-virtuoso rendering
const filteredReplies = useMemo(() => (replies || []).filter((reply) => !(reply.deleted && (reply.replyCount === 0 || !reply.replyCount))), [replies]);
const directRepliesByParentCid = useMemo(() => {
const map = new Map<string, Comment[]>();
for (const reply of filteredReplies) {
const directParentCid = reply?.parentCid;
if (!directParentCid || !reply?.cid) {
continue;
}
const existingReplies = map.get(directParentCid);
if (existingReplies) {
existingReplies.push(reply);
} else {
map.set(directParentCid, [reply]);
}
}
return map;
}, [filteredReplies]);
const quotedByMap = useQuotedByMap(filteredReplies);
@@ -760,6 +816,7 @@ const PostDesktop = ({
onApprove={onApprove}
onReject={onReject}
quotedByMap={quotedByMap}
directRepliesByParentCid={directRepliesByParentCid}
/>
{!isHidden && !content && !(deleted || removed) && <div className={styles.spacer} />}
{!isHidden && <CommentContent comment={post} />}
@@ -797,7 +854,14 @@ const PostDesktop = ({
data={filteredReplies}
itemContent={(index, reply) => (
<div className={styles.replyContainer}>
<Reply reply={reply} roles={roles} postReplyCount={replyCount} threadNumber={post?.number} quotedByMap={quotedByMap} />
<Reply
reply={reply}
roles={roles}
postReplyCount={replyCount}
threadNumber={post?.number}
quotedByMap={quotedByMap}
directRepliesByParentCid={directRepliesByParentCid}
/>
</div>
)}
useWindowScroll={true}
@@ -816,7 +880,14 @@ const PostDesktop = ({
!hasMore &&
filteredReplies.map((reply, index) => (
<div key={index} className={styles.replyContainer}>
<Reply reply={reply} roles={roles} postReplyCount={replyCount} threadNumber={post?.number} quotedByMap={quotedByMap} />
<Reply
reply={reply}
roles={roles}
postReplyCount={replyCount}
threadNumber={post?.number}
quotedByMap={quotedByMap}
directRepliesByParentCid={directRepliesByParentCid}
/>
</div>
))}
{/* Non-virtualized rendering for board view (last 5 replies or show omitted) */}
@@ -828,7 +899,14 @@ const PostDesktop = ({
showReplies &&
(showOmittedReplies[cid] ? filteredReplies : filteredReplies.slice(-5)).map((reply, index) => (
<div key={index} className={styles.replyContainer}>
<Reply reply={reply} roles={roles} postReplyCount={replyCount} threadNumber={post?.number} quotedByMap={quotedByMap} />
<Reply
reply={reply}
roles={roles}
postReplyCount={replyCount}
threadNumber={post?.number}
quotedByMap={quotedByMap}
directRepliesByParentCid={directRepliesByParentCid}
/>
</div>
))}
</div>
+9 -3
View File
@@ -171,12 +171,18 @@ const PostInfoAndMedia = ({ post, postReplyCount = 0, roles, threadNumber }: Pos
const stateString = useStateString(post);
const postMenuProps = useMemo(() => selectPostMenuProps(post), [post]);
const handleUserAddressClick = useAuthorAddressClick();
const numberOfPostsByAuthor = document.querySelectorAll(`[data-author-address="${shortAddress}"][data-post-cid="${postCid}"]`).length;
const pseudonymityMode = useSubplebbitField(subplebbitAddress, (sub) => sub?.features?.pseudonymityMode);
const showUserID = pseudonymityMode !== 'per-reply';
const handleUserAddressClick = useAuthorAddressClick();
const numberOfPostsByAuthor = useMemo(() => {
if (!showUserID || deleted || removed || !shortAddress || !postCid || typeof document === 'undefined') {
return 0;
}
return document.querySelectorAll(`[data-author-address="${shortAddress}"][data-post-cid="${postCid}"]`).length;
}, [showUserID, deleted, removed, shortAddress, postCid]);
const userID = address && Plebbit.getShortAddress({ address }); // shortened to 8 chars for display; users can verify the full user ID via "Copy user ID" in the post menu to guard against spoofing
const userIDBackgroundColor = hashStringToColor(userID);
const userIDTextColor = getTextColorForBackground(userIDBackgroundColor);
+31 -1
View File
@@ -1,4 +1,4 @@
import { useCallback, useMemo } from 'react';
import { useCallback, useMemo, useRef } from 'react';
import { Comment } from '@plebbit/plebbit-react-hooks';
import { QUOTE_NUMBER_REGEX } from '../lib/utils/url-utils';
import usePostNumberStore from '../stores/use-post-number-store';
@@ -8,6 +8,30 @@ interface ReplyQuoteTargets {
quotedPostNumbers: number[];
}
const getReplyFingerprint = (reply: Comment) =>
`${reply?.cid ?? ''}|${reply?.deleted ? '1' : '0'}|${reply?.removed ? '1' : '0'}|${reply?.edit?.timestamp ?? ''}|${reply?.state ?? ''}`;
const areQuotedByMapsEquivalent = (previousMap: Map<string, Comment[]>, nextMap: Map<string, Comment[]>) => {
if (previousMap.size !== nextMap.size) {
return false;
}
for (const [quotedCid, nextReplies] of nextMap) {
const previousReplies = previousMap.get(quotedCid);
if (!previousReplies || previousReplies.length !== nextReplies.length) {
return false;
}
for (let i = 0; i < nextReplies.length; i++) {
if (getReplyFingerprint(previousReplies[i]) !== getReplyFingerprint(nextReplies[i])) {
return false;
}
}
}
return true;
};
const extractReplyQuoteTargets = (replies: Comment[]) => {
const quotedPostNumbers = new Set<number>();
const replyQuoteTargets: ReplyQuoteTargets[] = [];
@@ -38,6 +62,7 @@ const extractReplyQuoteTargets = (replies: Comment[]) => {
};
const useQuotedByMap = (replies: Comment[] = []) => {
const stableQuotedByMapRef = useRef<Map<string, Comment[]>>(new Map());
const { replyQuoteTargets, quotedPostNumbers } = useMemo(() => extractReplyQuoteTargets(replies), [replies]);
// Subscribe only to post numbers referenced in this thread to avoid unrelated global store churn.
@@ -92,6 +117,11 @@ const useQuotedByMap = (replies: Comment[] = []) => {
}
}
if (areQuotedByMapsEquivalent(stableQuotedByMapRef.current, map)) {
return stableQuotedByMapRef.current;
}
stableQuotedByMapRef.current = map;
return map;
}, [replyQuoteTargets, quotedNumberToCid]);
};