mirror of https://github.com/usememos/memos.git
refactor(web): optimize memo statistics fetching by using cached data from memo store
This commit is contained in:
parent
72f93c5379
commit
424e599980
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue