Repository navigation
perf(web): virtualize long lists behind a shared windowing primitive - #51
Conversation
Shared windowed-list primitive for the web UI virtualization effort: @tanstack/react-virtual with dynamic measurement, an onEndReached infinite-scroll trigger generalized from the runs shell's hand-rolled distance-from-bottom check, optional sticky section headers, and net-new scroll restoration keyed by id. Pure helpers (isNearBottom + the scroll-position store) are unit-tested; the component is auto-exported via the existing ./components/* glob.
The tool tree mounted every row at once — opening a broad source (~3,150 tools for Cloudflare) put thousands of rows, each with a Radix dropdown portal, in the DOM. Flatten the per-account sections and their dotted-name trees into a single item list and render it through VirtualList, so only near-viewport rows mount. Section headers become sticky virtual items; expand/collapse state is lifted to ToolTree (keyed by section+path) so sections stay independent; selection scroll uses the virtualizer's scrollToIndex instead of scrollIntoView on an unmounted row.
The runs table mounted every accumulated row; deep keyset scrolling grew the DOM unbounded (each row carries a per-row actor-label atom subscription). Add a useWindowedRows hook to the shared primitive — windowing for surfaces that can't use <div> rows — and adopt it in RunsShell: the real <table> and sticky <thead> stay, but only near-viewport rows mount, bracketed by top/ bottom spacer <tr>s. The actor and code columns are already width-capped, so windowing a subset doesn't make table-layout:auto columns jitter. RunsPage now hands the shell keyed row descriptors (off-screen runs are cheap element descriptors that never run their per-row hooks) plus the loading/error status row and the load-more retry row. Scroll position is preserved across drawer open/close via scrollRestoreId.
A verbose or agentic run could mount hundreds of log lines or tool-call rows in the detail drawer at once. The single shared ScrollArea wrapped all three tabs; since Radix unmounts inactive tabs, give the two pure-list tabs their own VirtualList scroll containers and keep Properties (mixed content) in a ScrollArea. ToolCallItem's expanded state is lifted into ToolCallsTab so a row scrolled out of the window and back keeps its expansion. The interactions list stays inline on Properties — it's small in practice and embedded in mixed content, so windowing it isn't worth the complexity.
Pull the "which sticky header is pinned" computation out of VirtualList's range extractor into a pure resolveActiveStickyIndex(stickyIndices, startIndex) and unit-test it — the trickiest part of the windowing (it decides which section header stays mounted and pinned as the user scrolls between sections) now has direct coverage independent of the DOM-bound virtualizer.
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
Greptile SummaryIntroduces a shared
Confidence Score: 5/5Safe to merge. The virtualization logic is structurally correct and all three adoption sites behave correctly under normal use. The one subtle edge — scroll restoration firing the The change is entirely additive — a new primitive plus adoption on three surfaces. Existing behavior (infinite scroll, sticky headers, empty/loading states, expanded tool-call state) is preserved or improved. The pure scroll-math helpers are unit-tested. The scroll-restoration-triggers-loadMore concern is conditional and low-impact; it does not corrupt data or break navigation. packages/react/src/components/virtual-list.tsx — the Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
subgraph VirtualList["VirtualList (div rows)"]
VL_virtualizer["useVirtualizer\n(dynamic measure)"]
VL_range["rangeExtractor\n→ activeStickyRef"]
VL_render["render virtualItems\n(sticky or absolute)"]
VL_end["handleScroll\n→ isNearBottom → onEndReached"]
VL_restore["useLayoutEffect\n← readScrollPosition\n→ el.scrollTop = saved"]
end
subgraph useWindowedRows["useWindowedRows (table rows)"]
WR_virtualizer["useVirtualizer\n(fixed estimateSize)"]
WR_spacers["paddingTop / paddingBottom\n→ spacer tr rows"]
WR_end["onScroll\n→ isNearBottom → onEndReached"]
WR_restore["useLayoutEffect\n← readScrollPosition\n→ el.scrollTop = saved"]
end
subgraph scrollStore["Module-level scroll store"]
SS["Map id→scrollTop\nsaveScrollPosition\nreadScrollPosition\nclearScrollPosition"]
end
ToolTree["ToolTree\n(grouped tool list)"] -->|items + isSticky| VL_virtualizer
DetailDrawer["DetailDrawer\n(logs / tool-calls)"] -->|items| VL_virtualizer
RunsShell["RunsShell\n(runs table)"] -->|count + onEndReached| WR_virtualizer
VL_restore <-->|id key| SS
WR_restore <-->|id key| SS
VL_virtualizer --> VL_range
VL_range --> VL_render
VL_render --> VL_end
WR_virtualizer --> WR_spacers
WR_spacers --> WR_end
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
subgraph VirtualList["VirtualList (div rows)"]
VL_virtualizer["useVirtualizer\n(dynamic measure)"]
VL_range["rangeExtractor\n→ activeStickyRef"]
VL_render["render virtualItems\n(sticky or absolute)"]
VL_end["handleScroll\n→ isNearBottom → onEndReached"]
VL_restore["useLayoutEffect\n← readScrollPosition\n→ el.scrollTop = saved"]
end
subgraph useWindowedRows["useWindowedRows (table rows)"]
WR_virtualizer["useVirtualizer\n(fixed estimateSize)"]
WR_spacers["paddingTop / paddingBottom\n→ spacer tr rows"]
WR_end["onScroll\n→ isNearBottom → onEndReached"]
WR_restore["useLayoutEffect\n← readScrollPosition\n→ el.scrollTop = saved"]
end
subgraph scrollStore["Module-level scroll store"]
SS["Map id→scrollTop\nsaveScrollPosition\nreadScrollPosition\nclearScrollPosition"]
end
ToolTree["ToolTree\n(grouped tool list)"] -->|items + isSticky| VL_virtualizer
DetailDrawer["DetailDrawer\n(logs / tool-calls)"] -->|items| VL_virtualizer
RunsShell["RunsShell\n(runs table)"] -->|count + onEndReached| WR_virtualizer
VL_restore <-->|id key| SS
WR_restore <-->|id key| SS
VL_virtualizer --> VL_range
VL_range --> VL_render
VL_render --> VL_end
WR_virtualizer --> WR_spacers
WR_spacers --> WR_end
Reviews (3): Last reviewed commit: "fix(react): key VirtualList's measuremen..." | Re-trigger Greptile |
Wrap ToolCallsTab's toggle in useCallback (empty deps; it only calls the stable setExpanded setter), matching toggleGroup in ToolTree and keeping the windowed renderItem closure stable if ToolCallItem is ever memoized.
Pass getItemKey to the virtualizer so dynamically-measured row heights are cached by stable item key, not by index. VirtualList re-flattens its items on tool-tree expand/collapse/filter; with the default index keying, a reorder left the prior row's measured height cached at that index until the ResizeObserver re-measured. Matches TanStack Virtual's guidance for reordering dynamic lists.
…51) ## Summary Introduces a shared windowed-list primitive (`@executor-js/react/components/virtual-list`) and adopts it on the web-UI surfaces that mount unbounded or long lists, so they keep a bounded DOM instead of rendering every row at once. This is **M1 of a 2-PR stack**. The follow-up adds server-side cursor pagination to `tools.list` — the one list whose API still returns the whole set — and stacks on top of this branch. ## What's here (one commit per surface) - **VirtualList primitive** — `@tanstack/react-virtual` with dynamic measurement, an `onEndReached` infinite-scroll trigger (generalized from the runs shell's hand-rolled distance-from-bottom check), optional sticky section headers, and scroll restoration keyed by id. A `useWindowedRows` hook covers surfaces that can't use `<div>` rows (real `<table>`s). - **Tool source-list** (worst case) — a broad source could mount thousands of tool rows at once, each carrying a dropdown-menu portal. The grouped account sections and dotted-name trees are flattened into one windowed list; section headers become sticky virtual items; only near-viewport rows mount. - **Runs table** — windowed via `useWindowedRows`; the real `<table>` + sticky `<thead>` stay, with top/bottom spacer `<tr>`s for off-screen rows. The two variable-width columns are already capped, so windowing a subset doesn't make `table-layout: auto` jitter. - **Run-detail drawer** — the logs and tool-calls tabs get their own windowed scroll containers; tool-call expanded state is lifted so it survives a row scrolling out of the window and back. - **Sticky-header range logic** extracted to a pure function with unit tests. ## Deliberately not virtualized The integrations catalog, the service-tokens table, and the org-members list are **page-scrolled** lists that hold dozens of rows in practice. Virtualizing them would mean introducing an independent bounded scroller — a UX change for negligible benefit. The primitive is ready if any ever grows unbounded. ## Gates - `typecheck`: 43/43 packages - tests: shared React package 162, execution-history 19 (including new windowing-logic unit tests) - `oxfmt` + `oxlint --deny-warnings` clean on all changed files ## Verification still to do (live browser) Unit tests cover the windowing math; the visual behaviour needs a real browser (the e2e browser surface is cloud-only). Worth exercising by hand: - Sticky section headers pin and swap correctly while scrolling a grouped tool list. - Runs-table column widths stay stable while scrolling deep into the keyset list. - Detail-drawer logs / tool-calls scroll and measure correctly; expanded tool calls keep their state across scroll.
…51) ## Summary Introduces a shared windowed-list primitive (`@executor-js/react/components/virtual-list`) and adopts it on the web-UI surfaces that mount unbounded or long lists, so they keep a bounded DOM instead of rendering every row at once. This is **M1 of a 2-PR stack**. The follow-up adds server-side cursor pagination to `tools.list` — the one list whose API still returns the whole set — and stacks on top of this branch. ## What's here (one commit per surface) - **VirtualList primitive** — `@tanstack/react-virtual` with dynamic measurement, an `onEndReached` infinite-scroll trigger (generalized from the runs shell's hand-rolled distance-from-bottom check), optional sticky section headers, and scroll restoration keyed by id. A `useWindowedRows` hook covers surfaces that can't use `<div>` rows (real `<table>`s). - **Tool source-list** (worst case) — a broad source could mount thousands of tool rows at once, each carrying a dropdown-menu portal. The grouped account sections and dotted-name trees are flattened into one windowed list; section headers become sticky virtual items; only near-viewport rows mount. - **Runs table** — windowed via `useWindowedRows`; the real `<table>` + sticky `<thead>` stay, with top/bottom spacer `<tr>`s for off-screen rows. The two variable-width columns are already capped, so windowing a subset doesn't make `table-layout: auto` jitter. - **Run-detail drawer** — the logs and tool-calls tabs get their own windowed scroll containers; tool-call expanded state is lifted so it survives a row scrolling out of the window and back. - **Sticky-header range logic** extracted to a pure function with unit tests. ## Deliberately not virtualized The integrations catalog, the service-tokens table, and the org-members list are **page-scrolled** lists that hold dozens of rows in practice. Virtualizing them would mean introducing an independent bounded scroller — a UX change for negligible benefit. The primitive is ready if any ever grows unbounded. ## Gates - `typecheck`: 43/43 packages - tests: shared React package 162, execution-history 19 (including new windowing-logic unit tests) - `oxfmt` + `oxlint --deny-warnings` clean on all changed files ## Verification still to do (live browser) Unit tests cover the windowing math; the visual behaviour needs a real browser (the e2e browser surface is cloud-only). Worth exercising by hand: - Sticky section headers pin and swap correctly while scrolling a grouped tool list. - Runs-table column widths stay stable while scrolling deep into the keyset list. - Detail-drawer logs / tool-calls scroll and measure correctly; expanded tool calls keep their state across scroll.
Summary
Introduces a shared windowed-list primitive (
@executor-js/react/components/virtual-list) and adopts it on the web-UI surfaces that mount unbounded or long lists, so they keep a bounded DOM instead of rendering every row at once.This is M1 of a 2-PR stack. The follow-up adds server-side cursor pagination to
tools.list— the one list whose API still returns the whole set — and stacks on top of this branch.What's here (one commit per surface)
@tanstack/react-virtualwith dynamic measurement, anonEndReachedinfinite-scroll trigger (generalized from the runs shell's hand-rolled distance-from-bottom check), optional sticky section headers, and scroll restoration keyed by id. AuseWindowedRowshook covers surfaces that can't use<div>rows (real<table>s).useWindowedRows; the real<table>+ sticky<thead>stay, with top/bottom spacer<tr>s for off-screen rows. The two variable-width columns are already capped, so windowing a subset doesn't maketable-layout: autojitter.Deliberately not virtualized
The integrations catalog, the service-tokens table, and the org-members list are page-scrolled lists that hold dozens of rows in practice. Virtualizing them would mean introducing an independent bounded scroller — a UX change for negligible benefit. The primitive is ready if any ever grows unbounded.
Gates
typecheck: 43/43 packagesoxfmt+oxlint --deny-warningsclean on all changed filesVerification still to do (live browser)
Unit tests cover the windowing math; the visual behaviour needs a real browser (the e2e browser surface is cloud-only). Worth exercising by hand: