From 955ff0cad68f77a5554752fca6b697207a3c7e12 Mon Sep 17 00:00:00 2001 From: Johnny Date: Sun, 28 Dec 2025 17:50:43 +0800 Subject: [PATCH] refactor: optimize user fetching in MemoCommentMessage and MemoReactionListView components --- .../components/Inbox/MemoCommentMessage.tsx | 13 ++++--- .../components/MemoReactionListView/hooks.ts | 35 +++++++++---------- .../PagedMemoList/PagedMemoList.tsx | 26 ++++++++------ web/src/hooks/useUserQueries.ts | 31 ++++++++++++++++ 4 files changed, 69 insertions(+), 36 deletions(-) diff --git a/web/src/components/Inbox/MemoCommentMessage.tsx b/web/src/components/Inbox/MemoCommentMessage.tsx index 2f62debcc..f78cf0277 100644 --- a/web/src/components/Inbox/MemoCommentMessage.tsx +++ b/web/src/components/Inbox/MemoCommentMessage.tsx @@ -8,10 +8,11 @@ import { activityServiceClient, memoServiceClient, userServiceClient } from "@/c import { activityNamePrefix } from "@/helpers/resource-names"; import useAsyncEffect from "@/hooks/useAsyncEffect"; import useNavigateTo from "@/hooks/useNavigateTo"; +import { useUser } from "@/hooks/useUserQueries"; import { handleError } from "@/lib/error"; import { cn } from "@/lib/utils"; import { Memo } from "@/types/proto/api/v1/memo_service_pb"; -import { User, UserNotification, UserNotification_Status } from "@/types/proto/api/v1/user_service_pb"; +import { UserNotification, UserNotification_Status } from "@/types/proto/api/v1/user_service_pb"; import { useTranslate } from "@/utils/i18n"; interface Props { @@ -23,10 +24,12 @@ function MemoCommentMessage({ notification }: Props) { const navigateTo = useNavigateTo(); const [relatedMemo, setRelatedMemo] = useState(undefined); const [commentMemo, setCommentMemo] = useState(undefined); - const [sender, setSender] = useState(undefined); + const [senderName, setSenderName] = useState(undefined); const [initialized, setInitialized] = useState(false); const [hasError, setHasError] = useState(false); + const { data: sender } = useUser(senderName || "", { enabled: !!senderName }); + useAsyncEffect(async () => { if (!notification.activityId) { return; @@ -44,16 +47,12 @@ function MemoCommentMessage({ notification }: Props) { }); setRelatedMemo(memo); - // Fetch the comment memo const comment = await memoServiceClient.getMemo({ name: memoCommentPayload.memo, }); setCommentMemo(comment); - const sender = await userServiceClient.getUser({ - name: notification.sender, - }); - setSender(sender); + setSenderName(notification.sender); setInitialized(true); } } catch (error) { diff --git a/web/src/components/MemoReactionListView/hooks.ts b/web/src/components/MemoReactionListView/hooks.ts index f027bb5cb..6e83b8b33 100644 --- a/web/src/components/MemoReactionListView/hooks.ts +++ b/web/src/components/MemoReactionListView/hooks.ts @@ -1,33 +1,30 @@ import { useQueryClient } from "@tanstack/react-query"; -import { uniq } from "lodash-es"; -import { useEffect, useState } from "react"; -import { memoServiceClient, userServiceClient } from "@/connect"; +import { useMemo } from "react"; +import { memoServiceClient } from "@/connect"; import useCurrentUser from "@/hooks/useCurrentUser"; import { memoKeys } from "@/hooks/useMemoQueries"; +import { useUsersByNames } from "@/hooks/useUserQueries"; import type { Memo, Reaction } from "@/types/proto/api/v1/memo_service_pb"; import type { User } from "@/types/proto/api/v1/user_service_pb"; export type ReactionGroup = Map; export const useReactionGroups = (reactions: Reaction[]): ReactionGroup => { - const [reactionGroup, setReactionGroup] = useState(new Map()); + const creatorNames = useMemo(() => reactions.map((r) => r.creator), [reactions]); + const { data: userMap } = useUsersByNames(creatorNames); - useEffect(() => { - const fetchReactionGroups = async () => { - const newReactionGroup = new Map(); - for (const reaction of reactions) { - // Fetch user via gRPC directly since we need it within an effect - const user = await userServiceClient.getUser({ name: reaction.creator }); - const users = newReactionGroup.get(reaction.reactionType) || []; - users.push(user); - newReactionGroup.set(reaction.reactionType, uniq(users)); - } - setReactionGroup(newReactionGroup); - }; - fetchReactionGroups(); - }, [reactions]); + return useMemo(() => { + const reactionGroup = new Map(); + for (const reaction of reactions) { + const user = userMap?.get(reaction.creator); + if (!user) continue; - return reactionGroup; + const users = reactionGroup.get(reaction.reactionType) || []; + users.push(user); + reactionGroup.set(reaction.reactionType, users); + } + return reactionGroup; + }, [reactions, userMap]); }; interface UseReactionActionsOptions { diff --git a/web/src/components/PagedMemoList/PagedMemoList.tsx b/web/src/components/PagedMemoList/PagedMemoList.tsx index 832440d06..d0823c678 100644 --- a/web/src/components/PagedMemoList/PagedMemoList.tsx +++ b/web/src/components/PagedMemoList/PagedMemoList.tsx @@ -1,4 +1,5 @@ -import { ArrowUpIcon, LoaderIcon } from "lucide-react"; +import { useQueryClient } from "@tanstack/react-query"; +import { ArrowUpIcon } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { matchPath } from "react-router-dom"; import { Button } from "@/components/ui/button"; @@ -6,6 +7,7 @@ import { userServiceClient } from "@/connect"; import { useView } from "@/contexts/ViewContext"; import { DEFAULT_LIST_MEMOS_PAGE_SIZE } from "@/helpers/consts"; import { useInfiniteMemos } from "@/hooks/useMemoQueries"; +import { userKeys } from "@/hooks/useUserQueries"; import { Routes } from "@/router"; import { State } from "@/types/proto/api/v1/common_pb"; import type { Memo } from "@/types/proto/api/v1/memo_service_pb"; @@ -81,6 +83,7 @@ function useAutoFetchWhenNotScrollable({ const PagedMemoList = (props: Props) => { const t = useTranslate(); const { layout } = useView(); + const queryClient = useQueryClient(); // Show memo editor only on the root route const showMemoEditor = Boolean(matchPath(Routes.ROOT, window.location.pathname)); @@ -99,7 +102,7 @@ const PagedMemoList = (props: Props) => { // Apply custom sorting if provided, otherwise use memos directly const sortedMemoList = useMemo(() => (props.listSort ? props.listSort(memos) : memos), [memos, props.listSort]); - // Batch-fetch creators when new data arrives to improve performance + // Prefetch creators when new data arrives to improve performance useEffect(() => { if (!data?.pages || !props.showCreator) return; @@ -107,14 +110,17 @@ const PagedMemoList = (props: Props) => { if (!lastPage?.memos) return; const uniqueCreators = Array.from(new Set(lastPage.memos.map((memo) => memo.creator))); - void Promise.allSettled( - uniqueCreators.map((creator) => - userServiceClient.getUser({ name: creator }).catch(() => { - /* silently ignore errors */ - }), - ), - ); - }, [data?.pages, props.showCreator]); + for (const creator of uniqueCreators) { + void queryClient.prefetchQuery({ + queryKey: userKeys.detail(creator), + queryFn: async () => { + const user = await userServiceClient.getUser({ name: creator }); + return user; + }, + staleTime: 1000 * 60 * 5, + }); + } + }, [data?.pages, props.showCreator, queryClient]); // Auto-fetch hook: fetches more content when page isn't scrollable useAutoFetchWhenNotScrollable({ diff --git a/web/src/hooks/useUserQueries.ts b/web/src/hooks/useUserQueries.ts index fdb1a8efb..8c4854a59 100644 --- a/web/src/hooks/useUserQueries.ts +++ b/web/src/hooks/useUserQueries.ts @@ -15,6 +15,7 @@ export const userKeys = { currentUser: () => [...userKeys.all, "current"] as const, shortcuts: () => [...userKeys.all, "shortcuts"] as const, notifications: () => [...userKeys.all, "notifications"] as const, + byNames: (names: string[]) => [...userKeys.all, "byNames", ...names.sort()] as const, }; // NOTE: This hook is currently UNUSED in favor of the AuthContext-based @@ -226,3 +227,33 @@ export function useUpdateUserGeneralSetting(currentUserName?: string) { }, }); } + +// Hook to fetch multiple users by names (returns Map) +export function useUsersByNames(names: string[]) { + const enabled = names.length > 0; + const uniqueNames = Array.from(new Set(names)); + + return useQuery({ + queryKey: userKeys.byNames(uniqueNames), + queryFn: async () => { + const users = await Promise.all( + uniqueNames.map(async (name) => { + try { + const user = await userServiceClient.getUser({ name }); + return { name, user }; + } catch { + return { name, user: undefined }; + } + }), + ); + + const userMap = new Map(); + for (const { name, user } of users) { + userMap.set(name, user); + } + return userMap; + }, + enabled, + staleTime: 1000 * 60 * 5, // 5 minutes - user profiles don't change often + }); +}