From 3d2dbbf75ca4962c8d069ea7c9c4c9cc1f7862bc Mon Sep 17 00:00:00 2001 From: plebeius Date: Thu, 12 Feb 2026 20:00:32 +0800 Subject: [PATCH] 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. --- .../comment-content/comment-content.tsx | 47 +++++- src/components/edit-menu/edit-menu.tsx | 59 +++---- src/components/markdown/markdown.tsx | 105 ++++++------ src/components/post-desktop/post-desktop.tsx | 152 +++++++++++++----- src/components/post-mobile/post-mobile.tsx | 12 +- src/hooks/use-quoted-by-map.ts | 32 +++- 6 files changed, 286 insertions(+), 121 deletions(-) diff --git a/src/components/comment-content/comment-content.tsx b/src/components/comment-content/comment-content.tsx index 29e08097..c78d27cb 100644 --- a/src/components/comment-content/comment-content.tsx +++ b/src/components/comment-content/comment-content.tsx @@ -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 ; }; +const useScopedCidToNumber = (cids: string[]) => { + const sortedUniqueCids = useMemo(() => { + const uniqueCids = new Set(); + 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; + } + + const { cidToNumber } = usePostNumberStore.getState(); + const nextCidToNumber: Record = {}; + + 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) => { diff --git a/src/components/edit-menu/edit-menu.tsx b/src/components/edit-menu/edit-menu.tsx index 07c9bd64..f43acd84 100644 --- a/src/components/edit-menu/edit-menu.tsx +++ b/src/components/edit-menu/edit-menu.tsx @@ -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(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(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( @@ -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) => { 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')); diff --git a/src/components/markdown/markdown.tsx b/src/components/markdown/markdown.tsx index 03383d58..34e98a04 100644 --- a/src/components/markdown/markdown.tsx +++ b/src/components/markdown/markdown.tsx @@ -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 }]); - } + if (content && content.length <= MAX_LENGTH_FOR_GFM) { + plugins.push([remarkGfm, { singleTilde: false }]); + } - remarkPlugins.push([blockquoteToGreentext]); - remarkPlugins.push([spoilerTransform]); + plugins.push([blockquoteToGreentext]); + plugins.push([spoilerTransform]); + + return plugins; + }, [content]); const customSchema = useMemo( () => ({ @@ -270,10 +274,53 @@ 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]); + + const components = useMemo( + () => + ({ + p: ({ children }) =>

{children}

, + h1: ({ children }) =>

{children}

, + h2: ({ children }) =>

{children}

, + h3: ({ children }) =>

{children}

, + h4: ({ children }) =>

{children}

, + h5: ({ children }) =>

{children}

, + h6: ({ children }) =>

{children}

, + img: ({ src, alt }) => { + const displayText = src || alt || 'image'; + return {displayText}; + }, + video: ({ src }) => {src}, + iframe: ({ src }) => {src}, + source: ({ src }) => {src}, + spoiler: ({ children }) => {children}, + a: ({ href, children }) => { + if (href && !isInCatalogView) { + try { + const linkMediaInfo = getLinkMediaInfo(href); + const embedUrl = href.startsWith('http') ? new URL(href) : null; + if ((embedUrl && canEmbed(embedUrl)) || getHasThumbnail(linkMediaInfo, href)) { + return ; + } + } catch (e) { + console.debug('Invalid URL:', href); + } + + return renderAnchorLink(children, href, postCid); + } + + return renderAnchorLink(children, href || '', postCid); + }, + }) as ExtendedComponents, + [isInCatalogView, postCid], + ); return ( @@ -283,47 +330,7 @@ const Markdown = ({ content, title, postCid }: MarkdownProps) => { {content ? ': ' : ''} )} -

{children}

, - h1: ({ children }) =>

{children}

, - h2: ({ children }) =>

{children}

, - h3: ({ children }) =>

{children}

, - h4: ({ children }) =>

{children}

, - h5: ({ children }) =>

{children}

, - h6: ({ children }) =>

{children}

, - img: ({ src, alt }) => { - const displayText = src || alt || 'image'; - return {displayText}; - }, - video: ({ src }) => {src}, - iframe: ({ src }) => {src}, - source: ({ src }) => {src}, - spoiler: ({ children }) => {children}, - a: ({ href, children }) => { - if (href && !isInCatalogView) { - try { - const linkMediaInfo = getLinkMediaInfo(href); - const embedUrl = href.startsWith('http') ? new URL(href) : null; - if ((embedUrl && canEmbed(embedUrl)) || getHasThumbnail(linkMediaInfo, href)) { - return ; - } - } catch (e) { - console.debug('Invalid URL:', href); - } - - return renderAnchorLink(children, href, postCid); - } - - return renderAnchorLink(children, href || '', postCid); - }, - } as ExtendedComponents - } - /> + ); }; diff --git a/src/components/post-desktop/post-desktop.tsx b/src/components/post-desktop/post-desktop.tsx index bda32a47..3791513e 100644 --- a/src/components/post-desktop/post-desktop.tsx +++ b/src/components/post-desktop/post-desktop.tsx @@ -78,11 +78,11 @@ const PostInfo = ({ onApprove, onReject, quotedByMap, -}: PostProps) => { + directRepliesByParentCid, +}: PostProps & { directRepliesByParentCid?: Map }) => { 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,38 +427,57 @@ const PostInfo = ({ )} {!(removed || deleted) && !isModQueue && } - {cid && - parentCid && - replies && - replies.map( - (reply: Comment, index: number) => - reply?.parentCid === cid && - reply?.cid && - !(reply?.deleted || reply?.removed) && , - )} - {cid && - parentCid && - quotedByMap - ?.get(cid) - ?.map( - (reply: Comment, index: number) => - reply?.parentCid !== cid && - reply?.cid && - !(reply?.deleted || reply?.removed) && , - )} - {cid && - !parentCid && - quotedByMap - ?.get(cid) - ?.map( - (reply: Comment) => - reply?.cid && !(reply?.deleted || reply?.removed) && , - )} + {cid && parentCid && } + {cid && !parentCid && } ); }; +const ReplyBacklinks = ({ + post, + quotedByMap, + directRepliesByParentCid, +}: { + post: Comment; + quotedByMap?: Map; + directRepliesByParentCid?: Map; +}) => { + 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) && , + )} + {quotedByMap + ?.get(cid) + ?.map( + (reply: Comment, index: number) => + reply?.parentCid !== cid && + reply?.cid && + !(reply?.deleted || reply?.removed) && , + )} + + ); +}; + +const OpBacklinks = ({ cid, quotedByMap }: { cid: string; quotedByMap?: Map }) => ( + <> + {quotedByMap + ?.get(cid) + ?.map( + (reply: Comment) => + reply?.cid && !(reply?.deleted || reply?.removed) && , + )} + +); + interface PostMediaProps { commentMediaInfo: CommentMediaInfo | undefined; hasThumbnail: boolean; @@ -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 }) => { 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
{'>>'}
- + {link && !hidden && !(deleted || removed) && isValidURL(link) && ( (replies || []).filter((reply) => !(reply.deleted && (reply.replyCount === 0 || !reply.replyCount))), [replies]); + const directRepliesByParentCid = useMemo(() => { + const map = new Map(); + 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) &&
} {!isHidden && } @@ -797,7 +854,14 @@ const PostDesktop = ({ data={filteredReplies} itemContent={(index, reply) => (
- +
)} useWindowScroll={true} @@ -816,7 +880,14 @@ const PostDesktop = ({ !hasMore && filteredReplies.map((reply, index) => (
- +
))} {/* 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) => (
- +
))}
diff --git a/src/components/post-mobile/post-mobile.tsx b/src/components/post-mobile/post-mobile.tsx index f00c09ab..a11ee015 100644 --- a/src/components/post-mobile/post-mobile.tsx +++ b/src/components/post-mobile/post-mobile.tsx @@ -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); diff --git a/src/hooks/use-quoted-by-map.ts b/src/hooks/use-quoted-by-map.ts index 9e69c1b1..a6e9da25 100644 --- a/src/hooks/use-quoted-by-map.ts +++ b/src/hooks/use-quoted-by-map.ts @@ -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, nextMap: Map) => { + 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(); const replyQuoteTargets: ReplyQuoteTargets[] = []; @@ -38,6 +62,7 @@ const extractReplyQuoteTargets = (replies: Comment[]) => { }; const useQuotedByMap = (replies: Comment[] = []) => { + const stableQuotedByMapRef = useRef>(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]); };