From a84bbdafc0d0f3e0318e47781dd4a5e88796345a Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Mon, 13 Jul 2026 18:44:36 +0800 Subject: [PATCH] perf(inbox): virtualize notification list (MUL-4474) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../views/inbox/components/inbox-list.tsx | 81 +++++++++++++++++++ .../views/inbox/components/inbox-page.tsx | 35 +++----- 2 files changed, 92 insertions(+), 24 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 0000000000..07563ebdcc --- /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 92fe4e844f..749a1aede7 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}