From f599fe3b8589d283ab7ec42932df6799697bf888 Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Fri, 24 Jul 2026 17:14:11 +0800 Subject: [PATCH] fix(views): board/list load-more no longer flashes the skeleton + friendlier footer (#5893) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(views): board/list load-more stops flashing the full skeleton Tail (load-more) page fetches were folded into the surface-level isLoading through the per-branch aggregate, so scrolling to load more in List and status-grouped Board replaced the already-rendered rows with the full-surface skeleton. Track head-page pending separately and gate the surface isLoading/isRefreshing on heads only; per-branch pagination state that drives the load-more sentinel is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(views): friendlier infinite-scroll footer for board/list/swimlane Collapse four copies of the error/has-more/reached-end ternary into a shared ListLoadMoreFooter: the loading spinner now carries a "Loading…" label so a slow fetch no longer reads as stuck, and a column that actually paginated shows a muted "No more" marker at the end instead of stopping silently. InfiniteScrollSentinel gains an optional label; adds the table.no_more string in en/zh-Hans/ja/ko. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../views/issues/components/board-view.tsx | 42 +++++------ .../components/infinite-scroll-sentinel.tsx | 17 ++++- .../components/list-load-more-footer.tsx | 71 +++++++++++++++++++ .../views/issues/components/list-view.tsx | 50 +++++-------- .../views/issues/components/swimlane-view.tsx | 23 +++--- .../surface/use-issue-status-branches.ts | 24 +++++-- packages/views/locales/en/issues.json | 1 + packages/views/locales/ja/issues.json | 1 + packages/views/locales/ko/issues.json | 1 + packages/views/locales/zh-Hans/issues.json | 1 + 10 files changed, 155 insertions(+), 76 deletions(-) create mode 100644 packages/views/issues/components/list-load-more-footer.tsx diff --git a/packages/views/issues/components/board-view.tsx b/packages/views/issues/components/board-view.tsx index d9a8859df..e11fd7cf5 100644 --- a/packages/views/issues/components/board-view.tsx +++ b/packages/views/issues/components/board-view.tsx @@ -35,6 +35,7 @@ import { BoardColumn, BOARD_CARD_WIDTH, type BoardColumnGroup } from "./board-co import { BoardCardContent } from "./board-card"; import { HiddenColumnsPanel, HiddenColumnRow } from "./hidden-columns-panel"; import { InfiniteScrollSentinel } from "./infinite-scroll-sentinel"; +import { ListLoadMoreFooter } from "./list-load-more-footer"; import type { ChildProgress } from "./list-row"; import type { IssueCreateDefaults } from "../surface/types"; import type { @@ -793,9 +794,12 @@ const PaginatedAssigneeBoardColumn = memo(function PaginatedAssigneeBoardColumn( onCreateIssue={onCreateIssue} sortLabel={sortLabel} footer={ - hasMore ? ( - - ) : undefined + } /> ); @@ -822,21 +826,16 @@ const ServerPaginatedBoardColumn = memo(function ServerPaginatedBoardColumn({ onCreateIssue?: (defaults: IssueCreateDefaults) => void; sortLabel?: string | null; }) { - const { t } = useT("issues"); - const footer = page.isError ? ( - - ) : page.hasMore ? ( - - ) : undefined; + ); return ( - ) : undefined + } /> ); diff --git a/packages/views/issues/components/infinite-scroll-sentinel.tsx b/packages/views/issues/components/infinite-scroll-sentinel.tsx index de5e3bdf2..d233e182f 100644 --- a/packages/views/issues/components/infinite-scroll-sentinel.tsx +++ b/packages/views/issues/components/infinite-scroll-sentinel.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useRef } from "react"; +import { useEffect, useRef, type ReactNode } from "react"; import { Loader2 } from "lucide-react"; /** Sentinel that triggers once per visibility transition. Callback/loading @@ -9,11 +9,15 @@ import { Loader2 } from "lucide-react"; export function InfiniteScrollSentinel({ onVisible, loading, + label, rootMargin = "100px", - className = "flex items-center justify-center py-2", + className = "flex items-center justify-center gap-1.5 py-2", }: { onVisible: () => void; loading: boolean; + /** Shown next to the spinner while loading (e.g. "Loading…"). Gives the + * bare spinner context so a slow page fetch does not read as "stuck". */ + label?: ReactNode; rootMargin?: string; className?: string; }) { @@ -34,7 +38,14 @@ export function InfiniteScrollSentinel({ return (
- {loading && } + {loading && ( + <> + + {label && ( + {label} + )} + + )}
); } diff --git a/packages/views/issues/components/list-load-more-footer.tsx b/packages/views/issues/components/list-load-more-footer.tsx new file mode 100644 index 000000000..cdb6e8d6f --- /dev/null +++ b/packages/views/issues/components/list-load-more-footer.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { useT } from "../../i18n"; +import { InfiniteScrollSentinel } from "./infinite-scroll-sentinel"; + +/** Server row-branch page size (use-issue-status-branches / + * use-issue-group-branches). A column with no more rows than this never + * paginated, so its end carries no "no more" marker — the column is + * self-evidently complete. */ +const PAGINATED_THRESHOLD = 50; + +/** + * Unified end-of-column footer for the infinite-scrolled issue surfaces + * (Board / List / Swimlane). Collapses four copies of the same + * error / has-more / reached-end ternary into one place so the states and + * wording stay consistent: + * - error → retry + * - has more → sentinel + "Loading…" label (bare spinner read as "stuck") + * - reached end → muted "No more" marker, but only for columns that actually + * paginated (total beyond one page) + * - short list → nothing + */ +export function ListLoadMoreFooter({ + hasMore, + isLoading, + total, + onLoadMore, + isError = false, + onRetry, +}: { + hasMore: boolean; + isLoading: boolean; + total: number; + onLoadMore: () => void; + isError?: boolean; + onRetry?: () => void; +}) { + const { t } = useT("issues"); + + if (isError && onRetry) { + return ( + + ); + } + + if (hasMore) { + return ( + $.table.loading_branch)} + /> + ); + } + + if (total > PAGINATED_THRESHOLD) { + return ( +
+ {t(($) => $.table.no_more)} +
+ ); + } + + return null; +} diff --git a/packages/views/issues/components/list-view.tsx b/packages/views/issues/components/list-view.tsx index 91e639107..06383bccd 100644 --- a/packages/views/issues/components/list-view.tsx +++ b/packages/views/issues/components/list-view.tsx @@ -22,7 +22,7 @@ import { useViewStore } from "@multica/core/issues/stores/view-store-context"; import { StatusHeading } from "./status-heading"; import { ListRow, DraggableListRow, type ChildProgress } from "./list-row"; import { useDragSettle } from "./use-drag-settle"; -import { InfiniteScrollSentinel } from "./infinite-scroll-sentinel"; +import { ListLoadMoreFooter } from "./list-load-more-footer"; import { useT } from "../../i18n"; import { type DragMoveUpdates, @@ -56,11 +56,6 @@ const LIST_ROW_ESTIMATED_HEIGHT = 36; const EMPTY_PROGRESS_MAP = new Map(); const EMPTY_IDS: string[] = []; -// Passed to when there is no Footer. Must be a STABLE -// object, never `undefined`: react-virtuoso seeds `components` with an internal -// `{}` default, and an explicit `undefined` prop overwrites that default, so -// its startup destructure of `EmptyPlaceholder`/`Footer` throws (MUL-4474). -const EMPTY_VIRTUOSO_COMPONENTS = {}; function buildListGroups(visibleStatuses: IssueStatus[]): BoardColumnGroup[] { return visibleStatuses.map((status) => ({ @@ -462,33 +457,22 @@ function StatusAccordionItem({ // The infinite-scroll sentinel rides Virtuoso's Footer so it sits at the true // end of the virtualized rows and still fires loadMore when scrolled to it. const listComponents = useMemo( - () => { - if (page.isError) { - return { - Footer: () => ( - - ), - }; - } - if (page.hasMore) { - return { - Footer: () => ( - - ), - }; - } - return EMPTY_VIRTUOSO_COMPONENTS; - }, - [page, t], + () => ({ + // Always a non-undefined object: react-virtuoso throws if `components` + // is ever undefined (MUL-4474). The footer itself renders null for a + // short, non-paginated section. + Footer: () => ( + + ), + }), + [page], ); const computeItemKey = (_index: number, issue: Issue) => issue.id; diff --git a/packages/views/issues/components/swimlane-view.tsx b/packages/views/issues/components/swimlane-view.tsx index 6fa93a4ae..c5d175d64 100644 --- a/packages/views/issues/components/swimlane-view.tsx +++ b/packages/views/issues/components/swimlane-view.tsx @@ -51,6 +51,7 @@ import { Button } from "@multica/ui/components/ui/button"; import { StatusHeading } from "./status-heading"; import { HiddenColumnsPanel, HiddenColumnRow } from "./hidden-columns-panel"; import { InfiniteScrollSentinel } from "./infinite-scroll-sentinel"; +import { ListLoadMoreFooter } from "./list-load-more-footer"; import { AppLink } from "../../navigation"; import { ProjectIcon } from "../../projects/components/project-icon"; import { ActorAvatar } from "../../common/actor-avatar"; @@ -1775,20 +1776,16 @@ function SwimLaneCell({ —

)} - {page?.isError ? ( - - ) : page?.hasMore ? ( - - ) : null} + )} {/* One of these per lane×status cell (~170 on a real swimlane) — eagerly mounted tooltip roots here were the single largest slice diff --git a/packages/views/issues/surface/use-issue-status-branches.ts b/packages/views/issues/surface/use-issue-status-branches.ts index 0968958b5..655d3a534 100644 --- a/packages/views/issues/surface/use-issue-status-branches.ts +++ b/packages/views/issues/surface/use-issue-status-branches.ts @@ -59,6 +59,10 @@ interface StatusBranchData { isError: boolean; headUpdatedAt: number; headFetching: boolean; + /** The head (cursor === null) page alone is still loading with nothing to + * show. Distinct from `isLoading`, which also accumulates tail pages so the + * per-branch load-more sentinel can spin. */ + headPending: boolean; } function statusGroupKey(status: IssueStatus) { @@ -228,6 +232,7 @@ export function useIssueStatusBranches({ isError: false, headUpdatedAt: 0, headFetching: false, + headPending: false, }); } @@ -277,6 +282,7 @@ export function useIssueStatusBranches({ if (target.cursor === null) { current.headUpdatedAt = queryResult.dataUpdatedAt; current.headFetching = queryResult.isFetching; + current.headPending = queryResult.isPending; } } return result; @@ -395,11 +401,15 @@ export function useIssueStatusBranches({ ); const isTotalKnown = facets !== undefined; const total = facets?.total ?? issues.length; - const rowsPending = statuses.some( - (status) => branchData.get(status)?.isLoading, + // Surface-level loading reflects only each branch's HEAD page. A tail page + // (load-more) is pending/fetching while the already-rendered rows stay on + // screen, so it must never re-trigger the full-surface skeleton or the + // global refreshing indicator — the per-branch sentinel owns "loading more". + const headsPending = statuses.some( + (status) => branchData.get(status)?.headPending, ); - const rowsFetching = statuses.some( - (status) => branchData.get(status)?.isFetching, + const headsFetching = statuses.some( + (status) => branchData.get(status)?.headFetching, ); return { @@ -408,11 +418,11 @@ export function useIssueStatusBranches({ pagination, total, isTotalKnown, - isLoading: enabled && (facetsPending || rowsPending), + isLoading: enabled && (facetsPending || headsPending), isRefreshing: enabled && !facetsPending && - !rowsPending && - (facetsFetching || rowsFetching), + !headsPending && + (facetsFetching || headsFetching), }; } diff --git a/packages/views/locales/en/issues.json b/packages/views/locales/en/issues.json index a134dac31..f573dec36 100644 --- a/packages/views/locales/en/issues.json +++ b/packages/views/locales/en/issues.json @@ -130,6 +130,7 @@ "load_more_failed_retry": "Loading more failed — Retry", "load_more": "Load more", "loading_branch": "Loading…", + "no_more": "No more", "value_unavailable": "Unavailable value", "group_property_unavailable": "The grouped property is no longer available. Grouping was cleared.", "hierarchy": "Hierarchy", diff --git a/packages/views/locales/ja/issues.json b/packages/views/locales/ja/issues.json index ee6db3517..5ad04d750 100644 --- a/packages/views/locales/ja/issues.json +++ b/packages/views/locales/ja/issues.json @@ -128,6 +128,7 @@ "load_more_failed_retry": "続きの読み込みに失敗しました — 再試行", "load_more": "さらに読み込む", "loading_branch": "読み込み中…", + "no_more": "これ以上ありません", "value_unavailable": "利用できない値", "group_property_unavailable": "グループ化に使っていたプロパティは利用できないため、グループ化を解除しました。", "hierarchy": "階層", diff --git a/packages/views/locales/ko/issues.json b/packages/views/locales/ko/issues.json index 0f02cf480..568713b0c 100644 --- a/packages/views/locales/ko/issues.json +++ b/packages/views/locales/ko/issues.json @@ -128,6 +128,7 @@ "load_more_failed_retry": "추가 항목을 불러오지 못했습니다 — 다시 시도", "load_more": "더 불러오기", "loading_branch": "불러오는 중…", + "no_more": "더 이상 없음", "value_unavailable": "사용할 수 없는 값", "group_property_unavailable": "그룹화에 사용한 속성을 더 이상 사용할 수 없어 그룹화를 해제했습니다.", "hierarchy": "계층", diff --git a/packages/views/locales/zh-Hans/issues.json b/packages/views/locales/zh-Hans/issues.json index 24f5b8470..5a1dceb59 100644 --- a/packages/views/locales/zh-Hans/issues.json +++ b/packages/views/locales/zh-Hans/issues.json @@ -128,6 +128,7 @@ "load_more_failed_retry": "加载更多失败——重试", "load_more": "加载更多", "loading_branch": "加载中…", + "no_more": "没有更多了", "value_unavailable": "不可用的值", "group_property_unavailable": "用于分组的属性已不可用,分组已清除。", "hierarchy": "层级",