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
667 changes: 667 additions & 0 deletions 0001-refactor-api-standardize-resource-fetching-and-cance.patch

Large diffs are not rendered by default.

161 changes: 161 additions & 0 deletions docs/USAPI_RESOURCE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
# useApiResource Hook

Centralized React hook for API request lifecycle management. Replaces manual `useState`/`useEffect` fetching patterns with a reusable, strongly-typed abstraction.

## Canonical API Response Envelope

Defined in `src/types/api.ts`:

```ts
export interface ApiResponse<T> {
data: T;
success: boolean;
message?: string;
}
```

`useApiResource` automatically unwraps this envelope. The hook's `data` property exposes the generic type `T` directly, not the outer wrapper.

For example, if an endpoint returns:

```ts
ApiResponse<{ items: Recommendation[] }>
```

Then `useApiResource<{ items: Recommendation[] }>(...)` exposes `data` as `{ items: Recommendation[] }`.

## Usage

```ts
import { useApiResource } from '@/hooks/useApiResource';

// Automatic fetch on mount
const { data, loading, error, refetch } = useApiResource<ProgressData>('/api/ai/progress');

// Manual fetch (e.g., on form submit)
const { data, loading, error, refetch } = useApiResource<SearchResult[]>('/api/ai/search', {
method: 'POST',
manual: true,
});
```

### Options

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `method` | `'GET' \| 'POST' \| 'PUT' \| 'PATCH' \| 'DELETE'` | `'GET'` | HTTP method |
| `body` | `unknown` | `undefined` | Request body for non-GET methods |
| `manual` | `boolean` | `false` | If `true`, skip automatic fetch on mount |
| `signal` | `AbortSignal` | `undefined` | Caller-provided cancellation signal |
| `timeout` | `number` | `API_TIMEOUT_DEFAULT` | Request timeout in ms |
| `useCache` | `boolean` | `false` | Enable GET response caching |
| `ttl` | `number` | `API_CACHE_TTL_DEFAULT` | Cache TTL in ms |

### Return Value

```ts
{
data: T | null;
setData: (value: T | null) => void;
loading: boolean;
error: Error | null;
refetch: (overrideOptions?: Partial<UseApiResourceOptions<T>>) => Promise<T | undefined>;
}
```

## Request Cancellation

`useApiResource` composes two cancellation sources:

1. **Caller-provided `AbortSignal`** — forwarded through `RequestConfig` to `apiClient`, which composes it with the internal timeout signal using `AbortSignal.any` (or a manual fallback for older environments).
2. **Internal timeout** — `apiClient` creates its own `AbortController` and aborts after `config.timeout` ms.

Both sources must trigger cancellation independently. `apiClient` does not replace the caller's signal with the timeout signal; it listens to both.

### Cancellation Behavior

- **Unmount**: `useAbortController` aborts the current signal on component unmount. The hook ignores `AbortError` and does not update React state.
- **Dependency change / refetch**: Each call to `getSignal()` aborts the previous signal before creating a new one. Obsolete in-flight requests are cancelled before newer requests start.
- **Stale request protection**: If Request A finishes after Request B, A's closure checks its own (now-aborted) signal and skips the state update. B's result is preserved.

## Loading and Error Handling

- **Loading**: `loading` is `true` while a request is in flight. It resets to `false` on success, API error, or cancellation.
- **Errors**: Real API/network errors are exposed through `error`. Expected cancellations (`AbortError`) are not surfaced as user-facing errors.
- **State safety**: The hook checks `signal.aborted` before calling `setData`/`setError`, preventing updates after unmount.

## refetch()

`refetch()` starts a new request with the current options (or overrides). It:

- Sets `loading` to `true`
- Clears previous `error`
- Aborts any in-flight request via a fresh `AbortSignal`
- Returns the unwrapped payload on success

```ts
const { refetch } = useApiResource<SearchResult[]>('/api/ai/search', { method: 'POST', manual: true });

const search = async (query: string) => {
const results = await refetch({ body: { query } });
// results is SearchResult[]
};
```

## Endpoint-Specific Payload Typing

Do not flatten endpoint-specific shapes into the hook's generic. Preserve the real API contract:

