chore(web): replace memo list skeleton with a simple delayed spinner

The card-shaped skeleton never matched the loaded memos (heights vary
with markdown, images, and tags), making the swap feel jarring. Use the
same centered spinner the grid layout already used, still gated behind
the 250ms delay so fast loads render no indicator at all.

Ref https://github.com/orgs/usememos/discussions/6066
pull/6080/head
boojack 5 days ago
parent 41ff22b0cc
commit dc41480ece

@ -8,7 +8,7 @@ import { userServiceClient } from "@/connect";
import { useMemoFilterContext } from "@/contexts/MemoFilterContext";
import { useNewMemo } from "@/contexts/NewMemoContext";
import { useView } from "@/contexts/ViewContext";
import { DEFAULT_LIST_MEMOS_PAGE_SIZE, SKELETON_LOADING_DELAY_MS } from "@/helpers/consts";
import { DEFAULT_LIST_MEMOS_PAGE_SIZE, LOADING_INDICATOR_DELAY_MS } from "@/helpers/consts";
import { useDelayedFlag } from "@/hooks/useDelayedFlag";
import { useInfiniteMemos } from "@/hooks/useMemoQueries";
import { hoistMemoToFront } from "@/hooks/useMemoSorting";
@ -21,7 +21,6 @@ import ColumnGrid, { columnCountForWidth, GRID_GAP } from "../ColumnGrid";
import MemoEditor from "../MemoEditor";
import MemoFilters from "../MemoFilters";
import Placeholder from "../Placeholder";
import Skeleton from "../Skeleton";
// Memo identity for React keys and the grid's sticky column assignments. The pages use it
// for their renderer keys too, so flow-list and grid identity can never drift apart.
@ -31,8 +30,7 @@ export const getMemoKey = (memo: Memo) => `${memo.name}-${memo.updateTime}`;
// grid centers in the leftover space instead of filling it.
const MAX_COLUMN_WIDTH = 420;
// The grid packs cards into columns, so a card-shaped skeleton doesn't fit; use a spinner.
const GridLoader = () => (
const Loader = () => (
<div className="w-full flex flex-row justify-center items-center py-8">
<LoaderCircleIcon className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
@ -146,8 +144,8 @@ const PagedMemoList = (props: Props) => {
{ enabled: props.enabled ?? true },
);
// Only show the skeleton once loading exceeds the delay, so fast loads don't flash it.
const showSkeleton = useDelayedFlag(isLoading, SKELETON_LOADING_DELAY_MS);
// Only show the spinner once loading exceeds the delay, so fast loads don't flash it.
const showLoader = useDelayedFlag(isLoading, LOADING_INDICATOR_DELAY_MS);
// Flatten pages into a single array of memos
const memos = useMemo(() => data?.pages.flatMap((page) => page.memos) || [], [data]);
@ -234,10 +232,10 @@ const PagedMemoList = (props: Props) => {
</div>
) : undefined;
// Pagination skeleton, empty state, and back-to-top are identical across both layouts.
// Pagination spinner, empty state, and back-to-top are identical across both layouts.
const footer = (
<>
{isFetchingNextPage && (useGrid ? <GridLoader /> : <Skeleton showCreator={props.showCreator} count={2} />)}
{isFetchingNextPage && <Loader />}
{!isFetchingNextPage && !hasNextPage && sortedMemoList.length === 0 && !memoEditor && (
<Placeholder variant="empty" message={t("message.no-data")} />
)}
@ -253,14 +251,10 @@ const PagedMemoList = (props: Props) => {
<MentionResolutionProvider contents={contents}>
<div ref={layoutMeasureRef} className="w-full">
<div className={cn("flex flex-col justify-start w-full mx-auto", useGrid ? "max-w-none" : "max-w-2xl")}>
{/* During initial load, show the skeleton only after the delay; render nothing before then to avoid a flash. */}
{/* During initial load, show the spinner only after the delay; render nothing before then to avoid a flash. */}
{isLoading ? (
showSkeleton ? (
useGrid ? (
<GridLoader />
) : (
<Skeleton showCreator={props.showCreator} count={4} />
)
showLoader ? (
<Loader />
) : null
) : useGrid ? (
<>

@ -1,51 +0,0 @@
import { cn } from "@/lib/utils";
interface SkeletonProps {
showCreator?: boolean;
count?: number;
}
const skeletonBase = "bg-muted/70 rounded animate-pulse";
const MemoCardSkeleton = ({ showCreator, index }: { showCreator?: boolean; index: number }) => (
<div className="relative flex flex-col bg-card w-full px-4 py-3 mb-2 gap-2 rounded-lg border border-border">
<div className="w-full flex justify-between items-center gap-2">
<div className="grow flex items-center max-w-[calc(100%-8rem)]">
{showCreator ? (
<div className="w-full flex items-center gap-2">
<div className={cn("w-8 h-8 rounded-full shrink-0", skeletonBase)} />
<div className="flex flex-col gap-1">
<div className={cn("h-4 w-24", skeletonBase)} />
<div className={cn("h-3 w-16", skeletonBase)} />
</div>
</div>
) : (
<div className={cn("h-4 w-32", skeletonBase)} />
)}
</div>
<div className="flex gap-2">
<div className={cn("w-4 h-4", skeletonBase)} />
<div className={cn("w-4 h-4", skeletonBase)} />
</div>
</div>
<div className="space-y-2">
<div className={cn("h-4", skeletonBase, index % 3 === 0 ? "w-full" : index % 3 === 1 ? "w-4/5" : "w-5/6")} />
<div className={cn("h-4", skeletonBase, index % 2 === 0 ? "w-3/4" : "w-4/5")} />
{index % 2 === 0 && <div className={cn("h-4 w-2/3", skeletonBase)} />}
</div>
</div>
);
/**
* Memo list loading skeleton - shows card structure while loading.
* Only use for memo lists in PagedMemoList component.
*/
const Skeleton = ({ showCreator = false, count = 4 }: SkeletonProps) => (
<div className="w-full max-w-2xl mx-auto">
{Array.from({ length: count }, (_, i) => (
<MemoCardSkeleton key={i} showCreator={showCreator} index={i} />
))}
</div>
);
export default Skeleton;

@ -4,6 +4,6 @@ export const TAB_SPACE_WIDTH = 2;
// DEFAULT_LIST_MEMOS_PAGE_SIZE is the default page size for list memos request.
export const DEFAULT_LIST_MEMOS_PAGE_SIZE = 16;
// SKELETON_LOADING_DELAY_MS is how long a load must take before the loading skeleton appears.
// Loads that finish faster than this never render the skeleton, avoiding a flash on fast/self-hosted networks.
export const SKELETON_LOADING_DELAY_MS = 250;
// LOADING_INDICATOR_DELAY_MS is how long a load must take before the loading spinner appears.
// Loads that finish faster than this never render the spinner, avoiding a flash on fast/self-hosted networks.
export const LOADING_INDICATOR_DELAY_MS = 250;

@ -4,7 +4,7 @@ import { useEffect, useState } from "react";
* Returns a flag that turns true only after `active` has stayed true for `delay` ms,
* and turns false immediately once `active` becomes false.
*
* Useful for delaying loading indicators (e.g. skeletons) so they don't flash on fast operations.
* Useful for delaying loading indicators (e.g. spinners) so they don't flash on fast operations.
*/
export const useDelayedFlag = (active: boolean, delay: number): boolean => {
const [delayed, setDelayed] = useState(false);

Loading…
Cancel
Save