diff --git a/.axioma/quality.md b/.axioma/quality.md index 98c5c1a..7494ad4 100644 --- a/.axioma/quality.md +++ b/.axioma/quality.md @@ -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. diff --git a/lib/hooks/useLanguageDetection.test.ts b/lib/hooks/useLanguageDetection.test.ts index 5b8b683..856fa00 100644 --- a/lib/hooks/useLanguageDetection.test.ts +++ b/lib/hooks/useLanguageDetection.test.ts @@ -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(); + }); }); diff --git a/lib/hooks/useLanguageDetection.ts b/lib/hooks/useLanguageDetection.ts index 6bae3bb..30c30a5 100644 --- a/lib/hooks/useLanguageDetection.ts +++ b/lib/hooks/useLanguageDetection.ts @@ -153,6 +153,18 @@ export function useLanguageDetection(options: UseLanguageDetectionOptions = {}): const LanguageDetector = (window as unknown as { LanguageDetector: { availability?: () => Promise; create?: (options?: LanguageDetectorCreateOptions) => Promise } }).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(); @@ -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'); } @@ -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 () => {