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 cameraInputRef = useRef<HTMLInputElement>(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(() => {
if (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) => {
setLocalError(null);
if (!file) return;
const toastId = toast.loading('Uploading...');
toastIdRef.current = toast.loading('Uploading...');
try {
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);
// Reset file inputs
@@ -49,7 +59,8 @@ export default function ItemPhotoUpload({
} catch (err: any) {
const errorMsg = err.message || 'Upload failed';
setLocalError(errorMsg);
toast.error(errorMsg, { id: toastId });
toast.error(errorMsg, { id: toastIdRef.current || undefined });
toastIdRef.current = null;
onError(errorMsg);
}
};

View File

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