diff --git a/.axioma/quality.md b/.axioma/quality.md index 1a32978..c8366d3 100644 --- a/.axioma/quality.md +++ b/.axioma/quality.md @@ -69,3 +69,7 @@ ## 2024-06-26 - [Verifying Polymorphic Type Inference] **Learning:** Hooks that perform automatic type inference (like `useAIPrompt` for multimodal inputs) should have explicit unit tests for each supported input type (e.g., `ArrayBuffer`, `Blob`, `string[]`). Relying only on happy-path text tests can leave type-specific logic uncovered and prone to regressions. **Action:** Always include a dedicated test case for each supported input type in internal normalization or inference functions to ensure full branch coverage and robustness. + +## 2026-06-07 - [Explicit AbortError State Management] +**Learning:** When wrapping browser-native asynchronous APIs (like the Built-in AI APIs), it's critical to explicitly handle the `AbortError` in `catch` blocks by resetting the hook's status to `'idle'`. This ensures that if an operation is cancelled (e.g., via a signal or component unmount), the UI state doesn't remain "stuck" in a loading or active state, improving the robustness of state management across the application. +**Action:** Always transition the status to `'idle'` when an `AbortError` is caught in AI-related hooks to maintain synchronization between the UI and the underlying API state. diff --git a/lib/hooks/useAIPrompt.test.ts b/lib/hooks/useAIPrompt.test.ts index 3bb3f9d..54cc657 100644 --- a/lib/hooks/useAIPrompt.test.ts +++ b/lib/hooks/useAIPrompt.test.ts @@ -370,4 +370,19 @@ describe('useAIPrompt', () => { ) consoleSpy.mockRestore() }) + + it('should handle AbortError by resetting to idle', async () => { + const abortError = new Error('The operation was aborted') + abortError.name = 'AbortError' + mockSession.prompt.mockRejectedValue(abortError) + + const { result } = renderHook(() => useAIPrompt()) + + await act(async () => { + await result.current.prompt('hello') + }) + + expect(result.current.status).toBe('idle') + expect(result.current.error).toBeNull() + }) }) diff --git a/lib/hooks/useAIPrompt.ts b/lib/hooks/useAIPrompt.ts index e1c6bf1..6603271 100644 --- a/lib/hooks/useAIPrompt.ts +++ b/lib/hooks/useAIPrompt.ts @@ -405,6 +405,7 @@ export function useAIPrompt( } } catch (err) { if (err instanceof Error && err.name === 'AbortError') { + setStatus('idle') return } setError( diff --git a/lib/hooks/useAIRewriter.test.ts b/lib/hooks/useAIRewriter.test.ts index b2d1aaa..8ab41f5 100644 --- a/lib/hooks/useAIRewriter.test.ts +++ b/lib/hooks/useAIRewriter.test.ts @@ -312,4 +312,19 @@ describe('useAIRewriter', () => { expect(result.current.error).toBe(null); expect(result.current.progress).toBe(null); }); + + it('should handle AbortError by resetting to idle', async () => { + const abortError = new Error('The operation was aborted'); + abortError.name = 'AbortError'; + mockRewriter.rewrite.mockRejectedValue(abortError); + + const { result } = renderHook(() => useAIRewriter()); + + await act(async () => { + await result.current.rewrite('original text'); + }); + + expect(result.current.status).toBe('idle'); + expect(result.current.error).toBeNull(); + }); }); diff --git a/lib/hooks/useAIRewriter.ts b/lib/hooks/useAIRewriter.ts index 8c6edbf..4b9174b 100644 --- a/lib/hooks/useAIRewriter.ts +++ b/lib/hooks/useAIRewriter.ts @@ -190,6 +190,7 @@ export function useAIRewriter(options: UseAIRewriterOptions = {}): UseAIRewriter } } catch (err) { if (err instanceof Error && err.name === 'AbortError') { + setStatus('idle'); return; } setError(err instanceof Error ? err : new Error('Unknown error during rewriting')); diff --git a/lib/hooks/useAISummarize.test.ts b/lib/hooks/useAISummarize.test.ts index d8bcaba..d53405f 100644 --- a/lib/hooks/useAISummarize.test.ts +++ b/lib/hooks/useAISummarize.test.ts @@ -407,4 +407,19 @@ describe('useAISummarize', () => { expect(result.current.status).toBe('error'); expect(result.current.error?.message).toContain('User activation required'); }); + + it('should handle AbortError by resetting to idle', async () => { + const abortError = new Error('The operation was aborted'); + abortError.name = 'AbortError'; + mockSummarizer.summarize.mockRejectedValue(abortError); + + const { result } = renderHook(() => useAISummarize()); + + await act(async () => { + await result.current.summarize('input text'); + }); + + expect(result.current.status).toBe('idle'); + expect(result.current.error).toBeNull(); + }); }); diff --git a/lib/hooks/useAISummarize.ts b/lib/hooks/useAISummarize.ts index a8f82d9..5169264 100644 --- a/lib/hooks/useAISummarize.ts +++ b/lib/hooks/useAISummarize.ts @@ -297,6 +297,7 @@ export function useAISummarize(options: UseAISummarizeOptions = {}): UseAISummar } } catch (err) { if (err instanceof Error && err.name === 'AbortError') { + setStatus('idle'); return; } setError(err instanceof Error ? err : new Error('Unknown error during summarization')); diff --git a/lib/hooks/useAIWrite.test.ts b/lib/hooks/useAIWrite.test.ts index 92c839c..1b40d0f 100644 --- a/lib/hooks/useAIWrite.test.ts +++ b/lib/hooks/useAIWrite.test.ts @@ -297,4 +297,19 @@ describe('useAIWrite', () => { expect(result.current.error).toBe(null); expect(result.current.progress).toBe(null); }); + + it('should handle AbortError by resetting to idle', async () => { + const abortError = new Error('The operation was aborted'); + abortError.name = 'AbortError'; + mockWriter.write.mockRejectedValue(abortError); + + const { result } = renderHook(() => useAIWrite()); + + await act(async () => { + await result.current.write('prompt'); + }); + + expect(result.current.status).toBe('idle'); + expect(result.current.error).toBeNull(); + }); }); diff --git a/lib/hooks/useAIWrite.ts b/lib/hooks/useAIWrite.ts index 22a5d35..6175a9e 100644 --- a/lib/hooks/useAIWrite.ts +++ b/lib/hooks/useAIWrite.ts @@ -187,6 +187,7 @@ export function useAIWrite(options: UseAIWriteOptions = {}): UseAIWriteReturn { } } catch (err) { if (err instanceof Error && err.name === 'AbortError') { + setStatus('idle'); return; } setError(err instanceof Error ? err : new Error('Unknown error during writing')); diff --git a/lib/hooks/useTranslator.test.ts b/lib/hooks/useTranslator.test.ts index 945d707..355eeaa 100644 --- a/lib/hooks/useTranslator.test.ts +++ b/lib/hooks/useTranslator.test.ts @@ -247,4 +247,19 @@ describe('useTranslator', () => { expect(result.current.progress).toEqual({ loaded: 50, total: 100 }); }); + + it('should handle AbortError by resetting to idle', async () => { + const abortError = new Error('The operation was aborted'); + abortError.name = 'AbortError'; + mockTranslator.translate.mockRejectedValue(abortError); + + const { result } = renderHook(() => useTranslator({ sourceLanguage: 'en', targetLanguage: 'es', streaming: false, warmup: false })); + + await act(async () => { + await result.current.translate('Hello world'); + }); + + expect(result.current.status).toBe('idle'); + expect(result.current.error).toBeNull(); + }); }); diff --git a/lib/hooks/useTranslator.ts b/lib/hooks/useTranslator.ts index 6ff06a8..75308d9 100644 --- a/lib/hooks/useTranslator.ts +++ b/lib/hooks/useTranslator.ts @@ -330,6 +330,7 @@ export function useTranslator(options: UseTranslatorOptions = {}): UseTranslator } } catch (err) { if (err instanceof Error && err.name === 'AbortError') { + setStatus('idle'); return; } setError(err instanceof Error ? err : new Error('Unknown error during translation'));