From 0f9d3d7f26833aacf22922d3eacd53e081d1a157 Mon Sep 17 00:00:00 2001 From: wooktori Date: Mon, 29 Dec 2025 17:08:27 +0900 Subject: [PATCH 1/4] =?UTF-8?q?fix:=20following=20=EB=AC=B4=ED=95=9C=20?= =?UTF-8?q?=EC=8A=A4=ED=81=AC=EB=A1=A4=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/message/page.tsx | 112 ++++-------------- .../message-following-content/index.tsx | 98 +++++++++++++++ 2 files changed, 122 insertions(+), 88 deletions(-) create mode 100644 src/components/pages/message/message-following-content/index.tsx diff --git a/src/app/message/page.tsx b/src/app/message/page.tsx index 9b99a901..2c54d0f8 100644 --- a/src/app/message/page.tsx +++ b/src/app/message/page.tsx @@ -1,105 +1,41 @@ -'use client'; +import { cookies } from 'next/headers'; -import { useSearchParams } from 'next/navigation'; - -import { useEffect, useState } from 'react'; - -import Cookies from 'js-cookie'; +import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query'; import { API } from '@/api'; -import { ChatList } from '@/components/pages/chat'; -import { FollowingList, FollowingNone, FollowingSearch } from '@/components/pages/message'; -import { TabNavigation } from '@/components/shared'; -import { useInfiniteScroll } from '@/hooks/use-group/use-group-infinite-list'; -import { useIntersectionObserver } from '@/hooks/use-intersection-observer'; -import { INTERSECTION_OBSERVER_THRESHOLD } from '@/lib/constants/group-list'; +import { FollowingContent } from '@/components/pages/message/message-following-content'; -const SOCIAL_TABS = [ - { label: '팔로잉', value: 'following' }, - { label: '메세지', value: 'chat' }, -]; +const INITIAL_PAGE_SIZE = 10; -export default function FollowingPage() { - const [userId, setUserId] = useState(0); - const params = useSearchParams(); - const tab = params.get('tab') || 'chat'; +export default async function MessagePage() { + const cookieStore = await cookies(); + const userId = Number(cookieStore.get('userId')?.value || 0); - useEffect(() => { - const id = Cookies.get('userId'); - // eslint-disable-next-line react-hooks/set-state-in-effect - setUserId(Number(id)); - }, []); + const queryClient = new QueryClient(); - // 1. 무한 스크롤 훅 호출 - const { - items: followers, - error, - fetchNextPage, - hasNextPage, - isFetchingNextPage, - completedMessage, - } = useInfiniteScroll({ - queryFn: async ({ cursor, size }) => { + // 첫 페이지 우선 prefetch + await queryClient.prefetchInfiniteQuery({ + queryKey: ['followers', userId], + queryFn: async () => { return await API.followerService.getFollowers({ userId, - cursor, - size, + cursor: undefined, + size: INITIAL_PAGE_SIZE, }); }, - queryKey: ['followers', userId], - completedMessage: '모든 팔로잉을 불러왔습니다.', - enabled: !!userId, - }); - - // 2. IntersectionObserver로 스크롤 감지 - const sentinelRef = useIntersectionObserver({ - onIntersect: () => { - if (hasNextPage && !isFetchingNextPage) { - fetchNextPage(); - } + initialPageParam: undefined, + getNextPageParam: (lastPage) => { + return lastPage.nextCursor ?? undefined; }, - enabled: hasNextPage && error === null, - threshold: INTERSECTION_OBSERVER_THRESHOLD, + pages: 1, }); - return ( -
- - - {tab === 'chat' && } - {tab === 'following' && ( - <> - - {!error && followers && followers.length > 0 ? ( - <> - - - {/* 3. Sentinel 요소 (필수!) */} - {hasNextPage &&
} + // dehydrate로 직렬화 + const dehydratedState = dehydrate(queryClient); - {/* 4. 다음 페이지 로딩 중 */} - {isFetchingNextPage && ( -
- 더 불러오는 중... -
- )} - - {/* 5. 완료 메시지 */} - {!hasNextPage && ( -
- {completedMessage} -
- )} - - ) : ( - !error && ( -
- -
- ) - )} - - )} -
+ return ( + + + ); } diff --git a/src/components/pages/message/message-following-content/index.tsx b/src/components/pages/message/message-following-content/index.tsx new file mode 100644 index 00000000..b8f0ce71 --- /dev/null +++ b/src/components/pages/message/message-following-content/index.tsx @@ -0,0 +1,98 @@ +'use client'; + +import { useSearchParams } from 'next/navigation'; + +import { API } from '@/api'; +import { ChatList } from '@/components/pages/chat'; +import { FollowingList, FollowingNone, FollowingSearch } from '@/components/pages/message'; +import { TabNavigation } from '@/components/shared'; +import { useInfiniteScroll } from '@/hooks/use-group/use-group-infinite-list'; +import { useIntersectionObserver } from '@/hooks/use-intersection-observer'; +import { INTERSECTION_OBSERVER_THRESHOLD } from '@/lib/constants/group-list'; + +const SOCIAL_TABS = [ + { label: '팔로잉', value: 'following' }, + { label: '메세지', value: 'chat' }, +]; + +interface FollowingContentProps { + initialUserId: number; +} + +export const FollowingContent = ({ initialUserId }: FollowingContentProps) => { + const params = useSearchParams(); + const tab = params.get('tab') || 'chat'; + + // 1. 무한 스크롤 훅 호출 (서버에서 prefetch된 데이터가 자동으로 사용됨) + const { + items: followers, + error, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + completedMessage, + } = useInfiniteScroll({ + queryFn: async ({ cursor, size }) => { + return await API.followerService.getFollowers({ + userId: initialUserId, + cursor, + size, + }); + }, + queryKey: ['followers', initialUserId], + completedMessage: '모든 팔로잉을 불러왔습니다.', + enabled: !!initialUserId, + }); + + // 2. IntersectionObserver로 스크롤 감지 + const sentinelRef = useIntersectionObserver({ + onIntersect: () => { + if (hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, + enabled: hasNextPage && error === null, + threshold: INTERSECTION_OBSERVER_THRESHOLD, + }); + + return ( +
+ + + {tab === 'chat' && } + {tab === 'following' && ( + <> + + {!error && followers && followers.length > 0 ? ( + <> + + + {/* 3. Sentinel 요소 (필수!) */} + {hasNextPage &&
} + + {/* 4. 다음 페이지 로딩 중 */} + {isFetchingNextPage && ( +
+ 더 불러오는 중... +
+ )} + + {/* 5. 완료 메시지 */} + {!hasNextPage && ( +
+ {completedMessage} +
+ )} + + ) : ( + !error && ( +
+ +
+ ) + )} + + )} +
+ ); +}; From 135fc48cccc346c773a8741441eb6905f95198c4 Mon Sep 17 00:00:00 2001 From: wooktori Date: Mon, 29 Dec 2025 17:11:54 +0900 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20empty=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/pages/message/message-following-none/index.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/components/pages/message/message-following-none/index.tsx b/src/components/pages/message/message-following-none/index.tsx index d50f9705..49f35586 100644 --- a/src/components/pages/message/message-following-none/index.tsx +++ b/src/components/pages/message/message-following-none/index.tsx @@ -1,6 +1,9 @@ +import Image from 'next/image'; + export const FollowingNone = () => { return (
+ Empty Data 아직 팔로우 한 사람이 없어요. 마음에 드는 유저를 팔로우 해보세요!
From d5bc67e1baa4fad321f16434041d4f641b3cfbe6 Mon Sep 17 00:00:00 2001 From: wooktori Date: Mon, 29 Dec 2025 17:13:03 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20follow=20add=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EB=94=94=EC=9E=90=EC=9D=B8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/pages/message/message-following-search/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/pages/message/message-following-search/index.tsx b/src/components/pages/message/message-following-search/index.tsx index 63b64401..da240505 100644 --- a/src/components/pages/message/message-following-search/index.tsx +++ b/src/components/pages/message/message-following-search/index.tsx @@ -9,7 +9,7 @@ export const FollowingSearch = ({ userId }: { userId: number }) => { const { open } = useModal(); return (
open()} >
From f7f498372233cbe1914df1999710cbe9823072e5 Mon Sep 17 00:00:00 2001 From: wooktori Date: Mon, 29 Dec 2025 17:42:42 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/message/page.test.tsx | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/src/app/message/page.test.tsx b/src/app/message/page.test.tsx index f2c53c8f..0d9e2a46 100644 --- a/src/app/message/page.test.tsx +++ b/src/app/message/page.test.tsx @@ -1,10 +1,10 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { render, screen } from '@testing-library/react'; +import { FollowingContent } from '@/components/pages/message/message-following-content'; import { ModalProvider } from '@/components/ui'; import { useInfiniteScroll } from '@/hooks/use-group/use-group-infinite-list'; -import FollowingPage from './page'; - jest.mock('@/hooks/use-group/use-group-infinite-list', () => ({ useInfiniteScroll: jest.fn(), })); @@ -15,12 +15,18 @@ jest.mock('next/navigation', () => ({ }), })); -jest.mock('js-cookie', () => ({ - get: () => '1', -})); - describe('FollowingPage 테스트', () => { + let queryClient: QueryClient; + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + (useInfiniteScroll as jest.Mock).mockReturnValue({ items: [], error: null, @@ -31,11 +37,17 @@ describe('FollowingPage 테스트', () => { }); }); + afterEach(() => { + queryClient.clear(); + }); + test('팔로잉이 없을 경우 FollowingNone을 보여준다', async () => { render( - - - , + + + + + , ); expect(await screen.findByText('아직 팔로우 한 사람이 없어요.')).toBeInTheDocument();