Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/autocomplete-instant-local-search.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/cli-kit': patch
---

Make the autocomplete prompt feel instant when filtering against in-memory choices. The 400ms throttle on the search callback was designed for remote/paginated backends, but it also gated the default in-memory filter used by callers that don't supply their own `search` (e.g. the theme selector), producing a noticeable lag while typing. The prompt now exposes a `searchDebounceMs` prop, and `renderAutocompletePrompt` sets it to `0` when it injects its own synchronous filter. Custom remote-search consumers keep the existing 400ms throttle unless they opt out.
Original file line number Diff line number Diff line change
Expand Up @@ -620,6 +620,34 @@ describe('AutocompletePrompt', async () => {
`)
})

test('searchDebounceMs: 0 invokes search on every keystroke without throttling', async () => {
const search = vi.fn(async (term: string) => ({
data: DATABASE.filter((item) => item.label.includes(term)),
}))

const renderInstance = render(
<AutocompletePrompt
message="Associate your project with the org Castile Ventures?"
choices={DATABASE}
onSubmit={() => {}}
search={search}
searchDebounceMs={0}
/>,
)

await waitForInputsToBeReady()
await sendInputAndWaitForChange(renderInstance, 'f')
await sendInputAndWaitForChange(renderInstance, 'i')
await sendInputAndWaitForChange(renderInstance, 'r')

// With the default 400ms throttle, three rapid keystrokes coalesce to ~2 calls
// (leading + trailing edge). With searchDebounceMs=0, each keystroke fires.
expect(search).toHaveBeenCalledTimes(3)
expect(search).toHaveBeenNthCalledWith(1, 'f')
expect(search).toHaveBeenNthCalledWith(2, 'fi')
expect(search).toHaveBeenNthCalledWith(3, 'fir')
})

test('displays an error message if the search fails', async () => {
const search = (_term: string) => {
return Promise.reject(new Error('Something went wrong'))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,17 @@ export interface AutocompletePromptProps<T> {
abortSignal?: AbortSignal
infoMessage?: InfoMessageProps['message']
groupOrder?: string[]
/**
* Throttle window in milliseconds applied to the search callback. Defaults to 400ms,
* which is appropriate for remote/paginated backends. In-memory consumers (where the
* search callback resolves synchronously) can pass 0 for instant filtering on every
* keystroke.
*/
searchDebounceMs?: number
}

const MIN_NUMBER_OF_ITEMS_FOR_SEARCH = 5
const DEFAULT_SEARCH_DEBOUNCE_MS = 400

function AutocompletePrompt<T>({
message,
Expand All @@ -42,6 +50,7 @@ function AutocompletePrompt<T>({
abortSignal,
infoMessage,
groupOrder,
searchDebounceMs = DEFAULT_SEARCH_DEBOUNCE_MS,
}: React.PropsWithChildren<AutocompletePromptProps<T>>): ReactElement | null {
const complete = useComplete()
const [searchTerm, setSearchTerm] = useState('')
Expand Down Expand Up @@ -121,10 +130,10 @@ function AutocompletePrompt<T>({
clearTimeout(setLoadingWhenSlow.current)
})
},
400,
searchDebounceMs,
{leading: true, trailing: true},
),
[paginatedSearch, setPromptState],
[paginatedSearch, setPromptState, searchDebounceMs],
)

return (
Expand Down
6 changes: 6 additions & 0 deletions packages/cli-kit/src/public/node/ui.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,11 @@ export async function renderAutocompletePrompt<T>(
): Promise<T> {
throwInNonTTY({message: props.message, stdin: renderOptions?.stdin}, uiDebugOptions)

// The default search filters in-memory choices synchronously, so it doesn't need
// throttling. Skipping the throttle makes the keystroke-to-result latency feel
// instant. Callers that supply their own (typically remote/paginated) search keep
// the component's default throttle unless they opt out via `searchDebounceMs`.
const usingDefaultSearch = props.search === undefined
const newProps = {
search(term: string) {
const lowerTerm = term.toLowerCase()
Expand All @@ -426,6 +431,7 @@ export async function renderAutocompletePrompt<T>(
}),
})
},
...(usingDefaultSearch ? {searchDebounceMs: 0} : {}),
...props,
}

Expand Down
Loading