From 5215ba1ff9cf15d001fca917f17cb06b92ec1b5e Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Wed, 30 Sep 2026 08:44:17 +0200 Subject: [PATCH 1/3] perf(web): virtualize sidebar thread lists Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/web/src/components/Sidebar.drag.test.ts | 15 +- apps/web/src/components/Sidebar.drag.ts | 5 +- .../web/src/components/Sidebar.motion.test.ts | 57 +- apps/web/src/components/Sidebar.motion.ts | 49 +- .../src/components/Sidebar.pointer.test.ts | 33 +- apps/web/src/components/Sidebar.pointer.ts | 7 + apps/web/src/components/Sidebar.tsx | 542 ++++++++++-------- .../components/sidebar/SidebarVirtualList.tsx | 177 ++++++ apps/web/src/components/ui/sidebar.tsx | 58 +- patches/@legendapp__list@3.3.5.patch | 26 + pnpm-lock.yaml | 10 +- 11 files changed, 686 insertions(+), 293 deletions(-) create mode 100644 apps/web/src/components/sidebar/SidebarVirtualList.tsx diff --git a/apps/web/src/components/Sidebar.drag.test.ts b/apps/web/src/components/Sidebar.drag.test.ts index c757eca87aa4..9a19c102e07d 100644 --- a/apps/web/src/components/Sidebar.drag.test.ts +++ b/apps/web/src/components/Sidebar.drag.test.ts @@ -801,7 +801,14 @@ describe("lifted card clearance", () => { right: 260, width: 260, }); - const apply = (cardTop: number, cardHeight: number, y: number, listTop = 136, offset = 32) => + const apply = ( + cardTop: number, + cardHeight: number, + y: number, + listTop = 136, + offset = 32, + contentTop?: number, + ) => restrictBelowSidebarLabel( { transform: { ...stationary, y }, @@ -817,6 +824,7 @@ describe("lifted card clearance", () => { windowRect: null, }, offset, + contentTop, ); it.each([36, 82])("keeps a %ipx row below empty Pins even past the top edge", (height) => { @@ -826,6 +834,11 @@ describe("lifted card clearance", () => { } }); + it("uses the scrolled list origin instead of a virtual row container", () => { + expect(apply(511, 36, -200, 511, 32, 136).y).toBe(-200); + expect(apply(511, 36, -800, 511, 32, -164).y).toBe(-643); + }); + it("preserves pointer movement below the label", () => { expect(apply(511, 36, -200).y).toBe(-200); }); diff --git a/apps/web/src/components/Sidebar.drag.ts b/apps/web/src/components/Sidebar.drag.ts index bfd112d18998..ae6dc511460d 100644 --- a/apps/web/src/components/Sidebar.drag.ts +++ b/apps/web/src/components/Sidebar.drag.ts @@ -19,9 +19,10 @@ type Layout = Parameters[0]; export function restrictBelowSidebarLabel( { transform, containerNodeRect, draggingNodeRect }: Parameters[0], offset: number, + listTop = containerNodeRect?.top, ) { - if (!containerNodeRect || !draggingNodeRect) return transform; - const minimumY = containerNodeRect.top + offset - draggingNodeRect.top; + if (listTop === undefined || !draggingNodeRect) return transform; + const minimumY = listTop + offset - draggingNodeRect.top; return transform.y < minimumY ? { ...transform, y: minimumY } : transform; } diff --git a/apps/web/src/components/Sidebar.motion.test.ts b/apps/web/src/components/Sidebar.motion.test.ts index 9e92c4d2c76e..b0c0e41742d0 100644 --- a/apps/web/src/components/Sidebar.motion.test.ts +++ b/apps/web/src/components/Sidebar.motion.test.ts @@ -36,8 +36,15 @@ class TestRow { readonly name: string, public offsetHeight = 82, ) {} + get firstElementChild(): TestRow | null { + return this.children[0] ?? null; + } getBoundingClientRect() { - return { top: this.offsetTop + this.dragTranslate, height: this.offsetHeight }; + return { + top: this.offsetTop + this.dragTranslate, + left: this.offsetLeft, + height: this.offsetHeight, + }; } setAttribute(name: string, value: string) { this.removeAttribute(name); @@ -65,12 +72,18 @@ class TestRow { }); } -function fixture(rows: TestRow[]) { +function fixture(rows: TestRow[], virtual = false) { const media = { matches: false }; const parent = { children: rows, ownerDocument: { defaultView: { matchMedia: () => media } }, - getBoundingClientRect: () => ({ top: 0 }), + getBoundingClientRect: () => ({ top: 0, left: 0 }), + scrollTop: 50, + scrollLeft: 0, + querySelectorAll: () => + parent.children.filter((row) => + row.attributes.some((attribute) => attribute.name === "data-sidebar-list-key"), + ), append(node: TestRow) { parent.children.push(node); node.remove.mockImplementation(() => { @@ -81,6 +94,7 @@ function fixture(rows: TestRow[]) { function layout(next: TestRow[]) { let top = 8; for (const row of next) { + if (virtual) row.setAttribute("data-sidebar-list-key", row.name); row.offsetTop = top; top += row.offsetHeight + 1; } @@ -90,7 +104,7 @@ function fixture(rows: TestRow[]) { ]; } layout(rows); - const motion = createSidebarListMotion(parent as unknown as HTMLUListElement); + const motion = createSidebarListMotion(parent as unknown as HTMLUListElement, { virtual }); return { motion, layout, media, parent }; } @@ -172,6 +186,25 @@ describe("sidebar list motion", () => { expectMove(c, 16); }); + it("preserves child drag transforms when releasing virtual rows in a scrolled viewport", () => { + const [a, b] = [new TestRow("a wrapper"), new TestRow("b wrapper")]; + const [aRow, bRow] = [new TestRow("a"), new TestRow("b")]; + a.children = [aRow]; + b.children = [bRow]; + const { motion, layout } = fixture([a, b], true); + aRow.offsetTop = a.offsetTop; + bRow.offsetTop = b.offsetTop; + motion.update(false); + aRow.dragTranslate = 130; + bRow.dragTranslate = -83; + motion.release(); + layout([b, a]); + aRow.dragTranslate = bRow.dragTranslate = 0; + motion.update(true); + expectMove(a, 47); + expect(b.animate).not.toHaveBeenCalled(); + }); + it("does not glide on release when motion is reduced", () => { const [a, b] = [new TestRow("a"), new TestRow("b")]; const { motion, layout, media } = fixture([a, b]); @@ -259,6 +292,22 @@ describe("sidebar list motion", () => { expect(a.animate).toHaveBeenCalledTimes(2); }); + it("excludes virtual exit clones from later row motion", () => { + const a = new TestRow("a"); + const b = new TestRow("b"); + const { motion, layout, parent } = fixture([a, b], true); + motion.update(true); + layout([b]); + motion.update(true); + const clone = a.clones[0]!; + expect(parent.children.includes(clone)).toBe(true); + expect(parent.querySelectorAll().length).toBe(1); + expect(parent.querySelectorAll()[0]).toBe(b); + motion.update(true); + expect(clone.animations).toHaveLength(1); + expect(clone.clones).toHaveLength(0); + }); + it("fades a collapsed-shelf exit at its current visual box and a new wake in", () => { const a = new TestRow("a"); const b = new TestRow("b"); diff --git a/apps/web/src/components/Sidebar.motion.ts b/apps/web/src/components/Sidebar.motion.ts index dba6072e9d01..04a501843bbd 100644 --- a/apps/web/src/components/Sidebar.motion.ts +++ b/apps/web/src/components/Sidebar.motion.ts @@ -20,7 +20,7 @@ function progress(animation: Animation) { /** Animate rows between their layout positions. The list must be * positioned so every direct child's offsetTop has the same origin. */ -export function createSidebarListMotion(parent: HTMLUListElement) { +export function createSidebarListMotion(parent: HTMLElement, options?: { virtual: boolean }) { let positions: Map | null = null; let disposed = false; const reducedMotion = parent.ownerDocument.defaultView?.matchMedia( @@ -33,6 +33,13 @@ export function createSidebarListMotion(parent: HTMLUListElement) { // commit can glide every row from where dnd-kit left it into its slot. let released: Map | null = null; + const rows = () => + Array.from( + options?.virtual + ? parent.querySelectorAll("[data-sidebar-list-key]") + : parent.children, + ).filter((node): node is HTMLElement => node instanceof HTMLElement && !exiting.has(node)); + const remainingOffset = (node: HTMLElement) => { const current = running.get(node); const run = current ? current.offset * (1 - progress(current.animation)) : 0; @@ -56,6 +63,7 @@ export function createSidebarListMotion(parent: HTMLUListElement) { if ( (attribute.name === "id" && element.namespaceURI !== "http://www.w3.org/2000/svg") || attribute.name === "data-thread-item" || + attribute.name === "data-sidebar-list-key" || attribute.name === "data-thread-selection-safe" || attribute.name === "data-testid" ) { @@ -133,17 +141,24 @@ export function createSidebarListMotion(parent: HTMLUListElement) { update(animate: boolean) { if (disposed) return; const next = new Map( - Array.from(parent.children) - .filter((node): node is HTMLElement => node instanceof HTMLElement && !exiting.has(node)) - .map((node) => [ - node, - { - top: node.offsetTop, - left: node.offsetLeft, - width: node.offsetWidth, - height: node.offsetHeight, - }, - ]), + rows().map((node) => [ + node, + { + top: options?.virtual + ? node.getBoundingClientRect().top - + parent.getBoundingClientRect().top + + parent.scrollTop - + remainingOffset(node) + : node.offsetTop, + left: options?.virtual + ? node.getBoundingClientRect().left - + parent.getBoundingClientRect().left + + parent.scrollLeft + : node.offsetLeft, + width: node.offsetWidth, + height: node.offsetHeight, + }, + ]), ); let fadeCount = 0; if (positions !== null) { @@ -250,11 +265,13 @@ export function createSidebarListMotion(parent: HTMLUListElement) { * next update glides each of them into its committed slot. */ release() { suspend(); - const origin = parent.getBoundingClientRect().top; + const origin = parent.getBoundingClientRect().top - (options?.virtual ? parent.scrollTop : 0); released = new Map( - Array.from(parent.children) - .filter((node): node is HTMLElement => node instanceof HTMLElement && !exiting.has(node)) - .map((node) => [node, node.getBoundingClientRect().top - origin]), + rows().map((node) => [ + node, + (options?.virtual ? (node.firstElementChild ?? node) : node).getBoundingClientRect().top - + origin, + ]), ); }, suspend, diff --git a/apps/web/src/components/Sidebar.pointer.test.ts b/apps/web/src/components/Sidebar.pointer.test.ts index 9a82edbd6dc7..f68b08e61aa7 100644 --- a/apps/web/src/components/Sidebar.pointer.test.ts +++ b/apps/web/src/components/Sidebar.pointer.test.ts @@ -33,16 +33,17 @@ function gesture() { onPending: vi.fn(), }; const onFinish = vi.fn(); + const onBeforeStart = vi.fn(); // The sensor never reads dnd-kit's layout context or active node. const props = { active: "thread", event: pointer("pointerdown"), - options: { distance: 6, onAttach: vi.fn(), onFinish }, + options: { distance: 6, onAttach: vi.fn(), onFinish, onBeforeStart }, ...callbacks, } as unknown as SensorProps[0]["options"]>; const sensor = new SidebarPointerSensor(props); sensors.push(sensor); - return { sensor, onFinish, ...callbacks }; + return { sensor, onFinish, onBeforeStart, ...callbacks }; } beforeEach(() => { @@ -61,6 +62,34 @@ afterEach(() => { }); describe("sidebar pointer lifecycle", () => { + it("prepares virtual rows before drag capture, without materializing ordinary clicks", () => { + const drag = gesture(); + document.dispatchEvent(pointer("pointermove", { clientY: 14 })); + expect(drag.onBeforeStart).not.toHaveBeenCalled(); + drag.onStart.mockImplementation(() => expect(drag.onBeforeStart).toHaveBeenCalledOnce()); + document.dispatchEvent(pointer("pointermove", { clientY: 20 })); + expect(drag.onStart).toHaveBeenCalledOnce(); + }); + + it("cancels a failed preparation and ignores subsequent pointer events", () => { + const listeners = vi.spyOn(document, "addEventListener"); + const drag = gesture(); + const failure = new Error("Unable to prepare rows"); + drag.onBeforeStart.mockImplementation(() => { + throw failure; + }); + const move = listeners.mock.calls.find(([name]) => name === "pointermove")?.[1]; + if (typeof move !== "function") throw new Error("Missing move listener"); + expect(() => move.call(document, pointer("pointermove", { clientY: 20 }))).toThrow(failure); + document.dispatchEvent(pointer("pointermove", { clientY: 50 })); + document.dispatchEvent(pointer("pointerup", { buttons: 0 })); + expect(drag.onFinish).toHaveBeenCalledOnce(); + expect(drag.onCancel).toHaveBeenCalledOnce(); + expect(drag.onStart).not.toHaveBeenCalled(); + expect(drag.onMove).not.toHaveBeenCalled(); + expect(drag.onEnd).not.toHaveBeenCalled(); + }); + it("keeps a click idle and starts only after the drag threshold", () => { const click = gesture(); document.dispatchEvent(pointer("pointermove", { clientY: 16 })); diff --git a/apps/web/src/components/Sidebar.pointer.ts b/apps/web/src/components/Sidebar.pointer.ts index f79bc06c64be..27e6e82a1d3f 100644 --- a/apps/web/src/components/Sidebar.pointer.ts +++ b/apps/web/src/components/Sidebar.pointer.ts @@ -10,6 +10,7 @@ export function SidebarDragLifecycle({ onUnmount }: { onUnmount: () => void }) { type Options = { distance: number; + onBeforeStart?: () => void; onAttach: (sensor: SidebarPointerSensor) => void; onFinish: (started: boolean) => void; }; @@ -84,6 +85,12 @@ export class SidebarPointerSensor { this.document.addEventListener("click", this.suppressClick, { capture: true }); this.document.addEventListener("selectionchange", this.clearSelection); this.clearSelection(); + try { + this.props.options.onBeforeStart?.(); + } catch (error) { + this.cancel(); + throw error; + } this.props.onStart(this.coordinates()); return; } diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 7b9e0e545d56..88b9a4f81d73 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1,4 +1,6 @@ import { requestCustomSnooze } from "./CustomSnoozeDialog"; +import { flushSync } from "react-dom"; +import { SidebarVirtualList } from "./sidebar/SidebarVirtualList"; import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests"; import { resolveThreadCurrentPullRequestLink } from "@t3tools/shared/threadPullRequests"; import { useAtomValue } from "@effect/atom-react"; @@ -1602,11 +1604,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { data-thread-item {...sortableRootProps} {...(fileDropHandlers ?? {})} - className={cn( - // Matches the h-9 row so unrendered rows never shift the list when they paint. - "list-none [content-visibility:auto] [contain-intrinsic-size:auto_36px]", - sortable?.isDragging && "relative z-20", - )} + className={cn("list-none", sortable?.isDragging && "relative z-20")} > void; onSelect: () => void; onFileDropThreads: (threadRef: ScopedThreadRef, files: File[]) => void; @@ -2118,6 +2114,8 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { // which owns all keyboard interaction for the listbox. tabIndex={-1} aria-selected={props.isHighlighted} + aria-posinset={props.resultIndex + 1} + aria-setsize={props.resultCount} aria-current={accessibility.current} aria-label={accessibility.label} onMouseMove={props.onHighlight} @@ -2702,13 +2700,6 @@ export default function Sidebar() { setActiveSearchResultIndex(0); }, [threadSearchResultOrderKey]); - useEffect(() => { - if (!isSearchingThreads) return; - document - .getElementById(`sidebar-thread-search-result-${activeSearchResultIndex}`) - ?.scrollIntoView({ block: "nearest" }); - }, [activeSearchResultIndex, isSearchingThreads, threadSearchResultOrderKey]); - // Arm a timeout for the earliest upcoming wake so the shelf empties the // moment a snooze expires instead of on the next minute tick. Sorted // soonest-first, so entry 0 is the boundary. @@ -3175,17 +3166,26 @@ export default function Sidebar() { }, [unsnoozeThread], ); - const threadListRef = useRef(null); + const [materializeThreadList, setMaterializeThreadList] = useState(false); + const prepareThreadDrag = useCallback(() => { + listMotionRef.current?.suspend(); + flushSync(() => setMaterializeThreadList(true)); + }, []); + const threadListRef = useRef(null); const dragLabelOffsetRef = useRef(0); - const restrictBelowPins = useCallback( - (args) => restrictBelowSidebarLabel(args, dragLabelOffsetRef.current), - [], - ); + const restrictBelowPins = useCallback((args) => { + const viewport = threadListRef.current; + return restrictBelowSidebarLabel( + args, + dragLabelOffsetRef.current, + viewport ? viewport.getBoundingClientRect().top - viewport.scrollTop : undefined, + ); + }, []); const listMotionRef = useRef | null>(null); - const attachListMotionRef = useCallback((node: HTMLUListElement | null) => { + const attachListMotionRef = useCallback((node: HTMLElement | null) => { threadListRef.current = node; listMotionRef.current?.dispose(); - listMotionRef.current = node === null ? null : createSidebarListMotion(node); + listMotionRef.current = node === null ? null : createSidebarListMotion(node, { virtual: true }); listMotionRef.current?.update(false); }, []); @@ -3207,6 +3207,7 @@ export default function Sidebar() { listMotionRef.current?.release(); setDragState(null); } + setMaterializeThreadList(false); }, []); const attachDragSensor = useCallback((sensor: SidebarPointerSensor) => { dragSensorRef.current = sensor; @@ -3218,6 +3219,7 @@ export default function Sidebar() { useSensor(SidebarPointerSensor, { distance: 6, onAttach: attachDragSensor, + onBeforeStart: prepareThreadDrag, onFinish: finishThreadDrag, }), ); @@ -3371,7 +3373,10 @@ export default function Sidebar() { const listRect = list.getBoundingClientRect(); const scale = list.offsetWidth > 0 ? listRect.width / list.offsetWidth : 1; dragLabelOffsetRef.current = - header.getBoundingClientRect().top - listRect.top + SIDEBAR_DRAG_LABEL_HEIGHT * scale; + header.getBoundingClientRect().top - + listRect.top + + list.scrollTop + + SIDEBAR_DRAG_LABEL_HEIGHT * scale; } else { dragLabelOffsetRef.current = 0; } @@ -4442,7 +4447,8 @@ export default function Sidebar() { <> } > - + {isSearchingThreads ? ( threadSearchResults.length > 0 ? ( - + }} + /> ) : (

-

    0 && "flex-1", - )} - > - {(() => { - const renderThreadRowInner = ( - thread: EnvironmentThreadShell, - section: SidebarSection, - sortable?: SortableThreadRowBag, - ) => { - const threadKey = scopedThreadKey( - scopeThreadRef(thread.environmentId, thread.id), - ); - // Settled and snoozed are the ONLY things that collapse a - // row: every other thread is a full card. Density comes - // from users (or the auto rules) actually parking work, - // not from the sidebar second-guessing what still matters. - const isCard = section === "active" || section === "pinned"; - const rowVariant = isCard ? "card" : "slim"; - return ( - { + const renderThreadRowInner = ( + thread: EnvironmentThreadShell, + section: SidebarSection, + sortable?: SortableThreadRowBag, + ) => { + const threadKey = scopedThreadKey( + scopeThreadRef(thread.environmentId, thread.id), + ); + // Settled and snoozed are the ONLY things that collapse a + // row: every other thread is a full card. Density comes + // from users (or the auto rules) actually parking work, + // not from the sidebar second-guessing what still matters. + const isCard = section === "active" || section === "pinned"; + const rowVariant = isCard ? "card" : "slim"; + return ( + + ); + }; + const renderThreadRow = ( + thread: EnvironmentThreadShell, + section: SidebarSection, + ) => { + const threadKey = scopedThreadKey( + scopeThreadRef(thread.environmentId, thread.id), + ); + return ( + + {(bag) => renderThreadRowInner(thread, section, bag)} + + ); + }; + const from = dragState?.activeSection ?? null; + const items: { key: string; render: () => ReactNode }[] = [ + { + key: "draft-sessions", + render: () => ( + - ); - }; - const renderThreadRow = ( - thread: EnvironmentThreadShell, - section: SidebarSection, - ) => { - const threadKey = scopedThreadKey( - scopeThreadRef(thread.environmentId, thread.id), - ); - return ( - - {(bag) => renderThreadRowInner(thread, section, bag)} - - ); - }; - const from = dragState?.activeSection ?? null; - const items: ReactNode[] = [ - , - ]; - for (const item of sidebarListItems) { - if (item.kind === "thread") { - items.push(renderThreadRow(threadByKey.get(item.key)!, item.section)); - continue; - } - switch (item.marker) { - case "pinned-header": - items.push( + ), + }, + ]; + for (const item of sidebarListItems) { + if (item.kind === "thread") { + items.push({ + key: item.key, + render: () => renderThreadRow(threadByKey.get(item.key)!, item.section), + }); + continue; + } + if ( + item.marker === "snoozed-header" || + (item.marker === "settled-header" && snoozedThreads.length === 0) + ) { + items.push({ key: "shelf-spacer", render: () => null }); + } + switch (item.marker) { + case "pinned-header": + items.push({ + key: "pinned-header", + render: () => ( , - ); - break; - case "pinned-divider": - items.push( + /> + ), + }); + break; + case "pinned-divider": + items.push({ + key: "pinned-divider", + render: () => ( , - ); - break; - case "active-placeholder": - items.push( + /> + ), + }); + break; + case "active-placeholder": + items.push({ + key: "active-placeholder", + render: () => ( , - ); - break; - case "snoozed-header": - items.push( + /> + ), + }); + break; + case "snoozed-header": + items.push({ + key: "snoozed-shelf-header", + render: () => ( , - ); - break; - case "settled-header": - items.push( + /> + ), + }); + break; + case "settled-header": + items.push({ + key: "settled-shelf-header", + render: () => ( , - ); - break; - case "settled-placeholder": - items.push( + /> + ), + }); + break; + case "settled-placeholder": + items.push({ + key: "settled-placeholder", + render: () => ( , - ); - break; - } + /> + ), + }); + break; } - return items; - })()} - {settledShelfExpanded && hiddenSettledCount > 0 ? ( -
  • - -
  • - ) : null} -
+ } + if (settledShelfExpanded && hiddenSettledCount > 0) + items.push({ + key: "show-more-settled", + render: () => ( +
  • + +
  • + ), + }); + + return ( + { + const section = sectionByThreadKey.get(item.key); + return section === "pinned" || section === "active" + ? "card" + : section + ? "slim" + : item.key; + }} + // VoiceOver treats an exposed list as an interaction boundary, + // which hides its rows from ordinary linear navigation. The + // sidebar's navigation landmark already names this region. + role="presentation" + fillSpaceKey="shelf-spacer" + activeKey={routeThreadKey} + retainedKeys={[renamingThreadKey, dragState?.activeKey ?? null]} + materialize={materializeThreadList} + draggingKey={dragState?.activeKey} + onViewportRef={attachListMotionRef} + renderItem={(item) => item.render()} + /> + ); + })()} diff --git a/apps/web/src/components/sidebar/SidebarVirtualList.tsx b/apps/web/src/components/sidebar/SidebarVirtualList.tsx new file mode 100644 index 000000000000..11a7cb784e58 --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarVirtualList.tsx @@ -0,0 +1,177 @@ +import { LegendList, type LegendListRef } from "@legendapp/list/react"; +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; + +import { cn } from "~/lib/utils"; +import { getVirtualizedScrollFadeClassName } from "../ui/scroll-area"; + +const EMPTY_KEYS: readonly (string | null)[] = []; + +/** One viewport for the sidebar. Retain the last focused row and rows that own a rename or drag. */ +export function SidebarVirtualList({ + data, + materialize = false, + draggingKey, + onViewportRef, + renderItem, + getItemType, + activeKey, + revealVersion, + retainedKeys = EMPTY_KEYS, + estimatedItemSize = 83, + fillSpaceKey, + role, + id, + "aria-label": label, +}: { + data: T[]; + materialize?: boolean; + draggingKey?: string | undefined; + onViewportRef?: (node: HTMLElement | null) => void; + renderItem: (item: T, index: number) => ReactNode; + getItemType?: (item: T) => string; + activeKey: string | null; + revealVersion?: string; + retainedKeys?: readonly (string | null)[]; + estimatedItemSize?: number; + /** A non-interactive item that keeps the following shelves at the viewport bottom. */ + fillSpaceKey?: string; + /** Search results are a listbox. Thread rows stay presentational so screen + * readers traverse them linearly instead of entering a list boundary. */ + role: "listbox" | "presentation"; + id?: string; + "aria-label"?: string; +}) { + const listRef = useRef(null); + const revealed = useRef<{ key: string; version: string | undefined } | null>(null); + const [focusedKey, setFocusedKey] = useState(null); + const [loaded, setLoaded] = useState(false); + const [fillSpace, setFillSpace] = useState(0); + const extraData = useMemo(() => ({ renderItem, fillSpace }), [renderItem, fillSpace]); + const [fade, setFade] = useState({ top: false, bottom: false }); + const keys = materialize + ? data.map((item) => item.key) + : [ + ...new Set( + [activeKey, focusedKey, fillSpaceKey, ...retainedKeys].filter((key) => key != null), + ), + ]; + + useEffect(() => () => onViewportRef?.(null), [onViewportRef]); + + const updateFade = useCallback(() => { + const viewport = listRef.current?.getScrollableNode(); + if (!viewport) return; + const top = viewport.scrollTop > 1; + const bottom = viewport.scrollTop + viewport.clientHeight < viewport.scrollHeight - 1; + setFade((previous) => + previous.top === top && previous.bottom === bottom ? previous : { top, bottom }, + ); + }, []); + + useEffect(() => { + const list = listRef.current; + if (!loaded || !list || !fillSpaceKey) return; + const viewport: HTMLElement = list.getScrollableNode(); + let frame = 0; + const schedule = () => { + cancelAnimationFrame(frame); + frame = requestAnimationFrame(() => { + const state = list.getState(); + // Subtract the measured spacer, not the pending React value, so a resize + // cannot feed its previous height back into the next content estimate. + const naturalHeight = state.contentLength - (state.sizes.get(fillSpaceKey) ?? 0); + setFillSpace(Math.max(0, viewport.clientHeight - naturalHeight)); + }); + }; + const unlisten = list.getState().listen("totalSize", schedule); + const observer = new ResizeObserver(schedule); + observer.observe(viewport); + schedule(); + return () => { + cancelAnimationFrame(frame); + observer.disconnect(); + unlisten(); + }; + }, [fillSpaceKey, loaded]); + + useEffect(() => { + if (!loaded) return; + if (activeKey === null) { + revealed.current = null; + return; + } + const index = data.findIndex((item) => item.key === activeKey); + if (index < 0) { + revealed.current = null; + return; + } + if (revealed.current?.key === activeKey && revealed.current.version === revealVersion) return; + const list = listRef.current; + if (!list) return; + // Wait for the new data's layout and scroll anchoring before revealing its target. + const frame = requestAnimationFrame(() => { + revealed.current = { key: activeKey, version: revealVersion }; + const viewport = list.getScrollableNode(); + const row = Array.from( + viewport.querySelectorAll("[data-sidebar-list-key]"), + ).find((element) => element.dataset.sidebarListKey === activeKey); + if (row) { + const rect = row.getBoundingClientRect(); + const bounds = viewport.getBoundingClientRect(); + if (rect.top >= bounds.top && rect.bottom <= bounds.bottom) return; + } + void list.scrollToIndex({ index, animated: false, viewPosition: 0.5 }); + }); + return () => cancelAnimationFrame(frame); + }, [activeKey, data, loaded, revealVersion]); + + return ( + item.key} + {...(getItemType ? { getItemType } : {})} + renderItem={({ item, index }) => ( +
    + {renderItem(item, index)} +
    + )} + estimatedItemSize={estimatedItemSize} + drawDistance={400} + recycleItems={false} + maintainVisibleContentPosition + className={cn( + "relative h-0 min-h-0 flex-auto overscroll-contain [scrollbar-width:none] [&::-webkit-scrollbar]:hidden", + // Sortable cards translate past their measured row boxes while dragging. + "[&_:has(>[data-sidebar-list-key])]:[contain:layout_style]!", + "[&_:has(>[data-sidebar-list-key][data-sidebar-dragging])]:z-20", + getVirtualizedScrollFadeClassName(fade), + )} + onLoad={() => { + onViewportRef?.(listRef.current?.getScrollableNode() ?? null); + setLoaded(true); + updateFade(); + }} + onScroll={updateFade} + onItemSizeChanged={updateFade} + onFocusCapture={(event) => { + const row = event.target.closest("[data-sidebar-list-key]"); + // Portal focus still bubbles through this list. Keep its owning row mounted. + if (row?.dataset.sidebarListKey) setFocusedKey(row.dataset.sidebarListKey); + }} + /> + ); +} diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx index 58957b1477d6..f826ec50e868 100644 --- a/apps/web/src/components/ui/sidebar.tsx +++ b/apps/web/src/components/ui/sidebar.tsx @@ -585,39 +585,49 @@ function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) { function SidebarContent({ className, fixedHeader, + scrollable = true, ...props }: React.ComponentProps<"div"> & { fixedHeader?: React.ReactNode; + /** Virtualized children own their viewport and must receive a bounded height. */ + scrollable?: boolean; }) { + const content = ( +
    [data-sidebar=group]+[data-sidebar=group]]:pt-0", + fixedHeader && "[&>[data-sidebar=group]:first-child]:pt-0", + className, + )} + data-sidebar="content" + data-slot="sidebar-content" + {...props} + /> + ); return ( <> {fixedHeader ?
    {fixedHeader}
    : null} {/* Rows take focus on click. Scroll padding would make the browser nudge the list whenever a focused row sits under the fade. */} - -
    [data-sidebar=group]+[data-sidebar=group]]:pt-0", - fixedHeader && "[&>[data-sidebar=group]:first-child]:pt-0", - className, - )} - data-sidebar="content" - data-slot="sidebar-content" - {...props} - /> - + {scrollable ? ( + + {content} + + ) : ( + content + )} ); } diff --git a/patches/@legendapp__list@3.3.5.patch b/patches/@legendapp__list@3.3.5.patch index 88b739824b01..8425421192d9 100644 --- a/patches/@legendapp__list@3.3.5.patch +++ b/patches/@legendapp__list@3.3.5.patch @@ -1450,6 +1450,19 @@ diff --git a/react.js b/react.js index 914d2da..0c2917d 100644 --- a/react.js +++ b/react.js +@@ -4075,6 +4075,12 @@ function handleStickyRecycling(ctx, stickyArray, scroll, drawDistance, currentStickyIdx, pendingRemoval, isPinnedRenderIndex) { + if (arrayIdx === -1) { + state.stickyContainerPool.delete(containerIndex); + set$(ctx, `containerSticky${containerIndex}`, false); ++ // Former alwaysRender rows must leave the expanded pool when no longer buffered. ++ if (state.startBuffered === null || state.endBuffered === null || ++ itemIndex < state.startBuffered || itemIndex > state.endBuffered) { ++ pendingRemoval.push(containerIndex); ++ } ++ + continue; + } + const isRecentSticky = arrayIdx >= currentStickyIdx - 1 && arrayIdx <= currentStickyIdx + 1; @@ -4702,7 +4702,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { contentBelowAnchor = Math.max(0, contentBelowAnchor - ctx.scrollAxisGap); contentBelowAnchor += (ctx.values.get("footerSize") || 0) + getStylePaddingEnd(state.props); @@ -1518,6 +1531,19 @@ diff --git a/react.mjs b/react.mjs index 95465f2..b73bfdf 100644 --- a/react.mjs +++ b/react.mjs +@@ -4054,6 +4054,12 @@ function handleStickyRecycling(ctx, stickyArray, scroll, drawDistance, currentStickyIdx, pendingRemoval, isPinnedRenderIndex) { + if (arrayIdx === -1) { + state.stickyContainerPool.delete(containerIndex); + set$(ctx, `containerSticky${containerIndex}`, false); ++ // Former alwaysRender rows must leave the expanded pool when no longer buffered. ++ if (state.startBuffered === null || state.endBuffered === null || ++ itemIndex < state.startBuffered || itemIndex > state.endBuffered) { ++ pendingRemoval.push(containerIndex); ++ } ++ + continue; + } + const isRecentSticky = arrayIdx >= currentStickyIdx - 1 && arrayIdx <= currentStickyIdx + 1; @@ -4681,7 +4681,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { contentBelowAnchor = Math.max(0, contentBelowAnchor - ctx.scrollAxisGap); contentBelowAnchor += (ctx.values.get("footerSize") || 0) + getStylePaddingEnd(state.props); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e70207f2445f..a29f07f2cb64 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -95,7 +95,7 @@ patchedDependencies: '@effect/vitest@4.0.0-rc.115': 172bd95ef73a13d322888984415d5d18d9d2aa8e13fa529eac235efac837e902 '@expo/metro-config@57.0.12': 96f1a75347e6ea02dc4b7034ace815d8ee39e18b8166ebfb573d9e58328f0dc2 '@ff-labs/fff-node@0.9.4': c4e3cc2420ceb9dc650f9d189e9c24baf7e83f342998bacda5f459a4ce7927a8 - '@legendapp/list@3.3.5': 680cc6a5c5b4a4032e467e7b3fde22f89a84c0ee2e6eac6fda737d6277cc0806 + '@legendapp/list@3.3.5': ca0c23d102312f263482c712b69d9ad2720c30fc3d0d81dae77d0b501f932cc5 '@pierre/diffs@1.3.0-beta.10': 0ccee155b93b63d810e2c1a40c1fd676fb6fbcfa72cf6430dcedf1a3ae475ab4 '@react-native-ai/apple@0.12.0': 2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d '@react-native-menu/menu@2.0.0': f63d256bf6a97a873b5e628eb595bd6ef0075ddd5bdd890fc920f7a6024290dd @@ -256,7 +256,7 @@ importers: version: 57.0.14(@babel/core@7.29.7)(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(expo@57.0.18)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.11.4(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@legendapp/list': specifier: 'catalog:' - version: 3.3.5(patch_hash=680cc6a5c5b4a4032e467e7b3fde22f89a84c0ee2e6eac6fda737d6277cc0806)(react-dom@19.2.3(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 3.3.5(patch_hash=ca0c23d102312f263482c712b69d9ad2720c30fc3d0d81dae77d0b501f932cc5)(react-dom@19.2.3(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@material/material-color-utilities': specifier: 0.3.0 version: 0.3.0 @@ -624,7 +624,7 @@ importers: version: 0.9.0 '@legendapp/list': specifier: 'catalog:' - version: 3.3.5(patch_hash=680cc6a5c5b4a4032e467e7b3fde22f89a84c0ee2e6eac6fda737d6277cc0806)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + version: 3.3.5(patch_hash=ca0c23d102312f263482c712b69d9ad2720c30fc3d0d81dae77d0b501f932cc5)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@noble/hashes': specifier: 'catalog:' version: 1.8.0 @@ -13910,7 +13910,7 @@ snapshots: dependencies: jsbi: 4.3.2 - '@legendapp/list@3.3.5(patch_hash=680cc6a5c5b4a4032e467e7b3fde22f89a84c0ee2e6eac6fda737d6277cc0806)(react-dom@19.2.3(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@legendapp/list@3.3.5(patch_hash=ca0c23d102312f263482c712b69d9ad2720c30fc3d0d81dae77d0b501f932cc5)(react-dom@19.2.3(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: react: 19.2.3 use-sync-external-store: 1.6.0(react@19.2.3) @@ -13918,7 +13918,7 @@ snapshots: react-dom: 19.2.3(react@19.2.3) react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - '@legendapp/list@3.3.5(patch_hash=680cc6a5c5b4a4032e467e7b3fde22f89a84c0ee2e6eac6fda737d6277cc0806)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@legendapp/list@3.3.5(patch_hash=ca0c23d102312f263482c712b69d9ad2720c30fc3d0d81dae77d0b501f932cc5)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: react: 19.2.6 use-sync-external-store: 1.6.0(react@19.2.6) From 4311db88a504e03180326dec0d57791181b5f252 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Wed, 30 Sep 2026 09:00:56 +0200 Subject: [PATCH 2/3] fix(web): keep sidebar a11y and empty state with virtual lists Main made the thread list presentational so VoiceOver traverses rows linearly. Apply the same role to the virtual thread list, and stop the empty list from growing so the empty state stays at the top. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/web/src/components/Sidebar.tsx | 13 ++++++++----- .../src/components/sidebar/SidebarVirtualList.tsx | 7 ++++++- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 88b9a4f81d73..95f11b9545cd 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -3166,11 +3166,6 @@ export default function Sidebar() { }, [unsnoozeThread], ); - const [materializeThreadList, setMaterializeThreadList] = useState(false); - const prepareThreadDrag = useCallback(() => { - listMotionRef.current?.suspend(); - flushSync(() => setMaterializeThreadList(true)); - }, []); const threadListRef = useRef(null); const dragLabelOffsetRef = useRef(0); const restrictBelowPins = useCallback((args) => { @@ -3188,6 +3183,13 @@ export default function Sidebar() { listMotionRef.current = node === null ? null : createSidebarListMotion(node, { virtual: true }); listMotionRef.current?.update(false); }, []); + // Drag targets are measured from mounted rows, so pickup mounts the whole + // list first. finishThreadDrag restores virtualization. + const [materializeThreadList, setMaterializeThreadList] = useState(false); + const prepareThreadDrag = useCallback(() => { + listMotionRef.current?.suspend(); + flushSync(() => setMaterializeThreadList(true)); + }, []); // Hold the chosen section and order until every key write arrives. This // also covers first-time ordering, which assigns keys to keyless neighbors. @@ -5019,6 +5021,7 @@ export default function Sidebar() { // sidebar's navigation landmark already names this region. role="presentation" fillSpaceKey="shelf-spacer" + grow={sidebarListHasRows} activeKey={routeThreadKey} retainedKeys={[renamingThreadKey, dragState?.activeKey ?? null]} materialize={materializeThreadList} diff --git a/apps/web/src/components/sidebar/SidebarVirtualList.tsx b/apps/web/src/components/sidebar/SidebarVirtualList.tsx index 11a7cb784e58..d4f110dcbc32 100644 --- a/apps/web/src/components/sidebar/SidebarVirtualList.tsx +++ b/apps/web/src/components/sidebar/SidebarVirtualList.tsx @@ -19,6 +19,7 @@ export function SidebarVirtualList({ retainedKeys = EMPTY_KEYS, estimatedItemSize = 83, fillSpaceKey, + grow = true, role, id, "aria-label": label, @@ -35,6 +36,9 @@ export function SidebarVirtualList({ estimatedItemSize?: number; /** A non-interactive item that keeps the following shelves at the viewport bottom. */ fillSpaceKey?: string; + /** Fill the remaining sidebar height. Pass false while nothing is listed so + * a following empty state stays at the top instead of below an empty viewport. */ + grow?: boolean; /** Search results are a listbox. Thread rows stay presentational so screen * readers traverse them linearly instead of entering a list boundary. */ role: "listbox" | "presentation"; @@ -154,7 +158,8 @@ export function SidebarVirtualList({ recycleItems={false} maintainVisibleContentPosition className={cn( - "relative h-0 min-h-0 flex-auto overscroll-contain [scrollbar-width:none] [&::-webkit-scrollbar]:hidden", + "relative min-h-0 overscroll-contain [scrollbar-width:none] [&::-webkit-scrollbar]:hidden", + grow && "h-0 flex-auto", // Sortable cards translate past their measured row boxes while dragging. "[&_:has(>[data-sidebar-list-key])]:[contain:layout_style]!", "[&_:has(>[data-sidebar-list-key][data-sidebar-dragging])]:z-20", From edde26dd4d63bd31f55f21495079ffea82628508 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Wed, 30 Sep 2026 09:08:26 +0200 Subject: [PATCH 3/3] fix(web): refresh sidebar list fades on resize The fade only updated on scroll and item size changes, so resizing the sidebar or changing search results could leave the edge masks stale. Observe every virtual list, not just the thread list with a shelf spacer. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/web/src/components/sidebar/SidebarVirtualList.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/sidebar/SidebarVirtualList.tsx b/apps/web/src/components/sidebar/SidebarVirtualList.tsx index d4f110dcbc32..ac419aaca50a 100644 --- a/apps/web/src/components/sidebar/SidebarVirtualList.tsx +++ b/apps/web/src/components/sidebar/SidebarVirtualList.tsx @@ -74,12 +74,15 @@ export function SidebarVirtualList({ useEffect(() => { const list = listRef.current; - if (!loaded || !list || !fillSpaceKey) return; + if (!loaded || !list) return; const viewport: HTMLElement = list.getScrollableNode(); let frame = 0; const schedule = () => { cancelAnimationFrame(frame); frame = requestAnimationFrame(() => { + // Resizes and content changes can move either edge without a scroll event. + updateFade(); + if (!fillSpaceKey) return; const state = list.getState(); // Subtract the measured spacer, not the pending React value, so a resize // cannot feed its previous height back into the next content estimate. @@ -96,7 +99,7 @@ export function SidebarVirtualList({ observer.disconnect(); unlisten(); }; - }, [fillSpaceKey, loaded]); + }, [fillSpaceKey, loaded, updateFade]); useEffect(() => { if (!loaded) return;