mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-31 00:40:46 +02:00
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:
81
packages/views/inbox/components/inbox-list.tsx
Normal file
81
packages/views/inbox/components/inbox-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
Reference in New Issue
Block a user