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
139 changes: 122 additions & 17 deletions docs/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ contract changes.
| Hook | Source | Status |
| --- | --- | --- |
| `useApi` | `src/lib/useApi.ts` | Exported |
| `useApiMutation` | `src/lib/useApiMutation.ts` | Exported |
| `useClipboard` | `src/lib/useClipboard.ts` | Exported |
| `useDebounce` | `src/lib/useDebounce.ts` | Exported |
| `useLocalState` | `src/lib/useLocalState.ts` | Exported |
Expand All @@ -30,18 +31,21 @@ import { useApi } from "@/lib/useApi";
Return shape:

```ts
type ApiErrorKind = "timeout" | "generic";
type ApiErrorKind = "timeout" | "rate_limited" | "generic";

type State<T> =
| { status: "loading" }
| { status: "loading"; refetch: () => void }
| {
status: "error";
error: string;
errorKind: ApiErrorKind;
isTimeout: boolean;
isRateLimited: boolean;
retryAfterMs: number | null;
retry: () => void;
refetch: () => void;
}
| { status: "ok"; data: T };
| { status: "ok"; data: T; refetch: () => void };
```

Parameters:
Expand All @@ -52,43 +56,143 @@ Parameters:
Behaviour and gotchas:

- This is a client hook and must be used from a client component.
- The first state is `{ status: "loading" }`.
- The first state is `{ status: "loading"; refetch }`.
- When `path` changes, the hook dispatches a fresh loading state and fetches the
new path.
- If the component unmounts or `path` changes before a response settles, the
stale response is ignored through an internal cancellation flag.
stale response is ignored through an internal cancellation flag and the
in-flight request is aborted.
- `path: null` skips fetching and leaves the existing state unchanged.
- Detects `ApiTimeoutError` and sets `errorKind: "timeout"`, `isTimeout: true`, and `"Request timed out. Please try again."`. Generic errors set `errorKind: "generic"`, `isTimeout: false`, and `Error.message` (or `"failed to load"`).
- Provides a `retry()` callback affordance on the error state to trigger a refetch of the path.
Calling `refetch()` while `path` is `null` is a no-op.
- Detects `ApiTimeoutError` and sets `errorKind: "timeout"`, `isTimeout: true`, and `"Request timed out. Please try again."`. Detects `ApiRateLimitedError` and sets `errorKind: "rate_limited"`, `isRateLimited: true`, and `retryAfterMs`. Generic errors set `errorKind: "generic"`, `isTimeout: false`, and `Error.message` (or `"failed to load"`).
- Provides a stable `refetch()` callback on every status. Calling it aborts any
in-flight request and re-runs the fetch for the current `path`.
- Error states also expose `retry()`, which is the same callback as `refetch`,
kept for existing callers.
- When the browser fires an `online` event while the hook is in an error state,
the request is automatically retried.

Minimal real usage, based on `src/app/changelog/page.tsx`:
Minimal real usage, based on `src/app/agents/[agent]/page.tsx`:

```tsx
"use client";

import { ErrorMessage } from "@/components/ErrorMessage";
import { Spinner } from "@/components/Spinner";
import { useApi } from "@/lib/useApi";

type Entry = { version: string; date: string; notes: string[] };
type Usage = { items: { serviceId: string; total: number }[] };

export function ChangelogPreview() {
const state = useApi<{ entries: Entry[] }>("/api/v1/changelog");
export function AgentUsagePreview({ agent }: { agent: string }) {
const state = useApi<Usage>(
`/api/v1/agents/${encodeURIComponent(agent)}/usage`,
);

if (state.status === "loading") {
return <Spinner label="Loading changelog" />;
return <Spinner label="Loading usage" />;
}

if (state.status === "error") {
return <p role="alert">{state.error}</p>;
return (
<ErrorMessage title={state.error} onRetry={state.refetch} />
);
}

return <p>{state.data.entries.length} entries</p>;
return <p>{state.data.items.length} services</p>;
}
```

Use this hook for simple GET-backed client views that can be represented as
loading, error, or successful data. For write actions or request bodies, use the
helpers in `src/lib/apiClient.ts` directly.
loading, error, or successful data. For write actions or request bodies, prefer
`useApiMutation` below.

