From 717a55af62dbb3fbc99084036c3d48891113ee72 Mon Sep 17 00:00:00 2001 From: Erick Teowarang Date: Thu, 16 Jul 2026 14:18:12 +1000 Subject: [PATCH 1/2] feat(combobox): add built-in error state to async dropdowns Combobox.Async, Autocomplete.Async, and Select.Async now render an inline error state (message + Retry) in the popover when the fetcher throws/rejects, instead of silently falling back to the misleading "No results." empty state. - Shared useAsyncItems hook gains retry() and an onFetchError option that fires once per outage; aborted/superseded requests are ignored centrally. - New AsyncErrorState component + resolveAsyncContent helper. - New props on each .Async component: errorText, retryText, onFetchError. - Select.useAsync (separate impl) gets matching retry/onFetchError plumbing. - Docs, tests, changeset, and an example in the nextjs demo app. Co-Authored-By: Claude Opus 4.8 --- .changeset/async-fetch-error-state.md | 19 +++ docs/components/autocomplete.md | 17 +- docs/components/combobox.md | 28 ++- docs/components/select.md | 19 ++- .../src/modules/pages/dropdown-demo.tsx | 53 ++++++ .../core/src/components/async-error-state.tsx | 82 +++++++++ .../autocomplete-standalone.test.tsx | 43 +++++ .../components/autocomplete-standalone.tsx | 37 +++- packages/core/src/components/autocomplete.tsx | 5 +- .../components/combobox-standalone.test.tsx | 64 +++++++ .../src/components/combobox-standalone.tsx | 55 +++++- .../core/src/components/combobox.test.tsx | 121 ++++++++++++- .../src/components/select-standalone.test.tsx | 56 ++++++ .../core/src/components/select-standalone.tsx | 159 ++++++++++++------ packages/core/src/hooks/use-async-items.ts | 50 +++++- 15 files changed, 729 insertions(+), 79 deletions(-) create mode 100644 .changeset/async-fetch-error-state.md create mode 100644 packages/core/src/components/async-error-state.tsx diff --git a/.changeset/async-fetch-error-state.md b/.changeset/async-fetch-error-state.md new file mode 100644 index 00000000..47bad86f --- /dev/null +++ b/.changeset/async-fetch-error-state.md @@ -0,0 +1,19 @@ +--- +"@tailor-platform/app-shell": minor +--- + +Add a built-in error state to `Combobox.Async`, `Autocomplete.Async`, and `Select.Async` + +When an async fetcher throws or rejects, the components now render an inline error state in the popover — a "Couldn't load results." message plus a **Retry** button that re-runs the last fetch — instead of silently falling back to the misleading "No results." empty state. This makes failed fetches distinguishable from genuinely-empty results without any consumer code. + +The debounce/abort lifecycle is handled centrally: aborted/superseded requests are ignored, and outages are de-duped so typing during an outage doesn't stack repeated announcements. + +Three new props on each `.Async` component: + +| Prop | Type | Default | Description | +| -------------- | -------------------------- | -------------------------- | ------------------------------------------------------------------ | +| `errorText` | `string` | `"Couldn't load results."` | Message shown in the popover when the fetcher fails | +| `retryText` | `string` | `"Retry"` | Label for the retry button | +| `onFetchError` | `(error: unknown) => void` | - | Called once per outage when a fetch fails (logging/error tracking) | + +The `Combobox.useAsync` / `Autocomplete.useAsync` / `Select.useAsync` hooks now also expose `error` and a `retry()` function and accept an `onFetchError` option, for consumers building custom compositions with `Parts`. diff --git a/docs/components/autocomplete.md b/docs/components/autocomplete.md index f4b0eaad..a210b802 100644 --- a/docs/components/autocomplete.md +++ b/docs/components/autocomplete.md @@ -115,10 +115,13 @@ const fetcher: AutocompleteAsyncFetcher = async (query, { signal }) => { Accepts all the same props as `Autocomplete` except `items`, plus: -| Prop | Type | Default | Description | -| ------------- | ----------------------------- | -------------- | ------------------------------------------------------- | -| `fetcher` | `AutocompleteAsyncFetcher` | - | Fetcher called on each keystroke (debounced by default) | -| `loadingText` | `string` | `"Loading..."` | Text shown while loading | +| Prop | Type | Default | Description | +| -------------- | ----------------------------- | -------------------------- | ------------------------------------------------------------------ | +| `fetcher` | `AutocompleteAsyncFetcher` | - | Fetcher called on each keystroke (debounced by default) | +| `loadingText` | `string` | `"Loading..."` | Text shown while loading | +| `errorText` | `string` | `"Couldn't load results."` | Message shown in the popover when the fetcher fails (with Retry) | +| `retryText` | `string` | `"Retry"` | Label for the retry button in the error state | +| `onFetchError` | `(error: unknown) => void` | - | Called once per outage when a fetch fails (logging/error tracking) | ### AutocompleteAsyncFetcher @@ -131,7 +134,11 @@ type AutocompleteAsyncFetcher = }; ``` -`query` is `null` when the user has not typed anything (e.g. the dropdown was just opened or the input was cleared). Pass `{ fn, debounceMs }` to customize the debounce delay. Errors thrown by the fetcher are silently caught — handle errors inside the fetcher. +`query` is `null` when the user has not typed anything (e.g. the dropdown was just opened or the input was cleared). Pass `{ fn, debounceMs }` to customize the debounce delay. + +### Error handling + +If the fetcher throws or rejects, `Autocomplete.Async` renders a built-in inline error state in the popover — the `errorText` message plus a **Retry** button that re-runs the last fetch — instead of the misleading "No results." empty state. Aborted/superseded requests are ignored, and announcements are de-duped per outage. Pass `onFetchError` to run a side effect (logging, toast) — it fires once per outage and re-arms after the next successful fetch. ## Low-level Primitives diff --git a/docs/components/combobox.md b/docs/components/combobox.md index 74333018..4d4fbfc0 100644 --- a/docs/components/combobox.md +++ b/docs/components/combobox.md @@ -144,10 +144,13 @@ const fetcher: ComboboxAsyncFetcher = async (query, { signal }) => { Accepts all the same props as `Combobox` except `items`, plus: -| Prop | Type | Default | Description | -| ------------- | ------------------------- | -------------- | ------------------------------------------------------- | -| `fetcher` | `ComboboxAsyncFetcher` | - | Fetcher called on each keystroke (debounced by default) | -| `loadingText` | `string` | `"Loading..."` | Text shown while loading | +| Prop | Type | Default | Description | +| -------------- | -------------------------- | -------------------------- | ------------------------------------------------------------------ | +| `fetcher` | `ComboboxAsyncFetcher` | - | Fetcher called on each keystroke (debounced by default) | +| `loadingText` | `string` | `"Loading..."` | Text shown while loading | +| `errorText` | `string` | `"Couldn't load results."` | Message shown in the popover when the fetcher fails (with Retry) | +| `retryText` | `string` | `"Retry"` | Label for the retry button in the error state | +| `onFetchError` | `(error: unknown) => void` | - | Called once per outage when a fetch fails (logging/error tracking) | ### ComboboxAsyncFetcher @@ -160,7 +163,22 @@ type ComboboxAsyncFetcher = }; ``` -`query` is `null` when the user has not typed anything (e.g. the dropdown was just opened or the input was cleared). Pass `{ fn, debounceMs }` to customize the debounce delay. Errors thrown by the fetcher are silently caught — handle errors inside the fetcher. +`query` is `null` when the user has not typed anything (e.g. the dropdown was just opened or the input was cleared). Pass `{ fn, debounceMs }` to customize the debounce delay. + +### Error handling + +If the fetcher throws or rejects, `Combobox.Async` renders a built-in inline error state in the popover — the `errorText` message plus a **Retry** button that re-runs the last fetch — instead of the misleading "No results." empty state. Aborted/superseded requests (a keystroke replaced by a newer one) are ignored, and the component de-dupes per outage so typing during an outage doesn't stack repeated announcements. + +To run a side effect on failure (log to error tracking, show a toast), pass `onFetchError` — it fires **once per outage** (on the transition into the error state) and re-arms after the next successful fetch. The inline error state is shown regardless. + +```tsx + reportError(error)} +/> +``` ## Low-level Primitives diff --git a/docs/components/select.md b/docs/components/select.md index 42e9526c..b61a13e3 100644 --- a/docs/components/select.md +++ b/docs/components/select.md @@ -129,12 +129,15 @@ const fetcher: SelectAsyncFetcher = async ({ signal }) => { Accepts all the same props as `Select` except `items`, plus: -| Prop | Type | Default | Description | -| ---------------------- | ----------------------- | -------------- | ---------------------------------------------------------------------------------------------------------------------------------- | -| `fetcher` | `SelectAsyncFetcher` | - | Fetcher called each time the dropdown is opened | -| `loadingText` | `string` | `"Loading..."` | Text shown while loading | -| `modal` | `boolean` | `false` | Whether the select traps focus (modal behavior). Set to `true` when rendering inside a `Dialog` or `Sheet` to preserve focus-trap. | -| `alignItemWithTrigger` | `boolean` | `false` | Whether to align the selected item with the trigger when the dropdown opens. | +| Prop | Type | Default | Description | +| ---------------------- | -------------------------- | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| `fetcher` | `SelectAsyncFetcher` | - | Fetcher called each time the dropdown is opened | +| `loadingText` | `string` | `"Loading..."` | Text shown while loading | +| `errorText` | `string` | `"Couldn't load results."` | Message shown in the dropdown when the fetcher fails (with Retry) | +| `retryText` | `string` | `"Retry"` | Label for the retry button in the error state | +| `onFetchError` | `(error: unknown) => void` | - | Called once per outage when a fetch fails (logging/error tracking) | +| `modal` | `boolean` | `false` | Whether the select traps focus (modal behavior). Set to `true` when rendering inside a `Dialog` or `Sheet` to preserve focus-trap. | +| `alignItemWithTrigger` | `boolean` | `false` | Whether to align the selected item with the trigger when the dropdown opens. | > **Note:** `Select.Async` does not support `ItemGroup[]` — the fetcher must return a flat array. @@ -146,7 +149,9 @@ Accepts all the same props as `Select` except `items`, plus: type SelectAsyncFetcher = (options: { signal: AbortSignal }) => Promise; ``` -Errors thrown by the fetcher are silently caught — handle errors inside the fetcher (e.g. show a toast, return fallback data). +### Error handling + +If the fetcher throws or rejects, `Select.Async` renders a built-in inline error state in the dropdown — the `errorText` message plus a **Retry** button that re-runs the fetch — instead of the misleading empty state. Aborted requests (the dropdown closed before the fetch settled) are ignored, and announcements are de-duped per outage. Pass `onFetchError` to run a side effect (logging, toast) — it fires once per outage and re-arms after the next successful fetch. ## Low-level Primitives diff --git a/examples/nextjs-app/src/modules/pages/dropdown-demo.tsx b/examples/nextjs-app/src/modules/pages/dropdown-demo.tsx index 59138f90..10342c49 100644 --- a/examples/nextjs-app/src/modules/pages/dropdown-demo.tsx +++ b/examples/nextjs-app/src/modules/pages/dropdown-demo.tsx @@ -79,6 +79,21 @@ const allProgrammingLanguages = [ "CSS", ]; +// ── Error-state demos ── +// These fetchers always reject, so the built-in inline error state is shown and +// clicking "Retry" re-runs the fetch (which fails again) — keeping the example +// idempotent across reloads. Return types are annotated so the pickers still +// infer their item type despite the fetcher never resolving. +const failingFruitFetcher = async (): Promise => { + await new Promise((r) => setTimeout(r, 600)); + throw new Error("Simulated network error"); +}; + +const failingLanguageFetcher = async (_query: string | null): Promise => { + await new Promise((r) => setTimeout(r, 400)); + throw new Error("Simulated network error"); +}; + /** * Example: Combobox creatable with a confirmation dialog. * Demonstrates awaiting user input in onCreateItem via Promise. @@ -285,6 +300,19 @@ const DropdownComponentsDemoPage = () => { placeholder="Async multi-select..." /> + + {/* Error + retry: first open fails, Retry recovers */} +
+
Error + retry
+ ({ label: f.name, key: f.id })} + placeholder="Open to see the error..." + errorText="Couldn't load fruits." + retryText="Try again" + onFetchError={(error) => console.error("Select.Async fetch failed:", error)} + /> +
@@ -384,6 +412,19 @@ const DropdownComponentsDemoPage = () => { placeholder="Add programming languages..." /> + + {/* Error + retry: first fetch fails, Retry recovers */} +
+
Error + retry
+ console.error("Combobox.Async fetch failed:", error)} + /> +
@@ -494,6 +535,18 @@ const DropdownComponentsDemoPage = () => { placeholder="Search programming language..." /> + + {/* Error + retry: first fetch fails, Retry recovers */} +
+
Error + retry
+ console.error("Autocomplete.Async fetch failed:", error)} + /> +
diff --git a/packages/core/src/components/async-error-state.tsx b/packages/core/src/components/async-error-state.tsx new file mode 100644 index 00000000..31037593 --- /dev/null +++ b/packages/core/src/components/async-error-state.tsx @@ -0,0 +1,82 @@ +import * as React from "react"; +import { RotateCwIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; + +/** + * Inline error state rendered inside an async dropdown popover when the fetcher + * fails. Shared by `Combobox.Async`, `Autocomplete.Async`, and `Select.Async`. + * + * Renders the failure message plus a Retry affordance where the user is already + * looking, instead of the misleading empty ("No results.") state. + * + * The content is padding-free so callers can place it inside their existing + * padded empty/content slot; text color is inherited from that slot. + */ +function AsyncErrorState({ + message, + retryText, + onRetry, + className, + slot = "async-error", + ...props +}: React.ComponentProps<"div"> & { + /** The failure message, e.g. "Couldn't load results." */ + message: React.ReactNode; + /** Label for the retry button, e.g. "Retry". */ + retryText: React.ReactNode; + /** Re-runs the last fetch. */ + onRetry: () => void; + /** `data-slot` value for CSS scoping. */ + slot?: string; +}) { + return ( +
+ {message} + +
+ ); +} +AsyncErrorState.displayName = "AsyncErrorState"; + +/** + * Picks which popover content an async dropdown should render: loading takes + * precedence, then the error state, then the default (items / empty) content. + * + * Extracted so callers avoid nested ternaries at the call site. + */ +function resolveAsyncContent({ + loading, + error, + loadingContent, + errorContent, + defaultContent, +}: { + loading: boolean; + error: unknown; + loadingContent: T; + errorContent: T; + defaultContent: T; +}): T { + if (loading) return loadingContent; + if (error) return errorContent; + return defaultContent; +} + +export { AsyncErrorState, resolveAsyncContent }; diff --git a/packages/core/src/components/autocomplete-standalone.test.tsx b/packages/core/src/components/autocomplete-standalone.test.tsx index 08907d6c..e6a033d5 100644 --- a/packages/core/src/components/autocomplete-standalone.test.tsx +++ b/packages/core/src/components/autocomplete-standalone.test.tsx @@ -196,6 +196,49 @@ describe("Autocomplete.Async (standalone)", () => { vi.useRealTimers(); }); + + it("shows the inline error state (with Retry) when the fetcher throws", async () => { + const fetcher = vi.fn().mockRejectedValue(new Error("API error")); + const user = userEvent.setup(); + + render( + , + ); + const input = screen.getByRole("combobox"); + await user.click(input); + await user.type(input, "test"); + + await waitFor(() => { + expect(screen.getByText("Couldn't load results.")).toBeDefined(); + }); + expect(screen.getByRole("button", { name: /retry/i })).toBeDefined(); + }); + + it("re-runs the fetch when Retry is clicked", async () => { + let shouldFail = true; + const fetcher = vi.fn().mockImplementation(async () => { + if (shouldFail) throw new Error("API error"); + return ["Recovered"]; + }); + const user = userEvent.setup(); + + render(); + const input = screen.getByRole("combobox"); + await user.click(input); + await user.type(input, "test"); + + const retry = await screen.findByRole("button", { name: /retry/i }); + shouldFail = false; + await user.click(retry); + + await waitFor(() => { + expect(screen.getByText("Recovered")).toBeDefined(); + }); + }); }); describe("Autocomplete (standalone, grouped)", () => { diff --git a/packages/core/src/components/autocomplete-standalone.tsx b/packages/core/src/components/autocomplete-standalone.tsx index 3a0f7b30..4b203397 100644 --- a/packages/core/src/components/autocomplete-standalone.tsx +++ b/packages/core/src/components/autocomplete-standalone.tsx @@ -18,6 +18,7 @@ import { } from "./autocomplete"; import { defaultMapItem, isGroupedItems } from "./dropdown-items"; import type { MappedItem, ItemGroup, ExtractItem } from "./dropdown-items"; +import { AsyncErrorState, resolveAsyncContent } from "./async-error-state"; import type { AsyncFetcher } from "@/hooks/use-async-items"; /** Fetcher type for `Autocomplete.Async`. */ @@ -161,6 +162,20 @@ interface AutocompleteAsyncStandaloneProps extends AutocompletePropsBase { fetcher: AutocompleteAsyncFetcher; /** Text shown while loading. @default "Loading..." */ loadingText?: string; + /** + * Message shown in the popover when the fetcher fails, alongside a Retry + * affordance. Replaces the misleading empty state. + * @default "Couldn't load results." + */ + errorText?: string; + /** Label for the retry button in the error state. @default "Retry" */ + retryText?: string; + /** + * Called when a fetch fails. Fires once per outage (not per failed keystroke) + * and re-arms after the next success. Use for logging or error tracking — the + * inline error state is shown regardless. + */ + onFetchError?: (error: unknown) => void; } function AutocompleteAsyncStandalone(props: AutocompleteAsyncStandaloneProps) { @@ -169,6 +184,9 @@ function AutocompleteAsyncStandalone(props: AutocompleteAsyncStandaloneProps< placeholder, emptyText = "No results.", loadingText = "Loading...", + errorText = "Couldn't load results.", + retryText = "Retry", + onFetchError, mapItem: mapItemProp, className, disabled, @@ -181,7 +199,7 @@ function AutocompleteAsyncStandalone(props: AutocompleteAsyncStandaloneProps< id, } = props; - const async = useAsync({ fetcher }); + const async = useAsync({ fetcher, onFetchError }); const mapItem = (mapItemProp ?? defaultMapItem) as (item: T) => MappedItem; const handleValueChange = React.useCallback( @@ -214,7 +232,22 @@ function AutocompleteAsyncStandalone(props: AutocompleteAsyncStandaloneProps< - {async.loading ? loadingText : emptyText} + + {resolveAsyncContent({ + loading: async.loading, + error: async.error, + loadingContent: loadingText, + errorContent: ( + + ), + defaultContent: emptyText, + })} + {(item: T) => { const mapped = mapItem(item); diff --git a/packages/core/src/components/autocomplete.tsx b/packages/core/src/components/autocomplete.tsx index d239f557..fd9c339d 100644 --- a/packages/core/src/components/autocomplete.tsx +++ b/packages/core/src/components/autocomplete.tsx @@ -253,6 +253,8 @@ export interface AutocompleteUseAsyncReturn { value: string; /** The error thrown by the last fetch, if any */ error: unknown; + /** Re-runs the most recent fetch immediately (no debounce). Use for a Retry affordance. */ + retry: () => void; /** Value change handler — pass to the Root `onValueChange` prop. */ onValueChange: (value: string) => void; } @@ -300,13 +302,14 @@ export interface AutocompleteUseAsyncReturn { * ``` */ function useAsync(options: UseAsyncItemsOptions): AutocompleteUseAsyncReturn { - const { items, loading, query, error, onInputValueChange } = useAsyncItems(options); + const { items, loading, query, error, retry, onInputValueChange } = useAsyncItems(options); return { items, loading, value: query, error, + retry, onValueChange: onInputValueChange, }; } diff --git a/packages/core/src/components/combobox-standalone.test.tsx b/packages/core/src/components/combobox-standalone.test.tsx index 7c1a4d56..2faecd03 100644 --- a/packages/core/src/components/combobox-standalone.test.tsx +++ b/packages/core/src/components/combobox-standalone.test.tsx @@ -230,6 +230,70 @@ describe("Combobox.Async (standalone)", () => { render(); expect(screen.getByRole("combobox")).toBeDefined(); }); + + it("shows the inline error state (with Retry) when the fetcher throws", async () => { + const fetcher = vi.fn().mockRejectedValue(new Error("API error")); + const user = userEvent.setup(); + + render( + , + ); + const input = screen.getByRole("combobox"); + await user.click(input); + await user.type(input, "test"); + + await waitFor(() => { + expect(screen.getByText("Couldn't load results.")).toBeDefined(); + }); + expect(screen.getByRole("button", { name: /retry/i })).toBeDefined(); + }); + + it("re-runs the fetch when Retry is clicked", async () => { + let shouldFail = true; + const fetcher = vi.fn().mockImplementation(async () => { + if (shouldFail) throw new Error("API error"); + return ["Recovered"]; + }); + const user = userEvent.setup(); + + render(); + const input = screen.getByRole("combobox"); + await user.click(input); + await user.type(input, "test"); + + const retry = await screen.findByRole("button", { name: /retry/i }); + shouldFail = false; + await user.click(retry); + + await waitFor(() => { + expect(screen.getByText("Recovered")).toBeDefined(); + }); + }); + + it("calls onFetchError once per outage", async () => { + const fetcher = vi.fn().mockRejectedValue(new Error("API error")); + const onFetchError = vi.fn(); + const user = userEvent.setup(); + + render( + , + ); + const input = screen.getByRole("combobox"); + await user.click(input); + await user.type(input, "ab"); + + await waitFor(() => { + expect(onFetchError).toHaveBeenCalledTimes(1); + }); + // Further failing keystrokes during the same outage don't re-announce. + await user.type(input, "cd"); + await new Promise((r) => setTimeout(r, 350)); + expect(onFetchError).toHaveBeenCalledTimes(1); + }); }); type Item = { label: string }; diff --git a/packages/core/src/components/combobox-standalone.tsx b/packages/core/src/components/combobox-standalone.tsx index 7c471425..fa192609 100644 --- a/packages/core/src/components/combobox-standalone.tsx +++ b/packages/core/src/components/combobox-standalone.tsx @@ -24,6 +24,7 @@ import { import { defaultMapItem, isGroupedItems } from "./dropdown-items"; import type { MappedItem, ItemGroup, ExtractItem } from "./dropdown-items"; +import { AsyncErrorState, resolveAsyncContent } from "./async-error-state"; import type { AsyncFetcher } from "@/hooks/use-async-items"; /** Fetcher type for `Combobox.Async`. */ @@ -422,6 +423,20 @@ interface ComboboxAsyncOwnProps { fetcher: ComboboxAsyncFetcher; /** Text shown while loading. @default "Loading..." */ loadingText?: string; + /** + * Message shown in the popover when the fetcher fails, alongside a Retry + * affordance. Replaces the misleading empty state. + * @default "Couldn't load results." + */ + errorText?: string; + /** Label for the retry button in the error state. @default "Retry" */ + retryText?: string; + /** + * Called when a fetch fails. Fires once per outage (not per failed keystroke) + * and re-arms after the next success. Use for logging or error tracking — the + * inline error state is shown regardless. + */ + onFetchError?: (error: unknown) => void; } // -- Non-creatable -- @@ -446,6 +461,9 @@ function ComboboxAsyncBase(props: ComboboxAsyncPlainProps) { placeholder, emptyText = "No results.", loadingText = "Loading...", + errorText = "Couldn't load results.", + retryText = "Retry", + onFetchError, mapItem: mapItemProp, className, disabled, @@ -457,7 +475,7 @@ function ComboboxAsyncBase(props: ComboboxAsyncPlainProps) { ...valueProps } = props; - const async = useAsync({ fetcher }); + const async = useAsync({ fetcher, onFetchError }); const mapItem = (mapItemProp ?? defaultMapItem) as (item: T) => MappedItem; const getLabel = (item: T) => mapItem(item).label; @@ -466,7 +484,20 @@ function ComboboxAsyncBase(props: ComboboxAsyncPlainProps) { className={className} placeholder={placeholder} disabled={disabled} - emptyContent={async.loading ? loadingText : emptyText} + emptyContent={resolveAsyncContent({ + loading: async.loading, + error: async.error, + loadingContent: loadingText, + errorContent: ( + + ), + defaultContent: emptyText, + })} mapItem={mapItem} listChildren={flatItemRenderer(mapItem)} multiple={multiple} @@ -494,6 +525,9 @@ function ComboboxAsyncCreatable(props: ComboboxAsyncCreatableP placeholder, emptyText = "No results.", loadingText = "Loading...", + errorText = "Couldn't load results.", + retryText = "Retry", + onFetchError, mapItem, onCreateItem, formatCreateLabel: formatCreateLabelProp, @@ -506,7 +540,7 @@ function ComboboxAsyncCreatable(props: ComboboxAsyncCreatableP onValueChange, } = props as ComboboxAsyncOwnProps & CreatableInternalProps; - const async = useAsync({ fetcher }); + const async = useAsync({ fetcher, onFetchError }); const getLabel = (item: T) => mapItem(item).label; // Bridge onCreateItem → useCreatable's createItem + onItemCreated @@ -538,7 +572,20 @@ function ComboboxAsyncCreatable(props: ComboboxAsyncCreatableP className={className} placeholder={placeholder} disabled={disabled || creatable.creating} - emptyContent={async.loading ? loadingText : emptyText} + emptyContent={resolveAsyncContent({ + loading: async.loading, + error: async.error, + loadingContent: loadingText, + errorContent: ( + + ), + defaultContent: emptyText, + })} mapItem={mapItem} listChildren={creatableItemRenderer(mapItem, creatable)} multiple={multiple} diff --git a/packages/core/src/components/combobox.test.tsx b/packages/core/src/components/combobox.test.tsx index 1b977101..2c8dbd67 100644 --- a/packages/core/src/components/combobox.test.tsx +++ b/packages/core/src/components/combobox.test.tsx @@ -15,7 +15,7 @@ function SimpleCombobox(props: { value?: string; }) { return ( - + {...props}> @@ -1356,6 +1356,7 @@ describe("useAsync", () => { expect(result.current).toHaveProperty("loading"); expect(result.current).toHaveProperty("query"); expect(result.current).toHaveProperty("error"); + expect(result.current).toHaveProperty("retry"); expect(result.current).toHaveProperty("onInputValueChange"); }); @@ -1464,4 +1465,122 @@ describe("useAsync", () => { expect(capturedSignals[0].aborted).toBe(true); }); + + it("clears the error after a subsequent successful fetch", async () => { + let shouldFail = true; + const fetcher = vi.fn(async () => { + if (shouldFail) throw new Error("API error"); + return ["ok"]; + }); + const { result } = renderHook(() => useAsync({ fetcher })); + + act(() => { + result.current.onInputValueChange("a"); + }); + await advanceAndFlush(300); + expect(result.current.error).toBeInstanceOf(Error); + + shouldFail = false; + act(() => { + result.current.onInputValueChange("ab"); + }); + await advanceAndFlush(300); + + expect(result.current.error).toBeUndefined(); + expect(result.current.items).toEqual(["ok"]); + }); + + it("retry re-runs the most recent fetch without debounce", async () => { + let shouldFail = true; + const fetcher = vi.fn(async (q: string | null) => { + if (shouldFail) throw new Error("API error"); + return [q ?? ""]; + }); + const { result } = renderHook(() => useAsync({ fetcher })); + + act(() => { + result.current.onInputValueChange("react"); + }); + await advanceAndFlush(300); + expect(result.current.error).toBeInstanceOf(Error); + expect(fetcher).toHaveBeenCalledTimes(1); + + // Recover, then retry the same query — should fire immediately (no debounce) + shouldFail = false; + act(() => { + result.current.retry(); + }); + await advanceAndFlush(0); + + expect(fetcher).toHaveBeenCalledTimes(2); + expect(fetcher).toHaveBeenLastCalledWith("react", expect.anything()); + expect(result.current.error).toBeUndefined(); + expect(result.current.items).toEqual(["react"]); + }); + + it("fires onFetchError once per outage and re-arms after a success", async () => { + let shouldFail = true; + const fetcher = vi.fn(async () => { + if (shouldFail) throw new Error("API error"); + return ["ok"]; + }); + const onFetchError = vi.fn(); + const { result } = renderHook(() => useAsync({ fetcher, onFetchError })); + + // First failure → fires once + act(() => { + result.current.onInputValueChange("a"); + }); + await advanceAndFlush(300); + expect(onFetchError).toHaveBeenCalledTimes(1); + + // Still broken, more keystrokes → does NOT re-fire during the same outage + act(() => { + result.current.onInputValueChange("ab"); + }); + await advanceAndFlush(300); + expect(onFetchError).toHaveBeenCalledTimes(1); + + // Recover + shouldFail = false; + act(() => { + result.current.onInputValueChange("abc"); + }); + await advanceAndFlush(300); + expect(onFetchError).toHaveBeenCalledTimes(1); + + // New outage → fires again + shouldFail = true; + act(() => { + result.current.onInputValueChange("abcd"); + }); + await advanceAndFlush(300); + expect(onFetchError).toHaveBeenCalledTimes(2); + }); + + it("does not fire onFetchError for aborted (superseded) requests", async () => { + const onFetchError = vi.fn(); + const fetcher = vi.fn(async (_q: string | null, opts: { signal: AbortSignal }) => { + return new Promise((_resolve, reject) => { + opts.signal.addEventListener("abort", () => + reject(new DOMException("Aborted", "AbortError")), + ); + setTimeout(() => reject(new Error("late failure")), 200); + }); + }); + const { result } = renderHook(() => useAsync({ fetcher, onFetchError })); + + act(() => { + result.current.onInputValueChange("first"); + }); + await advanceAndFlush(300); + + // Supersede before the first settles + act(() => { + result.current.onInputValueChange("second"); + }); + await advanceAndFlush(300); + + expect(onFetchError).not.toHaveBeenCalled(); + }); }); diff --git a/packages/core/src/components/select-standalone.test.tsx b/packages/core/src/components/select-standalone.test.tsx index 4bf5ed22..792c76f2 100644 --- a/packages/core/src/components/select-standalone.test.tsx +++ b/packages/core/src/components/select-standalone.test.tsx @@ -337,6 +337,62 @@ describe("Select.Async (standalone)", () => { }); }); + it("shows the inline error state (with Retry) when the fetcher throws", async () => { + const user = userEvent.setup(); + const fetcher = vi.fn().mockRejectedValue(new Error("API error")); + + render( + , + ); + await user.click(screen.getByText("Pick one")); + + await waitFor(() => { + expect(screen.getByText("Couldn't load results.")).toBeDefined(); + }); + expect(screen.getByRole("button", { name: /retry/i })).toBeDefined(); + }); + + it("re-runs the fetch when Retry is clicked", async () => { + const user = userEvent.setup(); + let shouldFail = true; + const fetcher = vi.fn().mockImplementation(async () => { + if (shouldFail) throw new Error("API error"); + return ["Recovered"]; + }); + + render(); + await user.click(screen.getByText("Pick one")); + + const retry = await screen.findByRole("button", { name: /retry/i }); + shouldFail = false; + await user.click(retry); + + await waitFor(() => { + expect(screen.getByText("Recovered")).toBeDefined(); + }); + }); + + it("calls onFetchError once per outage across reopens", async () => { + const user = userEvent.setup(); + const fetcher = vi.fn().mockRejectedValue(new Error("API error")); + const onFetchError = vi.fn(); + + render(); + + await user.click(screen.getByText("Pick one")); + await waitFor(() => { + expect(onFetchError).toHaveBeenCalledTimes(1); + }); + + // Close and reopen while still broken — no re-announce during the same outage. + await user.keyboard("{Escape}"); + await user.click(screen.getByText("Pick one")); + await waitFor(() => { + expect(fetcher).toHaveBeenCalledTimes(2); + }); + expect(onFetchError).toHaveBeenCalledTimes(1); + }); + it("can reopen after closing while the fetch is still in flight", async () => { const user = userEvent.setup(); const pendingRequests: Array<{ diff --git a/packages/core/src/components/select-standalone.tsx b/packages/core/src/components/select-standalone.tsx index 458159df..52778af6 100644 --- a/packages/core/src/components/select-standalone.tsx +++ b/packages/core/src/components/select-standalone.tsx @@ -12,6 +12,7 @@ import { } from "./select"; import { defaultMapItem, isGroupedItems } from "./dropdown-items"; import type { MappedItem, ItemGroup, ExtractItem } from "./dropdown-items"; +import { AsyncErrorState, resolveAsyncContent } from "./async-error-state"; /** * Fetcher type for `Select.Async`. @@ -30,10 +31,10 @@ import type { MappedItem, ItemGroup, ExtractItem } from "./dropdown-items"; * If caching or deduplication is needed, implement it in the fetcher * (e.g. via a query library or a simple cache layer). * - * **Error handling:** Errors should be handled inside the fetcher. - * If the fetcher throws, the component silently falls back to an empty - * item list — there is no `onError` callback. Catch errors in the - * fetcher to show toasts, log to error tracking, or return fallback data. + * **Error handling:** If the fetcher throws or rejects, the component renders + * a built-in inline error state (with a Retry affordance) in place of the + * empty item list — the failure is no longer silently swallowed. To run a side + * effect on failure (logging, error tracking, a toast), pass `onFetchError`. */ export type SelectAsyncFetcher = (options: { signal: AbortSignal }) => Promise; @@ -217,6 +218,12 @@ function SelectStandalone(props: SelectStandaloneProps) { interface UseSelectAsyncOptions { /** Fetcher for async item loading. Called each time the dropdown is opened. */ fetcher: SelectAsyncFetcher; + /** + * Called when a fetch fails. Fires once per outage (not on every re-open + * while broken) and re-arms after the next success. Use for logging or error + * tracking; the inline error state is shown regardless. + */ + onFetchError?: (error: unknown) => void; } interface UseSelectAsyncReturn { @@ -226,6 +233,8 @@ interface UseSelectAsyncReturn { loading: boolean; /** The error thrown by the last fetch, if any */ error: unknown; + /** Re-runs the fetch immediately. Use for a Retry affordance. */ + retry: () => void; /** Open change handler — pass to the Root `onOpenChange` prop */ onOpenChange: (open: boolean) => void; } @@ -263,56 +272,74 @@ interface UseSelectAsyncReturn { * * ``` */ -function useAsync({ fetcher }: UseSelectAsyncOptions): UseSelectAsyncReturn { +function useAsync({ fetcher, onFetchError }: UseSelectAsyncOptions): UseSelectAsyncReturn { const [items, setItems] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(undefined); const fetcherRef = useRef(fetcher); fetcherRef.current = fetcher; + const onFetchErrorRef = useRef(onFetchError); + onFetchErrorRef.current = onFetchError; const abortControllerRef = useRef(null); - - const onOpenChange = React.useCallback((open: boolean) => { - if (open) { - abortControllerRef.current?.abort(); - const controller = new AbortController(); - abortControllerRef.current = controller; - setLoading(true); - - fetcherRef - .current({ signal: controller.signal }) - .then((result) => { - if (!controller.signal.aborted) { - setItems(result); - setError(undefined); - } - }) - .catch((e) => { - if (e instanceof DOMException && e.name === "AbortError") return; - if (!controller.signal.aborted) { - setItems([]); - setError(e); - } - }) - .finally(() => { - if (!controller.signal.aborted) { - setLoading(false); + // Whether we are currently in an error state, so onFetchError fires once per + // outage (on the transition into the error state) rather than on every re-open. + const inErrorStateRef = useRef(false); + + const runFetch = React.useCallback(() => { + abortControllerRef.current?.abort(); + const controller = new AbortController(); + abortControllerRef.current = controller; + setLoading(true); + + fetcherRef + .current({ signal: controller.signal }) + .then((result) => { + if (!controller.signal.aborted) { + setItems(result); + setError(undefined); + inErrorStateRef.current = false; + } + }) + .catch((e) => { + if (e instanceof DOMException && e.name === "AbortError") return; + if (!controller.signal.aborted) { + setItems([]); + setError(e); + // Announce the outage only on the transition into the error state. + if (!inErrorStateRef.current) { + inErrorStateRef.current = true; + onFetchErrorRef.current?.(e); } - }); - } else { - // Abort in-flight fetch on close to prevent content changes during - // the close animation, which can break Base UI's transition tracking. - abortControllerRef.current?.abort(); - abortControllerRef.current = null; - } + } + }) + .finally(() => { + if (!controller.signal.aborted) { + setLoading(false); + } + }); }, []); + const onOpenChange = React.useCallback( + (open: boolean) => { + if (open) { + runFetch(); + } else { + // Abort in-flight fetch on close to prevent content changes during + // the close animation, which can break Base UI's transition tracking. + abortControllerRef.current?.abort(); + abortControllerRef.current = null; + } + }, + [runFetch], + ); + useEffect(() => { return () => abortControllerRef.current?.abort(); }, []); - return { items, loading, error, onOpenChange }; + return { items, loading, error, retry: runFetch, onOpenChange }; } // ============================================================================ @@ -324,6 +351,20 @@ interface SelectAsyncOwnProps { fetcher: SelectAsyncFetcher; /** Text shown while loading. @default "Loading..." */ loadingText?: string; + /** + * Message shown in the dropdown when the fetcher fails, alongside a Retry + * affordance. Replaces the misleading empty state. + * @default "Couldn't load results." + */ + errorText?: string; + /** Label for the retry button in the error state. @default "Retry" */ + retryText?: string; + /** + * Called when a fetch fails. Fires once per outage (not on every re-open + * while broken) and re-arms after the next success. Use for logging or error + * tracking — the inline error state is shown regardless. + */ + onFetchError?: (error: unknown) => void; /** * Whether the select behaves as a modal (traps focus). * Set to `true` when rendering inside a Dialog or Sheet to preserve focus-trap. @@ -346,6 +387,9 @@ function SelectAsyncStandalone(props: SelectAsyncProps) { fetcher, placeholder, loadingText = "Loading...", + errorText = "Couldn't load results.", + retryText = "Retry", + onFetchError, // Base UI's modal + anchored alignment path can leave async selects // scroll-locked or invisible after reopening once items have loaded. modal = false, @@ -368,18 +412,39 @@ function SelectAsyncStandalone(props: SelectAsyncProps) { id, }; - const { items, loading, onOpenChange: handleOpenChange } = useAsync({ fetcher }); + const { + items, + loading, + error, + retry, + onOpenChange: handleOpenChange, + } = useAsync({ + fetcher, + onFetchError, + }); const mapItem = (mapItemProp ?? defaultMapItem) as (item: T) => MappedItem; const getLabel = (item: T) => mapItem(item).label; - const content = loading ? ( -
- {loadingText} -
- ) : ( - renderFlatItems(items, mapItem) - ); + const content = resolveAsyncContent({ + loading, + error, + loadingContent: ( +
+ {loadingText} +
+ ), + errorContent: ( + + ), + defaultContent: renderFlatItems(items, mapItem), + }); if (rest.multiple) { const { value, defaultValue, onValueChange, renderValue } = rest as SelectPropsMultiple; diff --git a/packages/core/src/hooks/use-async-items.ts b/packages/core/src/hooks/use-async-items.ts index 6bad394e..852165f8 100644 --- a/packages/core/src/hooks/use-async-items.ts +++ b/packages/core/src/hooks/use-async-items.ts @@ -9,10 +9,12 @@ const DEFAULT_DEBOUNCE_MS = 300; * this one — pass it through to `fetch()` so the browser cancels the * in-flight HTTP request automatically. * - * **Error handling:** Errors should be handled inside the fetcher. - * If the fetcher throws, the component silently falls back to an empty - * item list — there is no `onError` callback. Catch errors in the - * fetcher to show toasts, log to error tracking, or return fallback data. + * **Error handling:** If the fetcher throws or rejects, the component renders + * a built-in inline error state (with a Retry affordance) in place of the + * empty state — the failure is no longer silently swallowed. Aborted/superseded + * requests are ignored. To run a side effect on failure (logging, error + * tracking, a toast), pass `onFetchError`; it fires once per outage rather + * than once per failed keystroke. * * The `query` parameter is `null` when the user has not typed anything * (e.g. the dropdown was just opened). Return initial / default items @@ -84,6 +86,16 @@ export interface UseAsyncItemsOptions { * ``` */ fetcher: AsyncFetcher; + /** + * Called when a fetch fails (the fetcher throws or rejects with something + * other than an abort of a superseded request). + * + * Fires **once per outage** — i.e. on the transition into the error state, + * not on every failed keystroke — and re-arms after the next successful + * fetch. Use it for side effects like logging or error tracking; the inline + * error state is rendered regardless. + */ + onFetchError?: (error: unknown) => void; } export interface UseAsyncItemsReturn { @@ -95,6 +107,8 @@ export interface UseAsyncItemsReturn { query: string; /** The error thrown by the last fetch, if any */ error: unknown; + /** Re-runs the most recent fetch immediately (no debounce). Use for a Retry affordance. */ + retry: () => void; /** Input change handler — pass to the Root `onInputValueChange` prop */ onInputValueChange: (value: string) => void; /** Open change handler — pass to the Root `onOpenChange` prop to fetch initial items on open */ @@ -108,7 +122,10 @@ export interface UseAsyncItemsReturn { * Pass `filter={null}` to the Root component to disable internal filtering * since items are already filtered by the remote source. */ -export function useAsyncItems({ fetcher }: UseAsyncItemsOptions): UseAsyncItemsReturn { +export function useAsyncItems({ + fetcher, + onFetchError, +}: UseAsyncItemsOptions): UseAsyncItemsReturn { const { fn: fetcherFn, debounceMs } = resolveAsyncFetcher(fetcher); const [items, setItems] = useState([]); const [loading, setLoading] = useState(false); @@ -117,9 +134,17 @@ export function useAsyncItems({ fetcher }: UseAsyncItemsOptions): UseAsync const abortControllerRef = useRef(null); const debounceTimerRef = useRef | null>(null); - // Keep fetcher in a ref so the callback identity stays stable + // Keep fetcher/callback in refs so the callback identity stays stable const fetcherRef = useRef(fetcherFn); fetcherRef.current = fetcherFn; + const onFetchErrorRef = useRef(onFetchError); + onFetchErrorRef.current = onFetchError; + + // The last query fetched, so a Retry can re-run the same request. + const lastFetchQueryRef = useRef(null); + // Whether we are currently in an error state, so onFetchError fires once per + // outage (on the failing->error transition) rather than per failed keystroke. + const inErrorStateRef = useRef(false); const doFetch = useCallback( (fetchQuery: string | null, debounce: boolean) => { @@ -128,6 +153,7 @@ export function useAsyncItems({ fetcher }: UseAsyncItemsOptions): UseAsync debounceTimerRef.current = null; } + lastFetchQueryRef.current = fetchQuery; setLoading(true); const run = async () => { @@ -142,12 +168,18 @@ export function useAsyncItems({ fetcher }: UseAsyncItemsOptions): UseAsync if (!controller.signal.aborted) { setItems(result); setError(undefined); + inErrorStateRef.current = false; } } catch (e) { if (e instanceof DOMException && e.name === "AbortError") return; if (!controller.signal.aborted) { setItems([]); setError(e); + // Announce the outage only on the transition into the error state. + if (!inErrorStateRef.current) { + inErrorStateRef.current = true; + onFetchErrorRef.current?.(e); + } } } finally { if (!controller.signal.aborted) { @@ -165,6 +197,10 @@ export function useAsyncItems({ fetcher }: UseAsyncItemsOptions): UseAsync [debounceMs], ); + const retry = useCallback(() => { + doFetch(lastFetchQueryRef.current, false); + }, [doFetch]); + const onInputValueChange = useCallback( (value: string) => { setQuery(value); @@ -205,5 +241,5 @@ export function useAsyncItems({ fetcher }: UseAsyncItemsOptions): UseAsync }; }, []); - return { items, loading, query, error, onInputValueChange, onOpenChange }; + return { items, loading, query, error, retry, onInputValueChange, onOpenChange }; } From 08d5954c094a703133b492b15dc639546ee3c38f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 16 Jul 2026 04:29:41 +0000 Subject: [PATCH 2/2] fix: update stale JSDoc examples and add missing autocomplete onFetchError test Co-authored-by: erickteowarang <30395423+erickteowarang@users.noreply.github.com> --- .../autocomplete-standalone.test.tsx | 21 +++++++++++++++++++ packages/core/src/components/autocomplete.tsx | 6 +++++- packages/core/src/components/combobox.tsx | 6 +++++- 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/packages/core/src/components/autocomplete-standalone.test.tsx b/packages/core/src/components/autocomplete-standalone.test.tsx index e6a033d5..7dd4f730 100644 --- a/packages/core/src/components/autocomplete-standalone.test.tsx +++ b/packages/core/src/components/autocomplete-standalone.test.tsx @@ -239,6 +239,27 @@ describe("Autocomplete.Async (standalone)", () => { expect(screen.getByText("Recovered")).toBeDefined(); }); }); + + it("calls onFetchError once per outage", async () => { + const fetcher = vi.fn().mockRejectedValue(new Error("API error")); + const onFetchError = vi.fn(); + const user = userEvent.setup(); + + render( + , + ); + const input = screen.getByRole("combobox"); + await user.click(input); + await user.type(input, "ab"); + + await waitFor(() => { + expect(onFetchError).toHaveBeenCalledTimes(1); + }); + // Further failing keystrokes during the same outage don't re-announce. + await user.type(input, "cd"); + await new Promise((r) => setTimeout(r, 350)); + expect(onFetchError).toHaveBeenCalledTimes(1); + }); }); describe("Autocomplete (standalone, grouped)", () => { diff --git a/packages/core/src/components/autocomplete.tsx b/packages/core/src/components/autocomplete.tsx index fd9c339d..174895e6 100644 --- a/packages/core/src/components/autocomplete.tsx +++ b/packages/core/src/components/autocomplete.tsx @@ -296,7 +296,11 @@ export interface AutocompleteUseAsyncReturn { * > * ... * - * {movies.loading ? "Loading..." : "No results."} + * {movies.loading + * ? "Loading..." + * : movies.error + * ? + * : "No results."} * * * ``` diff --git a/packages/core/src/components/combobox.tsx b/packages/core/src/components/combobox.tsx index 2b9d10b4..ca10b5b1 100644 --- a/packages/core/src/components/combobox.tsx +++ b/packages/core/src/components/combobox.tsx @@ -722,7 +722,11 @@ function useCreatable( * > * ... * - * {countries.loading ? "Loading..." : "No results."} + * {countries.loading + * ? "Loading..." + * : countries.error + * ? + * : "No results."} * * * ```