Skip to content

Comments

fix: 무한스크롤 패턴 전역 도입#310

Open
heeeeyong wants to merge 6 commits intorefactorfrom
refactor-infiniteScroll
Open

fix: 무한스크롤 패턴 전역 도입#310
heeeeyong wants to merge 6 commits intorefactorfrom
refactor-infiniteScroll

Conversation

@heeeeyong
Copy link
Collaborator

@heeeeyong heeeeyong commented Feb 24, 2026

📝작업 내용

  • 무한스크롤 로직을 useInifinieScroll 공통 훅 기반으로 통일했습니다.
  • 기존 페이지별 수동 구현(스크롤 이벤트, 개별 observer, nextCursor/isLast 상태 관리)을 제거하고, fetchPage + reloadKey + sentinelRef 패턴으로 정리했습니다.
  • 내부 스크롤 컨테이너에서도 감지되도록 훅에 rootRef 옵션을 추가했습니다.
  • 하단 로딩 UI를 텍스트 대신 공통 LoadingSpinner로 통일했습니다.
  • 댓글 영역(FeedDetailPage)은 ReplyList 내부에서 직접 커서 기반 무한스크롤을 수행하도록 구조를 변경했습니다.
  • 리팩토링 과정에서 발견된 CSS 이슈를 보완했습니다.
    • 긴 영문 연속 텍스트가 한 줄로만 보이던 문제 해결
    • 데스크탑에서도 모바일 화면에서의 댓글 모달의 스크롤바 스타일을 적용
    • 댓글 모달의 불필요한 하단 여백 삭제

적용 파일

  • src/hooks/useInifinieScroll.ts
  • src/pages/feed/Feed.tsx
  • src/pages/feed/FollowerListPage.tsx
  • src/pages/searchBook/SearchBook.tsx
  • src/pages/notice/Notice.tsx
  • src/pages/mypage/SavePage.tsx
  • src/pages/memory/Memory.tsx
  • src/components/group/MyGroupModal.tsx
  • src/pages/groupSearch/GroupSearch.tsx
  • src/components/search/GroupSearchResult.tsx
  • src/components/search/GroupSearchResult.styled.ts
  • src/pages/feed/FeedDetailPage.tsx
  • src/components/common/Post/ReplyList.tsx
  • src/components/common/Post/ReplyList.tsx

위키

위키

Summary by CodeRabbit

  • 새로운 기능

    • 피드·댓글·검색·저장목록·팔로워 등 전반에 걸친 무한 스크롤(지연 로드) 도입.
  • 개선 사항

    • 좋아요/저장/팔로우/투표 동작에 낙관적 UI 적용(즉각 반영 후 실패 시 복원).
    • 중복 클릭 방지 기능 추가 및 작업 중 아이콘 흐림으로 로딩 상태 시각화.
    • 검색 입력 디바운스 처리와 로딩 표시 개선, 스크롤바 스타일링 보강.

@vercel
Copy link

vercel bot commented Feb 24, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
thip Ready Ready Preview, Comment Feb 24, 2026 5:51pm

@heeeeyong heeeeyong requested review from ho0010 and ljh130334 and removed request for ho0010 February 24, 2026 08:02
@coderabbitai
Copy link

coderabbitai bot commented Feb 24, 2026

Walkthrough

새 훅들(usePreventDoubleClick, useInifinieScroll, useDebouncedCallback)을 추가하고, 여러 컴포넌트에서 좋아요·저장·팔로우·투표에 낙관적 UI(optimistic update)와 롤백 로직을 도입했으며, 다수의 페이지를 무한 스크롤 훅으로 리팩터링하고 스타일/DOM 태그 및 package.json 의존성을 일부 변경했습니다.

Changes

Cohort / File(s) Summary
의존성
package.json
@tanstack/react-query v^5.90.21 의존성 추가
새 훅들
src/hooks/usePreventDoubleClick.ts, src/hooks/useInifinieScroll.ts, src/hooks/useDebouncedCallback.ts
더블클릭 방지(run/isLoading), IntersectionObserver 기반 무한 스크롤(페치/병합/재로드/sentinelRef), 디바운스 콜백 훅 추가
포스트·댓글 상호작용
src/components/common/Post/PostFooter.tsx, src/components/common/Post/Reply.tsx, src/components/common/Post/SubReply.tsx
좋아요/저장 동작에 낙관적 업데이트·롤백, usePreventDoubleClick 적용, 로딩 시 아이콘 불투명도 처리(시각 피드백) 도입
댓글 리스트/바텀시트
src/components/common/Post/ReplyList.tsx, src/components/common/Post/ReplyList.styled.ts, src/components/common/CommentBottomSheet/GlobalCommentBottomSheet.tsx, src/components/common/CommentBottomSheet/GlobalCommentBottomSheet.styled.ts
ReplyList에 postId/postType/reloadKey/rootRef 기반의 무한 모드 추가 및 스타일(disableBottomMargin) 추가. GlobalCommentBottomSheet에서 내부 댓글 페칭 제거하고 ReplyList로 대체, 스크롤 스타일 추가
팔로우 관련 컴포넌트
src/components/feed/Profile.tsx, src/components/feed/UserProfileItem.tsx
팔로우/언팔로우에 낙관적 업데이트·롤백, usePreventDoubleClick 적용, 로컬 ref로 상태 동기화 및 로딩 표시
무한 스크롤으로 리팩터링된 페이지들
src/pages/feed/Feed.tsx, src/pages/feed/FollowerListPage.tsx, src/pages/groupSearch/GroupSearch.tsx, src/pages/memory/Memory.tsx, src/pages/mypage/SavePage.tsx, src/pages/notice/Notice.tsx, src/pages/searchBook/SearchBook.tsx
각 페이지의 수동 페이징/observer 로직을 useInifinieScroll 훅으로 통합하여 items/nextCursor/isLast/isLoading/isLoadingMore/sentinelRef 기반으로 변경; 렌더링·reloadKey/데이터 소스 재연결
검색 결과 관련 변경
src/components/search/GroupSearchResult.tsx, src/components/search/GroupSearchResult.styled.ts
lastRoomElementCallbacksentinelRef/hasMore prop으로 교체, 로딩 텍스트를 LoadingSpinner로 변경, LoadingText 태그를 <p>에서 <div>로 변경(DOM 태그 변경)
그룹 모달
src/components/group/MyGroupModal.tsx
내부 수동 스크롤/페칭 로직을 useInifinieScroll로 대체하고 탭별 roomType 기반으로 로딩
메모리 투표(Poll)
src/components/memory/RecordItem/PollRecord.tsx
투표에 낙관적 업데이트·롤백 적용, usePreventDoubleClick 사용, optionsRef로 상태 동기화 및 서버 응답에 따른 정규화
기타 경미 변경
src/main.tsx
동일 라인 교체(기능적 변경 없음)

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant UI as Component (Post/Reply/Profile/Poll)
    participant Hook as usePreventDoubleClick
    participant API as Backend API
    participant State as Local State / Refs

    User->>UI: 좋아요/저장/팔로우/투표 클릭
    UI->>Hook: run(asyncAction) 호출
    activate Hook
    Hook->>State: 낙관적 상태 즉시 적용 (toggle, count 조정)
    Hook->>UI: 로딩 표시 활성화 (isLoading)
    Hook->>API: (300ms 지연 후) 요청 전송
    activate API
    alt API 성공
        API-->>Hook: 성공 응답
        Hook->>State: 상태 유지 또는 서버 응답으로 보정
        Hook->>UI: 성공 메시지(스낵바) 표시 가능
    else API 실패
        API-->>Hook: 실패 응답/에러
        Hook->>State: 이전 상태로 롤백
        Hook->>UI: 에러 스낵바 표시
    end
    deactivate API
    Hook->>UI: 로딩 표시 해제
    deactivate Hook
