perf(issues): virtualize inbox/list/board/swimlane (MUL-4474, 方案2) (#5349)

* perf(inbox): virtualize notification list (MUL-4474)

The inbox notification list rendered every item at once. Each row mounts an
avatar + hover card, so a long inbox inflates the tab-switch commit — the
same render-amplifier class this issue targets.

Extract an InboxList component that virtualizes the rows via react-virtuoso
(customScrollParent over the existing overflow-y-auto element, same pattern
as the issue-detail timeline). Only the visible window plus a small overscan
is mounted; everything else — selection, hover, archive, scroll semantics,
the row component and callbacks — is unchanged. Virtualization changes
exactly one thing: whether an off-screen row is in the DOM.

Slice 2a of MUL-4474 (inbox is the no-DnD surface, done first to prove the
Virtuoso + scroll + keyboard harness before the drag surfaces). Draft: must
pass the manual zero-functional-change pass on a real Desktop build before
merge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* perf(board): virtualize board columns (MUL-4474)

Each board column rendered every card at once; cards carry pickers, avatars,
and a per-issue activity indicator, so a tall column inflates the tab-switch
commit. Virtualize the cards within each column via react-virtuoso, using the
column's own scroll container as customScrollParent.

The dnd-kit droppable stays on the always-mounted column scroll container
(merged callback ref feeds both dnd-kit and Virtuoso), and SortableContext
still wraps the full id list. So cross-column drops (status/assignee change)
and reorder among on-screen cards are unchanged; reordering to an off-screen
target relies on drag auto-scroll to mount it — the documented virtualization
tradeoff, to be confirmed in the manual pass. The infinite-scroll sentinel
rides Virtuoso's Footer slot so loadMore still fires at the bottom, and a
per-item pt-2 reproduces the previous space-y-2 gap with padding inside the
measured item box.

issues-page.test.tsx: mock react-virtuoso to render items inline (jsdom has no
layout), and make the useDroppable mock's setNodeRef referentially stable to
match real dnd-kit — the board's merged customScrollParent ref would otherwise
loop on a fresh ref each render.

Slice 2b of MUL-4474 on the shared inbox/list/board/swimlane branch. Draft:
requires the manual zero-functional-change pass on a real Desktop build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* perf(list): virtualize issue list rows (MUL-4474)

The status-grouped list rendered every row in every expanded section at once;
each row carries a sortable, context menu, tooltip, and activity indicator, so
a long list inflates the tab-switch commit. Virtualize each expanded section's
rows with react-virtuoso, all instances sharing the page's single scroll
container as customScrollParent.

Everything structural is preserved by construction: the Base UI accordion,
sticky status headers, collapse, the per-section useDroppable, the per-section
SortableContext, and the load-more sentinel (now Virtuoso's Footer). The
Virtuoso only mounts for an expanded section (a collapsed/hidden panel has no
viewport to measure). Virtualization changes exactly one thing: whether an
off-screen row is in the DOM.

issue-surface.test.tsx: mock react-virtuoso inline (jsdom has no layout) so the
surface-level loading-semantics assertions still observe the list's rows.

Slice 2c of MUL-4474 on the shared branch. Draft: requires the manual
zero-functional-change pass on a real Desktop build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* perf(swimlane): virtualize lanes (MUL-4474)

The swimlane rendered every lane (each a full row of status cells) at once.
Virtualize the vertical lane axis with react-virtuoso over the board's outer
scroll box (customScrollParent), so only on-screen lanes stay mounted.

Behavior is preserved: pinned lanes keep their leading position, the
SortableContext still wraps the lane set for grip-drag reorder (its items are
only the non-pinned lane ids), per-cell droppables and per-cell card
SortableContexts are unchanged (cells live on mounted lanes), the sticky status
header stays above the list, and the per-status load-more sentinels ride
Virtuoso's Footer. pt-4 per lane reproduces the previous gap-4.

swimlane-view.test.tsx: mock react-virtuoso inline so the ~47 lane/cell/DnD
assertions still see the lanes the virtualized list renders.

Slice 2d of MUL-4474 on the shared branch — this completes the four surfaces
(inbox/board/list/swimlane). Draft: requires the full manual
zero-functional-change pass on a real Desktop build before merge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(issues): don't pass undefined to Virtuoso `components` (MUL-4474)

react-virtuoso seeds its `components` prop with an internal `{}` default;
passing `components={undefined}` (which the list and board did when there was
no Footer — hasMore false / no column footer) overwrites that default with
undefined, so Virtuoso's startup destructure of `EmptyPlaceholder`/`Footer`
throws and the surface crashes. jsdom tests mock react-virtuoso so this only
surfaced on a real Desktop build (found in manual perf testing).

Return a stable module-level empty object instead of undefined. Inbox (omits
the prop entirely) and swimlane (always supplies a Footer) never hit this and
are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
Naiyuan Qing
2026-07-14 16:34:30 +08:00
committed by GitHub
parent e07b5403ab
commit 40da795f6c
8 changed files with 366 additions and 148 deletions

View File

@@ -0,0 +1,81 @@
"use client";
import { useState } from "react";
import { Virtuoso } from "react-virtuoso";
import { Inbox } from "lucide-react";
import type { InboxItem } from "@multica/core/types";
import { InboxListItem } from "./inbox-list-item";
import { useT } from "../../i18n";
/**
* Scrollable, virtualized inbox notification list.
*
* Owns the scroll container so both the mobile and desktop layouts render an
* identical scroller. Rows are virtualized via react-virtuoso so only the
* visible window (plus a small overscan) is mounted — the notification list
* can grow long and every row otherwise carries an avatar + hover card, so
* mounting all of them inflates the tab-switch commit (MUL-4474).
*
* Virtualization changes exactly one thing: whether an off-screen row is in
* the DOM. Selection, hover, archive, and scroll semantics are unchanged —
* the row component and the callbacks are the same as the non-virtualized
* list. `customScrollParent` keeps Virtuoso reading/writing the existing
* `overflow-y-auto` element (same pattern as the issue-detail timeline), so
* scroll position behaves exactly as before.
*
* Known virtualization tradeoff: keyboard Tab only reaches currently-mounted
* rows; a keyboard-only user must scroll to bring off-screen rows into the
* tab order. The inbox has no custom arrow-key list navigation, so the
* practical surface is small, but it is called out for the manual pass.
*/
export function InboxList({
items,
selectedKey,
onSelect,
onArchive,
}: {
items: InboxItem[];
selectedKey: string;
onSelect: (item: InboxItem) => void;
onArchive: (id: string) => void;
}) {
const { t } = useT("inbox");
// Virtuoso's `customScrollParent` wants the actual HTMLElement, not a ref.
// A callback ref into state hands the element over once it mounts and
// triggers the re-render that lets Virtuoso attach to it.
const [scrollEl, setScrollEl] = useState<HTMLDivElement | null>(null);
if (items.length === 0) {
return (
<div className="flex-1 min-h-0 overflow-y-auto">
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
<Inbox className="mb-3 h-8 w-8 text-muted-foreground/50" />
<p className="text-sm">{t(($) => $.list.empty)}</p>
</div>
</div>
);
}
return (
<div ref={setScrollEl} className="flex-1 min-h-0 overflow-y-auto">
<div className="px-2 py-1">
{scrollEl && (
<Virtuoso
customScrollParent={scrollEl}
data={items}
computeItemKey={(_index, item) => item.id}
increaseViewportBy={{ top: 400, bottom: 400 }}
itemContent={(_index, item) => (
<InboxListItem
item={item}
isSelected={(item.issue_id ?? item.id) === selectedKey}
onClick={() => onSelect(item)}
onArchive={() => onArchive(item.id)}
/>
)}
/>
)}
</div>
</div>
);
}

View File

@@ -52,7 +52,8 @@ import {
} from "@multica/ui/components/ui/dropdown-menu";
import { useIsMobile } from "@multica/ui/hooks/use-mobile";
import { PageHeader } from "../../layout/page-header";
import { InboxListItem, useTimeAgo } from "./inbox-list-item";
import { useTimeAgo } from "./inbox-list-item";
import { InboxList } from "./inbox-list";
import { useTypeLabels } from "./inbox-detail-label";
import { getInboxDisplayTitle } from "./inbox-display";
import { useT } from "../../i18n";
@@ -271,23 +272,13 @@ export function InboxPage() {
</PageHeader>
);
const listBody = items.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
<Inbox className="mb-3 h-8 w-8 text-muted-foreground/50" />
<p className="text-sm">{t(($) => $.list.empty)}</p>
</div>
) : (
<div className="px-2 py-1">
{items.map((item) => (
<InboxListItem
key={item.id}
item={item}
isSelected={(item.issue_id ?? item.id) === selectedKey}
onClick={() => handleSelect(item)}
onArchive={() => handleArchive(item.id)}
/>
))}
</div>
const list = (
<InboxList
items={items}
selectedKey={selectedKey}
onSelect={handleSelect}
onArchive={handleArchive}
/>
);
const detailContent = selected?.issue_id ? (
@@ -418,9 +409,7 @@ export function InboxPage() {
return (
<div className="flex flex-1 flex-col min-h-0">
{listHeader}
<div className="flex-1 min-h-0 overflow-y-auto">
{listBody}
</div>
{list}
</div>
);
}
@@ -464,9 +453,7 @@ export function InboxPage() {
<ResizablePanel id="list" defaultSize={320} minSize={240} maxSize={480} groupResizeBehavior="preserve-pixel-size">
<div className="flex flex-col border-r h-full">
{listHeader}
<div className="flex-1 min-h-0 overflow-y-auto">
{listBody}
</div>
{list}
</div>
</ResizablePanel>
<ResizableHandle />

View File

@@ -1,6 +1,7 @@
"use client";
import { memo, useMemo, type ReactNode } from "react";
import { memo, useCallback, useMemo, useState, type ReactNode } from "react";
import { Virtuoso } from "react-virtuoso";
import { EyeOff, MoreHorizontal, Plus, UserMinus } from "lucide-react";
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
import { useDroppable } from "@dnd-kit/core";
@@ -35,6 +36,12 @@ import type { IssueCreateDefaults } from "../surface/types";
export const BOARD_COL_WIDTH = 280;
export const BOARD_CARD_WIDTH = BOARD_COL_WIDTH - 16 - 8; // col(280) - col p-2(16) - droppable p-1(8)
// Passed to <Virtuoso components> when the column has no footer. Must be a
// STABLE object, never `undefined`: an explicit `undefined` prop overwrites
// react-virtuoso's internal `{}` default and its startup destructure of
// `EmptyPlaceholder`/`Footer` throws (MUL-4474).
const EMPTY_VIRTUOSO_COMPONENTS = {};
export interface BoardColumnGroup {
id: string;
title: string;
@@ -85,6 +92,28 @@ export const BoardColumn = memo(function BoardColumn({
[issueIds, issueMap],
);
// The column's scroll container is both dnd-kit's droppable and Virtuoso's
// customScrollParent, so a merged callback ref feeds the element to both.
// useDroppable's setNodeRef is stable across renders. Keeping the droppable
// on the always-mounted scroll container (not on individual cards) is what
// lets cross-column drops survive virtualization — only the cards inside
// window in/out of the DOM.
const [scrollEl, setScrollEl] = useState<HTMLDivElement | null>(null);
const mergedRef = useCallback(
(el: HTMLDivElement | null) => {
setNodeRef(el);
setScrollEl(el);
},
[setNodeRef],
);
// Infinite-scroll sentinel rides Virtuoso's Footer slot so it sits at the
// real end of the virtualized list and its IntersectionObserver still fires
// loadMore when scrolled to the bottom.
const footerComponents = useMemo(
() => (footer ? { Footer: () => <>{footer}</> } : EMPTY_VIRTUOSO_COMPONENTS),
[footer],
);
return (
<div style={{ width: BOARD_COL_WIDTH }} className={`flex shrink-0 flex-col rounded-xl ${cfg?.columnBg ?? "bg-muted/40"} p-2`}>
<div className="mb-2 flex items-center justify-between px-1.5">
@@ -143,8 +172,8 @@ export const BoardColumn = memo(function BoardColumn({
</div>
)}
<div
ref={setNodeRef}
className={`absolute inset-0 space-y-2 overflow-y-auto rounded-lg p-1 transition-colors ${
ref={mergedRef}
className={`absolute inset-0 overflow-y-auto rounded-lg p-1 transition-colors ${
isOver && sortLabel
? "ring-2 ring-brand/25 bg-accent/15"
: isOver
@@ -152,25 +181,43 @@ export const BoardColumn = memo(function BoardColumn({
: ""
}`}
>
<SortableContext items={issueIds} strategy={verticalListSortingStrategy}>
{resolvedIssues.map((issue) => (
<DraggableBoardCard
key={issue.id}
issue={issue}
childProgress={childProgressMap?.get(issue.id)}
project={
issue.project_id ? projectMap?.get(issue.project_id) : undefined
}
disableSorting={!!sortLabel}
/>
))}
</SortableContext>
{issueIds.length === 0 && (
<p className="py-8 text-center text-xs text-muted-foreground">
{t(($) => $.board.empty_column)}
</p>
{resolvedIssues.length > 0 ? (
<SortableContext items={issueIds} strategy={verticalListSortingStrategy}>
{scrollEl && (
<Virtuoso
customScrollParent={scrollEl}
data={resolvedIssues}
computeItemKey={(_index, issue) => issue.id}
increaseViewportBy={{ top: 300, bottom: 300 }}
components={footerComponents}
itemContent={(index, issue) => (
// pt-2 on every card but the first reproduces the previous
// `space-y-2` gap; padding (not margin) is inside Virtuoso's
// measured item box so its height math stays correct.
<div className={index === 0 ? undefined : "pt-2"}>
<DraggableBoardCard
issue={issue}
childProgress={childProgressMap?.get(issue.id)}
project={
issue.project_id ? projectMap?.get(issue.project_id) : undefined
}
disableSorting={!!sortLabel}
/>
</div>
)}
/>
)}
</SortableContext>
) : (
<>
{issueIds.length === 0 && (
<p className="py-8 text-center text-xs text-muted-foreground">
{t(($) => $.board.empty_column)}
</p>
)}
{footer}
</>
)}
{footer}
</div>
</div>
</div>

View File

@@ -286,16 +286,23 @@ vi.mock("sonner", () => ({
}));
// Mock dnd-kit
vi.mock("@dnd-kit/core", () => ({
DndContext: ({ children }: any) => children,
DragOverlay: () => null,
PointerSensor: class {},
useSensor: () => ({}),
useSensors: () => [],
useDroppable: () => ({ setNodeRef: vi.fn(), isOver: false }),
pointerWithin: vi.fn(),
closestCenter: vi.fn(),
}));
vi.mock("@dnd-kit/core", () => {
// Real dnd-kit useDroppable returns a referentially stable setNodeRef
// (memoized internally). BoardColumn merges it with a state-setting
// callback ref for Virtuoso's customScrollParent, so a fresh function each
// render would loop the ref detach/reattach. Model the stable identity.
const stableSetNodeRef = () => {};
return {
DndContext: ({ children }: any) => children,
DragOverlay: () => null,
PointerSensor: class {},
useSensor: () => ({}),
useSensors: () => [],
useDroppable: () => ({ setNodeRef: stableSetNodeRef, isOver: false }),
pointerWithin: vi.fn(),
closestCenter: vi.fn(),
};
});
vi.mock("@dnd-kit/sortable", () => ({
SortableContext: ({ children }: any) => children,
@@ -329,6 +336,21 @@ vi.mock("@base-ui/react/accordion", () => ({
),
}));
// Mock react-virtuoso: jsdom has no layout, so the real Virtuoso computes a
// 0-height viewport and renders nothing (and throws on its resize plumbing).
// Render every item inline so the virtualized board columns expose their
// cards to the DOM, matching the non-virtualized behavior these tests assert.
vi.mock("react-virtuoso", () => ({
Virtuoso: ({ data, itemContent, components }: any) => (
<div data-testid="virtuoso-mock">
{(data ?? []).map((item: any, i: number) => (
<div key={i}>{itemContent(i, item)}</div>
))}
{components?.Footer ? <components.Footer /> : null}
</div>
),
}));
// ---------------------------------------------------------------------------
// Test data
// ---------------------------------------------------------------------------

View File

@@ -15,6 +15,7 @@ import {
type DragOverEvent,
} from "@dnd-kit/core";
import { SortableContext, verticalListSortingStrategy, arrayMove } from "@dnd-kit/sortable";
import { Virtuoso } from "react-virtuoso";
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
import { Button } from "@multica/ui/components/ui/button";
import type { Issue, IssueStatus, Project } from "@multica/core/types";
@@ -43,6 +44,11 @@ import type { IssueCreateDefaults } from "../surface/types";
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) => ({
@@ -294,6 +300,13 @@ export function ListView({
[issues, groups, onMoveIssue, groupIds, groupMap, sortBy, beginSettle, setColumns, columnsRef, isDraggingRef],
);
// The single scroll container is shared by every status panel's Virtuoso as
// its customScrollParent, so a callback ref hands the element to the panels
// once it mounts. Keeping one scroller (rather than one per panel) preserves
// the current sticky-header + cross-section scroll behavior; only the rows
// inside each expanded panel virtualize.
const [scrollEl, setScrollEl] = useState<HTMLDivElement | null>(null);
const content = (
<Accordion.Root
multiple
@@ -327,6 +340,7 @@ export function ListView({
isExpanded={isExpanded}
sortLabel={sortLabel}
sort={sort}
scrollParent={scrollEl}
/>
);
})}
@@ -335,7 +349,7 @@ export function ListView({
if (!dragEnabled) {
return (
<div className="flex-1 min-h-0 overflow-y-auto p-2 pt-0">
<div ref={setScrollEl} className="flex-1 min-h-0 overflow-y-auto p-2 pt-0">
{content}
</div>
);
@@ -349,7 +363,7 @@ export function ListView({
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
<div className="flex-1 min-h-0 overflow-y-auto p-2 pt-0">
<div ref={setScrollEl} className="flex-1 min-h-0 overflow-y-auto p-2 pt-0">
{content}
</div>
@@ -378,6 +392,7 @@ function StatusAccordionItem({
isExpanded,
sortLabel,
sort,
scrollParent,
}: {
status: IssueStatus;
issueIds: string[];
@@ -391,6 +406,7 @@ function StatusAccordionItem({
isExpanded: boolean;
sortLabel: string | null;
sort?: IssueSortParam;
scrollParent: HTMLElement | null;
}) {
const { t } = useT("issues");
const selection = useIssueSurfaceSelection();
@@ -422,6 +438,52 @@ function StatusAccordionItem({
const disableSorting = !!sortLabel;
// 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(
() =>
hasMore
? { Footer: () => <InfiniteScrollSentinel onVisible={loadMore} loading={isLoading} /> }
: EMPTY_VIRTUOSO_COMPONENTS,
[hasMore, loadMore, isLoading],
);
// Rows virtualize into the page's shared scroll parent. Only mount the
// Virtuoso when the section is expanded and that parent exists — a Virtuoso
// in a collapsed (0-height / hidden) panel has no viewport to measure. The
// droppable, SortableContext, sticky header, and collapse are unchanged;
// virtualization only decides whether an off-screen row is in the DOM.
const rows =
isExpanded && scrollParent && issues.length > 0 ? (
<Virtuoso
customScrollParent={scrollParent}
data={issues}
computeItemKey={(_index, issue) => issue.id}
increaseViewportBy={{ top: 400, bottom: 400 }}
components={listComponents}
itemContent={(_index, issue) =>
dragEnabled ? (
<DraggableListRow
issue={issue}
childProgress={childProgressMap.get(issue.id)}
project={
issue.project_id ? projectMap?.get(issue.project_id) : undefined
}
disableSorting={disableSorting}
/>
) : (
<ListRow
issue={issue}
childProgress={childProgressMap.get(issue.id)}
project={
issue.project_id ? projectMap?.get(issue.project_id) : undefined
}
/>
)
}
/>
) : null;
return (
<Accordion.Item value={status} ref={dragEnabled ? setDroppableRef : undefined}>
<Accordion.Header
@@ -482,37 +544,10 @@ function StatusAccordionItem({
{issues.length > 0 ? (
dragEnabled ? (
<SortableContext items={issueIds} strategy={verticalListSortingStrategy}>
{issues.map((issue) => (
<DraggableListRow
key={issue.id}
issue={issue}
childProgress={childProgressMap.get(issue.id)}
project={
issue.project_id ? projectMap?.get(issue.project_id) : undefined
}
disableSorting={disableSorting}
/>
))}
{hasMore && (
<InfiniteScrollSentinel onVisible={loadMore} loading={isLoading} />
)}
{rows}
</SortableContext>
) : (
<>
{issues.map((issue) => (
<ListRow
key={issue.id}
issue={issue}
childProgress={childProgressMap.get(issue.id)}
project={
issue.project_id ? projectMap?.get(issue.project_id) : undefined
}
/>
))}
{hasMore && (
<InfiniteScrollSentinel onVisible={loadMore} loading={isLoading} />
)}
</>
rows
)
) : (
<p className="py-6 text-center text-xs text-muted-foreground">

View File

@@ -248,6 +248,21 @@ vi.mock("@dnd-kit/utilities", () => ({
CSS: { Transform: { toString: () => undefined } },
}));
// Mock react-virtuoso: jsdom has no layout, so the real Virtuoso renders
// nothing (and throws on its resize plumbing). Render every lane inline so the
// virtualized swimlane exposes its lanes/cells/cards to the DOM, matching the
// non-virtualized behavior these tests assert.
vi.mock("react-virtuoso", () => ({
Virtuoso: ({ data, itemContent, components }: any) => (
<div data-testid="virtuoso-mock">
{(data ?? []).map((item: any, i: number) => (
<div key={i}>{itemContent(i, item)}</div>
))}
{components?.Footer ? <components.Footer /> : null}
</div>
),
}));
const mockIssues: Issue[] = [
{
id: "parent-1",

View File

@@ -17,6 +17,7 @@ import {
} from "@dnd-kit/core";
import { SortableContext, useSortable, verticalListSortingStrategy, arrayMove } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Virtuoso } from "react-virtuoso";
import { ChevronRight, EyeOff, GripVertical, MoreHorizontal, Pencil, Plus } from "lucide-react";
import { useQuery, useQueries, useQueryClient } from "@tanstack/react-query";
import type {
@@ -811,6 +812,8 @@ export function SwimLaneView({
);
const [activeIssue, setActiveIssue] = useState<Issue | null>(null);
// The outer scroll box is the customScrollParent for the lane Virtuoso.
const [scrollEl, setScrollEl] = useState<HTMLDivElement | null>(null);
const isDraggingRef = useRef(false);
// Settle lock: held from drop until the move mutation settles, so a cache
// change that lands mid-flight (e.g. a membership refetch) does not rebuild
@@ -1127,6 +1130,42 @@ export function SwimLaneView({
[sortedStatuses.length, trackWidth],
);
// Lanes render in one Virtuoso so only on-screen lanes stay mounted. Pinned
// lanes keep their leading position; the SortableContext still wraps the
// whole set (its `items` are only the non-pinned lane ids, so reorder is
// unchanged), and per-cell droppables live on always-mounted lane cells.
const orderedLanes = useMemo(
() => [
...laneGroups.filter((g) => g.isPinned),
...laneGroups.filter((g) => !g.isPinned),
],
[laneGroups],
);
const nonPinnedLaneIds = useMemo(
() =>
laneGroups
.filter((g) => !g.isPinned)
.map((g) => laneIdFor(swimlaneGrouping, g.rawId)),
[laneGroups, swimlaneGrouping],
);
// Per-status load-more sentinels ride Virtuoso's Footer so they sit at the
// true end of the lane list; pt-4 reproduces the previous gap-4.
const laneComponents = useMemo(
() => ({
Footer: () => (
<div className="pt-4">
<SwimLaneLoadMoreRow
sortedStatuses={sortedStatuses}
gridStyle={gridStyle}
myIssuesOpts={myIssuesOpts}
sort={sort}
/>
</div>
),
}),
[sortedStatuses, gridStyle, myIssuesOpts, sort],
);
return (
<DndContext
sensors={sensors}
@@ -1135,7 +1174,7 @@ export function SwimLaneView({
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
<div className="flex flex-1 min-h-0 gap-4 overflow-auto p-4">
<div ref={setScrollEl} className="flex flex-1 min-h-0 gap-4 overflow-auto p-4">
<div className="flex shrink-0 flex-col" style={{ width: `${trackWidth}px` }}>
{/* Sticky status header row — visually matches the top of a BoardColumn */}
<div className="sticky top-0 z-10 mb-2 bg-background/95 pb-2 backdrop-blur supports-[backdrop-filter]:bg-background/75">
@@ -1178,67 +1217,44 @@ export function SwimLaneView({
</div>
</div>
{/* Lane rows. Pinned lanes (the no-X bucket, and parent-grouping's
orphan fallback) sit at the top and are non-draggable; the rest
are wrapped in a SortableContext so users can reorder lanes by
dragging the grip handle. */}
<div className="flex flex-col gap-4">
{laneGroups
.filter((g) => g.isPinned)
.map((lane) => (
<DraggableSwimLane
key={lane.key}
lane={lane}
grouping={swimlaneGrouping}
isCollapsed={collapsedLanes.has(lane.key)}
onToggleCollapse={() => toggleLane(lane.key)}
localCells={localCells}
sortedStatuses={sortedStatuses}
issueMap={issueMapRef.current}
childProgressMap={childProgressMap}
projectMap={projectMap}
gridStyle={gridStyle}
paths={paths}
projectId={projectId}
onCreateIssue={onCreateIssue}
/>
))}
<SortableContext
items={laneGroups
.filter((g) => !g.isPinned)
.map((g) => laneIdFor(swimlaneGrouping, g.rawId))}
strategy={verticalListSortingStrategy}
>
{laneGroups
.filter((g) => !g.isPinned)
.map((lane) => (
<DraggableSwimLane
key={lane.key}
lane={lane}
grouping={swimlaneGrouping}
isCollapsed={collapsedLanes.has(lane.key)}
onToggleCollapse={() => toggleLane(lane.key)}
localCells={localCells}
sortedStatuses={sortedStatuses}
issueMap={issueMapRef.current}
childProgressMap={childProgressMap}
projectMap={projectMap}
gridStyle={gridStyle}
paths={paths}
projectId={projectId}
onCreateIssue={onCreateIssue}
/>
))}
</SortableContext>
{/* Per-status load-more sentinels — same bucketed cache as Board. */}
<SwimLaneLoadMoreRow
sortedStatuses={sortedStatuses}
gridStyle={gridStyle}
myIssuesOpts={myIssuesOpts}
sort={sort}
/>
</div>
{/* Lane rows, virtualized. Pinned lanes (the no-X bucket, and
parent-grouping's orphan fallback) keep their leading position and
stay non-draggable; the SortableContext still lets the rest reorder
by dragging the grip handle (its `items` are only the non-pinned
lane ids). Only on-screen lanes stay mounted. */}
<SortableContext
items={nonPinnedLaneIds}
strategy={verticalListSortingStrategy}
>
{scrollEl && (
<Virtuoso
customScrollParent={scrollEl}
data={orderedLanes}
computeItemKey={(_index, lane) => lane.key}
increaseViewportBy={{ top: 600, bottom: 600 }}
components={laneComponents}
itemContent={(index, lane) => (
<div className={index === 0 ? undefined : "pt-4"}>
<DraggableSwimLane
lane={lane}
grouping={swimlaneGrouping}
isCollapsed={collapsedLanes.has(lane.key)}
onToggleCollapse={() => toggleLane(lane.key)}
localCells={localCells}
sortedStatuses={sortedStatuses}
issueMap={issueMapRef.current}
childProgressMap={childProgressMap}
projectMap={projectMap}
gridStyle={gridStyle}
paths={paths}
projectId={projectId}
onCreateIssue={onCreateIssue}
/>
</div>
)}
/>
)}
</SortableContext>
</div>
{hiddenStatuses.length > 0 && (

View File

@@ -23,6 +23,21 @@ vi.mock("@multica/core/hooks", () => ({
useWorkspaceId: () => mockWsId.current,
}));
// The list/board virtualize their rows via react-virtuoso; jsdom has no layout
// so the real Virtuoso renders nothing (and throws on its resize plumbing).
// Render items inline so these surface-level loading-semantics assertions still
// see the issues the virtualized list would show.
vi.mock("react-virtuoso", () => ({
Virtuoso: ({ data, itemContent, components }: any) => (
<div data-testid="virtuoso-mock">
{(data ?? []).map((item: any, i: number) => (
<div key={i}>{itemContent(i, item)}</div>
))}
{components?.Footer ? <components.Footer /> : null}
</div>
),
}));
const mockAuthUser = { id: "user-1", email: "test@test.com", name: "Test User" };
vi.mock("@multica/core/auth", () => ({
useAuthStore: Object.assign(