- QA-1 Backend API contract: APPROVE (minor type contract 2) - QA-2 Persona forget algorithm: APPROVE (math verified) - QA-3 Auth & security: REQUEST_CHANGES - email normalize missing / JWT revalidate / secret fallback / whitelist - QA-4 Frontend runtime: REQUEST_CHANGES - review double-submit race / subjects/[id] NaN / history error states / native alerts - QA-5 Prisma & transactions: REQUEST_CHANGES - reviews.submit snapshot outside tx / tag cascade data loss / iteration race - QA-6 Build & deploy: REQUEST_CHANGES - missing migrations, postinstall, lockfile, PM2 ecosystem Follow-up fix proposals saved to .claude/state/pending-fix-proposals.jsonl (18 tickets)
102 lines
5.2 KiB
Markdown
102 lines
5.2 KiB
Markdown
# QA-4 Report — Frontend Runtime Bug Scan
|
|
|
|
> Worker: Explore agent · Lead review: critical/major 재조정
|
|
|
|
## Summary
|
|
- Pages audited: 13 + 3 layout + primitives + chart
|
|
- **verdict**: REQUEST_CHANGES
|
|
- critical: 2, major: 2, minor: 5, recommendation: 2
|
|
|
|
## Critical Findings
|
|
|
|
### [critical] C1 Review page double-submit race
|
|
- **Location**: `frontend/src/app/review/page.tsx:34-52`
|
|
- **Evidence**:
|
|
```tsx
|
|
const submit = async (id: number, result: StudyResult) => {
|
|
setSubmitting(true);
|
|
try { await api.post(`/reviews/${id}/submit`, { result }); ... }
|
|
finally { setSubmitting(false); }
|
|
};
|
|
```
|
|
`submitting` 이 전역 boolean 이라 동일 아이템에 대해 "맞음"→"스킵" 으로 빠르게 더블클릭하면 두 요청이 모두 나감. 두 번째는 backend 에서 "already processed" 로 반려되지만, 모바일 터치 반응이 느려서 중복 POST 발생 가능성이 높다.
|
|
- **Impact**: 중복 POST → 백엔드 race (QA-5 C2 와 합성되면 실제 데이터 오염).
|
|
- **Suggested fix**: `submitting` 을 `Set<number>` 로 바꿔 per-review lock.
|
|
```tsx
|
|
const [pending, setPending] = useState<Set<number>>(new Set());
|
|
const isPending = (id: number) => pending.has(id);
|
|
// 버튼 disabled={isPending(q.id)}
|
|
```
|
|
|
|
### [critical] C2 `/subjects/[id]` 에 숫자가 아닌 값이 오면 무한 로딩
|
|
- **Location**: `frontend/src/app/subjects/[id]/page.tsx:31, 45-49`
|
|
- **Evidence**:
|
|
```tsx
|
|
const subjectId = Number(params.id); // → NaN 가능
|
|
const load = useCallback(() => {
|
|
api.get<Subject>(`/subjects/${subjectId}`) // `/subjects/NaN` 요청
|
|
...
|
|
}, [subjectId]);
|
|
useEffect(() => {
|
|
if (!Number.isFinite(subjectId)) return; // 이미 load 는 만들어진 상태
|
|
load();
|
|
}, [subjectId, load]);
|
|
```
|
|
`useParams` 는 string 만 주고, `Number('abc')` 는 `NaN`. useEffect guard 는 호출을 막아주지만, **렌더 직후 `return <Loading/>`** 로 넘어가서 사용자는 로딩 스피너만 본다. 유효하지 않은 URL 처리 없음.
|
|
- **Impact**: `/subjects/abc` 같은 URL 이 로딩 화면에서 멈춤. 404 리다이렉트 없음.
|
|
- **Suggested fix**:
|
|
```tsx
|
|
const raw = Number(params.id);
|
|
const subjectId = Number.isFinite(raw) ? raw : null;
|
|
// 컴포넌트 초입: if (subjectId === null) return <NotFound ... />;
|
|
```
|
|
|
|
## Major Findings
|
|
|
|
### [major] M1 히스토리 페이지들이 에러 상태 처리 없음
|
|
- **Location**: `frontend/src/app/review/history/page.tsx:26-30`, `frontend/src/app/study/history/page.tsx:31-41`, `frontend/src/app/stats/page.tsx:50-57`
|
|
- **Evidence**: `.then(...)` 만 있고 `.catch` 가 없다. API 500 이 오면 `items === null` 로 남아 `로딩 중...` 이 영원히 뜬다.
|
|
- **Impact**: 런타임 에러 묵살.
|
|
- **Suggested fix**: `try/catch` 또는 `.catch(err => setError(err))` 와 재시도 버튼 UI 제공.
|
|
|
|
### [major] M2 네이티브 `confirm()` / `alert()` 사용
|
|
- **Location**: `frontend/src/app/subjects/page.tsx:72, 77`, `frontend/src/app/subjects/[id]/page.tsx:70` 등
|
|
- **Impact**: 일부 모바일 WebView, PWA 컨텍스트에서 차단될 수 있음. 삭제 취소 후 사용자 피드백 없음.
|
|
- **Suggested fix**: styled-components 기반 custom modal 또는 toast 로 교체.
|
|
|
|
## Minor Findings
|
|
|
|
### [minor] m1 `baseCorrectRate` state 타입이 string이라 number DTO 계약과 비대칭
|
|
- **Location**: `frontend/src/app/study/page.tsx:38, 77`
|
|
- **Evidence**: `useState<string>('')` + `type="number"` 입력. 전송 시 `Number(...) / 100`. 현재는 런타임 정상 작동하지만 타입 계약이 흐릿.
|
|
- **Suggested fix**: state 를 `useState<number | ''>('')` 로 명시.
|
|
|
|
### [minor] m2 dashboard `s.tag.subject.color` optional chaining 부재
|
|
- **Location**: `frontend/src/app/dashboard/page.tsx:113`
|
|
- **Evidence**: backend `DashboardService.summary()` 가 `skillSnapshot` → `tag` → `subject` 를 항상 include 하므로 **런타임 crash 없음**. Worker 는 major 로 올렸지만 재확인 결과 minor.
|
|
- **Suggested fix**: 일관성 유지 목적이면 `s.tag.subject?.color ?? theme.color.accent`.
|
|
|
|
### [minor] m3 `profile/page.tsx` setTimeout cleanup 없음
|
|
- **Location**: `profile/page.tsx:77`
|
|
- **Impact**: unmount 후 setState 경고 (dev only).
|
|
- **Suggested fix**: `useEffect(() => { if (saved) { const t = setTimeout(...); return () => clearTimeout(t); } }, [saved])`.
|
|
|
|
### [minor] m4 `AppShell` 에 서버 500 에러 처리 없음
|
|
- **Location**: `frontend/src/components/layout/AppShell.tsx:30-42`
|
|
- **Evidence**: 401 은 로그인 페이지로 rebound, 기타 에러는 `clearToken + /login` 으로 같이 처리됨 → **과잉 로그아웃**. 500 일 때도 토큰 지움.
|
|
- **Suggested fix**: `catch` 에서 status 분기. 401 만 로그아웃.
|
|
|
|
### [minor] m5 form 에러 메시지 `role="alert"` 부재
|
|
- **Location**: login/register/study/profile 페이지
|
|
- **Impact**: screen reader 미공지.
|
|
- **Suggested fix**: `<ErrorText role="alert">`.
|
|
|
|
## Recommendations
|
|
|
|
- axios 전역 `timeout: 10_000` 추가 (`frontend/src/lib/api.ts`).
|
|
- `router.back()` fallback — `window.history.length` 체크 후 `/study/history` 로.
|
|
|
|
## Verdict
|
|
|
|
**REQUEST_CHANGES** — critical 2건, major 2건. 대부분 UX·안정성 버그이며 배포 전 반드시 수정.
|