## `useApiMutation`

```ts
function useApiMutation<TData, TVariables = void>(
mutationFn: (
variables: TVariables,
options: { signal: AbortSignal },
) => Promise<TData>,
): {
mutate: (variables: TVariables) => Promise<TData>;
status: "idle" | "pending" | "success" | "error";
error: string | null;
reset: () => void;
};
```

Import from:

```ts
import { useApiMutation } from "@/lib/useApiMutation";
```

Parameters:

- `mutationFn`: async writer that receives call variables and an AbortSignal.
Forward the signal into `apiPost` / `apiDelete` / `apiPatch` so the shared
client can cancel the underlying fetch.

Return shape:

- `mutate(variables)`: starts the mutation, sets `status` to `"pending"`, and
resolves with `TData` on success. On failure it sets `status` to `"error"`,
mirrors a display-ready message on `error`, and rethrows.
- `status`: `"idle"` | `"pending"` | `"success"` | `"error"`.
- `error`: display-ready message when `status` is `"error"`, otherwise `null`.
- `reset()`: returns to `"idle"`, clears `error`, and aborts any in-flight
mutation.

Behaviour and gotchas:

- This is a client hook and must be used from a client component.
- Calling `mutate` while a previous mutation is still pending aborts the older
request and ignores its late response.
- Unmount aborts the current request and ignores late responses, matching the
`useApi` cancellation contract.
- Aborted / superseded mutations do not flip `status` to `"error"`.
- Non-Error rejections fall back to `"failed to mutate"`.

Minimal real usage, based on `src/app/services/new/page.tsx`:

```tsx
"use client";

import { apiPost } from "@/lib/apiClient";
import { useApiMutation } from "@/lib/useApiMutation";

type CreateServiceBody = {
serviceId: string;
priceStroops: number;
};

export function RegisterServiceButton(props: CreateServiceBody) {
const { mutate, status, error } = useApiMutation(
(body: CreateServiceBody, { signal }) =>
apiPost("/api/v1/services", body, { signal }),
);

return (
<div>
<button
type="button"
disabled={status === "pending"}
onClick={() => {
void mutate(props).catch(() => {});
}}
>
{status === "pending" ? "Saving…" : "Register"}
</button>
{error && <p role="alert">{error}</p>}
</div>
);
}
```

Use this hook for POST / DELETE / PATCH flows that need a shared pending, error,
and success state machine without copying AbortController cleanup into each
page.

## `usePolling`

Expand Down Expand Up @@ -389,4 +493,5 @@ Use this hook in components that need to react to connectivity changes, such as
## Coverage Note

This reference covers every hook exported from `src/lib` at the time of writing:
`useApi`, `useClipboard`, `useOnlineStatus`, `usePolling`, `useDebounce`, and `useLocalState`.
`useApi`, `useApiMutation`, `useClipboard`, `useOnlineStatus`, `usePolling`,
`useDebounce`, and `useLocalState`.
39 changes: 39 additions & 0 deletions src/app/agents/[agent]/page.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { render, screen, waitFor } from "@testing-library/react";
import { act } from "react";
import AgentDetailPage from "./page";
import { apiGet } from "@/lib/apiClient";

Expand Down Expand Up @@ -184,6 +185,44 @@ describe("AgentDetailPage", () => {
expect(await screen.findByRole("alert")).toHaveTextContent(
"Backend usage offline",
);
expect(
screen.getByRole("button", { name: /try again/i }),
).toBeInTheDocument();
});

it("refetches usage when Try again is clicked after an error", async () => {
let usageCalls = 0;

apiGetMock.mockImplementation((path: string) => {
if (path.endsWith("/usage")) {
usageCalls += 1;
if (usageCalls === 1) {
return Promise.reject(new Error("Backend usage offline")) as never;
}
return Promise.resolve({
agent: "agent-retry",
items: [{ serviceId: "svc-retry", total: 3 }],
}) as never;
}
if (path.endsWith("/total")) {
return Promise.resolve({ total: 10 }) as never;
}
return Promise.reject(new Error(`unexpected: ${path}`)) as never;
});

renderPage("agent-retry");

expect(await screen.findByRole("alert")).toHaveTextContent(
"Backend usage offline",
);

act(() => {
screen.getByRole("button", { name: /try again/i }).click();
});

expect(await screen.findByText("svc-retry")).toBeInTheDocument();
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
expect(usageCalls).toBe(2);
});

it("hides the spinner after a usage error", async () => {
Expand Down
11 changes: 6 additions & 5 deletions src/app/agents/[agent]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { use, useEffect, useState } from "react";
import { Breadcrumb } from "@/components/Breadcrumb";
import { EmptyState } from "@/components/EmptyState";
import { ErrorMessage } from "@/components/ErrorMessage";
import { PageShell } from "@/components/PageShell";
import { Spinner } from "@/components/Spinner";
import { apiGet } from "@/lib/apiClient";
Expand Down Expand Up @@ -39,7 +40,6 @@ export default function AgentDetailPage({
}, [agent, encodedAgent]);

const items = usageState.status === "ok" ? usageState.data.items : null;
const error = usageState.status === "error" ? usageState.error : null;
const total = totalState?.agent === agent ? totalState.total : null;

return (
Expand Down Expand Up @@ -68,10 +68,11 @@ export default function AgentDetailPage({
<Spinner label="Loading usage" />
</div>
)}
{error && (
<p role="alert" className="text-sm text-rose-600">
{error}
</p>
{usageState.status === "error" && (
<ErrorMessage
title={usageState.error}
onRetry={usageState.refetch}
/>
)}
<p className="text-sm">
Lifetime total:{" "}
Expand Down
48 changes: 32 additions & 16 deletions src/app/services/new/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -151,10 +151,14 @@ describe("NewServicePage", () => {
fireEvent.submit(screen.getByRole("button", { name: /register service/i }));

await waitFor(() => {
expect(apiPostMock).toHaveBeenCalledWith("/api/v1/services", {
serviceId: "test-service",
priceStroops: 100,
});
expect(apiPostMock).toHaveBeenCalledWith(
"/api/v1/services",
{
serviceId: "test-service",
priceStroops: 100,
},
expect.objectContaining({ signal: expect.any(AbortSignal) }),
);
});

await waitFor(() => {
Expand All @@ -176,10 +180,14 @@ describe("NewServicePage", () => {
fireEvent.submit(screen.getByRole("button", { name: /register service/i }));

await waitFor(() => {
expect(apiPostMock).toHaveBeenCalledWith("/api/v1/services", {
serviceId: "existing-service",
priceStroops: 50,
});
expect(apiPostMock).toHaveBeenCalledWith(
"/api/v1/services",
{
serviceId: "existing-service",
priceStroops: 50,
},
expect.objectContaining({ signal: expect.any(AbortSignal) }),
);
});

// Check page-level alert
Expand Down Expand Up @@ -262,10 +270,14 @@ describe("NewServicePage", () => {
fireEvent.submit(screen.getByRole("button", { name: /register service/i }));

await waitFor(() => {
expect(apiPostMock).toHaveBeenCalledWith("/api/v1/services", {
serviceId: "free-svc",
priceStroops: 0,
});
expect(apiPostMock).toHaveBeenCalledWith(
"/api/v1/services",
{
serviceId: "free-svc",
priceStroops: 0,
},
expect.objectContaining({ signal: expect.any(AbortSignal) }),
);
});

await waitFor(() => {
Expand All @@ -282,10 +294,14 @@ describe("NewServicePage", () => {
fireEvent.submit(screen.getByRole("button", { name: /register service/i }));

await waitFor(() => {
expect(apiPostMock).toHaveBeenCalledWith("/api/v1/services", {
serviceId: "",
priceStroops: 50,
});
expect(apiPostMock).toHaveBeenCalledWith(
"/api/v1/services",
{
serviceId: "",
priceStroops: 50,
},
expect.objectContaining({ signal: expect.any(AbortSignal) }),
);
});
});

Expand Down
Loading
Loading