mirror of https://github.com/usememos/memos.git
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:
parent
2371bbb1b7
commit
d794c0bf8b
|
|
@ -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;
|
||||||
|
|
@ -7,13 +7,14 @@ import { Button } from "@/components/ui/button";
|
||||||
import { DEFAULT_LIST_MEMOS_PAGE_SIZE } from "@/helpers/consts";
|
import { DEFAULT_LIST_MEMOS_PAGE_SIZE } from "@/helpers/consts";
|
||||||
import useResponsiveWidth from "@/hooks/useResponsiveWidth";
|
import useResponsiveWidth from "@/hooks/useResponsiveWidth";
|
||||||
import { Routes } from "@/router";
|
import { Routes } from "@/router";
|
||||||
import { memoStore, 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 { 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 MasonryView, { MemoRenderContext } from "../MasonryView";
|
||||||
import MemoEditor from "../MemoEditor";
|
import MemoEditor from "../MemoEditor";
|
||||||
|
import MemoSkeleton from "../MemoSkeleton";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
renderer: (memo: Memo, context?: MemoRenderContext) => JSX.Element;
|
renderer: (memo: Memo, context?: MemoRenderContext) => JSX.Element;
|
||||||
|
|
@ -22,6 +23,7 @@ interface Props {
|
||||||
orderBy?: string;
|
orderBy?: string;
|
||||||
filter?: string;
|
filter?: string;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
|
showCreator?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PagedMemoList = observer((props: Props) => {
|
const PagedMemoList = observer((props: Props) => {
|
||||||
|
|
@ -55,6 +57,13 @@ const PagedMemoList = observer((props: Props) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
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)));
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsRequesting(false);
|
setIsRequesting(false);
|
||||||
}
|
}
|
||||||
|
|
@ -134,6 +143,14 @@ const PagedMemoList = observer((props: Props) => {
|
||||||
|
|
||||||
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">
|
||||||
|
{/* Show skeleton loader during initial load */}
|
||||||
|
{isRequesting && sortedMemoList.length === 0 ? (
|
||||||
|
<>
|
||||||
|
{showMemoEditor && <MemoEditor className="mb-2" cacheKey="home-memo-editor" />}
|
||||||
|
<MemoSkeleton showCreator={props.showCreator} count={6} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<MasonryView
|
<MasonryView
|
||||||
memoList={sortedMemoList}
|
memoList={sortedMemoList}
|
||||||
renderer={props.renderer}
|
renderer={props.renderer}
|
||||||
|
|
@ -141,7 +158,7 @@ const PagedMemoList = observer((props: Props) => {
|
||||||
listMode={viewStore.state.layout === "LIST"}
|
listMode={viewStore.state.layout === "LIST"}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Loading indicator */}
|
{/* Loading indicator for pagination */}
|
||||||
{isRequesting && (
|
{isRequesting && (
|
||||||
<div className="w-full flex flex-row justify-center items-center my-4">
|
<div className="w-full flex flex-row justify-center items-center my-4">
|
||||||
<LoaderIcon className="animate-spin text-muted-foreground" />
|
<LoaderIcon className="animate-spin text-muted-foreground" />
|
||||||
|
|
@ -163,6 +180,8 @@ const PagedMemoList = observer((props: Props) => {
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ const Explore = observer(() => {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
orderBy={viewStore.state.orderByTimeAsc ? "display_time asc" : "display_time desc"}
|
orderBy={viewStore.state.orderByTimeAsc ? "display_time asc" : "display_time desc"}
|
||||||
|
showCreator
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -194,8 +194,11 @@ const userStore = (() => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { settings } = await userServiceClient.listUserSettings({ parent: state.currentUser });
|
// Fetch settings and shortcuts in parallel for better performance
|
||||||
const { shortcuts } = await shortcutServiceClient.listShortcuts({ parent: state.currentUser });
|
const [{ settings }, { shortcuts }] = await Promise.all([
|
||||||
|
userServiceClient.listUserSettings({ parent: state.currentUser }),
|
||||||
|
shortcutServiceClient.listShortcuts({ parent: state.currentUser }),
|
||||||
|
]);
|
||||||
|
|
||||||
// Extract and store each setting type
|
// Extract and store each setting type
|
||||||
const generalSetting = settings.find((s) => s.generalSetting)?.generalSetting;
|
const generalSetting = settings.find((s) => s.generalSetting)?.generalSetting;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue