Skip to content

[ListVirtualizer] Create the virtualizer component - #5414

Draft
michaldudak wants to merge 63 commits into
mui:masterfrom
michaldudak:list-virtualizer-component
Draft

[ListVirtualizer] Create the virtualizer component#5414
michaldudak wants to merge 63 commits into
mui:masterfrom
michaldudak:list-virtualizer-component

Conversation

@michaldudak

@michaldudak michaldudak commented Aug 4, 2026

Copy link
Copy Markdown
Member

An alternative virtualizer implementation to #5173.

The windowing engine from that PR is unchanged — measurement, scroll correction, adaptive estimates, scrollport padding, all of it. What differs is the public API: instead of a <Combobox.Virtualizer> part, the virtualizer itself becomes a standalone <ListVirtualizer> component exported from @base-ui/react/list-virtualizer.

This branch contains #5173, so the diff against master includes that work. To review only what is different:

git diff combobox-virtualization..list-virtualizer-component

API

import { ListVirtualizer } from '@base-ui/react/list-virtualizer';

<Combobox.List>
  <ListVirtualizer getItemKey={(item) => item.id}>
    {(item) => <Combobox.Item value={item}>{item.name}</Combobox.Item>}
  </ListVirtualizer>
</Combobox.List>

Lists publish a virtualization host through context, and <ListVirtualizer> binds to it and windows the collection directly. Combobox keeps its own store wiring, while a single public component serves any list that opts in: it already works inside <Autocomplete.List> (covered by a test), and Select can join by publishing its own host rather than by adding another part.

The props that only make sense to a list implementation — rows, renderRow, apiRef, pinnedRowIndex, scrollToRowIndex, restoreViewportVersion, onUnconstrainedHeight, totalSizeCssVariable — are no longer public; the component derives them from its host. The public surface is children, getItemKey, estimatedItemHeight, overscanPx, enabled, and actionsRef.

Rendering it outside a list that supports virtualization throws, since there is no collection to window.

Supporting the virtualizer in a list

A list opts in through three pieces: the root owns a registry, the list publishes two contexts, and the item consumes per-row metadata. Combobox does all three; Select would follow the same steps without touching ListVirtualizer itself.

1. Root — own the registry

const virtualizationRegistry = useRefWithInit(createListVirtualizationRegistry).current;

Keep it in the root's store. It is how the root learns that a virtualizer is mounted and hands scrolling over to it:

  • registry.virtualizer.resetScroll() when filtering, instead of resetting the list element directly.
  • registry.virtualizer.getRowMetrics(index) for the geometry of rows that are not mounted.
  • scrollItemIntoView: () => registry.virtualizer?.enabled !== true — a mounted but disabled virtualizer renders the whole collection, so the DOM scrollIntoView that static lists rely on has to stay on until the virtualizer actually owns the scroll position.

2. List — publish two contexts

<ListVirtualizationHostContext.Provider value={virtualizationHost}>
  <ListVirtualizationListStateContext.Provider value={virtualizationListState}>
    {element}
  </ListVirtualizationListStateContext.Provider>
</ListVirtualizationHostContext.Provider>

ListVirtualizationHost carries the wiring and must be stable — memoize it on the store:

Field Purpose
componentName Part namespace used in diagnostics — Combobox produces <Combobox.Item>, <Combobox.Root>
registry The registry the root created
virtualItemContext The list's own React context, through which the virtualizer passes per-row metadata to <Item>
warnUnsupportedConfiguration Dev-only. Warns about configurations the list cannot window, in its own vocabulary. Called only while a virtualizer is mounted, so a windowable list says nothing

ListVirtualizationListState carries everything reactive, and is re-created whenever a member changes:

Field Purpose
items The flat, ordered collection to window
activeIndex Row kept mounted even when it scrolls outside the window
scrollActiveIntoView Whether to also scroll to it — false for pointer highlights, which would otherwise move the list under the cursor
renderAllRows The list temporarily needs every row mounted (browser autofill)
renderAllRowsRestoreVersion Incremented when such a pass ends, so a virtualizer that mounts afterwards still restores its viewport

The contract is deliberately scoped to flat collections: any list whose rows are a single ordered sequence can implement it, while hierarchical collections need a virtualizer of their own. Nothing in it is Combobox-specific, so a Listbox or Feed could publish the same host without changes to ListVirtualizer.

