fix(views): board/list load-more no longer flashes the skeleton + friendlier footer (#5893)

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing
2026-07-24 17:14:11 +08:00
committed by GitHub
parent 485f0f3124
commit f599fe3b85
10 changed files with 155 additions and 76 deletions

View File

@@ -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 ? (
<InfiniteScrollSentinel onVisible={loadMore} loading={isLoading} />
) : undefined
<ListLoadMoreFooter
hasMore={hasMore}
isLoading={isLoading}
total={total}
onLoadMore={loadMore}
/>
}
/>
);
@@ -822,21 +826,16 @@ const ServerPaginatedBoardColumn = memo(function ServerPaginatedBoardColumn({
onCreateIssue?: (defaults: IssueCreateDefaults) => void;
sortLabel?: string | null;
}) {
const { t } = useT("issues");
const footer = page.isError ? (
<button
type="button"
className="w-full py-2 text-xs text-destructive hover:underline"
onClick={page.retry}
>
{t(($) => $.table.load_more_failed_retry)}
</button>
) : page.hasMore ? (
<InfiniteScrollSentinel
onVisible={page.loadMore}
loading={page.isLoading || page.isFetching}
const footer = (
<ListLoadMoreFooter
hasMore={page.hasMore}
isLoading={page.isLoading || page.isFetching}
total={page.total}
onLoadMore={page.loadMore}
isError={page.isError}
onRetry={page.retry}
/>
) : undefined;
);
return (
<BoardColumn
group={group}
@@ -893,9 +892,12 @@ const PaginatedBoardColumn = memo(function PaginatedBoardColumn({
onCreateIssue={onCreateIssue}
sortLabel={sortLabel}
footer={
hasMore ? (
<InfiniteScrollSentinel onVisible={loadMore} loading={isLoading} />
) : undefined
<ListLoadMoreFooter
hasMore={hasMore}
isLoading={isLoading}
total={total}
onLoadMore={loadMore}
/>
}
/>
);

View File

@@ -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 (
<div ref={sentinelRef} className={className} aria-hidden>
{loading && <Loader2 className="size-3 animate-spin text-muted-foreground" />}
{loading && (
<>
<Loader2 className="size-3 animate-spin text-muted-foreground" />
{label && (
<span className="text-xs text-muted-foreground">{label}</span>
)}
</>
)}
</div>
);
}

View File

@@ -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 (
<button
type="button"
className="w-full py-2 text-xs text-destructive hover:underline"
onClick={onRetry}
>
{t(($) => $.table.load_more_failed_retry)}
</button>
);
}
if (hasMore) {
return (
<InfiniteScrollSentinel
onVisible={onLoadMore}
loading={isLoading}
label={t(($) => $.table.loading_branch)}
/>
);
}
if (total > PAGINATED_THRESHOLD) {
return (
<div className="py-2 text-center text-xs text-muted-foreground/70">
{t(($) => $.table.no_more)}
</div>
);
}
return null;
}

View File

@@ -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<string, ChildProgress>();
const EMPTY_IDS: string[] = [];
// Passed to <Virtuoso components> 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: () => (
<button
type="button"
className="w-full py-2 text-xs text-destructive hover:underline"
onClick={page.retry}
>
{t(($) => $.table.load_more_failed_retry)}
</button>
),
};
}
if (page.hasMore) {
return {
Footer: () => (
<InfiniteScrollSentinel
onVisible={page.loadMore}
loading={page.isLoading || page.isFetching}
/>
),
};
}
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: () => (
<ListLoadMoreFooter
hasMore={page.hasMore}
isLoading={page.isLoading || page.isFetching}
total={page.total}
onLoadMore={page.loadMore}
isError={page.isError}
onRetry={page.retry}
/>
),
}),
[page],
);
const computeItemKey = (_index: number, issue: Issue) => issue.id;

View File

@@ -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({
&mdash;
</p>
)}
{page?.isError ? (
<button
type="button"
className="w-full py-2 text-xs text-destructive hover:underline"
onClick={page.retry}
>
{t(($) => $.table.load_more_failed_retry)}
</button>
) : page?.hasMore ? (
<InfiniteScrollSentinel
onVisible={page.loadMore}
loading={page.isLoading || page.isFetching}
{page && (
<ListLoadMoreFooter
hasMore={page.hasMore}
isLoading={page.isLoading || page.isFetching}
total={page.total}
onLoadMore={page.loadMore}
isError={page.isError}
onRetry={page.retry}
/>
) : null}
)}
</div>
{/* One of these per lane×status cell (~170 on a real swimlane) —
eagerly mounted tooltip roots here were the single largest slice

View File

@@ -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),
};
}

View File

@@ -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",

View File

@@ -128,6 +128,7 @@
"load_more_failed_retry": "続きの読み込みに失敗しました — 再試行",
"load_more": "さらに読み込む",
"loading_branch": "読み込み中…",
"no_more": "これ以上ありません",
"value_unavailable": "利用できない値",
"group_property_unavailable": "グループ化に使っていたプロパティは利用できないため、グループ化を解除しました。",
"hierarchy": "階層",

View File

@@ -128,6 +128,7 @@
"load_more_failed_retry": "추가 항목을 불러오지 못했습니다 — 다시 시도",
"load_more": "더 불러오기",
"loading_branch": "불러오는 중…",
"no_more": "더 이상 없음",
"value_unavailable": "사용할 수 없는 값",
"group_property_unavailable": "그룹화에 사용한 속성을 더 이상 사용할 수 없어 그룹화를 해제했습니다.",
"hierarchy": "계층",

View File

@@ -128,6 +128,7 @@
"load_more_failed_retry": "加载更多失败——重试",
"load_more": "加载更多",
"loading_branch": "加载中…",
"no_more": "没有更多了",
"value_unavailable": "不可用的值",
"group_property_unavailable": "用于分组的属性已不可用,分组已清除。",
"hierarchy": "层级",