diff --git a/web/src/components/PagedMemoList/PagedMemoList.tsx b/web/src/components/PagedMemoList/PagedMemoList.tsx index 21bacc2f..68786ce4 100644 --- a/web/src/components/PagedMemoList/PagedMemoList.tsx +++ b/web/src/components/PagedMemoList/PagedMemoList.tsx @@ -7,7 +7,8 @@ import { Button } from "@/components/ui/button"; import { userServiceClient } from "@/connect"; import { useMemoFilterContext } from "@/contexts/MemoFilterContext"; import { useNewMemo } from "@/contexts/NewMemoContext"; -import { DEFAULT_LIST_MEMOS_PAGE_SIZE } from "@/helpers/consts"; +import { DEFAULT_LIST_MEMOS_PAGE_SIZE, SKELETON_LOADING_DELAY_MS } from "@/helpers/consts"; +import { useDelayedFlag } from "@/hooks/useDelayedFlag"; import { useInfiniteMemos } from "@/hooks/useMemoQueries"; import { hoistMemoToFront } from "@/hooks/useMemoSorting"; import { userKeys } from "@/hooks/useUserQueries"; @@ -101,6 +102,9 @@ 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); + // Flatten pages into a single array of memos const memos = useMemo(() => data?.pages.flatMap((page) => page.memos) || [], [data]); @@ -158,9 +162,11 @@ const PagedMemoList = (props: Props) => { const children = ( memo.content)}>
- {/* Show skeleton loader during initial load */} + {/* During initial load, show the skeleton only after the delay; render nothing before then to avoid a flash. */} {isLoading ? ( - + showSkeleton ? ( + + ) : null ) : ( <> {showMemoEditor ? ( diff --git a/web/src/helpers/consts.ts b/web/src/helpers/consts.ts index 13c203de..66ac0a90 100644 --- a/web/src/helpers/consts.ts +++ b/web/src/helpers/consts.ts @@ -3,3 +3,7 @@ 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; diff --git a/web/src/hooks/index.ts b/web/src/hooks/index.ts index cab63a2a..ba8f7e1c 100644 --- a/web/src/hooks/index.ts +++ b/web/src/hooks/index.ts @@ -2,6 +2,7 @@ export * from "./useAsyncEffect"; export * from "./useCurrentUser"; export * from "./useDateFilterNavigation"; export * from "./useDebouncedEffect"; +export * from "./useDelayedFlag"; export * from "./useFilteredMemoStats"; export * from "./useLoading"; export * from "./useLocalStorage"; diff --git a/web/src/hooks/useDelayedFlag.ts b/web/src/hooks/useDelayedFlag.ts new file mode 100644 index 00000000..aa2823a9 --- /dev/null +++ b/web/src/hooks/useDelayedFlag.ts @@ -0,0 +1,23 @@ +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. + */ +export const useDelayedFlag = (active: boolean, delay: number): boolean => { + const [delayed, setDelayed] = useState(false); + + useEffect(() => { + if (!active) { + setDelayed(false); + return; + } + + const timeout = window.setTimeout(() => setDelayed(true), delay); + return () => window.clearTimeout(timeout); + }, [active, delay]); + + return delayed; +};