The split between the two is load-bearing. <Item> reads the host context to detect that it is inside a list, and context updates pierce React.memo. Putting the reactive state in the host would re-render every mounted item on every arrow key. Keeping the host stable means a highlight change re-renders only <ListVirtualizer>.

Reading the highlight in the list costs nothing extra in Combobox, because listProps already carries aria-activedescendant and re-renders it on each highlight change anyway.

3. Item — consume the row metadata

The item reads the context the host published and merges what the virtualizer supplies:

const virtualItem = React.useContext(ComboboxVirtualItemContext);

// The virtualizer knows the logical index; `index` stays a prop for static and external rows.
const explicitIndex = virtualItem?.index ?? indexProp;

// `virtualItem.props` supplies aria-posinset, aria-setsize and data-index.
useRenderElement('div', componentProps, {
  props: [itemProps, virtualItem?.props, defaultProps, elementProps],
});

Two dev-only hooks complete it: useVirtualItemDiagnostics (warns when a virtualized item is disabled without isItemDisabled on the root) and useNonVirtualizedItemRegistration (catches static items rendered alongside the virtualizer, in either mount order).

The windowing tests exercise this contract against a ~60-line synthetic host rather than Combobox, which is the check that it is implementable outside the components that ship with it.

Tradeoffs

  • Discoverability. Combobox.Virtualizer showed up in the namespace, the anatomy, and the Combobox API reference. ListVirtualizer needs a separate import and its own docs page. The anatomy snippet and the virtualization section both link to it to compensate.
  • Consistency. Everything else inside a Combobox tree is Combobox.*; this is the sole exception.
  • Misuse is caught at runtime, not by types.

Docs

New List Virtualizer page under Utils covering anatomy, item rendering, styling, sizing, disabled items, scrollToIndex, and third-party virtualizers. The Combobox page, both demo variants, and the virtualizer experiment are updated. It has no demo of its own — it links to the Combobox virtualized demo rather than duplicating it.

🤖 Generated with Claude Code

michaldudak and others added 16 commits July 30, 2026 11:05
…metry rewrites

The virtualization engine only observes native scroll events, so a
corrective scroll write (bottom pin, scroll anchoring) or a browser
clamp after the content shrinks could leave the committed row window
and render-zone transform targeting a superseded scroll position for a
frame. Releasing a scrollbar drag at the bottom blanked the list for a
frame, and an estimate refresh after scrolling up from the bottom
yanked the content down and flashed it mispositioned.

Base the rendered window and transform on the live scroll position
clamped to the current geometry, anticipate the bottom pin that
follows a rewrite at maximum scroll, rebuild windows that fall outside
the viewport, compensate browser clamps in the anchoring fallback, and
re-render before paint when a correction outruns the committed window.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The block padding lived on `Combobox.List`, outside the scroll container,
so it framed the popup at every scroll position and inset the scrollbar.
Drop it, along with the `--available-height` compensation it required.
The TanStack demos keep their own spacing inside the virtual content.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Rows are painted in a sticky viewport nested inside absolutely positioned
content, and padding on the scrollport landed in three different coordinate
systems: the absolute content ignored it, the sticky viewport was pinned
against the content edge, and the scroll math mixed padded `scrollTop` with
unpadded row positions. Padded lists never painted rows in the padding, jumped
by the padding at the maximum scroll position, and pushed all of the space
below the items when the collection was short enough not to scroll.

Measure the block padding and treat it as part of the scroll geometry: the
absolute content spans it, the sticky viewport covers the whole scrollport,
and row positions convert to scroll offsets. `--total-size` now reports the
scrollable content size, so a border-box scrollport sized from it still fits
its rows exactly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Opening a virtualized list with an arrow key scanned only the DOM for the
first enabled item, so the first unmounted slot was picked as enabled and
`isItemDisabled` was ignored. The initial-open scan now consults the consumer
predicate alongside the DOM state, keeping the attribute-based skipping that
mui#2604 relies on.

A disabled `<Combobox.Virtualizer>` stayed registered with the list, which
suppressed the DOM `scrollIntoView` that static lists rely on while the
virtualizer itself scrolled no rows. The registry entry now carries whether
virtualization is enabled, and DOM scrolling is only suppressed while it is.

