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
4 changes: 4 additions & 0 deletions .axioma/quality.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,3 +85,7 @@
## 2024-06-30 - [Mocking Global Types for Branch Coverage]
**Learning:** To achieve 100% branch coverage in functions that use `typeof Type !== 'undefined' && value instanceof Type` (a common pattern for SSR-safe browser API checks), tests must explicitly mock both the presence and absence of the global `Type`. Using `vi.stubGlobal('Type', class {})` allows exercising the `instanceof` branch even in environments where the API is not natively available.
**Action:** Always use `vi.stubGlobal` to provide mock constructors when testing branch logic that depends on environment-specific global types.

## 2024-06-30 - [Consistent AI Hook Error Recovery]
**Learning:** AI hooks that implement async 'warmup' or cancellable operations must explicitly handle failures and AbortErrors to avoid "stuck" UI states. Transitioning status to 'idle' on AbortError and logging warmup failures while resetting status ensures the hook remains usable for subsequent interactions.
**Action:** Always include AbortError handling in catch blocks and add error logging/state reset to warmup effects in all AI-related hooks.
39 changes: 39 additions & 0 deletions lib/hooks/useLanguageDetection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,4 +186,43 @@ describe('useLanguageDetection', () => {

expect(result.current.progress).toEqual({ loaded: 50, total: 100 });
});

it('should fail if LanguageDetector is a base constructor', async () => {
vi.stubGlobal('LanguageDetector', Object);
if (typeof window !== 'undefined') {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(window as any).LanguageDetector = Object;
}

const { result } = renderHook(() =>
useLanguageDetection({ text: 'test', warmup: false }),
);

await waitFor(() => expect(result.current.status).toBe('error'));
expect(result.current.error?.message).toBe('LanguageDetector is not available');
});

it('should handle AbortError by resetting to idle', async () => {
const abortError = new Error('Aborted');
abortError.name = 'AbortError';
mockDetector.detect.mockRejectedValue(abortError);

const { result } = renderHook(() => useLanguageDetection({ text: 'Hello' }));

await waitFor(() => expect(result.current.status).toBe('idle'));
expect(result.current.error).toBeNull();
});

it('should handle warmup failure and log error', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
mockDetectorCreate.mockRejectedValue(new Error('Warmup failed'));

const { result } = renderHook(() => useLanguageDetection({ warmup: true }));

await waitFor(() => expect(consoleSpy).toHaveBeenCalled());
expect(consoleSpy.mock.calls[0][0]).toContain('Failed to warmup language detector');
expect(result.current.status).toBe('idle');

consoleSpy.mockRestore();
});
});
23 changes: 22 additions & 1 deletion lib/hooks/useLanguageDetection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,18 @@ export function useLanguageDetection(options: UseLanguageDetectionOptions = {}):

const LanguageDetector = (window as unknown as { LanguageDetector: { availability?: () => Promise<Availability>; create?: (options?: LanguageDetectorCreateOptions) => Promise<LanguageDetector> } }).LanguageDetector;

// Ensure we're not dealing with base constructors
if (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
LanguageDetector === (Object as any) ||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
LanguageDetector === (Array as any) ||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
LanguageDetector === (Function as any)
) {
throw new Error('LanguageDetector is not available');
}

// Check availability
if (typeof LanguageDetector.availability === 'function') {
const avail = await LanguageDetector.availability();
Expand Down Expand Up @@ -211,6 +223,10 @@ export function useLanguageDetection(options: UseLanguageDetectionOptions = {}):
setAllLangs(limited);
setStatus('success');
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') {
setStatus('idle');
return;
}
setError(err instanceof Error ? err : new Error('Unknown error during language detection'));
setStatus('error');
}
Expand All @@ -227,7 +243,12 @@ export function useLanguageDetection(options: UseLanguageDetectionOptions = {}):

useEffect(() => {
if (warmup) {
createDetector().then(() => setStatus('idle')).catch(() => {});
createDetector()
.then(() => setStatus('idle'))
.catch((err) => {
console.error('Failed to warmup language detector:', err);
setStatus('idle');
});
}

return () => {
Expand Down
Loading