aws / aws/graph-explorer

Virtualized searchable Combobox for label-scale selects (10k+ node/edge labels)

Open
#2,086 0 comments 0 reactions 0 assignees View on GitHub
enhancement internal performance ready-for-agent
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.