Skip to content
Merged
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
19 changes: 19 additions & 0 deletions .changeset/async-fetch-error-state.md
Original file line number Diff line number Diff line change
@@ -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`.
17 changes: 12 additions & 5 deletions docs/components/autocomplete.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,10 +115,13 @@ const fetcher: AutocompleteAsyncFetcher<string> = async (query, { signal }) => {

Accepts all the same props as `Autocomplete` except `items`, plus:

| Prop | Type | Default | Description |
| ------------- | ----------------------------- | -------------- | ------------------------------------------------------- |
| `fetcher` | `AutocompleteAsyncFetcher<T>` | - | Fetcher called on each keystroke (debounced by default) |
| `loadingText` | `string` | `"Loading..."` | Text shown while loading |
| Prop | Type | Default | Description |
| -------------- | ----------------------------- | -------------------------- | ------------------------------------------------------------------ |
| `fetcher` | `AutocompleteAsyncFetcher<T>` | - | 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

Expand All @@ -131,7 +134,11 @@ type AutocompleteAsyncFetcher<T> =
};
```

`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

Expand Down
28 changes: 23 additions & 5 deletions docs/components/combobox.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,10 +144,13 @@ const fetcher: ComboboxAsyncFetcher<User> = async (query, { signal }) => {

Accepts all the same props as `Combobox` except `items`, plus:

| Prop | Type | Default | Description |
| ------------- | ------------------------- | -------------- | ------------------------------------------------------- |
| `fetcher` | `ComboboxAsyncFetcher<T>` | - | Fetcher called on each keystroke (debounced by default) |
| `loadingText` | `string` | `"Loading..."` | Text shown while loading |
| Prop | Type | Default | Description |
| -------------- | -------------------------- | -------------------------- | ------------------------------------------------------------------ |
| `fetcher` | `ComboboxAsyncFetcher<T>` | - | 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

Expand All @@ -160,7 +163,22 @@ type ComboboxAsyncFetcher<T> =
};
```

`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
<Combobox.Async
fetcher={fetcher}
errorText="Couldn't load users."
retryText="Try again"
onFetchError={(error) => reportError(error)}
/>
```

## Low-level Primitives

Expand Down
19 changes: 12 additions & 7 deletions docs/components/select.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,12 +129,15 @@ const fetcher: SelectAsyncFetcher<Fruit> = async ({ signal }) => {

Accepts all the same props as `Select` except `items`, plus:

| Prop | Type | Default | Description |
| ---------------------- | ----------------------- | -------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `fetcher` | `SelectAsyncFetcher<T>` | - | 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<T>` | - | 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<T>[]` — the fetcher must return a flat array.

Expand All @@ -146,7 +149,9 @@ Accepts all the same props as `Select` except `items`, plus:
type SelectAsyncFetcher<T> = (options: { signal: AbortSignal }) => Promise<T[]>;
```

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

Expand Down
53 changes: 53 additions & 0 deletions examples/nextjs-app/src/modules/pages/dropdown-demo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Fruit[]> => {
await new Promise((r) => setTimeout(r, 600));
throw new Error("Simulated network error");
};

const failingLanguageFetcher = async (_query: string | null): Promise<string[]> => {
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.
Expand Down Expand Up @@ -285,6 +300,19 @@ const DropdownComponentsDemoPage = () => {
placeholder="Async multi-select..."
/>
</div>

{/* Error + retry: first open fails, Retry recovers */}
<div style={sectionStyle}>
<div style={subHeadingStyle}>Error + retry</div>
<Select.Async
fetcher={failingFruitFetcher}
mapItem={(f) => ({ 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)}
/>
</div>
</div>
</Card.Content>
</Card.Root>
Expand Down Expand Up @@ -384,6 +412,19 @@ const DropdownComponentsDemoPage = () => {
placeholder="Add programming languages..."
/>
</div>

{/* Error + retry: first fetch fails, Retry recovers */}
<div style={sectionStyle}>
<div style={subHeadingStyle}>Error + retry</div>
<Combobox.Async
fetcher={failingLanguageFetcher}
placeholder="Type to trigger a failure..."
loadingText="Searching..."
errorText="Couldn't load languages."
retryText="Try again"
onFetchError={(error) => console.error("Combobox.Async fetch failed:", error)}
/>
</div>
</div>
</Card.Content>
</Card.Root>
Expand Down Expand Up @@ -494,6 +535,18 @@ const DropdownComponentsDemoPage = () => {
placeholder="Search programming language..."
/>
</div>

{/* Error + retry: first fetch fails, Retry recovers */}
<div style={sectionStyle}>
<div style={subHeadingStyle}>Error + retry</div>
<Autocomplete.Async
fetcher={failingLanguageFetcher}
placeholder="Type to trigger a failure..."
errorText="Couldn't load languages."
retryText="Try again"
onFetchError={(error) => console.error("Autocomplete.Async fetch failed:", error)}
/>
</div>
</div>
</Card.Content>
</Card.Root>
Expand Down
82 changes: 82 additions & 0 deletions packages/core/src/components/async-error-state.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
data-slot={slot}
className={cn("astw:flex astw:flex-col astw:items-center astw:gap-2", className)}
{...props}
>
<span role="alert">{message}</span>
<button
type="button"
// Keep focus on the input/trigger so clicking Retry doesn't close the popover.
onMouseDown={(e) => e.preventDefault()}
onClick={onRetry}
className={cn(
"astw:inline-flex astw:items-center astw:gap-1.5 astw:rounded-md astw:px-2 astw:py-1 astw:text-sm astw:font-medium astw:text-foreground astw:outline-none astw:transition-colors",
"astw:hover:bg-accent astw:hover:text-accent-foreground",
"astw:focus-visible:ring-ring/50 astw:focus-visible:ring-[3px]",
)}
>
<RotateCwIcon className="astw:size-3.5" />
{retryText}
</button>
</div>
);
}
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<T>({
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 };
64 changes: 64 additions & 0 deletions packages/core/src/components/autocomplete-standalone.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,70 @@ 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(
<Autocomplete.Async
fetcher={fetcher}
placeholder="Search..."
errorText="Couldn't load results."
/>,
);
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(<Autocomplete.Async fetcher={fetcher} placeholder="Search..." />);
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(
<Autocomplete.Async fetcher={fetcher} placeholder="Search..." onFetchError={onFetchError} />,
);
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)", () => {
Expand Down
Loading
Loading