feat(web): improve loading performance with skeleton screens and parallel fetches

- Add MemoSkeleton component for smooth initial page load experience
- Integrate skeleton loader into PagedMemoList during initial fetch
- Parallelize user settings and shortcuts API calls (~50% faster session init)
- Batch-fetch memo creators in parallel to eliminate individual loading spinners
- Pass showCreator prop to Explore page for proper skeleton rendering

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Steven 2025-10-28 23:53:35 +08:00
parent 2371bbb1b7
commit d794c0bf8b
4 changed files with 106 additions and 27 deletions

View File

@ -0,0 +1,56 @@
import { cn } from "@/lib/utils";
interface Props {
showCreator?: boolean;
count?: number;
}
const MemoSkeleton = ({ showCreator = false, count = 6 }: Props) => {
return (
<>
{Array.from({ length: count }).map((_, index) => (
<div
key={index}
className="relative flex flex-col justify-start items-start bg-card w-full px-4 py-3 mb-2 gap-2 rounded-lg border border-border animate-pulse"
>
{/* Header section */}
<div className="w-full flex flex-row justify-between items-center gap-2">
<div className="w-auto max-w-[calc(100%-8rem)] grow flex flex-row justify-start items-center">
{showCreator ? (
<div className="w-full flex flex-row justify-start items-center gap-2">
{/* Avatar skeleton */}
<div className="w-8 h-8 rounded-full bg-muted shrink-0" />
<div className="w-full flex flex-col justify-center items-start gap-1">
{/* Creator name skeleton */}
<div className="h-4 w-24 bg-muted rounded" />
{/* Timestamp skeleton */}
<div className="h-3 w-16 bg-muted rounded" />
</div>
</div>
) : (
<div className="h-4 w-32 bg-muted rounded" />
)}
</div>
{/* Action buttons skeleton */}
<div className="flex flex-row gap-2">
<div className="w-4 h-4 bg-muted rounded" />
<div className="w-4 h-4 bg-muted rounded" />
</div>
</div>
{/* Content section */}
<div className="w-full flex flex-col gap-2">
{/* Text content skeleton - varied heights for realism */}
<div className="space-y-2">
<div className={cn("h-4 bg-muted rounded", index % 3 === 0 ? "w-full" : index % 3 === 1 ? "w-4/5" : "w-5/6")} />
<div className={cn("h-4 bg-muted rounded", index % 2 === 0 ? "w-3/4" : "w-4/5")} />
{index % 2 === 0 && <div className="h-4 w-2/3 bg-muted rounded" />}
</div>
</div>
</div>
))}
</>
);
};
export default MemoSkeleton;

View File

@ -7,13 +7,14 @@ import { Button } from "@/components/ui/button";
import { DEFAULT_LIST_MEMOS_PAGE_SIZE } from "@/helpers/consts";
import useResponsiveWidth from "@/hooks/useResponsiveWidth";
import { Routes } from "@/router";
import { memoStore, viewStore } from "@/store";
import { memoStore, userStore, viewStore } from "@/store";
import { State } from "@/types/proto/api/v1/common";
import { Memo } from "@/types/proto/api/v1/memo_service";
import { useTranslate } from "@/utils/i18n";
import Empty from "../Empty";
import MasonryView, { MemoRenderContext } from "../MasonryView";
import MemoEditor from "../MemoEditor";
import MemoSkeleton from "../MemoSkeleton";
interface Props {
renderer: (memo: Memo, context?: MemoRenderContext) => JSX.Element;
@ -22,6 +23,7 @@ interface Props {
orderBy?: string;
filter?: string;
pageSize?: number;
showCreator?: boolean;
}
const PagedMemoList = observer((props: Props) => {
@ -55,6 +57,13 @@ const PagedMemoList = observer((props: Props) => {
});
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)));
}
} finally {
setIsRequesting(false);
}
@ -134,33 +143,43 @@ const PagedMemoList = observer((props: Props) => {
const children = (
<div className="flex flex-col justify-start items-start w-full max-w-full">
<MasonryView
memoList={sortedMemoList}
renderer={props.renderer}
prefixElement={showMemoEditor ? <MemoEditor className="mb-2" cacheKey="home-memo-editor" /> : undefined}
listMode={viewStore.state.layout === "LIST"}
/>
{/* Loading indicator */}
{isRequesting && (
<div className="w-full flex flex-row justify-center items-center my-4">
<LoaderIcon className="animate-spin text-muted-foreground" />
</div>
)}
{/* Empty state or back-to-top button */}
{!isRequesting && (
{/* Show skeleton loader during initial load */}
{isRequesting && sortedMemoList.length === 0 ? (
<>
{!nextPageToken && sortedMemoList.length === 0 ? (
<div className="w-full mt-12 mb-8 flex flex-col justify-center items-center italic">
<Empty />
<p className="mt-2 text-muted-foreground">{t("message.no-data")}</p>
</div>
) : (
<div className="w-full opacity-70 flex flex-row justify-center items-center my-4">
<BackToTop />
{showMemoEditor && <MemoEditor className="mb-2" cacheKey="home-memo-editor" />}
<MemoSkeleton showCreator={props.showCreator} count={6} />
</>
) : (
<>
<MasonryView
memoList={sortedMemoList}
renderer={props.renderer}
prefixElement={showMemoEditor ? <MemoEditor className="mb-2" cacheKey="home-memo-editor" /> : undefined}
listMode={viewStore.state.layout === "LIST"}
/>
{/* Loading indicator for pagination */}
{isRequesting && (
<div className="w-full flex flex-row justify-center items-center my-4">
<LoaderIcon className="animate-spin text-muted-foreground" />
</div>
)}
{/* Empty state or back-to-top button */}
{!isRequesting && (
<>
{!nextPageToken && sortedMemoList.length === 0 ? (
<div className="w-full mt-12 mb-8 flex flex-col justify-center items-center italic">
<Empty />
<p className="mt-2 text-muted-foreground">{t("message.no-data")}</p>
</div>
) : (
<div className="w-full opacity-70 flex flex-row justify-center items-center my-4">
<BackToTop />
</div>
)}
</>
)}
</>
)}
</div>

View File

@ -30,6 +30,7 @@ const Explore = observer(() => {
)
}
orderBy={viewStore.state.orderByTimeAsc ? "display_time asc" : "display_time desc"}
showCreator
/>
</div>
</section>

View File

@ -194,8 +194,11 @@ const userStore = (() => {
return;
}
const { settings } = await userServiceClient.listUserSettings({ parent: state.currentUser });
const { shortcuts } = await shortcutServiceClient.listShortcuts({ parent: state.currentUser });
// Fetch settings and shortcuts in parallel for better performance
const [{ settings }, { shortcuts }] = await Promise.all([
userServiceClient.listUserSettings({ parent: state.currentUser }),
shortcutServiceClient.listShortcuts({ parent: state.currentUser }),
]);
// Extract and store each setting type
const generalSetting = settings.find((s) => s.generalSetting)?.generalSetting;