Conversation
Walkthrough리뷰 작성 흐름을 인라인 드래프트 편집 지원으로 리팩토링하여, 이전의 통합 AI 생성 로직을 필드별 독립적 가드로 변경. 리뷰 관리의 탭 네비게이션을 로컬 상태에서 URL 기반 상태로 전환. Changes
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
Suggested labels
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (2)
src/app/review/write/[reviewId]/_components/review-write-flow.tsx (1)
900-937: 삭제 가능한 리스트에 인덱스 기반key사용
key={size-review-${index}}/key={material-review-${index}}— 중간 항목 삭제 시 React가 남은 DOM 노드를 재사용하면서 textarea의 커서 위치·포커스 상태가 어긋날 수 있습니다. 내용 자체를 key로 쓰거나, 생성 시 고유 ID를 부여하는 방식이 안전합니다.♻️ 제안: 안정적인 key 사용
sizeReviewItems/materialReviewItems를string[]대신{ id: string; value: string }[]구조로 관리하고, 항목 추가 시crypto.randomUUID()또는Date.now()기반 ID를 할당하세요.-const [sizeReviewItems, setSizeReviewItems] = useState<string[]>([]); +const [sizeReviewItems, setSizeReviewItems] = useState<{ id: string; value: string }[]>([]);이렇게 하면
key={item.id}로 안정적인 키를 사용할 수 있습니다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/app/review/write/`[reviewId]/_components/review-write-flow.tsx around lines 900 - 937, The list uses index-based keys (key={`size-review-${index}`}) which causes DOM reuse issues when items are removed; refactor sizeReviewItems and materialReviewItems from string[] to {id: string; value: string}[] (generate id via crypto.randomUUID() or Date.now()) and use item.id for the key (key={item.id}); update all places that read/write the string to use item.value (render value={item.value}, setSizeReviewItems to map/update by id, and filter removals by id), and adjust sizeTextareaRefs handling to track refs by index or id consistently alongside autoResizeTextarea so textarea refs remain stable when items are reordered/removed.src/components/reviews/review-management-content.tsx (1)
35-38:nextSearch폴백은 도달 불가능한 코드
nextParams.set('tab', tab)호출 이후nextSearch는 항상"tab=..."문자열이므로 truthy입니다.nextSearch ? ... : pathname삼항의pathname브랜치는 절대 실행되지 않습니다.♻️ 정리 제안
- const nextSearch = nextParams.toString(); - const nextHref = nextSearch ? `${pathname}?${nextSearch}` : pathname; + const nextHref = `${pathname}?${nextParams.toString()}`;🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/components/reviews/review-management-content.tsx` around lines 35 - 38, The ternary fallback is unreachable because after calling nextParams.set('tab', tab) nextSearch will always be truthy; simplify nextHref construction by removing the conditional and always using the serialized params (use nextParams or nextSearch) to build the href, e.g., compute nextSearch = nextParams.toString() and set nextHref = `${pathname}?${nextSearch}` (or handle the empty-case only if you intentionally allow empty params), referencing nextParams, nextSearch, nextHref, searchParams, pathname, and tab to locate and update the code.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/app/review/write/`[reviewId]/_components/review-write-flow.tsx:
- Around line 434-446: commitSizeDraftReview and commitMaterialDraftReview clear
React state but don't trigger DOM input events, so the textarea height remains
from previous content; attach refs (sizeDraftTextareaRef and
materialDraftTextareaRef) to the corresponding draft textareas and, after
calling setSizeDraftReview('') / setMaterialDraftReview(''), explicitly reset
the element's style height (or call autoResizeTextarea(textareaRef.current)) to
collapse it to the empty height—update commitSizeDraftReview and
commitMaterialDraftReview to call this ref-based resize logic and ensure the
textarea elements have ref={sizeDraftTextareaRef} /
ref={materialDraftTextareaRef}.
- Around line 408-428: The current early returns inside the startTransition
block stop further work when one AI generation fails, so change the failure
handling in the generateSizeAiReviewAction and generateMaterialAiReviewAction
blocks to not return from the enclosing callback: on failure call
setErrorMessage (with the existing message fallback), skip setting the
corresponding review items and lastGenerated... ref, but continue to attempt the
other generation; ensure both generateSizeAiReviewAction and
generateMaterialAiReviewAction are invoked independently and their results
handled separately so one failure doesn't prevent the other from running.
In `@src/components/reviews/review-management-content.tsx`:
- Around line 30-33: In handleTabChange, the guard currently compares tabParam
to tab which causes adding ?tab=... when tabParam is null; change the comparison
to use the normalized activeTab instead (i.e., return early if activeTab ===
tab) so clicks on the already-active UI tab don't modify the URL, and ensure the
subsequent navigation/update logic only runs when the activeTab differs from the
target tab.
---
Nitpick comments:
In `@src/app/review/write/`[reviewId]/_components/review-write-flow.tsx:
- Around line 900-937: The list uses index-based keys
(key={`size-review-${index}`}) which causes DOM reuse issues when items are
removed; refactor sizeReviewItems and materialReviewItems from string[] to {id:
string; value: string}[] (generate id via crypto.randomUUID() or Date.now()) and
use item.id for the key (key={item.id}); update all places that read/write the
string to use item.value (render value={item.value}, setSizeReviewItems to
map/update by id, and filter removals by id), and adjust sizeTextareaRefs
handling to track refs by index or id consistently alongside autoResizeTextarea
so textarea refs remain stable when items are reordered/removed.
In `@src/components/reviews/review-management-content.tsx`:
- Around line 35-38: The ternary fallback is unreachable because after calling
nextParams.set('tab', tab) nextSearch will always be truthy; simplify nextHref
construction by removing the conditional and always using the serialized params
(use nextParams or nextSearch) to build the href, e.g., compute nextSearch =
nextParams.toString() and set nextHref = `${pathname}?${nextSearch}` (or handle
the empty-case only if you intentionally allow empty params), referencing
nextParams, nextSearch, nextHref, searchParams, pathname, and tab to locate and
update the code.
| if (shouldGenerateSizeAiReview) { | ||
| const sizeResult = await generateSizeAiReviewAction(reviewId); | ||
| if (!sizeResult.success || !sizeResult.data) { | ||
| setErrorMessage(sizeResult.message || '사이즈 AI 생성에 실패했습니다.'); | ||
| return; | ||
| } | ||
| setSizeReviewItems(sizeResult.data.aiGeneratedReviews ?? []); | ||
| lastGeneratedSizeAiKeyRef.current = sizeAiRequestKey; | ||
| } | ||
| setSizeReviewItems(result.data.aiGeneratedReviews ?? []); | ||
| setSuccessMessage('사이즈 문장을 불러왔습니다.'); | ||
| }); | ||
| }; | ||
|
|
||
| const handleGenerateMaterialAiReview = () => { | ||
| setErrorMessage(''); | ||
| setSuccessMessage(''); | ||
| startTransition(async () => { | ||
| const result = await generateMaterialAiReviewAction(reviewId); | ||
| if (!result.success || !result.data) { | ||
| setErrorMessage(result.message || '소재 AI 생성에 실패했습니다.'); | ||
| return; | ||
| if (shouldGenerateMaterialAiReview) { | ||
| const materialResult = await generateMaterialAiReviewAction(reviewId); | ||
| if (!materialResult.success || !materialResult.data) { | ||
| setErrorMessage( | ||
| materialResult.message || '소재 AI 생성에 실패했습니다.', | ||
| ); | ||
| return; | ||
| } | ||
| setMaterialReviewItems(materialResult.data.aiGeneratedReviews ?? []); | ||
| lastGeneratedMaterialAiKeyRef.current = materialAiRequestKey; | ||
| } |
There was a problem hiding this comment.
사이즈 AI 생성 실패 시 소재 AI 생성이 묵묵히 스킵됨
Line 412의 return은 startTransition 콜백 전체를 종료시킵니다. shouldGenerateSizeAiReview && shouldGenerateMaterialAiReview 케이스에서 사이즈 호출이 실패하면 소재 AI 요청은 아예 시도되지 않고, 사용자는 두 번 다시 버튼을 눌러야 합니다. PR 목표("size, material, or both 조건 기반 생성")를 감안하면 두 요청은 독립적으로 처리되어야 합니다.
🔧 제안 수정: 독립 실패 처리
startTransition(async () => {
+ let hasError = false;
+
if (shouldGenerateSizeAiReview) {
const sizeResult = await generateSizeAiReviewAction(reviewId);
if (!sizeResult.success || !sizeResult.data) {
setErrorMessage(sizeResult.message || '사이즈 AI 생성에 실패했습니다.');
- return;
+ hasError = true;
+ } else {
+ setSizeReviewItems(sizeResult.data.aiGeneratedReviews ?? []);
+ lastGeneratedSizeAiKeyRef.current = sizeAiRequestKey;
}
- setSizeReviewItems(sizeResult.data.aiGeneratedReviews ?? []);
- lastGeneratedSizeAiKeyRef.current = sizeAiRequestKey;
}
if (shouldGenerateMaterialAiReview) {
const materialResult = await generateMaterialAiReviewAction(reviewId);
if (!materialResult.success || !materialResult.data) {
setErrorMessage(materialResult.message || '소재 AI 생성에 실패했습니다.');
- return;
+ hasError = true;
+ } else {
+ setMaterialReviewItems(materialResult.data.aiGeneratedReviews ?? []);
+ lastGeneratedMaterialAiKeyRef.current = materialAiRequestKey;
}
- setMaterialReviewItems(materialResult.data.aiGeneratedReviews ?? []);
- lastGeneratedMaterialAiKeyRef.current = materialAiRequestKey;
}
- setSuccessMessage('AI 문장을 불러왔습니다.');
+ if (!hasError) {
+ setSuccessMessage('AI 문장을 불러왔습니다.');
+ }
});📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| if (shouldGenerateSizeAiReview) { | |
| const sizeResult = await generateSizeAiReviewAction(reviewId); | |
| if (!sizeResult.success || !sizeResult.data) { | |
| setErrorMessage(sizeResult.message || '사이즈 AI 생성에 실패했습니다.'); | |
| return; | |
| } | |
| setSizeReviewItems(sizeResult.data.aiGeneratedReviews ?? []); | |
| lastGeneratedSizeAiKeyRef.current = sizeAiRequestKey; | |
| } | |
| setSizeReviewItems(result.data.aiGeneratedReviews ?? []); | |
| setSuccessMessage('사이즈 문장을 불러왔습니다.'); | |
| }); | |
| }; | |
| const handleGenerateMaterialAiReview = () => { | |
| setErrorMessage(''); | |
| setSuccessMessage(''); | |
| startTransition(async () => { | |
| const result = await generateMaterialAiReviewAction(reviewId); | |
| if (!result.success || !result.data) { | |
| setErrorMessage(result.message || '소재 AI 생성에 실패했습니다.'); | |
| return; | |
| if (shouldGenerateMaterialAiReview) { | |
| const materialResult = await generateMaterialAiReviewAction(reviewId); | |
| if (!materialResult.success || !materialResult.data) { | |
| setErrorMessage( | |
| materialResult.message || '소재 AI 생성에 실패했습니다.', | |
| ); | |
| return; | |
| } | |
| setMaterialReviewItems(materialResult.data.aiGeneratedReviews ?? []); | |
| lastGeneratedMaterialAiKeyRef.current = materialAiRequestKey; | |
| } | |
| if (shouldGenerateSizeAiReview) { | |
| let hasError = false; | |
| const sizeResult = await generateSizeAiReviewAction(reviewId); | |
| if (!sizeResult.success || !sizeResult.data) { | |
| setErrorMessage(sizeResult.message || '사이즈 AI 생성에 실패했습니다.'); | |
| hasError = true; | |
| } else { | |
| setSizeReviewItems(sizeResult.data.aiGeneratedReviews ?? []); | |
| lastGeneratedSizeAiKeyRef.current = sizeAiRequestKey; | |
| } | |
| } | |
| if (shouldGenerateMaterialAiReview) { | |
| const materialResult = await generateMaterialAiReviewAction(reviewId); | |
| if (!materialResult.success || !materialResult.data) { | |
| setErrorMessage( | |
| materialResult.message || '소재 AI 생성에 실패했습니다.', | |
| ); | |
| hasError = true; | |
| } else { | |
| setMaterialReviewItems(materialResult.data.aiGeneratedReviews ?? []); | |
| lastGeneratedMaterialAiKeyRef.current = materialAiRequestKey; | |
| } | |
| } | |
| if (!hasError) { | |
| setSuccessMessage('AI 문장을 불러왔습니다.'); | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/app/review/write/`[reviewId]/_components/review-write-flow.tsx around
lines 408 - 428, The current early returns inside the startTransition block stop
further work when one AI generation fails, so change the failure handling in the
generateSizeAiReviewAction and generateMaterialAiReviewAction blocks to not
return from the enclosing callback: on failure call setErrorMessage (with the
existing message fallback), skip setting the corresponding review items and
lastGenerated... ref, but continue to attempt the other generation; ensure both
generateSizeAiReviewAction and generateMaterialAiReviewAction are invoked
independently and their results handled separately so one failure doesn't
prevent the other from running.
| const commitSizeDraftReview = () => { | ||
| const nextValue = sizeDraftReview.trim(); | ||
| if (!nextValue) return; | ||
| setSizeReviewItems((prev) => [...prev, nextValue]); | ||
| setSizeDraftReview(''); | ||
| }; | ||
|
|
||
| const handleAddMaterialReviewItem = () => { | ||
| setMaterialReviewItems((prev) => { | ||
| setPendingMaterialFocusIndex(prev.length); | ||
| return [...prev, '']; | ||
| }); | ||
| const commitMaterialDraftReview = () => { | ||
| const nextValue = materialDraftReview.trim(); | ||
| if (!nextValue) return; | ||
| setMaterialReviewItems((prev) => [...prev, nextValue]); | ||
| setMaterialDraftReview(''); | ||
| }; |
There was a problem hiding this comment.
드래프트 커밋 후 textarea 높이가 리셋되지 않음
setSizeDraftReview('') / setMaterialDraftReview('')는 React 상태를 비우지만 DOM onInput 이벤트를 발생시키지 않으므로, autoResizeTextarea가 호출되지 않아 빈 textarea가 이전 입력 높이를 유지합니다. 커밋 후 텍스트에어리어에 ref를 달고 명시적으로 높이를 초기화해야 합니다.
🔧 제안 수정
+const sizeDraftTextareaRef = useRef<HTMLTextAreaElement | null>(null);
+const materialDraftTextareaRef = useRef<HTMLTextAreaElement | null>(null);
const commitSizeDraftReview = () => {
const nextValue = sizeDraftReview.trim();
if (!nextValue) return;
setSizeReviewItems((prev) => [...prev, nextValue]);
setSizeDraftReview('');
+ autoResizeTextarea(sizeDraftTextareaRef.current);
};
const commitMaterialDraftReview = () => {
const nextValue = materialDraftReview.trim();
if (!nextValue) return;
setMaterialReviewItems((prev) => [...prev, nextValue]);
setMaterialDraftReview('');
+ autoResizeTextarea(materialDraftTextareaRef.current);
};그리고 각 드래프트 textarea에 ref={sizeDraftTextareaRef} / ref={materialDraftTextareaRef} 추가.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/app/review/write/`[reviewId]/_components/review-write-flow.tsx around
lines 434 - 446, commitSizeDraftReview and commitMaterialDraftReview clear React
state but don't trigger DOM input events, so the textarea height remains from
previous content; attach refs (sizeDraftTextareaRef and
materialDraftTextareaRef) to the corresponding draft textareas and, after
calling setSizeDraftReview('') / setMaterialDraftReview(''), explicitly reset
the element's style height (or call autoResizeTextarea(textareaRef.current)) to
collapse it to the empty height—update commitSizeDraftReview and
commitMaterialDraftReview to call this ref-based resize logic and ensure the
textarea elements have ref={sizeDraftTextareaRef} /
ref={materialDraftTextareaRef}.
| const handleTabChange = (tab: ReviewTab) => { | ||
| if (tabParam === tab) { | ||
| return; | ||
| } |
There was a problem hiding this comment.
tabParam === tab 비교로 초기 URL에 불필요한 ?tab=writable 추가
URL에 tab 파라미터가 없는 초기 상태(tabParam === null)에서 사용자가 이미 활성화된 "쓸 수 있는 후기" 탭을 클릭하면, null === 'writable'은 false이므로 가드를 통과해 ?tab=writable이 URL에 추가됩니다. tabParam 대신 이미 정규화된 activeTab과 비교해야 합니다.
🔧 제안 수정
const handleTabChange = (tab: ReviewTab) => {
- if (tabParam === tab) {
+ if (activeTab === tab) {
return;
}
// ...
};📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const handleTabChange = (tab: ReviewTab) => { | |
| if (tabParam === tab) { | |
| return; | |
| } | |
| const handleTabChange = (tab: ReviewTab) => { | |
| if (activeTab === tab) { | |
| return; | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/components/reviews/review-management-content.tsx` around lines 30 - 33,
In handleTabChange, the guard currently compares tabParam to tab which causes
adding ?tab=... when tabParam is null; change the comparison to use the
normalized activeTab instead (i.e., return early if activeTab === tab) so clicks
on the already-active UI tab don't modify the URL, and ensure the subsequent
navigation/update logic only runs when the activeTab differs from the target
tab.
📝 개요
/reviews로 돌아왔을 때 작성 대기 탭이 선택되던 문제를 수정했습니다.tab)와 동기화해 리다이렉트/새로고침 시에도 의도한 탭이 유지되도록 개선했습니다.후기 문장 받아보기단일 흐름과 모바일 완료(Enter) 기반 직접 입력 추가를 지원합니다.🚀 주요 변경 사항
리뷰 작성 완료 후 작성 리뷰 탭 자동 노출
src/app/review/write/[reviewId]/_components/review-write-flow.tsxrouter.replace('/reviews?tab=written')로 변경리뷰 관리 탭 URL 동기화
src/components/reviews/review-management-content.tsx?tab=writable|written파싱으로 활성 탭 결정tab만 갱신scroll: false적용Step2 문장 생성/입력 UX 개선
src/app/review/write/[reviewId]/_components/review-write-flow.tsx후기 문장 받아보기로 통합직접 문장 추가 UX 개편 (모바일 우선)
직접 문장 추가버튼 제거+버튼으로 추가x버튼으로 삭제Shift+Enter줄바꿈, IME 조합 보호)텍스트 스타일 보정
text-base)text-base)📸 스크린샷 (선택)
✅ 체크리스트
이슈 해결 여부