[ListVirtualizer] Create the virtualizer component - #5414
Draft
michaldudak wants to merge 63 commits into
Draft
Conversation
…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>
commit: |
Bundle size
PerformanceTotal duration: 1,238.12 ms -94.60 ms(-7.1%) | Renders: 78 (+0) | Paint: 1,943.73 ms -159.48 ms(-7.6%)
14 tests within noise — details Check out the code infra dashboard for more information about this PR. |
✅ Deploy Preview for base-ui ready!Built without sensitive environment variables
To edit notification comments on pull requests, go to your Netlify project configuration. |
michaldudak
force-pushed
the
list-virtualizer-component
branch
2 times, most recently
from
August 4, 2026 11:12
c7fa37b to
3303bbe
Compare
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
force-pushed
the
list-virtualizer-component
branch
from
August 4, 2026 11:33
3303bbe to
de7829b
Compare
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
force-pushed
the
list-virtualizer-component
branch
from
August 4, 2026 12:59
de7829b to
e0fae07
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
masterincludes that work. To review only what is different:API
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 ischildren,getItemKey,estimatedItemHeight,overscanPx,enabled, andactionsRef.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
ListVirtualizeritself.1. Root — own the registry
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 DOMscrollIntoViewthat static lists rely on has to stay on until the virtualizer actually owns the scroll position.2. List — publish two contexts
ListVirtualizationHostcarries the wiring and must be stable — memoize it on the store:componentNameComboboxproduces<Combobox.Item>,<Combobox.Root>registryvirtualItemContext<Item>warnUnsupportedConfigurationListVirtualizationListStatecarries everything reactive, and is re-created whenever a member changes:itemsactiveIndexscrollActiveIntoViewfalsefor pointer highlights, which would otherwise move the list under the cursorrenderAllRowsrenderAllRowsRestoreVersionThe 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 pierceReact.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
listPropsalready carriesaria-activedescendantand 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:
Two dev-only hooks complete it:
useVirtualItemDiagnostics(warns when a virtualized item is disabled withoutisItemDisabledon the root) anduseNonVirtualizedItemRegistration(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
Combobox.Virtualizershowed up in the namespace, the anatomy, and the Combobox API reference.ListVirtualizerneeds a separate import and its own docs page. The anatomy snippet and the virtualization section both link to it to compensate.Combobox.*; this is the sole exception.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