fix(phase2): resolve code quality issues in ItemPhotoUpload (act warnings, toast cleanup, dual error handling)

Fixed 3 critical code quality issues:

1. Act() warnings in tests (9 tests):
   - Wrapped all async state updates in act() blocks in usePhotoUpload.test.ts
   - Tests using waitFor() now properly await state updates within act()
   - All 21 tests pass with zero act() warnings

2. Missing toast cleanup on unmount:
   - Added toastIdRef to track pending toast IDs
   - Added cleanup useEffect that dismisses toasts on component unmount
   - Prevents memory leaks and orphaned toast notifications

3. Dual error reporting channels (lines 23-28):
   - Removed useEffect that synced hook error to local state AND called onError callback
   - Now syncs hook error to local state only (for display)
   - Parent components rely on hook error state, reducing dual-path confusion
   - Toast error calls are explicit in catch block

Test Results:
- Frontend: 312/312 tests passing (includes 21 photo upload tests)
- Act() warnings: Eliminated
- No regressions introduced
This commit is contained in:
2026-04-21 12:50:44 +03:00
parent db9aafd47f
commit 5a64dadc1e
7 changed files with 69 additions and 42 deletions

View File

@@ -18,25 +18,35 @@ export default function ItemPhotoUpload({
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const cameraInputRef = useRef<HTMLInputElement>(null); const cameraInputRef = useRef<HTMLInputElement>(null);
const [localError, setLocalError] = useState<string | null>(null); const [localError, setLocalError] = useState<string | null>(null);
const toastIdRef = useRef<string | null>(null);
// Update local error when hook error changes // Sync hook error to local state for display
React.useEffect(() => { React.useEffect(() => {
if (error) { if (error) {
setLocalError(error); setLocalError(error);
onError(error);
} }
}, [error, onError]); }, [error]);
// Cleanup: dismiss pending toasts on unmount
React.useEffect(() => {
return () => {
if (toastIdRef.current) {
toast.dismiss(toastIdRef.current);
}
};
}, []);
const handleFileSelect = async (file: File) => { const handleFileSelect = async (file: File) => {
setLocalError(null); setLocalError(null);
if (!file) return; if (!file) return;
const toastId = toast.loading('Uploading...'); toastIdRef.current = toast.loading('Uploading...');
try { try {
const photo = await upload(file, itemId); const photo = await upload(file, itemId);
toast.success('Photo uploaded successfully', { id: toastId }); toast.success('Photo uploaded successfully', { id: toastIdRef.current });
toastIdRef.current = null;
onUploadSuccess(photo); onUploadSuccess(photo);
// Reset file inputs // Reset file inputs
@@ -49,7 +59,8 @@ export default function ItemPhotoUpload({
} catch (err: any) { } catch (err: any) {
const errorMsg = err.message || 'Upload failed'; const errorMsg = err.message || 'Upload failed';
setLocalError(errorMsg); setLocalError(errorMsg);
toast.error(errorMsg, { id: toastId }); toast.error(errorMsg, { id: toastIdRef.current || undefined });
toastIdRef.current = null;
onError(errorMsg); onError(errorMsg);
} }
}; };

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest' import { describe, it, expect, vi, beforeEach } from 'vitest'
import { renderHook, waitFor } from '@testing-library/react' import { renderHook, waitFor, act } from '@testing-library/react'
import { usePhotoUpload } from '@/hooks/usePhotoUpload' import { usePhotoUpload } from '@/hooks/usePhotoUpload'
import * as api from '@/lib/api' import * as api from '@/lib/api'
@@ -36,7 +36,10 @@ describe('usePhotoUpload Hook', () => {
const { result } = renderHook(() => usePhotoUpload()) const { result } = renderHook(() => usePhotoUpload())
const file = new File(['test'], 'test.jpg', { type: 'image/jpeg' }) const file = new File(['test'], 'test.jpg', { type: 'image/jpeg' })
const photo = await result.current.upload(file, 123) let photo
await act(async () => {
photo = await result.current.upload(file, 123)
})
expect(photo).toEqual(mockPhoto) expect(photo).toEqual(mockPhoto)
expect(result.current.error).toBe(null) expect(result.current.error).toBe(null)
@@ -50,16 +53,16 @@ describe('usePhotoUpload Hook', () => {
type: 'image/jpeg', type: 'image/jpeg',
}) })
try { await act(async () => {
await result.current.upload(largeFile, 123) try {
} catch (error) { await result.current.upload(largeFile, 123)
// Expected to throw } catch (error) {
} // Expected to throw
}
await waitFor(() => {
expect(result.current.error).toBeTruthy()
expect(result.current.error).toMatch(/File too large/)
}) })
expect(result.current.error).toBeTruthy()
expect(result.current.error).toMatch(/File too large/)
}) })
it('should reject invalid MIME types', async () => { it('should reject invalid MIME types', async () => {
@@ -67,16 +70,16 @@ describe('usePhotoUpload Hook', () => {
const invalidFile = new File(['test'], 'test.txt', { type: 'text/plain' }) const invalidFile = new File(['test'], 'test.txt', { type: 'text/plain' })
try { await act(async () => {
await result.current.upload(invalidFile, 123) try {
} catch (error) { await result.current.upload(invalidFile, 123)
// Expected to throw } catch (error) {
} // Expected to throw
}
await waitFor(() => {
expect(result.current.error).toBeTruthy()
expect(result.current.error).toMatch(/Invalid image format/)
}) })
expect(result.current.error).toBeTruthy()
expect(result.current.error).toMatch(/Invalid image format/)
}) })
it('should set isLoading to true during upload', async () => { it('should set isLoading to true during upload', async () => {
@@ -90,14 +93,19 @@ describe('usePhotoUpload Hook', () => {
const { result } = renderHook(() => usePhotoUpload()) const { result } = renderHook(() => usePhotoUpload())
const file = new File(['test'], 'test.jpg', { type: 'image/jpeg' }) const file = new File(['test'], 'test.jpg', { type: 'image/jpeg' })
const uploadPromiseResult = result.current.upload(file, 123) let uploadPromiseResult
await act(async () => {
uploadPromiseResult = result.current.upload(file, 123)
})
await waitFor(() => { await waitFor(() => {
expect(result.current.isLoading).toBe(true) expect(result.current.isLoading).toBe(true)
}) })
resolveUpload({ status: 'ok', photo: {} }) await act(async () => {
await uploadPromiseResult resolveUpload({ status: 'ok', photo: {} })
await uploadPromiseResult
})
await waitFor(() => { await waitFor(() => {
expect(result.current.isLoading).toBe(false) expect(result.current.isLoading).toBe(false)
@@ -127,7 +135,10 @@ describe('usePhotoUpload Hook', () => {
const { result } = renderHook(() => usePhotoUpload()) const { result } = renderHook(() => usePhotoUpload())
const file = new File(['test'], `test.${format.ext}`, { type: format.type }) const file = new File(['test'], `test.${format.ext}`, { type: format.type })
const photo = await result.current.upload(file, 123) let photo
await act(async () => {
photo = await result.current.upload(file, 123)
})
expect(photo).toEqual(mockPhoto) expect(photo).toEqual(mockPhoto)
expect(result.current.error).toBe(null) expect(result.current.error).toBe(null)
@@ -143,16 +154,16 @@ describe('usePhotoUpload Hook', () => {
const file = new File(['test'], 'test.jpg', { type: 'image/jpeg' }) const file = new File(['test'], 'test.jpg', { type: 'image/jpeg' })
try { await act(async () => {
await result.current.upload(file, 123) try {
} catch (error) { await result.current.upload(file, 123)
// Expected to throw } catch (error) {
} // Expected to throw
}
await waitFor(() => {
expect(result.current.error).toBeTruthy()
expect(result.current.error).toMatch(/Network error/)
}) })
expect(result.current.error).toBeTruthy()
expect(result.current.error).toMatch(/Network error/)
}) })
it('should reset error state on successful upload', async () => { it('should reset error state on successful upload', async () => {
@@ -170,7 +181,9 @@ describe('usePhotoUpload Hook', () => {
const { result } = renderHook(() => usePhotoUpload()) const { result } = renderHook(() => usePhotoUpload())
const file = new File(['test'], 'test.jpg', { type: 'image/jpeg' }) const file = new File(['test'], 'test.jpg', { type: 'image/jpeg' })
await result.current.upload(file, 123) await act(async () => {
await result.current.upload(file, 123)
})
expect(result.current.error).toBe(null) expect(result.current.error).toBe(null)
}) })
@@ -190,7 +203,10 @@ describe('usePhotoUpload Hook', () => {
const { result } = renderHook(() => usePhotoUpload()) const { result } = renderHook(() => usePhotoUpload())
const file = new File(['test'], 'test.jpg', { type: 'image/jpeg' }) const file = new File(['test'], 'test.jpg', { type: 'image/jpeg' })
const uploadedPhoto = await result.current.upload(file, 123) let uploadedPhoto
await act(async () => {
uploadedPhoto = await result.current.upload(file, 123)
})
expect(uploadedPhoto).toHaveProperty('thumbnail_url') expect(uploadedPhoto).toHaveProperty('thumbnail_url')
expect(uploadedPhoto).toHaveProperty('full_url') expect(uploadedPhoto).toHaveProperty('full_url')

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 541 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 541 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 361 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 541 B