Loading
sequenceDiagram
    participant User
    participant Page as Page Component
    participant Hook as useInifinieScroll
    participant Observer as IntersectionObserver
    participant API as Backend fetchPage
    participant State as Hook State

    User->>Page: 페이지 진입
    Page->>Hook: useInifinieScroll 초기화(fetchPage 제공)
    activate Hook
    Hook->>API: fetchPage(cursor=null)
    activate API
    API-->>Hook: items, nextCursor, isLast
    deactivate API
    Hook->>State: items 업데이트, sentinelRef 설정
    deactivate Hook

    User->>Page: 스크롤(하단)
    Observer->>Hook: sentinel 교차 -> loadMore
    activate Hook
    Hook->>API: fetchPage(cursor=nextCursor)
    activate API
    API-->>Hook: next items, nextCursor, isLast
    deactivate API
    Hook->>State: items 병합, isLoadingMore false
    deactivate Hook
    Page->>User: 신규 항목 렌더링
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested reviewers

  • ho0010
  • ljh130334

Poem

🐰 당근으로 코드를 톡톡 다듬고,
훅 한 줌 심어 중복을 막았네,
스크롤은 끝없이 꽃을 피우고,
낙관적 업데이트로 눈 깜짝할 사이,
에러 오면 폴짝 롤백, 다시 달려가네 ✨

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed PR 제목 '무한스크롤 패턴 전역 도입'은 변경사항의 핵심을 명확하게 반영하며, 전체 PR이 무한스크롤 로직을 공통 훅으로 통일하는 것을 정확하게 설명합니다.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch refactor-infiniteScroll

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
src/components/feed/UserProfileItem.tsx (1)

2-27: ⚠️ Potential issue | 🟠 Major

isFollowing 변경 시 로컬 상태가 동기화되지 않음

초기값만 반영되어 부모 데이터가 업데이트될 때 버튼 상태가 stale될 수 있습니다. Profile.tsx(38-41줄)의 패턴처럼 useEffect를 추가하여 동기화해야 합니다.

✅ 수정 제안
-import { useRef, useState } from 'react';
+import { useEffect, useRef, useState } from 'react';

그리고 26줄 이후에 추가:

