It may need to render hundreds of commands or list items, and I got performance problem.
Initial load is so slow when there are thousands of list items to render.
Every time I clear search term there is also a huge delay because it needs to render the entire list.
Bits UI's Command component's built-in filtering method is also too limited. Its value is a string, and default filtering is on the value. There is also custom filtering option, I can provide a filter function, but still it's hard to implement a advanced filtering on multiple fields of list item object. For example, I need to filter on title, subtitle, and keywords field. It is still possible. I have to construct a value to object map Record<String, Item>. Then in the filter function, obtain the original object with value and run filter score function on it.
function customFilter( commandValue: string, search: string, commandKeywords?: string[]): number { const obj = dict[commandValue] const score = ... // run scoring algo return score}
This becomes more complicated, and why don't I use an existing solution like fuse.js?
Even when there are thousands of list items, only ~30 will be rendered on the list
This solution should be very simple, just follow TanStack Virtual's example code in docs.
One problem I had was TanStack virtual assumes there is a single array of items to render, but I need to support sections and items. Sections contains items. TanStack Virtual doesn't provide an example to deal with this kind of nested structure.
My solution is to render sections and items in the same list <div/>. Use a separate virtualizer for each section. When items in each section are listed one by one via #each, they are listed by the virtualizer, so virtualizer can decide whether to really render them based on whether they are really in viewport (this is controlled by scrollMargin.
The position of each item are controlled with translateY style property, and calculated based on scrollMargin. For example, section 1's scrollMargin is 0, and if the height of section 1 is 100px, then the scrollMargin for section 2 becomes 100.
// utils.ts this file contains types and functions for genering dummy dataimport { faker } from "@faker-js/faker";export function generateId() { return Math.random().toString(36).substring(2, 15);}export type Item = { id: string; name: string; description: string;};export type Section = { name: string; items: Item[]; sectionRef: HTMLDivElement | null; sectionHeight: number;};export function getItems(n: number = 10): Item[] { return Array.from({ length: n }, () => ({ id: generateId(), name: faker.person.fullName(), description: faker.lorem.sentence(), }));}export function getSections(n: number = 10): Section[] { return Array.from({ length: n }, () => ({ name: faker.lorem.word(), items: getItems(3), sectionRef: null, sectionHeight: 0, }));}
shouldFilter is set to false in Command.Root.
All the filtering are done with fuse.js and svelte runes. When searchTerm or list items change, fuse.search is run to update resultingItems.
<!-- +page.svelte --><script lang="ts"> import { getSections, getItems } from "./utils.js" import * as Command from "$lib/components/ui/command/index.ts" import { createVirtualizer, type VirtualItem } from "@tanstack/svelte-virtual" import VirtualGroup from "./VirtualGroup.svelte" import Fuse from "fuse.js" import { setContext } from "svelte" const itemHeight = 30 setContext("itemHeight", itemHeight) const sections = $state(getSections(1)) const items = getItems(1000) let searchTerm = $state("") let virtualListEl: HTMLDivElement | null = $state(null) const fuse = new Fuse(items, { includeScore: true, threshold: 0.2, keys: ["name"] }) let resultingItems = $derived( // when search term changes, update the resulting items searchTerm.length > 0 ? fuse.search(searchTerm).map((item) => item.item) : items ) // section total height is auto derived from section refs let sectionTotalHeight = $derived(sections.reduce((acc, s) => acc + (s.sectionHeight ?? 0), 0)) // this should be a list of numbers, the first item is 0, the second item equal to first sectionRef.clientHeight, and so on let sectionsCummulativeHeight = $derived( sections.map((s, i) => sections.slice(0, i).reduce((acc, s) => acc + (s.sectionHeight ?? 0), 0)) ) let virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({ count: items.length, getScrollElement: () => virtualListEl, estimateSize: () => itemHeight, overscan: 5 }) let virtualItems: VirtualItem[] = $state([]) let itemsTotalSize = $state(0) $effect(() => { void resultingItems $virtualizer.setOptions({ count: resultingItems.length, scrollMargin: sectionTotalHeight }) virtualItems = $virtualizer.getVirtualItems() itemsTotalSize = $virtualizer.getTotalSize() })</script><Command.Root shouldFilter={false}> <Command.Input placeholder="Search..." bind:value={searchTerm} /> <Command.List bind:ref={virtualListEl}> <div style="position: relative; height: {itemsTotalSize + sectionTotalHeight}px; width: 100%;"> {#each sections as section, i} <VirtualGroup heading={section.name} items={section.items} parentRef={virtualListEl} bind:sectionRef={section.sectionRef} scrollMargin={sectionsCummulativeHeight[i]} bind:sectionHeight={section.sectionHeight} {searchTerm} /> {/each} {#each virtualItems as row (row.index)} <Command.Item style="position: absolute; top: 0; left: 0; width: 100%; height: {row.size}px; transform: translateY({row.start}px);" > <span>{row.index}: {resultingItems[row.index]?.name}</span> </Command.Item> {/each} </div> </Command.List> <footer class="">hello</footer></Command.Root>
Each section has a sectionRef and sectionHeight field, and are bind to VirtualGroup, so when their height changes, sectionsCummulativeHeight and sectionTotalHeight are also updated with $derived.
Each value in sectionsCummulativeHeight array is the scrollMargin for each section (basically means how much space does your previous sections take).
Within each section/VirutalGroup, filtering is done again with fuse.js on items in the section.
<!-- VirtualGroup.svelte --><script lang="ts"> import * as Command from "$lib/components/ui/command/index.ts" import type { Item } from "./utils.ts" import { getContext, onMount } from "svelte" import { createVirtualizer, type VirtualItem } from "@tanstack/svelte-virtual" import Fuse from "fuse.js" let { heading, items, parentRef, searchTerm, sectionHeight = $bindable(0), sectionRef = $bindable(null), scrollMargin = $bindable(0) }: { heading: string items: Item[] sectionHeight: number searchTerm: string parentRef: HTMLDivElement | null sectionRef: HTMLDivElement | null scrollMargin: number } = $props() const fuse = new Fuse(items, { includeScore: true, threshold: 0.2, keys: ["name"] }) const itemHeight = getContext<number>("itemHeight") ?? 30 let virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({ count: items.length, getScrollElement: () => parentRef, estimateSize: () => itemHeight, overscan: 5 }) let virtualItems: VirtualItem[] = $state([]) let itemsTotalSize = $state(0) let resultingItems = $derived( // when search term changes, update the resulting items searchTerm.length > 0 ? fuse.search(searchTerm).map((item) => item.item) : items ) $effect(() => { // when props.items update, update the fuse collection fuse.setCollection(items) }) $effect(() => { // when resultingItems changes, update virtualizer count and scrollMargin $virtualizer.setOptions({ count: resultingItems.length, scrollMargin }) virtualItems = $virtualizer.getVirtualItems() itemsTotalSize = $virtualizer.getTotalSize() }) $effect(() => { sectionHeight = itemsTotalSize + itemHeight })</script><Command.Group heading={`${heading} (${items.length})`} bind:ref={sectionRef} class="relative" style="height: {sectionHeight}px;"> {#each virtualItems as row (row.index)} <Command.Item style="position: absolute; top: 0; left: 0; width: 100%; height: {row.size}px; transform: translateY({row.start - scrollMargin + itemHeight}px);" > <span>{row.index}: {resultingItems[row.index]?.name}</span> </Command.Item> {/each}</Command.Group>