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 { 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<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
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<string>();
// 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 = (
<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 { 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<FilteredMemoStats>({
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<string, number> = {};
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<string, number> = {};
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;
};

View File

@ -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 (
<section className="@container w-full min-h-full flex flex-col justify-start items-center">