Skip to content

Virtualized Combobox component and keyword-search node-type picker migration - #2131

Open
mjuarros wants to merge 7 commits into
aws:mainfrom
mjuarros:migrate-combobox-to-base-ui-clean
Open

Virtualized Combobox component and keyword-search node-type picker migration#2131
mjuarros wants to merge 7 commits into
aws:mainfrom
mjuarros:migrate-combobox-to-base-ui-clean

Conversation

@mjuarros

@mjuarros mjuarros commented Aug 18, 2026

Copy link
Copy Markdown

Description

SelectField renders every option as a DOM node unconditionally. With a schema of 10,000+ vertex/edge types, this froze the UI for several seconds every time the Data Explorer node-type picker, the Search Sidebar's node-type filter, or its attribute filter was opened.

This replaces those three pickers with a new Combobox component built on Base UI's accessible combobox primitives plus @tanstack/react-virtual for windowed rendering, so only the visible options (~20) are ever mounted regardless of how many types the schema has. Type-to-filter narrows the list as you type. Every other SelectField usage in the app is untouched — they're all either fixed-size enums or scoped to a single vertex/edge type's own attributes, so they were never at risk of this.

How to read

  1. packages/graph-explorer/src/components/Combobox.tsx — the new component; start here
  2. packages/graph-explorer/src/routes/DataExplorer/DataExplorer.tsx, packages/graph-explorer/src/modules/SearchSidebar/FilterSearchTabContent.tsx, packages/graph-explorer/src/modules/SearchSidebar/useKeywordSearch.ts — the three sites swapped from SelectField to Combobox
  3. packages/graph-explorer/src/core/StateProvider/displayTypeConfigs.ts — a small pre-existing memoization bug in useSearchableAttributes fixed along the way (found while restoring a referential-stability guarantee post-migration); tangential to the main change
  4. packages/graph-explorer/src/components/Combobox.test.tsx, packages/graph-explorer/src/modules/SearchSidebar/useKeywordSearch.test.ts — tests, including a 10,000-item scale guardrail

Validation

Manually validated against a synthetic 10,000-vertex-type / 10,000-edge-type dataset: the Data Explorer and Search Sidebar pickers previously froze the page for several seconds on open; they now open instantly regardless of schema size. Confirmed with an isolated real-Chromium test harness that open latency stays flat (~30ms) across 10, 500, and 10,000 items — a virtualizer-wiring bug that briefly regressed this was caught the same way and fixed before landing.

pnpm checks and pnpm test pass (219 test files, 2696 tests).

Related Issues

Check List

  • I confirm that my contribution is made under the terms of the Apache 2.0 license.
  • I have verified pnpm checks passes with no errors.
  • I have verified pnpm test passes with no failures.
  • I have covered new added functionality with unit tests if necessary.
  • I have updated documentation if necessary.

Delay combobox implementation with no text type option
delayedCombobox

New virtualized combobox with text type option
virtualizedCombobox

mjuarrosImproving and others added 7 commits August 18, 2026 12:06
Backs large option lists (5,000-10,000+ items) with @tanstack/react-virtual
so rendering stays smooth instead of freezing the UI, matching the look
and keyboard/animation behavior of the existing Select component.

Also supports an inner label above the value, matching SelectField's
labelPlacement="inner" style, so sites migrating off SelectField don't
lose that caption.
Swaps the node-type dropdowns in FilterSearchTabContent and DataExplorer
over to the virtualized Combobox to fix UI freezing on schemas with large
numbers of vertex/edge types. DataExplorer uses the inner label to keep
the same caption the SelectField's labelPlacement="inner" showed.
The previous commit shipped a hand-rolled Combobox instead of the
@base-ui/react primitives the proposal called for. This finishes that
migration, then fixes what verifying it surfaced: a popup width
mismatch, an arrow button that didn't close on a second click, an
entrance animation twice as long as every other dropdown, and an
empty-state element reserving phantom space above the list.

The significant fix: the virtualizer was measuring the wrong scroll
element, so it rendered nearly all 10,000 options as real DOM nodes
on every open instead of ~20 - confirmed with an isolated Chromium
harness (~1.75s per open before, ~30ms after). Also fixes a
memoization bug in useSearchableAttributes surfaced while restoring
its referential-stability guarantee, and updates the test suite's
offsetHeight/offsetWidth mock to actually distinguish which element
gets measured.
…bility issues

The virtualized prop was missing from BaseCombobox.Root, silently bounding
keyboard navigation to whatever was mounted in the DOM window instead of
the full option count. Open/filter state was smeared across handlers that
didn't distinguish typed input from Base UI's own programmatic syncs,
causing stale display text and a popup that never closed on focus-out.
The toggle button is no longer hidden from the accessibility tree: when
the input already has a value, VoiceOver's Read-All treats it as content
to read and skips announcing its combobox role, so the button is what
re-announces it in that case.

Also fixes popup sizing/animation, prop passthrough safety, filtering
efficiency, and several tests that didn't assert what they claimed.
useTranslations() returned a fresh function on every render, which fed
into useAttributeOptions's own useMemo dependency array and defeated it
every render regardless of whether the underlying data changed. vertexOptions
had the same problem one level up: a fresh array of fresh objects on every
render, defeating Combobox's own filteredOptions memo on every keystroke.
…ive nanoid

