refactor(web): optimize memo statistics fetching by using cached data from memo store

This commit is contained in:
Steven 2025-11-24 21:37:12 +08:00
parent 72f93c5379
commit 424e599980
3 changed files with 95 additions and 153 deletions

View File

@ -9,10 +9,11 @@ import useResponsiveWidth from "@/hooks/useResponsiveWidth";
import { Routes } from "@/router"; import { Routes } from "@/router";
import { memoStore, userStore, viewStore } from "@/store"; import { memoStore, userStore, viewStore } from "@/store";
import { State } from "@/types/proto/api/v1/common"; 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 { useTranslate } from "@/utils/i18n";
import Empty from "../Empty"; import Empty from "../Empty";
import MasonryView, { MemoRenderContext } from "../MasonryView"; import type { MemoRenderContext } from "../MasonryView";
import MasonryView from "../MasonryView";
import MemoEditor from "../MemoEditor"; import MemoEditor from "../MemoEditor";
import MemoFilters from "../MemoFilters"; import MemoFilters from "../MemoFilters";
import MemoSkeleton from "../MemoSkeleton"; import MemoSkeleton from "../MemoSkeleton";
@ -37,6 +38,8 @@ const PagedMemoList = observer((props: Props) => {
// Ref to manage auto-fetch timeout to prevent memory leaks // Ref to manage auto-fetch timeout to prevent memory leaks
const autoFetchTimeoutRef = useRef<number | null>(null); const autoFetchTimeoutRef = useRef<number | null>(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 // Apply custom sorting if provided, otherwise use store memos directly
const sortedMemoList = props.listSort ? props.listSort(memoStore.state.memos) : memoStore.state.memos; const sortedMemoList = props.listSort ? props.listSort(memoStore.state.memos) : memoStore.state.memos;
@ -45,7 +48,8 @@ const PagedMemoList = observer((props: Props) => {
const showMemoEditor = Boolean(matchPath(Routes.ROOT, window.location.pathname)); const showMemoEditor = Boolean(matchPath(Routes.ROOT, window.location.pathname));
// Fetch more memos with pagination support // Fetch more memos with pagination support
const fetchMoreMemos = async (pageToken: string) => { const fetchMoreMemos = useCallback(
async (pageToken: string) => {
setIsRequesting(true); setIsRequesting(true);
try { try {
@ -68,13 +72,15 @@ const PagedMemoList = observer((props: Props) => {
} finally { } finally {
setIsRequesting(false); setIsRequesting(false);
} }
}; },
[props.state, props.orderBy, props.filter, props.pageSize, props.showCreator],
);
// Helper function to check if page has enough content to be scrollable // 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); const documentHeight = Math.max(document.body.scrollHeight, document.documentElement.scrollHeight);
return documentHeight > window.innerHeight + 100; // 100px buffer for safe measure return documentHeight > window.innerHeight + 100; // 100px buffer for safe measure
}; }, []);
// Auto-fetch more content if page isn't scrollable and more data is available // Auto-fetch more content if page isn't scrollable and more data is available
const checkAndFetchIfNeeded = useCallback(async () => { const checkAndFetchIfNeeded = useCallback(async () => {
@ -97,26 +103,43 @@ const PagedMemoList = observer((props: Props) => {
checkAndFetchIfNeeded(); checkAndFetchIfNeeded();
}, 500); }, 500);
} }
}, [nextPageToken, isRequesting, sortedMemoList.length]); }, [nextPageToken, isRequesting, sortedMemoList.length, isPageScrollable, fetchMoreMemos]);
// Refresh the entire memo list from the beginning // Refresh the entire memo list from the beginning
const refreshList = async () => { const refreshList = useCallback(async () => {
memoStore.state.updateStateId(); memoStore.state.updateStateId();
setNextPageToken(""); setNextPageToken("");
await fetchMoreMemos(""); 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<string>();
// Initial load and reload when props change // Initial load and reload when props change
useEffect(() => { useEffect(() => {
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(); refreshList();
}, [props.state, props.orderBy, props.filter, props.pageSize]); 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 // Auto-fetch more content when list changes and page isn't full
useEffect(() => { useEffect(() => {
if (!isRequesting && sortedMemoList.length > 0) { if (!isRequesting && sortedMemoList.length > 0) {
checkAndFetchIfNeeded(); checkAndFetchIfNeeded();
} }
}, [sortedMemoList.length, isRequesting, nextPageToken, checkAndFetchIfNeeded]); }, [sortedMemoList.length, isRequesting, checkAndFetchIfNeeded]);
// Cleanup timeout on component unmount // Cleanup timeout on component unmount
useEffect(() => { useEffect(() => {
@ -140,7 +163,7 @@ const PagedMemoList = observer((props: Props) => {
window.addEventListener("scroll", handleScroll); window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll); return () => window.removeEventListener("scroll", handleScroll);
}, [nextPageToken, isRequesting]); }, [nextPageToken, isRequesting, fetchMoreMemos]);
const children = ( const children = (
<div className="flex flex-col justify-start items-start w-full max-w-full"> <div className="flex flex-col justify-start items-start w-full max-w-full">

View File

@ -1,9 +1,7 @@
import dayjs from "dayjs"; import dayjs from "dayjs";
import { countBy } from "lodash-es"; import { countBy } from "lodash-es";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { memoServiceClient } from "@/grpcweb";
import { memoStore } from "@/store"; import { memoStore } from "@/store";
import { State } from "@/types/proto/api/v1/common";
import type { StatisticsData } from "@/types/statistics"; import type { StatisticsData } from "@/types/statistics";
export interface FilteredMemoStats { export interface FilteredMemoStats {
@ -13,68 +11,41 @@ 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): * This provides a unified approach for all pages (Home, Explore, Archived, Profile):
* - Uses the same filter as PagedMemoList for consistency * - Uses memos already loaded in the store by PagedMemoList
* - Fetches all memos matching the filter once * - Computes statistics and tags from those cached memos
* - Computes statistics and tags from those memos * - Updates automatically when memos are created, updated, or deleted
* - Stats/tags remain static and don't change when user applies additional filters * - 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 * @returns Object with statistics data, tag counts, and loading state
* *
* @example Home page * Note: This hook now computes stats from the memo store cache rather than
* const { statistics, tags } = useFilteredMemoStats( * making a separate API call. It relies on PagedMemoList to populate the store.
* `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
* );
*/ */
export const useFilteredMemoStats = (filter?: string, state: State = State.NORMAL, orderBy?: string): FilteredMemoStats => { export const useFilteredMemoStats = (): FilteredMemoStats => {
const [data, setData] = useState<FilteredMemoStats>({ const [data, setData] = useState<FilteredMemoStats>({
statistics: { statistics: {
activityStats: {}, activityStats: {},
}, },
tags: {}, tags: {},
loading: true, loading: false,
}); });
// React to memo store changes (create, update, delete) // React to memo store changes (create, update, delete)
const memoStoreStateId = memoStore.state.stateId; const memoStoreStateId = memoStore.state.stateId;
useEffect(() => { useEffect(() => {
const fetchMemosAndComputeStats = async () => { // Compute statistics and tags from memos already in the store
setData((prev) => ({ ...prev, loading: true })); // This avoids making a separate API call and relies on PagedMemoList to populate the store
const computeStatsFromCache = () => {
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
});
// Compute statistics and tags from fetched memos
const displayTimeList: Date[] = []; const displayTimeList: Date[] = [];
const tagCount: Record<string, number> = {}; const tagCount: Record<string, number> = {};
if (response.memos) { // Use memos already loaded in the store
for (const memo of response.memos) { const memos = memoStore.state.memos;
for (const memo of memos) {
// Add display time for calendar // Add display time for calendar
if (memo.displayTime) { if (memo.displayTime) {
displayTimeList.push(memo.displayTime); displayTimeList.push(memo.displayTime);
@ -87,7 +58,6 @@ export const useFilteredMemoStats = (filter?: string, state: State = State.NORMA
} }
} }
} }
}
// Compute activity calendar data // Compute activity calendar data
const activityStats = countBy(displayTimeList.map((date) => dayjs(date).format("YYYY-MM-DD"))); const activityStats = countBy(displayTimeList.map((date) => dayjs(date).format("YYYY-MM-DD")));
@ -97,20 +67,10 @@ export const useFilteredMemoStats = (filter?: string, state: State = State.NORMA
tags: tagCount, tags: tagCount,
loading: false, loading: false,
}); });
} catch (error) {
console.error("Failed to fetch memos for statistics:", error);
setData({
statistics: {
activityStats: {},
},
tags: {},
loading: false,
});
}
}; };
fetchMemosAndComputeStats(); computeStatsFromCache();
}, [filter, state, orderBy, memoStoreStateId]); }, [memoStoreStateId]);
return data; return data;
}; };

View File

@ -1,23 +1,17 @@
import { last } from "lodash-es";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { useMemo } from "react"; import { useMemo } from "react";
import { matchPath, Outlet, useLocation } from "react-router-dom"; 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 MobileHeader from "@/components/MobileHeader";
import useCurrentUser from "@/hooks/useCurrentUser";
import { useFilteredMemoStats } from "@/hooks/useFilteredMemoStats"; import { useFilteredMemoStats } from "@/hooks/useFilteredMemoStats";
import useResponsiveWidth from "@/hooks/useResponsiveWidth"; import useResponsiveWidth from "@/hooks/useResponsiveWidth";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Routes } from "@/router"; 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 MainLayout = observer(() => {
const { md, lg } = useResponsiveWidth(); const { md, lg } = useResponsiveWidth();
const location = useLocation(); const location = useLocation();
const currentUser = useCurrentUser();
// Determine context based on current route // Determine context based on current route
const context: MemoExplorerContext = useMemo(() => { const context: MemoExplorerContext = useMemo(() => {
@ -28,43 +22,8 @@ const MainLayout = observer(() => {
return "home"; // fallback return "home"; // fallback
}, [location.pathname]); }, [location.pathname]);
// Compute filter and state based on context // Fetch stats from memo store cache (populated by PagedMemoList)
// This should match what each page uses for their memo list const { statistics, tags } = useFilteredMemoStats();
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);
return ( return (
<section className="@container w-full min-h-full flex flex-col justify-start items-center"> <section className="@container w-full min-h-full flex flex-col justify-start items-center">