Also list `Combobox.Virtualizer` in the anatomy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…zation

# Conflicts:
#	docs/src/app/(docs)/react/components/combobox/types.md
The adaptive estimate caches were rewritten during render, so a concurrent
render that React discards — a transition whose sibling suspends, for example —
still cleared the measurements and known row IDs belonging to the tree that
stayed committed.

The decision is still made during render, because the estimate it invalidates
is part of that render's geometry, but it is now applied in a layout effect and
the render-visible estimate is derived from the pure decision instead of the
ref it is about to clear.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@pkg-pr-new

pkg-pr-new Bot commented Aug 4, 2026

Copy link
Copy Markdown

commit: e0fae07

@code-infra-dashboard

code-infra-dashboard Bot commented Aug 4, 2026

Copy link
Copy Markdown

Bundle size

Bundle Parsed size Gzip size
@base-ui/react 🔺+59.3KB(+13.20%) 🔺+19KB(+12.99%)

Details of bundle changes

Performance

Total duration: 1,238.12 ms -94.60 ms(-7.1%) | Renders: 78 (+0) | Paint: 1,943.73 ms -159.48 ms(-7.6%)

Test Duration Renders
Mixed surface mount (app-like density) 65.15 ms ▼-18.54 ms(-22.2%) 5 (+0)

14 tests within noise — details


Check out the code infra dashboard for more information about this PR.

@netlify

netlify Bot commented Aug 4, 2026

Copy link
Copy Markdown

Deploy Preview for base-ui ready!

Built without sensitive environment variables

Name Link
🔨 Latest commit e0fae07
🔍 Latest deploy log https://app.netlify.com/projects/base-ui/deploys/6a71e22b25c28500083bf076
😎 Deploy Preview https://deploy-preview-5414--base-ui.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@michaldudak michaldudak added type: new feature Expand the scope of the product to solve a new problem. component: combobox Changes related to the combobox component. poc labels Aug 4, 2026
@michaldudak
michaldudak force-pushed the list-virtualizer-component branch 2 times, most recently from c7fa37b to 3303bbe Compare August 4, 2026 11:12
The alignment test slept for exactly `DIRECT_INPUT_WINDOW_MS` after shrinking
the rows, so it sampled the geometry at the boundary of the refresh window it
was waiting on and failed on slower machines.

Shrinking the rows to the estimate makes the virtual total settle at exactly
`rows × estimate` once the last measured row is remeasured, which is the point
the alignment has to survive. Wait for that instead of for a delay.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@michaldudak
michaldudak force-pushed the list-virtualizer-component branch from 3303bbe to de7829b Compare August 4, 2026 11:33
Make the list virtualizer an official, list-agnostic virtualization provider
exported from `@base-ui/react/list-virtualizer` instead of a combobox-specific
part.

Lists publish a virtualization host through context, and `<ListVirtualizer>`
binds to it and windows the collection directly. This keeps the combobox store
wiring inside the combobox while a single public component serves any list that
opts in — it already works inside `<Autocomplete.List>`, and Select can join by
publishing its own host.

- Remove the `Combobox.Virtualizer` part and move the virtualizer to
  `list-virtualizer/ListVirtualizer.tsx`.
- Hide the internal props (`rows`, `renderRow`, `apiRef`, `pinnedRowIndex`,
  `scrollToRowIndex`, `restoreViewportVersion`, `onUnconstrainedHeight`,
  `totalSizeCssVariable`), which the component now derives from its host.
- Keep the host context free of reactive state so `<Combobox.Item>` does not
  re-render on every highlight change; the changing state gets its own context
  that only the virtualizer subscribes to.
- Throw instead of warning when the virtualizer is rendered outside a list,
  since there is no collection to render.
- Add a List Virtualizer docs page and update the combobox docs, demos, and
  virtualizer experiment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@michaldudak
michaldudak force-pushed the list-virtualizer-component branch from de7829b to e0fae07 Compare August 4, 2026 12:59
@github-actions github-actions Bot added the PR: out-of-date The pull request has merge conflicts and can't be merged. label Aug 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

component: combobox Changes related to the combobox component. poc PR: out-of-date The pull request has merge conflicts and can't be merged. type: new feature Expand the scope of the product to solve a new problem.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant