From 40da795f6cdb8ddfe684faadb2d748c9a1c1b98d Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Tue, 14 Jul 2026 16:34:30 +0800 Subject: [PATCH] =?UTF-8?q?perf(issues):=20virtualize=20inbox/list/board/s?= =?UTF-8?q?wimlane=20(MUL-4474,=20=E6=96=B9=E6=A1=882)=20(#5349)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 Co-authored-by: multica-agent * 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 Co-authored-by: multica-agent * 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 Co-authored-by: multica-agent * 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 Co-authored-by: multica-agent * 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 Co-authored-by: multica-agent --------- Co-authored-by: Claude Opus 4.8 Co-authored-by: multica-agent --- .../views/inbox/components/inbox-list.tsx | 81 ++++++++++ .../views/inbox/components/inbox-page.tsx | 35 ++--- .../views/issues/components/board-column.tsx | 89 ++++++++--- .../issues/components/issues-page.test.tsx | 42 ++++-- .../views/issues/components/list-view.tsx | 97 ++++++++---- .../issues/components/swimlane-view.test.tsx | 15 ++ .../views/issues/components/swimlane-view.tsx | 140 ++++++++++-------- .../issues/surface/issue-surface.test.tsx | 15 ++ 8 files changed, 366 insertions(+), 148 deletions(-) create mode 100644 packages/views/inbox/components/inbox-list.tsx diff --git a/packages/views/inbox/components/inbox-list.tsx b/packages/views/inbox/components/inbox-list.tsx new file mode 100644 index 000000000..07563ebdc --- /dev/null +++ b/packages/views/inbox/components/inbox-list.tsx @@ -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(null); + + if (items.length === 0) { + return ( +
+
+ +

{t(($) => $.list.empty)}

+
+
+ ); + } + + return ( +
+
+ {scrollEl && ( + item.id} + increaseViewportBy={{ top: 400, bottom: 400 }} + itemContent={(_index, item) => ( + onSelect(item)} + onArchive={() => onArchive(item.id)} + /> + )} + /> + )} +
+
+ ); +} diff --git a/packages/views/inbox/components/inbox-page.tsx b/packages/views/inbox/components/inbox-page.tsx index 92fe4e844..749a1aede 100644 --- a/packages/views/inbox/components/inbox-page.tsx +++ b/packages/views/inbox/components/inbox-page.tsx @@ -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() { ); - const listBody = items.length === 0 ? ( -
- -

{t(($) => $.list.empty)}

-
- ) : ( -
- {items.map((item) => ( - handleSelect(item)} - onArchive={() => handleArchive(item.id)} - /> - ))} -
+ const list = ( + ); const detailContent = selected?.issue_id ? ( @@ -418,9 +409,7 @@ export function InboxPage() { return (
{listHeader} -
- {listBody} -
+ {list}
); } @@ -464,9 +453,7 @@ export function InboxPage() {
{listHeader} -
- {listBody} -
+ {list}
diff --git a/packages/views/issues/components/board-column.tsx b/packages/views/issues/components/board-column.tsx index be6f5f64c..e0b804e84 100644 --- a/packages/views/issues/components/board-column.tsx +++ b/packages/views/issues/components/board-column.tsx @@ -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 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(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 (
@@ -143,8 +172,8 @@ export const BoardColumn = memo(function BoardColumn({
)}
- - {resolvedIssues.map((issue) => ( - - ))} - - {issueIds.length === 0 && ( -

- {t(($) => $.board.empty_column)} -

+ {resolvedIssues.length > 0 ? ( + + {scrollEl && ( + 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. +
+ +
+ )} + /> + )} +
+ ) : ( + <> + {issueIds.length === 0 && ( +

+ {t(($) => $.board.empty_column)} +

+ )} + {footer} + )} - {footer}
diff --git a/packages/views/issues/components/issues-page.test.tsx b/packages/views/issues/components/issues-page.test.tsx index 45bd81846..3e567d37a 100644 --- a/packages/views/issues/components/issues-page.test.tsx +++ b/packages/views/issues/components/issues-page.test.tsx @@ -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) => ( +
+ {(data ?? []).map((item: any, i: number) => ( +
{itemContent(i, item)}
+ ))} + {components?.Footer ? : null} +
+ ), +})); + // --------------------------------------------------------------------------- // Test data // --------------------------------------------------------------------------- diff --git a/packages/views/issues/components/list-view.tsx b/packages/views/issues/components/list-view.tsx index 0e0c60b87..803f4c101 100644 --- a/packages/views/issues/components/list-view.tsx +++ b/packages/views/issues/components/list-view.tsx @@ -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(); 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) => ({ @@ -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(null); + const content = ( ); })} @@ -335,7 +349,7 @@ export function ListView({ if (!dragEnabled) { return ( -
+
{content}
); @@ -349,7 +363,7 @@ export function ListView({ onDragOver={handleDragOver} onDragEnd={handleDragEnd} > -
+
{content}
@@ -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: () => } + : 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 ? ( + issue.id} + increaseViewportBy={{ top: 400, bottom: 400 }} + components={listComponents} + itemContent={(_index, issue) => + dragEnabled ? ( + + ) : ( + + ) + } + /> + ) : null; + return ( 0 ? ( dragEnabled ? ( - {issues.map((issue) => ( - - ))} - {hasMore && ( - - )} + {rows} ) : ( - <> - {issues.map((issue) => ( - - ))} - {hasMore && ( - - )} - + rows ) ) : (

diff --git a/packages/views/issues/components/swimlane-view.test.tsx b/packages/views/issues/components/swimlane-view.test.tsx index a20b12d71..f3c6571d2 100644 --- a/packages/views/issues/components/swimlane-view.test.tsx +++ b/packages/views/issues/components/swimlane-view.test.tsx @@ -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) => ( +

+ {(data ?? []).map((item: any, i: number) => ( +
{itemContent(i, item)}
+ ))} + {components?.Footer ? : null} +
+ ), +})); + const mockIssues: Issue[] = [ { id: "parent-1", diff --git a/packages/views/issues/components/swimlane-view.tsx b/packages/views/issues/components/swimlane-view.tsx index 87ffaca3f..03edae61c 100644 --- a/packages/views/issues/components/swimlane-view.tsx +++ b/packages/views/issues/components/swimlane-view.tsx @@ -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(null); + // The outer scroll box is the customScrollParent for the lane Virtuoso. + const [scrollEl, setScrollEl] = useState(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: () => ( +
+ +
+ ), + }), + [sortedStatuses, gridStyle, myIssuesOpts, sort], + ); + return ( -
+
{/* Sticky status header row — visually matches the top of a BoardColumn */}
@@ -1178,67 +1217,44 @@ export function SwimLaneView({
- {/* 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. */} -
- {laneGroups - .filter((g) => g.isPinned) - .map((lane) => ( - toggleLane(lane.key)} - localCells={localCells} - sortedStatuses={sortedStatuses} - issueMap={issueMapRef.current} - childProgressMap={childProgressMap} - projectMap={projectMap} - gridStyle={gridStyle} - paths={paths} - projectId={projectId} - onCreateIssue={onCreateIssue} - /> - ))} - !g.isPinned) - .map((g) => laneIdFor(swimlaneGrouping, g.rawId))} - strategy={verticalListSortingStrategy} - > - {laneGroups - .filter((g) => !g.isPinned) - .map((lane) => ( - toggleLane(lane.key)} - localCells={localCells} - sortedStatuses={sortedStatuses} - issueMap={issueMapRef.current} - childProgressMap={childProgressMap} - projectMap={projectMap} - gridStyle={gridStyle} - paths={paths} - projectId={projectId} - onCreateIssue={onCreateIssue} - /> - ))} - - - {/* Per-status load-more sentinels — same bucketed cache as Board. */} - -
+ {/* 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. */} + + {scrollEl && ( + lane.key} + increaseViewportBy={{ top: 600, bottom: 600 }} + components={laneComponents} + itemContent={(index, lane) => ( +
+ toggleLane(lane.key)} + localCells={localCells} + sortedStatuses={sortedStatuses} + issueMap={issueMapRef.current} + childProgressMap={childProgressMap} + projectMap={projectMap} + gridStyle={gridStyle} + paths={paths} + projectId={projectId} + onCreateIssue={onCreateIssue} + /> +
+ )} + /> + )} +
{hiddenStatuses.length > 0 && ( diff --git a/packages/views/issues/surface/issue-surface.test.tsx b/packages/views/issues/surface/issue-surface.test.tsx index 686c3f0c6..ecf53e0aa 100644 --- a/packages/views/issues/surface/issue-surface.test.tsx +++ b/packages/views/issues/surface/issue-surface.test.tsx @@ -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) => ( +
+ {(data ?? []).map((item: any, i: number) => ( +
{itemContent(i, item)}
+ ))} + {components?.Footer ? : null} +
+ ), +})); + const mockAuthUser = { id: "user-1", email: "test@test.com", name: "Test User" }; vi.mock("@multica/core/auth", () => ({ useAuthStore: Object.assign(