From 424e599980e5bb1edb3637dc24c906bc55107d41 Mon Sep 17 00:00:00 2001 From: Steven Date: Mon, 24 Nov 2025 21:37:12 +0800 Subject: [PATCH] refactor(web): optimize memo statistics fetching by using cached data from memo store --- .../PagedMemoList/PagedMemoList.tsx | 85 ++++++++----- web/src/hooks/useFilteredMemoStats.ts | 114 ++++++------------ web/src/layouts/MainLayout.tsx | 49 +------- 3 files changed, 95 insertions(+), 153 deletions(-) diff --git a/web/src/components/PagedMemoList/PagedMemoList.tsx b/web/src/components/PagedMemoList/PagedMemoList.tsx index fd76c6c8b..1bdb1a5e5 100644 --- a/web/src/components/PagedMemoList/PagedMemoList.tsx +++ b/web/src/components/PagedMemoList/PagedMemoList.tsx @@ -9,10 +9,11 @@ import useResponsiveWidth from "@/hooks/useResponsiveWidth"; import { Routes } from "@/router"; import { memoStore, userStore, viewStore } from "@/store"; import { State } from "@/types/proto/api/v1/common"; -import { Memo } from "@/types/proto/api/v1/memo_service"; +import type { Memo } from "@/types/proto/api/v1/memo_service"; import { useTranslate } from "@/utils/i18n"; import Empty from "../Empty"; -import MasonryView, { MemoRenderContext } from "../MasonryView"; +import type { MemoRenderContext } from "../MasonryView"; +import MasonryView from "../MasonryView"; import MemoEditor from "../MemoEditor"; import MemoFilters from "../MemoFilters"; import MemoSkeleton from "../MemoSkeleton"; @@ -37,6 +38,8 @@ const PagedMemoList = observer((props: Props) => { // Ref to manage auto-fetch timeout to prevent memory leaks const autoFetchTimeoutRef = useRef(null); + // Ref to track if initial fetch has been triggered to prevent duplicates + const initialFetchTriggeredRef = useRef(false); // Apply custom sorting if provided, otherwise use store memos directly const sortedMemoList = props.listSort ? props.listSort(memoStore.state.memos) : memoStore.state.memos; @@ -45,36 +48,39 @@ const PagedMemoList = observer((props: Props) => { const showMemoEditor = Boolean(matchPath(Routes.ROOT, window.location.pathname)); // Fetch more memos with pagination support - const fetchMoreMemos = async (pageToken: string) => { - setIsRequesting(true); + const fetchMoreMemos = useCallback( + async (pageToken: string) => { + setIsRequesting(true); - try { - const response = await memoStore.fetchMemos({ - state: props.state || State.NORMAL, - orderBy: props.orderBy || "display_time desc", - filter: props.filter, - pageSize: props.pageSize || DEFAULT_LIST_MEMOS_PAGE_SIZE, - pageToken, - }); + try { + const response = await memoStore.fetchMemos({ + state: props.state || State.NORMAL, + orderBy: props.orderBy || "display_time desc", + filter: props.filter, + pageSize: props.pageSize || DEFAULT_LIST_MEMOS_PAGE_SIZE, + pageToken, + }); - setNextPageToken(response?.nextPageToken || ""); + setNextPageToken(response?.nextPageToken || ""); - // Batch-fetch creators in parallel to avoid individual fetches in MemoView - // 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))); + // Batch-fetch creators in parallel to avoid individual fetches in MemoView + // 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))); + } + } finally { + setIsRequesting(false); } - } finally { - setIsRequesting(false); - } - }; + }, + [props.state, props.orderBy, props.filter, props.pageSize, props.showCreator], + ); // Helper function to check if page has enough content to be scrollable - const isPageScrollable = () => { + const isPageScrollable = useCallback(() => { const documentHeight = Math.max(document.body.scrollHeight, document.documentElement.scrollHeight); return documentHeight > window.innerHeight + 100; // 100px buffer for safe measure - }; + }, []); // Auto-fetch more content if page isn't scrollable and more data is available const checkAndFetchIfNeeded = useCallback(async () => { @@ -97,26 +103,43 @@ const PagedMemoList = observer((props: Props) => { checkAndFetchIfNeeded(); }, 500); } - }, [nextPageToken, isRequesting, sortedMemoList.length]); + }, [nextPageToken, isRequesting, sortedMemoList.length, isPageScrollable, fetchMoreMemos]); // Refresh the entire memo list from the beginning - const refreshList = async () => { + const refreshList = useCallback(async () => { memoStore.state.updateStateId(); setNextPageToken(""); await fetchMoreMemos(""); - }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fetchMoreMemos]); + + // Track previous props to detect changes + const propsKey = `${props.state}-${props.orderBy}-${props.filter}-${props.pageSize}`; + const prevPropsKeyRef = useRef(); // Initial load and reload when props change useEffect(() => { - refreshList(); - }, [props.state, props.orderBy, props.filter, props.pageSize]); + const propsChanged = prevPropsKeyRef.current !== undefined && prevPropsKeyRef.current !== propsKey; + prevPropsKeyRef.current = propsKey; + + // Skip first render if we haven't marked it yet + if (!initialFetchTriggeredRef.current) { + initialFetchTriggeredRef.current = true; + refreshList(); + return; + } + // For subsequent changes, refresh if props actually changed + if (propsChanged) { + refreshList(); + } + }, [refreshList, propsKey]); // Auto-fetch more content when list changes and page isn't full useEffect(() => { if (!isRequesting && sortedMemoList.length > 0) { checkAndFetchIfNeeded(); } - }, [sortedMemoList.length, isRequesting, nextPageToken, checkAndFetchIfNeeded]); + }, [sortedMemoList.length, isRequesting, checkAndFetchIfNeeded]); // Cleanup timeout on component unmount useEffect(() => { @@ -140,7 +163,7 @@ const PagedMemoList = observer((props: Props) => { window.addEventListener("scroll", handleScroll); return () => window.removeEventListener("scroll", handleScroll); - }, [nextPageToken, isRequesting]); + }, [nextPageToken, isRequesting, fetchMoreMemos]); const children = (
diff --git a/web/src/hooks/useFilteredMemoStats.ts b/web/src/hooks/useFilteredMemoStats.ts index 303d9a617..99a553646 100644 --- a/web/src/hooks/useFilteredMemoStats.ts +++ b/web/src/hooks/useFilteredMemoStats.ts @@ -1,9 +1,7 @@ import dayjs from "dayjs"; import { countBy } from "lodash-es"; import { useEffect, useState } from "react"; -import { memoServiceClient } from "@/grpcweb"; import { memoStore } from "@/store"; -import { State } from "@/types/proto/api/v1/common"; import type { StatisticsData } from "@/types/statistics"; export interface FilteredMemoStats { @@ -13,104 +11,66 @@ export interface FilteredMemoStats { } /** - * Hook to fetch and compute statistics and tags from memos matching a filter. + * Hook to compute statistics and tags from memos in the store cache. * * This provides a unified approach for all pages (Home, Explore, Archived, Profile): - * - Uses the same filter as PagedMemoList for consistency - * - Fetches all memos matching the filter once - * - Computes statistics and tags from those memos - * - Stats/tags remain static and don't change when user applies additional filters + * - Uses memos already loaded in the store by PagedMemoList + * - Computes statistics and tags from those cached memos + * - Updates automatically when memos are created, updated, or deleted + * - No separate API call needed, reducing network overhead * - * @param filter - CEL filter expression (same as used for memo list) - * @param state - Memo state (NORMAL for most pages, ARCHIVED for archived page) - * @param orderBy - Optional sort order (not used for stats, but ensures consistency) * @returns Object with statistics data, tag counts, and loading state * - * @example Home page - * const { statistics, tags } = useFilteredMemoStats( - * `creator_id == ${currentUserId}`, - * State.NORMAL - * ); - * - * @example Explore page - * const { statistics, tags } = useFilteredMemoStats( - * `visibility in ["PUBLIC", "PROTECTED"]`, - * State.NORMAL - * ); - * - * @example Archived page - * const { statistics, tags } = useFilteredMemoStats( - * `creator_id == ${currentUserId}`, - * State.ARCHIVED - * ); + * Note: This hook now computes stats from the memo store cache rather than + * making a separate API call. It relies on PagedMemoList to populate the store. */ -export const useFilteredMemoStats = (filter?: string, state: State = State.NORMAL, orderBy?: string): FilteredMemoStats => { +export const useFilteredMemoStats = (): FilteredMemoStats => { const [data, setData] = useState({ statistics: { activityStats: {}, }, tags: {}, - loading: true, + loading: false, }); // React to memo store changes (create, update, delete) const memoStoreStateId = memoStore.state.stateId; useEffect(() => { - const fetchMemosAndComputeStats = async () => { - setData((prev) => ({ ...prev, loading: true })); + // Compute statistics and tags from memos already in the store + // This avoids making a separate API call and relies on PagedMemoList to populate the store + const computeStatsFromCache = () => { + const displayTimeList: Date[] = []; + const tagCount: Record = {}; - try { - // Fetch all memos matching the filter - // Use large page size to ensure we get all memos for accurate stats - const response = await memoServiceClient.listMemos({ - state, - filter, - orderBy, - pageSize: 10000, // Large enough to get all memos - }); + // Use memos already loaded in the store + const memos = memoStore.state.memos; - // Compute statistics and tags from fetched memos - const displayTimeList: Date[] = []; - const tagCount: Record = {}; - - if (response.memos) { - for (const memo of response.memos) { - // Add display time for calendar - if (memo.displayTime) { - displayTimeList.push(memo.displayTime); - } - - // Count tags - if (memo.tags && memo.tags.length > 0) { - for (const tag of memo.tags) { - tagCount[tag] = (tagCount[tag] || 0) + 1; - } - } - } + for (const memo of memos) { + // Add display time for calendar + if (memo.displayTime) { + displayTimeList.push(memo.displayTime); } - // Compute activity calendar data - const activityStats = countBy(displayTimeList.map((date) => dayjs(date).format("YYYY-MM-DD"))); - - setData({ - statistics: { activityStats }, - tags: tagCount, - loading: false, - }); - } catch (error) { - console.error("Failed to fetch memos for statistics:", error); - setData({ - statistics: { - activityStats: {}, - }, - tags: {}, - loading: false, - }); + // Count tags + if (memo.tags && memo.tags.length > 0) { + for (const tag of memo.tags) { + tagCount[tag] = (tagCount[tag] || 0) + 1; + } + } } + + // Compute activity calendar data + const activityStats = countBy(displayTimeList.map((date) => dayjs(date).format("YYYY-MM-DD"))); + + setData({ + statistics: { activityStats }, + tags: tagCount, + loading: false, + }); }; - fetchMemosAndComputeStats(); - }, [filter, state, orderBy, memoStoreStateId]); + computeStatsFromCache(); + }, [memoStoreStateId]); return data; }; diff --git a/web/src/layouts/MainLayout.tsx b/web/src/layouts/MainLayout.tsx index 783591d4c..d8d8dc0c5 100644 --- a/web/src/layouts/MainLayout.tsx +++ b/web/src/layouts/MainLayout.tsx @@ -1,23 +1,17 @@ -import { last } from "lodash-es"; import { observer } from "mobx-react-lite"; import { useMemo } from "react"; import { matchPath, Outlet, useLocation } from "react-router-dom"; -import { MemoExplorer, MemoExplorerContext, MemoExplorerDrawer } from "@/components/MemoExplorer"; +import type { MemoExplorerContext } from "@/components/MemoExplorer"; +import { MemoExplorer, MemoExplorerDrawer } from "@/components/MemoExplorer"; import MobileHeader from "@/components/MobileHeader"; -import useCurrentUser from "@/hooks/useCurrentUser"; import { useFilteredMemoStats } from "@/hooks/useFilteredMemoStats"; import useResponsiveWidth from "@/hooks/useResponsiveWidth"; import { cn } from "@/lib/utils"; import { Routes } from "@/router"; -import { userStore } from "@/store"; -import { extractUserIdFromName } from "@/store/common"; -import { State } from "@/types/proto/api/v1/common"; -import { Visibility } from "@/types/proto/api/v1/memo_service"; const MainLayout = observer(() => { const { md, lg } = useResponsiveWidth(); const location = useLocation(); - const currentUser = useCurrentUser(); // Determine context based on current route const context: MemoExplorerContext = useMemo(() => { @@ -28,43 +22,8 @@ const MainLayout = observer(() => { return "home"; // fallback }, [location.pathname]); - // Compute filter and state based on context - // This should match what each page uses for their memo list - const { filter, state } = useMemo(() => { - if (location.pathname === Routes.ROOT && currentUser) { - // Home: current user's normal memos - return { - filter: `creator_id == ${extractUserIdFromName(currentUser.name)}`, - state: State.NORMAL, - }; - } else if (location.pathname === Routes.EXPLORE) { - // Explore: visible memos (PUBLIC for visitors, PUBLIC+PROTECTED for logged-in) - const visibilities = currentUser ? [Visibility.PUBLIC, Visibility.PROTECTED] : [Visibility.PUBLIC]; - const visibilityValues = visibilities.map((v) => `"${v}"`).join(", "); - return { - filter: `visibility in [${visibilityValues}]`, - state: State.NORMAL, - }; - } else if (matchPath("/archived", location.pathname) && currentUser) { - // Archived: current user's archived memos - return { - filter: `creator_id == ${extractUserIdFromName(currentUser.name)}`, - state: State.ARCHIVED, - }; - } else if (matchPath("/u/:username", location.pathname)) { - // Profile: specific user's normal memos - const username = last(location.pathname.split("/")); - const user = userStore.getUserByName(`users/${username}`); - return { - filter: user ? `creator_id == ${extractUserIdFromName(user.name)}` : undefined, - state: State.NORMAL, - }; - } - return { filter: undefined, state: State.NORMAL }; - }, [location.pathname, currentUser]); - - // Fetch stats using the same filter as the memo list - const { statistics, tags } = useFilteredMemoStats(filter, state); + // Fetch stats from memo store cache (populated by PagedMemoList) + const { statistics, tags } = useFilteredMemoStats(); return (