Virtualized searchable Combobox for label-scale selects (10k+ node/edge labels)
- Dominant language
- TypeScript
- Stars
- 481
- Forks
- 108
- Avg merge
- 6d 8h
- Merged PRs (30d)
- 5
Description
## Problem Statement
When a graph has 10,000+ distinct vertex-type labels and 10,000+ edge-type labels, Graph Explorer's UI locks up. The worst offenders are the node-type/attribute pick-lists rendered as plain Radix `` dropdowns: opening one mounts every option at once, freezing the browser. Because keyword search and the Data Explorer node-type picker are core, unavoidable flows, users on large schemas effectively cannot use the product.
## Solution
Introduce a single reusable, virtualized, searchable **Combobox** component built on Base UI (`@base-ui/react`) with `@tanstack/react-virtual` for windowing, and swap it into the three label-scale select surfaces. Users get a type-to-filter input backed by a windowed result list that stays instant regardless of how many labels exist. Existing small-list selects keep using the current `SelectField`/Radix `Select` untouched.
## User Stories
1. As a user of a graph with 10,000+ vertex labels, I want the keyword-search node-type picker to open instantly, so that the app doesn't freeze when I start a search.
2. As a user, I want to type into the node-type picker to filter labels, so that I can find a type without scrolling thousands of entries.
3. As a user, I want the filtered results to appear immediately as I type, so that searching feels responsive.
4. As a user, I want to pick an attribute to search by from a large attribute list without the UI locking up, so that I can scope my keyword search.
5. As a user, I want the Data Explorer node-type picker to open and filter instantly on a large schema, so that I can browse entities of a chosen type.
6. As a user, I want to select a label with the keyboard (arrow keys + Enter), so that I can operate the picker without a mouse.
7. As a keyboard/screen-reader user, I want the combobox to announce roles, active option, and position correctly, so that it's accessible even though the list is virtualized.
8. As a user, I want to see a clear "no results" state when my filter matches nothing, so that I know the query was applied.
9. As a user, I want the currently selected label shown in the closed control, so that I know my current selection.
10. As a user, I want the filter input to reset when I reopen the picker, so that I always start from the full list.
11. As a user, I want a disabled picker to look and behave as disabled, so that I understand when selection isn't available.
12. As a user with a normal (small) schema, I want the existing selects to keep working exactly as before, so that nothing regresses for me.
13. As a developer, I want one Combobox component encapsulating virtualization and filtering, so that the rest of the app is shielded from those library decisions.
14. As a developer, I want the search-attribute derivation to be memoized, so that typing in keyword search doesn't recompute a full scan-and-sort on every render.
15. As a maintainer, I want Base UI imported via subpath exports, so that it tree-shakes and doesn't duplicate bundle weight against the existing radix-ui dependency.
16. As a maintainer, I want the new dependencies (`@base-ui/react`, `@tanstack/react-virtual`) confined behind the Combobox abstraction, so that adopting a second headless library stays contained.
## Implementation Decisions
- **New Combobox component** built on `@base-ui/react` (v1.7.0+, stable, MIT), styled to match our design system, mimicking shadcn's Base UI combobox wrapper anatomy. Import Base UI via subpath exports to preserve tree-shaking. Two headless libraries (Base UI alongside radix-ui) is an accepted, deliberate, contained decision; the component abstraction shields app code.
- **Virtualization** via `@tanstack/react-virtual`, wired using Base UI's virtualized pattern: the `virtualized` prop on the combobox root, `Combobox.useFilteredItems()` to feed the virtualizer `count`, absolutely-positioned rows with `measureElement`/overscan, and `onItemHighlighted` → `virtualizer.scrollToIndex()` to keep the keyboard-highlighted row mounted. Use a stable item key from branded IDs.
- **Filtering strategy is staged / measure-first.** Start with the default filtering path plus React `useDeferredValue()` on the query to keep the input responsive while the filtered/virtualized list renders on a deferred pass. Only if measurement at 10k still janks, escalate to controlled filtering (`useFilter().contains` over the in-memory array) with debounce and/or a result cap (`limit`). Do not pre-optimize to debounce.
- **Accessibility is provided by Base UI** (W3C APG combobox pattern: roles, keyboard nav, `aria-setsize`/`aria-posinset` for virtualized lists). No hand-rolled a11y.
- **Component API** mirrors `SelectField`'s surface: single-select only (`multiple={false}`), controlled `value` / `onValueChange`, object items shaped `{label, value}` (auto `itemToStringLabel`/`itemToStringValue`), placeholder, `disabled`, `className`, optional `name`. No react-hook-form/error props (nothing uses them).
- **Swap the Combobox into exactly three label-scale sites**, preserving their existing controlled wiring: the keyword-search node-type select and attribute select (currently raw Radix `Select` primitives driven by Jotai atoms `selectedVertexTypeAtom`/`selectedAttributeAtom`), and the Data Explorer node-type picker (currently `SelectField`, router-driven).
- **Leave `SelectField` and all its small-list callers untouched.**
- **Memoize `useSearchableAttributes`** (currently an unmemoized scan + sort + allocation on every render, on the keyword-search hot path) — via `useMemo` or by converting it to a Jotai `atomFamily` keyed by vertex type, so it caches like the other display-config selectors.
## Testing Decisions
- Good tests here assert **external behavior, not implementation or layout** (per `docs/agents/testing.md` — no assertions on CSS classes, element types, DOM node counts, or layout). Runner is Vitest on happy-dom; component tests opt in via the `// @vitest-environment happy-dom` pragma and use React Testing Library + `user-event`.
- **Primary seam: the Combobox component in isolation.** Hand-roll a render wrapper mirroring `Styles.test.tsx` (MemoryRouter + QueryClientProvider + Jotai Provider + TooltipProvider). Cover: type-to-filter narrows the list; selecting calls `onValueChange` with the correct value; placeholder / empty-state / disabled; keyboard (arrow + Enter) selection. **Scale guardrail expressed as behavior:** mount with a 10,000-item array (built via `createArray(10000, createRandomVertexTypeConfig)` from `utils/testing/randomData.ts`), filter to a specific late item (e.g. `GXTestV9000`) and select it — proving it stays interactive at scale without asserting DOM counts.
- **`useSearchableAttributes` memoization test** — hook test via `renderHookWithState` with a large schema; assert the returned array is **referentially stable** across re-renders when inputs are unchanged. This is the real regression guard and needs no layout.
- **Do NOT build a "bounded-render / only-N-rows-mount" unit test** — happy-dom has no layout and no ResizeObserver/getBoundingClientRect stubs, so windowing isn't observable without net-new brittle infra that fights the testing philosophy. Windowing performance is instead validated via chrome-devtools MCP against the 1.4.7 Neptune instance (confirmed 10,044 vertex / 10,015 edge labels), plus a final human-in-the-loop pass.
- **Integration sites** rely on the thin swap; `DataExplorer.test.tsx` already renders the route and will exercise the new component. No net-new suites for `FilterSearchTabContent`/`SelectField` (none exist today).
- Prior art: virtualized-component tests today module-mock the virtualizer (`Styles.test.tsx`, `SchemaExplorerSidebar.test.tsx`); factories in `utils/testing/randomData.ts` and `DbState` schema injection.
## Out of Scope
- Migrating existing `react-virtuoso` surfaces (Entities Filter `CheckboxList`, Vertex/Edge Styles, Connection Data) to TanStack Virtual — they stay on Virtuoso.
- GraphViewer Legend virtualization (backlog).
- `useBackgroundImageMap` per-vertex-type query fan-out fix — one TanStack query per type, ~10k queries (backlog).
- Memoizing the per-render `.values().toArray()` Map→Array copies at the six consumer sites (backlog; do only if 10k still janks after this work).
- Swapping the Combobox into the bounded attribute-scale selects (Data Explorer Display Name/Description pickers, NodeExpand attribute select) — backlog; trivial follow-up once the component exists.
- Multi-select combobox, chips, grouped headers — not needed by the target sites.
## Further Notes
- Target/definition of done: "feels instant" at 10k vertex + 10k edge labels is the goal; "usable" is the acceptable floor if the ideal can't land quickly. High priority is the keyword-search dropdowns (core, unavoidable); the Data Explorer picker rides along in the same swap.
- Base UI's `virtualized` prop is only a hand-off flag — it does not virtualize by itself; the app must bring `@tanstack/react-virtual` and, for 10k+, take over filtering because Base UI's built-in matcher walks all items in memory per keystroke (the actual bottleneck, distinct from render cost).
- Base UI is React 19 + React Compiler compatible (no `"use no memo"` needed).
- The larger display-config selectors (`displayVertexTypeConfigsSelector`/`displayEdgeTypeConfigsSelector`) are already cached Jotai derived atoms and are fine at 10k — only `useSearchableAttributes` recomputes per render.
> [!IMPORTANT]
> Internal only — this issue is maintained by the core team and is not accepting external contributions.
Contributor guide
Research direction
Start by locating the Combobox work and the three named label-scale sites: keyword-search node type, keyword-search attribute, and the Data Explorer node-type picker. Read the testing guidance in docs/agents/testing.md and prior patterns in Styles.test.tsx, SchemaExplorerSidebar.test.tsx, DataExplorer.test.tsx, and utils/testing/randomData.ts. Done means the specified behavior tests pass and the 10k-label flows remain usable, with performance checked as described.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- react, typescript
- Domain
- accessibility, frontend, performance, testing
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 15/100