From be7ef74698433892ca84f351d29c39a01f891dea Mon Sep 17 00:00:00 2001 From: Steven Date: Mon, 22 Dec 2025 20:12:44 +0800 Subject: [PATCH] fix: eliminate duplicate API requests by deduplicating user fetch calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add request deduplication to getOrFetchUser using RequestDeduplicator - Consolidates multiple simultaneous calls for same user into single API request - Prevents duplicate 401 errors and wasted network traffic - Matches pattern already used by fetchUsers and fetchUserStats - Remove backwards compatibility aliases (getOrFetchUserByName, getOrFetchUserByUsername) - Update all call sites to use canonical getOrFetchUser method Fixes issue where PagedMemoList, useMemoViewState, MainLayout, and UserProfile were making duplicate user fetch requests when loading user data. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Haiku 4.5 --- .../components/Inbox/MemoCommentMessage.tsx | 2 +- .../components/MemoReactionListView/hooks.ts | 2 +- .../MemoView/hooks/useMemoViewState.ts | 2 +- .../PagedMemoList/PagedMemoList.tsx | 2 +- web/src/layouts/MainLayout.tsx | 2 +- web/src/pages/UserProfile.tsx | 2 +- web/src/store/user.ts | 51 +++++++------------ 7 files changed, 23 insertions(+), 40 deletions(-) diff --git a/web/src/components/Inbox/MemoCommentMessage.tsx b/web/src/components/Inbox/MemoCommentMessage.tsx index 050d7a388..9c1fd3cc3 100644 --- a/web/src/components/Inbox/MemoCommentMessage.tsx +++ b/web/src/components/Inbox/MemoCommentMessage.tsx @@ -50,7 +50,7 @@ const MemoCommentMessage = observer(({ notification }: Props) => { }); setCommentMemo(comment); - const sender = await userStore.getOrFetchUserByName(notification.sender); + const sender = await userStore.getOrFetchUser(notification.sender); setSender(sender); setInitialized(true); } diff --git a/web/src/components/MemoReactionListView/hooks.ts b/web/src/components/MemoReactionListView/hooks.ts index 0cbfaaa62..cf933aaf2 100644 --- a/web/src/components/MemoReactionListView/hooks.ts +++ b/web/src/components/MemoReactionListView/hooks.ts @@ -15,7 +15,7 @@ export const useReactionGroups = (reactions: Reaction[]): ReactionGroup => { const fetchReactionGroups = async () => { const newReactionGroup = new Map(); for (const reaction of reactions) { - const user = await userStore.getOrFetchUserByName(reaction.creator); + const user = await userStore.getOrFetchUser(reaction.creator); const users = newReactionGroup.get(reaction.reactionType) || []; users.push(user); newReactionGroup.set(reaction.reactionType, uniq(users)); diff --git a/web/src/components/MemoView/hooks/useMemoViewState.ts b/web/src/components/MemoView/hooks/useMemoViewState.ts index 95f0cf8b5..fee314d0c 100644 --- a/web/src/components/MemoView/hooks/useMemoViewState.ts +++ b/web/src/components/MemoView/hooks/useMemoViewState.ts @@ -116,7 +116,7 @@ export const useMemoCreator = (creatorName: string) => { if (fetchedRef.current) return; fetchedRef.current = true; (async () => { - const user = await userStore.getOrFetchUserByName(creatorName); + const user = await userStore.getOrFetchUser(creatorName); setCreator(user); })(); }, [creatorName]); diff --git a/web/src/components/PagedMemoList/PagedMemoList.tsx b/web/src/components/PagedMemoList/PagedMemoList.tsx index 1cb49178e..f65e169cd 100644 --- a/web/src/components/PagedMemoList/PagedMemoList.tsx +++ b/web/src/components/PagedMemoList/PagedMemoList.tsx @@ -67,7 +67,7 @@ const PagedMemoList = observer((props: Props) => { // This significantly improves perceived performance by pre-populating the cache if (response?.memos && props.showCreator) { const uniqueCreators = Array.from(new Set(response.memos.map((memo) => memo.creator))); - await Promise.allSettled(uniqueCreators.map((creator) => userStore.getOrFetchUserByName(creator))); + await Promise.allSettled(uniqueCreators.map((creator) => userStore.getOrFetchUser(creator))); } } finally { setIsRequesting(false); diff --git a/web/src/layouts/MainLayout.tsx b/web/src/layouts/MainLayout.tsx index 8878427a7..f6502c7c4 100644 --- a/web/src/layouts/MainLayout.tsx +++ b/web/src/layouts/MainLayout.tsx @@ -35,7 +35,7 @@ const MainLayout = observer(() => { // Fetch or get user to obtain user name (e.g., "users/123") // Note: User stats will be fetched by useFilteredMemoStats userStore - .getOrFetchUserByUsername(username) + .getOrFetchUser(`users/${username}`) .then((user) => { setProfileUserName(user.name); }) diff --git a/web/src/pages/UserProfile.tsx b/web/src/pages/UserProfile.tsx index ea514cc97..5b2cf057a 100644 --- a/web/src/pages/UserProfile.tsx +++ b/web/src/pages/UserProfile.tsx @@ -30,7 +30,7 @@ const UserProfile = observer(() => { } userStore - .getOrFetchUserByUsername(username) + .getOrFetchUser(`users/${username}`) .then((user) => { setUser(user); loadingState.setFinish(); diff --git a/web/src/store/user.ts b/web/src/store/user.ts index a0a438927..b0d7433a1 100644 --- a/web/src/store/user.ts +++ b/web/src/store/user.ts @@ -72,44 +72,28 @@ const userStore = (() => { const state = new LocalState(); const deduplicator = new RequestDeduplicator(); - const getOrFetchUserByName = async (name: string) => { + const getOrFetchUser = async (name: string) => { const userMap = state.userMapByName; if (userMap[name]) { return userMap[name] as User; } - const user = await userServiceClient.getUser({ - name: name, - }); - state.setPartial({ - userMapByName: { - ...userMap, - [name]: user, - }, - }); - return user; - }; - - const getOrFetchUserByUsername = async (username: string) => { - const userMap = state.userMapByName; - for (const name in userMap) { - if (userMap[name].username === username) { - return userMap[name]; + const requestKey = createRequestKey("getOrFetchUser", { name }); + return deduplicator.execute(requestKey, async () => { + // Double-check cache in case another request finished first + if (state.userMapByName[name]) { + return state.userMapByName[name] as User; } - } - // Use GetUser with username - supports both "users/{id}" and "users/{username}" - const user = await userServiceClient.getUser({ - name: `users/${username}`, + const user = await userServiceClient.getUser({ + name: name, + }); + state.setPartial({ + userMapByName: { + ...state.userMapByName, + [name]: user, + }, + }); + return user; }); - if (!user) { - throw new Error(`User with username ${username} not found`); - } - state.setPartial({ - userMapByName: { - ...userMap, - [user.name]: user, - }, - }); - return user; }; const getUserByName = (name: string) => { @@ -292,8 +276,7 @@ const userStore = (() => { return { state, - getOrFetchUserByName, - getOrFetchUserByUsername, + getOrFetchUser, getUserByName, fetchUsers, updateUser,