+  useEffect(() => {
+    const normalized = !!isFollowing;
+    followedRef.current = normalized;
+    setFollowed(normalized);
+  }, [isFollowing]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/feed/UserProfileItem.tsx` around lines 2 - 27, The local
follow state (followed and followedRef) in UserProfileItem is only set from the
initial isFollowing prop and becomes stale when the parent updates isFollowing;
add a useEffect in the UserProfileItem component that watches the isFollowing
prop and updates both setFollowed(!!isFollowing) and followedRef.current =
!!isFollowing so the button and ref stay in sync with prop changes; reference
the followed state, followedRef, and the isFollowing prop in this effect to
mirror the pattern used in Profile.tsx.
src/components/common/Post/ReplyList.tsx (1)

66-87: ⚠️ Potential issue | 🟠 Major

초기 로딩 중 LoadingSpinnerEmptyState가 동시에 표시됩니다.

무한 스크롤 모드에서 초기 로딩 시 list.length === 0이므로:

  • 라인 66-68: LoadingSpinner 렌더링 ✓
  • 라인 69 → 82-87: hasComments가 false → EmptyState 렌더링 ✓

두 컴포넌트가 동시에 화면에 표시되어 "아직 댓글이 없어요" 메시지와 로딩 스피너가 함께 노출됩니다.

🐛 수정 제안
+ {isInfiniteMode && commentFeed.isLoading && list.length === 0 && (
+   <LoadingSpinner size="small" fullHeight={false} />
+ )}
- {hasComments ? (
+ {hasComments ? (
    list.map((comment, commentIndex) => (
      ...
    ))
+ ) : isInfiniteMode && commentFeed.isLoading ? null : (
- ) : (
    <EmptyState>
      <div className="title">아직 댓글이 없어요</div>
      <div className="sub-title">첫번째 댓글을 남겨보세요</div>
    </EmptyState>
  )}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/common/Post/ReplyList.tsx` around lines 66 - 87, The
EmptyState is rendered during initial infinite-scroll loading because
isInfiniteMode && commentFeed.isLoading && list.length === 0 is true; update the
EmptyState rendering logic so it is suppressed while the initial load is in
progress—e.g., compute a showEmpty flag and only render EmptyState when
!hasComments && !(isInfiniteMode && commentFeed.isLoading && list.length === 0),
or incorporate that same condition into the existing ternary that decides
between list.map and EmptyState; adjust usages of isInfiniteMode,
commentFeed.isLoading, list.length, hasComments, LoadingSpinner, Reply, and
SubReply accordingly.
src/pages/feed/Feed.tsx (1)

79-114: ⚠️ Potential issue | 🟡 Minor

초기 로딩 500ms 목표 유지 여부 확인 필요.

로딩 처리 흐름이 바뀌어 체감 시간이 달라질 수 있으니, 초기 로딩이 500ms 목표를 충족하는지 측정/기록해 주세요.
Based on learnings: On src/pages/feed/Feed.tsx, ensure the feed page initial loading delay is 500ms (not 1000ms). This means the user-perceived load time should be within 500ms; verify network latency and any heavy computation on initial render. Implement optimizations such as code-splitting/lazy loading, suspense, skeletons, or incremental rendering to meet the target, and document measured latency in performance notes or tests.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/pages/feed/Feed.tsx` around lines 79 - 114, The feed initial-loading flow
currently shows LoadingSpinner tied to currentFeed.isLoading; ensure the
user-perceived initial load is <=500ms by removing/shortening any artificial 1s
delays in the feed data-fetch path and by lazy-loading heavy UI pieces (wrap
TotalFeed and MyFeed in React.lazy + Suspense or render a lightweight skeleton
instead of the full component during initial render); verify
currentFeed.isLoading gating doesn't wait longer than 500ms and avoid expensive
sync work in Feed render; add a performance measurement around the initial
render/data fetch (timestamp when Feed mounts and when first meaningful content
renders) and record/assert the measured latency in performance notes or a test
to confirm <500ms.
🧹 Nitpick comments (6)
src/pages/searchBook/SearchBook.tsx (1)

159-183: handleSaveButton의 낙관적 UI 패턴은 적절하나 롤백 로직에 중복이 있습니다.

라인 170-174와 175-180의 롤백 로직이 동일합니다. 헬퍼 함수로 추출하면 가독성이 개선됩니다.

♻️ 중복 제거 제안
  const handleSaveButton = () => {
    if (!isbn) return;
    runSave(async () => {
      const nextSaved = !isSavedRef.current;
      isSavedRef.current = nextSaved;
      setIsSaved(nextSaved);

      await new Promise(resolve => setTimeout(resolve, 300));

+     const rollbackIfStale = () => {
+       if (isSavedRef.current === nextSaved) {
+         const rollback = !nextSaved;
+         isSavedRef.current = rollback;
+         setIsSaved(rollback);
+       }
+     };
+
      try {
        const response = await postSaveBook(isbn, nextSaved);
-       if (!response.isSuccess && isSavedRef.current === nextSaved) {
-         const rollback = !nextSaved;
-         isSavedRef.current = rollback;
-         setIsSaved(rollback);
-       }
+       if (!response.isSuccess) rollbackIfStale();
      } catch {
-       if (isSavedRef.current === nextSaved) {
-         const rollback = !nextSaved;
-         isSavedRef.current = rollback;
-         setIsSaved(rollback);
-       }
+       rollbackIfStale();
      }
    });
  };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/pages/searchBook/SearchBook.tsx` around lines 159 - 183, The rollback
logic in handleSaveButton is duplicated in the try/failure and catch branches;
extract a small helper (e.g., rollbackIfUnchanged or performRollback) inside or
next to handleSaveButton that accepts the attempted state (nextSaved) and
performs the isSavedRef.current check, flips to the rollback value, and calls
setIsSaved; then replace both duplicated blocks with a call to that helper when
postSaveBook fails or throws, keeping references to isSavedRef, setIsSaved and
nextSaved and ensuring the helper uses the same conditional (if
isSavedRef.current === nextSaved) before rolling back.
src/pages/mypage/SavePage.tsx (1)

72-95: 낙관적 저장 토글 패턴이 적절합니다.

unsave 시 목록에서 즉시 제거하고, 실패 시 previousBooks로 롤백하는 흐름이 깔끔합니다. 단, handleSaveToggleusePreventDoubleClick이 적용되지 않았으므로, 빠른 연속 클릭 시 중복 API 호출이 발생할 수 있습니다. 다른 컴포넌트(PollRecord, SearchBook)에서는 usePreventDoubleClick으로 보호하고 있으므로 동일하게 적용하는 것을 권장합니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/pages/mypage/SavePage.tsx` around lines 72 - 95, The handleSaveToggle
function performs optimistic updates but lacks protection against rapid repeated
clicks; wrap or guard handleSaveToggle with the existing usePreventDoubleClick
hook (same pattern used in PollRecord/SearchBook) so multiple quick invocations
don’t trigger duplicate postSaveBook calls. Locate handleSaveToggle and apply
usePreventDoubleClick to the exported/onClick handler (or create a wrapped
version like const handleSaveToggleSafe =
usePreventDoubleClick(handleSaveToggle)) ensuring the wrapped handler calls
postSaveBook and still performs the same optimistic savedBooks.setItems and
rollback logic on failure.
src/hooks/useInifinieScroll.ts (1)

72-88: loadMore가 state 의존성으로 인해 매 상태 변경마다 재생성되어 Observer가 자주 재구독됨

loadMoreuseCallback 의존성에 isLastnextCursor가 포함되어 있어, 페이지를 불러올 때마다 loadMore → observer effect 재실행 → disconnect() + 새 observe() 사이클이 반복됩니다. 기능적으로 문제는 없지만, 빈번한 observer teardown/re-creation은 불필요한 오버헤드입니다.

isLastnextCursor도 ref로 관리하면 loadMore가 안정적인 참조를 유지하여 observer가 재구독되지 않습니다.

Also applies to: 98-113

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/useInifinieScroll.ts` around lines 72 - 88, loadMore is being
re-created whenever state variables isLast or nextCursor change; convert them to
refs to stabilize the callback: create isLastRef and nextCursorRef (initialized
from current state), update those refs wherever you call setIsLast or
setNextCursor, and inside loadMore read isLastRef.current and
nextCursorRef.current instead of the state variables; keep isFetchingRef and
fetchPageRef usage as-is and remove isLast and nextCursor from loadMore's
useCallback dependencies so loadMore maintains a stable reference for the
observer (also apply the same ref-based pattern to the other loadMore-like
callback at lines 98-113).
src/components/common/Post/ReplyList.tsx (1)

56-62: useCallback 의존성에 commentFeed 전체 객체를 넣으면 매 렌더마다 불필요하게 재생성됩니다.

useInifinieScroll 훅은 매 렌더마다 새로운 객체를 반환하므로, 의존성 배열에 commentFeed를 넣으면 handleReload가 계속 재생성됩니다. 실제로 필요한 것은 commentFeed.reload(= loadFirstPage)뿐이며, 이는 useCallback으로 감싸져 있어 안정적입니다. commentFeed.reload만 참조하도록 변경하세요.

♻️ 수정 제안
- }, [commentFeed, isInfiniteMode, onReload]);
+ }, [commentFeed.reload, isInfiniteMode, onReload]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/common/Post/ReplyList.tsx` around lines 56 - 62, The
handleReload useCallback currently depends on the whole commentFeed object which
causes unnecessary re-creations because useInfiniteScroll returns a new object
each render; change the dependency to reference only the stable method
commentFeed.reload (i.e., use commentFeed.reload in the callback and add
commentFeed.reload to the dependency array) so handleReload remains stable;
update the useCallback declaration for handleReload to call commentFeed.reload()
when isInfiniteMode and include only commentFeed.reload, isInfiniteMode, and
onReload in the dependency list.
src/pages/memory/Memory.tsx (1)

193-203: 새 기록 선삽입 시 중복 방지 고려.

서버 응답에 동일 기록이 포함될 수 있어 중복 노출 가능성이 있습니다. id 기준으로 이미 존재하면 prepend를 건너뛰는 처리를 고려해주세요.

♻️ 중복 방지 예시
-      setRecordItems(prev => [newRecord, ...prev]);
+      setRecordItems(prev =>
+        prev.some(record => record.id === newRecord.id) ? prev : [newRecord, ...prev],
+      );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/pages/memory/Memory.tsx` around lines 193 - 203, When handling a newly
uploaded record in the useEffect that reads location.state?.newRecord, avoid
blindly prepending it via setRecordItems; instead check the existing list
(recordsList.items or the currentRecords memo) for an item with the same id and
only call setRecordItems(prev => [newRecord, ...prev]) if no item with
newRecord.id already exists, then continue to setShowUploadProgress(true) and
navigate(...). This ensures duplication is prevented when the server response
already contains the same record.
src/components/common/Post/PostFooter.tsx (1)

45-54: 부모에서 likeCount 갱신 시 동기화 추가 고려.

props 갱신으로 initialLikeCount가 바뀔 때 로컬 상태가 stale될 수 있어 동기화용 effect를 추가하는 편이 안전합니다.

