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": "层级",