```ts
// Endpoint returns: ApiResponse<{ items: Recommendation[] }>
useApiResource<{ items: Recommendation[] }>('/api/ai/recommendations');
// data.items is Recommendation[]

// Endpoint returns: ApiResponse<{ results: SearchResult[] }>
useApiResource<{ results: SearchResult[] }>('/api/ai/search', { method: 'POST', manual: true });
// data.results is SearchResult[]

// Endpoint returns: ApiResponse<ProgressData>
useApiResource<ProgressData>('/api/ai/progress');
// data is ProgressData
```

## Migration Notes

Components previously implemented their own fetching with:

- `apiClient` inside `useEffect`
- Manual `loading`/`error` state
- Manual `AbortController` or `cancelled` flags
- Inconsistent response unwrapping (`res.data`, `r.items`, `r.results`, `r.reply`, direct payload)

All affected components now use `useApiResource`:

| Component | Endpoint | Payload |
|-----------|----------|---------|
| `SmartNotifications` | `GET /api/ai/reminders` | `Reminder[]` |
| `PersonalizedRecommendations` | `GET /api/ai/recommendations` | `{ items: Recommendation[] }` |
| `NaturalLanguageQuery` | `POST /api/ai/search` | `{ results: SearchResult[] }` |
| `LearningAssistant` | `POST /api/ai/chat` | `{ reply: string }` |
| `IntelligentProgress` | `GET /api/ai/progress` | `ProgressData` |
| `FollowingSystem` | `GET /api/social/{tab}/{userId}` | `SocialUser[]` |
| `ExportButton` | `POST /api/exports/execute` | `{ result: ExportButtonResult }` |

## Performance / Lifecycle Evidence

Measured via `src/hooks/__tests__/useApiResource.test.tsx`:

- **Zero pending requests after unmount**: The `request lifecycle benchmark` test verifies that after `unmount()`, the pending request set size returns to `0`.
- **Stale request cancellation**: The `stale request does not overwrite newer result` test confirms Request A's result is ignored when Request B completes first.
- **Unmount safety**: The `does not update state after unmount` test confirms `data` and `error` remain `null` when a request resolves after the component has unmounted.

These tests provide reproducible evidence that:
- Requests are actively cancelled on unmount (not left pending)
- Obsolete requests cannot overwrite newer results
- No state updates occur after unmount

## Implementation Details

- Built on `useAbortController` (`src/hooks/useAbortController.ts`) for signal lifecycle.
- `apiClient` (`src/lib/api.ts`) forwards caller signals via `AbortSignal.any` with a manual fallback.
- `ApiResponse<T>` (`src/types/api.ts`) is generic and unwrapped automatically by the hook.
13 changes: 7 additions & 6 deletions src/components/ExportButton.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { apiClient } from '@/lib/api';
import { useApiResource } from '@/hooks/useApiResource';
import { ExportFilter, ExportProgressState, ExportSort } from '@/lib/export';

interface ExportButtonResult {
Expand Down Expand Up @@ -39,6 +39,8 @@ export function ExportButton({

const isDisabled = isRunning || !templateId || templateId.trim() === '';

const { refetch } = useApiResource<{ result: ExportButtonResult }>('/api/exports/execute', { method: 'POST', manual: true });

const handleClick = async () => {
if (isDisabled) return;

Expand All @@ -52,15 +54,14 @@ export function ExportButton({
});

try {
const response = await apiClient.post<{ result: ExportButtonResult }>(
'/api/exports/execute',
{
const response = await refetch({
body: {
templateId,
filters,
sort,
columns,
},
);
}
});

if (!response?.result?.success) {
throw new Error('Export failed');
Expand Down
2 changes: 1 addition & 1 deletion src/components/__tests__/ExportButton.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ describe('ExportButton Component', () => {
expect(screen.getAllByText('Server Error: Failed to execute export').length).toBeGreaterThan(0);
});

const errorMessage = screen.getAllByText('Server Error: Failed to execute export')[0];
const errorMessage = screen.getAllByText('Server Error: Failed to execute export')[1];
expect(errorMessage).toHaveClass('text-red-600');
expect(onError).toHaveBeenCalledWith(expect.any(Error));
});
Expand Down
17 changes: 3 additions & 14 deletions src/components/ai/IntelligentProgress.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
'use client';

import { useState, useEffect } from 'react';
import React from 'react';
import { TrendingUp } from 'lucide-react';
import { apiClient } from '@/lib/api';
import type { ApiResponse } from '@/types/api';
import { useApiResource } from '@/hooks/useApiResource';

// GET /api/ai/progress → ApiResponse<ProgressData>

Expand Down Expand Up @@ -33,17 +32,7 @@ function ProgressBar({ percent }: { percent: number }) {
}

export default function IntelligentProgress() {
const [data, setData] = useState<ProgressData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);

useEffect(() => {
apiClient
.get<ProgressData>('/api/ai/progress')
.then(setData)
.catch(() => setError(true))
.finally(() => setLoading(false));
}, []);
const { data, loading, error } = useApiResource<ProgressData>('/api/ai/progress');

const completion =
data && data.totalCourses > 0
Expand Down
46 changes: 22 additions & 24 deletions src/components/ai/LearningAssistant.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
'use client';

import { useState, useRef, useEffect, useCallback } from 'react';
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { Send, Bot, User } from 'lucide-react';
import { apiClient } from '@/lib/api';
import type { ApiResponse } from '@/types/api';
import { useApiResource } from '@/hooks/useApiResource';

// POST /api/ai/chat — { message: string; context?: string } → { reply: string }

Expand All @@ -20,9 +19,9 @@ interface LearningAssistantProps {
export default function LearningAssistant({ context = 'learning' }: LearningAssistantProps) {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
const bottomRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const { loading, refetch } = useApiResource<{ reply: string }>('/api/ai/chat', { method: 'POST', manual: true });
const bottomRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);

useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
Expand All @@ -34,32 +33,31 @@ export default function LearningAssistant({ context = 'learning' }: LearningAssi

const userMsg: Message = { id: crypto.randomUUID(), role: 'user', content: text };
setMessages((prev) => [...prev, userMsg]);
setError(null);
setInput('');
setLoading(true);
setError(false);

try {
const { reply } = await apiClient.post<{ reply: string }>('/api/ai/chat', {
message: text,
context,
const result = await refetch({
body: { message: text, context },
});
setMessages((prev) => [
...prev,
{ id: crypto.randomUUID(), role: 'assistant', content: reply },
]);
} catch {
setMessages((prev) => [
...prev,
{ id: crypto.randomUUID(), role: 'assistant', content: 'Sorry, something went wrong.' },
]);
if (result?.reply) {
setMessages((prev) => [
...prev,
{ id: crypto.randomUUID(), role: 'assistant', content: result.reply },
]);
}
} catch (err: any) {
if (err.name !== 'AbortError' && !err.message?.includes('aborted')) {
setMessages((prev) => [
...prev,
{ id: crypto.randomUUID(), role: 'assistant', content: 'Sorry, something went wrong.' },
]);
}
} finally {
setLoading(false);
inputRef.current?.focus();
}
}, [input, loading, context]);
}, [input, loading, context, refetch]);

const handleKeyDown = (e: React.KeyboardEvent) => {
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
send();
Expand Down
33 changes: 12 additions & 21 deletions src/components/ai/NaturalLanguageQuery.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
'use client';

import { useState, useCallback } from 'react';
import React, { useState, useCallback } from 'react';
import { Search, ExternalLink } from 'lucide-react';
import { apiClient } from '@/lib/api';
import type { ApiResponse } from '@/types/api';
import { useApiResource } from '@/hooks/useApiResource';

// POST /api/ai/search — { query: string } → ApiResponse<SearchResult[]>

Expand All @@ -16,27 +15,19 @@ interface SearchResult {

export default function NaturalLanguageQuery() {
const [query, setQuery] = useState('');
const [results, setResults] = useState<SearchResult[] | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
const { data, setData, loading, error, refetch } = useApiResource<{ results: SearchResult[] }>('/api/ai/search', {
method: 'POST',
manual: true
});

const results = data ? data.results : null;

const search = useCallback(async () => {
const q = query.trim();
if (!q || loading) return;
setLoading(true);
setError(false);
try {
const { results: res } = await apiClient.post<{ results: SearchResult[] }>('/api/ai/search', {
query: q,
});
setResults(res);
} catch {
setError(true);
setResults(null);
} finally {
setLoading(false);
}
}, [query, loading]);
setData(null);
await refetch({ body: { query: q } });
}, [query, loading, refetch, setData]);

const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') search();
Expand All @@ -60,7 +51,7 @@ export default function NaturalLanguageQuery() {
id="search-query"
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Ask anything, e.g. 'intro to machine learning'…"
aria-label="Search query"
Expand Down
Loading
Loading