♻️ 동기화 예시
+  useEffect(() => {
+    setLikeCount(initialLikeCount);
+  }, [initialLikeCount]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/common/Post/PostFooter.tsx` around lines 45 - 54, Props
updates to initialLikeCount aren't synchronized to local state, so add an effect
that updates the local likeCount state and any likeCountRef when the
initialLikeCount prop changes; specifically, add a useEffect watching
initialLikeCount that calls setLikeCount(initialLikeCount) and sets
likeCountRef.current = initialLikeCount (mirroring the existing patterns used
for isLiked/isSaved with setLiked/likedRef and setSaved/savedRef).
🤖 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/components/group/MyGroupModal.tsx`:
- Around line 138-140: The inline style on the sentinel div uses gridColumn
('gridColumn: "1 / -1"') but the parent Content styled component uses
flex-direction: column so gridColumn has no effect; in MyGroupModal remove the
unnecessary gridColumn style from the div that uses roomList.sentinelRef, or if
the intent was to span columns change the parent layout to CSS grid in the
Content styled component—locate the sentinel div (roomList.sentinelRef) and
either drop gridColumn or update Content to display: grid with appropriate grid
template so the spanning makes sense.

In `@src/components/memory/RecordItem/PollRecord.tsx`:
- Around line 96-105: The code in PollRecord uses
Math.max(...optimisticOptions.map(...)) which returns -Infinity for an empty
optimisticOptions; guard this by deriving maxCount safely (e.g., default to 0
when optimisticOptions is empty) before computing normalizedOptions, and ensure
optionsRef/current state logic (optionsRef.current, setCurrentOptions) uses that
safe maxCount; also remove or make the artificial await new Promise(resolve =>
setTimeout(resolve, 300)) explicit/configurable (e.g., remove the fixed 300ms
delay or replace it with a documented, optional animation delay flag) and add a
brief comment explaining any retained delay so callers understand the UX
tradeoff.

In `@src/hooks/useDebouncedCallback.ts`:
- Around line 3-6: The function generics violate the no-explicit-any rule;
update the parameter type in useDebouncedCallback so T is constrained with
unknown[] instead of any[] (e.g., change T extends (...args: any[]) => void to T
extends (...args: unknown[]) => void and adjust the callback parameter typing
accordingly) to improve type safety and satisfy
`@typescript-eslint/no-explicit-any`.

In `@src/hooks/useInifinieScroll.ts`:
- Line 20: Rename the hook symbol useInifinieScroll to useInfiniteScroll and
update its filename and all imports/exports accordingly: change the exported
function name in src/hooks/useInifinieScroll.ts to useInfiniteScroll, rename the
file to src/hooks/useInfiniteScroll.ts (or update any barrel export), then run a
repository-wide replace for "useInifinieScroll" → "useInfiniteScroll" to update
all import statements and usages so they match the corrected export and avoid
broken references.
- Around line 53-70: There's a race where reloadKey changes while a previous
fetch is in-flight causing loadFirstPage to early-return and later the stale
fetch to overwrite state; fix by adding a generation counter ref (e.g.,
generationRef) that's incremented in the effect that responds to reloadKey,
capture const gen = generationRef.current at the start of loadFirstPage, change
the isFetchingRef guard to only short-circuit if isFetchingRef.current && gen
=== generationRef.current (so a new generation can start its own fetch), and
before calling setItems/setNextCursor/setIsLast check gen ===
generationRef.current to ignore stale responses from older generations; update
references to fetchPageRef, isFetchingRef, and loadFirstPage accordingly.

In `@src/main.tsx`:
- Line 8: package.json currently includes `@tanstack/react-query` but main
bootstrap uses createRoot(...).render(<App />) without initializing or providing
a QueryClient; either remove the unused dependency from package.json or wire up
react-query by creating a QueryClient instance and wrapping the root with
QueryClientProvider (e.g., in main.tsx or inside App.tsx) so the app is rendered
as <QueryClientProvider client={queryClient}><App/></QueryClientProvider>;
update imports and ensure QueryClient is constructed before calling
createRoot(...).render.

In `@src/pages/groupSearch/GroupSearch.tsx`:
- Around line 136-168: The current useInifinieScroll usage can miss a new search
if a previous fetch is in-flight (isFetchingRef) because loadFirstPage returns
early; update useInifinieScroll to guarantee reloads when reloadKey changes by
either (A) adding a pendingReload boolean/queue that, when a reload is requested
during isFetchingRef, stores the intent and runs loadFirstPage again once the
current fetch finishes, or (B) support abortable fetches: accept an AbortSignal
into fetchPage (and forward it to getSearchRooms) and cancel the prior request
on reload so the new queryTerm/searchStatus immediately triggers a fresh fetch;
update the hook API (e.g., expose triggerReload(force=true) or ensure reloadKey
changes always call loadFirstPage) and adjust call sites (the GroupSearch use of
useInifinieScroll and fetchPage) to use the new API or pass an AbortSignal so
the latest search is always loaded.

---

Outside diff comments:
In `@src/components/common/Post/ReplyList.tsx`:
- Around line 66-87: The EmptyState is rendered during initial infinite-scroll
loading because isInfiniteMode && commentFeed.isLoading && list.length === 0 is
true; update the EmptyState rendering logic so it is suppressed while the
initial load is in progress—e.g., compute a showEmpty flag and only render
EmptyState when !hasComments && !(isInfiniteMode && commentFeed.isLoading &&
list.length === 0), or incorporate that same condition into the existing ternary
that decides between list.map and EmptyState; adjust usages of isInfiniteMode,
commentFeed.isLoading, list.length, hasComments, LoadingSpinner, Reply, and
SubReply accordingly.

In `@src/components/feed/UserProfileItem.tsx`:
- Around line 2-27: The local follow state (followed and followedRef) in
UserProfileItem is only set from the initial isFollowing prop and becomes stale
when the parent updates isFollowing; add a useEffect in the UserProfileItem
component that watches the isFollowing prop and updates both
setFollowed(!!isFollowing) and followedRef.current = !!isFollowing so the button
and ref stay in sync with prop changes; reference the followed state,
followedRef, and the isFollowing prop in this effect to mirror the pattern used
in Profile.tsx.

In `@src/pages/feed/Feed.tsx`:
- Around line 79-114: The feed initial-loading flow currently shows
LoadingSpinner tied to currentFeed.isLoading; ensure the user-perceived initial
load is <=500ms by removing/shortening any artificial 1s delays in the feed
data-fetch path and by lazy-loading heavy UI pieces (wrap TotalFeed and MyFeed
in React.lazy + Suspense or render a lightweight skeleton instead of the full
component during initial render); verify currentFeed.isLoading gating doesn't
wait longer than 500ms and avoid expensive sync work in Feed render; add a
performance measurement around the initial render/data fetch (timestamp when
Feed mounts and when first meaningful content renders) and record/assert the
measured latency in performance notes or a test to confirm <500ms.

---

Nitpick comments:
In `@src/components/common/Post/PostFooter.tsx`:
- Around line 45-54: Props updates to initialLikeCount aren't synchronized to
local state, so add an effect that updates the local likeCount state and any
likeCountRef when the initialLikeCount prop changes; specifically, add a
useEffect watching initialLikeCount that calls setLikeCount(initialLikeCount)
and sets likeCountRef.current = initialLikeCount (mirroring the existing
patterns used for isLiked/isSaved with setLiked/likedRef and setSaved/savedRef).

In `@src/components/common/Post/ReplyList.tsx`:
- Around line 56-62: The handleReload useCallback currently depends on the whole
commentFeed object which causes unnecessary re-creations because
useInfiniteScroll returns a new object each render; change the dependency to
reference only the stable method commentFeed.reload (i.e., use
commentFeed.reload in the callback and add commentFeed.reload to the dependency
array) so handleReload remains stable; update the useCallback declaration for
handleReload to call commentFeed.reload() when isInfiniteMode and include only
commentFeed.reload, isInfiniteMode, and onReload in the dependency list.

In `@src/hooks/useInifinieScroll.ts`:
- Around line 72-88: loadMore is being re-created whenever state variables
isLast or nextCursor change; convert them to refs to stabilize the callback:
create isLastRef and nextCursorRef (initialized from current state), update
those refs wherever you call setIsLast or setNextCursor, and inside loadMore
read isLastRef.current and nextCursorRef.current instead of the state variables;
keep isFetchingRef and fetchPageRef usage as-is and remove isLast and nextCursor
from loadMore's useCallback dependencies so loadMore maintains a stable
reference for the observer (also apply the same ref-based pattern to the other
loadMore-like callback at lines 98-113).

In `@src/pages/memory/Memory.tsx`:
- Around line 193-203: When handling a newly uploaded record in the useEffect
that reads location.state?.newRecord, avoid blindly prepending it via
setRecordItems; instead check the existing list (recordsList.items or the
currentRecords memo) for an item with the same id and only call
setRecordItems(prev => [newRecord, ...prev]) if no item with newRecord.id
already exists, then continue to setShowUploadProgress(true) and navigate(...).
This ensures duplication is prevented when the server response already contains
the same record.

In `@src/pages/mypage/SavePage.tsx`:
- Around line 72-95: The handleSaveToggle function performs optimistic updates
but lacks protection against rapid repeated clicks; wrap or guard
handleSaveToggle with the existing usePreventDoubleClick hook (same pattern used
in PollRecord/SearchBook) so multiple quick invocations don’t trigger duplicate
postSaveBook calls. Locate handleSaveToggle and apply usePreventDoubleClick to
the exported/onClick handler (or create a wrapped version like const
handleSaveToggleSafe = usePreventDoubleClick(handleSaveToggle)) ensuring the
wrapped handler calls postSaveBook and still performs the same optimistic
savedBooks.setItems and rollback logic on failure.

In `@src/pages/searchBook/SearchBook.tsx`:
- Around line 159-183: The rollback logic in handleSaveButton is duplicated in
the try/failure and catch branches; extract a small helper (e.g.,
rollbackIfUnchanged or performRollback) inside or next to handleSaveButton that
accepts the attempted state (nextSaved) and performs the isSavedRef.current
check, flips to the rollback value, and calls setIsSaved; then replace both
duplicated blocks with a call to that helper when postSaveBook fails or throws,
keeping references to isSavedRef, setIsSaved and nextSaved and ensuring the
helper uses the same conditional (if isSavedRef.current === nextSaved) before
rolling back.

ℹ️ Review info

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Disabled knowledge base sources:

  • Jira integration is disabled

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 2bf7630 and c7a9d3d.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (23)
  • package.json
  • src/components/common/Post/PostFooter.tsx
  • src/components/common/Post/Reply.tsx
  • src/components/common/Post/ReplyList.tsx
  • src/components/common/Post/SubReply.tsx
  • src/components/feed/Profile.tsx
  • src/components/feed/UserProfileItem.tsx
  • src/components/group/MyGroupModal.tsx
  • src/components/memory/RecordItem/PollRecord.tsx
  • src/components/search/GroupSearchResult.styled.ts
  • src/components/search/GroupSearchResult.tsx
  • src/hooks/useDebouncedCallback.ts
  • src/hooks/useInifinieScroll.ts
  • src/hooks/usePreventDoubleClick.ts
  • src/main.tsx
  • src/pages/feed/Feed.tsx
  • src/pages/feed/FeedDetailPage.tsx
  • src/pages/feed/FollowerListPage.tsx
  • src/pages/groupSearch/GroupSearch.tsx
  • src/pages/memory/Memory.tsx
  • src/pages/mypage/SavePage.tsx
  • src/pages/notice/Notice.tsx
  • src/pages/searchBook/SearchBook.tsx

Comment on lines +138 to +140
{!roomList.isLast && (
<div ref={roomList.sentinelRef} style={{ gridColumn: '1 / -1', height: 20 }} />
)}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

gridColumn: '1 / -1'은 부모가 flex 레이아웃이므로 효과 없음

Content 스타일드 컴포넌트는 flex-direction: column을 사용하므로 gridColumn 속성이 적용되지 않습니다. 불필요한 스타일이므로 제거하거나, 의도한 레이아웃이 grid라면 확인이 필요합니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/group/MyGroupModal.tsx` around lines 138 - 140, The inline
style on the sentinel div uses gridColumn ('gridColumn: "1 / -1"') but the
parent Content styled component uses flex-direction: column so gridColumn has no
effect; in MyGroupModal remove the unnecessary gridColumn style from the div
that uses roomList.sentinelRef, or if the intent was to span columns change the
parent layout to CSS grid in the Content styled component—locate the sentinel
div (roomList.sentinelRef) and either drop gridColumn or update Content to
display: grid with appropriate grid template so the spanning makes sense.

Comment on lines +96 to +105
const maxCount = Math.max(...optimisticOptions.map(item => item.count));
const normalizedOptions = optimisticOptions.map(item => ({
...item,
isHighest: item.count === maxCount,
}));

optionsRef.current = normalizedOptions;
setCurrentOptions(normalizedOptions);

await new Promise(resolve => setTimeout(resolve, 300));
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Math.max(...[]) 엣지 케이스 및 인위적 딜레이

  1. optimisticOptions가 빈 배열이면 Math.max() 호출 시 -Infinity가 반환됩니다. 현재 투표 옵션이 없는 경우는 실질적으로 발생하기 어렵지만, 방어 코드를 추가하면 안전합니다.

  2. 라인 105의 setTimeout(resolve, 300) — 낙관적 UI 애니메이션을 위한 의도적 딜레이로 이해되나, API 실패 시 유저가 300ms 동안 잘못된 상태를 보게 되며, 네트워크 지연이 합쳐지면 체감 응답 시간이 길어집니다. 필요한 경우 주석으로 의도를 남겨두세요.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/memory/RecordItem/PollRecord.tsx` around lines 96 - 105, The
code in PollRecord uses Math.max(...optimisticOptions.map(...)) which returns
-Infinity for an empty optimisticOptions; guard this by deriving maxCount safely
(e.g., default to 0 when optimisticOptions is empty) before computing
normalizedOptions, and ensure optionsRef/current state logic
(optionsRef.current, setCurrentOptions) uses that safe maxCount; also remove or
make the artificial await new Promise(resolve => setTimeout(resolve, 300))
explicit/configurable (e.g., remove the fixed 300ms delay or replace it with a
documented, optional animation delay flag) and add a brief comment explaining
any retained delay so callers understand the UX tradeoff.

Comment on lines +3 to +6
export const useDebouncedCallback = <T extends (...args: any[]) => void>(
callback: T,
delay: number,
) => {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Check ESLint configuration for no-explicit-any rule
find . -name ".eslintrc*" -o -name "eslint.config.*" | head -5

Repository: THIP-TextHip/THIP-Web

Length of output: 459


🏁 Script executed:

# Read the specific file to see current state
cat -n src/hooks/useDebouncedCallback.ts

Repository: THIP-TextHip/THIP-Web

Length of output: 1064


🏁 Script executed:

# Search for ESLint rule configuration and useDebouncedCallback usages
rg -n "no-explicit-any" -A 2 -B 2
rg -n "useDebouncedCallback" src --type ts --type tsx

Repository: THIP-TextHip/THIP-Web

Length of output: 92


🏁 Script executed:

# Read ESLint configuration
cat -n eslint.config.js

Repository: THIP-TextHip/THIP-Web

Length of output: 954


🏁 Script executed:

# Search for usages of useDebouncedCallback
rg -n "useDebouncedCallback" src

Repository: THIP-TextHip/THIP-Web

Length of output: 174


🏁 Script executed:

# Check if any[] in function parameter types would trigger the rule
rg -n "any\[\]" src --type ts -B 2 -A 2

Repository: THIP-TextHip/THIP-Web

Length of output: 403


any[] 사용으로 ESLint 규칙 위반

tseslint.configs.recommended 구성에 포함된 @typescript-eslint/no-explicit-any 규칙을 위반합니다. unknown[]으로 대체하면 타입 안정성이 개선됩니다.

✅ 수정 제안
-export const useDebouncedCallback = <T extends (...args: any[]) => void>(
+export const useDebouncedCallback = <T extends (...args: unknown[]) => void>(
📝 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.

Suggested change
export const useDebouncedCallback = <T extends (...args: any[]) => void>(
callback: T,
delay: number,
) => {
export const useDebouncedCallback = <T extends (...args: unknown[]) => void>(
callback: T,
delay: number,
) => {
🧰 Tools
🪛 ESLint

[error] 3-3: Unexpected any. Specify a different type.

(@typescript-eslint/no-explicit-any)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/useDebouncedCallback.ts` around lines 3 - 6, The function generics
violate the no-explicit-any rule; update the parameter type in
useDebouncedCallback so T is constrained with unknown[] instead of any[] (e.g.,
change T extends (...args: any[]) => void to T extends (...args: unknown[]) =>
void and adjust the callback parameter typing accordingly) to improve type
safety and satisfy `@typescript-eslint/no-explicit-any`.

threshold?: number;
}

export const useInifinieScroll = <T>({
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

훅 이름 오타: useInifinieScrolluseInfiniteScroll

"Inifinie"는 "Infinite"의 오타입니다. 이 이름이 파일명, 내보내기, 그리고 모든 소비자 파일의 import 경로에 전파되어 있습니다. 현재 기능에 영향은 없지만 코드베이스 전체 검색 및 가독성에 부정적입니다.

#!/bin/bash
# Description: 오타가 포함된 import 경로를 사용하는 모든 파일 확인
rg -n "useInifinieScroll" --type=ts --type=tsx -g '!node_modules'
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/useInifinieScroll.ts` at line 20, Rename the hook symbol
useInifinieScroll to useInfiniteScroll and update its filename and all
imports/exports accordingly: change the exported function name in
src/hooks/useInifinieScroll.ts to useInfiniteScroll, rename the file to
src/hooks/useInfiniteScroll.ts (or update any barrel export), then run a
repository-wide replace for "useInifinieScroll" → "useInfiniteScroll" to update
all import statements and usages so they match the corrected export and avoid
broken references.

Comment on lines +53 to +70
const loadFirstPage = useCallback(async () => {
if (!enabled || isFetchingRef.current) return;
isFetchingRef.current = true;
setIsLoading(true);
setError(null);

try {
const res = await fetchPageRef.current(null);
setItems(res.items);
setNextCursor(res.nextCursor);
setIsLast(res.isLast);
} catch (error) {
setError(error instanceof Error ? error.message : '목록을 불러오지 못했습니다.');
} finally {
setIsLoading(false);
isFetchingRef.current = false;
}
}, [enabled]);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

reloadKey 변경 시 진행 중인 fetch가 있으면 stale 데이터가 표시되는 레이스 컨디션

reloadKey가 변경되면 effect(라인 90-96)가 items를 초기화하고 loadFirstPage()를 호출합니다. 그러나 이전 fetch가 진행 중이면 isFetchingRef.currenttrue이므로 loadFirstPage가 즉시 반환됩니다. 이후 이전 fetch가 완료되면 stale 데이터가 setItems로 설정되고, isFetchingRef가 해제되지만 새로운 loadFirstPage는 다시 호출되지 않습니다.

결과적으로 탭 전환이나 키 변경 시 빈 목록 또는 이전 탭의 데이터가 표시될 수 있습니다.

수정 방향: 생성 카운터(generation counter) 또는 AbortController를 사용하여 stale 응답을 무시하세요.

🐛 생성 카운터 기반 수정 제안
+ const generationRef = useRef(0);

  const loadFirstPage = useCallback(async () => {
    if (!enabled || isFetchingRef.current) return;
+   const gen = generationRef.current;
    isFetchingRef.current = true;
    setIsLoading(true);
    setError(null);

    try {
      const res = await fetchPageRef.current(null);
+     if (gen !== generationRef.current) return; // stale response
      setItems(res.items);
      setNextCursor(res.nextCursor);
      setIsLast(res.isLast);
    } catch (error) {
+     if (gen !== generationRef.current) return;
      setError(error instanceof Error ? error.message : '목록을 불러오지 못했습니다.');
    } finally {
      setIsLoading(false);
      isFetchingRef.current = false;
    }
  }, [enabled]);

  const loadMore = useCallback(async () => {
    if (!enabled || isFetchingRef.current || isLast || !nextCursor) return;
+   const gen = generationRef.current;
    isFetchingRef.current = true;
    setIsLoadingMore(true);

    try {
      const res = await fetchPageRef.current(nextCursor);
+     if (gen !== generationRef.current) return;
      setItems(prev => merge(prev, res.items));
      setNextCursor(res.nextCursor);
      setIsLast(res.isLast);
    } catch (error) {
+     if (gen !== generationRef.current) return;
      setError(error instanceof Error ? error.message : '추가 목록을 불러오지 못했습니다.');
    } finally {
      setIsLoadingMore(false);
      isFetchingRef.current = false;
    }
  }, [enabled, isLast, nextCursor, merge]);

  useEffect(() => {
    if (!enabled) return;
+   generationRef.current += 1;
+   isFetchingRef.current = false; // allow new fetch
    setItems([]);
    setNextCursor(null);
    setIsLast(false);
    void loadFirstPage();
  }, [enabled, reloadKey, loadFirstPage]);

Also applies to: 90-96

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/useInifinieScroll.ts` around lines 53 - 70, There's a race where
reloadKey changes while a previous fetch is in-flight causing loadFirstPage to
early-return and later the stale fetch to overwrite state; fix by adding a
generation counter ref (e.g., generationRef) that's incremented in the effect
that responds to reloadKey, capture const gen = generationRef.current at the
start of loadFirstPage, change the isFetchingRef guard to only short-circuit if
isFetchingRef.current && gen === generationRef.current (so a new generation can
start its own fetch), and before calling setItems/setNextCursor/setIsLast check
gen === generationRef.current to ignore stale responses from older generations;
update references to fetchPageRef, isFetchingRef, and loadFirstPage accordingly.

initGA();

createRoot(document.getElementById('root')!).render(<App />);
createRoot(document.getElementById('root')!).render(<App />); No newline at end of file
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check for QueryClientProvider and QueryClient configuration
echo "=== Searching for QueryClientProvider and QueryClient ==="
rg -n "QueryClientProvider|QueryClient" src

echo -e "\n=== Searching for react-query imports ==="
rg -n "from.*react-query|import.*react-query|from.*@tanstack/react-query|import.*@tanstack/react-query" src

echo -e "\n=== Checking main.tsx content ==="
cat -n src/main.tsx

echo -e "\n=== Checking App.tsx content ==="
fd "App.tsx" src --exec cat -n {}

Repository: THIP-TextHip/THIP-Web

Length of output: 978


🏁 Script executed:

#!/bin/bash
# Check if react-query or `@tanstack/react-query` is in package.json
echo "=== Checking package.json for react-query dependency ==="
rg -n "react-query|@tanstack/react-query" package.json

echo -e "\n=== Full dependencies section ==="
cat -n package.json | grep -A 30 '"dependencies"'

Repository: THIP-TextHip/THIP-Web

Length of output: 1324


🏁 Script executed:

#!/bin/bash
# Check if react-query hooks are actually used in the codebase
echo "=== Searching for react-query hook usage (useQuery, useMutation, etc.) ==="
rg -n "useQuery|useMutation|useInfiniteQuery|useIsFetching|useQueries|useQueryClient|useQueryErrorResetBoundary" src

echo -e "\n=== Checking all imports from `@tanstack/react-query` ==="
rg -n "from.*@tanstack/react-query|import.*@tanstack/react-query" src

echo -e "\n=== Checking if QueryClientProvider exists anywhere ==="
rg -n "QueryClientProvider" .

Repository: THIP-TextHip/THIP-Web

Length of output: 253


@tanstack/react-query 사용 계획 확인 필요

@tanstack/react-query가 package.json에 추가되었지만 현재 코드에서는 사용되고 있지 않습니다. 두 가지 중 하나를 진행해야 합니다:

  1. react-query를 사용할 계획이 없다면: 불필요한 의존성이므로 package.json에서 제거하세요.
  2. 향후 react-query 사용을 계획 중이라면: QueryClientProvidermain.tsx 또는 App.tsx의 루트에 설정하고 QueryClient를 초기화하세요. 예를 들어:
const queryClient = new QueryClient();
<QueryClientProvider client={queryClient}>
  <App />
</QueryClientProvider>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main.tsx` at line 8, package.json currently includes
`@tanstack/react-query` but main bootstrap uses createRoot(...).render(<App />)
without initializing or providing a QueryClient; either remove the unused
dependency from package.json or wire up react-query by creating a QueryClient
instance and wrapping the root with QueryClientProvider (e.g., in main.tsx or
inside App.tsx) so the app is rendered as <QueryClientProvider
client={queryClient}><App/></QueryClientProvider>; update imports and ensure
QueryClient is constructed before calling createRoot(...).render.

Comment on lines +136 to +168
const queryTerm = searchStatus === 'searching' ? debouncedSearchTerm : searchTerm.trim();
const searchResult = useInifinieScroll({
enabled: searchStatus !== 'idle' && (searchStatus === 'searched' || queryTerm.length > 0),
reloadKey: `${searchStatus}-${queryTerm}-${selectedFilter}-${category}`,
fetchPage: async cursor => {
const isFinalized = searchStatus === 'searched';
const isAllCategory = !queryTerm && category === '';
if (searchStatus === 'searching' && !queryTerm) {
return { items: [], nextCursor: null, isLast: true };
}

const res = await getSearchRooms(
trimmedTerm,
queryTerm,
toSortKey(selectedFilter),
nextCursor,
cursor ?? undefined,
isFinalized,
category,
isAllCategory,
);
if (res.isSuccess) {
const { roomList, nextCursor: nc, isLast: last } = res.data;

setRooms(prev => [...prev, ...roomList]);
setNextCursor(nc);
setIsLast(last);
} else {
setIsLast(true);
if (!res.isSuccess) {
throw new Error(res.message || '검색 실패');
}
} catch {
setIsLast(true);
} finally {
setIsLoadingMore(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchTerm, nextCursor, isLast, isLoadingMore, selectedFilter, searchStatus, category]);

const lastRoomElementCallback = useCallback(
(node: HTMLDivElement | null) => {
if (isLoadingMore || isLast) return;

if (observerRef.current) observerRef.current.disconnect();

observerRef.current = new IntersectionObserver(entries => {
if (entries[0].isIntersecting && !isLoadingMore && !isLast) {
loadMore();
}
});

if (node) observerRef.current.observe(node);
return {
items: res.data.roomList,
nextCursor: res.data.nextCursor,
isLast: res.data.isLast,
};
},
[isLoadingMore, isLast, loadMore],
);
rootMargin: '100px 0px',
threshold: 0.1,
});
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

빠른 검색어 변경 시 새 검색이 누락될 수 있습니다.

useInifinieScroll이 in-flight 상태(isFetchingRef)면 loadFirstPage를 즉시 반환하므로, 검색어가 바뀌는 타이밍에 이전 요청이 진행 중이면 새 검색이 트리거되지 않을 수 있습니다. 훅 레벨에서 pending reload 큐를 두거나, 요청을 abort/ignore하는 방식으로 최신 검색어가 반드시 로드되도록 보완해주세요. (이 훅을 쓰는 다른 화면에도 동일 영향)

🛠️ useInifinieScroll 보완 예시(개념)
+  const pendingReloadRef = useRef(false);

   const loadFirstPage = useCallback(async () => {
-    if (!enabled || isFetchingRef.current) return;
+    if (!enabled) return;
+    if (isFetchingRef.current) {
+      pendingReloadRef.current = true;
+      return;
+    }
     isFetchingRef.current = true;
     setIsLoading(true);
     setError(null);

     try {
       const res = await fetchPageRef.current(null);
       setItems(res.items);
       setNextCursor(res.nextCursor);
       setIsLast(res.isLast);
     } finally {
       setIsLoading(false);
       isFetchingRef.current = false;
+      if (pendingReloadRef.current) {
+        pendingReloadRef.current = false;
+        void loadFirstPage();
+      }
     }
   }, [enabled]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/pages/groupSearch/GroupSearch.tsx` around lines 136 - 168, The current
useInifinieScroll usage can miss a new search if a previous fetch is in-flight
(isFetchingRef) because loadFirstPage returns early; update useInifinieScroll to
guarantee reloads when reloadKey changes by either (A) adding a pendingReload
boolean/queue that, when a reload is requested during isFetchingRef, stores the
intent and runs loadFirstPage again once the current fetch finishes, or (B)
support abortable fetches: accept an AbortSignal into fetchPage (and forward it
to getSearchRooms) and cancel the prior request on reload so the new
queryTerm/searchStatus immediately triggers a fresh fetch; update the hook API
(e.g., expose triggerReload(force=true) or ensure reloadKey changes always call
loadFirstPage) and adjust call sites (the GroupSearch use of useInifinieScroll
and fetchPage) to use the new API or pass an AbortSignal so the latest search is
always loaded.

@heeeeyong heeeeyong self-assigned this Feb 24, 2026
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
src/components/common/CommentBottomSheet/GlobalCommentBottomSheet.tsx (2)

29-29: React 19 관용적 useRef 타입 표기로 통일하세요.

React 19에서 useRef<T>(null)의 반환 타입은 RefObject<T | null>로 변경되었습니다. 현재 useRef<HTMLDivElement | null>(null)은 타입 파라미터에 | null을 명시적으로 포함시켜 최종 타입이 RefObject<HTMLDivElement | null>로 동일하게 추론되긴 하지만, 관용적 표기는 useRef<HTMLDivElement>(null)입니다.

♻️ 관용적 표기 제안
-  const contentRef = useRef<HTMLDivElement | null>(null);
+  const contentRef = useRef<HTMLDivElement>(null);

As per the React 19 library documentation: useRef<T>(null)RefObject<T | null>.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/common/CommentBottomSheet/GlobalCommentBottomSheet.tsx` at
line 29, The ref declaration contentRef uses a non-idiomatic type; change the
generic from useRef<HTMLDivElement | null>(null) to useRef<HTMLDivElement>(null)
so the inferred type becomes RefObject<HTMLDivElement | null> per React 19
conventions; update the declaration named contentRef accordingly (search for
contentRef/useRef in GlobalCommentBottomSheet) and run typecheck to confirm no
other sites rely on the explicit | null annotation.

124-124: reloadKeyisOpen 접미사가 항상 'open'이므로 불필요합니다.

GlobalCommentBottomSheet!isOpen일 때 null을 반환(Line 110)하므로, ReplyList가 실제로 마운트되는 시점에서 isOpen은 항상 true입니다. 따라서 ${isOpen ? 'open' : 'closed'} 접미사는 항상 '-open'으로 평가되어 읽는 이에게 혼선을 줄 수 있습니다.

♻️ 단순화 제안
-              reloadKey={`${replyReloadKey}-${isOpen ? 'open' : 'closed'}`}
+              reloadKey={String(replyReloadKey)}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/common/CommentBottomSheet/GlobalCommentBottomSheet.tsx` at
line 124, The reloadKey suffix uses the ternary `${isOpen ? 'open' : 'closed'}`
but since GlobalCommentBottomSheet returns null when !isOpen and ReplyList only
mounts when open, the ternary always yields 'open'; change the prop on ReplyList
from reloadKey={`${replyReloadKey}-${isOpen ? 'open' : 'closed'}`} to a
deterministic value (e.g., reloadKey={`${replyReloadKey}-open`} or simply
reloadKey={replyReloadKey}) to remove the redundant isOpen check and clarify
intent; update the line using reloadKey, referencing replyReloadKey, isOpen,
GlobalCommentBottomSheet, and ReplyList.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/components/common/CommentBottomSheet/GlobalCommentBottomSheet.tsx`:
- Line 29: The ref declaration contentRef uses a non-idiomatic type; change the
generic from useRef<HTMLDivElement | null>(null) to useRef<HTMLDivElement>(null)
so the inferred type becomes RefObject<HTMLDivElement | null> per React 19
conventions; update the declaration named contentRef accordingly (search for
contentRef/useRef in GlobalCommentBottomSheet) and run typecheck to confirm no
other sites rely on the explicit | null annotation.
- Line 124: The reloadKey suffix uses the ternary `${isOpen ? 'open' :
'closed'}` but since GlobalCommentBottomSheet returns null when !isOpen and
ReplyList only mounts when open, the ternary always yields 'open'; change the
prop on ReplyList from reloadKey={`${replyReloadKey}-${isOpen ? 'open' :
'closed'}`} to a deterministic value (e.g., reloadKey={`${replyReloadKey}-open`}
or simply reloadKey={replyReloadKey}) to remove the redundant isOpen check and
clarify intent; update the line using reloadKey, referencing replyReloadKey,
isOpen, GlobalCommentBottomSheet, and ReplyList.

ℹ️ Review info

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Disabled knowledge base sources:

  • Jira integration is disabled

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between c7a9d3d and 3f42ebb.

📒 Files selected for processing (1)
  • src/components/common/CommentBottomSheet/GlobalCommentBottomSheet.tsx

- 댓글 모달 margin-bottom 삭제
- 댓글 모달의 스크롤바 스타일을 데스크탑에서도 동일하게 적용
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 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/components/common/Post/ReplyList.tsx`:
- Around line 67-88: The EmptyState is shown during initial infinite-mode
loading because the EmptyState render (when hasComments is false) doesn't
account for the initial loading flag; update ReplyList rendering logic to
suppress EmptyState while isInfiniteMode && commentFeed.isLoading && list.length
=== 0 (e.g., compute isInitialLoading = isInfiniteMode && commentFeed.isLoading
&& list.length === 0) and only render EmptyState when !isInitialLoading and
!hasComments, leaving the LoadingSpinner behavior unchanged; adjust the
conditional around EmptyState (and any hasComments check) accordingly so
EmptyState is not displayed during initial load.

ℹ️ Review info

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Disabled knowledge base sources:

  • Jira integration is disabled

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 3f42ebb and 96e2d56.

📒 Files selected for processing (4)
  • src/components/common/CommentBottomSheet/GlobalCommentBottomSheet.styled.ts
  • src/components/common/CommentBottomSheet/GlobalCommentBottomSheet.tsx
  • src/components/common/Post/ReplyList.styled.ts
  • src/components/common/Post/ReplyList.tsx

Comment on lines +67 to 88
<Container disableBottomMargin={disableBottomMargin}>
{isInfiniteMode && commentFeed.isLoading && list.length === 0 && (
<LoadingSpinner size="small" fullHeight={false} />
)}
{hasComments ? (
commentList.map((comment, commentIndex) => (
list.map((comment, commentIndex) => (
<div className="comment-group" key={comment.commentId || `comment-${commentIndex}`}>
<Reply {...comment} onDelete={onReload} />
<Reply {...comment} onDelete={handleReload} />
{comment.replyList.map((sub, replyIndex) => (
<SubReply
key={sub.commentId || `reply-${comment.commentId || commentIndex}-${replyIndex}`}
{...sub}
onDelete={onReload}
onDelete={handleReload}
/>
))}
</div>
))
) : (
<EmptyState>
<EmptyState disableBottomMargin={disableBottomMargin}>
<div className="title">아직 댓글이 없어요</div>
<div className="sub-title">첫번째 댓글을 남겨보세요</div>
</EmptyState>
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

초기 로딩 중 빈 상태 문구가 함께 노출됩니다.

무한 모드에서 Line 68 조건으로 스피너가 보일 때, Line 85의 EmptyState도 동시에 렌더링되어 잘못된 안내가 잠깐 노출될 수 있습니다. 초기 로딩 시에는 EmptyState를 숨기는 게 자연스럽습니다.

🛠️ 제안 수정
-      ) : (
-        <EmptyState disableBottomMargin={disableBottomMargin}>
+      ) : isInfiniteMode && commentFeed.isLoading ? null : (
+        <EmptyState disableBottomMargin={disableBottomMargin}>
           <div className="title">아직 댓글이 없어요</div>
           <div className="sub-title">첫번째 댓글을 남겨보세요</div>
         </EmptyState>
       )}
📝 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.

Suggested change
<Container disableBottomMargin={disableBottomMargin}>
{isInfiniteMode && commentFeed.isLoading && list.length === 0 && (
<LoadingSpinner size="small" fullHeight={false} />
)}
{hasComments ? (
commentList.map((comment, commentIndex) => (
list.map((comment, commentIndex) => (
<div className="comment-group" key={comment.commentId || `comment-${commentIndex}`}>
<Reply {...comment} onDelete={onReload} />
<Reply {...comment} onDelete={handleReload} />
{comment.replyList.map((sub, replyIndex) => (
<SubReply
key={sub.commentId || `reply-${comment.commentId || commentIndex}-${replyIndex}`}
{...sub}
onDelete={onReload}
onDelete={handleReload}
/>
))}
</div>
))
) : (
<EmptyState>
<EmptyState disableBottomMargin={disableBottomMargin}>
<div className="title">아직 댓글이 없어요</div>
<div className="sub-title">첫번째 댓글을 남겨보세요</div>
</EmptyState>
<Container disableBottomMargin={disableBottomMargin}>
{isInfiniteMode && commentFeed.isLoading && list.length === 0 && (
<LoadingSpinner size="small" fullHeight={false} />
)}
{hasComments ? (
list.map((comment, commentIndex) => (
<div className="comment-group" key={comment.commentId || `comment-${commentIndex}`}>
<Reply {...comment} onDelete={handleReload} />
{comment.replyList.map((sub, replyIndex) => (
<SubReply
key={sub.commentId || `reply-${comment.commentId || commentIndex}-${replyIndex}`}
{...sub}
onDelete={handleReload}
/>
))}
</div>
))
) : isInfiniteMode && commentFeed.isLoading ? null : (
<EmptyState disableBottomMargin={disableBottomMargin}>
<div className="title">아직 댓글이 없어요</div>
<div className="sub-title">첫번째 댓글을 남겨보세요</div>
</EmptyState>
)}
</Container>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/common/Post/ReplyList.tsx` around lines 67 - 88, The
EmptyState is shown during initial infinite-mode loading because the EmptyState
render (when hasComments is false) doesn't account for the initial loading flag;
update ReplyList rendering logic to suppress EmptyState while isInfiniteMode &&
commentFeed.isLoading && list.length === 0 (e.g., compute isInitialLoading =
isInfiniteMode && commentFeed.isLoading && list.length === 0) and only render
EmptyState when !isInitialLoading and !hasComments, leaving the LoadingSpinner
behavior unchanged; adjust the conditional around EmptyState (and any
hasComments check) accordingly so EmptyState is not displayed during initial
load.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant