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:
@@ -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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 |
Reference in New Issue
Block a user