@base-ui/react and @tanstack/react-virtual were declared in both the
workspace root and packages/graph-explorer, though only graph-explorer
imports them. Also adds an override for nanoid (GHSA-2v37-7h3g-55p8),
pulled in transitively via vite > postcss and confined to dev, following
the existing override pattern for esbuild/dompurify/undici.
…-filter support

Adds an ADR for introducing @base-ui/react and @tanstack/react-virtual
alongside the existing Radix/shadcn stack, exceptions to the React
Compiler guidance for the memoization and useVirtualizer cases this PR
needed, the Base UI vs Radix data-attribute divergence, and the jsdom
offsetHeight gotcha the test suite works around. Also updates user-facing
docs to mention that the node-type/attribute pickers now support typing
to filter.
@mjuarros
mjuarros marked this pull request as ready for review August 18, 2026 18:58

@noel-improv noel-improv left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Big PR but overall nice organization just a few comments left

Comment thread pnpm-workspace.yaml
flatted@<3.4.2: ">=3.4.2"
form-data@>=4.0.0 <4.0.6: ">=4.0.6"
minimatch@<10.2.3: ">=10.2.3"
nanoid@<3.3.18: ">=3.3.18"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you confirm this override is intended as unbounded? The lockfile was already on 3.3.18, which is outside <3.3.18, but it still moves to nanoid 6.0.1 (ESM-only, engines.node: ^22 || ^24 || >=26, no require export condition) and postcss loads nanoid/non-secure from CJS. Maybe bound it to ">=3.3.18 <4", or drop it?

// previously validated behavior (fixed across several rounds of
// manual testing).
if (reason !== "keyboard" && reason !== "none") return;
virtualizerRef.current?.scrollToIndex(index, { align: "center" });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this need a guard for the cleared-highlight case? Base UI fires onItemHighlighted with index: -1 and reason: "keyboard" when the highlight clears, which gets past the check above, and scrollToIndex(-1) clamps to 0 — I think that scrolls the list to the top on the first keystroke. Base UI's virtualized example uses if (!item || !virtualizer) return;.

- **A second UI primitive stack now exists, scoped to one file.** An agent choosing between `Combobox` and `Select`/`SelectField` for a new picker should pick based on scale: `Combobox` for schema-sized/unbounded lists that need type-to-filter and virtualization, `Select`/`SelectField` for small bounded enums. Do not introduce a third primitive stack for the same class of problem — extend `Combobox` instead.
- **Base UI's attribute convention differs from Radix's.** Base UI emits bare boolean data attributes (`data-open`, `data-closed`, `data-starting-style`, `data-ending-style`), not Radix's `data-state="open"`/`"closed"`. The project's `data-open:`/`data-closed:` Tailwind shorthand is scoped to the Radix convention and will not match Base UI elements — see `docs/agents/design.md`.
- **`useVirtualizer` needs a React Compiler suppression.** The call in `Combobox.tsx` carries a `// eslint-disable-next-line react-compiler/incompatible-library` comment, since the compiler can't verify the hook's internal mutation patterns are safe to auto-memoize. See `docs/agents/react.md`.
- **The trigger/input interaction pattern is VoiceOver-validated, not just ARIA-linted.** The decorative arrow button is `aria-hidden` and click-only (not keyboard-focusable); the input itself gets an explicit `onClick` handler to open the list, because a text input has no native "click" default action the way a `<button>` does, and VoiceOver's Control-Option-Space gesture needs one to trigger reliably. A grouped, AX-visible trigger button reads to VoiceOver as "stop interacting with this group" rather than "open the list" — this was found and fixed via live VoiceOver testing on macOS across several rounds, not derived from an accessibility guideline. Changing this interaction pattern needs to be re-validated with a screen reader, not just re-derived from ARIA best practices.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This says the arrow button is aria-hidden and click-only, but Combobox.tsx:261-272 keeps the Trigger in the accessibility tree on purpose (with a comment explaining why). Is the ADR the stale one here?

Comment thread docs/agents/design.md
- Use **Tailwind v4 CSS syntax** — `@theme`, `@utility`, `@custom-variant` blocks in CSS.
- Prefer **data attributes** for conditional styles. Tailwind v4 provides two forms:
- `data-open:` / `data-closed:` — shorthand variants (defined via `@custom-variant` in `index.css`) that match `[data-state="open"]` / `[data-state="closed"]`, the Radix convention. Note: this **redefines** Tailwind's native behavior, where a bare `data-open:` would match the presence of a `data-open` attribute. Also: `aria-invalid:` for form validation.
- **Base UI components don't emit `data-state`.** They set bare boolean attributes instead (`data-open`, `data-closed`, `data-starting-style`, `data-ending-style`), so the project's `data-open:`/`data-closed:` shorthand above will silently never match a Base UI element. For Base UI components (currently only `components/Combobox.tsx`), use Tailwind's native bare-attribute variants directly — `data-open:`, `data-ending-style:`, etc. — not the Radix-scoped shorthand.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This warns that data-open:/data-closed: never match Base UI elements, then recommends using them. index.css:5-6 redefines both globally to [data-state="open"]/[data-state="closed"], so they'd still silently fail here. Should this say data-[open]:/data-[closed]: instead? (data-ending-style:/data-starting-style: aren't redefined, so those work as written.)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

3 participants