From 7336b6f5c2c13a311504836ed4def4f06f3e36d9 Mon Sep 17 00:00:00 2001 From: Daniil Verevkin Date: Tue, 7 Jul 2026 22:33:39 +0200 Subject: [PATCH] Render Select options in chunks as the list scrolls Opening a Select with tens of thousands of options (e.g. picking a target branch in a repo with ~25k remote branches) rendered every option eagerly, freezing the UI for ~13s on open and making the search input unusable (~80s to type a query). Render the first 100 options and grow the window as the dropdown scrolls; reset the window when the search term changes. With the new 25k-option story, opening the dropdown drops from ~13.3s to ~80ms and search typing from ~80s to ~85ms. Co-Authored-By: Claude Fable 5 --- .../src/lib/components/select/Select.svelte | 30 +++++++++++++++++-- .../stories/components/Select.stories.svelte | 27 +++++++++++++++++ 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/lib/components/select/Select.svelte b/packages/ui/src/lib/components/select/Select.svelte index 122e51d6b8d..13f2c28f141 100644 --- a/packages/ui/src/lib/components/select/Select.svelte +++ b/packages/ui/src/lib/components/select/Select.svelte @@ -93,12 +93,36 @@ ), ); + // Rendering every option eagerly freezes the app when the list is huge + // (e.g. selecting a target branch in a repo with tens of thousands of + // remote branches), so render in chunks that grow as the list scrolls. + const RENDER_CHUNK = 100; + const RENDER_MORE_THRESHOLD = 200; // px left to scroll before rendering more + let renderLimit = $state(RENDER_CHUNK); + const renderedOptions = $derived(filteredOptions.slice(0, renderLimit)); + + let lastSearchValue = untrack(() => searchValue); + $effect(() => { + if (searchValue !== lastSearchValue) { + lastSearchValue = searchValue; + renderLimit = RENDER_CHUNK; + } + }); + + function renderMoreOnScroll(e: Event) { + if (renderLimit >= filteredOptions.length) return; + const { scrollTop, scrollHeight, clientHeight } = e.target as HTMLElement; + if (scrollHeight - scrollTop - clientHeight < RENDER_MORE_THRESHOLD) { + renderLimit += RENDER_CHUNK; + } + } + // Group options by separators const groupedOptions = $derived.by(() => { const groups: SelectItem[][] = []; let currentGroup: SelectItem[] = []; - for (const option of filteredOptions) { + for (const option of renderedOptions) { if (option.separator) { if (currentGroup.length > 0) { groups.push(currentGroup); @@ -118,7 +142,7 @@ // Flatten grouped options for navigation while preserving order const selectableOptions = $derived.by( - () => filteredOptions.filter((item) => !item.separator) as SelectItem[], + () => renderedOptions.filter((item) => !item.separator) as SelectItem[], ); // Auto-highlight first option when search results change, reset when search is cleared @@ -379,7 +403,7 @@ tabindex="-1" onkeydown={(ev: KeyboardEvent) => handleKeyDown(ev)} > - + {#if searchable && options.length > 5} {/if} diff --git a/packages/ui/src/stories/components/Select.stories.svelte b/packages/ui/src/stories/components/Select.stories.svelte index b3ac07733d6..0c12a14935b 100644 --- a/packages/ui/src/stories/components/Select.stories.svelte +++ b/packages/ui/src/stories/components/Select.stories.svelte @@ -79,11 +79,17 @@ argTypes: {}, }); + const hugeOptions = Array.from({ length: 25000 }, (_, i) => ({ + value: `branch-${i}`, + label: `origin/feature/branch-${i}`, + })); + let selectedItem = $state("1"); let selectedWithIcon = $state("js"); let selectedWithEmoji = $state("happy"); let selectedLongOption = $state(); let selectedWithSeparators = $state("new"); + let selectedHugeOption = $state(); let customBtnOpen = $state(false); @@ -111,6 +117,27 @@ {/snippet} + + {#snippet template(_args)} +
+ +
+ {/snippet} +
+ {#snippet template(args)}