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>
This commit is contained in:
Naiyuan Qing
2026-07-13 18:44:36 +08:00
parent 411a160b99
commit a84bbdafc0
2 changed files with 92 additions and 24 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 />