authorgravatar for 97427849+flamboh@users.noreply.github.comoliver <97427849+flamboh@users.noreply.github.com> 2026-10-03 13:27:38-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-03 17:27:38-03:00
logfffe6e6c82c12769e0cbc4d89a23ec845c5abed1
tree7470f9cac23c4e224933e81d1868e89d1ff5f44d
parent71dbaf1f94b580491d033f2e347a9b1c507fd793
signature Signed by PGP key B5690EEEBB952194

perf(web): virtualize command palette results (#15266)


8 files changed, 400 insertions(+), 19 deletions(-)

apps/web/src/components/CommandPalette.logic.test.ts+45
......@@ -5,12 +5,14 @@ import { makeThreadFixture } from "../test-fixtures";
55import {
66 buildBrowseGroups,
77 buildCommandPaletteProjectMetadata,
8 buildCommandPaletteRows,
89 buildProjectActionItems,
910 buildThreadActionItems,
1011 buildLinkedThreadActionItems,
1112 enumerateCommandPaletteItems,
1213 filterPinnedBrowseEntries,
1314 filterCommandPaletteGroups,
15 findHighlightedCommandPaletteItem,
1416 reduceCommandPaletteUiState,
1517 type CommandPaletteActionItem,
1618 type CommandPaletteGroup,
......@@ -849,3 +851,46 @@ describe("filterCommandPaletteGroups", () => {
849851 ]);
850852 });
851853});
854
855describe("virtualized command palette rows", () => {
856 const action = (value: string, disabled = false): CommandPaletteActionItem => ({
857 kind: "action",
858 value,
859 searchTerms: [],
860 title: value,
861 icon: null,
862 ...(disabled ? { disabled } : {}),
863 run: async () => {},
864 });
865 const groups: CommandPaletteGroup[] = [
866 { value: "actions", label: "Actions", items: [action("new-thread"), action("offline", true)] },
867 { value: "threads", label: "Threads", items: [action("thread-a"), action("thread-b")] },
868 ];
869
870 it("keeps group order and headings while indexing only enabled items", () => {
871 const { rows, itemValues, rowIndexByItemIndex } = buildCommandPaletteRows(groups);
872
873 expect(rows.map((row) => (row.kind === "label" ? `# ${row.label}` : row.key))).toEqual([
874 "# Actions",
875 "actions:new-thread",
876 "actions:offline",
877 "# Threads",
878 "threads:thread-a",
879 "threads:thread-b",
880 ]);
881 expect(itemValues).toEqual(["new-thread", "thread-a", "thread-b"]);
882 expect(rowIndexByItemIndex).toEqual([1, 4, 5]);
883 expect(rows.flatMap((row) => (row.kind === "item" ? [row.itemIndex] : []))).toEqual([
884 0,
885 null,
886 1,
887 2,
888 ]);
889 });
890
891 it("resolves Enter to the highlighted item without needing its row mounted", () => {
892 expect(findHighlightedCommandPaletteItem(groups, "thread-b")?.value).toBe("thread-b");
893 expect(findHighlightedCommandPaletteItem(groups, "offline")).toBeNull();
894 expect(findHighlightedCommandPaletteItem(groups, null)).toBeNull();
895 });
896});
apps/web/src/components/CommandPalette.logic.ts+59
......@@ -172,6 +172,65 @@ export interface CommandPaletteView {
172172 readonly initialQuery?: string;
173173}
174174
175export type CommandPaletteRow =
176 | {
177 readonly kind: "label";
178 readonly key: string;
179 readonly label: string;
180 readonly first: boolean;
181 }
182 | {
183 readonly kind: "item";
184 readonly key: string;
185 readonly item: CommandPaletteActionItem | CommandPaletteSubmenuItem;
186 /** Position among enabled items, or null for disabled rows the keyboard skips. */
187 readonly itemIndex: number | null;
188 };
189
190/**
191 * Flattens groups into the rows a virtualized list renders. `itemValues` is the
192 * highlightable item order Base UI navigates; `rowIndexByItemIndex` maps a
193 * highlight back to its row for scrolling.
194 */
195export function buildCommandPaletteRows(groups: ReadonlyArray<CommandPaletteGroup>) {
196 const rows: CommandPaletteRow[] = [];
197 const itemValues: string[] = [];
198 const rowIndexByItemIndex: number[] = [];
199 for (const group of groups) {
200 if (group.label) {
201 rows.push({
202 kind: "label",
203 key: `group:${group.value}`,
204 label: group.label,
205 first: rows.length === 0,
206 });
207 }
208 for (const item of group.items) {
209 const itemIndex = item.disabled ? null : itemValues.length;
210 if (itemIndex !== null) {
211 itemValues.push(item.value);
212 rowIndexByItemIndex.push(rows.length);
213 }
214 rows.push({ kind: "item", key: `${group.value}:${item.value}`, item, itemIndex });
215 }
216 }
217 return { rows, itemValues, rowIndexByItemIndex };
218}
219
220/** The enabled item Enter should run for a highlight, whether or not its row is mounted. */
221export function findHighlightedCommandPaletteItem(
222 groups: ReadonlyArray<CommandPaletteGroup>,
223 highlightedItemValue: string | null,
224): CommandPaletteActionItem | CommandPaletteSubmenuItem | null {
225 if (highlightedItemValue === null) return null;
226 for (const group of groups) {
227 for (const item of group.items) {
228 if (item.value === highlightedItemValue && !item.disabled) return item;
229 }
230 }
231 return null;
232}
233
175234export function enumerateCommandPaletteItems(
176235 items: ReadonlyArray<CommandPaletteActionItem>,
177236): CommandPaletteActionItem[] {
apps/web/src/components/CommandPalette.tsx+73-10
......@@ -77,6 +77,7 @@ import {
7777 type KeyboardEvent,
7878 type ReactNode,
7979} from "react";
80import type { LegendListRef } from "@legendapp/list/react";
8081import { useAtomValue } from "@effect/atom-react";
8182
8283import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal";
......@@ -157,7 +158,9 @@ import {
157158 buildRootGroups,
158159 buildThreadActionItems,
159160 buildLinkedThreadActionItems,
161 buildCommandPaletteRows,
160162 enumerateCommandPaletteItems,
163 findHighlightedCommandPaletteItem,
161164 type CommandPaletteActionItem,
162165 type CommandPaletteOpenIntent,
163166 type CommandPaletteSubmenuItem,
......@@ -174,7 +177,10 @@ import {
174177import { orderItemsByPreferredIds, sortLogicalProjectsForSidebar } from "./Sidebar.logic";
175178import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic";
176179import { CommandPaletteContent } from "./CommandPaletteContent";
177import { CommandPaletteResults } from "./CommandPaletteResults";
180import {
181 CommandPaletteVirtualizedResults,
182 scrollCommandPaletteRowIntoView,
183} from "./CommandPaletteResults";
178184import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon, ForgejoIcon } from "./Icons";
179185import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
180186import { Checkbox } from "./ui/checkbox";
......@@ -694,6 +700,15 @@ function OpenCommandPaletteDialog(props: {
694700 const deferredQuery = useDeferredValue(query);
695701 const isActionsOnly = deferredQuery.startsWith(">");
696702 const [highlightedItemValue, setHighlightedItemValue] = useState<string | null>(null);
703 const resultListRef = useRef<LegendListRef | null>(null);
704 // Typing or entering a submenu clears the highlight. Base UI keeps its own on the
705 // first row, but the palette shows none until the user navigates, and the first
706 // ArrowDown lands on it.
707 const highlightClearedRef = useRef(false);
708 function clearTypedHighlight(): void {
709 highlightClearedRef.current = true;
710 setHighlightedItemValue(null);
711 }
697712 const clientSettings = useClientSettings();
698713 const createProject = useAtomCommand(projectEnvironment.create, {
699714 reportFailure: false,
......@@ -1425,7 +1440,7 @@ function OpenCommandPaletteDialog(props: {
14251440 ? {
14261441 source: match.source,
14271442 snippet: match.snippet,
1428 query: threadSearchQuery,
1443 query: threadSearch.query,
14291444 }
14301445 : undefined;
14311446 },
......@@ -1446,7 +1461,7 @@ function OpenCommandPaletteDialog(props: {
14461461 projectTitleById,
14471462 providerEntryByEnvironmentAndInstanceId,
14481463 threadContentMatchByKey,
1449 threadSearchQuery,
1464 threadSearch.query,
14501465 threads,
14511466 ],
14521467 );
......@@ -1463,6 +1478,7 @@ function OpenCommandPaletteDialog(props: {
14631478 ...(view.initialQuery ? { initialQuery: view.initialQuery } : {}),
14641479 },
14651480 ]);
1481 highlightClearedRef.current = true;
14661482 setHighlightedItemValue(null);
14671483 setQuery(view.initialQuery ?? "");
14681484 },
......@@ -1494,7 +1510,7 @@ function OpenCommandPaletteDialog(props: {
14941510
14951511 function handleQueryChange(nextQuery: string): void {
14961512 browseNavigation.invalidate();
1497 setHighlightedItemValue(null);
1513 clearTypedHighlight();
14981514 setQuery(nextQuery);
14991515 if (nextQuery === "" && currentView?.initialQuery) {
15001516 popView();
......@@ -2938,6 +2954,9 @@ function OpenCommandPaletteDialog(props: {
29382954 } else if (isBrowsing) {
29392955 displayedGroups = relativePathNeedsActiveProject ? [] : browseGroups;
29402956 }
2957 const resultRows = buildCommandPaletteRows(displayedGroups);
2958 const autoHighlightsFirstRow =
2959 !isBrowsing && !isRemoteProjectCloneFlow && newProjectFlow === null;
29412960
29422961 const inputPlaceholder =
29432962 newProjectFlow !== null
......@@ -3083,6 +3102,43 @@ function OpenCommandPaletteDialog(props: {
30833102 if (event.key === "Backspace" && query === "" && isSubmenu) {
30843103 event.preventDefault();
30853104 popView();
3105 return;
3106 }
3107
3108 // Base UI ignores navigation keys with modifiers, so these fallbacks do too.
3109 if (event.ctrlKey || event.shiftKey || event.altKey || event.metaKey) return;
3110 // Base UI only keeps a hidden highlight on the first row when it auto-highlights.
3111 const firstItemValue = autoHighlightsFirstRow ? resultRows.itemValues[0] : undefined;
3112 if (
3113 event.key === "ArrowDown" &&
3114 highlightClearedRef.current &&
3115 firstItemValue &&
3116 !event.nativeEvent.isComposing
3117 ) {
3118 (event as typeof event & { preventBaseUIHandler?: () => void }).preventBaseUIHandler?.();
3119 event.preventDefault();
3120 highlightClearedRef.current = false;
3121 setHighlightedItemValue(firstItemValue);
3122 scrollCommandPaletteRowIntoView(
3123 resultListRef.current,
3124 resultRows.rowIndexByItemIndex[0] ?? 0,
3125 );
3126 return;
3127 }
3128
3129 // Base UI clicks the highlighted row on Enter, which does nothing once the
3130 // virtualized list has unmounted it, so run the tracked highlight directly.
3131 if (event.key === "Enter" && !event.nativeEvent.isComposing && event.keyCode !== 229) {
3132 const highlightedItem = findHighlightedCommandPaletteItem(
3133 displayedGroups,
3134 highlightedItemValue ?? firstItemValue ?? null,
3135 );
3136 if (highlightedItem) {
3137 (event as typeof event & { preventBaseUIHandler?: () => void }).preventBaseUIHandler?.();
3138 event.preventDefault();
3139 event.stopPropagation();
3140 executeItem(highlightedItem);
3141 }
30863142 }
30873143 }
30883144
......@@ -3349,9 +3405,7 @@ function OpenCommandPaletteDialog(props: {
33493405 <CommandPaletteContent
33503406 key={`${viewStack.length}-${browseGeneration}-${isBrowsing}-${newProjectFlow ? "new-project" : (addProjectCloneFlow?.step ?? "none")}`}
33513407 aria-label="Command palette"
3352 autoHighlight={
3353 isBrowsing || isRemoteProjectCloneFlow || newProjectFlow !== null ? false : "always"
3354 }
3408 autoHighlight={autoHighlightsFirstRow ? "always" : false}
33553409 footerActionLabel={footerActionLabel}
33563410 footerTrailing={footerTrailing}
33573411 inputAccessory={inputAccessory}
......@@ -3387,8 +3441,16 @@ function OpenCommandPaletteDialog(props: {
33873441 onKeyDown: handleKeyDown,
33883442 }}
33893443 mode="none"
3390 onItemHighlighted={(value) => {
3444 items={resultRows.itemValues}
3445 virtualized
3446 onItemHighlighted={(value, eventDetails) => {
3447 if (eventDetails.reason === "none" && highlightClearedRef.current) return;
3448 highlightClearedRef.current = false;
33913449 setHighlightedItemValue(typeof value === "string" ? value : null);
3450 const rowIndex = resultRows.rowIndexByItemIndex[eventDetails.index];
3451 if (eventDetails.reason === "keyboard" && rowIndex !== undefined) {
3452 scrollCommandPaletteRowIntoView(resultListRef.current, rowIndex);
3453 }
33923454 }}
33933455 onValueChange={handleQueryChange}
33943456 showBackHint={isSubmenu}
......@@ -3426,8 +3488,9 @@ function OpenCommandPaletteDialog(props: {
34263488 </div>
34273489 </div>
34283490 ) : null}
3429 <CommandPaletteResults
3430 groups={displayedGroups}
3491 <CommandPaletteVirtualizedResults
3492 rows={resultRows.rows}
3493 listRef={resultListRef}
34313494 highlightedItemValue={highlightedItemValue}
34323495 isActionsOnly={isActionsOnly}
34333496 keybindings={keybindings}
apps/web/src/components/CommandPaletteResults.tsx+102-8
......@@ -1,9 +1,13 @@
11import { type ResolvedKeybindingsConfig } from "@t3tools/contracts";
2import { LegendList, type LegendListRef } from "@legendapp/list/react";
23import { ChevronRightIcon } from "lucide-react";
4import { type RefObject, useCallback, useState } from "react";
5import { cn } from "~/lib/utils";
36import { shortcutLabelForCommand } from "../keybindings";
47import {
58 type CommandPaletteActionItem,
69 type CommandPaletteGroup,
10 type CommandPaletteRow,
711 type CommandPaletteSubmenuItem,
812} from "./CommandPalette.logic";
913import {
......@@ -12,8 +16,11 @@ import {
1216 CommandGroupLabel,
1317 CommandItem,
1418 CommandList,
19 CommandListHeading,
20 CommandListVirtualized,
1521 CommandShortcut,
1622} from "./ui/command";
23import { getVirtualizedScrollFadeClassName } from "./ui/scroll-area";
1724import { ThreadSearchMatchExcerpt } from "./ThreadSearchMatch";
1825
1926interface CommandPaletteResultsProps {
......@@ -25,16 +32,20 @@ interface CommandPaletteResultsProps {
2532 onExecuteItem: (item: CommandPaletteActionItem | CommandPaletteSubmenuItem) => void;
2633}
2734
35function CommandPaletteEmptyState(props: { emptyStateMessage?: string; isActionsOnly: boolean }) {
36 return (
37 <div className="py-10 text-center text-sm text-muted-foreground">
38 {props.emptyStateMessage ??
39 (props.isActionsOnly
40 ? "No matching actions."
41 : "No matching commands, projects, or threads.")}
42 </div>
43 );
44}
45
2846export function CommandPaletteResults(props: CommandPaletteResultsProps) {
2947 if (props.groups.length === 0) {
30 return (
31 <div className="py-10 text-center text-sm text-muted-foreground">
32 {props.emptyStateMessage ??
33 (props.isActionsOnly
34 ? "No matching actions."
35 : "No matching commands, projects, or threads.")}
36 </div>
37 );
48 return <CommandPaletteEmptyState {...props} />;
3849 }
3950
4051 return (
......@@ -63,6 +74,87 @@ export function CommandPaletteResults(props: CommandPaletteResultsProps) {
6374 );
6475}
6576
77/**
78 * Scrolls a keyboard highlight into view the way the unvirtualized list did:
79 * nearest edge, clear of the scroll fade. Rows outside the rendered window
80 * fall back to the list's own scrolling.
81 */
82export function scrollCommandPaletteRowIntoView(list: LegendListRef | null, rowIndex: number) {
83 const element = list?.getState?.().elementAtIndex(rowIndex);
84 if (element instanceof HTMLElement) {
85 element.scrollIntoView({ block: "nearest" });
86 return;
87 }
88 void list?.scrollIndexIntoView?.({ index: rowIndex, animated: false });
89}
90
91interface CommandPaletteVirtualizedResultsProps extends Omit<CommandPaletteResultsProps, "groups"> {
92 rows: ReadonlyArray<CommandPaletteRow>;
93 listRef: RefObject<LegendListRef | null>;
94}
95
96/**
97 * Renders only the visible rows. The parent passes the flat item order to the
98 * Command root as `items` with `virtualized`, scrolls keyboard highlights into
99 * view through `listRef`, and runs Enter itself since the row may be unmounted.
100 */
101export function CommandPaletteVirtualizedResults(props: CommandPaletteVirtualizedResultsProps) {
102 const { listRef } = props;
103 const [scrollFade, setScrollFade] = useState({ top: false, bottom: false });
104 const updateScrollFade = useCallback(() => {
105 const scrollElement = listRef.current?.getScrollableNode?.();
106 if (!(scrollElement instanceof HTMLElement)) return;
107 const top = scrollElement.scrollTop > 1;
108 const bottom =
109 scrollElement.scrollHeight - scrollElement.clientHeight - scrollElement.scrollTop > 1;
110 setScrollFade((current) =>
111 current.top === top && current.bottom === bottom ? current : { top, bottom },
112 );
113 }, [listRef]);
114
115 if (props.rows.length === 0) {
116 return <CommandPaletteEmptyState {...props} />;
117 }
118
119 return (
120 <CommandListVirtualized>
121 <LegendList<CommandPaletteRow>
122 ref={listRef}
123 data={props.rows}
124 keyExtractor={(row) => row.key}
125 getItemType={(row) => row.kind}
126 extraData={props.highlightedItemValue}
127 renderItem={({ item: row }) =>
128 row.kind === "label" ? (
129 <div className={row.first ? undefined : "pt-1.5"}>
130 <CommandListHeading>{row.label}</CommandListHeading>
131 </div>
132 ) : row.itemIndex === null ? (
133 <DisabledCommandPaletteResultRow item={row.item} />
134 ) : (
135 <CommandPaletteResultRow
136 index={row.itemIndex}
137 item={row.item}
138 keybindings={props.keybindings}
139 isActive={props.highlightedItemValue === row.item.value}
140 onExecuteItem={props.onExecuteItem}
141 />
142 )
143 }
144 estimatedItemSize={40}
145 drawDistance={400}
146 onLayout={updateScrollFade}
147 onScroll={updateScrollFade}
148 contentContainerClassName="px-2"
149 className={cn(
150 "min-h-0 scroll-py-6 overflow-x-hidden overscroll-y-contain py-2",
151 getVirtualizedScrollFadeClassName(scrollFade),
152 )}
153 />
154 </CommandListVirtualized>
155 );
156}
157
66158function DisabledCommandPaletteResultRow(props: {
67159 item: CommandPaletteActionItem | CommandPaletteSubmenuItem;
68160}) {
......@@ -96,6 +188,7 @@ function DisabledCommandPaletteResultRow(props: {
96188}
97189
98190function CommandPaletteResultRow(props: {
191 index?: number;
99192 item: CommandPaletteActionItem | CommandPaletteSubmenuItem;
100193 isActive: boolean;
101194 keybindings: ResolvedKeybindingsConfig;
......@@ -107,6 +200,7 @@ function CommandPaletteResultRow(props: {
107200
108201 return (
109202 <CommandItem
203 {...(props.index === undefined ? {} : { index: props.index })}
110204 value={props.item.value}
111205 active={props.isActive}
112206 onMouseDown={(event) => {
apps/web/src/components/ui/autocomplete.tsx+27-1
......@@ -146,11 +146,35 @@ function AutocompleteGroup({ className, ...props }: AutocompletePrimitive.Group.
146146 );
147147}
148148
149const GROUP_LABEL_CLASS_NAME = "px-2 py-1.5 font-medium text-muted-foreground text-xs";
150
149151function AutocompleteGroupLabel({ className, ...props }: AutocompletePrimitive.GroupLabel.Props) {
150152 return (
151153 <AutocompletePrimitive.GroupLabel
152 className={cn("px-2 py-1.5 font-medium text-muted-foreground text-xs", className)}
154 className={cn(GROUP_LABEL_CLASS_NAME, className)}
155 data-slot="autocomplete-group-label"
156 {...props}
157 />
158 );
159}
160
161// A group label for virtualized lists, where rows cannot be wrapped in a group.
162function AutocompleteListHeading({ className, ...props }: React.ComponentProps<"div">) {
163 return (
164 <div
165 className={cn(GROUP_LABEL_CLASS_NAME, className)}
153166 data-slot="autocomplete-group-label"
167 role="presentation"
168 {...props}
169 />
170 );
171}
172
173function AutocompleteListVirtualized({ className, ...props }: AutocompletePrimitive.List.Props) {
174 return (
175 <AutocompletePrimitive.List
176 className={cn("size-full min-w-0", className)}
177 data-slot="autocomplete-list"
154178 {...props}
155179 />
156180 );
......@@ -226,6 +250,8 @@ export {
226250 AutocompleteGroupLabel,
227251 AutocompleteEmpty,
228252 AutocompleteList,
253 AutocompleteListHeading,
254 AutocompleteListVirtualized,
229255 AutocompleteClear,
230256 AutocompleteCollection,
231257};
apps/web/src/components/ui/command.tsx+27
......@@ -12,6 +12,8 @@ import {
1212 AutocompleteInput,
1313 AutocompleteItem,
1414 AutocompleteList,
15 AutocompleteListHeading,
16 AutocompleteListVirtualized,
1517} from "~/components/ui/autocomplete";
1618import { DIALOG_BACKDROP_CLASS, DIALOG_POPUP_CLASS } from "~/components/ui/dialog-styles";
1719import { Button } from "~/components/ui/button";
......@@ -123,6 +125,29 @@ function CommandList({ className, ...props }: React.ComponentProps<typeof Autoco
123125 );
124126}
125127
128// Pair with a LegendList child; the list owns scrolling and spacing.
129function CommandListVirtualized({
130 className,
131 ...props
132}: React.ComponentProps<typeof AutocompleteListVirtualized>) {
133 return (
134 <AutocompleteListVirtualized
135 className={cn("flex max-h-[inherit] flex-col", className)}
136 data-slot="command-list"
137 {...props}
138 />
139 );
140}
141
142function CommandListHeading({
143 className,
144 ...props
145}: React.ComponentProps<typeof AutocompleteListHeading>) {
146 return (
147 <AutocompleteListHeading className={className} data-slot="command-group-label" {...props} />
148 );
149}
150
126151function CommandPanel({ className, ...props }: React.ComponentProps<"div">) {
127152 return (
128153 <div
......@@ -229,6 +254,8 @@ export {
229254 CommandInput,
230255 CommandItem,
231256 CommandList,
257 CommandListHeading,
258 CommandListVirtualized,
232259 CommandPanel,
233260 CommandShortcut,
234261};
apps/web/src/state/queries.threadSearch.test.tsx created+64
......@@ -0,0 +1,64 @@
1import { EnvironmentId } from "@t3tools/contracts";
2import { act } from "react";
3import { create, type ReactTestRenderer } from "react-test-renderer";
4import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
5
6import { useThreadSearch } from "./queries";
7
8const searchResults = vi.hoisted(() => ({
9 matches: [{ threadId: "thread-1", source: "user", snippet: "a link" }],
10 isLoading: false,
11}));
12vi.mock("@effect/atom-react", async (importOriginal) => ({
13 ...(await importOriginal<typeof import("@effect/atom-react")>()),
14 useAtomValue: (atom: { label?: ReadonlyArray<unknown> }) =>
15 String(atom.label?.[0]).includes("empty") ? { matches: [], isLoading: false } : searchResults,
16}));
17
18const environmentIds = [EnvironmentId.make("local")];
19type ThreadSearch = ReturnType<typeof useThreadSearch>;
20
21function Probe(props: { query: string; onResult: (result: ThreadSearch) => void }) {
22 props.onResult(useThreadSearch(environmentIds, props.query));
23 return null;
24}
25
26describe("useThreadSearch", () => {
27 let renderer: ReactTestRenderer | null = null;
28 beforeEach(() => {
29 vi.useFakeTimers();
30 vi.stubGlobal("window", globalThis);
31 });
32 afterEach(() => {
33 act(() => renderer?.unmount());
34 renderer = null;
35 vi.unstubAllGlobals();
36 vi.useRealTimers();
37 });
38
39 it("keeps the settled query and matches stable while typing, then reports the settled query", () => {
40 const results: ThreadSearch[] = [];
41 const render = (query: string) => {
42 const element = <Probe query={query} onResult={(result) => results.push(result)} />;
43 act(() => {
44 if (renderer) renderer.update(element);
45 else renderer = create(element);
46 });
47 return results.at(-1)!;
48 };
49
50 render("");
51 const typing = ["li", "lin", "link "].map(render);
52 expect(typing.map((result) => result.query)).toEqual(["", "", ""]);
53 expect(new Set(typing.map((result) => result.matches)).size).toBe(1);
54 expect(typing.every((result) => result.isPending)).toBe(true);
55
56 act(() => {
57 vi.advanceTimersByTime(200);
58 });
59 const settled = results.at(-1)!;
60 expect(settled.query).toBe("link");
61 expect(settled.matches).toBe(searchResults.matches);
62 expect(settled.isPending).toBe(false);
63 });
64});
apps/web/src/state/queries.ts+3
......@@ -72,6 +72,8 @@ export function useThreadSearch(
7272 query: string,
7373): {
7474 readonly matches: ReadonlyArray<EnvironmentThreadSearchMatch>;
75 /** The settled query `matches` came from; it only changes when results do. */
76 readonly query: string;
7577 readonly isPending: boolean;
7678} {
7779 const normalizedQuery = query.trim();
......@@ -88,6 +90,7 @@ export function useThreadSearch(
8890 const isDebouncing = canSearch && normalizedQuery !== debouncedQuery;
8991 return {
9092 matches: isDebouncing ? EMPTY_THREAD_SEARCH_MATCHES : result.matches,
93 query: settledQuery ?? "",
9194 isPending: canSearch && (isDebouncing || result.isLoading),
9295 };
9396}